The card window sidebar's Style section embedded StyleEditorView symbols-only (showsBackground: false, showsSymbols: true), drawing the curated grid whole inside the sidebar's own scroll view — a permanently open 5-7 row grid ahead of every other section. Replace it with the reusable compact SymbolPicker (already used by BoardInfoPopover for the board's glyph): a single well at rest, the grid only inside its own popover. - CardStyleSection no longer instantiates StyleEditorView at all. A new "Symbol" row mirrors the existing "Background" row's inspector shape (caption leading, control trailing), wiring SymbolPicker's onSelect/ onSelectColor to icon/iconColor through StyleCommand.apply(...on: undo), the exact funnel the background combo already rides — so the card window's undo session semantics (13-native-undo.md) are unchanged. - The picker is fed CuratedSymbols.available rather than its own smaller general-purpose default, so a card's curated vocabulary doesn't shrink. - StyleEditorLayout.sidebar and showsBackground stay in StyleEditor.swift (still correct, still tested) rather than being cut as dead code — a separate, larger cleanup this card doesn't make (recorded on the card). - DESIGN/03-board-ui.md and DESIGN/05-card-window.md: updated the sentences describing the sidebar hosting the style editor's symbol grid to describe the compact picker instead. - Tests: CardSessionUndoTests gains a symbol/tint analogue of the existing background-combo session-routing test; CardSidebarTests gains a curated-set tripwire and an updated file-header note. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
258 lines
13 KiB
Swift
258 lines
13 KiB
Swift
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. 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 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
|
||
}
|
||
}
|