The comments column gets a handle — the beside-mount divider is now user-draggable
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
This commit is contained in:
@@ -107,6 +107,26 @@ public enum AppPreferences {
|
|||||||
UserDefaults.standard.bool(forKey: commentsNewestFirstKey)
|
UserDefaults.standard.bool(forKey: commentsNewestFirstKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: The comments column's user width
|
||||||
|
|
||||||
|
/// **The comments column's width, once the beside-mount divider has been dragged** — the drag's
|
||||||
|
/// memory (05-card-window.md ▸ The comments column, extended 2026-08-09: "the divider between
|
||||||
|
/// the body and the comments column is user-resizable … app-wide, persisted, the same home as
|
||||||
|
/// the pane's other two bits").
|
||||||
|
///
|
||||||
|
/// App-wide for the neighbours' reason exactly: every open card window's divider answers to one
|
||||||
|
/// figure, so a window that took it as a parameter would need something above it keeping every
|
||||||
|
/// window in step with a value that has exactly one instance — which is also what makes
|
||||||
|
/// `@AppStorage` the right binding for it, as it is for `commentsBesideBody`.
|
||||||
|
///
|
||||||
|
/// Stored as a `Double` in points — what a drag actually produces — rather than in characters:
|
||||||
|
/// re-deriving characters from it on every launch would round-trip through the body font for a
|
||||||
|
/// number that is no longer a character count once someone has dragged it away from the default.
|
||||||
|
/// **`0` reads as "never dragged"**: `CardWindowMetrics.clampedCommentsColumnWidth`'s floor is
|
||||||
|
/// always positive, so a real drag can never land on zero, which makes the plain
|
||||||
|
/// `double(forKey:)` reading safe here in a way `boardZoomLevel`'s note warns it usually is not.
|
||||||
|
public static let commentsColumnWidthKey = "commentsColumnWidth"
|
||||||
|
|
||||||
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
||||||
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
||||||
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
||||||
|
|||||||
@@ -127,9 +127,11 @@ enum CardWindowMetrics {
|
|||||||
/// (05-card-window.md ▸ Composition; ▸ The comments column).
|
/// (05-card-window.md ▸ Composition; ▸ The comments column).
|
||||||
///
|
///
|
||||||
/// Wider than the sidebar and narrower than the body's default measure, which is what it holds:
|
/// Wider than the sidebar and narrower than the body's default measure, which is what it holds:
|
||||||
/// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer. It is a
|
/// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer.
|
||||||
/// *fixed* width for the sidebar's reason — "resize flex always goes to the body, never the fixed
|
/// **The resting default, not a ceiling** (re-ruled 2026-08-09 — the divider between it and the
|
||||||
/// panes" (05 ▸ Composition) — so this is not a fraction of anything either.
|
/// body is user-draggable now, `clampedCommentsColumnWidth` below): this is what a fresh window
|
||||||
|
/// opens at before anyone has dragged it, `bodyDefaultCharacters`'s own relationship to the body
|
||||||
|
/// column read for this one instead.
|
||||||
///
|
///
|
||||||
/// Stacked, the pane takes the body's width instead and this number is not consulted at all,
|
/// Stacked, the pane takes the body's width instead and this number is not consulted at all,
|
||||||
/// which is why the window's minimum grows only in the beside mount (`CommentsMount.widensWindow`).
|
/// which is why the window's minimum grows only in the beside mount (`CommentsMount.widensWindow`).
|
||||||
@@ -141,13 +143,53 @@ enum CardWindowMetrics {
|
|||||||
|
|
||||||
/// The narrowest the comments pane is allowed to get — the floor its share of the window's
|
/// The narrowest the comments pane is allowed to get — the floor its share of the window's
|
||||||
/// minimum is measured at, a shorter measure than the body's because a comment is a remark rather
|
/// minimum is measured at, a shorter measure than the body's because a comment is a remark rather
|
||||||
/// than a document.
|
/// than a document. **And, since 2026-08-09, the same number the divider drag may not cross** —
|
||||||
|
/// one figure serving both jobs is what keeps the window's minimum and the drag's floor from
|
||||||
|
/// silently disagreeing (`clampedCommentsColumnWidth`).
|
||||||
static let commentsMinimumCharacters: CGFloat = 28
|
static let commentsMinimumCharacters: CGFloat = 28
|
||||||
|
|
||||||
static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
|
static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
|
columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Clamps a proposed comments-column width to what the divider drag may reach** (05-card-window.md
|
||||||
|
/// ▸ The comments column, extended 2026-08-09 — the beside mount's divider is user-resizable).
|
||||||
|
///
|
||||||
|
/// Two floors, read from opposite ends:
|
||||||
|
/// - never narrower than `commentsMinimumWidth` — "the comments pane shouldn't collapse below a
|
||||||
|
/// usable width", the same figure the window's own minimum already reserves for it;
|
||||||
|
/// - never wide enough to leave the body under `bodyMinimumWidth` — "main content keeps a
|
||||||
|
/// healthy minimum" made exact, which is why this needs `containerWidth` at all: the beside
|
||||||
|
/// `HStack`'s own width, the window's content area minus the fixed sidebar, is what the second
|
||||||
|
/// floor is measured against.
|
||||||
|
///
|
||||||
|
/// A container too narrow for both floors at once — a window part-way through animating to its
|
||||||
|
/// minimum, say — still answers `commentsMinimumWidth` rather than a maximum below the minimum,
|
||||||
|
/// which a bare `min(max(proposed, minimum), maximum)` would produce for a negative `maximum`.
|
||||||
|
/// That is `CommentsMount.bodyHeight`'s `max(0, …)` posture again: the floor wins over an input
|
||||||
|
/// that has gone past what the arithmetic can honor.
|
||||||
|
///
|
||||||
|
/// Pure, and deliberately unaware of any live window — a drag session and a unit test alike call
|
||||||
|
/// it with whatever numbers they have; nothing here reads `NSFont` or an `NSWindow`.
|
||||||
|
static func clampedCommentsColumnWidth(
|
||||||
|
_ proposed: CGFloat,
|
||||||
|
bodyPointSize: CGFloat,
|
||||||
|
containerWidth: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
let minimum = commentsMinimumWidth(bodyPointSize: bodyPointSize)
|
||||||
|
let maximum = max(minimum, containerWidth - bodyMinimumWidth(bodyPointSize: bodyPointSize))
|
||||||
|
return min(max(proposed, minimum), maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The comments divider's interactive width — wider than the hairline it draws so the cursor has
|
||||||
|
/// something real to land the resize pointer on, the lane strip's own reasoning
|
||||||
|
/// (`BoardMetrics.resizeHandleWidth`) applied to a divider that is a genuine `HStack` element
|
||||||
|
/// rather than an overlay hung off a neighbour. Narrower than a full gutter: it is a grab target,
|
||||||
|
/// not a gap.
|
||||||
|
static func commentsDividerHitWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
(bodyPointSize * 0.7).rounded()
|
||||||
|
}
|
||||||
|
|
||||||
/// The smallest an attachment chip may be before the row wraps — a thumbnail, a few characters of
|
/// The smallest an attachment chip may be before the row wraps — a thumbnail, a few characters of
|
||||||
/// filename, and the padding around them. Middle truncation does the rest, so a long name shrinks
|
/// filename, and the padding around them. Middle truncation does the rest, so a long name shrinks
|
||||||
/// rather than widening the pane.
|
/// rather than widening the pane.
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import UniformTypeIdentifiers
|
|||||||
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
|
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
|
||||||
///
|
///
|
||||||
/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width, and
|
/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width, and
|
||||||
/// the resize flex still goes to the body and never to the two fixed panes.
|
/// the resize flex still goes to the body and never to the sidebar. **The comments column is no
|
||||||
|
/// longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the beside
|
||||||
|
/// mount, so the flex the body and the comments pane split is now the user's to move, within the two
|
||||||
|
/// floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing.
|
||||||
///
|
///
|
||||||
/// ### What this milestone builds, and what it deliberately does not
|
/// ### What this milestone builds, and what it deliberately does not
|
||||||
///
|
///
|
||||||
@@ -37,8 +40,12 @@ import UniformTypeIdentifiers
|
|||||||
/// ### The width rule, in one line
|
/// ### The width rule, in one line
|
||||||
///
|
///
|
||||||
/// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That
|
/// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That
|
||||||
/// is the whole of "the window's resize flex goes to the body" — no split view, no stored divider
|
/// is the whole of "the window's resize flex goes to the body, never the sidebar" — no split view
|
||||||
/// position, nothing for a drag to disagree with.
|
/// between body and sidebar, no stored divider position there, nothing for a drag to disagree with.
|
||||||
|
///
|
||||||
|
/// **The comments column keeps its own, separate width rule** (added 2026-08-09,
|
||||||
|
/// `CommentsColumnDivider`): a real divider, a drag, and a stored width — the exception this
|
||||||
|
/// sentence used to have none of, and still does not have between the body and the *sidebar*.
|
||||||
///
|
///
|
||||||
/// ### Why the title no longer scrolls with the body
|
/// ### Why the title no longer scrolls with the body
|
||||||
///
|
///
|
||||||
@@ -110,6 +117,21 @@ struct CardWindowView: View {
|
|||||||
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
||||||
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
||||||
|
|
||||||
|
/// **The comments column's user-set width**, in points — the divider's memory (05-card-window.md
|
||||||
|
/// ▸ The comments column, extended 2026-08-09). `@AppStorage` for the same reason its two
|
||||||
|
/// neighbours above are: every open card window's divider answers to one figure. `0` is the
|
||||||
|
/// "never dragged" reading (`AppPreferences.commentsColumnWidthKey`'s doc) — a real drag can
|
||||||
|
/// never land there, since `CardWindowMetrics.clampedCommentsColumnWidth`'s floor is always
|
||||||
|
/// positive.
|
||||||
|
@AppStorage(AppPreferences.commentsColumnWidthKey) private var storedCommentsColumnWidth: Double = 0
|
||||||
|
|
||||||
|
/// **The divider's in-flight width**, live only while a drag is running — `nil` the rest of the
|
||||||
|
/// time. `LaneResizeSession.liveWidth`'s same split applied to a much smaller session: the drag
|
||||||
|
/// tracks the cursor in memory on every tick (`CommentsColumnDivider.onChange`), and only the
|
||||||
|
/// *release* writes `storedCommentsColumnWidth` (`.onCommit`), so a flick that fires dozens of
|
||||||
|
/// events writes `UserDefaults` — and every sibling card window's divider — exactly once.
|
||||||
|
@State private var commentsColumnWidthOverride: CGFloat?
|
||||||
|
|
||||||
/// The body font's point size, read once per body evaluation: every measurement in this view —
|
/// The body font's point size, read once per body evaluation: every measurement in this view —
|
||||||
/// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale
|
/// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale
|
||||||
/// together when the system text size changes.
|
/// together when the system text size changes.
|
||||||
@@ -118,6 +140,32 @@ struct CardWindowView: View {
|
|||||||
/// Where the comments pane sits, when it is shown at all.
|
/// Where the comments pane sits, when it is shown at all.
|
||||||
private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }
|
private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }
|
||||||
|
|
||||||
|
/// The comments column's width before this render's clamp: the live drag's own width while one is
|
||||||
|
/// running, the persisted figure once the divider has ever been dragged and released, and
|
||||||
|
/// `CardWindowMetrics.commentsColumnWidth`'s font-derived default before either has ever happened.
|
||||||
|
/// Unclamped on purpose: clamping needs the beside `HStack`'s live container width, which only the
|
||||||
|
/// `.beside` render branch has (`clampedCommentsColumnWidth(in:)`), so this stays the one place
|
||||||
|
/// that decides *which number* to clamp rather than also deciding *how far*.
|
||||||
|
private var commentsColumnWidth: CGFloat {
|
||||||
|
if let commentsColumnWidthOverride { return commentsColumnWidthOverride }
|
||||||
|
return storedCommentsColumnWidth > 0
|
||||||
|
? CGFloat(storedCommentsColumnWidth)
|
||||||
|
: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `commentsColumnWidth` clamped against `containerWidth` — the beside `HStack`'s own width this
|
||||||
|
/// render (`CardWindowMetrics.clampedCommentsColumnWidth`). Re-derived every render rather than
|
||||||
|
/// written back into `storedCommentsColumnWidth`: a window too narrow for the persisted figure
|
||||||
|
/// (a smaller display, say) shows the clamped width without silently shrinking the preference a
|
||||||
|
/// wider window will want back.
|
||||||
|
private func clampedCommentsColumnWidth(in containerWidth: CGFloat) -> CGFloat {
|
||||||
|
CardWindowMetrics.clampedCommentsColumnWidth(
|
||||||
|
commentsColumnWidth,
|
||||||
|
bodyPointSize: bodyPointSize,
|
||||||
|
containerWidth: containerWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The two columns — **or the raw-source editor in place of both of them**.
|
/// The two columns — **or the raw-source editor in place of both of them**.
|
||||||
///
|
///
|
||||||
/// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the
|
/// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the
|
||||||
@@ -178,18 +226,38 @@ struct CardWindowView: View {
|
|||||||
if showComments {
|
if showComments {
|
||||||
switch mount {
|
switch mount {
|
||||||
case .beside:
|
case .beside:
|
||||||
|
// The `GeometryReader` is new here for the divider's sake — a resizable column needs
|
||||||
|
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`),
|
||||||
|
// exactly the reason the stacked branch below already reaches for one.
|
||||||
|
GeometryReader { proxy in
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
bodyColumn
|
bodyColumn
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
|
||||||
Divider()
|
CommentsColumnDivider(
|
||||||
|
bodyPointSize: bodyPointSize,
|
||||||
|
containerWidth: proxy.size.width,
|
||||||
|
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
|
||||||
|
onChange: { commentsColumnWidthOverride = $0 },
|
||||||
|
onCommit: { width in
|
||||||
|
storedCommentsColumnWidth = Double(width)
|
||||||
|
// The persisted figure now says the same thing the override does —
|
||||||
|
// clearing it hands `commentsColumnWidth` back to one source of truth
|
||||||
|
// rather than two that happen to agree.
|
||||||
|
commentsColumnWidthOverride = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
commentsPane
|
commentsPane
|
||||||
// Fixed, like the sidebar: "resize flex always goes to the body, never the
|
// **User-resizable since 2026-08-09** — the sidebar's "resize flex
|
||||||
// fixed panes" (05 ▸ Composition).
|
// always goes to the body" (05 ▸ Composition) still holds between the
|
||||||
.frame(width: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize))
|
// body and the *sidebar*; the body and the *comments column* now split
|
||||||
|
// it by the divider's drag instead, clamped so neither pane's own floor
|
||||||
|
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
|
||||||
|
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
|
||||||
.frame(maxHeight: .infinity, alignment: .top)
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case .stacked:
|
case .stacked:
|
||||||
// The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to
|
// The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,87 @@ struct CommentsWindowMinimumTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The column divider's clamp
|
||||||
|
|
||||||
|
@Suite("Comments ▸ the column divider")
|
||||||
|
struct CommentsColumnDividerClampTests {
|
||||||
|
|
||||||
|
@Test("A proposal between the two floors passes through untouched")
|
||||||
|
func withinBoundsPassesThrough() {
|
||||||
|
// A generous container — 1200pt — so neither floor is anywhere near the proposal.
|
||||||
|
#expect(
|
||||||
|
CardWindowMetrics.clampedCommentsColumnWidth(400, bodyPointSize: 13, containerWidth: 1200) == 400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A proposal narrower than the minimum clamps up to it")
|
||||||
|
func tooNarrowClampsToMinimum() {
|
||||||
|
let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
|
||||||
|
#expect(
|
||||||
|
CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 13, containerWidth: 1200)
|
||||||
|
== minimum
|
||||||
|
)
|
||||||
|
// Zero and negative proposals — a drag that overshoots past the window's own edge — read the
|
||||||
|
// same way, never as a width smaller than the floor.
|
||||||
|
#expect(
|
||||||
|
CardWindowMetrics.clampedCommentsColumnWidth(-50, bodyPointSize: 13, containerWidth: 1200)
|
||||||
|
== minimum
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A proposal that would starve the body clamps down to the body's own floor")
|
||||||
|
func tooWideClampsToLeaveTheBodyItsFloor() {
|
||||||
|
let bodyFloor = CardWindowMetrics.bodyMinimumWidth(bodyPointSize: 13)
|
||||||
|
let containerWidth: CGFloat = 700
|
||||||
|
let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
|
||||||
|
10_000, bodyPointSize: 13, containerWidth: containerWidth
|
||||||
|
)
|
||||||
|
#expect(clamped == containerWidth - bodyFloor)
|
||||||
|
#expect(containerWidth - clamped == bodyFloor, "the body keeps exactly its floor, not less")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A container too narrow for both floors still answers the comments floor, never less")
|
||||||
|
func aStarvedContainerStillAnswersTheFloor() {
|
||||||
|
// A container narrower than `commentsMinimumWidth + bodyMinimumWidth` — the two floors
|
||||||
|
// cannot both be honored, and the comments floor wins rather than the arithmetic producing a
|
||||||
|
// maximum below the minimum (`min(max(proposed, minimum), maximum)` with a negative maximum).
|
||||||
|
let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
|
||||||
|
#expect(
|
||||||
|
CardWindowMetrics.clampedCommentsColumnWidth(minimum, bodyPointSize: 13, containerWidth: 10)
|
||||||
|
== minimum
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
CardWindowMetrics.clampedCommentsColumnWidth(10_000, bodyPointSize: 13, containerWidth: 10)
|
||||||
|
== minimum
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("At the window's own minimum size, the clamp forces exactly the comments floor")
|
||||||
|
func atTheWindowMinimumTheClampAgreesWithIt() {
|
||||||
|
// `CardWindowMetrics.minimumSize(commentsColumn: true)` is sidebar + body floor + comments
|
||||||
|
// floor. The beside `HStack`'s container at that window size is the window minus the sidebar
|
||||||
|
// — body floor plus comments floor — so a persisted width far larger than either floor still
|
||||||
|
// has to land on exactly the comments floor here, or the window's own minimum and the
|
||||||
|
// divider's drag floor would disagree about what "as small as this gets" means.
|
||||||
|
let bodyPointSize: CGFloat = 13
|
||||||
|
let minimum = CardWindowMetrics.minimumSize(bodyPointSize: bodyPointSize, commentsColumn: true)
|
||||||
|
let sidebarWidth = CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)
|
||||||
|
let containerWidth = minimum.width - sidebarWidth
|
||||||
|
|
||||||
|
let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
|
||||||
|
10_000, bodyPointSize: bodyPointSize, containerWidth: containerWidth
|
||||||
|
)
|
||||||
|
#expect(clamped == CardWindowMetrics.commentsMinimumWidth(bodyPointSize: bodyPointSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The floor scales with the body font, like every other measurement in this window")
|
||||||
|
func theFloorScalesWithTheBodyFont() {
|
||||||
|
let small = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 11, containerWidth: 1200)
|
||||||
|
let large = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 24, containerWidth: 1200)
|
||||||
|
#expect(large > small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The sort direction
|
// MARK: - The sort direction
|
||||||
|
|
||||||
@Suite("Comments ▸ the sort direction")
|
@Suite("Comments ▸ the sort direction")
|
||||||
|
|||||||
Reference in New Issue
Block a user