import AppKit import SwiftUI /// **The user-draggable divider between the body column and the comments pane**, in the beside mount /// only (05-card-window.md ▸ The comments column, extended 2026-08-09: "the divider … is /// user-resizable, with sensible min/max clamps"). /// /// A real `HStack` element rather than an overlay hung off a neighbour — `LaneResizeHandle`'s own /// alternative, and the wrong one here: that handle overlaps *into* the inter-lane gap it already /// has a right to because it is drawn as part of the lane it resizes, and the lane strip is drawn /// after it in one continuous row. This divider sits between two independent panes with zero spacing /// between them (`HStack(spacing: 0)`), so an overlay wide enough to grab would paint under one /// neighbour and over the other depending on which sibling SwiftUI happens to draw second — a /// z-order bug waiting to be found by a user's cursor. Giving the divider its own modest width /// instead (`CardWindowMetrics.commentsDividerHitWidth`) is the same interactive width with none of /// that hazard: `Divider()` draws its hairline centered in the wider box, and the box itself is what /// both hovers and drags. /// /// **Pure arithmetic, impure host**: what a drag computes is `CardWindowMetrics /// .clampedCommentsColumnWidth`, called fresh on every tick; what this view owns is only the touch /// bookkeeping the arithmetic needs and cannot itself hold — the drag's anchor width, the last tick's /// clamped result (so release has something to commit), and the cursor push/pop balance /// (`LaneResizeHandle`'s own idiom, `NSCursor.resizeLeftRight`). struct CommentsColumnDivider: View { /// The body font's point size — every measurement here derives from it, same as the rest of the /// window. let bodyPointSize: CGFloat /// The beside `HStack`'s own width this render — the window's content area minus the fixed /// sidebar, and the figure the drag's upper clamp is measured against /// (`CardWindowMetrics.clampedCommentsColumnWidth`). let containerWidth: CGFloat /// The comments column's width **as it is drawn right now**, already clamped. Read once, at the /// first tick of a new drag, as the anchor translation is measured from — not a `@State` default, /// because the true current width lives one level up (`CardWindowView.commentsColumnWidth`, live /// mid-drag and persisted otherwise) and a stale copy captured at this view's own init would drift /// the moment a sibling window's drag changed the app-wide figure out from under it. let currentWidth: CGFloat /// Reports a new clamped width on every drag tick — the caller's job is only to hold it as a live /// override for this render (`CardWindowView.commentsColumnWidthOverride`), never to persist it: /// writing `UserDefaults` on every pixel of a drag would be `LaneResizeSession`'s mistake to make, /// and it already made a different choice — track live, write once. let onChange: (CGFloat) -> Void /// Fires exactly once per gesture, at release, with the width the drag ended on — the one write /// that reaches `storedCommentsColumnWidth`, plain callback for `onChange`'s reason: the source of /// truth is app-wide state this view does not own. let onCommit: (CGFloat) -> Void /// The drag's anchor — the column's width when the current gesture began, `nil` between drags. /// Captured once per gesture rather than measured incrementally, so a fast flick that fires /// several `.onChanged` events before this view re-renders still resolves against one fixed /// starting point instead of compounding rounding from tick to tick. @State private var anchor: CGFloat? /// The most recent clamped width this gesture computed — read back at `.onEnded` and handed to /// `onCommit`, since the gesture's own `DragGesture.Value` at release carries the final /// *translation*, not the clamped width that translation produced. @State private var liveWidth: CGFloat? /// Guards the cursor push/pop balance — `LaneResizeHandle`'s own reason: a fast cursor can leave /// the strip mid-drag, so the two calls must be idempotent to never leave a stuck resize cursor. @State private var cursorPushed = false private var hitWidth: CGFloat { CardWindowMetrics.commentsDividerHitWidth(bodyPointSize: bodyPointSize) } var body: some View { Divider() .frame(width: hitWidth) .contentShape(Rectangle()) .onHover { inside in if inside { pushCursor() } else { popCursor() } } // `.global`, not `.local`: dragging the divider moves the divider itself (the body // column shrinks or grows underneath it), so a `.local` translation would be measured // against a coordinate space that is sliding out from under the gesture — the same // reasoning `LaneResizeHandle` states for its own strip. .gesture( DragGesture(minimumDistance: 2, coordinateSpace: .global) .onChanged { value in if anchor == nil { pushCursor() anchor = currentWidth } // Dragging left (negative translation) widens the comments pane — the // divider is trailing the body, so ceding ground to the left is the pane // gaining it. let proposed = (anchor ?? currentWidth) - value.translation.width let clamped = CardWindowMetrics.clampedCommentsColumnWidth( proposed, bodyPointSize: bodyPointSize, containerWidth: containerWidth ) liveWidth = clamped onChange(clamped) } .onEnded { _ in if let liveWidth { onCommit(liveWidth) } anchor = nil liveWidth = nil popCursor() } ) // Pointer-only, `LaneResizeHandle`'s own carve-out: the accessible path to the same // outcome is **View ▸ Comments Beside Body** unchecked, which drops the divider // entirely rather than asking a keyboard to drive a continuous width. .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 } }