From edc094a5f41fb43c6ada0eb14794f0ed84bf9265 Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 27 Jul 2026 20:10:59 -0400 Subject: [PATCH] =?UTF-8?q?Realign=20the=20width=20and=20lane-move=20comma?= =?UTF-8?q?nds=20=E2=80=94=20three=20fresh=20settlements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN edits landing from the parallel session, honored: - Caret chords yield to any focused text control (04 ▸ Grammar): Move Left/Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ now disable while the board popover is open — its fields are the one non-inline text surface a board window has today; the search field and card-window fields extend the rule with their own cards. - The width pair batches over a multi-lane selection (03 ▸ Lane, the styling precedent): each selected lane steps one unit through one bracket (stepLaneWidths); floor members hold on a decrease; the context-menu stepper stays single-lane by nature. - A width write landing on 1 removes the width key (03 ▸ Lane, the remove-at-default family), every mechanism alike — writeLaneWidths is the one commit point; a hand-written width: 1 is preserved by the unchanged guard until the app itself next edits width. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Kanban/LiveStore/BoardStore.swift | 49 ++++++++++++++++++--- Kanban/UI/Board/BoardCommands.swift | 63 +++++++++++++++++++-------- KanbanTests/LaneWidthWriteTests.swift | 60 +++++++++++++++++++++++-- 3 files changed, 143 insertions(+), 29 deletions(-) diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index db4b76c..3fe8919 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -678,19 +678,54 @@ public final class BoardStore { /// second thing to do about it. The lane stays at its old width, which is the truth — nothing was /// written. public func setLaneWidth(_ id: ItemID, units: Int) { - let clamped = max(1, units) - guard let lane = snapshot.lanes.first(where: { $0.id == id }), - LaneLayoutMath.displayUnits(of: lane) != clamped - else { return } + writeLaneWidths([(id, max(1, units))]) + } + + /// Steps every lane in `ids` one unit — the Increase/Decrease Lane Width menu items' batch + /// (03-board-ui.md § Lane, settled: "they batch over a multi-lane selection — each selected + /// lane steps one unit, one gesture, one commit"; the context-menu stepper stays single-lane + /// by nature and keeps calling `setLaneWidth`). + /// + /// Lanes already at the one-unit floor simply hold there on a decrease — the batch is not + /// refused because one member has nowhere to go, matching the style batch's silent-skip shape. + public func stepLaneWidths(_ ids: Set, by delta: Int) { + let changes: [(ItemID, Int)] = snapshot.lanes + .filter { ids.contains($0.id) && !$0.isDeleted } + .map { ($0.id, max(1, LaneLayoutMath.displayUnits(of: $0) + delta)) } + writeLaneWidths(changes) + } + + /// The one commit point every width mechanism shares — the edge drag, the context-menu stepper, + /// and the menu items' batch. One `performWrite` bracket whatever the count: one gesture, one + /// app-mediated reload, one commit on git boards (the style batch's rule). + /// + /// **A width landing on 1 removes the `width` key** (03-board-ui.md § Lane, settled — the + /// remove-at-default family beside the empty rename's `title` and the None well's + /// `background`): a default lane's frontmatter stays clean whichever mechanism wrote it. A + /// hand-written `width: 1` is legal and preserved until the app itself next edits width — the + /// unchanged-units guard below skips it, so only a real change reaches the remove. + private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) { + let writes: [(folder: URL, units: Int)] = changes.compactMap { change in + guard let lane = snapshot.lanes.first(where: { $0.id == change.id }), + LaneLayoutMath.displayUnits(of: lane) != change.units + else { return nil } + return (rootURL.appendingPathComponent(change.id.rawValue), change.units) + } + guard !writes.isEmpty else { return } - let folder = rootURL.appendingPathComponent(id.rawValue) // The closure's signature is spelled out because of `try?`: with the error discarded at the // call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any // Error`, which `performWrite` will not take. Same wart as the value-returning call sites // `performWrite`'s doc comment records, arriving from the other direction. try? performWrite { () throws(BoardWriteError) -> Void in - try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in - document.set(FrontmatterKeys.width, to: .int(clamped)) + for write in writes { + try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in + if write.units == 1 { + document.remove(FrontmatterKeys.width) + } else { + document.set(FrontmatterKeys.width, to: .int(write.units)) + } + } } } } diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index b27afa8..807f3aa 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -202,22 +202,35 @@ struct MoveCardCommands: View { /// **Never into the trash** costs nothing: the quasi-lane is not in the live lane order, so a step /// past the last real lane is simply off the end — which is also the disable rule at the walls, /// following the width stepper's floor style rather than letting the store no-op silently. +/// +/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled): +/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a +/// field ever sees the key. The inline title editors are covered by `acceptsBoardMutations`; the +/// board popover's fields are covered by disabling while the popover is open at all — coarser than +/// per-field focus, but the popover is a configuration surface (04's carve-out) and no lane move +/// belongs under it. The search field (m5-search) and the card window's fields (whose windows never +/// publish a `boardStore` in the first place) extend the same rule with their own cards. struct MoveLaneCommands: View { @FocusedValue(\.boardStore) private var store + @FocusedValue(\.boardInfo) private var boardInfo var body: some View { Button("Move Left") { move(by: -1) } .keyboardShortcut(.leftArrow, modifiers: .command) - .disabled(destination(-1) == nil) + .disabled(caretChordsYield || destination(-1) == nil) Button("Move Right") { move(by: 1) } .keyboardShortcut(.rightArrow, modifiers: .command) - .disabled(destination(1) == nil) + .disabled(caretChordsYield || destination(1) == nil) + } + + private var caretChordsYield: Bool { + boardInfo?.isPresented == true } /// The sole selected live lane and the display slot one step would put it in — `nil` when there @@ -404,48 +417,60 @@ struct BoardStyleCommand: View { /// size — window-growing behaviour belongs to the right-edge drag alone — and they are uncapped, so /// widths beyond what the screen can fit stay reachable here even though the drag hard-stops. /// -/// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused -/// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a -/// trash-side selection and an empty one all disable them. Decrease additionally disables at one -/// unit, which is the floor. +/// **They batch over a multi-lane selection** (03-board-ui.md § Lane, settled — the styling +/// precedent): each selected lane steps one unit, one gesture, one commit +/// (`BoardStore.stepLaneWidths`); the context-menu stepper stays single-lane by nature. A card +/// selection, a trash-side selection and an empty one all disable them. Decrease additionally +/// disables when **every** selected lane is at the one-unit floor — a mixed batch stays live, its +/// floor members simply holding (the style batch's silent skip). +/// +/// **⌥⌘←/⌥⌘→ yield to any focused text control** (04-interactions.md ▸ Grammar's caret-chords +/// rule) — see `MoveLaneCommands`, whose ⌘←/⌘→ carry the same obligation and whose doc comment +/// carries the mechanism. struct LaneWidthCommands: View { @FocusedValue(\.boardStore) private var store + @FocusedValue(\.boardInfo) private var boardInfo var body: some View { Button("Increase Lane Width") { step(by: 1) } .keyboardShortcut(.rightArrow, modifiers: [.option, .command]) - .disabled(selectedLane == nil) + .disabled(caretChordsYield || selectedLanes.isEmpty) Button("Decrease Lane Width") { step(by: -1) } .keyboardShortcut(.leftArrow, modifiers: [.option, .command]) - .disabled(!canDecrease) + .disabled(caretChordsYield || !canDecrease) } - /// The sole selected live lane, or `nil` — the whole of these items' validation. + private var caretChordsYield: Bool { + boardInfo?.isPresented == true + } + + /// The selected live lanes, in snapshot order — the batch, and the items' validation. /// /// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than /// left for the write to refuse: an item that is going to fail should not look available. - private var selectedLane: Lane? { - guard let store, store.acceptsBoardMutations else { return nil } + private var selectedLanes: [Lane] { + guard let store, store.acceptsBoardMutations else { return [] } let selection = store.selection - guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil } - return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted } + guard selection.liveness == .live, !selection.isEmpty else { return [] } + return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted } } - /// A one-unit lane cannot shrink: `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only - /// outcome is a no-op reads better disabled than dead. + /// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads + /// better disabled than dead — which for a batch means *some* member must have room to shrink. private var canDecrease: Bool { - guard let lane = selectedLane else { return false } - return LaneLayoutMath.displayUnits(of: lane) > 1 + selectedLanes.contains { LaneLayoutMath.displayUnits(of: $0) > 1 } } private func step(by delta: Int) { - guard let store, let lane = selectedLane else { return } - store.setLaneWidth(lane.id, units: LaneLayoutMath.displayUnits(of: lane) + delta) + guard let store else { return } + let lanes = selectedLanes + guard !lanes.isEmpty else { return } + store.stepLaneWidths(Set(lanes.map(\.id)), by: delta) } } diff --git a/KanbanTests/LaneWidthWriteTests.swift b/KanbanTests/LaneWidthWriteTests.swift index 643ced9..5814d74 100644 --- a/KanbanTests/LaneWidthWriteTests.swift +++ b/KanbanTests/LaneWidthWriteTests.swift @@ -114,14 +114,16 @@ struct LaneWidthWriteTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - // Lane two is at 3×, so a clamped 1 is a real change and actually reaches disk. + // Lane two is at 3×, so a clamped 1 is a real change and actually reaches disk — where the + // remove-at-default rule turns it into an absent key (03-board-ui.md § Lane, settled), which + // the read side displays as one unit. store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 0) - #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1)) + #expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing) try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3")) let reopened = try BoardStore(rootURL: fixture.root) reopened.setLaneWidth(ItemID(rawValue: Ident.lane2), units: -7) - #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1)) + #expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing) } @Test("A lane that is not in the snapshot is a no-op") @@ -160,6 +162,58 @@ struct LaneWidthWriteTests { #expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either") } + @Test("A width landing on one removes the key — the remove-at-default family") + func landingOnOneRemovesTheKey() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // Lane two carries `width: 3`; stepping it down to one must not leave `width: 1` behind — + // "a default lane's frontmatter stays clean, drag, stepper, and menu items alike" + // (03-board-ui.md § Lane, settled). The read side then displays one unit off the absent key. + store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 1) + + let after = try fixture.indexText(Ident.lane2) + #expect(!after.contains("width")) + #expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing) + } + + @Test("A hand-written width of one is preserved until the app itself next edits width") + func handWrittenOneIsPreserved() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "1")) + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane1) + + // Displays one unit, asked for one unit: the unchanged-units guard fires before the + // remove-at-default rule can, so the legal hand-written `width: 1` stays byte-for-byte. + store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1) + #expect(try fixture.indexData(Ident.lane1) == before) + } + + @Test("The menu batch steps every selected lane one unit in one bracket, floor members holding") + func batchStepsEverySelectedLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let ids: Set = [ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.lane2)] + + // Lane one displays 1× (no key), lane two 3×: one decrease holds the floor member and + // steps the other — "each selected lane steps one unit, one gesture, one commit" + // (03-board-ui.md § Lane, settled). + store.stepLaneWidths(ids, by: -1) + #expect(try loadedLane(Ident.lane1, in: fixture).width.isMissing, "held at the floor, key never materializes") + #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(2)) + + // A fresh store, because the step reads the snapshot and the first write's reload is the + // watcher's (async) — the second gesture in a real session steps off the reloaded board. + let reopened = try BoardStore(rootURL: fixture.root) + reopened.stepLaneWidths(ids, by: 1) + #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2)) + #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(3)) + } + @Test("A readable-but-uneditable lane refuses the write, banners it, and keeps its bytes") func uneditableLaneBannersAndChangesNothing() throws { let fixture = try makeBoard()