Files
lanework/Kanban/UI/Board/LaneResizeHandle.swift
T
rzen 409f430813 The lane-resize release gets its own hold — the strip stops two-stepping through the stale snapshot
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
2026-08-01 19:33:33 -04:00

104 lines
5.3 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 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
}
}