View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
108 lines
5.5 KiB
Swift
108 lines
5.5 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 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, 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
|
||
}
|
||
}
|