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
+23 -8
View File
@@ -580,7 +580,7 @@ struct BoardView: View {
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
// **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
// the pre-drag layout for a round trip.
let resizing = resize.governs(lane.id)
@@ -625,6 +625,7 @@ struct BoardView: View {
committedUnits: units,
standard: standard,
gap: spacing,
totalUnits: stripTotalUnits,
window: window
)
// 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
/// 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
/// animating mid-resize, so deriving the standard 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
/// value equals what the formula yields once the session ends the handoff is seamless (see
/// During a resize session the standard comes from the session and not from the viewport: the
/// 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. Within the screen's fit the session's answer is
/// 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`).
///
/// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that
@@ -730,15 +735,25 @@ struct BoardView: View {
/// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
units += arrivingLaneUnits
return LaneLayoutMath.standardWidth(
stripWidth: stripWidth,
totalUnits: units,
totalUnits: stripTotalUnits + arrivingLaneUnits,
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;
/// 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,
/// 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
/// spans any whole number of units 1, so `allowedRange`'s ceiling is only ever the on-screen fit
/// (`maxUnits`) there is no `Column.widthRange` equivalent to fold in, and shrinking is always
/// allowed.
/// spans any whole number of units 1, so `allowedRange`'s ceiling is not a width range but the
/// strip's own capacity (`resizeMaxUnits`) there is no `Column.widthRange` equivalent to fold in,
/// and shrinking is always allowed.
enum LaneLayoutMath {
// 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
/// never oscillates.
///
/// Ticks are capped to `allowedRange`, which in Lanework folds in **only** the on-screen fit
/// (`maxUnits`) there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, no
/// cap"), and the uncapped widths beyond the screen's capacity are the stepper's business, not
/// the drag's.
/// Ticks are capped to `allowedRange`, whose ceiling is the strip's capacity (`resizeMaxUnits`)
/// there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, no cap"), and
/// past the screen fit the tick keeps firing, re-dividing rather than growing the window.
///
/// **`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(
liveWidth: CGFloat,
currentUnits: Int,
@@ -148,25 +182,21 @@ enum LaneLayoutMath {
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
let currentSlot = slotWidth(units: currentUnits, standard: standard, gap: gap)
if currentUnits < allowedRange.upperBound, liveWidth > currentSlot + gap {
return currentUnits + 1
}
if currentUnits > allowedRange.lowerBound {
let previousSlot = slotWidth(units: currentUnits - 1, standard: standard, gap: gap)
if liveWidth < previousSlot + gap - reentry {
return currentUnits - 1
}
}
return currentUnits
snappedUnits(
liveWidth: liveWidth,
currentUnits: currentUnits,
slotFor: { slotWidth(units: $0, standard: standard, gap: gap) },
gap: gap,
allowedRange: allowedRange,
reentry: reentry)
}
/// 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`
/// (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
/// visible frame, with rubber-band feedback"). The snap tick never follows the width past the
/// bound (see `snappedUnits`' clamp), so this is purely cosmetic give.
/// the bound without a hard stop (03-board-ui.md § Lane: the rubber band "moves to the true end
/// of travel the strip's own capacity"). The snap tick never follows the width past the bound
/// (see `snappedUnits`' clamp), so this is purely cosmetic give.
static func resistedWidth(
proposed: CGFloat,
minSlot: CGFloat,
@@ -184,9 +214,12 @@ enum LaneLayoutMath {
/// allowed regardless of screen room, including from a window already hanging off the edge
/// (negative headroom).
///
/// Unlike the pathfinder's twin there is no width ceiling to `min` against: the drag's only
/// bound is the screen, because it is the mechanism that grows the window. Larger widths are
/// reachable through the stepper, which re-divides instead (03-board-ui.md § Lane).
/// Unlike the pathfinder's twin this is **not the tick's ceiling** it is the boundary where
/// one mechanism hands over to the other (settled 2026-08-08, 03-board-ui.md § Lane). Up to it
/// 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
/// 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))
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 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
/// `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.
@@ -75,7 +82,8 @@ struct LaneResizeHandle: View {
// not exist yet; when it does, this is where the `isDragging` guard goes.
pushCursor()
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)
}
+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