diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index b708b9b..fac8f62 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -107,6 +107,26 @@ public enum AppPreferences { 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, /// 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 diff --git a/Kanban/UI/Card/CardWindowMetrics.swift b/Kanban/UI/Card/CardWindowMetrics.swift index f85eee8..4414043 100644 --- a/Kanban/UI/Card/CardWindowMetrics.swift +++ b/Kanban/UI/Card/CardWindowMetrics.swift @@ -127,9 +127,11 @@ enum CardWindowMetrics { /// (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: - /// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer. It is a - /// *fixed* width for the sidebar's reason — "resize flex always goes to the body, never the fixed - /// panes" (05 ▸ Composition) — so this is not a fraction of anything either. + /// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer. + /// **The resting default, not a ceiling** (re-ruled 2026-08-09 — the divider between it and the + /// 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, /// 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 /// 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 func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat { 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 /// filename, and the padding around them. Middle truncation does the rest, so a long name shrinks /// rather than widening the pane. diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index d88ab41..afc421a 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -18,7 +18,10 @@ import UniformTypeIdentifiers /// 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 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 /// @@ -37,8 +40,12 @@ import UniformTypeIdentifiers /// ### The width rule, in one line /// /// 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 -/// position, nothing for a drag to disagree with. +/// is the whole of "the window's resize flex goes to the body, never the sidebar" — no split view +/// 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 /// @@ -110,6 +117,21 @@ struct CardWindowView: View { @AppStorage(AppPreferences.showCommentsKey) private var showComments = 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 sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale /// 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. 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**. /// /// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the @@ -178,17 +226,37 @@ struct CardWindowView: View { if showComments { switch mount { case .beside: - HStack(spacing: 0) { - bodyColumn - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + // 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) { + bodyColumn + .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 - // Fixed, like the sidebar: "resize flex always goes to the body, never the - // fixed panes" (05 ▸ Composition). - .frame(width: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize)) - .frame(maxHeight: .infinity, alignment: .top) + commentsPane + // **User-resizable since 2026-08-09** — the sidebar's "resize flex + // always goes to the body" (05 ▸ Composition) still holds between the + // 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) + } } case .stacked: diff --git a/Kanban/UI/Card/CommentsColumnDivider.swift b/Kanban/UI/Card/CommentsColumnDivider.swift new file mode 100644 index 0000000..494a948 --- /dev/null +++ b/Kanban/UI/Card/CommentsColumnDivider.swift @@ -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 + } +} diff --git a/KanbanTests/CardCommentsLayoutTests.swift b/KanbanTests/CardCommentsLayoutTests.swift index da1b91e..cfcd78e 100644 --- a/KanbanTests/CardCommentsLayoutTests.swift +++ b/KanbanTests/CardCommentsLayoutTests.swift @@ -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 @Suite("Comments ▸ the sort direction")