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