The lane-resize release gets its own hold — the strip stops two-stepping through the stale snapshot

LaneWidthHold: after the release the session's frozen standard and the
WRITTEN unit count keep governing the strip until a landed snapshot
carries that width back — dividing the already-grown window by the
stale unit total in between was the visible two-step. A deliberately
separate type from CommittedHold: that hold stands in for an
arrangement and any landing retires it; this one stands in for a value
and only a landing that carries it will do. Echo reads through
LaneLayoutMath.displayUnits so the width-1 key-removal case compares
right; the watch rides landedReloads so a value-equal echo still
answers; width writes now return whether bytes reached disk so a
refused or no-op write dissolves the hold instead of arming it; the
1500ms timeout family covers the rest.

Drag-perf card 231e3693.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 19:33:33 -04:00
parent f6105d4389
commit 409f430813
6 changed files with 591 additions and 32 deletions
+2
View File
@@ -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.
+17 -6
View File
@@ -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<ItemID>, by delta: Int) {
@discardableResult
public func stepLaneWidths(_ ids: Set<ItemID>, 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<Int>, 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
+24 -3
View File
@@ -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
+8 -3
View File
@@ -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()
}
+181 -20
View File
@@ -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<Void, Never>?
/// 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
+359
View File
@@ -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 (32) and 102
/// (21). 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))
}
}