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
+102 -37
View File
@@ -64,19 +64,27 @@ struct LaneWidthHold: Equatable, Sendable {
///
/// ### The invariant that makes it feel solid
///
/// **While a session is active, every OTHER lane keeps its exact pixel width.** That is achieved by
/// freezing the strip's standard (1×) width at drag start and sizing the *window* so that after
/// each snap tick the ordinary viewport-derived formula reproduces that frozen standard exactly
/// so releasing the drag hands back to the resting layout with no pixel jump. This is the opposite
/// mechanism from the stepper (and its / keyboard face), which never touches the window and
/// re-divides the existing width across the new unit total; the design is explicit that
/// window-growing behaviour belongs to the drag alone.
/// **While the window still has screen to grow into, every OTHER lane keeps its exact pixel width.**
/// That is achieved by freezing the strip's standard (1×) width at drag start and sizing the
/// *window* so that after each snap tick the ordinary viewport-derived formula reproduces that
/// frozen standard exactly so releasing the drag hands back to the resting layout with no pixel
/// jump.
///
/// **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
/// (`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`
/// 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
///
@@ -89,7 +97,7 @@ struct LaneWidthHold: Equatable, Sendable {
final class LaneResizeSession {
/// 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.
private(set) var laneID: ItemID?
@@ -109,12 +117,21 @@ final class LaneResizeSession {
/// does.
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
/// the standard from the live viewport width would feed the animation back into the layout and
/// pulse every lane. Read within renders already triggered by the observed properties above, so
/// it need not itself be observed.
@ObservationIgnored private(set) var standard: CGFloat = 1
///
/// 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.
@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.
@ObservationIgnored private var startUnits: Int = 1
/// The largest unit count that fits on screen. The drag's only ceiling: Lanework's `width` has
/// no cap (03-board-ui.md § Lane), so nothing else bounds growth.
/// The strip's whole unit total at drag start every lane's display units plus the trash
/// 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
/// The host window, resized by ±(standard + gap) on each tick. Weak a window can close,
/// though a resize cannot outlive the gesture that drives it.
/// The tick's actual ceiling: the strip's own capacity, the count past which the re-divide would
/// 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?
/// 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.
@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.
/// Whether the session governs the strip's layout at all its `standard` is in force for a drag
/// and for the hold that follows it alike.
var isActive: Bool { laneID != nil }
/// 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.
private let resistance: CGFloat = 0.25
/// Unit counts the tick may reach: one up to the on-screen fit. The floor is 1 because a lane
/// spans at least one unit; there is no ceiling but the screen.
/// 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; 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> {
1...max(1, fittingUnits)
1...max(1, ceilingUnits)
}
private var minSlot: CGFloat {
LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap)
/// The standard the strip would be drawn at with the dragged lane spanning `units` frozen
/// 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 {
LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap)
/// The dragged lane's rendered width at `units` its own units against the standard *that* count
/// 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
/// 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
/// 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?) {
///
/// `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()
self.laneID = laneID
self.startUnits = units
self.units = units
self.standard = standard
self.startStandard = standard
self.startTotalUnits = max(1, totalUnits)
self.gap = gap
self.window = window
self.reducedMotion = Motion.prefersReducedMotion
self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
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
@@ -234,15 +290,14 @@ final class LaneResizeSession {
/// intermediate step.
func update(translation: CGFloat) {
guard isDragging else { return }
let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap)
liveWidth = LaneLayoutMath.resistedWidth(
proposed: startSlot + translation,
proposed: slot(forUnits: startUnits) + translation,
minSlot: minSlot, maxSlot: maxSlot, resistance: resistance)
var target = units
while true {
let next = LaneLayoutMath.snappedUnits(
liveWidth: liveWidth, currentUnits: target,
standard: standard, gap: gap, allowedRange: allowedRange, reentry: reentry)
slotFor: slot(forUnits:), gap: gap, allowedRange: allowedRange, reentry: reentry)
if next == target { break }
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 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)) {
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
/// 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.
///
/// **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) {
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 }
guard let window else { return }
guard let window, delta != 0 else { return }
var frame = window.frame
frame.size.width += delta // right-edge growth: origin and height unchanged
// 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
/// 03-board-ui.md § Lane requires ("Growth hard-stops at the screen's visible frame"). Defers
/// the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current count is
/// the honest answer (growth needs a window to grow).
/// The on-screen fit, from the window's headroom to its screen's visible frame **where window
/// growth ends and the re-divide begins** (03-board-ui.md § Lane: "at the screen's visible frame
/// the window stops and the drag degrades to the re-divide"), which is a handover and not a stop.
/// 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 {
guard let window, let screen = window.screen ?? NSScreen.main else { return currentUnits }
let headroom = screen.visibleFrame.maxX - window.frame.maxX