The drag learns the stepper's trick — past the screen's edge, lane growth re-divides instead of stopping

The right-edge drag's growth was capped at the screen's visible frame,
because each snap tick grows the window; on a window near the screen edge
that left a lane stuck at a tick or two of headroom. Settled 2026-08-08
(03-board-ui.md § Lane, superseding the pathfinder's hard stop): at the
screen the window pins and each further tick re-divides the fixed strip
width across one more unit — siblings compress, the stepper's mechanism
arriving under the drag's fingers. The regimes meet with no pixel jump
(the re-divided standard at the fit IS the frozen standard, by the
exact-fill identity), shrinking mirrors the way back, the rubber band
moves to the strip's own capacity, and a window with no headroom at all —
full screen included — re-divides from the very first snap.

New pure arithmetic in LaneLayoutMath (pinnedStripWidth, resizeStandard,
resizeMaxUnits, resizeWindowDelta, snappedUnits over per-count slots);
LaneResizeSession splits the tick across the regimes and derives its
standard from the live count; the handle and BoardView hand the session
the strip's whole divide. 2709 unit tests green (+11).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 21:36:56 -04:00
parent 05b1a787de
commit 9a52b795b2
8 changed files with 564 additions and 91 deletions
+2
View File
@@ -1,5 +1,7 @@
**August 2026** **August 2026**
Dragging a lane's edge past the screen now keeps widening the lane by narrowing the others, instead of stopping — so wide lanes work even in full screen.
Edit ▸ Undo, ⌘Z, and the toolbar's Undo and Redo buttons now work on board changes — they had been silently disabled. Edit ▸ Undo, ⌘Z, and the toolbar's Undo and Redo buttons now work on board changes — they had been silently disabled.
The board's symbol now appears in the title bar beside the board's name, in its chosen tint. The board's symbol now appears in the title bar beside the board's name, in its chosen tint.
+1 -1
View File
@@ -20,7 +20,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total. - Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically. - Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **Growth hard-stops at the screen's visible frame, with rubber-band feedback** (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound — pathfinder behavior, proven): the drag never compresses siblings and the window never overflows the screen. The header context menu's Width control (stepper, uncapped) is the precise control — and deliberately the opposite mechanism: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress). Widths beyond the screen's capacity stay reachable through it. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window. - Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **At the screen's visible frame the window stops and the drag degrades to the re-divide** (settled 2026-08-08, superseding the pathfinder's hard stop): further snaps re-divide the now-fixed width across one more unit each — siblings compress, exactly the stepper's mechanism — so a lane keeps growing at the siblings' expense once the screen is spent; a window with no headroom to begin with (already at the edge, or full screen) re-divides from the first snap. The two regimes meet without a pixel jump — the re-divided standard at the screen fit *is* the frozen standard. Shrinking mirrors it: re-divide back down to the screen fit, then window shrinking resumes (and shrinking is always allowed). The rubber-band feedback (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound) moves to the true end of travel — the strip's own capacity — and the window still never overflows the screen. The header context menu's Width control (stepper, uncapped) remains the precise control: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress); the drag now reaches those widths too, but the stepper stays the exact, accessible path. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window.
## Card face ## Card face
+23 -8
View File
@@ -580,7 +580,7 @@ struct BoardView: View {
@ViewBuilder @ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View { private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
// **The session governs through the release**, not just the drag: after the release its // **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 // 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 // carries that width back (`LaneWidthHold`). Reading `lane.width` in that window would draw
// the pre-drag layout for a round trip. // the pre-drag layout for a round trip.
let resizing = resize.governs(lane.id) let resizing = resize.governs(lane.id)
@@ -625,6 +625,7 @@ struct BoardView: View {
committedUnits: units, committedUnits: units,
standard: standard, standard: standard,
gap: spacing, gap: spacing,
totalUnits: stripTotalUnits,
window: window window: window
) )
// The read-only lock disables every mutating gesture, not just the menu items // The read-only lock disables every mutating gesture, not just the menu items
@@ -703,10 +704,14 @@ struct BoardView: View {
/// `laneDrops.stripFrame.width` instead, which is the same number read at event time exactly /// `laneDrops.stripFrame.width` instead, which is the same number read at event time exactly
/// the registry's purpose. The two agree because the padded container fills the reader. /// the registry's purpose. The two agree because the padded container fills the reader.
/// ///
/// During a resize session the standard is **frozen** at its drag-start value: the window is /// During a resize session the standard comes from the session and not from the viewport: the
/// animating mid-resize, so deriving the standard from the live width would feed that animation /// window is animating mid-resize, so deriving it from the live width would feed that animation
/// back into every lane and pulse the whole strip. The window is sized on each tick so this frozen /// back into every lane and pulse the whole strip. Within the screen's fit the session's answer is
/// value equals what the formula yields once the session ends the handoff is seamless (see /// its drag-start value, **frozen** the window is sized on each tick so it equals what the
/// formula yields once the session ends, and the siblings never move. Past the fit the window is
/// pinned and the session re-divides instead (settled 2026-08-08, 03-board-ui.md § Lane), so the
/// number returned here shrinks with each tick and every lane compresses which is exactly how
/// the drag borrows the stepper's mechanism. Either way the handoff at the end is seamless (see
/// `LaneResizeSession`). /// `LaneResizeSession`).
/// ///
/// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that /// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that
@@ -730,15 +735,25 @@ struct BoardView: View {
/// (`arrivingLaneUnits`). /// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat { private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard } if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
units += arrivingLaneUnits
return LaneLayoutMath.standardWidth( return LaneLayoutMath.standardWidth(
stripWidth: stripWidth, stripWidth: stripWidth,
totalUnits: units, totalUnits: stripTotalUnits + arrivingLaneUnits,
gap: spacing gap: spacing
) )
} }
/// The strip's resting divide: every lane's units plus the trash's fixed one while it is shown.
///
/// Named because it is asked twice for two purposes. The resting layout divides the viewport by
/// it (plus a cross-board arrival's units, which are a hover-only addition and no part of the
/// strip itself), and each lane's grab strip hands it to `LaneResizeSession.begin` as the total
/// the drag's re-divide works from once the window has spent the screen (03-board-ui.md § Lane).
/// The arrival is deliberately absent from the second: a resize refuses to start while a drag
/// session is in flight, so there is never an arriving lane at a drag's begin.
private var stripTotalUnits: Int {
LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
}
/// The units a cross-board lane run would add to this strip while its shadow is proposed here; /// The units a cross-board lane run would add to this strip while its shadow is proposed here;
/// zero for a within-board drag, whose lanes are already counted. /// zero for a within-board drag, whose lanes are already counted.
/// ///
+159 -24
View File
@@ -16,10 +16,17 @@ import CoreGraphics
/// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below, /// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below,
/// since the behaviour is what was proven, not the code). /// since the behaviour is what was proven, not the code).
/// ///
/// **The two mechanisms meet at the screen's visible frame** (settled 2026-08-08, § Lane): once the
/// window can grow no further the drag stops moving it and degrades to the re-divide the same
/// fixed width across one more unit per tick, siblings compressing, which is precisely what the
/// stepper does. `pinnedStripWidth`, `resizeStandard`, `resizeMaxUnits` and `resizeWindowDelta` are
/// that second regime; they are written so the boundary itself costs no pixels, since the re-divided
/// standard at the screen fit *is* the frozen standard.
///
/// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane /// 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 /// spans any whole number of units 1, so `allowedRange`'s ceiling is not a width range but the
/// (`maxUnits`) there is no `Column.widthRange` equivalent to fold in, and shrinking is always /// strip's own capacity (`resizeMaxUnits`) there is no `Column.widthRange` equivalent to fold in,
/// allowed. /// and shrinking is always allowed.
enum LaneLayoutMath { enum LaneLayoutMath {
// MARK: - The resting layout // MARK: - The resting layout
@@ -136,10 +143,37 @@ enum LaneLayoutMath {
/// 10pt (true of every lane width a real window produces), so calling this on every drag event /// 10pt (true of every lane width a real window produces), so calling this on every drag event
/// never oscillates. /// never oscillates.
/// ///
/// Ticks are capped to `allowedRange`, which in Lanework folds in **only** the on-screen fit /// Ticks are capped to `allowedRange`, whose ceiling is the strip's capacity (`resizeMaxUnits`)
/// (`maxUnits`) there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, no /// there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, no cap"), and
/// cap"), and the uncapped widths beyond the screen's capacity are the stepper's business, not /// past the screen fit the tick keeps firing, re-dividing rather than growing the window.
/// the drag's. ///
/// **`slotFor` rather than a standard**, because the standard is no longer one number for the
/// whole gesture: beyond the screen fit each further unit re-divides the pinned strip, so slot
/// `k` and slot `k + 1` are measured against *different* standards (`resizeStandard`). The
/// thresholds are unchanged in form they simply ask the caller how wide each slot would be.
/// The band stays non-empty in the re-divide too: the slots still grow strictly with `k`, since
/// a unit added to a lane takes more from the strip than the re-divide gives back.
static func snappedUnits(
liveWidth: CGFloat,
currentUnits: Int,
slotFor: (Int) -> CGFloat,
gap: CGFloat,
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
if currentUnits < allowedRange.upperBound, liveWidth > slotFor(currentUnits) + gap {
return currentUnits + 1
}
if currentUnits > allowedRange.lowerBound {
if liveWidth < slotFor(currentUnits - 1) + gap - reentry {
return currentUnits - 1
}
}
return currentUnits
}
/// The same snap where every slot is measured against one standard the whole of the gesture
/// below the screen fit, and the shape the hit-testing and layout call sites think in.
static func snappedUnits( static func snappedUnits(
liveWidth: CGFloat, liveWidth: CGFloat,
currentUnits: Int, currentUnits: Int,
@@ -148,25 +182,21 @@ enum LaneLayoutMath {
allowedRange: ClosedRange<Int>, allowedRange: ClosedRange<Int>,
reentry: CGFloat reentry: CGFloat
) -> Int { ) -> Int {
let currentSlot = slotWidth(units: currentUnits, standard: standard, gap: gap) snappedUnits(
if currentUnits < allowedRange.upperBound, liveWidth > currentSlot + gap { liveWidth: liveWidth,
return currentUnits + 1 currentUnits: currentUnits,
} slotFor: { slotWidth(units: $0, standard: standard, gap: gap) },
if currentUnits > allowedRange.lowerBound { gap: gap,
let previousSlot = slotWidth(units: currentUnits - 1, standard: standard, gap: gap) allowedRange: allowedRange,
if liveWidth < previousSlot + gap - reentry { reentry: reentry)
return currentUnits - 1
}
}
return currentUnits
} }
/// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot, /// 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` /// 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 /// (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 /// the bound without a hard stop (03-board-ui.md § Lane: the rubber band "moves to the true end
/// visible frame, with rubber-band feedback"). The snap tick never follows the width past the /// of travel the strip's own capacity"). The snap tick never follows the width past the bound
/// bound (see `snappedUnits`' clamp), so this is purely cosmetic give. /// (see `snappedUnits`' clamp), so this is purely cosmetic give.
static func resistedWidth( static func resistedWidth(
proposed: CGFloat, proposed: CGFloat,
minSlot: CGFloat, minSlot: CGFloat,
@@ -184,9 +214,12 @@ enum LaneLayoutMath {
/// allowed regardless of screen room, including from a window already hanging off the edge /// allowed regardless of screen room, including from a window already hanging off the edge
/// (negative headroom). /// (negative headroom).
/// ///
/// Unlike the pathfinder's twin there is no width ceiling to `min` against: the drag's only /// Unlike the pathfinder's twin this is **not the tick's ceiling** it is the boundary where
/// bound is the screen, because it is the mechanism that grows the window. Larger widths are /// one mechanism hands over to the other (settled 2026-08-08, 03-board-ui.md § Lane). Up to it
/// reachable through the stepper, which re-divides instead (03-board-ui.md § Lane). /// the drag grows the window and the siblings keep their pixels; past it the window is spent and
/// each further tick re-divides instead (`resizeStandard`), which is how a lane keeps growing at
/// the siblings' expense on a full screen. A window with no headroom at all answers
/// `currentUnits`, so the very first tick is already a re-divide.
/// ///
/// Pure so it can be unit-tested; the session computes `headroom` from the live window and its /// Pure so it can be unit-tested; the session computes `headroom` from the live window and its
/// screen and defers the arithmetic here. /// screen and defers the arithmetic here.
@@ -195,4 +228,106 @@ enum LaneLayoutMath {
let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step)) let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step))
return max(currentUnits, currentUnits + extra) return max(currentUnits, currentUnits + extra)
} }
// MARK: - The drag past the screen: the re-divide
/// The strip's width once the window has grown as far as its screen allows the width every
/// tick past the screen fit re-divides, since the window is pinned from there on.
///
/// The drag-start width is **derived, not measured**: the strip always fills exactly
/// (`standardWidth`), so a frozen standard `s` over `T` units means a strip of `s·T + g·(T + 1)`
/// and nothing else. Growth adds one `s + g` step per unit of on-screen headroom. Deriving it is
/// what makes the boundary free: a measured viewport width, carrying whatever half-point the
/// layout rounded to, would put a visible step where the two regimes meet.
static func pinnedStripWidth(
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> CGFloat {
let total = CGFloat(max(1, startTotalUnits))
let atStart = startStandard * total + gap * (total + 1)
return atStart + CGFloat(max(0, fittingUnits - startUnits)) * (startStandard + gap)
}
/// The strip's standard (1×) width part-way through a right-edge drag, for a dragged lane
/// spanning `units` the whole of the drag's two regimes in one function (03-board-ui.md
/// § Lane, settled 2026-08-08).
///
/// At or below the screen fit the answer is the standard frozen at drag start: the window takes
/// the step, so the division is unchanged and every other lane keeps its exact pixels. Past the
/// fit the window is pinned, so each further unit the dragged lane claims is one more unit the
/// same `pinnedStripWidth` has to divide across the siblings compress, which is the stepper's
/// mechanism arriving under the drag's fingers.
///
/// **The regimes meet with no pixel jump.** At `units == fittingUnits` the pinned width divided
/// across its own unit total is `startStandard` exactly, by the exact-fill identity
/// `pinnedStripWidth` is built from the same reason the release hands back to the resting
/// layout without a flinch (`LaneResizeSession`).
static func resizeStandard(
forUnits units: Int,
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> CGFloat {
guard units > fittingUnits else { return startStandard }
return standardWidth(
stripWidth: pinnedStripWidth(
startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap),
totalUnits: max(1, startTotalUnits) + (units - startUnits),
gap: gap)
}
/// The drag's ceiling the true end of travel, which past the screen fit is a question about
/// the strip's capacity rather than about the screen.
///
/// The re-divide can always take one more unit, but not usefully forever: at some total
/// `standardWidth`'s 1pt floor engages and the strip stops filling exactly, which is the point
/// where the arithmetic stops describing anything on screen. That total is the largest `T`
/// satisfying `(W g·(T + 1)) / T 1`, i.e. `floor((W g) / (1 + g))`, and the dragged lane's
/// ceiling is that total read back through the units it contributed. This is where the rubber
/// band now sits (§ Lane: "the true end of travel the strip's own capacity").
///
/// **Never below the screen fit**, and so never below the count the drag started from
/// shrinking is always allowed. A degenerate strip (a non-finite standard, a gap at or below
/// 1pt, a capacity under one whole unit) falls back to the fit rather than inventing a bound.
static func resizeMaxUnits(
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> Int {
let fit = max(startUnits, fittingUnits)
guard startStandard.isFinite, gap.isFinite, gap > -1 else { return fit }
let width = pinnedStripWidth(
startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap)
guard width.isFinite else { return fit }
let capacity = (width - gap) / (1 + gap)
guard capacity >= 1 else { return fit }
let total = Int(min(capacity, CGFloat(Int.max) / 2).rounded(.down))
return max(fit, startUnits + (total - max(1, startTotalUnits)))
}
/// How far the window moves on a tick from `oldUnits` to `newUnits`: one `step` per unit of that
/// change lying **inside** the screen fit, and nothing at all for the part beyond it, where the
/// re-divide has taken over and the window is pinned (03-board-ui.md § Lane).
///
/// A difference of clamped counts rather than a per-step walk, because a flick can cross the
/// boundary in one gesture event and the two sides must net out exactly: `F 1 F + 2` is one
/// step (only the first unit was ever the window's to give), `F + 2 F + 5` is none, and
/// `F + 2 F 1` hands that one step back. Shrinking mirrors growing by construction.
static func resizeWindowDelta(
from oldUnits: Int,
to newUnits: Int,
fittingUnits: Int,
step: CGFloat
) -> CGFloat {
CGFloat(min(newUnits, fittingUnits) - min(oldUnits, fittingUnits)) * step
}
} }
+9 -1
View File
@@ -28,6 +28,13 @@ struct LaneResizeHandle: View {
let standard: CGFloat let standard: CGFloat
let gap: CGFloat let gap: CGFloat
/// The strip's whole unit total THIS render every lane's units plus the shown trash's fixed
/// one, the same divide the resting layout runs on (`BoardView.stripTotalUnits`). The session
/// needs it because past the screen's fit the drag re-divides the strip rather than growing the
/// window (03-board-ui.md § Lane, settled 2026-08-08), and a re-divide is a fact about the whole
/// strip rather than about this lane.
let totalUnits: Int
/// How the session reaches the host window it resizes. A closure rather than a stored /// 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 /// `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. /// captured in an early body evaluation would be `nil` for the window's whole life.
@@ -75,7 +82,8 @@ struct LaneResizeHandle: View {
// not exist yet; when it does, this is where the `isDragging` guard goes. // not exist yet; when it does, this is where the `isDragging` guard goes.
pushCursor() pushCursor()
session.begin(laneID: laneID, units: committedUnits, session.begin(laneID: laneID, units: committedUnits,
standard: standard, gap: gap, window: window()) standard: standard, gap: gap,
totalUnits: totalUnits, window: window())
} }
session.update(translation: value.translation.width) session.update(translation: value.translation.width)
} }
+102 -37
View File
@@ -64,19 +64,27 @@ struct LaneWidthHold: Equatable, Sendable {
/// ///
/// ### The invariant that makes it feel solid /// ### The invariant that makes it feel solid
/// ///
/// **While a session is active, every OTHER lane keeps its exact pixel width.** That is achieved by /// **While the window still has screen to grow into, every OTHER lane keeps its exact pixel width.**
/// freezing the strip's standard (1×) width at drag start and sizing the *window* so that after /// That is achieved by freezing the strip's standard (1×) width at drag start and sizing the
/// each snap tick the ordinary viewport-derived formula reproduces that frozen standard exactly /// *window* so that after each snap tick the ordinary viewport-derived formula reproduces that
/// so releasing the drag hands back to the resting layout with no pixel jump. This is the opposite /// frozen standard exactly so releasing the drag hands back to the resting layout with no pixel
/// mechanism from the stepper (and its / keyboard face), which never touches the window and /// jump.
/// re-divides the existing width across the new unit total; the design is explicit that ///
/// window-growing behaviour belongs to the drag alone. /// **At the screen's visible frame that stops and the drag degrades to the re-divide** (settled
/// 2026-08-08, 03-board-ui.md § Lane). Past the fit the window is pinned and each further tick
/// divides the same width across one more unit the stepper's mechanism, arriving under the drag's
/// fingers so the siblings compress and the lane keeps growing at their expense. A window with no
/// headroom to begin with (already at the edge, or full screen) re-divides from the very first
/// snap, which is what makes the drag work at all on a maximised window. The boundary costs nothing:
/// the re-divided standard at the fit *is* the frozen standard (`LaneLayoutMath.resizeStandard`), so
/// `standard` below is a function of the live unit count rather than one number for the gesture.
/// ///
/// The session owns the two things that must move together on each tick: the SwiftUI unit count /// 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 /// (`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 curves `Motion.laneResize` /// 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 /// 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. /// entry so the window edge and the lanes to its right travel as one. Past the fit only the first
/// of the two moves, and the window is left exactly where the screen ended.
/// ///
/// ### Three phases, not two /// ### Three phases, not two
/// ///
@@ -89,7 +97,7 @@ struct LaneWidthHold: Equatable, Sendable {
final class LaneResizeSession { final class LaneResizeSession {
/// The lane this session governs being dragged, or holding its written width until the echo; /// 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 /// `nil` when idle. Observed flipping it drives `BoardView`'s session-standard override and the
/// shadow slot on and off, and `LaneView`'s column count. /// shadow slot on and off, and `LaneView`'s column count.
private(set) var laneID: ItemID? private(set) var laneID: ItemID?
@@ -109,12 +117,21 @@ final class LaneResizeSession {
/// does. /// does.
private(set) var units: Int = 1 private(set) var units: Int = 1
/// The strip's standard (1×) width, frozen at drag start. Used for ALL lane widths in /// The strip's standard (1×) width **as of the live unit count**. Used for ALL lane widths in
/// `BoardView` while a session is active the window is animating mid-session, so recomputing /// `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 /// 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 /// pulse every lane. Read within renders already triggered by the observed properties above, so
/// it need not itself be observed. /// it need not itself be observed.
@ObservationIgnored private(set) var standard: CGFloat = 1 ///
/// Within the screen fit this is the value frozen at drag start and nothing moves but the
/// window; past it the pinned strip re-divides, and this is how every *other* lane learns to
/// compress `BoardView.standardWidth` hands the same number to all of them
/// (`LaneLayoutMath.resizeStandard`).
var standard: CGFloat { standard(forUnits: units) }
/// The strip's standard (1×) width frozen at drag start regime A's answer whole, and regime
/// B's starting point.
@ObservationIgnored private var startStandard: CGFloat = 1
/// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin. /// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin.
@ObservationIgnored private var gap: CGFloat = 12 @ObservationIgnored private var gap: CGFloat = 12
@@ -122,12 +139,25 @@ final class LaneResizeSession {
/// The committed unit count at drag start the anchor the drag translation is measured from. /// The committed unit count at drag start the anchor the drag translation is measured from.
@ObservationIgnored private var startUnits: Int = 1 @ObservationIgnored private var startUnits: Int = 1
/// The largest unit count that fits on screen. The drag's only ceiling: Lanework's `width` has /// The strip's whole unit total at drag start every lane's display units plus the trash
/// no cap (03-board-ui.md § Lane), so nothing else bounds growth. /// column's fixed one while it is shown, exactly the total `BoardView` divides the resting
/// layout by. The re-divide needs it twice over: it is what the pinned strip width is derived
/// from, and what each extra unit the drag claims is added to.
@ObservationIgnored private var startTotalUnits: Int = 1
/// The largest unit count that still fits on screen **the boundary between the two regimes**,
/// not a ceiling. Up to it a tick grows the window; past it a tick re-divides
/// (`LaneLayoutMath.maxUnits`, `fittingMaxUnits`).
@ObservationIgnored private var fittingUnits: Int = 1 @ObservationIgnored private var fittingUnits: Int = 1
/// The host window, resized by ±(standard + gap) on each tick. Weak a window can close, /// The tick's actual ceiling: the strip's own capacity, the count past which the re-divide would
/// though a resize cannot outlive the gesture that drives it. /// break the exact fill (`LaneLayoutMath.resizeMaxUnits`). Computed once at begin, since every
/// input to it is frozen there.
@ObservationIgnored private var ceilingUnits: Int = 1
/// The host window, resized by ±(standard + gap) on each tick that still has screen to move
/// into, and left alone on the re-divide's. Weak a window can close, though a resize cannot
/// outlive the gesture that drives it.
@ObservationIgnored private weak var window: NSWindow? @ObservationIgnored private weak var window: NSWindow?
/// Reduce Motion, read once at `begin` and frozen for the gesture (10-accessibility.md's /// Reduce Motion, read once at `begin` and frozen for the gesture (10-accessibility.md's
@@ -149,8 +179,8 @@ final class LaneResizeSession {
/// (`LaneResizeHoldTests`). Nothing in the app writes it. /// (`LaneResizeHoldTests`). Nothing in the app writes it.
@ObservationIgnored var holdTimeout: Duration = LaneWidthHold.timeout @ObservationIgnored var holdTimeout: Duration = LaneWidthHold.timeout
/// Whether the session governs the strip's layout at all the frozen standard is in force for a /// Whether the session governs the strip's layout at all its `standard` is in force for a drag
/// drag and for the hold that follows it alike. /// and for the hold that follows it alike.
var isActive: Bool { laneID != nil } var isActive: Bool { laneID != nil }
/// Whether a gesture is still driving it. False during the hold, which no mouse is holding. /// Whether a gesture is still driving it. False during the hold, which no mouse is holding.
@@ -186,20 +216,31 @@ final class LaneResizeSession {
/// The rubber-band overshoot fraction past the end slots. /// The rubber-band overshoot fraction past the end slots.
private let resistance: CGFloat = 0.25 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 /// Unit counts the tick may reach: one up to the strip's capacity. The floor is 1 because a lane
/// spans at least one unit; there is no ceiling but the screen. /// spans at least one unit; the ceiling is where the re-divide runs out of strip to divide, the
/// screen having stopped bounding it (03-board-ui.md § Lane, settled 2026-08-08).
private var allowedRange: ClosedRange<Int> { private var allowedRange: ClosedRange<Int> {
1...max(1, fittingUnits) 1...max(1, ceilingUnits)
} }
private var minSlot: CGFloat { /// The standard the strip would be drawn at with the dragged lane spanning `units` frozen
LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap) /// within the screen fit, re-divided past it. Every slot the gesture measures goes through here.
private func standard(forUnits units: Int) -> CGFloat {
LaneLayoutMath.resizeStandard(
forUnits: units, startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap)
} }
private var maxSlot: CGFloat { /// The dragged lane's rendered width at `units` its own units against the standard *that* count
LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap) /// implies, which past the fit is not the standard the neighbouring counts imply.
private func slot(forUnits units: Int) -> CGFloat {
LaneLayoutMath.slotWidth(units: units, standard: standard(forUnits: units), gap: gap)
} }
private var minSlot: CGFloat { slot(forUnits: allowedRange.lowerBound) }
private var maxSlot: CGFloat { slot(forUnits: allowedRange.upperBound) }
// MARK: - Lifecycle // MARK: - Lifecycle
/// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far /// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far
@@ -209,17 +250,32 @@ final class LaneResizeSession {
/// about the same strip, and its own release will arm the hold that matters. `units` is the /// 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 /// 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. /// 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?) { ///
/// `totalUnits` is the strip's whole divide every lane plus the shown trash's fixed one, the
/// same total the resting layout uses. The re-divide past the screen fit is arithmetic *about
/// the strip*, not about the dragged lane, so it cannot be reconstructed from the lane alone.
func begin(
laneID: ItemID,
units: Int,
standard: CGFloat,
gap: CGFloat,
totalUnits: Int,
window: NSWindow?
) {
endHold() endHold()
self.laneID = laneID self.laneID = laneID
self.startUnits = units self.startUnits = units
self.units = units self.units = units
self.standard = standard self.startStandard = standard
self.startTotalUnits = max(1, totalUnits)
self.gap = gap self.gap = gap
self.window = window self.window = window
self.reducedMotion = Motion.prefersReducedMotion self.reducedMotion = Motion.prefersReducedMotion
self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window) self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window)
self.ceilingUnits = LaneLayoutMath.resizeMaxUnits(
startUnits: units, startStandard: standard, startTotalUnits: self.startTotalUnits,
fittingUnits: self.fittingUnits, gap: gap)
} }
/// Applies a drag translation (points, measured from the gesture's start): tracks the live width /// Applies a drag translation (points, measured from the gesture's start): tracks the live width
@@ -234,15 +290,14 @@ final class LaneResizeSession {
/// intermediate step. /// intermediate step.
func update(translation: CGFloat) { func update(translation: CGFloat) {
guard isDragging else { return } guard isDragging else { return }
let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap)
liveWidth = LaneLayoutMath.resistedWidth( liveWidth = LaneLayoutMath.resistedWidth(
proposed: startSlot + translation, proposed: slot(forUnits: startUnits) + translation,
minSlot: minSlot, maxSlot: maxSlot, resistance: resistance) minSlot: minSlot, maxSlot: maxSlot, resistance: resistance)
var target = units var target = units
while true { while true {
let next = LaneLayoutMath.snappedUnits( let next = LaneLayoutMath.snappedUnits(
liveWidth: liveWidth, currentUnits: target, liveWidth: liveWidth, currentUnits: target,
standard: standard, gap: gap, allowedRange: allowedRange, reentry: reentry) slotFor: slot(forUnits:), gap: gap, allowedRange: allowedRange, reentry: reentry)
if next == target { break } if next == target { break }
target = next target = next
} }
@@ -280,9 +335,10 @@ final class LaneResizeSession {
} }
// The settle: the live width, which has been tracking the cursor, comes to rest on the slot // 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. // the written count names measured against the standard THAT count implies, which is the
// one still governing the strip (frozen within the fit, re-divided past it).
withAnimation(Motion.laneResize(reduced: reducedMotion)) { withAnimation(Motion.laneResize(reduced: reducedMotion)) {
liveWidth = LaneLayoutMath.slotWidth(units: committed, standard: standard, gap: gap) liveWidth = slot(forUnits: committed)
} }
} }
@@ -335,10 +391,17 @@ final class LaneResizeSession {
/// on the two matching lane-resize curves. The window grows and shrinks at its RIGHT edge /// on the two matching lane-resize 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 /// 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. /// this lane's own left edge and the drag's coordinate origin, stays put.
///
/// **The window only takes the part of the step that fits on screen**
/// (`LaneLayoutMath.resizeWindowDelta`): past the fit the delta is zero, the unit count moves
/// alone, and what the eye sees is the siblings compressing into the width the window already
/// has. A zero delta skips the animation group outright rather than animating a frame to itself
/// running the group for nothing would leave every re-divide tick paying for a window animation.
private func tick(to newUnits: Int) { private func tick(to newUnits: Int) {
let delta = CGFloat(newUnits - units) * (standard + gap) let delta = LaneLayoutMath.resizeWindowDelta(
from: units, to: newUnits, fittingUnits: fittingUnits, step: startStandard + gap)
withAnimation(Motion.laneResize(reduced: reducedMotion)) { units = newUnits } withAnimation(Motion.laneResize(reduced: reducedMotion)) { units = newUnits }
guard let window else { return } guard let window, delta != 0 else { return }
var frame = window.frame var frame = window.frame
frame.size.width += delta // right-edge growth: origin and height unchanged frame.size.width += delta // right-edge growth: origin and height unchanged
// Reduce Motion's variant of the rubber-band feedback is the *instant* one // Reduce Motion's variant of the rubber-band feedback is the *instant* one
@@ -357,10 +420,12 @@ final class LaneResizeSession {
} }
} }
/// The on-screen fit, from the window's headroom to its screen's visible frame the hard stop /// The on-screen fit, from the window's headroom to its screen's visible frame **where window
/// 03-board-ui.md § Lane requires ("Growth hard-stops at the screen's visible frame"). Defers /// growth ends and the re-divide begins** (03-board-ui.md § Lane: "at the screen's visible frame
/// the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current count is /// the window stops and the drag degrades to the re-divide"), which is a handover and not a stop.
/// the honest answer (growth needs a window to grow). /// Defers the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current
/// count is the honest answer growth needs a window to grow, so every tick re-divides, exactly
/// as it does for a window already pinned to the screen's edge.
private static func fittingMaxUnits(currentUnits: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) -> Int { 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 } guard let window, let screen = window.screen ?? NSScreen.main else { return currentUnits }
let headroom = screen.visibleFrame.maxX - window.frame.maxX let headroom = screen.visibleFrame.maxX - window.frame.maxX
+187 -15
View File
@@ -15,9 +15,12 @@ import Testing
/// back to `k - 1` is `slotWidth(k - 1) + gap - reentry`, 10pt shy of that same boundary so 21 /// back to `k - 1` is `slotWidth(k - 1) + gap - reentry`, 10pt shy of that same boundary so 21
/// ticks down at 102 (112 10) and 32 at 214 (224 10). /// ticks down at 102 (112 10) and 32 at 214 (224 10).
/// ///
/// Where the pathfinder's twin suite pinned a hard 13 width cap, these pin the *screen fit*: in /// Where the pathfinder's twin suite pinned a hard 13 width cap, these pin the two regimes the
/// Lanework `allowedRange`'s ceiling is only ever how far the window can grow (`maxUnits`), because /// screen fit divides (03-board-ui.md § Lane, settled 2026-08-08): up to the fit a tick grows the
/// the width field itself has no cap. /// window, past it a tick re-divides the pinned strip, and `allowedRange`'s ceiling is the strip's
/// own capacity rather than either the width field itself has no cap. The suites below take a
/// screen fit as a *range* where the mechanism is not what is under test, and the re-divide gets its
/// own suite at the foot of the file.
private let standard: CGFloat = 100 private let standard: CGFloat = 100
private let gap: CGFloat = 12 private let gap: CGFloat = 12
@@ -311,26 +314,26 @@ struct LaneSnapTests {
@Test("The snap never steps past the allowed range") @Test("The snap never steps past the allowed range")
func neverTicksPastTheAllowedRange() { func neverTicksPastTheAllowedRange() {
#expect(snapped(5000, current: 3) == 3, "the on-screen fit is the ceiling") #expect(snapped(5000, current: 3) == 3, "the range's ceiling holds")
#expect(snapped(0, current: 1) == 1, "one unit is the floor") #expect(snapped(0, current: 1) == 1, "one unit is the floor")
#expect(snapped(-500, current: 1) == 1) #expect(snapped(-500, current: 1) == 1)
} }
@Test("The ceiling is the screen fit, and it is the only ceiling") @Test("The range's ceiling is the only ceiling, and it is not a width cap")
func snapRespectsTheOnScreenFit() { func snapRespectsTheAllowedCeiling() {
// With the fit capping the range at 2×, no live width ticks to 3×. // With the range capped at 2×, no live width ticks to 3×.
let fitsTwo = 1...2 let capsAtTwo = 1...2
#expect(snapped(1000, current: 2, range: fitsTwo) == 2) #expect(snapped(1000, current: 2, range: capsAtTwo) == 2)
#expect(snapped(5000, current: 2, range: fitsTwo) == 2) #expect(snapped(5000, current: 2, range: capsAtTwo) == 2)
// Below the cap it still ticks normally. // Below the cap it still ticks normally.
#expect(snapped(200, current: 1, range: fitsTwo) == 2) #expect(snapped(200, current: 1, range: capsAtTwo) == 2)
// A roomier screen keeps ticking well past the pathfinder's old 3× ceiling Lanework's // A roomier ceiling keeps ticking well past the pathfinder's old 3× Lanework's width has
// width has no cap of its own (03-board-ui.md § Lane). // no cap of its own (03-board-ui.md § Lane).
#expect(snapped(5000, current: 3, range: 1...9) == 4) #expect(snapped(5000, current: 3, range: 1...9) == 4)
#expect(snapped(5000, current: 8, range: 1...9) == 9) #expect(snapped(5000, current: 8, range: 1...9) == 9)
} }
// MARK: maxUnits // MARK: maxUnits the boundary, not the ceiling
@Test("maxUnits turns window headroom into whole growable units") @Test("maxUnits turns window headroom into whole growable units")
func maxUnitsFromHeadroom() { func maxUnitsFromHeadroom() {
@@ -338,7 +341,8 @@ struct LaneSnapTests {
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 250, step: step) == 3) #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 250, step: step) == 3)
// Just over one step +1. // Just over one step +1.
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 120, step: step) == 2) #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 120, step: step) == 2)
// Less than a step no growth room, but shrinking stays allowed. // Less than a step no window growth left, so the drag re-divides from its very first
// tick; shrinking stays allowed either way.
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 50, step: step) == 1) #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 50, step: step) == 1)
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 0, step: step) == 2) #expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 0, step: step) == 2)
// Never below currentUnits even with a negative headroom (a window already past the visible // Never below currentUnits even with a negative headroom (a window already past the visible
@@ -373,3 +377,171 @@ struct LaneSnapTests {
maxSlot: slot(3), resistance: 0.25) == 343) maxSlot: slot(3), resistance: 0.25) == 343)
} }
} }
// MARK: - The drag past the screen
/// The right-edge drag's **second regime** (03-board-ui.md § Lane, settled 2026-08-08): at the
/// screen's visible frame the window stops growing, and each further tick re-divides the now-pinned
/// strip across one more unit instead the stepper's mechanism, driven by the drag, with the
/// siblings compressing. Before this the tick simply clamped at the fit, so a lane on a maximised
/// window refused to widen at all.
///
/// Fixture: the file's standard of 100 and gap of 12 on a **four-unit strip** the dragged 1× lane,
/// two more 1× lanes and the shown trash's fixed unit with two whole steps of screen headroom, so
/// the fit is 3×. The strip is 100·4 + 12·5 = 460 at drag start and 460 + 2·112 = 684 once the window
/// is flush against the screen, which is the width every tick past 3× re-divides.
@Suite("LaneLayoutMath ▸ the drag past the screen")
struct LaneRedivideTests {
private let startUnits = 1
private let startTotal = 4
private let fit = 3
private let pinned: CGFloat = 684
private func standardFor(_ units: Int) -> CGFloat {
LaneLayoutMath.resizeStandard(
forUnits: units, startUnits: startUnits, startStandard: standard,
startTotalUnits: startTotal, fittingUnits: fit, gap: gap)
}
private func slotFor(_ units: Int) -> CGFloat {
LaneLayoutMath.slotWidth(units: units, standard: standardFor(units), gap: gap)
}
private var ceiling: Int {
LaneLayoutMath.resizeMaxUnits(
startUnits: startUnits, startStandard: standard,
startTotalUnits: startTotal, fittingUnits: fit, gap: gap)
}
private func snapped(_ liveWidth: CGFloat, current: Int) -> Int {
LaneLayoutMath.snappedUnits(liveWidth: liveWidth, currentUnits: current,
slotFor: slotFor, gap: gap,
allowedRange: 1...ceiling, reentry: reentry)
}
// MARK: The pinned strip
@Test("The pinned strip is the drag-start strip plus one step per unit of screen headroom")
func pinnedStripWidthIsDerivedNotMeasured() {
#expect(LaneLayoutMath.pinnedStripWidth(
startUnits: startUnits, startStandard: standard,
startTotalUnits: startTotal, fittingUnits: fit, gap: gap) == pinned)
// No headroom at all: the strip is exactly what the frozen standard and the unit total
// imply, and the re-divide starts from the very first tick.
#expect(LaneLayoutMath.pinnedStripWidth(
startUnits: startUnits, startStandard: standard,
startTotalUnits: startTotal, fittingUnits: startUnits, gap: gap) == 460)
}
// MARK: Continuity at the boundary
@Test("The two regimes meet with no pixel jump")
func theBoundaryCostsNothing() {
// Everything up to the fit is the frozen standard: the window took the step, so the
// division never moved.
#expect(standardFor(1) == standard)
#expect(standardFor(2) == standard)
#expect(standardFor(fit) == standard, "the re-divided standard AT the fit is the frozen one")
// Past it the same width divides across one more unit, so it can only shrink.
#expect(standardFor(fit + 1) < standard)
#expect(abs(standardFor(fit + 1) - 84) < 0.0001) // (684 12·8) / 7
#expect(standardFor(fit + 2) < standardFor(fit + 1))
}
@Test("Past the fit the strip still fills exactly — the width is pinned, the division is not")
func exactFillSurvivesTheRedivide() {
for units in (fit + 1)...12 {
let redivided = standardFor(units)
// Four boxes stand in the strip the dragged lane, two 1× lanes and the trash so
// there are five gaps: the three between them and the two outer margins.
let filled = slotFor(units) + 3 * redivided + 5 * gap
#expect(abs(filled - pinned) < 0.0001, "\(units)× must still fill the pinned strip exactly")
}
}
// MARK: The snap, measured slot by slot
@Test("A tick past the fit fires on the re-divided slot, and does not double-tick")
func theRedivideTicksOnceAndSettles() {
// 3× is the last slot the window pays for: 324 wide, its trailing gap ending at 336.
let threshold = slotFor(fit) + gap
#expect(threshold == 336)
#expect(snapped(threshold, current: fit) == fit, "exactly at the far edge holds (strict >)")
let ticked = snapped(threshold + 0.1, current: fit)
#expect(ticked == fit + 1, "the old clamp at the screen fit is gone")
// 4× is measured against the SMALLER standard the re-divide produced, and its threshold
// still sits beyond the width that fired the tick so the session's iteration settles in
// one step rather than running away up the strip.
#expect(slotFor(fit + 1) + gap > threshold + 0.1)
#expect(snapped(threshold + 0.1, current: ticked) == ticked, "no double tick")
// And it does not immediately reverse either: the tick-down threshold is 10pt back inside
// the gap it just cleared.
#expect(snapped(threshold + 0.1, current: ticked) != fit)
}
@Test("Ticking back down retreats through the same re-divided slots")
func theRedivideTicksBackDown() {
// Coming back from 4×, the boundary is slot(3) + gap = 336 and the re-entry point 10pt
// inside it, at 326 the same asymmetry as within the fit.
#expect(snapped(326, current: fit + 1) == fit + 1)
#expect(snapped(325.9, current: fit + 1) == fit)
#expect(snapped(330, current: fit + 1) == fit + 1, "re-entering the gap is not enough")
}
@Test("The ceiling is the strip's capacity, and units run well past the screen fit")
func theCeilingIsTheStripsCapacity() {
// 684 wide with 12pt gaps divides into at most 51 whole units before `standardWidth`'s 1pt
// floor would break the exact fill floor((684 12) / 13) and the dragged lane reads
// that back through the 47 units it added.
#expect(ceiling == 48)
#expect(ceiling >= fit)
#expect(standardFor(ceiling) >= 1)
#expect(abs(slotFor(ceiling) + 3 * standardFor(ceiling) + 5 * gap - pinned) < 0.0001)
// One unit further the 1pt floor engages, the division stops being a division, and the
// strip would overflow which is precisely why the ceiling sits where it does.
#expect(standardFor(ceiling + 1) == 1)
#expect(slotFor(ceiling + 1) + 3 + 5 * gap > pinned)
// The snap walks all the way there and stops.
#expect(snapped(5000, current: fit) == fit + 1)
#expect(snapped(5000, current: ceiling) == ceiling)
}
@Test("The ceiling never falls below the screen fit, and a degenerate strip falls back to it")
func theCeilingFallsBackToTheFit() {
// Shrinking is always allowed, so the fit is the floor of the ceiling however odd the
// inputs are a non-finite standard and a gap that would make the capacity formula
// meaningless both answer the fit rather than inventing a bound.
let degenerate: [(CGFloat, CGFloat)] = [(.nan, gap), (.infinity, gap), (standard, -1), (standard, -50)]
for (brokenStandard, brokenGap) in degenerate {
#expect(LaneLayoutMath.resizeMaxUnits(
startUnits: startUnits, startStandard: brokenStandard,
startTotalUnits: startTotal, fittingUnits: fit, gap: brokenGap) == fit)
}
// A start already past the fit (a window hanging off the screen) still cannot be clamped
// below where it stands.
#expect(LaneLayoutMath.resizeMaxUnits(
startUnits: 6, startStandard: .nan,
startTotalUnits: startTotal, fittingUnits: fit, gap: gap) == 6)
}
// MARK: The window's share of a tick
@Test("The window moves only for the part of a step that fits on screen")
func theWindowTakesOnlyItsShare() {
// Wholly inside the fit: every unit is the window's.
#expect(LaneLayoutMath.resizeWindowDelta(from: 1, to: 3, fittingUnits: fit, step: step) == 2 * step)
// A flick across the boundary: only the first unit was ever the window's to give.
#expect(LaneLayoutMath.resizeWindowDelta(from: 2, to: 5, fittingUnits: fit, step: step) == step)
// Wholly above it: the window is pinned and the re-divide does the whole of the work.
#expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 7, fittingUnits: fit, step: step) == 0)
#expect(LaneLayoutMath.resizeWindowDelta(from: fit, to: fit + 1, fittingUnits: fit, step: step) == 0)
// Shrinking mirrors it exactly the step handed back is the one that was taken.
#expect(LaneLayoutMath.resizeWindowDelta(from: 5, to: 2, fittingUnits: fit, step: step) == -step)
#expect(LaneLayoutMath.resizeWindowDelta(from: 7, to: 4, fittingUnits: fit, step: step) == 0)
#expect(LaneLayoutMath.resizeWindowDelta(from: 3, to: 1, fittingUnits: fit, step: step) == -2 * step)
#expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 4, fittingUnits: fit, step: step) == 0)
}
}
+81 -5
View File
@@ -16,13 +16,18 @@ import Testing
/// ///
/// Fixture geometry is `LaneLayoutMathTests`': a frozen standard of 100 and a gap of 12, so the slot /// 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 /// 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 /// (21). Every drag here **shrinks**, which keeps the arithmetic the frozen standard's throughout:
/// the count the drag started from (`LaneResizeSession.fittingMaxUnits`) and growth has no room /// with no `NSWindow` to measure, the on-screen fit is the count the drag started from
/// direction is nothing to the hold's state machine, which is what is under test. /// (`LaneResizeSession.fittingMaxUnits`), so growth would land in the re-divide regime that is the
/// suite at the foot of this file, and direction is nothing to the hold's state machine, which is
/// what is under test here.
private let standard: CGFloat = 100 private let standard: CGFloat = 100
private let gap: CGFloat = 12 private let gap: CGFloat = 12
/// The fixture strip's whole divide the 1× lane plus the 3× one, trash hidden.
private let boardUnits = 4
/// Enough leftward translation from a 3× start to land on 2×, and on 1×: 324 120 = 204, under the /// 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. /// 214 threshold and over the 102 one; 324 240 rubber-bands to 96, under both.
private let toTwoUnits: CGFloat = -120 private let toTwoUnits: CGFloat = -120
@@ -70,7 +75,8 @@ struct LaneResizeHoldTests {
/// A session mid-drag on lane two, `translation` points to the left of its 3× start. /// A session mid-drag on lane two, `translation` points to the left of its 3× start.
private func dragging(_ translation: CGFloat) -> LaneResizeSession { private func dragging(_ translation: CGFloat) -> LaneResizeSession {
let session = LaneResizeSession() let session = LaneResizeSession()
session.begin(laneID: lane2, units: 3, standard: standard, gap: gap, window: nil) session.begin(laneID: lane2, units: 3, standard: standard, gap: gap,
totalUnits: boardUnits, window: nil)
session.update(translation: translation) session.update(translation: translation)
return session return session
} }
@@ -334,7 +340,8 @@ struct LaneResizeHoldTests {
// width the release wrote not the snapshot's, which is still a round trip behind. // 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)) let onScreen = session.displayUnits(of: try lane(Ident.lane2, in: store))
#expect(onScreen == 2) #expect(onScreen == 2)
session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap, window: nil) session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap,
totalUnits: boardUnits, window: nil)
#expect(!session.isSettled) #expect(!session.isSettled)
#expect(session.isDragging(lane2)) #expect(session.isDragging(lane2))
@@ -357,3 +364,72 @@ struct LaneResizeHoldTests {
#expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) #expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap))
} }
} }
// MARK: - The drag past the screen
/// **The re-divide at the session level** (03-board-ui.md § Lane, settled 2026-08-08). A window with
/// no headroom one already flush against the screen's edge, or full screen, and in a fixture one
/// with no `NSWindow` at all has an on-screen fit equal to the count the drag started from, so its
/// very first tick is a re-divide: the strip's width is pinned, the dragged lane takes one more unit
/// of it, and the siblings compress. The clamp that used to sit at the fit is what made a lane on a
/// maximised window refuse to widen at all.
///
/// Same fixture as above the 1× lane, the 3× lane and no trash, so a four-unit strip 460 points
/// wide (100·4 + 12·5) dragged from **one** unit, which with no window makes the fit 1× and puts
/// every tick in the second regime.
@MainActor
@Suite("The lane resize past the screen's edge")
struct LaneResizePastTheScreenTests {
/// A drag of the 1× lane, `translation` points to the right of its start.
private func dragging(_ translation: CGFloat) -> LaneResizeSession {
let session = LaneResizeSession()
session.begin(laneID: lane1, units: 1, standard: standard, gap: gap,
totalUnits: boardUnits, window: nil)
session.update(translation: translation)
return session
}
@Test("With no window to grow, the drag re-divides instead of refusing to tick")
func aPinnedWindowRedividesFromTheFirstTick() {
let session = dragging(200)
// The 460pt strip re-divided: 2× puts the lane's edge at 167.2, 3× at 212, and 7× at
// 301.6, whose trailing gap is the first threshold the 300pt live edge has not cleared.
#expect(session.units == 7, "the fit is 1× here, and the old clamp stopped the tick dead")
#expect(session.liveWidth == 300, "well inside the strip's capacity, so no resistance")
// The siblings compress, which is what a re-divide IS: the standard every other lane is
// drawn at comes down as the dragged one takes more units of the same strip.
#expect(abs(session.standard - 32.8) < 0.0001) // (460 12·11) / 10
#expect(session.standard < standard)
}
@Test("The tick still stops — at the strip's capacity, where the re-divide runs out of strip")
func theRedivideStopsAtTheStripsCapacity() {
// floor((460 12) / 13) = 34 whole units the strip can still divide into, of which this
// lane contributes 31. Past that `standardWidth`'s 1pt floor would break the exact fill.
let session = dragging(10_000)
#expect(session.units == 31)
#expect(session.liveWidth > LaneLayoutMath.slotWidth(units: 31, standard: session.standard, gap: gap),
"the rubber band gives past the true end of travel, and the tick does not follow")
}
@Test("A release past the fit settles on the re-divided slot, not the frozen one")
func theSettleUsesTheRedividedStandard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = dragging(200)
session.end { id, units in store.setLaneWidth(id, units: units) }
#expect(session.isSettled)
#expect(session.hold == LaneWidthHold(laneID: lane1, units: 7))
// The width the release wrote, measured against the standard THAT count implies the one
// still governing the strip while the hold stands.
#expect(session.liveWidth
== LaneLayoutMath.slotWidth(units: 7, standard: session.standard, gap: gap))
#expect(abs(session.liveWidth - 301.6) < 0.0001)
}
}