diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 19a6d19..e27273d 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -11,9 +11,9 @@ import os /// /// Almost everything here is about beginning and ending: acquiring the shared store, stamping the /// registry, holding the board's security-scoped access, remembering the window's frame, and running -/// the close flush before any of it is let go. The board *itself* — lanes, cards, drag, the whole of -/// 03-board-ui.md — is a placeholder below, deliberately throwaway and confined to one small view so -/// the milestone that builds the real thing replaces exactly that and nothing else. +/// the close flush before any of it is let go. The board *itself* — the lane strip and everything in +/// it — is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out +/// of the way. /// /// ### Failure opens welcome /// @@ -63,8 +63,13 @@ struct BoardWindowHost: View { case let .open(store): VStack(spacing: 0) { BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) } - PlaceholderBoardView(store: store) + // The window is handed to the board as a closure, not a value: `WindowAccessor` + // attaches after this body first runs, and the lane-resize drag needs the *live* + // window to grow at its right edge (03-board-ui.md § Lane). + BoardView(store: store, window: { windowController.window }) } + // "The board in front", for the menu items that act on it (`LaneWidthCommands`). + .focusedSceneValue(\.boardStore, store) } } @@ -162,54 +167,3 @@ struct BoardWindowHost: View { } } } - -// MARK: - The placeholder board - -/// Stand-in for the board (03-board-ui.md): the title and a list of lane titles, and nothing else. -/// -/// **Deliberately throwaway.** The next milestone builds the full-visibility lane layout — masonry -/// cards, drag, the trash quasi-lane, the toolbar — and replaces this view wholesale. It is kept in -/// one small view with no state of its own so that replacement is a deletion rather than an -/// untangling. What it does prove today is that the window renders the *store's* snapshot: an -/// external edit shows up here through the watcher like it will in the real thing. -private struct PlaceholderBoardView: View { - - let store: BoardStore - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(AppModel.displayName(of: store)) - .font(.largeTitle) - - if liveLanes.isEmpty { - Text("No lanes yet") - .foregroundStyle(.secondary) - } else { - ForEach(liveLanes) { lane in - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(lane.title.value ?? "Untitled") - .font(.headline) - .foregroundStyle(lane.title.value == nil ? .secondary : .primary) - Text("\(liveCardCount(in: lane))") - .font(.callout) - .foregroundStyle(.secondary) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(24) - } - } - - /// Tombstoned lanes render nowhere on the board (03-board-ui.md collapses them into the trash - /// quasi-lane) — true of the placeholder as much as of the real layout. - private var liveLanes: [Lane] { - store.snapshot.lanes.filter { !$0.isDeleted } - } - - private func liveCardCount(in lane: Lane) -> Int { - lane.cards.filter { !$0.isDeleted }.count - } -} diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 6eb5a38..6602bb7 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -99,7 +99,7 @@ struct KanbanApp: App { } } - /// The two menu items this milestone owns. + /// The menu items the app has built so far. /// /// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts /// mechanism remaps menu items *by title*, so these strings are the keys a user's custom binding @@ -114,6 +114,13 @@ struct KanbanApp: App { .keyboardShortcut("o", modifiers: .command) } + // The Board menu (11-command-nexus.md). Its items act on the frontmost board window, which + // they reach through the focus system rather than through the app model — see + // `LaneWidthCommands`, which also owns their validation. + CommandMenu("Board") { + LaneWidthCommands() + } + CommandGroup(after: .windowList) { // No default chord — "— (no default)" in the Nexus is deliberate, not a gap; it remaps // like any other item. diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 6f63725..b5e0695 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -447,6 +447,8 @@ public final class BannerCenter { if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" } case let .style(title): if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" } + case let .resize(title): + if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" } case let .importAttachment(filename): "Couldn't import '\(filename)'" case .listAttachments: diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 0954b65..cb92026 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -605,6 +605,46 @@ public final class BoardStore { } } + // MARK: - Lane width + + /// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md § + /// Lane): the right-edge drag's release and the stepper's ⌥⌘→/⌥⌘← both land here, and they differ + /// only in what they did to the *window* on the way (the drag grew it, the stepper did not). + /// + /// **The value written is an integer, replacing whatever was there.** `width` is a lenient field + /// on the read side — missing, malformed, zero and negative all render as one unit + /// (`LaneLayoutMath.displayUnits`) with the author's bytes left alone — but an explicit width + /// change is the user overwriting that value, so the Writer puts a plain integer in its place + /// (01-storage-format.md § Frontmatter). + /// + /// Three ways this does nothing, all deliberate: a count below 1 clamps to 1 (a lane spans at + /// least one unit), an id that is not in the snapshot is ignored (the lane vanished under the + /// gesture — the reload that removed it is the authority), and a count already equal to what the + /// lane displays writes nothing (a drag that ends where it started must not stamp `modified` or + /// mint a git commit). + /// + /// Failures are already the banner's: `performWrite` posts every `BoardWriteError` before it + /// rethrows, so the rethrow is swallowed here rather than propagated to a gesture that has no + /// 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 } + + 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)) + } + } + } + // MARK: - Selection (delegated) // The three thin pass-throughs to `transient`, and the only ones. diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index c37995b..738c49c 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -1192,6 +1192,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case restore(title: String?) case purge(title: String?) case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md) + case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane) case importAttachment(filename: String) case listAttachments case renumberChildren // order-maintenance sweep (compaction) @@ -1216,6 +1217,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .restore: .restore(title: title) case .purge: .purge(title: title) case .style: .style(title: title) + case .resize: .resize(title: title) } } @@ -1237,6 +1239,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .restore(title): Self.phrase("restore", title) case let .purge(title): Self.phrase("purge", title) case let .style(title): Self.phrase("style", title) + case let .resize(title): Self.phrase("resize", title) case let .importAttachment(filename): "import attachment '\(filename)'" case .listAttachments: "list attachments" case .renumberChildren: "renumber children" diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift new file mode 100644 index 0000000..3172da7 --- /dev/null +++ b/Kanban/UI/Board/BoardCommands.swift @@ -0,0 +1,78 @@ +import SwiftUI + +// MARK: - The focused board + +/// The frontmost board window's store, published into the focus system by `BoardWindowHost` so menu +/// items can act on "the board in front" without the app model keeping a which-window-is-key +/// register of its own. +/// +/// `focusedSceneValue` rather than `focusedValue`: the value is the *window's*, not any particular +/// control's, so it stays available whatever inside the board has keyboard focus — which is what a +/// menu item validating against the selection needs. +struct FocusedBoardStoreKey: FocusedValueKey { + typealias Value = BoardStore +} + +extension FocusedValues { + var boardStore: BoardStore? { + get { self[FocusedBoardStoreKey.self] } + set { self[FocusedBoardStoreKey.self] = newValue } + } +} + +// MARK: - Lane width items + +/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane, +/// 11-command-nexus.md), and therefore the *re-divide* mechanism: each step re-divides the existing +/// window width across the new unit total, compressing the siblings. They never touch the window's +/// 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. Nothing selects a lane yet — the lane-chrome card wires the header +/// click (04-interactions.md ▸ Selection) — so these validate-disable in today's build, which is +/// expected rather than broken. +struct LaneWidthCommands: View { + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("Increase Lane Width") { + step(by: 1) + } + .keyboardShortcut(.rightArrow, modifiers: [.option, .command]) + .disabled(selectedLane == nil) + + Button("Decrease Lane Width") { + step(by: -1) + } + .keyboardShortcut(.leftArrow, modifiers: [.option, .command]) + .disabled(!canDecrease) + } + + /// The sole selected live lane, or `nil` — the whole of these items' validation. + /// + /// A read-only board disables every mutating command (02-architecture.md § The lock's scope), so + /// the lock is folded in here 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.isReadOnly else { return nil } + 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 } + } + + /// 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. + private var canDecrease: Bool { + guard let lane = selectedLane else { return false } + return LaneLayoutMath.displayUnits(of: lane) > 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) + } +} diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift new file mode 100644 index 0000000..b4af1be --- /dev/null +++ b/Kanban/UI/Board/BoardView.swift @@ -0,0 +1,140 @@ +import AppKit +import SwiftUI + +/// The lane strip — the board itself (03-board-ui.md § Layout — full visibility). +/// +/// **Every lane is always on screen.** There is no horizontal scroll and no minimum lane width: the +/// window's width divides across the lanes' width units, a lane of n units taking n whole units of +/// that division, and resizing the window is the width control. A board with more units than the +/// window comfortably fits compresses every lane; that degenerate case is accepted, not floored +/// (the remedy is the user's — fewer units or a bigger window). +/// +/// Two mechanisms change a lane's width and they are deliberately opposites (03-board-ui.md § Lane): +/// the **right-edge drag** grows or shrinks the *window* one standard width per snap so the other +/// lanes keep their exact pixels (`LaneResizeSession`), while the **stepper** — and its ⌥⌘→/⌥⌘← +/// keyboard face — re-divides the existing window width across the new unit total, compressing the +/// siblings and never touching the window (`BoardStore.setLaneWidth`). +/// +/// ### What is deliberately not here yet +/// +/// Selection, drag and drop, the trash quasi-lane, the toolbar, search, styling and the lane context +/// menu all belong to later milestone cards. This view is the layout and the resize interaction, and +/// the chrome inside `LaneView` is a placeholder those cards replace. +struct BoardView: View { + + let store: BoardStore + + /// How the resize session reaches the host window it grows and shrinks. Injected by + /// `BoardWindowHost`, which owns the window controller; a closure because the window attaches + /// after the first body evaluation. + let window: @MainActor () -> NSWindow? + + /// One resize at a time, per window. `@State` so it lives exactly as long as this board window's + /// view does, which is the interaction's whole lifetime. + @State private var resize = LaneResizeSession() + + /// The inter-lane gap, and the strip's outer margin — one number, because the standard-width + /// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`). + private let spacing: CGFloat = 12 + + var body: some View { + GeometryReader { viewport in + let lanes = liveLanes + // During a resize session the standard is FROZEN at its drag-start value: the window is + // animating mid-resize, so deriving the standard from the live viewport width would feed + // that animation back into every lane and pulse the whole strip. The window is sized on + // each tick so this frozen value equals what the viewport formula yields once the + // session ends — the handoff is seamless (see `LaneResizeSession`). + let standard = resize.isActive + ? resize.standard + : LaneLayoutMath.standardWidth( + stripWidth: viewport.size.width, + totalUnits: LaneLayoutMath.totalUnits(of: lanes), + gap: spacing) + HStack(alignment: .top, spacing: spacing) { + ForEach(lanes) { lane in + laneSlot(lane, standard: standard) + } + } + .padding(spacing) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + /// One lane's strip slot, plus its trailing grab strip. + /// + /// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the + /// interior gaps it spans). While THIS lane is being resized the slot holds two layers, kept + /// structurally stable so the `LaneView` never loses identity — its scroll position, its + /// masonry cache — across the drag: + /// + /// • a shadow at the SNAPPED slot width, full strip height, behind the lane — the resting + /// footprint the siblings and the window are already aligned to; + /// • the live `LaneView` in front at `liveWidth`, which tracks the cursor and so overflows + /// (drawing over the right neighbour, hence the slot's `zIndex(1)`) or underfills the shadow + /// between ticks. + /// + /// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out + /// off the tidy snapped layout regardless of the live overflow. + @ViewBuilder + private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View { + let resizing = resize.isResizing(lane.id) + let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane) + let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing) + ZStack(alignment: .topLeading) { + if resizing { + LaneResizeShadow() + .frame(width: slotWidth) + .frame(maxHeight: .infinity) + .allowsHitTesting(false) + } + // Interior columns follow the SNAPPED unit count while this lane is being resized — a + // column count is integral, so it tracks k (which ticks and animates), not the live + // continuous width and not the not-yet-committed `lane.width`. The live width still + // narrows and widens the columns continuously, so the cards reflow under the cursor + // between ticks (free via `MasonryLayout`). + LaneView(lane: lane, columns: units) + .frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading) + } + .frame(width: slotWidth, alignment: .topLeading) + .zIndex(resizing ? 1 : 0) + .overlay(alignment: .trailing) { + LaneResizeHandle( + store: store, + session: resize, + laneID: lane.id, + committedUnits: LaneLayoutMath.displayUnits(of: lane), + standard: standard, + gap: spacing, + window: window + ) + // The read-only lock disables every mutating gesture, not just the menu items + // (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag + // resizes the *window* on the way, so a refused commit would leave the window grown + // around a lane that snapped back — and the lock's row is already saying why nothing + // can be written. + .disabled(store.isReadOnly) + } + } + + /// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** — + /// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane + /// (a later card), and a lane that is not on the board consumes none of the window's width. + private var liveLanes: [Lane] { + store.snapshot.lanes.filter { !$0.isDeleted } + } +} + +// MARK: - Resize shadow + +/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize. +/// +/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone, +/// and this is the same shape that card and lane drops will want. Kept here rather than invented +/// twice. +private struct LaneResizeShadow: View { + var body: some View { + RoundedRectangle(cornerRadius: 10) + .fill(.quaternary.opacity(0.5)) + } +} diff --git a/Kanban/UI/Board/LaneLayoutMath.swift b/Kanban/UI/Board/LaneLayoutMath.swift new file mode 100644 index 0000000..146b4ab --- /dev/null +++ b/Kanban/UI/Board/LaneLayoutMath.swift @@ -0,0 +1,164 @@ +import CoreGraphics + +/// The board strip's geometry, as pure arithmetic — no view, no window, no state +/// (`LaneLayoutMathTests`). +/// +/// Two rules from 03-board-ui.md meet here, and they are deliberately *different* mechanisms +/// sharing one set of numbers: +/// +/// - **Full visibility** (§ Layout — full visibility): the window's width divides across the lanes' +/// width units, so `standardWidth` is the whole of the resting layout. There is no horizontal +/// scroll and no minimum lane width to honour — enough units in a small window compress every +/// lane, and that is accepted rather than floored. +/// - **The right-edge drag** (§ Lane): a snap between whole unit counts that grows or shrinks the +/// *window* by one standard width per tick, so the other lanes keep their exact pixels. +/// `slotWidth`, `snappedUnits`, `resistedWidth` and `maxUnits` are that interaction's arithmetic, +/// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below, +/// since the behaviour is what was proven, not the code). +/// +/// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane +/// spans any whole number of units ≥ 1, so `allowedRange`'s ceiling is only ever the on-screen fit +/// (`maxUnits`) — there is no `Column.widthRange` equivalent to fold in, and shrinking is always +/// allowed. +enum LaneLayoutMath { + + // MARK: - The resting layout + + /// The 1× (one unit) lane width for a strip `stripWidth` points wide laying out `totalUnits` + /// whole units with `gap` between lanes **and `gap` again outside the first and the last** — + /// hence `totalUnits + 1` gaps: the `totalUnits - 1` interior ones plus the strip's two outer + /// margins. The strip therefore always exactly fills, which is what "every lane is always on + /// screen" means arithmetically (03-board-ui.md § Layout — full visibility). + /// + /// **Floored at 1pt, and at nothing else.** The design is explicit that the degenerate case is + /// accepted, not floored: a minimum lane width would reintroduce horizontal scroll, which was + /// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame + /// is never zero or negative — the pathological input (a strip narrower than its own gaps) must + /// not produce a negative size for SwiftUI to complain about. + static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat { + let count = CGFloat(max(1, totalUnits)) + return max(1, (stripWidth - gap * (count + 1)) / count) + } + + /// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1` + /// interior gaps it swallows. `BoardView`'s per-lane frame is this exact expression, so a + /// snapped slot and a committed lane are the same pixels. + static func slotWidth(units: Int, standard: CGFloat, gap: CGFloat) -> CGFloat { + standard * CGFloat(units) + gap * CGFloat(units - 1) + } + + /// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1 + /// otherwise. + /// + /// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key, a + /// non-numeric value, a fraction, a zero or a negative all arrive here as `.missing` or + /// `.malformed` and render as one unit — the bytes on disk are left exactly as the author wrote + /// them until the user actually changes the width, at which point the Writer replaces them with + /// an integer (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side + /// already refuses anything below 1, and this function is the single place the rest of the UI + /// asks "how many units does this lane span". + static func displayUnits(of lane: Lane) -> Int { + max(1, lane.width.value ?? 1) + } + + /// The unit total a strip of `lanes` divides across — the sum of their display units, never + /// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board. + /// + /// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because + /// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a + /// single trash entry) and so consumes none of the window's width. When the trash quasi-lane + /// arrives it joins this total as one fixed unit — "Show/Hide Trash is a re-divide trigger". + static func totalUnits(of lanes: [Lane]) -> Int { + max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) }) + } + + // MARK: - The drag's snap + + /// The snapped unit count after a live-width change: `currentUnits` unless the live width has + /// moved far enough into "shadow leads" territory for an adjacent slot, in which case it ticks + /// by exactly one. + /// + /// This is an ASYMMETRIC snap, not a midpoint-±-band around a boundary: the shadow (the snapped + /// count, which sizes the visible slot) leads the live edge going up and deliberately lags it + /// coming back down. + /// + /// • Tick UP fires the instant the live edge clears the FAR side of the gap that trails slot + /// `k` — `liveWidth > slotWidth(k) + gap` — which is exactly where the next lane's content + /// would start. The moment the cursor has eaten the whole gap, the bigger slot is already + /// the honest read of what is under it, so the shadow jumps there right away: it never + /// lags, and the live edge can only ever overhang the shadow by at most one gap width, + /// transiently, in the instant just before a tick. + /// • Tick DOWN fires only once the live edge has retreated `reentry` (10pt, fixed) back INTO + /// that same gap — `liveWidth < slotWidth(k - 1) + gap - reentry` — rather than at the + /// mirror image of the tick-up threshold. Shrinking back the instant the edge re-enters the + /// gap it just cleared would flap the window on the smallest jitter right at the crossing; + /// requiring a real 10pt of retreat means the cursor has to mean it. (03-board-ui.md § Lane + /// names exactly this: "shadow snaps at the inter-column gap with 10pt release hysteresis".) + /// + /// `reentry` is the ONLY hysteresis in this design — it exists to give the tick-down threshold + /// room, not to make the two thresholds symmetric. Stability follows from the two thresholds + /// never meeting: for shadow `k` the hold band is `(slotWidth(k - 1) + gap - reentry, + /// slotWidth(k) + gap]`, which stays non-empty as long as `standard` is many times larger than + /// 10pt (true of every lane width a real window produces), so calling this on every drag event + /// never oscillates. + /// + /// Ticks are capped to `allowedRange`, which in Lanework folds in **only** the on-screen fit + /// (`maxUnits`) — there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, … — no + /// cap"), and the uncapped widths beyond the screen's capacity are the stepper's business, not + /// the drag's. + static func snappedUnits( + liveWidth: CGFloat, + currentUnits: Int, + standard: CGFloat, + gap: CGFloat, + allowedRange: ClosedRange, + reentry: CGFloat + ) -> Int { + let currentSlot = slotWidth(units: currentUnits, standard: standard, gap: gap) + if currentUnits < allowedRange.upperBound, liveWidth > currentSlot + gap { + return currentUnits + 1 + } + if currentUnits > allowedRange.lowerBound { + let previousSlot = slotWidth(units: currentUnits - 1, standard: standard, gap: gap) + if liveWidth < previousSlot + gap - reentry { + return currentUnits - 1 + } + } + return currentUnits + } + + /// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot, + /// maxSlot]` the proposed width passes through untouched; beyond either end only `resistance` + /// (0.25) of the overshoot is applied, so the edge visibly resists but still gives, signalling + /// the bound without a hard stop (03-board-ui.md § Lane: "Growth hard-stops at the screen's + /// visible frame, with rubber-band feedback"). The snap tick never follows the width past the + /// bound (see `snappedUnits`' clamp), so this is purely cosmetic give. + static func resistedWidth( + proposed: CGFloat, + minSlot: CGFloat, + maxSlot: CGFloat, + resistance: CGFloat + ) -> CGFloat { + if proposed < minSlot { return minSlot - (minSlot - proposed) * resistance } + if proposed > maxSlot { return maxSlot + (proposed - maxSlot) * resistance } + return proposed + } + + /// The largest unit count that fits on screen: `currentUnits` plus as many whole `step` + /// (= standard + gap) growths as the window has room to expand into before its right edge would + /// pass the screen's visible frame. **Never less than `currentUnits`** — shrinking is always + /// allowed regardless of screen room, including from a window already hanging off the edge + /// (negative headroom). + /// + /// Unlike the pathfinder's twin there is no width ceiling to `min` against: the drag's only + /// bound is the screen, because it is the mechanism that grows the window. Larger widths are + /// reachable through the stepper, which re-divides instead (03-board-ui.md § Lane). + /// + /// Pure so it can be unit-tested; the session computes `headroom` from the live window and its + /// screen and defers the arithmetic here. + static func maxUnits(currentUnits: Int, headroom: CGFloat, step: CGFloat) -> Int { + guard step > 0, headroom.isFinite else { return currentUnits } + let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step)) + return max(currentUnits, currentUnits + extra) + } +} diff --git a/Kanban/UI/Board/LaneResizeHandle.swift b/Kanban/UI/Board/LaneResizeHandle.swift new file mode 100644 index 0000000..8113045 --- /dev/null +++ b/Kanban/UI/Board/LaneResizeHandle.swift @@ -0,0 +1,89 @@ +import AppKit +import SwiftUI + +/// The invisible 12pt grab strip overlaid at a lane's trailing edge that drives a +/// `LaneResizeSession` from a `DragGesture` (03-board-ui.md § Lane, right-edge drag-to-resize). +/// +/// Overlaid so ~8pt hangs into the inter-lane gap and only ~4pt sits over the lane itself (clear of +/// the lane's own vertical scrollbar, which lives at that inner edge). **Every lane gets one, the +/// last included** — the rightmost lane's drag is the one that grows the window into free screen +/// space, which is the interaction's headline case. +/// +/// It carries no drop target of its own, so it never participates in card/lane/file drops, and as an +/// overlay it hit-tests above the lane body's own gestures. +struct LaneResizeHandle: View { + + let store: BoardStore + let session: LaneResizeSession + + let laneID: ItemID + + /// The lane's committed unit count — the k the session starts from. + let committedUnits: Int + + /// The strip's standard (1×) width THIS render; captured as the frozen standard the instant the + /// drag begins. + let standard: CGFloat + let gap: CGFloat + + /// How the session reaches the host window it resizes. A closure rather than a stored + /// `NSWindow?` because the window attaches asynchronously (`WindowAccessor`), and a value + /// captured in an early body evaluation would be `nil` for the window's whole life. + let window: @MainActor () -> NSWindow? + + /// Guards `NSCursor` push/pop balance — a fast cursor can leave the strip mid-drag, and the drag + /// can end on or off it, so pushes and pops must be idempotent to never leave a stuck resize + /// cursor. + @State private var cursorPushed = false + + private let handleWidth: CGFloat = 12 + + /// Rightward shift: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt + /// into the gap (clear of the scrollbar). + private let overhang: CGFloat = 8 + + var body: some View { + Color.clear + .frame(width: handleWidth) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .offset(x: overhang) + .onHover { inside in + if inside { pushCursor() } else { popCursor() } + } + // `.global` (window-fixed) space, NOT the handle's own: a tick moves the handle with its + // lane, but the window's top-left is pinned (right-edge growth), so a window-fixed + // translation stays a faithful physical-cursor delta throughout the drag. + .gesture( + DragGesture(minimumDistance: 2, coordinateSpace: .global) + .onChanged { value in + if !session.isResizing(laneID) { + // m5-drag: a resize and a card/lane move must not run at once — they + // would both mutate the same strip layout. The board's drag state does + // not exist yet; when it does, this is where the `isDragging` guard goes. + pushCursor() + session.begin(laneID: laneID, units: committedUnits, + standard: standard, gap: gap, window: window()) + } + session.update(translation: value.translation.width) + } + .onEnded { _ in + guard session.isResizing(laneID) else { return } + session.end { id, units in store.setLaneWidth(id, units: units) } + popCursor() + } + ) + } + + private func pushCursor() { + guard !cursorPushed else { return } + NSCursor.resizeLeftRight.push() + cursorPushed = true + } + + private func popCursor() { + guard cursorPushed else { return } + NSCursor.pop() + cursorPushed = false + } +} diff --git a/Kanban/UI/Board/LaneResizeSession.swift b/Kanban/UI/Board/LaneResizeSession.swift new file mode 100644 index 0000000..2bfb80e --- /dev/null +++ b/Kanban/UI/Board/LaneResizeSession.swift @@ -0,0 +1,188 @@ +import AppKit +import QuartzCore +import SwiftUI + +/// Window-local state for an in-flight lane resize — the right-edge drag of 03-board-ui.md § Lane. +/// At most one runs per board window at a time; `BoardView` owns it as `@State` and hands it to the +/// lanes and their grab strips. +/// +/// The interaction has three collaborating pieces: `LaneLayoutMath` (the pure geometry), this +/// session (the live state plus the per-tick window resize), and `LaneResizeHandle` (the invisible +/// grab strip that drives it from a `DragGesture`). All three are ported from the pathfinder's +/// proven `ColumnResize.swift`, which is what 03-board-ui.md's "pathfinder behavior, proven" refers +/// to. +/// +/// ### The invariant that makes it feel solid +/// +/// **While a session is active, every OTHER lane keeps its exact pixel width.** That is achieved by +/// freezing the strip's standard (1×) width at drag start and sizing the *window* so that after +/// each snap tick the ordinary viewport-derived formula reproduces that frozen standard exactly — +/// so releasing the drag hands back to the resting layout with no pixel jump. This is the opposite +/// mechanism from the stepper (and its ⌥⌘→/⌥⌘← keyboard face), which never touches the window and +/// re-divides the existing width across the new unit total; the design is explicit that +/// window-growing behaviour belongs to the drag alone. +/// +/// The session owns the two things that must move together on each tick: the SwiftUI unit count +/// (`units`, which drives the shadow slot, the siblings' positions, and the resizing lane's masonry +/// column count) and the host window's width. They animate on matching 0.2s curves — the lane-resize +/// entry in 03-board-ui.md § Motion's snappy-spring vocabulary — so the window edge and the lanes to +/// its right travel as one. +@MainActor +@Observable +final class LaneResizeSession { + + /// The lane being resized; `nil` when idle. Observed — flipping it drives `BoardView`'s + /// frozen-standard override and the shadow slot on and off, and `LaneView`'s column count. + private(set) var laneID: ItemID? + + /// The dragged lane's live rendered width — tracks the cursor continuously (rubber-banded at + /// the ends), so its masonry reflows live between ticks. + private(set) var liveWidth: CGFloat = 0 + + /// The snapped unit count k. Drives the shadow slot width, the layout slot the siblings + /// position off, and the resizing lane's masonry column count. Ticks by ±1 and animates. + private(set) var units: Int = 1 + + /// The strip's standard (1×) width, frozen at drag start. Used for ALL lane widths in + /// `BoardView` while a session is active — the window is animating mid-session, so recomputing + /// the standard from the live viewport width would feed the animation back into the layout and + /// pulse every lane. Read within renders already triggered by the observed properties above, so + /// it need not itself be observed. + @ObservationIgnored private(set) var standard: CGFloat = 1 + + /// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin. + @ObservationIgnored private var gap: CGFloat = 12 + + /// The committed unit count at drag start — the anchor the drag translation is measured from. + @ObservationIgnored private var startUnits: Int = 1 + + /// The largest unit count that fits on screen. The drag's only ceiling: Lanework's `width` has + /// no cap (03-board-ui.md § Lane), so nothing else bounds growth. + @ObservationIgnored private var fittingUnits: Int = 1 + + /// The host window, resized by ±(standard + gap) on each tick. Weak — a window can close, + /// though a resize cannot outlive the gesture that drives it. + @ObservationIgnored private weak var window: NSWindow? + + var isActive: Bool { laneID != nil } + + func isResizing(_ id: ItemID) -> Bool { laneID == id } + + /// The tick-down re-entry distance: how far the live edge must retreat back into a gap it has + /// already crossed before the shadow shrinks (03-board-ui.md § Lane's "10pt release + /// hysteresis" — see `LaneLayoutMath.snappedUnits`). Fixed in points, not proportional to + /// `standard`: it only needs to be comfortably larger than cursor jitter, which 10pt is + /// regardless of lane size. + private let reentry: CGFloat = 10 + + /// The rubber-band overshoot fraction past the end slots. + private let resistance: CGFloat = 0.25 + + /// Unit counts the tick may reach: one up to the on-screen fit. The floor is 1 because a lane + /// spans at least one unit; there is no ceiling but the screen. + private var allowedRange: ClosedRange { + 1...max(1, fittingUnits) + } + + private var minSlot: CGFloat { + LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap) + } + + private var maxSlot: CGFloat { + LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap) + } + + // MARK: - Lifecycle + + /// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far + /// the window can grow on its current screen. + func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) { + self.laneID = laneID + self.startUnits = units + self.units = units + self.standard = standard + self.gap = gap + self.window = window + self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) + self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window) + } + + /// Applies a drag translation (points, measured from the gesture's start): tracks the live width + /// to the cursor with end-resistance, then ticks the snapped unit count to its fixed point and + /// applies the result in one animated step. + /// + /// A single call to `snappedUnits` only ever steps by ±1, but a fast flick can carry the live + /// width across two (or more) thresholds between consecutive gesture events, so it is iterated + /// here until it stops moving — bounded by `allowedRange`'s width, so this never loops more than + /// a couple of times in practice. `tick(to:)` already computes a correct multi-step window-size + /// delta from `units` to the target, so only the FINAL target gets one `tick` call, not one per + /// intermediate step. + func update(translation: CGFloat) { + guard isActive else { return } + let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap) + liveWidth = LaneLayoutMath.resistedWidth( + proposed: startSlot + translation, + minSlot: minSlot, maxSlot: maxSlot, resistance: resistance) + var target = units + while true { + let next = LaneLayoutMath.snappedUnits( + liveWidth: liveWidth, currentUnits: target, + standard: standard, gap: gap, allowedRange: allowedRange, reentry: reentry) + if next == target { break } + target = next + } + if target != units { tick(to: target) } + } + + /// Commits the snapped unit count and dismisses the session. Order matters for a flash-free + /// handoff: write the model FIRST (the session is still active, so the frozen standard still + /// governs and the shadow slot does not budge), THEN clear the session inside the snap animation + /// — at which point `BoardView` reverts to the viewport-derived standard, which the window + /// sizing has kept equal to the frozen one, so the resting layout reproduces the same pixels + /// while the live width animates the last sub-tick of overflow/underfill away. The window and + /// the siblings are already in place; neither is touched here. + /// + /// The commit is a *write*, not a snapshot mutation: it goes to disk through the Writer and + /// comes back as an ordinary reload (02-architecture.md § Layering's one-way flow), so the lane + /// briefly renders at its pre-drag width if the write fails — which is exactly the honesty the + /// banner then explains. + func end(commit: (ItemID, Int) -> Void) { + guard let laneID else { return } + commit(laneID, units) + withAnimation(.snappy(duration: 0.2)) { + self.laneID = nil + self.liveWidth = 0 + } + } + + // MARK: - Tick + + /// A single snapped step: animate the unit count (which resizes the shadow slot, translates the + /// lanes to the right, and reflows the resizing lane's interior columns) and the window's width + /// on matching 0.2s curves. The window grows and shrinks at its RIGHT edge — width changes by + /// ±step with `origin.x` and height held — so everything to the left, including this lane's own + /// left edge and the drag's coordinate origin, stays put. + private func tick(to newUnits: Int) { + let delta = CGFloat(newUnits - units) * (standard + gap) + withAnimation(.snappy(duration: 0.2)) { units = newUnits } + guard let window else { return } + var frame = window.frame + frame.size.width += delta // right-edge growth: origin and height unchanged + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + context.allowsImplicitAnimation = true + window.setFrame(frame, display: true) + } + } + + /// The on-screen fit, from the window's headroom to its screen's visible frame — the hard stop + /// 03-board-ui.md § Lane requires ("Growth hard-stops at the screen's visible frame"). Defers + /// the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current count is + /// the honest answer (growth needs a window to grow). + private static func fittingMaxUnits(currentUnits: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) -> Int { + guard let window, let screen = window.screen ?? NSScreen.main else { return currentUnits } + let headroom = screen.visibleFrame.maxX - window.frame.maxX + return LaneLayoutMath.maxUnits(currentUnits: currentUnits, headroom: headroom, step: standard + gap) + } +} diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift new file mode 100644 index 0000000..713cf3a --- /dev/null +++ b/Kanban/UI/Board/LaneView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// One lane: a header and a vertically scrolling masonry of cards (03-board-ui.md § Lane). +/// +/// **The chrome here is deliberately minimal**, and the design's real lane is a later card: the +/// leading SF Symbol, the new-card button, the drag surface, the context menu (Rename, Style…, the +/// quick-style recents row, the Width stepper, Delete), the colour accent band, inline rename and +/// the search-aware count all arrive with the lane-chrome milestone. What this view owes the +/// full-visibility layout card is the shape — a header, a body that scrolls vertically only, and a +/// masonry whose interior column count is the lane's width — and that is all it does. +struct LaneView: View { + + let lane: Lane + + /// Interior masonry columns — the lane's width units, or the resize session's snapped count + /// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can + /// override it (see `BoardView.laneSlot`). + let columns: Int + + /// Spacing between cards, and between the interior columns. + private let cardSpacing: CGFloat = 8 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + ScrollView(.vertical) { + // Cards stay standard width whatever the lane spans: at a slot width of + // `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly + // `units` columns of `standard` (03-board-ui.md § Layout — full visibility). + MasonryLayout(columns: columns, spacing: cardSpacing) { + ForEach(liveCards) { card in + CardStubView(card: card) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + + /// The header — title, or a quiet "Untitled" where there is none, plus the live card count. + /// + /// Titles are optional at every level (03-board-ui.md § Card face): a missing `title` renders as + /// a secondary-styled placeholder rather than as an empty row. **Replaced wholesale by the + /// lane-chrome card**, which brings the icon, the count badge's real styling, the new-card + /// button, the drag surface and the context menu. + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(lane.title.value ?? "Untitled") + .font(.headline) + .foregroundStyle(lane.title.value == nil ? .secondary : .primary) + .lineLimit(1) + .truncationMode(.tail) + Text("\(liveCards.count)") + .font(.callout) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer(minLength: 0) + } + .padding(.horizontal, 4) + } + + /// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane — the + /// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective + /// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a + /// tombstoned lane at all. + private var liveCards: [Card] { + lane.cards.filter { !$0.isDeleted } + } +} + +// MARK: - Card stub + +/// A card, as a rounded plate with its title — **a stand-in, replaced by the card-face card**, which +/// brings the leading icon, the attachment chip, the selection and cut treatments, inline rename and +/// the sole-selected card's attachment carousel (03-board-ui.md § Card face). +/// +/// It takes whatever width `MasonryLayout` proposes (one interior column = one standard width) and +/// sizes its own height to its content, which is what makes the masonry masonry: a taller card only +/// pushes the cards below it in its own column. +private struct CardStubView: View { + + let card: Card + + var body: some View { + Text(card.title.value ?? "Untitled") + .font(.body) + .foregroundStyle(card.title.value == nil ? .secondary : .primary) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary)) + } +} diff --git a/Kanban/UI/Board/MasonryLayout.swift b/Kanban/UI/Board/MasonryLayout.swift new file mode 100644 index 0000000..6498df1 --- /dev/null +++ b/Kanban/UI/Board/MasonryLayout.swift @@ -0,0 +1,102 @@ +import SwiftUI + +/// Masonry layout for a lane's interior card columns (03-board-ui.md § Layout — full visibility: +/// "a wide lane flows them into as many interior masonry columns as it has units"; § Lane: "masonry +/// grid when wide — settled, the pathfinder's masonry works"). +/// +/// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` → column +/// `i % columns`), and each column stacks its children top-aligned and independently — there is +/// **no row alignment across columns**. With uniform card heights this renders exactly like a +/// row-major grid, but when one card grows taller (the sole selected card's attachment carousel, +/// 03-board-ui.md § Card face) it only pushes the cards below it in its *own* column; the +/// neighbouring columns do not move. +/// +/// A `Layout` rather than an `HStack` of per-column `VStack`s so the caller keeps a single +/// `ForEach` — reflowing cards across columns preserves view identity and animates as positional +/// moves, not as remove/insert transitions. That is what lets the interior reflow *during* a resize +/// drag read as cards sliding rather than blinking. +/// +/// **Vocabulary note.** In Lanework a "lane" is the kanban column; this layout's own interior +/// tracks are "columns". The pathfinder called them lanes, which is why the ported reasoning below +/// reads the way it does. +struct MasonryLayout: Layout { + + /// Number of interior columns (the lane's width units); clamped to ≥ 1. + var columns: Int + + /// Spacing between columns and between stacked cards within a column. + var spacing: CGFloat + + private var columnCount: Int { max(1, columns) } + + private func columnWidth(for totalWidth: CGFloat) -> CGFloat { + max(0, (totalWidth - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount)) + } + + // MARK: - Measurement cache + // + // A lane with several hundred cards is measured a LOT: SwiftUI probes a layout's `sizeThatFits` + // more than once per pass (different proposals), and `placeSubviews` needs every height again + // right after. Unmemoized that is `subviews.count` full subtree measurements per call. + // + // The cache holds one height per subview, keyed by the column width they were measured at: + // column width is the only thing this layout ever proposes (height is always `nil`, so a card's + // height is a pure function of its width and its content). A different column width — a window + // resize, a width change — discards the whole table, which is correct and cheap: it is exactly + // the case where every height really did change. + // + // Staleness is handled by SwiftUI itself: `updateCache` runs whenever the layout's subviews + // change, which is the only way a card's measured height can change at a fixed column width + // (its content changed → its view tree was rebuilt → the layout's content is new). Clearing + // there means the cache never outlives the content it measured. + struct Cache { + var columnWidth: CGFloat = .nan + var heights: [CGFloat] = [] + } + + func makeCache(subviews: Subviews) -> Cache { Cache() } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache = Cache() + } + + /// `subviews[index]`'s height at `column` width, measured once per (content, column width) + /// generation and reused for every later probe and for the placement pass. + private func height(of subviews: Subviews, at index: Int, column: CGFloat, cache: inout Cache) -> CGFloat { + if cache.columnWidth != column || cache.heights.count != subviews.count { + cache.columnWidth = column + cache.heights = [CGFloat](repeating: .nan, count: subviews.count) + } + if !cache.heights[index].isNaN { + return cache.heights[index] + } + let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height + cache.heights[index] = height + return height + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + let width = proposal.width ?? 0 + let column = columnWidth(for: width) + var heights = [CGFloat](repeating: 0, count: columnCount) + for index in subviews.indices { + let height = height(of: subviews, at: index, column: column, cache: &cache) + let target = index % columnCount + heights[target] += height + (heights[target] > 0 ? spacing : 0) + } + return CGSize(width: width, height: heights.max() ?? 0) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { + let column = columnWidth(for: bounds.width) + var y = [CGFloat](repeating: bounds.minY, count: columnCount) + for index in subviews.indices { + let target = index % columnCount + let x = bounds.minX + CGFloat(target) * (column + spacing) + let height = height(of: subviews, at: index, column: column, cache: &cache) + subviews[index].place(at: CGPoint(x: x, y: y[target]), + proposal: ProposedViewSize(width: column, height: height)) + y[target] += height + spacing + } + } +} diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index 2d27c4b..3806e6b 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -50,6 +50,7 @@ private let everyOperation: [WriteOperation] = [ .restore(title: "Fix login"), .purge(title: "Fix login"), .style(title: "Fix login"), + .resize(title: "Fix login"), .importAttachment(filename: "photo.png"), .listAttachments, .renumberChildren, @@ -64,6 +65,7 @@ private let titledOperations: [(with: WriteOperation, without: WriteOperation)] (.restore(title: "Fix login"), .restore(title: nil)), (.purge(title: "Fix login"), .purge(title: nil)), (.style(title: "Fix login"), .style(title: nil)), + (.resize(title: "Fix login"), .resize(title: nil)), ] // MARK: - Ordering diff --git a/KanbanTests/LaneLayoutMathTests.swift b/KanbanTests/LaneLayoutMathTests.swift new file mode 100644 index 0000000..fb2c259 --- /dev/null +++ b/KanbanTests/LaneLayoutMathTests.swift @@ -0,0 +1,305 @@ +import CoreGraphics +import Foundation +import Testing +@testable import Kanban + +/// Unit tests for the board strip's pure geometry (03-board-ui.md § Layout — full visibility and +/// § Lane): the resting division of the window across width units, and the right-edge drag's +/// asymmetric "shadow leads" snap. +/// +/// Fixture throughout the snap suites: a frozen standard of 100 and a gap of 12 — so a `step` +/// (`standard + gap`) is 112, and the slot widths are: +/// 1× = 100 · 2× = 212 · 3× = 324 +/// The tick-up threshold for shadow `k` is `slotWidth(k) + gap` — the far side of the gap trailing +/// that slot — so 1↔2 ticks up at 112 (100 + 12) and 2↔3 at 224 (212 + 12). The tick-down threshold +/// back to `k - 1` is `slotWidth(k - 1) + gap - reentry`, 10pt shy of that same boundary — so 2↔1 +/// ticks down at 102 (112 − 10) and 3↔2 at 214 (224 − 10). +/// +/// Where the pathfinder's twin suite pinned a hard 1…3 width cap, these pin the *screen fit*: in +/// Lanework `allowedRange`'s ceiling is only ever how far the window can grow (`maxUnits`), because +/// the width field itself has no cap. + +private let standard: CGFloat = 100 +private let gap: CGFloat = 12 +private let step: CGFloat = standard + gap // 112 +private let reentry: CGFloat = 10 + +/// A screen that fits three units — the drag's ceiling in most tests below. +private let fitsThree = 1...3 + +private func slot(_ units: Int) -> CGFloat { + LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) +} + +private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange? = nil) -> Int { + LaneLayoutMath.snappedUnits(liveWidth: liveWidth, currentUnits: current, + standard: standard, gap: gap, + allowedRange: range ?? fitsThree, reentry: reentry) +} + +// MARK: - Lane fixtures + +/// Loads a board whose lanes carry the given `width:` frontmatter values (`nil` writes no key), in +/// the order given — `order` keys make the display order the argument order. +/// +/// Built through `BoardLoader` rather than by constructing `Lane` values by hand: `width`'s +/// 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] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try write("---\nschema: 1\ntitle: Board\n---\n", to: root) + for (index, width) in widths.enumerated() { + var frontmatter = "---\nschema: 1\ntitle: Lane \(index)\norder: \(1024 * (index + 1))\n" + if let width { + frontmatter += "width: \(width)\n" + } + frontmatter += "---\n" + let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try write(frontmatter, to: folder) + } + return try BoardLoader.load(boardRoot: root).model.lanes +} + +private func write(_ text: String, to folder: URL) throws { + try Data(text.utf8).write(to: folder.appendingPathComponent("index.md")) +} + +private func lane(width: String?) throws -> Lane { + let loaded = try lanes(widths: [width]) + return try #require(loaded.first) +} + +// MARK: - The resting layout + +@Suite("LaneLayoutMath ▸ the resting division") +struct LaneLayoutStandardWidthTests { + + @Test("The strip divides its width across the units, counting a gap outside each end") + func standardWidthCountsOuterMargins() { + // 1000 = 1 lane + 2 outer gaps: (1000 − 24) / 1. + #expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 1, gap: 12) == 976) + // Three units: 4 gaps (2 interior + 2 outer) → (1000 − 48) / 3. + #expect(abs(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: 12) - 952.0 / 3.0) < 0.0001) + // A wide lane consumes several units of the same division, and the strip still fills + // exactly: 4 units of standard plus the gaps is the whole strip. + let standard = LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 4, gap: 12) + let filled = 4 * standard + 12 * 5 + #expect(abs(filled - 1000) < 0.0001) + } + + @Test("A slot swallows the interior gaps it spans, so lanes and slots are the same pixels") + func slotWidthSwallowsInteriorGaps() { + #expect(slot(1) == 100) + #expect(slot(2) == 212) // 2·100 + 1·12 + #expect(slot(3) == 324) // 3·100 + 2·12 + } + + @Test("Compression is accepted, not floored — only a 1pt floor keeps frames positive") + func degenerateStripsCompressWithoutAMinimum() { + // Twenty units in a small window: each lane is a sliver, and that is the design's answer + // ("the degenerate case is accepted, not floored"), not a scroll bar. + let squeezed = LaneLayoutMath.standardWidth(stripWidth: 400, totalUnits: 20, gap: 12) + #expect(squeezed > 0) + #expect(squeezed < 10) + // Narrower than its own gaps: still positive, because a zero or negative frame is a + // rendering bug rather than a design outcome. + #expect(LaneLayoutMath.standardWidth(stripWidth: 10, totalUnits: 4, gap: 12) == 1) + #expect(LaneLayoutMath.standardWidth(stripWidth: 0, totalUnits: 1, gap: 12) == 1) + } + + @Test("An empty strip still has a divisor") + func zeroUnitsIsTreatedAsOne() { + #expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 0, gap: 12) == 976) + #expect(LaneLayoutMath.totalUnits(of: []) == 1) + } +} + +// MARK: - Reading a lane's units + +@Suite("LaneLayoutMath ▸ display units") +struct LaneDisplayUnitsTests { + + @Test("A valid width spans that many units") + func validWidthSpansItsUnits() throws { + #expect(LaneLayoutMath.displayUnits(of: try lane(width: "3")) == 3) + #expect(LaneLayoutMath.displayUnits(of: try lane(width: "1")) == 1) + // The read side coerces where a sensible reading exists, and the layout follows it. + #expect(LaneLayoutMath.displayUnits(of: try lane(width: "\"2\"")) == 2) + } + + @Test("A missing, malformed, zero or negative width renders as one unit") + func leniencyRendersAsOne() throws { + let missing = try lane(width: nil) + #expect(missing.width.isMissing) + #expect(LaneLayoutMath.displayUnits(of: missing) == 1) + + // Each of these stays `.malformed` on the model — the bytes are preserved, not corrected — + // and renders as 1 (01-storage-format.md § Frontmatter's lenient-field rule). + for raw in ["wide", "0", "-3", "1.5"] { + let lane = try lane(width: raw) + #expect(lane.width.isMalformed, "width: \(raw) should stay malformed rather than coerce") + #expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit") + } + } + + @Test("The strip's unit total is the sum over the lanes it is given") + func totalUnitsSumsDisplayUnits() throws { + let loaded = try lanes(widths: ["2", nil, "3", "banana"]) + #expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 1, 3, 1]) + #expect(LaneLayoutMath.totalUnits(of: loaded) == 7) + #expect(LaneLayoutMath.totalUnits(of: Array(loaded.prefix(2))) == 3) + } + + @Test("There is no upper cap on a lane's width") + func widthIsUncapped() throws { + // 03-board-ui.md § Lane: "1×, 2×, 3×, … — no cap". A wide lane simply takes more of the + // division; nothing clamps it on the way in. + let wide = try lane(width: "40") + #expect(LaneLayoutMath.displayUnits(of: wide) == 40) + #expect(LaneLayoutMath.totalUnits(of: [wide]) == 40) + } +} + +// MARK: - The snap + +@Suite("LaneLayoutMath ▸ the drag's snap") +struct LaneSnapTests { + + // MARK: Tick up — fires just past the far side of the gap, not inside it + + @Test("Ticks up only past the far side of the trailing gap") + func ticksUpOnlyPastTheFarSideOfTheGap() { + // Slot 1 is 100; its trailing gap runs to 112 (100 + 12). The shadow must not lead the live + // edge while the edge is still IN the gap. + #expect(snapped(111.9, current: 1) == 1) // still inside the gap holds + #expect(snapped(112, current: 1) == 1) // exactly at the far edge holds (strict >) + #expect(snapped(112.1, current: 1) == 2) // clearing the gap ticks up immediately + } + + @Test("Ticks up by exactly one per call") + func ticksUpByExactlyOne() { + // A live width well into 3×'s slot still steps only one unit per call (the continuous drag + // calls this on every event, and the session iterates it to a fixed point). + #expect(snapped(1000, current: 1) == 2) + #expect(snapped(1000, current: 2) == 3) + } + + // MARK: Tick down — fires only 10pt back inside the gap just crossed + + @Test("Ticks down only below the 10pt re-entry point") + func ticksDownOnlyBelowTheReentryPoint() { + // Coming back from 2×, the boundary is slot(1) + gap = 112; tick-down requires retreating a + // further 10pt to 102. + #expect(snapped(102.1, current: 2) == 2) // just above the re-entry point holds + #expect(snapped(102, current: 2) == 2) // exactly at the re-entry point holds (strict <) + #expect(snapped(101.9, current: 2) == 1) // clearing the re-entry point ticks down + } + + @Test("Re-entering the gap is not enough to tick down") + func ticksDownDoesNotFireWhileStillInTheGap() { + // Immediately after re-entering the gap (e.g. 110), the edge has NOT yet retreated the full + // 10pt, so the shadow must still hold at 2× — this is the asymmetry: growing was instant, + // shrinking is not. + #expect(snapped(110, current: 2) == 2) + #expect(snapped(103, current: 2) == 2) + } + + // MARK: Hold-band stability from both directions + + @Test("The hold band is stable from both directions") + func holdBandIsStable() { + // The hold band for shadow 2× is (102, 224] — everything strictly between the 1↔2 re-entry + // point and the 2↔3 tick-up threshold holds at 2× with no oscillation. + for width in stride(from: CGFloat(103), through: 223, by: 20) { + #expect(snapped(width, current: 2) == 2, "2× holds at \(width)") + } + } + + @Test("A value that just ticked up does not immediately tick back down") + func noImmediateReversalAfterTickingUp() { + // Crossing 112.1 ticks 1× → 2×. Re-evaluating at that same live width with the NEW unit + // count must hold, not bounce back — 112.1 is comfortably above the 2↔1 re-entry point. + let justTicked = snapped(112.1, current: 1) + #expect(justTicked == 2) + #expect(snapped(112.1, current: justTicked) == 2) + } + + @Test("A value that just ticked down does not immediately tick back up") + func noImmediateReversalAfterTickingDown() { + let justTicked = snapped(101.9, current: 2) + #expect(justTicked == 1) + #expect(snapped(101.9, current: justTicked) == 1) + } + + // MARK: Range clamping + + @Test("The snap never steps past the allowed range") + func neverTicksPastTheAllowedRange() { + #expect(snapped(5000, current: 3) == 3, "the on-screen fit is the ceiling") + #expect(snapped(0, current: 1) == 1, "one unit is the floor") + #expect(snapped(-500, current: 1) == 1) + } + + @Test("The ceiling is the screen fit, and it is the only ceiling") + func snapRespectsTheOnScreenFit() { + // With the fit capping the range at 2×, no live width ticks to 3×. + let fitsTwo = 1...2 + #expect(snapped(1000, current: 2, range: fitsTwo) == 2) + #expect(snapped(5000, current: 2, range: fitsTwo) == 2) + // Below the cap it still ticks normally. + #expect(snapped(200, current: 1, range: fitsTwo) == 2) + // A roomier screen keeps ticking well past the pathfinder's old 3× ceiling — Lanework's + // width has no cap of its own (03-board-ui.md § Lane). + #expect(snapped(5000, current: 3, range: 1...9) == 4) + #expect(snapped(5000, current: 8, range: 1...9) == 9) + } + + // MARK: maxUnits + + @Test("maxUnits turns window headroom into whole growable units") + func maxUnitsFromHeadroom() { + // Two whole steps of headroom (2 × 112 = 224) → grow up to +2. + #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 250, step: step) == 3) + // Just over one step → +1. + #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 120, step: step) == 2) + // Less than a step → no growth room, but shrinking stays allowed. + #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 50, step: step) == 1) + #expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 0, step: step) == 2) + // Never below currentUnits even with a negative headroom (a window already past the visible + // frame) — shrinking is always allowed. + #expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: -300, step: step) == 2) + // No width ceiling to clamp against: a huge screen means a huge fit. The pathfinder capped + // this at 3. + #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 10_000, step: step) == 90) + // A degenerate step is not divided by. + #expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 500, step: 0) == 2) + } + + // MARK: Rubber-band resistance + + @Test("Inside the bounds the live width passes through untouched") + func widthPassesThroughInsideBounds() { + #expect(LaneLayoutMath.resistedWidth(proposed: 200, minSlot: slot(1), + maxSlot: slot(3), resistance: 0.25) == 200) + #expect(LaneLayoutMath.resistedWidth(proposed: slot(1), minSlot: slot(1), + maxSlot: slot(3), resistance: 0.25) == slot(1)) + #expect(LaneLayoutMath.resistedWidth(proposed: slot(3), minSlot: slot(1), + maxSlot: slot(3), resistance: 0.25) == slot(3)) + } + + @Test("Past either bound the edge gives only a quarter of the overshoot") + func widthResistsPastEitherBound() { + // 20 below the 100 floor → 100 − 20·0.25 = 95. + #expect(LaneLayoutMath.resistedWidth(proposed: 80, minSlot: slot(1), + maxSlot: slot(3), resistance: 0.25) == 95) + // 76 above the 324 ceiling → 324 + 76·0.25 = 343. + #expect(LaneLayoutMath.resistedWidth(proposed: 400, minSlot: slot(1), + maxSlot: slot(3), resistance: 0.25) == 343) + } +} diff --git a/KanbanTests/LaneWidthWriteTests.swift b/KanbanTests/LaneWidthWriteTests.swift new file mode 100644 index 0000000..643ced9 --- /dev/null +++ b/KanbanTests/LaneWidthWriteTests.swift @@ -0,0 +1,204 @@ +import Foundation +import Testing +@testable import Kanban + +/// `BoardStore.setLaneWidth` — the one commit point both width mechanisms share (03-board-ui.md § +/// Lane): the right-edge drag's release and the ⌥⌘→/⌥⌘← stepper. +/// +/// These drive a real store over a real temp board and then read the **raw bytes** back, never the +/// app's own read path, because the interesting claims are about the file: the integer that lands, +/// the stamps that follow it, and everything else surviving byte-for-byte. `WriterFixture`, `Ident` +/// and `Item` come from `WriterTestSupport.swift`, as they do for every other write suite. + +// MARK: - Fixtures + +/// A lane index carrying an explicit `width:` — `Item.rich` deliberately has none, and half of what +/// is under test here is what happens to a value that is already there (valid or not). +private func laneText(order: String, title: String, width: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + width: \(width) + project: lanework # agent overlay + created: 2026-01-01T09:00:00Z + modified: 2026-02-02T09:00:00Z + modified-by: claude + --- + \(title) body. + + """ +} + +/// A board with one plain lane (no `width`), one already at 3×, and one whose frontmatter is +/// readable but uneditable. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3")) + try fixture.item(Ident.lane3, Item.uneditable) + return fixture +} + +/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come +/// through a resize byte-for-byte, in order. +private func untouchedLines(_ text: String) -> [Substring] { + text.split(separator: "\n", omittingEmptySubsequences: false).filter { + !$0.hasPrefix("modified") && !$0.hasPrefix("width:") + } +} + +private func loadedLane(_ id: String, in fixture: WriterFixture) throws -> Lane { + let model = try BoardLoader.load(boardRoot: fixture.root).model + return try #require(model.lanes.first { $0.id.rawValue == id }) +} + +// MARK: - Tests + +@MainActor +@Suite("BoardStore ▸ lane width") +struct LaneWidthWriteTests { + + @Test("A width change writes the integer, stamps modified, clears modified-by, and touches nothing else") + func writesTheIntegerAndStamps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexText(Ident.lane1) + + store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 3) + + let after = try fixture.indexText(Ident.lane1) + #expect(after.contains("width: 3")) + #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution") + #expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh") + + // Everything the write does not own survives exactly, in order: the unknown key with its + // inline comment, the reserved `labels`, the original `created`, and the body. + #expect(untouchedLines(after) == untouchedLines(before)) + + let lane = try loadedLane(Ident.lane1, in: fixture) + #expect(lane.width == .valid(3)) + #expect(lane.modifiedBy.isMissing) + let modified = try #require(lane.modified.value) + #expect(abs(modified.timeIntervalSinceNow) < 60, "modified is stamped with the time of the write") + + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A width change replaces whatever was there — a malformed value included") + func replacesAMalformedValue() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "wide")) + let store = try BoardStore(rootURL: fixture.root) + + // `width: wide` is lenient on the read side — it renders as one unit with the bytes left + // alone (01-storage-format.md § Frontmatter). An explicit change is the user overwriting + // it, so the Writer puts a plain integer in its place. + #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.width == .malformed(raw: "wide")) + store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 2) + + let after = try fixture.indexText(Ident.lane1) + #expect(after.contains("width: 2")) + #expect(!after.contains("wide")) + #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2)) + } + + @Test("A count below one clamps to one rather than writing a value the read side would reject") + func clampsBelowOne() throws { + let fixture = try makeBoard() + 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. + store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 0) + #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1)) + + 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)) + } + + @Test("A lane that is not in the snapshot is a no-op") + func unknownLaneIsANoOp() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane1) + + // The lane vanished under the gesture (or never existed): the reload that removed it is the + // authority, and inventing a file here would be the app disagreeing with disk. + store.setLaneWidth(ItemID(rawValue: Ident.indexless), units: 3) + + #expect(!fixture.exists(Ident.indexless)) + #expect(try fixture.indexData(Ident.lane1) == before) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("Setting the width a lane already displays writes nothing at all") + func unchangedWidthIsANoOp() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let atThree = try fixture.indexData(Ident.lane2) + let atOne = try fixture.indexData(Ident.lane1) + + // A drag that ends where it started, and a stepper pressed against its floor: neither may + // stamp `modified` or (on a git board) mint a commit. + store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 3) + #expect(try fixture.indexData(Ident.lane2) == atThree) + + // Lane one has no `width` key at all, so it *displays* one unit — setting one unit is the + // same no-op, and must not materialize the key. + store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1) + #expect(try fixture.indexData(Ident.lane1) == atOne) + #expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either") + } + + @Test("A readable-but-uneditable lane refuses the write, banners it, and keeps its bytes") + func uneditableLaneBannersAndChangesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane3) + + store.setLaneWidth(ItemID(rawValue: Ident.lane3), units: 2) + + // The refusal is the settled readable-but-uneditable rule: the file loads and renders, but + // a surgical edit of it cannot be expressed, so nothing is written. + #expect(try fixture.indexData(Ident.lane3) == before) + #expect(try fixture.indexText(Ident.lane3) == Item.uneditable) + + // `performWrite` posts before it rethrows, and `setLaneWidth` swallows the rethrow — the + // banner is the only thing that says the gesture did not happen, so it must be there. + #expect(store.banners.oneShots.count == 1) + let posted = try #require(store.banners.oneShots.first) + #expect(posted.error.operation == .resize(title: "Odd"), + "the title is enriched off the document the write refused") + #expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't resize 'Odd' — ")) + #expect(store.bannerRows.contains { $0.id == "one-shot:\(posted.id.uuidString)" }) + } + + @Test("A read-only board refuses the write without a second banner") + func readOnlyBoardRefusesQuietly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + let before = try fixture.indexData(Ident.lane1) + + store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 4) + + // The lock row is already standing; a refusal per gesture would bury it under echoes of + // itself (02-architecture.md § Write-failure surfacing). + #expect(try fixture.indexData(Ident.lane1) == before) + #expect(store.banners.oneShots.isEmpty) + #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) + } +}