⌘P had no story: KanbanApp removed the platform's Print row outright on 11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line retires. File ▸ Print… now prints the board in front — lanes left to right, each lane's cards top to bottom, as a linear document rather than a picture of the strip — or, from a card window, that card. The trash is unreachable by construction: it is a sibling container of `lanes`, not a lane. The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is one Codable value carrying the printing card's five bullets — which components (title, icon+labels line, rendered body, comments off by default with either reading order), page breaks, one base face and size every other size derives from, and a toggleable running head and foot. `PrintSource` is what is being printed, frozen at ⌘P so the panel's repeated relayouts and a board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the pair into a block list, which is where every decision a rendered page hides becomes something a test can hold: component order, comment ordering, and page-break markers that are markers rather than whitespace. Empty is empty all the way up — a card with nothing to print consumes no page break, and a lane whose cards all dropped out takes its heading with it. A page break is a pagination fact, not a spacing one. TextKit has no page-break character, so `PrintDocumentView` splits the document into sections at its breaks and flows each into as many page-sized text containers as it needs: a container boundary *is* a sheet boundary, at any paper size with any margins. Bodies come from the app's one Markdown pass — `BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the chosen family reaches the text and fixed-pitch code keeps its own, and drawn under a forced light appearance so the card window's dynamic label colours do not print white. Options ride in the print panel's own accessory rather than a pre-flight sheet of ours, which buys the system's live preview of the real paginated document; the preview refreshes through one KVO revision counter rather than thirteen mirrored properties. Profiles persist app-side in UserDefaults, never in board files — a print profile is how this user likes to read, not what a board is (`BoardZoomStore`'s argument). A name is a profile's identity, folded case-insensitively; "Last Used" is reserved in every spelling, kept out of the stored list, and captured when an operation actually ran, so a cancelled print rewrites nothing. Both decoders are total: one unrecognized key must not cost a user every profile they saved. DESIGN/11-command-nexus.md gains the Print row and loses the sentence saying it would never have one. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
154 lines
8.2 KiB
Swift
154 lines
8.2 KiB
Swift
import AppKit
|
|
|
|
/// **The type scale of a printed document, derived from one base choice** — "font face, size & style"
|
|
/// (the printing card's fourth bullet) answered as the scope ruling settles it: the user picks a face
|
|
/// and a body size, and everything else is a multiple of them.
|
|
///
|
|
/// ### Why one size and not six
|
|
///
|
|
/// A print dialog that asked separately for the heading size, the byline size and the running-head size
|
|
/// would be a typesetting program with a Print button. The ratios below are the same relationships the
|
|
/// card window's Preview already uses (`BodyMarkupRenderer` sets every indent, padding and heading step
|
|
/// as a multiple of the body point size, so the surface grows with the system text size —
|
|
/// 10-accessibility.md ▸ Text); this file is that discipline pointed at paper, where the base comes from
|
|
/// `PrintOptions.fontSize` instead of from the system.
|
|
///
|
|
/// ### Why the face is applied as a *remap* rather than threaded through
|
|
///
|
|
/// Bodies are rendered by `BodyMarkupRenderer`, which is the app's one Markdown-to-typography pass and
|
|
/// hardcodes the system font by design (it draws what the card window draws). Teaching it a font family
|
|
/// would put a print-only parameter into the surface that renders every card on screen. So a print sets
|
|
/// its face afterwards, by walking the finished string's `.font` runs and rebuilding each one in the
|
|
/// chosen family at its own size and with its own traits (`restyled`). One consequence is deliberate:
|
|
/// **code stays monospaced**. A fenced block set in Palatino is not what anyone means by choosing
|
|
/// Palatino.
|
|
@MainActor
|
|
enum PrintTypography {
|
|
|
|
// MARK: - The scale
|
|
|
|
/// The document's base — body text, comment bodies, and the measure everything else is a multiple
|
|
/// of.
|
|
static func body(_ options: PrintOptions) -> NSFont {
|
|
font(family: options.fontFamily, size: options.fontSize, weight: .regular)
|
|
}
|
|
|
|
/// The board's name at the top of a board print. The largest thing on the page, because it is the
|
|
/// only thing that names the whole document.
|
|
static func boardHeading(_ options: PrintOptions) -> NSFont {
|
|
font(family: options.fontFamily, size: options.fontSize * 1.7, weight: .bold)
|
|
}
|
|
|
|
/// A lane's name. Below the board and above a card, which is exactly its place in the structure.
|
|
static func laneHeading(_ options: PrintOptions) -> NSFont {
|
|
font(family: options.fontFamily, size: options.fontSize * 1.35, weight: .semibold)
|
|
}
|
|
|
|
/// A card's title.
|
|
static func cardTitle(_ options: PrintOptions) -> NSFont {
|
|
font(family: options.fontFamily, size: options.fontSize * 1.15, weight: .semibold)
|
|
}
|
|
|
|
/// The icon-and-labels line, and a comment's byline — the two secondary lines, at one size so the
|
|
/// page has one voice for "this is about the content, not the content".
|
|
static func secondary(_ options: PrintOptions) -> NSFont {
|
|
font(family: options.fontFamily, size: options.fontSize * 0.85, weight: .regular)
|
|
}
|
|
|
|
/// The running head and foot. Smallest on the page: furniture that must be readable and must not
|
|
/// compete.
|
|
static func runningHead(_ options: PrintOptions) -> NSFont {
|
|
font(family: options.fontFamily, size: options.fontSize * 0.8, weight: .regular)
|
|
}
|
|
|
|
/// The comments heading — "3 comments" over the thread.
|
|
static func commentsHeading(_ options: PrintOptions) -> NSFont {
|
|
font(family: options.fontFamily, size: options.fontSize * 0.95, weight: .semibold)
|
|
}
|
|
|
|
// MARK: - Resolving a face
|
|
|
|
/// A font in `family` at `size`, falling back to the system font of that size and weight.
|
|
///
|
|
/// **The fallback is the whole leniency story** and it mirrors `ItemSymbol.name(_:fallback:)`
|
|
/// exactly: a stored profile is a value that travels between machines and OS releases, and a family
|
|
/// that is not installed here must degrade rather than refuse. `NSFont(name:size:)` against a family
|
|
/// name resolves the family's regular face on macOS; when it cannot, the system font is the answer.
|
|
static func font(family: String?, size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
|
let size = max(1, size)
|
|
guard let family, !family.isEmpty else {
|
|
return NSFont.systemFont(ofSize: size, weight: weight)
|
|
}
|
|
let descriptor = NSFontDescriptor(fontAttributes: [.family: family])
|
|
if let base = NSFont(descriptor: descriptor, size: size) {
|
|
return weight == .regular ? base : bolder(base, weight: weight) ?? base
|
|
}
|
|
return NSFont.systemFont(ofSize: size, weight: weight)
|
|
}
|
|
|
|
/// A heavier cut of `font`, or `nil` when the family has none — a family with only one weight
|
|
/// renders a "semibold" heading in its one face, which is what a single-weight face means.
|
|
private static func bolder(_ font: NSFont, weight: NSFont.Weight) -> NSFont? {
|
|
var traits = font.fontDescriptor.symbolicTraits
|
|
traits.insert(.bold)
|
|
let descriptor = font.fontDescriptor.withSymbolicTraits(traits)
|
|
return NSFont(descriptor: descriptor, size: font.pointSize)
|
|
}
|
|
|
|
/// Whether the running system can set text in `family` — the picker's own filter, and
|
|
/// `ItemSymbol.exists`' posture applied to type: the font set is the *machine's*, so a hardcoded
|
|
/// list would be wrong on the first machine that had a different one.
|
|
static func families() -> [String] {
|
|
NSFontManager.shared.availableFontFamilies.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
// MARK: - The remap
|
|
|
|
/// `attributed` with every non-monospaced `.font` run rebuilt in `family`, keeping each run's own
|
|
/// size and traits.
|
|
///
|
|
/// The traits are carried across rather than recomputed, which is what makes a body's structure
|
|
/// survive the change of face: `**bold**` stays bold, `*emphasis*` stays italic, a heading stays
|
|
/// whatever weight the renderer gave it, and the size ladder (headings larger, captions smaller) is
|
|
/// untouched because each run keeps its own point size.
|
|
///
|
|
/// **Monospaced runs are skipped on purpose** — see the type's note. `isMonospaced` on
|
|
/// `NSFontDescriptor.symbolicTraits` is the test, which catches both the app's explicit
|
|
/// `monospacedSystemFont` code style and any face that reports itself fixed-pitch.
|
|
///
|
|
/// A `nil` family is the identity: the string is returned untouched rather than rebuilt into the
|
|
/// system font it is already set in.
|
|
static func restyled(_ attributed: NSAttributedString, family: String?) -> NSAttributedString {
|
|
guard let family, !family.isEmpty else { return attributed }
|
|
|
|
let output = NSMutableAttributedString(attributedString: attributed)
|
|
output.enumerateAttribute(.font, in: NSRange(location: 0, length: output.length)) { value, range, _ in
|
|
guard let font = value as? NSFont else { return }
|
|
let traits = font.fontDescriptor.symbolicTraits
|
|
guard !traits.contains(.monoSpace) else { return }
|
|
|
|
var descriptor = NSFontDescriptor(fontAttributes: [.family: family])
|
|
descriptor = descriptor.withSymbolicTraits(traits)
|
|
guard let replacement = NSFont(descriptor: descriptor, size: font.pointSize) else { return }
|
|
output.addAttribute(.font, value: replacement, range: range)
|
|
}
|
|
return output
|
|
}
|
|
|
|
// MARK: - Ink
|
|
|
|
/// **Paper is white, so ink is black** — and the app's dynamic colours are not.
|
|
///
|
|
/// `BodyMarkupRenderer` sets `NSColor.labelColor` and friends, which resolve *at draw time against
|
|
/// the drawing appearance*: in a dark-mode app that is near-white, which on paper is nothing at all.
|
|
/// The print view therefore draws in a forced light appearance (`PrintDocumentView`), which resolves
|
|
/// every one of those dynamic colours the way a printed page needs. This constant is for the text
|
|
/// this file's own callers compose — headings, bylines, running heads — where naming the ink
|
|
/// explicitly is clearer than relying on the appearance override two files away.
|
|
static let ink = NSColor.textColor
|
|
|
|
/// Secondary ink, for bylines and running heads. Dynamic like `ink`, resolved by the same forced
|
|
/// appearance.
|
|
static let secondaryInk = NSColor.secondaryLabelColor
|
|
}
|