Files
lanework/Kanban/UI/Card/CardWindowMetrics.swift
T
rzen 40322247e0 Build the style, details, and actions sidebar sections
The sidebar completes: the shared style editor gains a second anchor —
StyleEditorLayout carries the geometry (the popover keeps its settled
268/14/7/8 untouched as the default; the sidebar packs columns to its
width with no inner scroller) while every well, the batch display, the
arrow grammar, and the one applyStyle bracket stay the shared
component's. The card anchor is fixed, not tracking: the target is
this card, and the fate walk retires the window when the card goes.
Details renders every unknown frontmatter key read-only in file order —
Card.document already carried them — showing the author's own bytes
where the raw span is a value and the engine's rendering for block
scalars and empties; reserved enhanced-schema keys are ordinary
unknowns, and no keys means no section. Actions: Delete rides the same
tombstone bytes as Backspace and drop-on-trash through a one-line
seam, says nothing about selection, and lets the fate walk dismiss;
Reveal in Finder resolves through the attachment scope so the two
paths cannot disagree. History reserves its m7 slot without drawing a
header no base board can honor.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 12:22:04 -04:00

179 lines
8.8 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 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.
static func minimumSize(bodyPointSize: CGFloat) -> CGSize {
CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize) + bodyMinimumWidth(bodyPointSize: bodyPointSize),
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
}
}