Realign the width and lane-move commands — three fresh settlements
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
This commit is contained in:
@@ -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
|
/// second thing to do about it. The lane stays at its old width, which is the truth — nothing was
|
||||||
/// written.
|
/// written.
|
||||||
public func setLaneWidth(_ id: ItemID, units: Int) {
|
public func setLaneWidth(_ id: ItemID, units: Int) {
|
||||||
let clamped = max(1, units)
|
writeLaneWidths([(id, max(1, units))])
|
||||||
guard let lane = snapshot.lanes.first(where: { $0.id == id }),
|
}
|
||||||
LaneLayoutMath.displayUnits(of: lane) != clamped
|
|
||||||
else { return }
|
/// 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<ItemID>, 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
|
// 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
|
// 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
|
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
|
||||||
// `performWrite`'s doc comment records, arriving from the other direction.
|
// `performWrite`'s doc comment records, arriving from the other direction.
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
|
for write in writes {
|
||||||
document.set(FrontmatterKeys.width, to: .int(clamped))
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// **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,
|
/// 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.
|
/// 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 {
|
struct MoveLaneCommands: View {
|
||||||
|
|
||||||
@FocusedValue(\.boardStore) private var store
|
@FocusedValue(\.boardStore) private var store
|
||||||
|
@FocusedValue(\.boardInfo) private var boardInfo
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button("Move Left") {
|
Button("Move Left") {
|
||||||
move(by: -1)
|
move(by: -1)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.leftArrow, modifiers: .command)
|
.keyboardShortcut(.leftArrow, modifiers: .command)
|
||||||
.disabled(destination(-1) == nil)
|
.disabled(caretChordsYield || destination(-1) == nil)
|
||||||
|
|
||||||
Button("Move Right") {
|
Button("Move Right") {
|
||||||
move(by: 1)
|
move(by: 1)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.rightArrow, modifiers: .command)
|
.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
|
/// 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
|
/// 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.
|
/// 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
|
/// **They batch over a multi-lane selection** (03-board-ui.md § Lane, settled — the styling
|
||||||
/// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a
|
/// precedent): each selected lane steps one unit, one gesture, one commit
|
||||||
/// trash-side selection and an empty one all disable them. Decrease additionally disables at one
|
/// (`BoardStore.stepLaneWidths`); the context-menu stepper stays single-lane by nature. A card
|
||||||
/// unit, which is the floor.
|
/// 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 {
|
struct LaneWidthCommands: View {
|
||||||
|
|
||||||
@FocusedValue(\.boardStore) private var store
|
@FocusedValue(\.boardStore) private var store
|
||||||
|
@FocusedValue(\.boardInfo) private var boardInfo
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button("Increase Lane Width") {
|
Button("Increase Lane Width") {
|
||||||
step(by: 1)
|
step(by: 1)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
|
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
|
||||||
.disabled(selectedLane == nil)
|
.disabled(caretChordsYield || selectedLanes.isEmpty)
|
||||||
|
|
||||||
Button("Decrease Lane Width") {
|
Button("Decrease Lane Width") {
|
||||||
step(by: -1)
|
step(by: -1)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
|
.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
|
/// 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.
|
/// left for the write to refuse: an item that is going to fail should not look available.
|
||||||
private var selectedLane: Lane? {
|
private var selectedLanes: [Lane] {
|
||||||
guard let store, store.acceptsBoardMutations else { return nil }
|
guard let store, store.acceptsBoardMutations else { return [] }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
guard selection.liveness == .live, !selection.isEmpty else { return [] }
|
||||||
return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted }
|
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
|
/// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
|
||||||
/// outcome is a no-op reads better disabled than dead.
|
/// better disabled than dead — which for a batch means *some* member must have room to shrink.
|
||||||
private var canDecrease: Bool {
|
private var canDecrease: Bool {
|
||||||
guard let lane = selectedLane else { return false }
|
selectedLanes.contains { LaneLayoutMath.displayUnits(of: $0) > 1 }
|
||||||
return LaneLayoutMath.displayUnits(of: lane) > 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func step(by delta: Int) {
|
private func step(by delta: Int) {
|
||||||
guard let store, let lane = selectedLane else { return }
|
guard let store else { return }
|
||||||
store.setLaneWidth(lane.id, units: LaneLayoutMath.displayUnits(of: lane) + delta)
|
let lanes = selectedLanes
|
||||||
|
guard !lanes.isEmpty else { return }
|
||||||
|
store.stepLaneWidths(Set(lanes.map(\.id)), by: delta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,14 +114,16 @@ struct LaneWidthWriteTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
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)
|
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"))
|
try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
|
||||||
let reopened = try BoardStore(rootURL: fixture.root)
|
let reopened = try BoardStore(rootURL: fixture.root)
|
||||||
reopened.setLaneWidth(ItemID(rawValue: Ident.lane2), units: -7)
|
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")
|
@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")
|
#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> = [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")
|
@Test("A readable-but-uneditable lane refuses the write, banners it, and keeps its bytes")
|
||||||
func uneditableLaneBannersAndChangesNothing() throws {
|
func uneditableLaneBannersAndChangesNothing() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
Reference in New Issue
Block a user