⌘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
278 lines
12 KiB
Swift
278 lines
12 KiB
Swift
import AppKit
|
|
|
|
/// `[PrintBlock]` → the attributed text a page draws: **the drawing half of printing, and only the
|
|
/// drawing half**.
|
|
///
|
|
/// Every decision was already made in `PrintDocumentBuilder` — which components appear, in what order,
|
|
/// which end of a thread comes first, where a sheet boundary falls — which is what keeps this file free
|
|
/// of policy, exactly as `BodyMarkupRenderer` is kept free of it by `BodyMarkup`. The parallel is not a
|
|
/// coincidence: **card bodies are rendered by that very renderer**, through the same
|
|
/// `BodyMarkup.parse`, so a printed body is typographically the same document Preview shows. Reading
|
|
/// Markdown a second way here would guarantee the two eventually disagreed about a table, a task
|
|
/// checkbox or a nested quote.
|
|
///
|
|
/// ### Sections, not one string
|
|
///
|
|
/// The output is an **array** of attributed strings, split at `.pageBreak`. That is what makes a page
|
|
/// break honest: each section is paginated independently by `PrintDocumentView`, so a section always
|
|
/// starts at the top of a sheet. Inserting form feeds or padding newlines into one long string would
|
|
/// have been the alternative, and TextKit does not paginate on either — it would have produced a break
|
|
/// that looked right at one paper size and drifted at every other.
|
|
@MainActor
|
|
enum PrintDocumentRenderer {
|
|
|
|
// MARK: - Sections
|
|
|
|
/// The document, split into independently paginated sections.
|
|
///
|
|
/// An empty block list answers `[]` rather than one empty section — a document with nothing in it
|
|
/// has no pages, which is the answer `PrintCoordinator` refuses to print rather than spending a
|
|
/// sheet on a running head over blank paper.
|
|
static func sections(for blocks: [PrintBlock], options rawOptions: PrintOptions, cardFolder: URL? = nil) -> [NSAttributedString] {
|
|
let options = rawOptions.normalized
|
|
var sections: [NSAttributedString] = []
|
|
var current = NSMutableAttributedString()
|
|
|
|
for block in blocks {
|
|
if case .pageBreak = block {
|
|
if current.length > 0 { sections.append(current) }
|
|
current = NSMutableAttributedString()
|
|
continue
|
|
}
|
|
append(block, to: current, options: options, cardFolder: cardFolder)
|
|
}
|
|
if current.length > 0 { sections.append(current) }
|
|
return sections
|
|
}
|
|
|
|
// MARK: - One block
|
|
|
|
private static func append(
|
|
_ block: PrintBlock,
|
|
to output: NSMutableAttributedString,
|
|
options: PrintOptions,
|
|
cardFolder: URL?
|
|
) {
|
|
let size = options.fontSize
|
|
|
|
switch block {
|
|
case .pageBreak:
|
|
// Consumed by `sections(for:options:cardFolder:)` before it ever reaches here; switched
|
|
// exhaustively so a future block cannot be forgotten.
|
|
break
|
|
|
|
case let .boardHeading(title):
|
|
append(
|
|
title,
|
|
font: PrintTypography.boardHeading(options),
|
|
color: PrintTypography.ink,
|
|
spacingBefore: 0,
|
|
spacingAfter: size * 0.9,
|
|
to: output
|
|
)
|
|
|
|
case let .laneHeading(title):
|
|
// A rule under the lane name, which is the one piece of decoration this document has and
|
|
// earns it: in a flowed print the lane heading is the only signal that one column ended and
|
|
// another began.
|
|
append(
|
|
title,
|
|
font: PrintTypography.laneHeading(options),
|
|
color: PrintTypography.ink,
|
|
spacingBefore: size * 1.4,
|
|
spacingAfter: size * 0.6,
|
|
to: output,
|
|
underlined: true
|
|
)
|
|
|
|
case let .cardTitle(title):
|
|
append(
|
|
title,
|
|
font: PrintTypography.cardTitle(options),
|
|
color: PrintTypography.ink,
|
|
spacingBefore: size * 1.0,
|
|
spacingAfter: size * 0.2,
|
|
to: output
|
|
)
|
|
|
|
case let .cardMeta(icon, labels):
|
|
appendMeta(icon: icon, labels: labels, options: options, to: output)
|
|
|
|
case let .cardBody(body):
|
|
appendBody(body, options: options, cardFolder: cardFolder, to: output)
|
|
|
|
case let .commentsHeading(count):
|
|
append(
|
|
commentsHeadingText(count: count),
|
|
font: PrintTypography.commentsHeading(options),
|
|
color: PrintTypography.secondaryInk,
|
|
spacingBefore: size * 0.9,
|
|
spacingAfter: size * 0.2,
|
|
to: output
|
|
)
|
|
|
|
case let .comment(author, created, body):
|
|
append(
|
|
byline(author: author, created: created),
|
|
font: PrintTypography.secondary(options),
|
|
color: PrintTypography.secondaryInk,
|
|
spacingBefore: size * 0.5,
|
|
spacingAfter: size * 0.1,
|
|
to: output,
|
|
indent: size * 1.5
|
|
)
|
|
appendBody(body, options: options, cardFolder: cardFolder, to: output, indent: size * 1.5)
|
|
}
|
|
}
|
|
|
|
/// A card's Markdown, through the app's one Markdown pass and then re-faced.
|
|
///
|
|
/// `cardFolder` is `nil` for a board print, and that is a real limitation rather than an oversight:
|
|
/// a body's relative image resolves against *its own card's* folder (`BodyTarget.resolve`), and a
|
|
/// board print walks many cards, so passing one folder would resolve some images against the wrong
|
|
/// card. An unresolvable relative image renders as the placeholder chip `BodyMarkupRenderer` already
|
|
/// draws for a remote one, which is the honest degrade. A card print, which has exactly one folder,
|
|
/// passes it and prints its images.
|
|
private static func appendBody(
|
|
_ body: String,
|
|
options: PrintOptions,
|
|
cardFolder: URL?,
|
|
to output: NSMutableAttributedString,
|
|
indent: CGFloat = 0
|
|
) {
|
|
let markup = BodyMarkup.parse(body)
|
|
let rendered = BodyMarkupRenderer.attributedString(
|
|
for: markup,
|
|
context: BodyMarkupRenderer.Context(pointSize: options.fontSize, cardFolder: cardFolder)
|
|
)
|
|
let faced = PrintTypography.restyled(rendered, family: options.fontFamily)
|
|
guard faced.length > 0 else { return }
|
|
|
|
guard indent > 0 else {
|
|
output.append(faced)
|
|
return
|
|
}
|
|
// A comment body sits under its byline, so it is indented with it. The indent is *added* to
|
|
// whatever the body's own paragraph styles already carry (a nested list keeps its nesting),
|
|
// which is why this adjusts the existing styles rather than installing one.
|
|
let indented = NSMutableAttributedString(attributedString: faced)
|
|
indented.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: indented.length)) { value, range, _ in
|
|
let style = ((value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
|
style.firstLineHeadIndent += indent
|
|
style.headIndent += indent
|
|
indented.addAttribute(.paragraphStyle, value: style, range: range)
|
|
}
|
|
output.append(indented)
|
|
}
|
|
|
|
/// The icon-and-labels line: the symbol, then the labels joined by a middle dot.
|
|
///
|
|
/// The icon is a **text attachment** rather than a rendered-to-text name: an SF Symbol has no
|
|
/// spelling a reader would recognize, and `NSImage(systemSymbolName:)` is the same resolution
|
|
/// `ItemSymbol` performs everywhere else in the app. A symbol that cannot be made into an image at
|
|
/// print time contributes nothing — the line still prints its labels, which is the same
|
|
/// omit-rather-than-box degrade `ItemSymbol` promises.
|
|
///
|
|
/// Labels are joined with " · " rather than drawn as chips. A chip is a screen affordance (a
|
|
/// coloured, rounded, hit-testable thing); on paper it is ink around a word, and 01's reserved
|
|
/// `labels` key carries no colour to draw it in anyway.
|
|
private static func appendMeta(icon: String?, labels: [String], options: PrintOptions, to output: NSMutableAttributedString) {
|
|
let font = PrintTypography.secondary(options)
|
|
let style = NSMutableParagraphStyle()
|
|
style.paragraphSpacingBefore = 0
|
|
style.paragraphSpacing = options.fontSize * 0.45
|
|
|
|
let line = NSMutableAttributedString()
|
|
if let icon, let image = symbolImage(icon, size: font.pointSize) {
|
|
let attachment = NSTextAttachment()
|
|
attachment.image = image
|
|
attachment.bounds = CGRect(x: 0, y: font.descender * 0.5, width: image.size.width, height: image.size.height)
|
|
line.append(NSAttributedString(attachment: attachment))
|
|
if !labels.isEmpty {
|
|
line.append(NSAttributedString(string: " "))
|
|
}
|
|
}
|
|
if !labels.isEmpty {
|
|
line.append(NSAttributedString(string: labels.joined(separator: " · ")))
|
|
}
|
|
guard line.length > 0 else { return }
|
|
|
|
line.append(NSAttributedString(string: "\n"))
|
|
line.addAttributes(
|
|
[.font: font, .foregroundColor: PrintTypography.secondaryInk, .paragraphStyle: style],
|
|
range: NSRange(location: 0, length: line.length)
|
|
)
|
|
output.append(line)
|
|
}
|
|
|
|
/// One SF Symbol at text size, or `nil` when this system cannot draw it.
|
|
private static func symbolImage(_ name: String, size: CGFloat) -> NSImage? {
|
|
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil }
|
|
return image.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: size, weight: .regular))
|
|
}
|
|
|
|
// MARK: - Plain lines
|
|
|
|
private static func append(
|
|
_ text: String,
|
|
font: NSFont,
|
|
color: NSColor,
|
|
spacingBefore: CGFloat,
|
|
spacingAfter: CGFloat,
|
|
to output: NSMutableAttributedString,
|
|
underlined: Bool = false,
|
|
indent: CGFloat = 0
|
|
) {
|
|
guard !text.isEmpty else { return }
|
|
|
|
let style = NSMutableParagraphStyle()
|
|
style.paragraphSpacingBefore = spacingBefore
|
|
style.paragraphSpacing = spacingAfter
|
|
style.firstLineHeadIndent = indent
|
|
style.headIndent = indent
|
|
if underlined {
|
|
// A hairline under the whole measure, drawn by a text block rather than by an underline
|
|
// attribute, so it spans the column instead of only the letters — `BodyMarkupRenderer`'s
|
|
// thematic-break mechanism, reused.
|
|
let rule = NSTextBlock()
|
|
rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .maxY)
|
|
rule.setBorderColor(.separatorColor)
|
|
rule.setWidth(font.pointSize * 0.2, type: .absoluteValueType, for: .padding, edge: .maxY)
|
|
style.textBlocks = [rule]
|
|
}
|
|
|
|
output.append(NSAttributedString(string: text + "\n", attributes: [
|
|
.font: font,
|
|
.foregroundColor: color,
|
|
.paragraphStyle: style
|
|
]))
|
|
}
|
|
|
|
// MARK: - The words the document says about itself
|
|
|
|
/// "3 comments" / "1 comment" — the thread's heading.
|
|
static func commentsHeadingText(count: Int) -> String {
|
|
count == 1 ? "1 comment" : "\(count) comments"
|
|
}
|
|
|
|
/// A comment's byline. Both halves are optional and each is a lenient field, so all four
|
|
/// combinations have to read as a sentence:
|
|
///
|
|
/// - both → "Ada Lovelace — 9 Aug 2026 at 14:30"
|
|
/// - author only → "Ada Lovelace" (a comment whose `created` was unreadable — the thread already
|
|
/// sorts those last rather than refusing them)
|
|
/// - date only → the date ("**Missing renders unattributed**" — `Comment.author`)
|
|
/// - neither → "Comment", so the body still has a line announcing it and never runs into the one
|
|
/// above it
|
|
static func byline(author: String?, created: Date?) -> String {
|
|
let name = author?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let stamp = created.map { $0.formatted(date: .abbreviated, time: .shortened) }
|
|
switch (name?.isEmpty == false ? name : nil, stamp) {
|
|
case let (author?, stamp?): return "\(author) — \(stamp)"
|
|
case let (author?, nil): return author
|
|
case let (nil, stamp?): return stamp
|
|
case (nil, nil): return "Comment"
|
|
}
|
|
}
|
|
}
|