diff --git a/DRAG-REORDER.md b/DRAG-REORDER.md index 682d701..dd1fc1e 100644 --- a/DRAG-REORDER.md +++ b/DRAG-REORDER.md @@ -191,3 +191,5 @@ The board strip itself has nothing to autoscroll: every lane is always visible ( ## Adjacent interaction: the lane resize drag (not a drag session) Dragging a lane's trailing edge resizes it between whole unit counts — see `LaneResizeSession.swift` and `LaneLayoutMath`. It deliberately lives OUTSIDE the drag-session machinery above: the handle is a plain `DragGesture`, carries no drop target, and refuses to start while a card/lane session is in flight. Its layout trick inverts this document's premise: instead of reflowing siblings around a shadow, the session freezes the strip's standard width and resizes the *window* by one standard-plus-gap per snap tick, so every other lane keeps its exact pixels and the release settles the dragged lane into a slot that's already in place. Snapping is asymmetric ("shadow leads"): tick up the instant the live edge clears the inter-lane gap; tick down only after retreating 10pt back into it — the 10pt re-entry band is the only hysteresis, cousin to the dead-region hold above. + +It does borrow one thing from the machinery it lives outside: **the release gets its own hold** (`LaneWidthHold`). The release writes the new unit count, and that write takes the one-way flow's round trip, so a session that cleared there would hand the strip back to a standard divided from the already-grown window by the snapshot's still-stale unit total — a visible two-step, once into the wrong arrangement and again when the echo lands. So the frozen standard and the *written* unit count keep governing until the snapshot carries that width, with the same timeout-and-dissolve guarantee as the committed-overlay hold. The condition is the only real difference, and it follows from the subject: that hold stands in for an arrangement and any landing on the board retires it, while this one stands in for a value and only a landing that actually carries it will do. diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 91218ba..ae34ea6 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1518,7 +1518,14 @@ public final class BoardStore: HealHost { /// 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) { + /// + /// **Returns whether bytes reached disk**, which is the same question as "is an echo reload + /// coming": all three do-nothing paths above answer `false`, and so does a refusal. The edge + /// drag's release hold reads it — an overlay that outlived a write nobody made would draw a + /// width the board never got (`LaneResizeSession.end`). Discardable because every other caller + /// has nothing to do with the answer. + @discardableResult + public func setLaneWidth(_ id: ItemID, units: Int) -> Bool { writeLaneWidths([(id, max(1, units))]) } @@ -1529,11 +1536,12 @@ public final class BoardStore: HealHost { /// /// Lanes already at the one-unit floor simply hold there on a decrease — the batch is not /// refused because one member has nowhere to go, matching the style batch's silent-skip shape. - public func stepLaneWidths(_ ids: Set, by delta: Int) { + @discardableResult + public func stepLaneWidths(_ ids: Set, by delta: Int) -> Bool { let changes: [(ItemID, Int)] = snapshot.lanes .filter { ids.contains($0.id) } .map { ($0.id, max(1, LaneLayoutMath.displayUnits(of: $0) + delta)) } - writeLaneWidths(changes) + return writeLaneWidths(changes) } /// The one commit point every width mechanism shares — the edge drag, the context-menu stepper, @@ -1545,14 +1553,16 @@ public final class BoardStore: HealHost { /// `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)]) { + /// + /// Returns whether the bracket actually wrote — see `setLaneWidth` for who asks and why. + private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) -> Bool { let writes: [(folder: URL, units: Int, prior: FieldValue, title: String?)] = 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, lane.width, lane.title.value) } - guard !writes.isEmpty else { return } + guard !writes.isEmpty else { return false } // 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 @@ -1563,7 +1573,7 @@ public final class BoardStore: HealHost { try Self.setWidth(write.units, at: write.folder) } } - guard landed != nil else { return } + guard landed != nil else { return false } // resize → prior width (13-native-undo.md ▸ Rules). One step whatever the batch's size — the // menu items step every selected lane in one gesture, and one gesture is one step. @@ -1589,6 +1599,7 @@ public final class BoardStore: HealHost { try Self.setWidth(write.units, at: write.folder) } } + return true } /// The width write itself, spelled once so the gesture and its redo cannot drift apart on the diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 4bb7634..b672ca8 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -172,6 +172,17 @@ struct BoardView: View { .onChange(of: store.snapshotGeneration) { _, generation in appModel.dragSession.handOff(root: store.rootKey, generation: generation) } + // **The lane-resize release hold's hand-off** (`LaneWidthHold`), the same shape one rung + // narrower: that overlay stands in for an *arrangement* and any landing retires it, this one + // for a *width* and only a landing that carries it will do — an unrelated reload arriving + // first must not hand the strip back to a snapshot that still says the old unit count. + // + // **`landedReloads`, not `snapshotGeneration`**: the question is "is the width current", + // asked on every walk that landed, so a reload whose tree came back value-equal — which + // skips the assignment and moves no generation — still gets to answer it. + .onChange(of: store.landedReloads) { _, _ in + resize.handOff(against: store.snapshot.lanes) + } .trashPurgeAlert(store: store, confirmations: confirmations) // The board's own anchor for the Style… popover — the surface a board-targeted session hangs // off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor). @@ -435,8 +446,12 @@ struct BoardView: View { /// cross-board case alone, ⌥ being ignored by a within-board lane drag. @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) + // **The session governs through the release**, not just the drag: after the release its + // frozen standard and the unit count it *wrote* keep answering here until the snapshot + // carries that width back (`LaneWidthHold`). Reading `lane.width` in that window would draw + // the pre-drag layout for a round trip. + let resizing = resize.governs(lane.id) + let units = resize.displayUnits(of: lane) let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing) ZStack(alignment: .topLeading) { if resizing { @@ -474,7 +489,7 @@ struct BoardView: View { store: store, session: resize, laneID: lane.id, - committedUnits: LaneLayoutMath.displayUnits(of: lane), + committedUnits: units, standard: standard, gap: spacing, window: window @@ -561,6 +576,12 @@ struct BoardView: View { /// value equals what the formula yields once the session ends — the handoff is seamless (see /// `LaneResizeSession`). /// + /// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that + /// makes the handoff seamless is between the frozen standard and the formula run over the *new* + /// unit total, and the write takes a watcher round trip to put that total in the snapshot; + /// dividing the already-grown window by the stale total in between is exactly the two-step the + /// hold exists to remove. + /// /// Otherwise it is the ordinary division, over three contributions: /// /// - the **live lanes**' units. A lane in flight is still a lane on the board, so a within-board diff --git a/Kanban/UI/Board/LaneResizeHandle.swift b/Kanban/UI/Board/LaneResizeHandle.swift index 6ecf8c9..656d6b2 100644 --- a/Kanban/UI/Board/LaneResizeHandle.swift +++ b/Kanban/UI/Board/LaneResizeHandle.swift @@ -18,7 +18,9 @@ struct LaneResizeHandle: View { let laneID: ItemID - /// The lane's committed unit count — the k the session starts from. + /// The lane's committed unit count — the k the session starts from. What is **on screen**, which + /// while a release hold stands is the width that release wrote rather than the stale snapshot's + /// (`LaneResizeSession.displayUnits(of:)`). let committedUnits: Int /// The strip's standard (1×) width THIS render; captured as the frozen standard the instant the @@ -60,7 +62,10 @@ struct LaneResizeHandle: View { .gesture( DragGesture(minimumDistance: 2, coordinateSpace: .global) .onChanged { value in - if !session.isResizing(laneID) { + // `isDragging`, not `governs`: a release hold on this very lane is still + // governing the strip, and a new drag begins over it rather than being + // mistaken for the old one still running (`LaneResizeSession.begin`). + if !session.isDragging(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. @@ -71,7 +76,7 @@ struct LaneResizeHandle: View { session.update(translation: value.translation.width) } .onEnded { _ in - guard session.isResizing(laneID) else { return } + guard session.isDragging(laneID) else { return } session.end { id, units in store.setLaneWidth(id, units: units) } popCursor() } diff --git a/Kanban/UI/Board/LaneResizeSession.swift b/Kanban/UI/Board/LaneResizeSession.swift index d69a781..8279da8 100644 --- a/Kanban/UI/Board/LaneResizeSession.swift +++ b/Kanban/UI/Board/LaneResizeSession.swift @@ -2,6 +2,56 @@ import AppKit import QuartzCore import SwiftUI +// MARK: - The release hold + +/// The hand-off condition for **the lane-resize release hold**, as a value so the state machine is +/// testable without a filesystem. +/// +/// The release *writes* the lane's new width, and that write rides the one-way flow — Writer → disk +/// → watcher → reload (02-architecture.md § Layering). Between the release and that echo the +/// snapshot still says the OLD unit count while the window has already grown, so a session that +/// cleared at release would hand the strip to `LaneLayoutMath.standardWidth` dividing the *new* +/// window width by the *stale* unit total: every lane takes a proportionally wrong width for a beat +/// — the resized one snapping back toward its old share — and the board then jumps a second time +/// when the echo lands. This is the drag's committed-overlay hold (DRAG-REORDER.md § The +/// committed-overlay hold) applied to the resize, which lives outside the drag machinery and so +/// needs its own: the frozen standard and the written unit count keep governing until the snapshot +/// carries the write. +/// +/// A deliberately separate type from `CommittedHold` rather than a reuse of it. The two share only +/// their shape: that hold's subject is a *rearrangement*, retired by any snapshot landing on the +/// board, and this one's is a *value*, which can only be retired by a snapshot that actually carries +/// it — an unrelated reload landing first must NOT release this hold, or the two-step is back. +struct LaneWidthHold: Equatable, Sendable { + + /// The lane the release wrote. + var laneID: ItemID + + /// The unit count it wrote — what the landed snapshot has to agree with. + var units: Int + + /// How long the hold may stand with no echo arriving. `CommittedHold.timeout`'s figure and its + /// reasoning: comfortably longer than a write plus a watcher round trip, short enough that a + /// write nobody echoes does not leave the board drawing a width it never got. + static let timeout: Duration = .milliseconds(1500) + + /// Whether a snapshot holding `lanes` retires this hold. + /// + /// The width is read exactly as the board reads it (`LaneLayoutMath.displayUnits`), which is the + /// whole of the one-unit case: **a width landing on 1 removes the `width` key** + /// (`BoardStore.writeLaneWidths`, the remove-at-default family), so the echo carrying that write + /// has no key to compare against — only the display reading sees the 1 that was asked for. + /// + /// A lane no longer in the snapshot retires it too: it was deleted or moved away under the + /// gesture, no echo naming it is ever coming, and the snapshot is the authority. + func isRetired(by lanes: [Lane]) -> Bool { + guard let lane = lanes.first(where: { $0.id == laneID }) else { return true } + return LaneLayoutMath.displayUnits(of: lane) == units + } +} + +// MARK: - The session + /// 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. @@ -27,20 +77,36 @@ import SwiftUI /// column count) and the host window's width. They animate on matching curves — `Motion.laneResize` /// and `Motion.laneResizeWindowDuration`, the two faces of 03-board-ui.md § Motion's lane-resize /// entry — so the window edge and the lanes to its right travel as one. +/// +/// ### Three phases, not two +/// +/// *Idle* (`laneID == nil`), *dragging* (`isDragging`), and — after the release — *holding*, which +/// is the same governance standing over a write that has not echoed back yet (`LaneWidthHold`). The +/// layout reads one question throughout, `governs(_:)`, so the strip cannot tell the last two +/// apart; only the handle's gesture does, since a hold is not something a mouse is still driving. @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. + /// The lane this session governs — being dragged, or holding its written width until the echo; + /// `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 release hold, or `nil` while the drag is still in flight (or the session idle). + /// See `LaneWidthHold`. + private(set) var hold: LaneWidthHold? + /// 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. + /// + /// It stops ticking at the release and becomes **the written count** — the number the hold is + /// waiting for the snapshot to agree with, and the one the strip lays the lane out at until it + /// does. private(set) var units: Int = 1 /// The strip's standard (1×) width, frozen at drag start. Used for ALL lane widths in @@ -75,9 +141,40 @@ final class LaneResizeSession { /// (`Motion.prefersReducedMotion`). @ObservationIgnored private var reducedMotion = false + @ObservationIgnored private var holdTimeoutTask: Task? + + /// How long this session's hold may stand with no echo arriving — `LaneWidthHold.timeout`, and a + /// `var` for one reason only: the dissolve is a `Task` sleeping on the main actor, and a test + /// that had to wait the real figure out would be 1.5 s of wall clock in the suite + /// (`LaneResizeHoldTests`). Nothing in the app writes it. + @ObservationIgnored var holdTimeout: Duration = LaneWidthHold.timeout + + /// Whether the session governs the strip's layout at all — the frozen standard is in force for a + /// drag and for the hold that follows it alike. var isActive: Bool { laneID != nil } - func isResizing(_ id: ItemID) -> Bool { laneID == id } + /// Whether a gesture is still driving it. False during the hold, which no mouse is holding. + var isDragging: Bool { laneID != nil && hold == nil } + + /// Whether the release has **settled**: the width is written and the layout it produced is being + /// held until the echo reload lands (`LaneWidthHold`). + var isSettled: Bool { hold != nil } + + /// Whether this session — dragging or holding — is the authority on `id`'s width. + func governs(_ id: ItemID) -> Bool { laneID == id } + + /// Whether `id` is the lane a gesture is currently dragging. + func isDragging(_ id: ItemID) -> Bool { laneID == id && hold == nil } + + /// The unit count `lane` spans **on screen right now**: this session's while it governs that + /// lane — the live snapped count mid-drag, the written count mid-hold — and the snapshot's + /// otherwise. + /// + /// The mid-hold answer is the point: for that stretch the snapshot's `width` is the *old* one, + /// and every reader that took it at face value would draw the pre-drag layout. + func displayUnits(of lane: Lane) -> Int { + governs(lane.id) ? units : LaneLayoutMath.displayUnits(of: lane) + } /// 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 @@ -107,7 +204,13 @@ final class LaneResizeSession { /// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far /// the window can grow on its current screen. + /// + /// **A standing hold is superseded, not waited out** — a second drag is the user's newer answer + /// about the same strip, and its own release will arm the hold that matters. `units` is the + /// anchor the caller reads off the screen (`displayUnits(of:)`), so a drag begun mid-hold starts + /// from the width that is showing rather than from the stale snapshot's. func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) { + endHold() self.laneID = laneID self.startUnits = units self.units = units @@ -130,7 +233,7 @@ final class LaneResizeSession { /// 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 } + guard isDragging else { return } let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap) liveWidth = LaneLayoutMath.resistedWidth( proposed: startSlot + translation, @@ -146,25 +249,83 @@ final class LaneResizeSession { 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. + /// Commits the snapped unit count and enters the **release hold**. /// /// 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(Motion.laneResize(reduced: reducedMotion)) { - self.laneID = nil - self.liveWidth = 0 + /// comes back as an ordinary reload (02-architecture.md § Layering's one-way flow). Clearing the + /// session here — as this used to — hands the strip back to the viewport-derived standard one + /// whole round trip too early: the window is already at its new width but the snapshot still + /// carries the old unit total, so the division is wrong for every lane and the board visibly + /// two-steps, once into that stale arrangement and again when the echo lands. So the session + /// keeps governing (`LaneWidthHold`), and all that happens here is the last sub-tick of + /// overflow/underfill animating away against the width the release actually wrote. + /// + /// `commit` answers **whether bytes reached disk** (`BoardStore.setLaneWidth`). A `false` — a + /// refusal the banner is already explaining, or a drag that ended on the width it started from — + /// means no echo is ever coming, so the hold is never armed and the snapshot takes the layout + /// back immediately: the lane returns to its pre-drag width, which is the truth. + func end(commit: (ItemID, Int) -> Bool) { + guard let laneID, hold == nil else { return } + let committed = units + guard commit(laneID, committed) else { return dissolve() } + + let hold = LaneWidthHold(laneID: laneID, units: committed) + self.hold = hold + let timeout = holdTimeout + holdTimeoutTask?.cancel() + holdTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: timeout) + guard !Task.isCancelled, let self else { return } + self.expire(hold) } + + // The settle: the live width, which has been tracking the cursor, comes to rest on the slot + // the written count names — measured against the FROZEN standard, the one still governing. + withAnimation(Motion.laneResize(reduced: reducedMotion)) { + liveWidth = LaneLayoutMath.slotWidth(units: committed, standard: standard, gap: gap) + } + } + + /// **The hand-off**: a walk landed, so a hold whose width the snapshot now agrees with dissolves + /// and the snapshot is the authority again. + /// + /// Called from `BoardView`'s watch on `BoardStore.landedReloads` — *every* landed walk, not just + /// the ones that assigned. The condition is "the width the write named is what the current + /// snapshot says", never "a counter moved": a reload whose tree came back value-equal skips the + /// assignment and does not bump `snapshotGeneration` (02-architecture.md § Live-reload + /// resilience), and a hold keyed to that bump would sit out its whole deadline over a snapshot + /// that already agreed with it. Retiring on the *value* is right whichever counter moved. + func handOff(against lanes: [Lane]) { + guard let hold, hold.isRetired(by: lanes) else { return } + dissolve() + } + + /// The deadline's own body, spelled as a method rather than inlined in the `Task` so the + /// dissolve can be pinned directly as well as through the clock (`LaneResizeHoldTests`). + /// + /// A hold that has already handed off — or been superseded by a second drag's — is not this + /// one's to end. + func expire(_ hold: LaneWidthHold) { + guard self.hold == hold else { return } + dissolve() + } + + /// Governance ends: the strip goes back to the viewport-derived standard and the snapshot's unit + /// counts. Animated on the resize curve because the two are only *meant* to be the same pixels — + /// on the echo path they are, and the animation shows nothing; on the timeout path they are not, + /// and what moves is the resize un-happening. + private func dissolve() { + endHold() + withAnimation(Motion.laneResize(reduced: reducedMotion)) { + laneID = nil + liveWidth = 0 + } + } + + private func endHold() { + hold = nil + holdTimeoutTask?.cancel() + holdTimeoutTask = nil } // MARK: - Tick diff --git a/KanbanTests/LaneResizeHoldTests.swift b/KanbanTests/LaneResizeHoldTests.swift new file mode 100644 index 0000000..12e5edc --- /dev/null +++ b/KanbanTests/LaneResizeHoldTests.swift @@ -0,0 +1,359 @@ +import CoreGraphics +import Foundation +import Testing +@testable import Kanban + +/// **The lane-resize release hold** (`LaneWidthHold`, `LaneResizeSession.end`): the bridge across +/// the one-way flow's round trip between a release and the reload that carries its write. +/// +/// The bug these pin is a visible two-step. The drag grows the *window* one standard-plus-gap per +/// tick and freezes the strip's standard so the siblings never move; the release then writes the new +/// unit count, which takes a Writer → disk → watcher → reload round trip to reach the snapshot. A +/// session that cleared at the release handed the strip back to +/// `LaneLayoutMath.standardWidth(stripWidth:totalUnits:gap:)` for that whole stretch — the *grown* +/// window divided by the *stale* unit total — so every lane took a proportionally wrong width and +/// the board jumped a second time when the echo landed. +/// +/// Fixture geometry is `LaneLayoutMathTests`': a frozen standard of 100 and a gap of 12, so the slot +/// widths are 1× = 100 · 2× = 212 · 3× = 324, and the tick-down thresholds are 214 (3→2) and 102 +/// (2→1). Every drag here **shrinks**, because with no `NSWindow` to measure the on-screen fit is +/// the count the drag started from (`LaneResizeSession.fittingMaxUnits`) and growth has no room — +/// direction is nothing to the hold's state machine, which is what is under test. + +private let standard: CGFloat = 100 +private let gap: CGFloat = 12 + +/// Enough leftward translation from a 3× start to land on 2×, and on 1×: 324 − 120 = 204, under the +/// 214 threshold and over the 102 one; 324 − 240 rubber-bands to 96, under both. +private let toTwoUnits: CGFloat = -120 +private let toOneUnit: CGFloat = -240 + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) + +private func laneText(order: String, title: String, width: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + width: \(width) + created: 2026-01-01T09:00:00Z + --- + \(title) body. + + """ +} + +/// One plain lane (no `width`, so 1×) and one at 3× — the one every drag below shrinks. +@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")) + return fixture +} + +@MainActor +@Suite("The lane-resize release hold") +struct LaneResizeHoldTests { + + // MARK: - Harness + + /// One walk through the store's inbound door — the echo, as the watcher delivers it. + private func reload(_ store: BoardStore, _ origin: WatchOrigin = .appMediated) async { + store.handleWatcherEvent(.treeChanged(origin)) + await store.awaitQuiescence() + } + + /// A session mid-drag on lane two, `translation` points to the left of its 3× start. + private func dragging(_ translation: CGFloat) -> LaneResizeSession { + let session = LaneResizeSession() + session.begin(laneID: lane2, units: 3, standard: standard, gap: gap, window: nil) + session.update(translation: translation) + return session + } + + /// A session that has released onto `store` and is holding the width it wrote. + private func settled(on store: BoardStore, translation: CGFloat = toTwoUnits) -> LaneResizeSession { + let session = dragging(translation) + session.end { id, units in store.setLaneWidth(id, units: units) } + return session + } + + private func lane(_ id: String, in store: BoardStore) throws -> Lane { + try #require(store.snapshot.lanes.first { $0.id.rawValue == id }) + } + + /// Polls for `condition`, because the deadline's dissolve is a `Task` on this very actor: the + /// test has to yield for it to run at all. Bounded, so a dissolve that never comes fails rather + /// than hangs. + private func settles(_ condition: () -> Bool) async -> Bool { + for _ in 0..<200 { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return condition() + } + + // MARK: - Entering the hold + + @Test("The release holds instead of clearing — the frozen standard and the written units keep governing") + func releaseEntersTheHold() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + let session = settled(on: store) + + #expect(session.isSettled) + #expect(session.hold == LaneWidthHold(laneID: lane2, units: 2)) + // Still governing — which is the whole fix. `isActive` is what `BoardView.standardWidth` + // reads to keep the frozen standard in force, and `governs` is what the lane slot reads. + #expect(session.isActive) + #expect(session.governs(lane2)) + #expect(session.standard == standard) + // …but no longer *dragging*: no mouse is holding this. + #expect(!session.isDragging) + #expect(!session.isDragging(lane2)) + + // The live width comes to rest on the written count's slot, measured against the frozen + // standard: the last sub-tick of overflow animates away and nothing else moves. + #expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) + + // The snapshot is a round trip behind, and this is precisely the window in which reading it + // would draw the pre-drag layout. + let stale = try lane(Ident.lane2, in: store) + #expect(LaneLayoutMath.displayUnits(of: stale) == 3) + #expect(session.displayUnits(of: stale) == 2, "the written count answers, not the stale one") + #expect(session.displayUnits(of: try lane(Ident.lane1, in: store)) == 1, + "a lane the session does not govern still reads from the snapshot") + } + + @Test("A drag that ends where it started writes nothing and holds nothing") + func anUnchangedReleaseNeverArms() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // 3× released at 3×: `setLaneWidth`'s unchanged-units guard refuses it, so there is no write, + // no reload and therefore no echo — an overlay armed here would only ever time out. + let session = dragging(0) + session.end { id, units in store.setLaneWidth(id, units: units) } + + #expect(!session.isSettled) + #expect(!session.isActive) + #expect(session.liveWidth == 0) + } + + @Test("A refused write dissolves the session outright — the hold must not outlive it") + func aRefusedWriteNeverArms() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + + let session = settled(on: store) + + // Nothing reached disk, so nothing is coming back: the snapshot takes the layout straight + // back and the lane returns to its pre-drag width, which is the truth (and the banner row is + // already saying why). + #expect(!session.isSettled) + #expect(!session.isActive) + #expect(try lane(Ident.lane2, in: store).width == .valid(3)) + } + + // MARK: - The hand-off + + @Test("Only a snapshot carrying the written width releases the hold") + func theEchoReleasesIt() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = settled(on: store) + + session.handOff(against: store.snapshot.lanes) + #expect(session.isSettled, "a snapshot that still says the old width is not the echo") + + await reload(store) + #expect(try lane(Ident.lane2, in: store).width == .valid(2)) + #expect(store.snapshotGeneration == 1) + + session.handOff(against: store.snapshot.lanes) + #expect(!session.isSettled) + #expect(!session.isActive) + #expect(session.liveWidth == 0) + } + + @Test("A reload that does not carry the written width leaves the hold standing, whatever else moved") + func anUnrelatedReloadDoesNotRelease() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = settled(on: store) + + // Somebody else got there first: another lane renamed, and this lane's width put back where + // it was. The walk lands and moves the generation — which is why this hold is retired by a + // *value* and not by a counter, unlike the drag's `CommittedHold`. Handing the strip back on + // the bump alone would divide the already-grown window by the unit total the board still + // has, which is the two-step the hold exists to remove. + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Renamed")) + try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3")) + await reload(store, .foreign) + #expect(store.snapshotGeneration == 1) + #expect(try lane(Ident.lane2, in: store).width == .valid(3)) + + session.handOff(against: store.snapshot.lanes) + #expect(session.isSettled) + #expect(session.governs(lane2)) + + // And the deadline is what ends it, since the width it named is never coming. + session.expire(try #require(session.hold)) + #expect(!session.isActive) + } + + @Test("A landed walk that assigns nothing still releases a hold the current snapshot satisfies") + func aValueEqualWalkReleasesIt() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = settled(on: store) + + // The echo lands and is applied, but nobody asks the hold about it yet; then a second walk + // lands over the same tree. That one comes back value-equal, so it skips the assignment and + // moves no `snapshotGeneration` — a hold watching for a *bump* would sit out its whole + // deadline over a snapshot that already agrees with it. + await reload(store) + await reload(store) + #expect(store.landedReloads == 2) + #expect(store.snapshotGeneration == 1, "the second walk had nothing to assign") + + session.handOff(against: store.snapshot.lanes) + #expect(!session.isSettled) + #expect(!session.isActive) + } + + @Test("A width landing on one is echoed by an ABSENT key, and that releases the hold") + func theOneUnitEchoIsAnAbsentKey() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + let session = settled(on: store, translation: toOneUnit) + #expect(session.hold == LaneWidthHold(laneID: lane2, units: 1)) + + await reload(store) + // The remove-at-default rule: there is no `width: 1` on disk to compare against, so the + // condition can only ever be the *display* reading of an absent key. + #expect(try lane(Ident.lane2, in: store).width.isMissing) + + session.handOff(against: store.snapshot.lanes) + #expect(!session.isSettled) + #expect(!session.isActive) + } + + @Test("A lane that vanished under the gesture retires the hold — the snapshot is the authority") + func aVanishedLaneRetiresIt() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = settled(on: store) + + session.handOff(against: store.snapshot.lanes.filter { $0.id != lane2 }) + + #expect(!session.isSettled) + #expect(!session.isActive) + } + + /// The condition as a value, away from the session — both traps in one place. + @Test("The hold's condition reads the width exactly as the board does") + func theConditionIsTheDisplayReading() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let atThree = try lane(Ident.lane2, in: store) + let keyless = try lane(Ident.lane1, in: store) + + #expect(!LaneWidthHold(laneID: lane2, units: 2).isRetired(by: [atThree])) + #expect(LaneWidthHold(laneID: lane2, units: 3).isRetired(by: [atThree])) + // A lane with no `width` key at all displays one unit, which is what a 1× write leaves + // behind — and is therefore the echo of one. + #expect(LaneWidthHold(laneID: lane1, units: 1).isRetired(by: [keyless])) + #expect(!LaneWidthHold(laneID: lane1, units: 2).isRetired(by: [keyless])) + #expect(LaneWidthHold(laneID: lane2, units: 2).isRetired(by: [])) + } + + // MARK: - The deadline + + @Test("A hold with no echo coming times out, and the snapshot takes the layout back") + func theDeadlineDissolvesIt() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + let session = dragging(toTwoUnits) + // The seam: the real figure is `LaneWidthHold.timeout`, and waiting it out would be 1.5 s of + // wall clock in the suite for a claim about the dissolve rather than about the clock. + session.holdTimeout = .milliseconds(20) + session.end { id, units in store.setLaneWidth(id, units: units) } + #expect(session.isSettled) + + #expect(await settles { !session.isSettled }, "a hold with no hand-off coming must dissolve") + #expect(!session.isActive) + #expect(session.liveWidth == 0) + } + + @Test("The dissolve ends the hold it was armed for, and no other") + func theDissolveEndsOnlyItsOwnHold() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = settled(on: store) + let hold = try #require(session.hold) + + session.expire(LaneWidthHold(laneID: lane1, units: 9)) + #expect(session.isSettled, "a hold this session is not holding is not this session's to end") + + session.expire(hold) + #expect(!session.isSettled) + #expect(!session.isActive) + } + + @Test("A second drag supersedes a standing hold, and the retired deadline never reaches it") + func aSecondDragSupersedesTheHold() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + let session = dragging(toTwoUnits) + session.holdTimeout = .milliseconds(20) + session.end { id, units in store.setLaneWidth(id, units: units) } + + // The user grabs the same edge again before the echo. The anchor is what is on screen — the + // width the release wrote — not the snapshot's, which is still a round trip behind. + let onScreen = session.displayUnits(of: try lane(Ident.lane2, in: store)) + #expect(onScreen == 2) + session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap, window: nil) + + #expect(!session.isSettled) + #expect(session.isDragging(lane2)) + #expect(session.units == 2) + + try? await Task.sleep(for: .milliseconds(80)) + #expect(session.isDragging(lane2), "the retired hold's deadline must not end the drag that followed it") + } + + @Test("A hold ignores drag updates — no mouse is driving it") + func theHoldTakesNoTranslations() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = settled(on: store) + + session.update(translation: toOneUnit) + + #expect(session.units == 2) + #expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) + } +}