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
116 lines
6.0 KiB
Swift
116 lines
6.0 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. What is **on screen**, which
|
||
/// while a release hold stands is the width that release wrote rather than the stale snapshot's
|
||
/// (`LaneResizeSession.displayUnits(of:)`).
|
||
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
|
||
|
||
/// 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.
|
||
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
|
||
|
||
/// The board's ruler (`BoardZoom`) — the gap this strip is proportioned against moves with the
|
||
/// level, so the grab target has to move with it too or it drifts off the gap it lives in.
|
||
@Environment(\.boardZoom) private var zoom
|
||
|
||
/// The grab strip's width and its rightward shift — both font-derived, because the inter-lane
|
||
/// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
|
||
/// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
|
||
/// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
||
/// into the gap (clear of the lane's own scrollbar).
|
||
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: zoom.bodyPointSize) }
|
||
|
||
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: zoom.bodyPointSize) }
|
||
|
||
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
|
||
// `isDragging`, not `governs`: a release hold on this very lane is still
|
||
// governing the strip, and a new drag begins over it rather than being
|
||
// mistaken for the old one still running (`LaneResizeSession.begin`).
|
||
if !session.isDragging(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,
|
||
totalUnits: totalUnits, window: window())
|
||
}
|
||
session.update(translation: value.translation.width)
|
||
}
|
||
.onEnded { _ in
|
||
guard session.isDragging(laneID) else { return }
|
||
session.end { id, units in store.setLaneWidth(id, units: units) }
|
||
popCursor()
|
||
}
|
||
)
|
||
// **Pointer-only, and out of the tree** — "the header context menu's width stepper — and
|
||
// its keyboard face, the Increase/Decrease Lane Width menu items — is the accessible
|
||
// path; edge drag is enhancement only" (10-accessibility.md ▸ Moving without dragging).
|
||
// An invisible strip that can only be dragged is a stop with nothing behind it, and it
|
||
// would sit between two lane containers in the strip's traversal.
|
||
.accessibilityHidden(true)
|
||
}
|
||
|
||
private func pushCursor() {
|
||
guard !cursorPushed else { return }
|
||
NSCursor.resizeLeftRight.push()
|
||
cursorPushed = true
|
||
}
|
||
|
||
private func popCursor() {
|
||
guard cursorPushed else { return }
|
||
NSCursor.pop()
|
||
cursorPushed = false
|
||
}
|
||
}
|