Files
lanework/Kanban/UI/Board/LaneResizeSession.swift
T
rzen ff3ba298f0 Build the full-visibility lane layout
The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 08:16:14 -04:00

189 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import QuartzCore
import SwiftUI
/// Window-local state for an in-flight lane resize — the right-edge drag of 03-board-ui.md § Lane.
/// At most one runs per board window at a time; `BoardView` owns it as `@State` and hands it to the
/// lanes and their grab strips.
///
/// The interaction has three collaborating pieces: `LaneLayoutMath` (the pure geometry), this
/// session (the live state plus the per-tick window resize), and `LaneResizeHandle` (the invisible
/// grab strip that drives it from a `DragGesture`). All three are ported from the pathfinder's
/// proven `ColumnResize.swift`, which is what 03-board-ui.md's "pathfinder behavior, proven" refers
/// to.
///
/// ### 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.
///
/// 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 0.2s curves — the lane-resize
/// entry in 03-board-ui.md § Motion's snappy-spring vocabulary — so the window edge and the lanes to
/// its right travel as one.
@MainActor
@Observable
final class LaneResizeSession {
/// The lane being resized; `nil` when idle. Observed — flipping it drives `BoardView`'s
/// frozen-standard override and the shadow slot on and off, and `LaneView`'s column count.
private(set) var laneID: ItemID?
/// The dragged lane's live rendered width — tracks the cursor continuously (rubber-banded at
/// the ends), so its masonry reflows live between ticks.
private(set) var liveWidth: CGFloat = 0
/// The snapped unit count k. Drives the shadow slot width, the layout slot the siblings
/// position off, and the resizing lane's masonry column count. Ticks by ±1 and animates.
private(set) var units: Int = 1
/// The strip's standard (1×) width, frozen at drag start. 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
/// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin.
@ObservationIgnored private var gap: CGFloat = 12
/// 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.
@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.
@ObservationIgnored private weak var window: NSWindow?
var isActive: Bool { laneID != nil }
func isResizing(_ id: ItemID) -> Bool { laneID == id }
/// The tick-down re-entry distance: how far the live edge must retreat back into a gap it has
/// already crossed before the shadow shrinks (03-board-ui.md § Lane's "10pt release
/// hysteresis" — see `LaneLayoutMath.snappedUnits`). Fixed in points, not proportional to
/// `standard`: it only needs to be comfortably larger than cursor jitter, which 10pt is
/// regardless of lane size.
private let reentry: CGFloat = 10
/// 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.
private var allowedRange: ClosedRange<Int> {
1...max(1, fittingUnits)
}
private var minSlot: CGFloat {
LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap)
}
private var maxSlot: CGFloat {
LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap)
}
// MARK: - Lifecycle
/// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far
/// the window can grow on its current screen.
func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) {
self.laneID = laneID
self.startUnits = units
self.units = units
self.standard = standard
self.gap = gap
self.window = window
self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window)
}
/// Applies a drag translation (points, measured from the gesture's start): tracks the live width
/// to the cursor with end-resistance, then ticks the snapped unit count to its fixed point and
/// applies the result in one animated step.
///
/// A single call to `snappedUnits` only ever steps by ±1, but a fast flick can carry the live
/// width across two (or more) thresholds between consecutive gesture events, so it is iterated
/// here until it stops moving — bounded by `allowedRange`'s width, so this never loops more than
/// a couple of times in practice. `tick(to:)` already computes a correct multi-step window-size
/// delta from `units` to the target, so only the FINAL target gets one `tick` call, not one per
/// intermediate step.
func update(translation: CGFloat) {
guard isActive else { return }
let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap)
liveWidth = LaneLayoutMath.resistedWidth(
proposed: startSlot + 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)
if next == target { break }
target = next
}
if target != units { tick(to: target) }
}
/// Commits the snapped unit count and dismisses the session. Order matters for a flash-free
/// handoff: write the model FIRST (the session is still active, so the frozen standard still
/// governs and the shadow slot does not budge), THEN clear the session inside the snap animation
/// — at which point `BoardView` reverts to the viewport-derived standard, which the window
/// sizing has kept equal to the frozen one, so the resting layout reproduces the same pixels
/// while the live width animates the last sub-tick of overflow/underfill away. The window and
/// the siblings are already in place; neither is touched here.
///
/// The commit is a *write*, not a snapshot mutation: it goes to disk through the Writer and
/// comes back as an ordinary reload (02-architecture.md § Layering's one-way flow), so the lane
/// briefly renders at its pre-drag width if the write fails — which is exactly the honesty the
/// banner then explains.
func end(commit: (ItemID, Int) -> Void) {
guard let laneID else { return }
commit(laneID, units)
withAnimation(.snappy(duration: 0.2)) {
self.laneID = nil
self.liveWidth = 0
}
}
// MARK: - Tick
/// A single snapped step: animate the unit count (which resizes the shadow slot, translates the
/// lanes to the right, and reflows the resizing lane's interior columns) and the window's width
/// on matching 0.2s 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.
private func tick(to newUnits: Int) {
let delta = CGFloat(newUnits - units) * (standard + gap)
withAnimation(.snappy(duration: 0.2)) { units = newUnits }
guard let window else { return }
var frame = window.frame
frame.size.width += delta // right-edge growth: origin and height unchanged
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
context.allowsImplicitAnimation = true
window.setFrame(frame, display: true)
}
}
/// 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).
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
return LaneLayoutMath.maxUnits(currentUnits: currentUnits, headroom: headroom, step: standard + gap)
}
}