Collapsible lanes — frontmatter-backed slim strips outside the width division
A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
+353
-36
@@ -24,6 +24,22 @@ import SwiftUI
|
||||
/// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus'
|
||||
/// Lane row, in its order, complete as of m5.
|
||||
///
|
||||
/// ### Collapsed: the slim strip
|
||||
///
|
||||
/// A lane whose `collapsed` reading is `true` (`LaneLayoutMath.isCollapsed`) draws **none** of the
|
||||
/// above: no header, no masonry, no resize handle — a fixed-width vertical strip carrying the lane's
|
||||
/// glyph, the same count badge, and its title rotated to read **bottom-up** and justified to the top of
|
||||
/// the room below them (03-board-ui.md § Lane ▸ Collapsed lanes; the orientation is an owner ruling of
|
||||
/// 2026-08-08/09 — see `collapsedTitle`). The accent band, the plate, the selection treatment and the
|
||||
/// cut dim are unchanged, because a folded lane is still that lane.
|
||||
///
|
||||
/// What the fold takes away it takes away **by not drawing it**, which is how the hidden trash's
|
||||
/// invisibility works and why this needs so little code elsewhere: no card face means no marquee
|
||||
/// registration and no navigation frame, no grid registration means the card zones have nothing to
|
||||
/// resolve against (`BoardDropContext.retargetCards` answers end-of-lane instead), and no header means
|
||||
/// no rename target and no new-card button. The strip keeps exactly three gestures: a plain click
|
||||
/// expands, a modified click selects the lane, and a drag reorders it.
|
||||
///
|
||||
/// ### The card face
|
||||
///
|
||||
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
|
||||
@@ -122,6 +138,12 @@ struct LaneView: View, Equatable {
|
||||
/// is set to.
|
||||
@State private var measuredHeaderHeight: CGFloat = 0
|
||||
|
||||
/// The vertical room the collapsed strip's rotated title has to run in — measured, because it is
|
||||
/// whatever the strip's height leaves after the glyph and the badge, and the title's **truncation
|
||||
/// is stated against it** (`collapsedTitle`). Zero until the strip lays out, which draws no title
|
||||
/// for one frame rather than a badly-placed one.
|
||||
@State private var collapsedTitleTravel: CGFloat = 0
|
||||
|
||||
/// The card stack's viewport height — the masonry's height *floor* (`scrollableCards`).
|
||||
/// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum
|
||||
/// can stretch the content to fill it; only an explicit minimum can.
|
||||
@@ -169,14 +191,27 @@ struct LaneView: View, Equatable {
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
header
|
||||
cardStack
|
||||
// **The fold is a different body, not a hidden one** (03-board-ui.md § Lane ▸ Collapsed
|
||||
// lanes): the header and the masonry are not built at all, which is what makes every
|
||||
// "collapsed lanes are excluded from …" rule below true by construction rather than by a
|
||||
// guard per gesture — nothing registers a frame, a grid, a header rect or a marquee target
|
||||
// because nothing exists to register one.
|
||||
if isCollapsed {
|
||||
collapsedStrip
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
header
|
||||
cardStack
|
||||
}
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.background(lanePlate)
|
||||
.overlay(selectionStroke)
|
||||
// The fold's own drop feedback — see `collapsedDropStroke`. Outside the `if` so it is one
|
||||
// overlay whatever the lane is doing, and inert (clear) whenever there is nothing to say.
|
||||
.overlay(collapsedDropStroke)
|
||||
// The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
|
||||
// lane is cut cards and all.
|
||||
.cutTreatment(of: lane.id, in: store)
|
||||
@@ -198,7 +233,171 @@ struct LaneView: View, Equatable {
|
||||
// hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
|
||||
// which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
|
||||
// by construction rather than by a second rule.
|
||||
.accessibilityLabel(AccessibilityPhrases.laneLabel(title: lane.title.value, cards: renderedCards.count))
|
||||
.accessibilityLabel(AccessibilityPhrases.laneLabel(
|
||||
title: lane.title.value,
|
||||
// **A collapsed lane speaks its held cards, not its rendered ones** — it renders none, and
|
||||
// "0 cards" would say the lane was empty when it is merely shut. The badge on the strip
|
||||
// shows the same number for the same reason (`collapsedStrip`).
|
||||
cards: isCollapsed ? lane.cards.count : renderedCards.count,
|
||||
collapsed: isCollapsed))
|
||||
}
|
||||
|
||||
/// Whether this lane is folded to its slim strip — `LaneLayoutMath.isCollapsed`'s reading, named
|
||||
/// here because this body asks it a dozen times (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||
private var isCollapsed: Bool {
|
||||
LaneLayoutMath.isCollapsed(lane)
|
||||
}
|
||||
|
||||
// MARK: - The collapsed strip
|
||||
|
||||
/// The fold's whole face: the lane's glyph, its title rotated to read top-to-bottom, and the count
|
||||
/// badge, stacked in a column the width of `BoardMetrics.collapsedLaneWidth` (03-board-ui.md § Lane
|
||||
/// ▸ Collapsed lanes).
|
||||
///
|
||||
/// **The whole strip is one surface with three gestures**, deliberately fewer than the expanded
|
||||
/// lane's two-surface arrangement (header + empty space) because there is nothing to tell apart: a
|
||||
/// plain click **expands** (the fold's own reversal, and the discoverable one — there is no header
|
||||
/// to hang a chevron on), a ⌘/⇧ click enters the ordinary selection grammar so a folded lane can
|
||||
/// still be selected and ranged over by pointer, and a drag reorders the lane exactly as its header
|
||||
/// would (`startLaneDrag`). A double click is a plain click twice: the first expands, and the second
|
||||
/// lands on the lane that is now open — no create, because the strip has no empty space to
|
||||
/// double-click *into*.
|
||||
private var collapsedStrip: some View {
|
||||
VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||||
// **The glyph and the badge are the strip's chrome, and they sit at its top** (owner ruling
|
||||
// 2026-08-08/09): the title is top-justified below them, so the two fixed pieces stay
|
||||
// exactly where they are however long the title runs — a badge underneath a
|
||||
// variable-length run would drift with the words above it.
|
||||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
// **The lane's held count, not its rendered one**: a folded lane renders no card, so the
|
||||
// filter has nothing to narrow here and `0` would be a lie about the lane's contents. The
|
||||
// search's own answer is the *expanded* badge's rule and stays there (`countBadge`).
|
||||
countBadgeText("\(lane.cards.count)")
|
||||
collapsedTitle
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
// The strip is chrome all the way down, so it has to be hit-testable across its whole height —
|
||||
// including the empty stretch under the badge, which is most of it on a tall board.
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let modifier = ClickModifier.current
|
||||
guard modifier == .plain else {
|
||||
// ⌘/⇧ keep their grammar: the strip is the lane's one pointer surface while folded, so
|
||||
// taking the modifiers away would make a collapsed lane unselectable by mouse.
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||
modifier: modifier
|
||||
)
|
||||
return
|
||||
}
|
||||
expand()
|
||||
}
|
||||
.onDrag(startLaneDrag, preview: { dragReplica })
|
||||
// **The strip is the lane's heading while folded** — the header's role, since there is no
|
||||
// header (10-accessibility.md ▸ Rotor: "lane titles are headings, so the headings rotor jumps
|
||||
// lane-to-lane"). Flattened for the same reason the header is: the glyph and the count are the
|
||||
// container's information, already spoken by its label.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
|
||||
.accessibilityAddTraits(headerTraits)
|
||||
// The keyboard's way out of the fold, and the pointer's, on one method: VO-Space would
|
||||
// otherwise be the one path that could not undo what it can see.
|
||||
.accessibilityAction(named: "Expand Lane") { expand() }
|
||||
.accessibilityAction { toggleLaneSelection() }
|
||||
.boardTextInk(headerInk)
|
||||
.contextMenu { laneMenu }
|
||||
.accessibilityActions { laneActions }
|
||||
// The Style… popover's anchor while folded — the strip stands in for the header, which is
|
||||
// where an expanded lane's popover hangs (`styleEditorPresentation` decides whether this lane
|
||||
// is the session's anchor at all). Without it a restyle invoked from the strip's own menu would
|
||||
// have nowhere to present from.
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .trailing) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
}
|
||||
|
||||
/// The title, turned a quarter turn **counterclockwise** so it reads **bottom-up** — the European
|
||||
/// book-spine orientation — and **justified to the top** of the room below the strip's chrome
|
||||
/// (owner ruling 2026-08-08/09, superseding the top-down reading this shipped with for an hour).
|
||||
///
|
||||
/// Both halves are one alignment plus one sign, and both are worth spelling out because the
|
||||
/// rotation is a *visual* transform over an unrotated layout:
|
||||
///
|
||||
/// - **`-90°`** maps the unrotated `+x` direction (the reading direction) onto `-y` (up), so the
|
||||
/// string's first character lands at the run's bottom and the last at its top: the eye travels
|
||||
/// upward, and the tail ellipsis — plain `.truncationMode(.tail)`, untouched — lands at the top
|
||||
/// end, which is where the string's tail lands by construction.
|
||||
/// - **`alignment: .trailing`** puts a short string against the unrotated box's right edge, and
|
||||
/// right maps to up under the same rotation: the glyphs occupy the *topmost* stretch of the run
|
||||
/// and the slack falls away beneath them. Anchoring the box instead of the glyphs would leave a
|
||||
/// short title floating in the middle of a full-height frame.
|
||||
///
|
||||
/// **It truncates against the strip's height**, which is the whole reason the travel is measured
|
||||
/// rather than derived: a rotated `Text` given no width lays out at its full ideal length and simply
|
||||
/// overhangs the strip's ends. Laying it out at `collapsedTitleTravel` first — the leftover height,
|
||||
/// measured by the clear box it is drawn over — is 03's graceful-truncation rule holding in the one
|
||||
/// place on the board where the axis is vertical.
|
||||
///
|
||||
/// The clear box takes whatever the chrome above it left (`maxHeight: .infinity`), so the leftover
|
||||
/// arithmetic is the layout's rather than this file's.
|
||||
private var collapsedTitle: some View {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { collapsedTitleTravel = $0 }
|
||||
.overlay { rotatedTitle(travel: collapsedTitleTravel) }
|
||||
}
|
||||
|
||||
/// The title turned on its side, laid out against `travel` points of vertical room — the strip's and
|
||||
/// its replica's one construction, so the drag image reads and truncates exactly as the lane does.
|
||||
private func rotatedTitle(travel: CGFloat) -> some View {
|
||||
Text(lane.title.value ?? "Untitled")
|
||||
.boardFont(.headline)
|
||||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(width: travel, alignment: .trailing)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
|
||||
/// **The drop feedback a folded lane can give** (03-board-ui.md § Lane ▸ Collapsed lanes: a drop on
|
||||
/// the strip appends at the lane's end, "with the normal drop feedback").
|
||||
///
|
||||
/// The normal feedback is a shadow opening in the masonry at the landing slot, and a strip has no
|
||||
/// masonry to open one in — so the strip says the same thing the way the card-attach highlight does
|
||||
/// (04-interactions.md ▸ Drag and drop: "each target gets one clear signal, and the card-attach
|
||||
/// highlight exists precisely because that target has no shadow"): an accent edge around the lane
|
||||
/// while the proposal names it. Both session kinds reach it, ours and Finder's, because both land
|
||||
/// at the same place.
|
||||
///
|
||||
/// Clear rather than absent when there is nothing to say, so the overlay never changes identity.
|
||||
@ViewBuilder
|
||||
private var collapsedDropStroke: some View {
|
||||
let proposing = isCollapsed
|
||||
&& (cardProposal != nil
|
||||
|| drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) != nil)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
proposing
|
||||
? Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast))
|
||||
: .clear,
|
||||
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
/// Unfolds the lane — the strip's click, its VoiceOver action, and the menu row's expand half, on
|
||||
/// one method so the three cannot mean different things.
|
||||
private func expand() {
|
||||
guard store.acceptsBoardMutations else { return }
|
||||
store.setLaneCollapsed(lane.id, collapsed: false)
|
||||
}
|
||||
|
||||
/// Folds it — the header chevron's and the menu row's collapse half.
|
||||
private func collapse() {
|
||||
guard store.acceptsBoardMutations else { return }
|
||||
store.setLaneCollapsed(lane.id, collapsed: true)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -256,7 +455,15 @@ struct LaneView: View, Equatable {
|
||||
measuredHeaderHeight = frame.height
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
// **The header's trailing chrome, outside the drag region** — the overlay is attached
|
||||
// after `.onDrag` deliberately, which is what makes both controls clickable rather than
|
||||
// grabbable (the new-card button's long-standing arrangement, now shared).
|
||||
.overlay(alignment: .trailing) {
|
||||
HStack(spacing: 0) {
|
||||
collapseButton
|
||||
newCardButton
|
||||
}
|
||||
}
|
||||
// **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on
|
||||
// a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike,
|
||||
// since the ink they need is the same question and only the *verification* differs
|
||||
@@ -351,6 +558,18 @@ struct LaneView: View, Equatable {
|
||||
|
||||
widthControl
|
||||
|
||||
// **The fold's own row, under the width control it belongs beside** (03-board-ui.md § Lane ▸
|
||||
// Collapsed lanes) — one row whose title says which direction it goes, the Show/Hide Trash
|
||||
// shape rather than a checkmark: a menu row that toggles a *geometry* reads better as the verb
|
||||
// it performs than as a state it reports.
|
||||
//
|
||||
// **Single-lane, like the width stepper above it and unlike Style… and Delete**: the design
|
||||
// gives the width batch to the ⌥⌘→/⌥⌘← menu items and keeps the stepper on the lane whose menu
|
||||
// is open, and the fold has no keyboard face to carry a batch. So this row acts on the clicked
|
||||
// lane outright, which is also what its singular title promises.
|
||||
collapseControl
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Divider()
|
||||
|
||||
// Delete: File ▸ Delete's exact store path (`store.delete`), on the same widened target set
|
||||
@@ -373,10 +592,21 @@ struct LaneView: View, Equatable {
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
|
||||
.disabled(!store.acceptsBoardMutations || units <= 1)
|
||||
collapseControl
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Delete") { deleteTargets() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// The fold's one row, in the direction this lane can actually go — the context menu's and its
|
||||
/// VoiceOver twin's, spelled once so the two surfaces cannot drift into meaning different things
|
||||
/// (the `laneActions` rule).
|
||||
private var collapseControl: some View {
|
||||
isCollapsed
|
||||
? Button("Expand Lane") { expand() }
|
||||
: Button("Collapse Lane") { collapse() }
|
||||
}
|
||||
|
||||
/// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers
|
||||
/// (the context menu row and its accessibility twin).
|
||||
private func beginRename() {
|
||||
@@ -456,12 +686,13 @@ struct LaneView: View, Equatable {
|
||||
countBadge
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
// Reserves the button's width so a long title truncates before it collides, and keeps the
|
||||
// button out of the gestured region. **Font-derived** rather than a fixed 22pt: the button
|
||||
// is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph
|
||||
// itself at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
||||
// quietly stop holding (`BoardMetrics.newCardButtonReserve`).
|
||||
.padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize))
|
||||
// Reserves the trailing chrome's width so a long title truncates before it collides, and keeps
|
||||
// those controls out of the gestured region. **Font-derived** rather than a fixed figure: both
|
||||
// are `Image`s at a relative image scale, so a fixed reserve would be overrun by the glyphs
|
||||
// themselves at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
||||
// quietly stop holding (`BoardMetrics.laneHeaderTrailingReserve`, which is the collapse
|
||||
// chevron's room plus the new-card button's).
|
||||
.padding(.trailing, BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: pointSize))
|
||||
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
@@ -509,7 +740,14 @@ struct LaneView: View, Equatable {
|
||||
/// what the body renders, and while an inline rename is open its card is one of the things the
|
||||
/// body renders (see `renderedCards`).
|
||||
private var countBadge: some View {
|
||||
Text("\(renderedCards.count)")
|
||||
countBadgeText("\(renderedCards.count)")
|
||||
}
|
||||
|
||||
/// The badge's chrome with the number as a parameter — the header's and the collapsed strip's
|
||||
/// badge are the *same* badge, and the two differ only in which count they are of (see
|
||||
/// `collapsedStrip`).
|
||||
private func countBadgeText(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.boardFont(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -518,6 +756,35 @@ struct LaneView: View, Equatable {
|
||||
.background(Capsule().fill(.quaternary))
|
||||
}
|
||||
|
||||
/// **The collapse chevron** (03-board-ui.md § Lane ▸ Collapsed lanes) — the pointer's way into the
|
||||
/// fold, beside the new-card button at the header's trailing edge.
|
||||
///
|
||||
/// **Always visible, not hover-revealed**, because the header has no hover chrome to match: the
|
||||
/// new-card button beside it has been unconditional since it existed, and one control that appears
|
||||
/// on hover next to one that does not reads as a glitch rather than as a convention. The cost is
|
||||
/// paid in the header's trailing reserve, which is the figure that keeps the title truncating before
|
||||
/// either glyph (`headerContent`).
|
||||
///
|
||||
/// `chevron.left` because the fold travels that way — the lane's content collapses toward its
|
||||
/// leading edge and leaves a spine — and the strip's reversal is a click on the strip itself, which
|
||||
/// is why there is no `chevron.right` twin drawn over there: the whole strip is the button.
|
||||
///
|
||||
/// Mutating, so it disables exactly where every other write path does — the read-only lock and the
|
||||
/// focused-editor rule (`store.acceptsBoardMutations`, the pointer twin of a disabled menu item).
|
||||
private var collapseButton: some View {
|
||||
Button {
|
||||
collapse()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(AccessibilityPhrases.collapseLaneLabel(lane: lane.title.value))
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*:
|
||||
/// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its
|
||||
/// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so
|
||||
@@ -585,6 +852,9 @@ struct LaneView: View, Equatable {
|
||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||
// (03-board-ui.md § Motion).
|
||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
// And which of them are folded, frozen with them: a collapsed lane's shadow is its slim
|
||||
// strip rather than the slot its preserved `width` would buy (`DragSession.laneCollapsed`).
|
||||
collapsed: members.map { LaneLayoutMath.isCollapsed($0) },
|
||||
source: store,
|
||||
// The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among
|
||||
// the lanes with the dragged run removed — the strip proposal's own index space. A
|
||||
@@ -617,7 +887,14 @@ struct LaneView: View, Equatable {
|
||||
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
|
||||
private var dragReplica: some View {
|
||||
let count = max(1, draggedLaneCount)
|
||||
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
||||
// **A folded lane needs no anchoring, and must not have any.** The padding exists because the
|
||||
// grab surface — the title bar — is a sliver at the top of a full-height replica, so the image
|
||||
// has to be shifted for the cursor to keep the point it grabbed. The strip *is* the whole lane:
|
||||
// SwiftUI centres the preview on the view the drag started from, which here is already the
|
||||
// thing being carried, so a shift would be the very displacement the anchor exists to undo.
|
||||
let anchor = isCollapsed
|
||||
? DragPreviewAnchor.Padding.none
|
||||
: DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||||
@@ -648,31 +925,38 @@ struct LaneView: View, Equatable {
|
||||
private var replicaFace: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
headerContent
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
ForEach(renderedCards.prefix(12)) { card in
|
||||
HStack(alignment: .firstTextBaseline,
|
||||
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.boardFont(.body)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
// **A folded lane's replica is its strip** (03-board-ui.md § Motion's faithful-replica
|
||||
// rule, read literally): the drop leaves the lane folded, so carrying a squeezed header and
|
||||
// a stack of card rows would show the user a lane the board is not about to draw.
|
||||
if isCollapsed {
|
||||
collapsedReplicaStrip
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
headerContent
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
ForEach(renderedCards.prefix(12)) { card in
|
||||
HStack(alignment: .firstTextBaseline,
|
||||
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.boardFont(.body)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(BoardSurface.cardPlate)
|
||||
)
|
||||
}
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(BoardSurface.cardPlate)
|
||||
)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
|
||||
// The lane's own wash over an opaque base — the replica floats over whatever the cursor is
|
||||
@@ -684,12 +968,45 @@ struct LaneView: View, Equatable {
|
||||
.dragReplicaShadow(zoom: zoom)
|
||||
}
|
||||
|
||||
/// The collapsed strip as the replica draws it: the same three pieces in the same column, with the
|
||||
/// travel a *fixed* figure rather than a measured one.
|
||||
///
|
||||
/// A drag image is a snapshot — no geometry observers — so the live strip's measured travel is not
|
||||
/// available here, and reusing `collapsedTitleTravel` would let the replica write into the state the
|
||||
/// resting lane truncates by. The replica's own height less its furniture is the honest figure, and
|
||||
/// it is the same arithmetic the live strip's layout performs.
|
||||
private var collapsedReplicaStrip: some View {
|
||||
VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
countBadgeText("\(lane.cards.count)")
|
||||
// The live strip's own travel, **fixed rather than flexible**: a drag image is a snapshot
|
||||
// and must not observe its own geometry (it would write into the state the resting lane
|
||||
// truncates by, from inside a preview). The figure is honest because the replica is drawn at
|
||||
// the lane's own height, so the leftover room is the same leftover room.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: collapsedTitleTravel)
|
||||
.overlay { rotatedTitle(travel: collapsedTitleTravel) }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
||||
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
|
||||
/// ask the same question the drawing does, and two derivations of one figure would be two
|
||||
/// answers.
|
||||
///
|
||||
/// **A folded lane takes the strip's exact width, floor and all**: the floor exists for a lane that
|
||||
/// has not measured itself and so has no width to draw at, and the strip's width is a constant
|
||||
/// nobody has to measure (`BoardMetrics.collapsedLaneWidth`) — applying a 6-em floor to a 3.4-em
|
||||
/// strip would draw a replica almost twice as wide as the thing it is a replica of.
|
||||
private var replicaWidth: CGFloat {
|
||||
max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
|
||||
guard !isCollapsed else { return BoardMetrics.collapsedLaneWidth(bodyPointSize: pointSize) }
|
||||
return max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
private var replicaHeight: CGFloat {
|
||||
|
||||
Reference in New Issue
Block a user