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() } ) // **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 } }