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:
@@ -49,7 +49,10 @@ private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange<Int>
|
||||
/// leniency is a *read-side* rule (01-storage-format.md § Frontmatter), so the only honest way to
|
||||
/// ask "what does a malformed width display as" is to put the malformed bytes on disk and load
|
||||
/// them.
|
||||
private func lanes(widths: [String?]) throws -> [Lane] {
|
||||
/// `collapsed` is the parallel list of `collapsed:` values (`nil` writes no key), padded with `nil`
|
||||
/// when it is shorter than `widths` — a board with no folded lane passes none at all and reads exactly
|
||||
/// as it did before the key existed.
|
||||
private func lanes(widths: [String?], collapsed: [String?] = []) throws -> [Lane] {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
@@ -61,6 +64,9 @@ private func lanes(widths: [String?]) throws -> [Lane] {
|
||||
if let width {
|
||||
frontmatter += "width: \(width)\n"
|
||||
}
|
||||
if index < collapsed.count, let value = collapsed[index] {
|
||||
frontmatter += "collapsed: \(value)\n"
|
||||
}
|
||||
frontmatter += "---\n"
|
||||
let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
@@ -545,3 +551,152 @@ struct LaneRedivideTests {
|
||||
#expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 4, fittingUnits: fit, step: step) == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Collapsed lanes
|
||||
|
||||
/// **The fold's arithmetic** (03-board-ui.md § Lane ▸ Collapsed lanes, settled 2026-08-08): a collapsed
|
||||
/// lane takes a fixed slim strip and is *not* part of the width re-division — its width comes off the
|
||||
/// top and what is left divides among the expanded lanes alone.
|
||||
///
|
||||
/// The exact-fill identity is the property worth pinning rather than any one number, because it is what
|
||||
/// "every lane is always on screen" means arithmetically once two kinds of lane share the strip:
|
||||
/// `C·collapsedWidth + T·standard + (T + C + 1)·gap` is the whole strip, always.
|
||||
@Suite("LaneLayoutMath ▸ collapsed lanes")
|
||||
struct LaneCollapseLayoutTests {
|
||||
|
||||
/// A slim strip in this suite. Deliberately not `BoardMetrics`' figure: the math takes it as a
|
||||
/// parameter, and a test that read the metric would pin the metric rather than the arithmetic.
|
||||
private let strip: CGFloat = 44
|
||||
|
||||
@Test("The reading is lenient: only a real `true` folds a lane")
|
||||
func onlyTrueFolds() throws {
|
||||
#expect(LaneLayoutMath.isCollapsed(try lane(width: nil)) == false)
|
||||
let folded = try lanes(widths: [nil], collapsed: ["true"])
|
||||
#expect(LaneLayoutMath.isCollapsed(try #require(folded.first)))
|
||||
|
||||
// An explicit `false`, a quoted word, and a value with no boolean reading at all — three
|
||||
// shapes, two readings, and the malformed one is expanded because the default is the absent
|
||||
// key's.
|
||||
for (raw, expected) in [("false", false), ("\"yes\"", true), ("maybe", false), ("3", false)] {
|
||||
let loaded = try lanes(widths: [nil], collapsed: [raw])
|
||||
#expect(LaneLayoutMath.isCollapsed(try #require(loaded.first)) == expected,
|
||||
"collapsed: \(raw) should read as \(expected ? "folded" : "expanded")")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Collapsed lanes are outside the unit total, so folding one is a re-divide trigger")
|
||||
func collapsedLanesLeaveTheUnitTotal() throws {
|
||||
let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil])
|
||||
// The folded lane keeps its `width` — that is how expanding restores the lane the user had —
|
||||
// and still answers `displayUnits`; it simply contributes none of them to the division.
|
||||
#expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 3, 1])
|
||||
#expect(LaneLayoutMath.collapsedCount(of: loaded) == 1)
|
||||
#expect(LaneLayoutMath.totalUnits(of: loaded) == 3)
|
||||
#expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 4)
|
||||
}
|
||||
|
||||
@Test("The strip still fills exactly with both kinds of lane in it")
|
||||
func mixedStripsFillExactly() throws {
|
||||
let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil])
|
||||
let total = LaneLayoutMath.totalUnits(of: loaded)
|
||||
let folded = LaneLayoutMath.collapsedCount(of: loaded)
|
||||
let standard = LaneLayoutMath.standardWidth(
|
||||
stripWidth: 1000, totalUnits: total, gap: gap,
|
||||
collapsedCount: folded, collapsedWidth: strip)
|
||||
|
||||
// 1000 = 3 standards + 1 strip + 5 gaps (4 slots, so 5 gaps counting both margins).
|
||||
#expect(abs(standard - (1000 - strip - gap * 5) / 3) < 0.0001)
|
||||
let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip)
|
||||
#expect(abs(widths.reduce(0, +) + gap * CGFloat(widths.count + 1) - 1000) < 0.0001)
|
||||
// The expanded lanes are their slots; the folded one is the constant, whatever its `width`.
|
||||
#expect(abs(widths[0] - LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) < 0.0001)
|
||||
#expect(widths[1] == strip)
|
||||
#expect(abs(widths[2] - standard) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Folding a lane widens its siblings without touching the window")
|
||||
func foldingRedividesTheRemainder() throws {
|
||||
let expanded = try lanes(widths: [nil, nil, nil])
|
||||
let folded = try lanes(widths: [nil, nil, nil], collapsed: [nil, nil, "true"])
|
||||
|
||||
let before = LaneLayoutMath.standardWidth(
|
||||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: expanded), gap: gap,
|
||||
collapsedCount: 0, collapsedWidth: strip)
|
||||
let after = LaneLayoutMath.standardWidth(
|
||||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: folded), gap: gap,
|
||||
collapsedCount: LaneLayoutMath.collapsedCount(of: folded), collapsedWidth: strip)
|
||||
#expect(after > before, "the two survivors grow into what the third gave up")
|
||||
}
|
||||
|
||||
@Test("A board with every lane folded divides nothing and still answers a positive width")
|
||||
func allCollapsedIsTheDegenerateEdge() throws {
|
||||
let loaded = try lanes(widths: ["2", "3"], collapsed: ["true", "true"])
|
||||
#expect(LaneLayoutMath.collapsedCount(of: loaded) == 2)
|
||||
// No expanded unit exists, so the total is the divisor guard rather than a description of
|
||||
// anything on screen — and nothing draws with the standard it produces.
|
||||
#expect(LaneLayoutMath.totalUnits(of: loaded) == 1)
|
||||
let standard = LaneLayoutMath.standardWidth(
|
||||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded), gap: gap,
|
||||
collapsedCount: 2, collapsedWidth: strip)
|
||||
#expect(standard > 0)
|
||||
let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip)
|
||||
#expect(widths == [strip, strip])
|
||||
|
||||
// A shown trash column *is* a real unit in that total, and correctly takes the remainder.
|
||||
let withTrash = LaneLayoutMath.standardWidth(
|
||||
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1), gap: gap,
|
||||
collapsedCount: 2, collapsedWidth: strip)
|
||||
#expect(abs(withTrash - (1000 - 2 * strip - gap * 4)) < 0.0001)
|
||||
|
||||
// The pathological strip stays positive rather than negative, `standardWidth`'s 1pt floor.
|
||||
#expect(LaneLayoutMath.standardWidth(
|
||||
stripWidth: 10, totalUnits: 1, gap: gap, collapsedCount: 4, collapsedWidth: strip) == 1)
|
||||
}
|
||||
|
||||
@Test("A strip with no folded lane reads exactly as it did before the key existed")
|
||||
func theDefaultsAreInert() {
|
||||
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap)
|
||||
== LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap,
|
||||
collapsedCount: 0, collapsedWidth: strip))
|
||||
}
|
||||
|
||||
@Test("Hit testing walks the drawn widths, so a folded lane's zone is where it is drawn")
|
||||
func hitTestingFollowsTheDrawnWidths() throws {
|
||||
let loaded = try lanes(widths: [nil, nil, nil], collapsed: [nil, "true", nil])
|
||||
let localStandard: CGFloat = 100
|
||||
let widths = LaneLayoutMath.drawnWidths(
|
||||
of: loaded, standard: localStandard, gap: gap, collapsedWidth: strip)
|
||||
#expect(widths == [100, strip, 100])
|
||||
|
||||
// Lane 0 spans [12, 112), the strip [124, 168), lane 2 [180, 280) — gaps answer nil.
|
||||
#expect(LaneLayoutMath.laneIndex(atX: 50, widths: widths, gap: gap) == 0)
|
||||
#expect(LaneLayoutMath.laneIndex(atX: 118, widths: widths, gap: gap) == nil)
|
||||
#expect(LaneLayoutMath.laneIndex(atX: 130, widths: widths, gap: gap) == 1)
|
||||
#expect(LaneLayoutMath.laneIndex(atX: 167, widths: widths, gap: gap) == 1)
|
||||
#expect(LaneLayoutMath.laneIndex(atX: 200, widths: widths, gap: gap) == 2)
|
||||
#expect(LaneLayoutMath.laneIndex(atX: 400, widths: widths, gap: gap) == nil)
|
||||
|
||||
// The unit-count entry point is the same walk, so a board with no folded lane cannot answer
|
||||
// differently from one with.
|
||||
#expect(LaneLayoutMath.laneIndex(atX: 130, unitCounts: [1, 1, 1], standard: localStandard, gap: gap)
|
||||
== LaneLayoutMath.laneIndex(atX: 130, widths: [100, 100, 100], gap: gap))
|
||||
}
|
||||
|
||||
@Test("A dragged run's span is its drawn footprint, folded members included")
|
||||
func draggedRunsMeasureWhatTheyWillDraw() {
|
||||
let localStandard: CGFloat = 100
|
||||
// Two dragged lanes, the second folded: 100 + gap + 44 rather than 100 + gap + 100.
|
||||
let widths = LaneLayoutMath.drawnWidths(
|
||||
units: [1, 3], collapsed: [false, true],
|
||||
standard: localStandard, gap: gap, collapsedWidth: strip)
|
||||
#expect(widths == [100, strip])
|
||||
#expect(DropSlotMath.laneRunSpan(widths: widths, gap: gap) == 100 + gap + strip)
|
||||
|
||||
// A shorter `collapsed` list reads as expanded past its end — what a caller with no fold to
|
||||
// report passes (the trashed-lane restore).
|
||||
#expect(LaneLayoutMath.drawnWidths(
|
||||
units: [1, 2], collapsed: [],
|
||||
standard: localStandard, gap: gap, collapsedWidth: strip)
|
||||
== [100, LaneLayoutMath.slotWidth(units: 2, standard: localStandard, gap: gap)])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user