Files
lanework/Kanban/UI/Card/CardWindowMetrics.swift
T
rzen 0a01405ced 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
2026-08-09 08:38:07 -04:00

300 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import CoreGraphics
/// The card window's fixed geometry — **derived from font metrics, never written down in points**
/// (05-card-window.md ▸ Composition: the attributes sidebar has a "fixed narrow width derived from
/// font metrics (full relative scaling, 10-accessibility.md)"; 10 ▸ Text: "relative text styles
/// everywhere, no fixed point sizes … metrics derive from font metrics, so layout survives the
/// largest system text sizes").
///
/// ### The derivation, named once
///
/// Every width here is **a character count in the body font**, and the arithmetic behind it is one
/// expression used three times:
///
/// ```
/// width = characters × averageCharacterAdvance × pointSize + 2 × gutter
/// ```
///
/// `averageCharacterAdvance` is the system font's rough average advance for mixed-case Latin text as
/// a fraction of its point size — half an em, the classic typesetter's estimate. It is deliberately
/// an *estimate* rather than a measurement: the sidebar is sized so a filename, a palette grid and a
/// key/value row have room, not so any particular string fits exactly, and a measured advance would
/// make this geometry depend on which glyphs happened to be on screen. The gutter is one em, which
/// is what keeps the whole thing scaling together.
///
/// ### Why the point size is a parameter
///
/// So the rule is a pure function and a test can hold it still. `bodyPointSize` below is the one
/// place that asks the system what the body font actually is; everything else takes it as an
/// argument, which is also what makes "the sidebar is narrower at 11pt and wider at 18pt" a fact a
/// suite can assert rather than something to be verified by eye at three text sizes.
enum CardWindowMetrics {
// MARK: - The unit
/// The system font's approximate average advance per character, as a fraction of its point size.
static let averageCharacterAdvance: CGFloat = 0.5
/// The horizontal inset on each side of a column: one em, so it scales with everything else.
static func gutter(bodyPointSize: CGFloat) -> CGFloat {
bodyPointSize
}
/// A column `characters` body-characters wide, gutters included — the one expression.
static func columnWidth(characters: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
let text = characters * averageCharacterAdvance * bodyPointSize
return (text + 2 * gutter(bodyPointSize: bodyPointSize)).rounded()
}
/// A line's height in the body font — the vertical counterpart of the advance, used only for the
/// window's minimum and default heights.
static func lineHeight(bodyPointSize: CGFloat) -> CGFloat {
bodyPointSize * 1.4
}
// MARK: - The sidebar
/// How wide the attributes sidebar is, in characters. Narrow by contract — it holds a
/// middle-truncated filename, a palette grid and a key/value row, and nothing in it ever wants
/// the window's spare width, which all goes to the body (05 ▸ Composition).
static let sidebarCharacters: CGFloat = 26
/// **The sidebar's width, and the only place it is decided.** Fixed for a given text size: the
/// window's resize flex goes entirely to the body column, so this is not a fraction of anything.
static func sidebarWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: sidebarCharacters, bodyPointSize: bodyPointSize)
}
/// What a sidebar *section* actually gets to lay out in: the column minus its two gutters.
///
/// Named because a section can need a number rather than a proposal — originally the embedded
/// style editor's grids, a fixed count of fixed-size wells per row whose count had to be decided
/// before the layout ran (`StyleEditorLayout.sidebar(contentWidth:)`); the Style section's compact
/// `SymbolPicker` (2026-08-08) replaced that embed, but the Background color combo it sits beside
/// still sizes itself off this figure (`CardStyleSection.backgroundComboRow`). Everything else in
/// the sidebar simply fills what it is proposed and never asks.
static func sidebarContentWidth(bodyPointSize: CGFloat) -> CGFloat {
sidebarWidth(bodyPointSize: bodyPointSize) - 2 * gutter(bodyPointSize: bodyPointSize)
}
// MARK: - The body column
/// The narrowest the body column is allowed to get — a measure of prose short enough to be a
/// floor rather than a preference.
static let bodyMinimumCharacters: CGFloat = 44
/// The body column at its resting default: a comfortable measure, which the user then resizes.
static let bodyDefaultCharacters: CGFloat = 74
static func bodyMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize)
}
// MARK: - A sidebar section
/// The gap between a sidebar section's header and its content, and between two rows of it —
/// half a gutter, which is the attachment rows' inset and the rendered body's rhythm too, so the
/// whole window is spaced by one unit rather than by three that happen to agree.
static func sidebarRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
previewPadding(bodyPointSize: bodyPointSize)
}
// MARK: - The attachments section
/// An attachment row's thumbnail: a **small** square, one and a half ems on a side
/// (05-card-window.md ▸ Attachments: "Compact rows: small QuickLook thumbnail (Finder-icon
/// fallback) + middle-truncated filename, one row per file").
///
/// Derived rather than a point size, like everything else here, and deliberately *small*: this
/// is an inventory, not a gallery — "viewing media is the card window's job" was settled about
/// the window, not about this list, and a row tall enough to see a screenshot in would push the
/// four sections beneath it off the sidebar.
static func attachmentThumbnailSide(bodyPointSize: CGFloat) -> CGFloat {
(bodyPointSize * 1.5).rounded()
}
/// The inset inside an attachment row, and the gap between its thumbnail and its filename —
/// half a gutter, the rendered body's rhythm, so the sidebar's list and the body's blocks are
/// spaced by the same unit.
static func attachmentRowPadding(bodyPointSize: CGFloat) -> CGFloat {
previewPadding(bodyPointSize: bodyPointSize)
}
// MARK: - The comments pane
/// How wide the comments pane is when it is mounted **beside** the body, in characters
/// (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.
/// **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`).
static let commentsColumnCharacters: CGFloat = 40
static func commentsColumnWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: commentsColumnCharacters, bodyPointSize: bodyPointSize)
}
/// 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. **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.
static func commentChipMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
(attachmentThumbnailSide(bodyPointSize: bodyPointSize) + bodyPointSize * 6).rounded()
}
/// The composer's resting height — **four lines and a bit**, which is the shape of the thing it
/// invites: enough that a two-sentence remark is visible whole, short enough that it never
/// dominates a thread. It scrolls internally past that rather than growing the pane, so a long
/// draft cannot push the thread off screen.
static func composerHeight(bodyPointSize: CGFloat) -> CGFloat {
(lineHeight(bodyPointSize: bodyPointSize) * 4.5).rounded()
}
/// An inline edit session's editor, one line taller than the composer: it opens over text that
/// already exists, so the common case is reading it before changing it.
static func inlineEditorHeight(bodyPointSize: CGFloat) -> CGFloat {
(lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
}
/// The find bar's query field — **twelve characters**, the shortest measure that still shows a
/// two-word search whole. It is deliberately narrower than the board's search field (seventeen):
/// this bar shares a strip with a counter and three controls inside a fixed-width pane, and a
/// field sized to the query would push the Done button off the end of the narrowest window the
/// design allows.
static func findFieldWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: 12, bodyPointSize: bodyPointSize)
}
/// The gap between two comments in the thread — a full gutter, one step larger than the rhythm
/// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
/// rather than with its neighbour.
static func commentSpacing(bodyPointSize: CGFloat) -> CGFloat {
gutter(bodyPointSize: bodyPointSize)
}
// MARK: - The rendered body
/// One step of structural indent in Preview — a list level, a quote level. One and a half ems,
/// which is wide enough for a bullet plus its space and narrow enough that four nested levels
/// still leave a measure worth reading.
static func previewIndent(bodyPointSize: CGFloat) -> CGFloat {
(bodyPointSize * 1.5).rounded()
}
/// The inside padding of a table cell and the inset of a code block — half a gutter, so the
/// rendered body's rhythm is the column's rhythm halved rather than a second, unrelated one.
static func previewPadding(bodyPointSize: CGFloat) -> CGFloat {
(gutter(bodyPointSize: bodyPointSize) / 2).rounded()
}
/// The widest an inline image is drawn at.
///
/// A number rather than "the text container's width" on purpose: a `NSTextAttachment`'s bounds
/// are fixed at build time, so an image sized to the window would have to be rebuilt on every
/// resize — and the body column is resizable by contract. A generous cap keeps a screenshot
/// legible without letting a 4000-pixel-wide one push the measure around, and an image narrower
/// than the cap is never enlarged.
static func previewImageMaximumWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: 56, bodyPointSize: bodyPointSize)
}
// MARK: - The window
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
/// for a title, its date line and a few lines of body.
///
/// - Parameter commentsColumn: whether the comments pane is currently mounted **beside** the body
/// — "the window's minimum width grows only while the column is shown side-by-side"
/// (05-card-window.md ▸ Composition). Stacked, or hidden, the pane costs the window no width at
/// all, which is the narrow-display case the layout option exists for. The height is unchanged
/// either way: a stacked pane divides the height it is given rather than demanding more, and a
/// window at its minimum height simply gets a short thread.
///
/// Defaulted to `false` so every caller that predates the comments pane still asks the same
/// question it always did.
static func minimumSize(bodyPointSize: CGFloat, commentsColumn: Bool = false) -> CGSize {
let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0
return CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize)
+ bodyMinimumWidth(bodyPointSize: bodyPointSize)
+ comments,
height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
)
}
/// The size a card window opens at when there is no last-used size to open at — a first-ever
/// card window, and nothing else (05 ▸ Window: "New windows open at the last-used card-window
/// size, cascaded"; `AppPreferences.lastCardWindowSize` is that memory).
static func defaultSize(bodyPointSize: CGFloat) -> CGSize {
CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize)
+ columnWidth(characters: bodyDefaultCharacters, bodyPointSize: bodyPointSize),
height: (lineHeight(bodyPointSize: bodyPointSize) * 32).rounded()
)
}
// MARK: - The live metric
/// The body font's point size as the system currently reports it — the one impure read, kept to
/// one line so every derivation above stays testable.
@MainActor
static var bodyPointSize: CGFloat {
NSFont.preferredFont(forTextStyle: .body).pointSize
}
}