From 9a52b795b224b05da9e0e75d91cfa683bcdb4bde Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 21:36:56 -0400 Subject: [PATCH] =?UTF-8?q?The=20drag=20learns=20the=20stepper's=20trick?= =?UTF-8?q?=20=E2=80=94=20past=20the=20screen's=20edge,=20lane=20growth=20?= =?UTF-8?q?re-divides=20instead=20of=20stopping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 + DESIGN/03-board-ui.md | 2 +- Kanban/UI/Board/BoardView.swift | 31 +++- Kanban/UI/Board/LaneLayoutMath.swift | 183 ++++++++++++++++++--- Kanban/UI/Board/LaneResizeHandle.swift | 10 +- Kanban/UI/Board/LaneResizeSession.swift | 139 +++++++++++----- KanbanTests/LaneLayoutMathTests.swift | 202 ++++++++++++++++++++++-- KanbanTests/LaneResizeHoldTests.swift | 86 +++++++++- 8 files changed, 564 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d58071c..284b333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **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. The board's symbol now appears in the title bar beside the board's name, in its chosen tint. diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index eaebb18..d2f2b63 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -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. - 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 diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 921610b..e937492 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -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. /// diff --git a/Kanban/UI/Board/LaneLayoutMath.swift b/Kanban/UI/Board/LaneLayoutMath.swift index 429faf2..1ff43b1 100644 --- a/Kanban/UI/Board/LaneLayoutMath.swift +++ b/Kanban/UI/Board/LaneLayoutMath.swift @@ -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, + 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, 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 + } } diff --git a/Kanban/UI/Board/LaneResizeHandle.swift b/Kanban/UI/Board/LaneResizeHandle.swift index 7edb652..701e055 100644 --- a/Kanban/UI/Board/LaneResizeHandle.swift +++ b/Kanban/UI/Board/LaneResizeHandle.swift @@ -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) } diff --git a/Kanban/UI/Board/LaneResizeSession.swift b/Kanban/UI/Board/LaneResizeSession.swift index 8279da8..aba71bb 100644 --- a/Kanban/UI/Board/LaneResizeSession.swift +++ b/Kanban/UI/Board/LaneResizeSession.swift @@ -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 { - 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 diff --git a/KanbanTests/LaneLayoutMathTests.swift b/KanbanTests/LaneLayoutMathTests.swift index 79b0323..9354216 100644 --- a/KanbanTests/LaneLayoutMathTests.swift +++ b/KanbanTests/LaneLayoutMathTests.swift @@ -15,9 +15,12 @@ import Testing /// back to `k - 1` is `slotWidth(k - 1) + gap - reentry`, 10pt shy of that same boundary — so 2↔1 /// ticks down at 102 (112 − 10) and 3↔2 at 214 (224 − 10). /// -/// Where the pathfinder's twin suite pinned a hard 1…3 width cap, these pin the *screen fit*: in -/// Lanework `allowedRange`'s ceiling is only ever how far the window can grow (`maxUnits`), because -/// the width field itself has no cap. +/// Where the pathfinder's twin suite pinned a hard 1…3 width cap, these pin the two regimes the +/// screen fit divides (03-board-ui.md § Lane, settled 2026-08-08): up to the fit a tick grows the +/// 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 gap: CGFloat = 12 @@ -311,26 +314,26 @@ struct LaneSnapTests { @Test("The snap never steps past the allowed range") 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(-500, current: 1) == 1) } - @Test("The ceiling is the screen fit, and it is the only ceiling") - func snapRespectsTheOnScreenFit() { - // With the fit capping the range at 2×, no live width ticks to 3×. - let fitsTwo = 1...2 - #expect(snapped(1000, current: 2, range: fitsTwo) == 2) - #expect(snapped(5000, current: 2, range: fitsTwo) == 2) + @Test("The range's ceiling is the only ceiling, and it is not a width cap") + func snapRespectsTheAllowedCeiling() { + // With the range capped at 2×, no live width ticks to 3×. + let capsAtTwo = 1...2 + #expect(snapped(1000, current: 2, range: capsAtTwo) == 2) + #expect(snapped(5000, current: 2, range: capsAtTwo) == 2) // Below the cap it still ticks normally. - #expect(snapped(200, current: 1, range: fitsTwo) == 2) - // A roomier screen keeps ticking well past the pathfinder's old 3× ceiling — Lanework's - // width has no cap of its own (03-board-ui.md § Lane). + #expect(snapped(200, current: 1, range: capsAtTwo) == 2) + // A roomier ceiling keeps ticking well past the pathfinder's old 3× — Lanework's width has + // no cap of its own (03-board-ui.md § Lane). #expect(snapped(5000, current: 3, range: 1...9) == 4) #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") func maxUnitsFromHeadroom() { @@ -338,7 +341,8 @@ struct LaneSnapTests { #expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 250, step: step) == 3) // Just over one step → +1. #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: 2, headroom: 0, step: step) == 2) // 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) } } + +// 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) + } +} diff --git a/KanbanTests/LaneResizeHoldTests.swift b/KanbanTests/LaneResizeHoldTests.swift index 12e5edc..27bcb85 100644 --- a/KanbanTests/LaneResizeHoldTests.swift +++ b/KanbanTests/LaneResizeHoldTests.swift @@ -16,13 +16,18 @@ import Testing /// /// 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 (3→2) and 102 -/// (2→1). Every drag here **shrinks**, because with no `NSWindow` to measure the on-screen fit is -/// the count the drag started from (`LaneResizeSession.fittingMaxUnits`) and growth has no room — -/// direction is nothing to the hold's state machine, which is what is under test. +/// (2→1). Every drag here **shrinks**, which keeps the arithmetic the frozen standard's throughout: +/// with no `NSWindow` to measure, the on-screen fit is the count the drag started from +/// (`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 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 /// 214 threshold and over the 102 one; 324 − 240 rubber-bands to 96, under both. 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. private func dragging(_ translation: CGFloat) -> 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) return session } @@ -334,7 +340,8 @@ struct LaneResizeHoldTests { // width the release wrote — not the snapshot's, which is still a round trip behind. let onScreen = session.displayUnits(of: try lane(Ident.lane2, in: store)) #expect(onScreen == 2) - session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap, window: nil) + session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap, + totalUnits: boardUnits, window: nil) #expect(!session.isSettled) #expect(session.isDragging(lane2)) @@ -357,3 +364,72 @@ struct LaneResizeHoldTests { #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) + } +}