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
90 lines
3.8 KiB
Swift
90 lines
3.8 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// The invisible 12pt grab strip overlaid at a lane's trailing edge that drives a
|
||
/// `LaneResizeSession` from a `DragGesture` (03-board-ui.md § Lane, right-edge drag-to-resize).
|
||
///
|
||
/// Overlaid so ~8pt hangs into the inter-lane gap and only ~4pt sits over the lane itself (clear of
|
||
/// the lane's own vertical scrollbar, which lives at that inner edge). **Every lane gets one, the
|
||
/// last included** — the rightmost lane's drag is the one that grows the window into free screen
|
||
/// space, which is the interaction's headline case.
|
||
///
|
||
/// It carries no drop target of its own, so it never participates in card/lane/file drops, and as an
|
||
/// overlay it hit-tests above the lane body's own gestures.
|
||
struct LaneResizeHandle: View {
|
||
|
||
let store: BoardStore
|
||
let session: LaneResizeSession
|
||
|
||
let laneID: ItemID
|
||
|
||
/// The lane's committed unit count — the k the session starts from.
|
||
let committedUnits: Int
|
||
|
||
/// The strip's standard (1×) width THIS render; captured as the frozen standard the instant the
|
||
/// drag begins.
|
||
let standard: CGFloat
|
||
let gap: CGFloat
|
||
|
||
/// 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.
|
||
let window: @MainActor () -> NSWindow?
|
||
|
||
/// Guards `NSCursor` push/pop balance — a fast cursor can leave the strip mid-drag, and the drag
|
||
/// can end on or off it, so pushes and pops must be idempotent to never leave a stuck resize
|
||
/// cursor.
|
||
@State private var cursorPushed = false
|
||
|
||
private let handleWidth: CGFloat = 12
|
||
|
||
/// Rightward shift: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
||
/// into the gap (clear of the scrollbar).
|
||
private let overhang: CGFloat = 8
|
||
|
||
var body: some View {
|
||
Color.clear
|
||
.frame(width: handleWidth)
|
||
.frame(maxHeight: .infinity)
|
||
.contentShape(Rectangle())
|
||
.offset(x: overhang)
|
||
.onHover { inside in
|
||
if inside { pushCursor() } else { popCursor() }
|
||
}
|
||
// `.global` (window-fixed) space, NOT the handle's own: a tick moves the handle with its
|
||
// lane, but the window's top-left is pinned (right-edge growth), so a window-fixed
|
||
// translation stays a faithful physical-cursor delta throughout the drag.
|
||
.gesture(
|
||
DragGesture(minimumDistance: 2, coordinateSpace: .global)
|
||
.onChanged { value in
|
||
if !session.isResizing(laneID) {
|
||
// m5-drag: a resize and a card/lane move must not run at once — they
|
||
// would both mutate the same strip layout. The board's drag state does
|
||
// 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())
|
||
}
|
||
session.update(translation: value.translation.width)
|
||
}
|
||
.onEnded { _ in
|
||
guard session.isResizing(laneID) else { return }
|
||
session.end { id, units in store.setLaneWidth(id, units: units) }
|
||
popCursor()
|
||
}
|
||
)
|
||
}
|
||
|
||
private func pushCursor() {
|
||
guard !cursorPushed else { return }
|
||
NSCursor.resizeLeftRight.push()
|
||
cursorPushed = true
|
||
}
|
||
|
||
private func popCursor() {
|
||
guard cursorPushed else { return }
|
||
NSCursor.pop()
|
||
cursorPushed = false
|
||
}
|
||
}
|