LaneWidthHold: after the release the session's frozen standard and the WRITTEN unit count keep governing the strip until a landed snapshot carries that width back — dividing the already-grown window by the stale unit total in between was the visible two-step. A deliberately separate type from CommittedHold: that hold stands in for an arrangement and any landing retires it; this one stands in for a value and only a landing that carries it will do. Echo reads through LaneLayoutMath.displayUnits so the width-1 key-removal case compares right; the watch rides landedReloads so a value-equal echo still answers; width writes now return whether bytes reached disk so a refused or no-op write dissolves the hold instead of arming it; the 1500ms timeout family covers the rest. Drag-perf card 231e3693. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
104 lines
5.3 KiB
Swift
104 lines
5.3 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
|
||
|
||
/// 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 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: BoardMetrics.bodyPointSize) }
|
||
|
||
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: BoardMetrics.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, 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
|
||
}
|
||
}
|