Kanban.xcodeproj must be regenerated (xcodegen generate) before building. CardWindowMetrics.commentsColumnWidth was a fixed figure between the body column and the comments pane in the side-by-side layout; it is now only the resting default. A new CommentsColumnDivider replaces the plain Divider() between the two panes with a real HStack element carrying a resize-left-right cursor and a drag gesture, clamped through a new pure seam, CardWindowMetrics.clampedCommentsColumnWidth — never narrower than the existing commentsMinimumWidth floor, never wide enough to push the body under its own bodyMinimumWidth. The drag tracks live in memory (commentsColumnWidthOverride) and writes AppPreferences.commentsColumnWidthKey exactly once, on release, mirroring LaneResizeSession's live-track/write-once split rather than hammering UserDefaults per tick. Persistence is app-wide via @AppStorage, matching showComments and commentsBesideBody — the pane's other two layout bits — rather than the per-card BoardRegistry.cardWindowFrames: this is "how the pane is arranged," the same kind of fact those two already are, not a per-card window geometry. Flagged on the card thread as a call worth owner review. Six new unit tests cover the clamp's two floors, the degenerate case where a container is too narrow for both, and its agreement with CardWindowMetrics.minimumSize at the window's own floor. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
125 lines
6.7 KiB
Swift
125 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|