Files
lanework/Kanban/UI/Card/CardWindowMetrics.swift
T
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
The card window recomposes into three componentized panes (body,
comments, attributes) with two mounts — beside or body-over-comments
at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is
one persisted app-wide bit, no content-derived auto-show; File ▸ Add
Comment flips it on and focuses the composer. The thread renders
author lines, edited markers, card-subset Markdown bodies, and
read-only Quick Look chips under a count header with the sort-
direction control. The composer edits comments/.draft/ on the slow
cadence (blur, close, quit, ~30s interval), Escape only moves focus,
⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms
debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start
bytes, close flushes. File drops within either authoring surface
carve out of the window-wide card default into that surface's
attachments/; paperclips cover the no-drag path. Close flush runs
inline flush, then draft save, then the comments/.trash purge;
open sweeps crash residue.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 20:19:52 -04:00

247 lines
13 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 one section needs a number rather than a proposal — the embedded style editor's
/// grids are a fixed count of fixed-size wells per row, and the count has to be decided before
/// the layout runs (`StyleEditorLayout.sidebar(contentWidth:)`). 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. 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.
///
/// 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.
static let commentsMinimumCharacters: CGFloat = 28
static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
}
/// 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 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
}
}