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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user