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:
@@ -308,6 +308,23 @@ struct BoardDropContext {
|
||||
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
|
||||
let standard: @MainActor () -> CGFloat
|
||||
|
||||
/// A collapsed lane's fixed strip width at this board's current zoom (03-board-ui.md § Lane ▸
|
||||
/// Collapsed lanes) — the one lane width that is not a multiple of `standard`, so every zone list
|
||||
/// and every hit test below needs it to place the lanes at all.
|
||||
///
|
||||
/// Read off the registry rather than captured, for `bodyPointSize`'s own reason: these delegates run
|
||||
/// at event time, outside any body evaluation, and the level is a property of the window.
|
||||
var collapsedWidth: CGFloat {
|
||||
BoardMetrics.collapsedLaneWidth(bodyPointSize: registry.bodyPointSize)
|
||||
}
|
||||
|
||||
/// The lanes' **drawn** widths in board order — a slot width per expanded lane, the fixed strip per
|
||||
/// folded one. The one geometry every strip-level answer here is built from.
|
||||
func drawnWidths(of lanes: [Lane]) -> [CGFloat] {
|
||||
LaneLayoutMath.drawnWidths(
|
||||
of: lanes, standard: standard(), gap: gap, collapsedWidth: collapsedWidth)
|
||||
}
|
||||
|
||||
/// Whether two of these would send a drop to the same place — **the whole of what this value
|
||||
/// contributes to `LaneView.==` and `CardFaceView.==`.**
|
||||
///
|
||||
@@ -400,17 +417,22 @@ struct BoardDropContext {
|
||||
guard let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
// **Drawn widths, not unit counts**: a folded lane occupies one narrow zone on the strip rather
|
||||
// than the slot its preserved `width` would buy, and a folded lane being dragged contributes its
|
||||
// strip to the run's span cap (03-board-ui.md § Lane ▸ Collapsed lanes). Both readings come off
|
||||
// the one function the strip itself lays out with, so the zones cannot disagree with the picture.
|
||||
let restingWidths = drawnWidths(of: resting)
|
||||
let slot = DropSlotMath.laneSlot(
|
||||
cursorX: cursor.x,
|
||||
restingUnits: restingUnits,
|
||||
draggedUnits: session.laneUnits,
|
||||
standard: standard(),
|
||||
restingWidths: restingWidths,
|
||||
draggedWidths: LaneLayoutMath.drawnWidths(
|
||||
units: session.laneUnits, collapsed: session.laneCollapsed,
|
||||
standard: standard(), gap: gap, collapsedWidth: collapsedWidth),
|
||||
gap: gap,
|
||||
current: session.stripProposal(onBoardRooted: store.rootKey)
|
||||
)
|
||||
guard let slot else { return } // a dead region: hold the current proposal
|
||||
let index = min(max(0, slot), restingUnits.count)
|
||||
let index = min(max(0, slot), restingWidths.count)
|
||||
session.propose(DropTarget(boardRoot: store.rootKey, container: .strip, index: index))
|
||||
}
|
||||
|
||||
@@ -443,6 +465,26 @@ struct BoardDropContext {
|
||||
// by its callers, so the delegate, the strip's fall-through and the autoscroll driver all
|
||||
// leave the same address behind.
|
||||
session.noteRetarget(.lane(laneID), registry: registry)
|
||||
// **A folded lane is one zone, and the zone means the end of the lane** (03-board-ui.md § Lane ▸
|
||||
// Collapsed lanes: "dropping a drag onto the collapsed strip appends the payload at the lane's
|
||||
// END, like an end-of-lane drop"). It is answered *before* the cursor is read, deliberately, and
|
||||
// the answer says why: a strip draws no cards, so there is no row geometry for a position to be
|
||||
// resolved against and nothing about the pointer's y can change the reading. The strip's own
|
||||
// accent edge is the feedback the missing shadow would have been (`LaneView.collapsedDropStroke`).
|
||||
//
|
||||
// The index is counted in the lane's **resting** layout — its cards with the dragged run lifted
|
||||
// out — because that is the space every proposal in this app is counted in and the space
|
||||
// `BoardStore.moveCards` resolves in.
|
||||
//
|
||||
// No hover-to-auto-expand in v1 (noted as future work): the drop lands, the lane stays folded,
|
||||
// and the badge on its strip goes up by one when the echo reload arrives.
|
||||
if let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
||||
LaneLayoutMath.isCollapsed(lane) {
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||
let count = lane.cards.count { !hidden.contains($0.id) }
|
||||
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: count))
|
||||
return
|
||||
}
|
||||
guard let cursor = globalCursor() else { return }
|
||||
guard let resting = session.restingLayouts.layout(
|
||||
inLane: laneID,
|
||||
@@ -477,12 +519,8 @@ struct BoardDropContext {
|
||||
func retargetCardsFromStrip() {
|
||||
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
||||
let lanes = store.snapshot.lanes
|
||||
let index = LaneLayoutMath.laneIndex(
|
||||
atX: cursor.x,
|
||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
standard: standard(),
|
||||
gap: gap
|
||||
)
|
||||
// Drawn widths, so a folded lane's narrow zone is where it actually is — see `retargetLanes`.
|
||||
let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap)
|
||||
guard let index, lanes.indices.contains(index) else { return }
|
||||
retargetCards(inLane: lanes[index].id)
|
||||
}
|
||||
@@ -664,13 +702,28 @@ struct BoardDropContext {
|
||||
/// exactly the tradeoff every proposal in this app makes: the answer stays a pure function of the
|
||||
/// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
||||
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||||
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
||||
let grid = registry.grids[laneID]
|
||||
guard acceptsFileDrop(info),
|
||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID })
|
||||
else {
|
||||
session.proposeFile(nil)
|
||||
return
|
||||
}
|
||||
// **A folded lane is one zone, and it creates at the lane's end** — `retargetCards`' collapsed
|
||||
// branch, in the file mode's vocabulary and for its reasons: no card faces means no attach
|
||||
// target and no row geometry, so neither of `FileDropZones.landing`'s first two answers can
|
||||
// apply and the third has nothing to resolve against. The count is the files', as always.
|
||||
if LaneLayoutMath.isCollapsed(lane) {
|
||||
session.proposeFile(FileDropTarget(
|
||||
boardRoot: store.rootKey,
|
||||
landing: .create(laneID: laneID, index: lane.cards.count),
|
||||
fileCount: FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
|
||||
))
|
||||
return
|
||||
}
|
||||
guard let cursor = globalCursor(), let grid = registry.grids[laneID] else {
|
||||
session.proposeFile(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let rendered = lane.cards
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight }
|
||||
@@ -720,12 +773,7 @@ struct BoardDropContext {
|
||||
return
|
||||
}
|
||||
let lanes = store.snapshot.lanes
|
||||
let index = LaneLayoutMath.laneIndex(
|
||||
atX: cursor.x,
|
||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
standard: standard(),
|
||||
gap: gap
|
||||
)
|
||||
let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap)
|
||||
guard let index, lanes.indices.contains(index) else {
|
||||
session.proposeFile(nil)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user