Print boards and cards with configurable components and named print profiles
⌘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
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
// MARK: - The focused card window's printable card
|
||||
|
||||
/// **What a card window offers File ▸ Print…**: the card as the last snapshot found it, the lane it is in,
|
||||
/// the board it belongs to, its folder, and the thread it is showing.
|
||||
///
|
||||
/// A handle of its own rather than a reuse of `cardComments` or `cardAttachments`, because neither carries
|
||||
/// the card: the comments pane knows a title and a folder (it announces about them), the attachments
|
||||
/// section knows a folder, and printing needs the whole `Card` — its `icon`, its reserved `labels`, its
|
||||
/// body. `CardAttachments`' shape and for its reasons: one per window, `@State` in the host, published
|
||||
/// through the focus system so a **menu row** reaches the frontmost card window without anyone keeping a
|
||||
/// which-window-is-key register (`FocusedBoardStoreKey`).
|
||||
///
|
||||
/// **Re-derived from every snapshot**, like the window's title and subtitle: a card renamed, restyled or
|
||||
/// moved between lanes mid-session prints as it is now, not as it was when the window opened.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CardPrintSubject {
|
||||
|
||||
/// `nil` until the window has joined its board — which is also exactly when there is nothing to print.
|
||||
var card: Card?
|
||||
|
||||
/// The lane the card is in, for the print's context line. `nil` renders as the same "Untitled"
|
||||
/// placeholder a lane header shows (`PrintDocumentBuilder.untitled`).
|
||||
var laneTitle: String?
|
||||
|
||||
/// The board's display name (`AppModel.displayName(of:)`), for the running head.
|
||||
var boardTitle = ""
|
||||
|
||||
/// The card's folder — the anchor a relative image in its body resolves against (`BodyTarget.resolve`).
|
||||
var cardFolder: URL?
|
||||
|
||||
/// The thread as the pane last read it, read again at print time.
|
||||
///
|
||||
/// A closure rather than a stored value for `CardComments.readThread`'s reason: comments are
|
||||
/// window-scoped and outside the snapshot, so there is nothing to republish from — the pane re-reads
|
||||
/// from disk, and a print asks the same question at the moment it is asked to print.
|
||||
@ObservationIgnored
|
||||
var readThread: (() -> CommentThread)?
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
struct FocusedCardPrintKey: FocusedValueKey {
|
||||
typealias Value = CardPrintSubject
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var cardPrint: CardPrintSubject? {
|
||||
get { self[FocusedCardPrintKey.self] }
|
||||
set { self[FocusedCardPrintKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Print…
|
||||
|
||||
/// **File ▸ Print… (⌘P)** — 11-command-nexus.md's Print row, and the retirement of that document's "No
|
||||
/// Print story in v1 (⌘P unused)" line.
|
||||
///
|
||||
/// ### Two scopes, one row
|
||||
///
|
||||
/// The board window prints **the board**: its lanes left to right, each lane's cards top to bottom, as a
|
||||
/// linear document rather than a picture of the strip (`PrintSource.board`). The card window prints **that
|
||||
/// card**. One menu row for both, which is what the platform means by Print — the frontmost window's
|
||||
/// document — and which is why the row reaches its subject through the focus system rather than through the
|
||||
/// app model.
|
||||
///
|
||||
/// The two are told apart by which focused value is present, and cannot both be: a card window publishes no
|
||||
/// `boardStore` (`CardWindowHost`), so the board branch is scopeless there. The order below therefore
|
||||
/// decides nothing, and it follows `RevealInFinderCommand`'s — the board in front wins, the card-window
|
||||
/// branch stands when a card window is.
|
||||
///
|
||||
/// ### Validation is scope and nothing else
|
||||
///
|
||||
/// Neither the read-only lock nor the focused-editor rule closes it, unlike every mutating row in
|
||||
/// `BoardCommands.swift`: a print is a **read**, and a locked board is exactly the board someone wants a
|
||||
/// paper copy of (`RevealInFinderCommand`'s posture, and `BoardInfoCommand`'s). An inline title editor is no
|
||||
/// obstacle either — the print takes the snapshot as it stands, which is what is on screen.
|
||||
///
|
||||
/// A board with **nothing to print** — no lanes, or every card empty of every included component — still
|
||||
/// enables the row, deliberately: the honest place to discover that is the panel's own preview, and a ⌘P
|
||||
/// that greys out on a board the user is looking at reads as a broken app rather than as an empty document.
|
||||
/// The refusal, when it happens, is `PrintCoordinator`'s and it says so.
|
||||
struct PrintCommand: View {
|
||||
|
||||
let appModel: AppModel
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.cardPrint) private var cardPrint
|
||||
|
||||
var body: some View {
|
||||
Button("Print…") {
|
||||
print()
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: .command)
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
|
||||
/// One answer for both the `disabled` state and the action — the codebase's usual shape, for its usual
|
||||
/// reason: two derivations of a rule are two chances to disagree.
|
||||
private var isEnabled: Bool {
|
||||
Self.isEnabled(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil)
|
||||
}
|
||||
|
||||
/// The row's validation as a pure function of the two facts it turns on, extracted from `isEnabled` for
|
||||
/// `SaveAsTemplateCommand.allowsSave`'s reason: a rule that can only be exercised through a menu is a
|
||||
/// rule nobody tests.
|
||||
///
|
||||
/// **Two disjuncts and nothing else.** No lock, no focused-editor rule, no is-there-anything-to-print —
|
||||
/// see the type's doc comment for why each of those is deliberately absent. A card window whose board
|
||||
/// has not loaded yet publishes a subject with no card, which is the second disjunct's whole point:
|
||||
/// scope alone would enable the row over a window with nothing behind it.
|
||||
static func isEnabled(hasBoard: Bool, hasPrintableCard: Bool) -> Bool {
|
||||
hasBoard || hasPrintableCard
|
||||
}
|
||||
|
||||
private func print() {
|
||||
if let store {
|
||||
PrintCoordinator.printBoard(store: store, profiles: appModel.printProfiles)
|
||||
return
|
||||
}
|
||||
if let cardPrint {
|
||||
PrintCoordinator.printCard(cardPrint, profiles: appModel.printProfiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PrintCoordinator
|
||||
|
||||
/// **The AppKit half of ⌘P**: build the session, hand the panel our accessory, run the operation.
|
||||
///
|
||||
/// ### Why the operation is sheeted on the window rather than run modally
|
||||
///
|
||||
/// `runModal()` would block the main thread inside a nested run loop for as long as the panel is up, which
|
||||
/// is the shape `NSSavePanel`'s uses in `DuplicateBoardCommand` — and is right there, because that panel
|
||||
/// answers a question the copy is *waiting* on. A print is not: the board keeps reloading, the watcher keeps
|
||||
/// running, an agent may be writing. So this uses the sheeted form, whose completion is where the Last Used
|
||||
/// capture lands.
|
||||
///
|
||||
/// ### The one refusal
|
||||
///
|
||||
/// A document with no pages is refused with an alert instead of printed. It is reachable two ways — a board
|
||||
/// with no cards, and every component toggled off — and both deserve a sentence rather than a sheet of
|
||||
/// running heads over blank paper. The alert is the whole response: nothing failed, so this is not
|
||||
/// 02-architecture.md's write-failure banner surface, which is about writes.
|
||||
@MainActor
|
||||
enum PrintCoordinator {
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing")
|
||||
|
||||
// MARK: Entry points
|
||||
|
||||
/// **The board**, as of this moment (`PrintSource`'s frozen-snapshot note).
|
||||
static func printBoard(store: BoardStore, profiles: PrintProfileStore) {
|
||||
let snapshot = store.snapshot
|
||||
let title = AppModel.displayName(of: store)
|
||||
|
||||
let session = PrintSession(
|
||||
provider: PrintSourceProvider(
|
||||
withoutComments: PrintSource.board(snapshot, titled: title),
|
||||
// Deferred, and read at most once — the comments toggle decides whether a board print pays
|
||||
// a thread read per card (`PrintSourceProvider`).
|
||||
withComments: {
|
||||
PrintSource.board(snapshot, titled: title) { card in
|
||||
PrintComment.list(of: store.commentThread(inCard: card.id))
|
||||
}
|
||||
}
|
||||
),
|
||||
profiles: profiles,
|
||||
// No single folder is right for every card's relative images, so a board print resolves none
|
||||
// (`PrintDocumentRenderer.appendBody`).
|
||||
cardFolder: nil,
|
||||
jobTitle: title,
|
||||
boardTitle: title
|
||||
)
|
||||
run(session)
|
||||
}
|
||||
|
||||
/// **One card**, with the thread read now.
|
||||
static func printCard(_ subject: CardPrintSubject, profiles: PrintProfileStore) {
|
||||
guard let card = subject.card else { return }
|
||||
let comments = subject.readThread.map { PrintComment.list(of: $0()) } ?? []
|
||||
let source = PrintSource.card(
|
||||
card,
|
||||
laneTitle: subject.laneTitle,
|
||||
boardTitle: subject.boardTitle,
|
||||
comments: comments
|
||||
)
|
||||
let session = PrintSession(
|
||||
provider: PrintSourceProvider(complete: source),
|
||||
profiles: profiles,
|
||||
cardFolder: subject.cardFolder,
|
||||
jobTitle: card.title.value ?? PrintDocumentBuilder.untitled,
|
||||
boardTitle: subject.boardTitle
|
||||
)
|
||||
run(session)
|
||||
}
|
||||
|
||||
// MARK: The operation
|
||||
|
||||
private static func run(_ session: PrintSession) {
|
||||
guard !session.blocks().isEmpty else {
|
||||
refuse(session)
|
||||
return
|
||||
}
|
||||
|
||||
// `NSPrintInfo.shared`, deliberately: it is where the panel's paper, orientation and margins are
|
||||
// remembered between prints, which is exactly the continuity a user expects from a print dialog —
|
||||
// and an app with no `NSDocument` has no per-document print info for them to live in instead.
|
||||
let printInfo = NSPrintInfo.shared
|
||||
// Both modes are moot while the view answers `knowsPageRange` itself — AppKit takes the view's page
|
||||
// rects and does not subdivide them further — and they are set anyway to say what the document is:
|
||||
// one column exactly as wide as the page, which never spills sideways.
|
||||
printInfo.horizontalPagination = .clip
|
||||
printInfo.verticalPagination = .automatic
|
||||
|
||||
let view = PrintDocumentView(session: session, printInfo: printInfo)
|
||||
let operation = NSPrintOperation(view: view, printInfo: printInfo)
|
||||
operation.jobTitle = session.jobTitle
|
||||
operation.showsPrintPanel = true
|
||||
operation.showsProgressPanel = true
|
||||
|
||||
let panel = operation.printPanel
|
||||
// The preview is what makes the accessory worth having (`PrintOptionsAccessoryController`), and the
|
||||
// page-setup group is what lets the paper questions be answered in the same dialog rather than in a
|
||||
// second one this app does not have (there is no File ▸ Page Setup… row — 11-command-nexus.md).
|
||||
panel.options.formUnion([.showsPreview, .showsPaperSize, .showsOrientation, .showsScaling, .showsCopies, .showsPageRange])
|
||||
panel.addAccessoryController(PrintOptionsAccessoryController(session: session))
|
||||
|
||||
// **The Last Used capture happens when the operation ends, and only if it ran** — see
|
||||
// `PrintCompletion`, which is also the reason the sheeted form needs a delegate at all.
|
||||
if let window = keyWindow() {
|
||||
let completion = PrintCompletion(session: session)
|
||||
operation.runModal(
|
||||
for: window,
|
||||
delegate: completion,
|
||||
didRun: #selector(PrintCompletion.printOperationDidRun(_:success:contextInfo:)),
|
||||
contextInfo: nil
|
||||
)
|
||||
} else {
|
||||
// No window to sheet on — which should not happen for a command scoped to a focused window, but
|
||||
// a print is still a legitimate thing to do and a modal run is the honest fallback. `run()` is
|
||||
// synchronous, so the capture is an ordinary line rather than a callback.
|
||||
if operation.run() {
|
||||
session.profiles.captureLastUsed(session.options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window the sheet hangs on: the app's key window, which for a command validated against the focus
|
||||
/// system *is* the window that published the subject. Asked of AppKit rather than threaded through the
|
||||
/// focus system because a `NSWindow` is not a value a `FocusedValue` should carry, and because
|
||||
/// `WindowAccessor`'s per-window controllers exist for window *lifecycle*, not for presenting over one.
|
||||
private static func keyWindow() -> NSWindow? {
|
||||
NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
|
||||
private static func refuse(_ session: PrintSession) {
|
||||
logger.notice("print refused: the document has no content")
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Nothing to Print"
|
||||
alert.informativeText = session.options.describesAnyContent
|
||||
? "'\(session.jobTitle)' has no cards with any of the content you chose to include."
|
||||
: "Turn on at least one of Title, Icon & Labels, Body or Comments."
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheeted operation's delegate — **and the only place the Last Used capture can honestly happen.**
|
||||
///
|
||||
/// `runModal(for:delegate:didRun:contextInfo:)` presents the panel as a sheet and **returns immediately**,
|
||||
/// which is the whole reason this class exists: capturing on the line after that call would record the
|
||||
/// options the panel *opened* with, before the user touched a control. A synchronous capture is only
|
||||
/// available on the windowless `run()` fallback, which is where `PrintCoordinator` puts one.
|
||||
///
|
||||
/// **The gate is `success`, so a cancelled print rewrites nothing.** Cancel and a printer failure are
|
||||
/// indistinguishable here — both arrive as `success == false` — and of the two possible mistakes the
|
||||
/// asymmetry is clear: capturing on Cancel would overwrite the user's remembered settings with ones they
|
||||
/// abandoned, while declining to capture on a jam merely leaves Last Used where it was. So the settings a
|
||||
/// jammed print was configured with are not remembered; that is the cheaper loss, and the print system's
|
||||
/// own queue window is where the user retries the job anyway.
|
||||
///
|
||||
/// **It retains itself until the callback.** `NSPrintOperation` holds its delegate weakly, and the sheeted
|
||||
/// form outlives every local in the method that presented it — so the object hands its own reference back
|
||||
/// only once the callback has landed.
|
||||
@MainActor
|
||||
private final class PrintCompletion: NSObject {
|
||||
|
||||
private let session: PrintSession
|
||||
private var untilTheCallback: PrintCompletion?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing")
|
||||
|
||||
init(session: PrintSession) {
|
||||
self.session = session
|
||||
super.init()
|
||||
untilTheCallback = self
|
||||
}
|
||||
|
||||
@objc func printOperationDidRun(_ operation: NSPrintOperation, success: Bool, contextInfo: UnsafeMutableRawPointer?) {
|
||||
Self.logger.info("print operation finished — success: \(success, privacy: .public)")
|
||||
if success {
|
||||
session.profiles.captureLastUsed(session.options)
|
||||
}
|
||||
untilTheCallback = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import AppKit
|
||||
|
||||
/// **The printed page** — the view `NSPrintOperation` paginates and draws, and the one place a page
|
||||
/// break becomes a real sheet boundary.
|
||||
///
|
||||
/// ### Why a custom view rather than printing an `NSTextView`
|
||||
///
|
||||
/// An `NSTextView` paginates itself, which is most of what this file does, and it was the obvious first
|
||||
/// choice. It cannot keep the one promise the feature is built on: **"between lanes" must really start a
|
||||
/// new page** (`PrintPageBreaks`). TextKit has no page-break character — a form feed is laid out as
|
||||
/// whitespace, not as a boundary — so a text view could only ever have been given padding newlines, which
|
||||
/// land in the right place at one paper size and drift at every other, and which would silently stop
|
||||
/// working the day someone changed the margins. A break has to be a *pagination* fact, not a spacing one.
|
||||
///
|
||||
/// So the document is split into sections at its breaks (`PrintDocumentRenderer.sections`), each section
|
||||
/// gets its own TextKit stack, and each section's text is flowed into as many page-sized text containers
|
||||
/// as it needs. A section therefore always begins at the top of a sheet, at every paper size, with any
|
||||
/// margins — because a container boundary *is* a page boundary here, by construction.
|
||||
///
|
||||
/// **TextKit 1 deliberately** (`NSLayoutManager`, `NSTextContainer`), and not by inertia: card bodies are
|
||||
/// rendered by `BodyMarkupRenderer`, whose GFM tables are `NSTextTable`s — a TextKit 1 construct, which
|
||||
/// is the same reason the card window's own body surface runs on TextKit 1 (`CardBodySurfaceView`). The
|
||||
/// multiple-containers-per-layout-manager flow this file relies on is also TextKit 1's; TextKit 2 models
|
||||
/// it differently and would have to be a separate design, not a search-and-replace.
|
||||
///
|
||||
/// ### Header and footer are drawn here, not handed to AppKit
|
||||
///
|
||||
/// `NSView` has a `pageHeader`/`pageFooter` pair and `drawPageBorder(withSize:)` to go with them. They
|
||||
/// are not used: their content is a single attributed string per page with no control over placement,
|
||||
/// they draw outside the imageable rect the pagination already accounts for, and their behaviour depends
|
||||
/// on an `NSPrintInfo` dictionary key rather than on anything this app can state. Drawing the two lines
|
||||
/// inside the page rect — with the text container's height reduced by exactly their heights — makes the
|
||||
/// running head, the pagination and the folio one arithmetic instead of three that have to agree.
|
||||
///
|
||||
/// ### One known limit, stated rather than hidden
|
||||
///
|
||||
/// There is no widow/orphan control: a card title can fall as the last line of a page with its body
|
||||
/// overleaf. `NSParagraphStyle` has no keep-with-next, so the honest fixes are a measure-and-push pass or
|
||||
/// per-card sections — the second of which is exactly what `.betweenCards` already offers a user who
|
||||
/// cares. Left as it is, and noted here so the next reader knows it was a decision.
|
||||
@MainActor
|
||||
final class PrintDocumentView: NSView {
|
||||
|
||||
// MARK: - What it prints
|
||||
|
||||
private let session: PrintSession
|
||||
|
||||
/// The imageable area of one sheet — paper minus margins, as `NSPrintInfo` reports it. Fixed for the
|
||||
/// operation: the panel's paper and orientation controls rebuild the operation rather than mutating
|
||||
/// this.
|
||||
private let pageSize: CGSize
|
||||
|
||||
/// The running head's and foot's heights, computed once from the options' own type scale. Zero when
|
||||
/// the line has nothing to say, which is what reclaims the paper rather than leaving a blank band
|
||||
/// (`PrintRunningHead.Line.isEmpty`).
|
||||
private var headerHeight: CGFloat = 0
|
||||
private var footerHeight: CGFloat = 0
|
||||
|
||||
/// One page: the layout manager that owns its glyphs, and which of its containers this page is.
|
||||
private struct Page {
|
||||
let layoutManager: NSLayoutManager
|
||||
let containerIndex: Int
|
||||
}
|
||||
|
||||
private var pages: [Page] = []
|
||||
|
||||
/// The text storages, held only to keep them alive: a layout manager does not retain its storage, and
|
||||
/// a deallocated storage takes the glyphs with it (a page that draws nothing, intermittently).
|
||||
private var storages: [NSTextStorage] = []
|
||||
|
||||
/// The options the current pagination was computed from — the guard that keeps `knowsPageRange` from
|
||||
/// re-flowing the whole document on every one of the preview's repeated calls when nothing changed.
|
||||
private var paginatedOptions: PrintOptions?
|
||||
|
||||
/// A ceiling on the page count, so a pathological layout cannot spin forever inside a modal panel.
|
||||
/// It is deliberately far above any real print: a 500-card board with every comment is a few hundred
|
||||
/// sheets, and a document that wants more than this has hit a bug, not a use case.
|
||||
private static let pageLimit = 5000
|
||||
|
||||
/// The date the print was configured — stamped once, at construction, so every sheet of one job
|
||||
/// carries the same date even if the job straddles midnight.
|
||||
private let printedAt = Date()
|
||||
|
||||
init(session: PrintSession, printInfo: NSPrintInfo) {
|
||||
self.session = session
|
||||
pageSize = Self.imageableSize(of: printInfo)
|
||||
super.init(frame: CGRect(origin: .zero, size: pageSize))
|
||||
// **Forced light appearance, and it is load-bearing.** Bodies come from `BodyMarkupRenderer`,
|
||||
// which sets `NSColor.labelColor` and its neighbours — dynamic colours resolved against the
|
||||
// drawing appearance at draw time. In a dark-mode app that resolves to near-white, which on paper
|
||||
// is a blank sheet. Pinning the view's appearance resolves every one of them the way paper needs,
|
||||
// without the renderer having to know it is being printed.
|
||||
appearance = NSAppearance(named: .aqua)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("PrintDocumentView is created in code") }
|
||||
|
||||
/// Text goes down the page, so the view's y does too — which also makes a page's rect
|
||||
/// `(pageIndex × height)` rather than a subtraction from the total.
|
||||
override var isFlipped: Bool { true }
|
||||
|
||||
// MARK: - Paper
|
||||
|
||||
/// The imageable content size: the paper minus the four margins the print panel is showing.
|
||||
///
|
||||
/// `paperSize` and the four margins rather than `imageablePageBounds`, deliberately: the latter is the
|
||||
/// *printer's* hardware limit, and using it would silently override the margins the user set in the
|
||||
/// panel — a document that ignored a 1-inch margin because the printer could reach further. The
|
||||
/// margins are the document's, and the panel owns them.
|
||||
static func imageableSize(of printInfo: NSPrintInfo) -> CGSize {
|
||||
let paper = printInfo.paperSize
|
||||
let width = paper.width - printInfo.leftMargin - printInfo.rightMargin
|
||||
let height = paper.height - printInfo.topMargin - printInfo.bottomMargin
|
||||
// A margin set larger than the paper is reachable from a hand-edited print preset; a floor keeps
|
||||
// the pagination loop from meeting a container it can never fill.
|
||||
return CGSize(width: max(72, width), height: max(72, height))
|
||||
}
|
||||
|
||||
/// Where the text lives on a page, once the running head and foot have taken theirs.
|
||||
private var textSize: CGSize {
|
||||
CGSize(width: pageSize.width, height: max(24, pageSize.height - headerHeight - footerHeight))
|
||||
}
|
||||
|
||||
// MARK: - Pagination
|
||||
|
||||
/// **The whole pagination**, run by AppKit before each print and before each preview refresh.
|
||||
///
|
||||
/// Re-flowing is guarded on the options rather than done unconditionally: the print panel calls this
|
||||
/// several times per interaction, and a 500-card board's layout is not free.
|
||||
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
|
||||
paginate()
|
||||
let count = max(1, pages.count)
|
||||
setFrameSize(CGSize(width: pageSize.width, height: pageSize.height * CGFloat(count)))
|
||||
range.pointee = NSRange(location: 1, length: count)
|
||||
return true
|
||||
}
|
||||
|
||||
override func rectForPage(_ page: Int) -> NSRect {
|
||||
NSRect(
|
||||
x: 0,
|
||||
y: CGFloat(page - 1) * pageSize.height,
|
||||
width: pageSize.width,
|
||||
height: pageSize.height
|
||||
)
|
||||
}
|
||||
|
||||
/// How many sheets the document currently needs — the folio's denominator, and the summary line's
|
||||
/// number. Paginates if it has to, so a caller never has to sequence the two.
|
||||
func pageCount() -> Int {
|
||||
paginate()
|
||||
return max(1, pages.count)
|
||||
}
|
||||
|
||||
private func paginate() {
|
||||
let options = session.options.normalized
|
||||
guard paginatedOptions != options else { return }
|
||||
paginatedOptions = options
|
||||
|
||||
measureRunningLines(options: options)
|
||||
|
||||
pages = []
|
||||
storages = []
|
||||
|
||||
let sections = PrintDocumentRenderer.sections(
|
||||
for: session.blocks(),
|
||||
options: options,
|
||||
cardFolder: session.cardFolder
|
||||
)
|
||||
|
||||
for section in sections {
|
||||
let storage = NSTextStorage(attributedString: section)
|
||||
let manager = NSLayoutManager()
|
||||
// Font leading, so a line of 18pt heading and a line of 11pt body each take the space their
|
||||
// own face asks for — the same reason the card window's body surface leaves it on.
|
||||
manager.usesFontLeading = true
|
||||
storage.addLayoutManager(manager)
|
||||
storages.append(storage)
|
||||
|
||||
let total = manager.numberOfGlyphs
|
||||
var laidOut = 0
|
||||
var containerIndex = 0
|
||||
|
||||
// A section with no glyphs still gets no page: `sections(for:...)` never emits an empty one,
|
||||
// and a defensive page here would print a sheet of running heads over nothing.
|
||||
while laidOut < total, pages.count < Self.pageLimit {
|
||||
let container = NSTextContainer(size: textSize)
|
||||
// The renderer's own indents are the document's; a container inset would add a second,
|
||||
// invisible one that only printing had.
|
||||
container.lineFragmentPadding = 0
|
||||
container.widthTracksTextView = false
|
||||
container.heightTracksTextView = false
|
||||
manager.addTextContainer(container)
|
||||
manager.ensureLayout(for: container)
|
||||
|
||||
let glyphs = manager.glyphRange(for: container)
|
||||
// A container that accepted nothing cannot be filled by another of the same size — an
|
||||
// image or a table wider or taller than the page. Stopping is the only termination this
|
||||
// loop can honestly have; the content that did not fit is clipped rather than looping
|
||||
// forever inside a modal print panel.
|
||||
guard glyphs.length > 0 else { break }
|
||||
|
||||
pages.append(Page(layoutManager: manager, containerIndex: containerIndex))
|
||||
containerIndex += 1
|
||||
laidOut = glyphs.location + glyphs.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two bands' heights, from the running-head font and whether either line has anything in it.
|
||||
private func measureRunningLines(options: PrintOptions) {
|
||||
let font = PrintTypography.runningHead(options)
|
||||
let line = font.ascender - font.descender + font.leading
|
||||
let gap = options.fontSize * 0.8
|
||||
|
||||
headerHeight = PrintRunningHead.header(
|
||||
options: options,
|
||||
boardTitle: session.boardTitle,
|
||||
dateText: dateText
|
||||
).isEmpty ? 0 : line + gap
|
||||
|
||||
// Measured against a representative folio rather than the real one: page 1 of 1 and page 9 of 99
|
||||
// are the same height, and the count is not known until pagination has run — which is what this
|
||||
// measurement is an input to.
|
||||
footerHeight = PrintRunningHead.footer(
|
||||
options: options,
|
||||
pageText: PrintRunningHead.pageText(page: 1, of: 1)
|
||||
).isEmpty ? 0 : line + gap
|
||||
}
|
||||
|
||||
// MARK: - Drawing
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
let options = session.options.normalized
|
||||
guard let index = pageIndex(in: dirtyRect), pages.indices.contains(index) else { return }
|
||||
|
||||
let page = pages[index]
|
||||
let pageTop = CGFloat(index) * pageSize.height
|
||||
let container = page.layoutManager.textContainers[page.containerIndex]
|
||||
let glyphs = page.layoutManager.glyphRange(for: container)
|
||||
let origin = CGPoint(x: 0, y: pageTop + headerHeight)
|
||||
|
||||
page.layoutManager.drawBackground(forGlyphRange: glyphs, at: origin)
|
||||
page.layoutManager.drawGlyphs(forGlyphRange: glyphs, at: origin)
|
||||
|
||||
drawRunningLines(options: options, pageIndex: index, pageTop: pageTop)
|
||||
}
|
||||
|
||||
/// Which page is being drawn.
|
||||
///
|
||||
/// `NSPrintOperation.current?.currentPage` is the authority — it is exactly what the printing
|
||||
/// machinery is tracking — and the arithmetic is the fallback for the one case where there is no
|
||||
/// operation: a draw on screen, which only happens if someone ever puts this view in a window.
|
||||
private func pageIndex(in dirtyRect: NSRect) -> Int? {
|
||||
if let page = NSPrintOperation.current?.currentPage, page > 0 {
|
||||
return page - 1
|
||||
}
|
||||
guard pageSize.height > 0 else { return nil }
|
||||
return Int((dirtyRect.minY / pageSize.height).rounded(.down))
|
||||
}
|
||||
|
||||
private func drawRunningLines(options: PrintOptions, pageIndex: Int, pageTop: CGFloat) {
|
||||
let font = PrintTypography.runningHead(options)
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: PrintTypography.secondaryInk
|
||||
]
|
||||
|
||||
if headerHeight > 0 {
|
||||
draw(
|
||||
PrintRunningHead.header(options: options, boardTitle: session.boardTitle, dateText: dateText),
|
||||
attributes: attributes,
|
||||
in: NSRect(x: 0, y: pageTop, width: pageSize.width, height: headerHeight)
|
||||
)
|
||||
}
|
||||
if footerHeight > 0 {
|
||||
let line = PrintRunningHead.footer(
|
||||
options: options,
|
||||
pageText: PrintRunningHead.pageText(page: pageIndex + 1, of: max(1, pages.count))
|
||||
)
|
||||
draw(
|
||||
line,
|
||||
attributes: attributes,
|
||||
in: NSRect(
|
||||
x: 0,
|
||||
y: pageTop + pageSize.height - footerHeight,
|
||||
width: pageSize.width,
|
||||
height: footerHeight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One running line, its two ends at the two ends of the measure.
|
||||
///
|
||||
/// Each end is drawn separately with its own alignment rather than joined by tabs: a tab stop would
|
||||
/// have to be recomputed per paper size, and a leading string long enough to reach the trailing one
|
||||
/// would push it off the page instead of truncating. Two rects cannot collide destructively — the
|
||||
/// worst case is two texts that meet in the middle, each truncated by its own rect.
|
||||
private func draw(_ line: PrintRunningHead.Line, attributes: [NSAttributedString.Key: Any], in rect: NSRect) {
|
||||
let inset = rect
|
||||
|
||||
if !line.leading.isEmpty {
|
||||
var leading = attributes
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.alignment = .left
|
||||
style.lineBreakMode = .byTruncatingTail
|
||||
leading[.paragraphStyle] = style
|
||||
NSAttributedString(string: line.leading, attributes: leading)
|
||||
.draw(with: CGRect(x: inset.minX, y: inset.minY, width: inset.width * 0.6, height: inset.height),
|
||||
options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
||||
}
|
||||
if !line.trailing.isEmpty {
|
||||
var trailing = attributes
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.alignment = .right
|
||||
style.lineBreakMode = .byTruncatingTail
|
||||
trailing[.paragraphStyle] = style
|
||||
NSAttributedString(string: line.trailing, attributes: trailing)
|
||||
.draw(with: CGRect(x: inset.minX + inset.width * 0.6, y: inset.minY, width: inset.width * 0.4, height: inset.height),
|
||||
options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
||||
}
|
||||
}
|
||||
|
||||
/// The print's date, formatted once — the running head's trailing end.
|
||||
private var dateText: String {
|
||||
printedAt.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The accessory controller
|
||||
|
||||
/// **The print panel's own pane of Lanework options** — `NSPrintPanelAccessorizing`, hosting SwiftUI, with
|
||||
/// the system's live preview redrawing as the controls change.
|
||||
///
|
||||
/// ### Why the accessory rather than a pre-flight sheet of our own
|
||||
///
|
||||
/// The scope ruling prefers it and the reason holds up: ⌘P is one of the most over-learned gestures on the
|
||||
/// platform, and a sheet of ours *before* the print panel would put two dialogs between the user and a
|
||||
/// sheet of paper — the second of which asks about paper, orientation and the printer, which is where a
|
||||
/// user expects the *first* one to. The accessory route also gets the thing a pre-flight sheet could never
|
||||
/// have: **the system's own preview**, showing the actual paginated document, updating as a toggle flips.
|
||||
/// Rebuilding that inside an app sheet would mean re-implementing the preview, the paper controls, and the
|
||||
/// PDF/queue destinations.
|
||||
///
|
||||
/// The one thing an accessory is cramped for is **profile management**, which is why the popup here does
|
||||
/// the three management gestures through named prompts (`PrintProfilePrompt`) rather than an inline
|
||||
/// editable list. That is a genuine compromise and it is the right one: choosing a profile is a
|
||||
/// once-per-print gesture that belongs in the flow, while naming and deleting them is rare and is fine
|
||||
/// behind a prompt.
|
||||
///
|
||||
/// ### The preview refresh is one KVO key, deliberately
|
||||
///
|
||||
/// `keyPathsForValuesAffectingPreview()` is a KVO contract: the panel observes the key paths it returns and
|
||||
/// redraws when one changes. A `@Observable` session cannot be observed that way, and mirroring thirteen
|
||||
/// options as thirteen `@objc dynamic` properties would be thirteen chances to forget one — a toggle that
|
||||
/// silently stopped updating the preview. So there is exactly **one** observed key, a revision counter the
|
||||
/// form bumps whenever the options value changes at all. The options are `Equatable`, so "changed" is a
|
||||
/// real comparison rather than a notification storm.
|
||||
@MainActor
|
||||
final class PrintOptionsAccessoryController: NSViewController, NSPrintPanelAccessorizing {
|
||||
|
||||
private let session: PrintSession
|
||||
|
||||
/// **The one key the panel observes.** See the type's note — bumped by the form whenever
|
||||
/// `session.options` changes, which is what makes the preview live.
|
||||
@objc dynamic private(set) var optionsRevision = 0
|
||||
|
||||
init(session: PrintSession) {
|
||||
self.session = session
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("PrintOptionsAccessoryController is created in code") }
|
||||
|
||||
override func loadView() {
|
||||
let hosting = NSHostingView(rootView: PrintOptionsForm(session: session) { [weak self] in
|
||||
// Willingly on the main actor: the form is a SwiftUI view in this controller's own view tree.
|
||||
self?.optionsRevision += 1
|
||||
})
|
||||
// The panel sizes its accessory to the view it is given, and a hosting view with no frame reports
|
||||
// zero. The width is the panel's own comfortable measure; the height is what the form needs.
|
||||
hosting.frame = CGRect(origin: .zero, size: CGSize(width: 480, height: 430))
|
||||
view = hosting
|
||||
}
|
||||
|
||||
// MARK: NSPrintPanelAccessorizing
|
||||
|
||||
/// The **collapsed** summary the panel shows when the accessory is not the visible pane — the answer to
|
||||
/// "what will this print do" without opening anything.
|
||||
///
|
||||
/// Four rows, each one a decision the user could otherwise only recover by switching back: the profile
|
||||
/// they are on (with its modified state, which is the one thing a popup title cannot show once the
|
||||
/// pane is hidden), what is included, where pages break, and the face.
|
||||
nonisolated func localizedSummaryItems() -> [[NSPrintPanel.AccessorySummaryKey: String]] {
|
||||
MainActor.assumeIsolated { summaryItems() }
|
||||
}
|
||||
|
||||
private func summaryItems() -> [[NSPrintPanel.AccessorySummaryKey: String]] {
|
||||
let options = session.options.normalized
|
||||
return [
|
||||
[
|
||||
.itemName: "Profile",
|
||||
.itemDescription: session.isModified
|
||||
? "\(session.selectedProfileName) (modified)"
|
||||
: session.selectedProfileName
|
||||
],
|
||||
[.itemName: "Includes", .itemDescription: PrintOptionsSummary.includes(options)],
|
||||
[.itemName: "Page Breaks", .itemDescription: PrintOptionsSummary.pageBreaks(options)],
|
||||
[.itemName: "Type", .itemDescription: PrintOptionsSummary.type(options)]
|
||||
]
|
||||
}
|
||||
|
||||
/// The panel redraws its preview when this changes — see the type's note on why there is one of them.
|
||||
nonisolated func keyPathsForValuesAffectingPreview() -> Set<String> {
|
||||
// `#keyPath` rather than a string literal, so a rename of the property is a compile error rather
|
||||
// than a preview that quietly stopped updating. It needs the actor to form, and the panel asks this
|
||||
// on the main thread like everything else it does.
|
||||
MainActor.assumeIsolated { [#keyPath(optionsRevision)] }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The summary's wording
|
||||
|
||||
/// The sentences the collapsed summary shows, as pure functions of the options — separated from the
|
||||
/// controller for the reason every rule in this feature is: a wording nobody can test drifts from the
|
||||
/// controls it describes.
|
||||
enum PrintOptionsSummary {
|
||||
|
||||
/// "Title, labels, body" — the components, in the order they print, or "Nothing" for the state a user
|
||||
/// can reach by turning all four off (`PrintOptions.describesAnyContent`).
|
||||
static func includes(_ options: PrintOptions) -> String {
|
||||
var parts: [String] = []
|
||||
if options.includesTitle { parts.append("title") }
|
||||
if options.includesLabels { parts.append("labels") }
|
||||
if options.includesBody { parts.append("body") }
|
||||
if options.includesComments {
|
||||
parts.append(options.commentSort == .newestFirst ? "comments (newest first)" : "comments (oldest first)")
|
||||
}
|
||||
guard !parts.isEmpty else { return "Nothing" }
|
||||
return parts.joined(separator: ", ").capitalizedFirstLetter
|
||||
}
|
||||
|
||||
static func pageBreaks(_ options: PrintOptions) -> String {
|
||||
switch options.pageBreaks {
|
||||
case .flow: "Continuous"
|
||||
case .betweenLanes: "Between lanes"
|
||||
case .betweenCards: "Between cards"
|
||||
}
|
||||
}
|
||||
|
||||
/// "Palatino 11 pt" / "System 11 pt". The size is written as an integer when it is one, because
|
||||
/// "11 pt" is what a user typed and "11.0 pt" is what a `Double` remembers.
|
||||
static func type(_ options: PrintOptions) -> String {
|
||||
let face = options.fontFamily ?? "System"
|
||||
let size = options.fontSize
|
||||
let text = size == size.rounded() ? String(Int(size)) : String(format: "%.1f", size)
|
||||
return "\(face) \(text) pt"
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var capitalizedFirstLetter: String {
|
||||
guard let first else { return self }
|
||||
return first.uppercased() + dropFirst()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The form
|
||||
|
||||
/// The accessory's controls — the card's five bullets, in the order it lists them, plus the profile row
|
||||
/// that makes them reusable.
|
||||
///
|
||||
/// SwiftUI inside an `NSHostingView` inside a print panel: the panel is AppKit and modal, but the controls
|
||||
/// are the app's, and every other configuration surface in Lanework is SwiftUI (the style editor, the board
|
||||
/// popover, the settings pane). A second UI vocabulary for one pane would be a second set of layout and
|
||||
/// accessibility habits to keep honest.
|
||||
private struct PrintOptionsForm: View {
|
||||
|
||||
let session: PrintSession
|
||||
|
||||
/// Called whenever the options value changes — bumps the controller's KVO counter, which is what makes
|
||||
/// the panel's preview live (`PrintOptionsAccessoryController`).
|
||||
let onOptionsChange: () -> Void
|
||||
|
||||
/// The families, read once: `NSFontManager`'s list is a few hundred entries and does not change while a
|
||||
/// print panel is up.
|
||||
@State private var families: [String] = PrintTypography.families()
|
||||
|
||||
/// The sentinel the picker uses for "the system font", since `nil` is not a `Picker` tag value.
|
||||
private static let systemFace = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
profiles
|
||||
Divider()
|
||||
components
|
||||
Divider()
|
||||
breaks
|
||||
Divider()
|
||||
type
|
||||
Divider()
|
||||
runningLines
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onChange(of: session.options) { _, _ in onOptionsChange() }
|
||||
}
|
||||
|
||||
// MARK: Profiles
|
||||
|
||||
@ViewBuilder
|
||||
private var profiles: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Picker("Profile", selection: profileSelection) {
|
||||
ForEach(session.profiles.menuNames, id: \.self) { name in
|
||||
Text(name).tag(name)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 240)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Save is always live: on the reserved row it is how a named profile is *created* from the
|
||||
// options in front of you, which is the gesture the whole feature exists for.
|
||||
Button("Save…") { save() }
|
||||
Button("Rename…") { rename() }
|
||||
.disabled(!session.canManageSelection)
|
||||
Button("Delete") { session.deleteSelectedProfile() }
|
||||
.disabled(!session.canManageSelection)
|
||||
}
|
||||
|
||||
if session.isModified {
|
||||
Text("Modified — Save… keeps these settings under a name.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The popup's binding. Reading is the session's label; writing goes through `selectProfile(named:)`,
|
||||
/// which is what copies the profile's options in rather than only moving a selection.
|
||||
private var profileSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { session.selectedProfileName },
|
||||
set: { session.selectProfile(named: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let suggested = session.canManageSelection ? session.selectedProfileName : ""
|
||||
guard let name = PrintProfilePrompt.ask(
|
||||
title: "Save Print Profile",
|
||||
message: "Name these print settings so you can reuse them.",
|
||||
defaultValue: suggested,
|
||||
prompt: "Save"
|
||||
) else { return }
|
||||
session.saveProfile(named: name)
|
||||
}
|
||||
|
||||
private func rename() {
|
||||
guard let name = PrintProfilePrompt.ask(
|
||||
title: "Rename Print Profile",
|
||||
message: "Give '\(session.selectedProfileName)' a new name.",
|
||||
defaultValue: session.selectedProfileName,
|
||||
prompt: "Rename"
|
||||
) else { return }
|
||||
session.renameSelectedProfile(to: name)
|
||||
}
|
||||
|
||||
// MARK: Components
|
||||
|
||||
@ViewBuilder
|
||||
private var components: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Include").font(.headline)
|
||||
Toggle("Title", isOn: binding(\.includesTitle))
|
||||
Toggle("Icon & Labels", isOn: binding(\.includesLabels))
|
||||
Toggle("Body", isOn: binding(\.includesBody))
|
||||
Toggle("Comments", isOn: binding(\.includesComments))
|
||||
|
||||
Picker("Comment order", selection: binding(\.commentSort)) {
|
||||
Text("Oldest first").tag(PrintCommentSort.oldestFirst)
|
||||
Text("Newest first").tag(PrintCommentSort.newestFirst)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
.padding(.leading, 18)
|
||||
// Disabled rather than hidden: a control that vanishes takes the *existence* of the choice with
|
||||
// it, and the sort is remembered across the toggle (`PrintOptions.commentSort`).
|
||||
.disabled(!session.options.includesComments)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Page breaks
|
||||
|
||||
@ViewBuilder
|
||||
private var breaks: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Page Breaks").font(.headline)
|
||||
Picker("", selection: binding(\.pageBreaks)) {
|
||||
Text("Continuous").tag(PrintPageBreaks.flow)
|
||||
Text("Start each lane on a new page").tag(PrintPageBreaks.betweenLanes)
|
||||
Text("Start each card on a new page").tag(PrintPageBreaks.betweenCards)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Type
|
||||
|
||||
@ViewBuilder
|
||||
private var type: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Type").font(.headline)
|
||||
HStack(spacing: 8) {
|
||||
Picker("Face", selection: faceSelection) {
|
||||
Text("System").tag(Self.systemFace)
|
||||
Divider()
|
||||
ForEach(families, id: \.self) { family in
|
||||
Text(family).tag(family)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 260)
|
||||
|
||||
Stepper(value: sizeSelection, in: PrintOptions.fontSizeRange, step: 0.5) {
|
||||
Text("Size \(sizeLabel) pt")
|
||||
}
|
||||
}
|
||||
Text("Headings, bylines and the running head are all derived from this size.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var faceSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { session.options.fontFamily ?? Self.systemFace },
|
||||
set: { session.options.fontFamily = $0 == Self.systemFace ? nil : $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private var sizeSelection: Binding<Double> {
|
||||
Binding(
|
||||
get: { session.options.fontSize },
|
||||
set: { session.options.fontSize = PrintOptions.clamped(fontSize: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var sizeLabel: String {
|
||||
let size = session.options.fontSize
|
||||
return size == size.rounded() ? String(Int(size)) : String(format: "%.1f", size)
|
||||
}
|
||||
|
||||
// MARK: Header and footer
|
||||
|
||||
@ViewBuilder
|
||||
private var runningLines: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Header & Footer").font(.headline)
|
||||
Toggle("Board title", isOn: binding(\.headerShowsBoardTitle))
|
||||
Toggle("Print date", isOn: binding(\.headerShowsPrintDate))
|
||||
Toggle("Page numbers", isOn: binding(\.footerShowsPageNumbers))
|
||||
Toggle("Custom line", isOn: binding(\.footerShowsCustomLine))
|
||||
TextField("", text: binding(\.footerCustomLine), prompt: Text("Footer text"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.leading, 18)
|
||||
.disabled(!session.options.footerShowsCustomLine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: One binding shape for every option
|
||||
|
||||
/// A writable binding into `session.options` through a key path — thirteen controls, one mechanism, so a
|
||||
/// new option is a row rather than a row plus a binding plus a chance to bind the wrong field.
|
||||
private func binding<Value>(_ keyPath: WritableKeyPath<PrintOptions, Value>) -> Binding<Value> {
|
||||
Binding(
|
||||
get: { session.options[keyPath: keyPath] },
|
||||
set: { session.options[keyPath: keyPath] = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The naming prompt
|
||||
|
||||
/// The one-field prompt behind Save… and Rename….
|
||||
///
|
||||
/// **An `NSAlert`, run modally over the print panel**, and not a SwiftUI sheet: the accessory has no window
|
||||
/// of its own to present from — it is a view inside AppKit's panel — and a nested modal session is exactly
|
||||
/// what the platform provides for a dialog raised from a modal dialog. It is also the shape Finder uses for
|
||||
/// the same gesture.
|
||||
///
|
||||
/// The refusal path is quiet: `nil` for Cancel, and `nil` for a name the catalog will not take, which is
|
||||
/// the same answer because both mean "nothing was named" (`PrintProfileCatalog.isAcceptable` states which
|
||||
/// names those are — blank, and the reserved one).
|
||||
@MainActor
|
||||
enum PrintProfilePrompt {
|
||||
|
||||
static func ask(title: String, message: String, defaultValue: String, prompt: String) -> String? {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: prompt)
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let field = NSTextField(frame: CGRect(x: 0, y: 0, width: 260, height: 24))
|
||||
field.stringValue = defaultValue
|
||||
field.placeholderString = "Profile name"
|
||||
alert.accessoryView = field
|
||||
// Without this the field is not first responder and the user has to click into it before typing.
|
||||
alert.window.initialFirstResponder = field
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return nil }
|
||||
let name = PrintProfileCatalog.normalized(field.stringValue)
|
||||
guard PrintProfileCatalog.isAcceptable(name) else { return nil }
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
// MARK: - PrintSourceProvider
|
||||
|
||||
/// **One print's content, read at most twice** — once without comments, once with them if the user ever
|
||||
/// asks.
|
||||
///
|
||||
/// ### Why it is a class and not a `PrintSource`
|
||||
///
|
||||
/// A thread is a **disk read** and comments are window-scoped, outside the board snapshot
|
||||
/// (01-storage-format.md § Enhanced schema: "the walk stays O(cards): the card window reads its own
|
||||
/// thread … and the board snapshot never loads comment content"). So a board print with comments on
|
||||
/// costs one read per card, and comments are **off by default** precisely so nobody pays that without
|
||||
/// asking (`PrintOptions.includesComments`). A plain value would force the choice at ⌘P, before the user
|
||||
/// has seen the dialog: either read every thread on every ⌘P, or make the comments toggle inert.
|
||||
///
|
||||
/// This is the third answer. The comment-less source is taken eagerly (it is a walk of an
|
||||
/// already-loaded snapshot and costs nothing), the comment-bearing one is built on first demand, and the
|
||||
/// result is kept — so flipping the toggle back and forth in the panel, which re-lays-out the document
|
||||
/// each time, reads each thread exactly once.
|
||||
///
|
||||
/// ### The snapshot is frozen either way
|
||||
///
|
||||
/// Both sources describe the board **as it was when ⌘P was pressed** (`PrintSource`'s own note). The
|
||||
/// comment read is the one thing that happens later, which is a deliberate seam rather than a leak: a
|
||||
/// thread that gained a comment between ⌘P and the toggle prints with it, and that is more useful than
|
||||
/// a print that hid a comment because a dialog was open when it arrived.
|
||||
@MainActor
|
||||
final class PrintSourceProvider {
|
||||
|
||||
private let withoutComments: PrintSource
|
||||
private let buildWithComments: () -> PrintSource
|
||||
private var cached: PrintSource?
|
||||
|
||||
init(withoutComments: PrintSource, withComments: @escaping () -> PrintSource) {
|
||||
self.withoutComments = withoutComments
|
||||
buildWithComments = withComments
|
||||
}
|
||||
|
||||
/// A source that already carries its comments — the card-window case, where "read the thread" is one
|
||||
/// read the window has already done and there is nothing to defer.
|
||||
init(complete source: PrintSource) {
|
||||
withoutComments = source
|
||||
buildWithComments = { source }
|
||||
cached = source
|
||||
}
|
||||
|
||||
func source(includingComments: Bool) -> PrintSource {
|
||||
guard includingComments else { return withoutComments }
|
||||
if let cached { return cached }
|
||||
let source = buildWithComments()
|
||||
cached = source
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PrintSession
|
||||
|
||||
/// **One ⌘P, from the dialog opening to the sheet coming out** — the live options, the content behind
|
||||
/// them, and the profile store they are saved into.
|
||||
///
|
||||
/// ### Why a session object at all
|
||||
///
|
||||
/// Three surfaces have to agree about one set of options while the print panel is up: the accessory's
|
||||
/// controls (which write them), the document view (which lays out from them, several times, as the user
|
||||
/// tries things), and the profile popup (which replaces them wholesale when a profile is chosen). A
|
||||
/// `PrintOptions` value passed by copy to each would give three views of one decision. So the session is
|
||||
/// the one holder, and it is the thing the accessory and the view are both built around — the
|
||||
/// `CardComments` / `CardAttachments` pattern applied to a modal rather than a window.
|
||||
///
|
||||
/// It is deliberately **per print**, created by `PrintCoordinator` and discarded when the operation
|
||||
/// finishes. Nothing here outlives the panel except what it writes into `PrintProfileStore`, which is
|
||||
/// where persistence belongs.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PrintSession {
|
||||
|
||||
/// The options the panel is currently configured with. Every control in the accessory writes here,
|
||||
/// and the document view reads here at layout time.
|
||||
var options: PrintOptions
|
||||
|
||||
/// Which row of the profile popup is showing — a name, either the reserved one or a saved profile's
|
||||
/// (`PrintProfileStore.menuNames`).
|
||||
///
|
||||
/// **It is a label, not a binding.** Choosing a profile copies its options in; editing a control
|
||||
/// afterwards does *not* write back to the profile — it marks the selection as modified
|
||||
/// (`isModified`), and only Save commits. A popup that silently rewrote the profile the user was
|
||||
/// looking at would make named profiles useless, which is `PrintOptions`' value-semantics note one
|
||||
/// level up.
|
||||
var selectedProfileName: String
|
||||
|
||||
/// Whether the live options have drifted from the selected profile's — what puts the popup's row in
|
||||
/// its "(modified)" state and what makes Save meaningful.
|
||||
///
|
||||
/// The reserved row is **never** modified: it *is* the last-used options, so drifting from it is
|
||||
/// what it is for.
|
||||
var isModified: Bool {
|
||||
guard !PrintProfile.isReserved(selectedProfileName) else { return false }
|
||||
guard let saved = profiles.catalog.options(named: selectedProfileName) else { return true }
|
||||
return saved != options
|
||||
}
|
||||
|
||||
let profiles: PrintProfileStore
|
||||
|
||||
/// What is being printed. Read through `source(for:)` so the comments toggle decides whether the
|
||||
/// threads are read at all.
|
||||
@ObservationIgnored
|
||||
private let provider: PrintSourceProvider
|
||||
|
||||
/// The printed card's own folder, for a card print — the anchor a relative image resolves against
|
||||
/// (`BodyTarget.resolve`). `nil` for a board print, where there is no single folder that would be
|
||||
/// right for every card (`PrintDocumentRenderer.appendBody`).
|
||||
@ObservationIgnored
|
||||
let cardFolder: URL?
|
||||
|
||||
/// The print job's name, as the print queue and the Save-as-PDF panel show it — the board's name for
|
||||
/// a board print, the card's for a card print.
|
||||
@ObservationIgnored
|
||||
let jobTitle: String
|
||||
|
||||
/// The board's name, for the running head. Held beside `jobTitle` rather than derived from it because
|
||||
/// the two differ for a card print, and beside the source rather than read out of it because the
|
||||
/// source is rebuilt when the comments toggle flips while the title never changes.
|
||||
@ObservationIgnored
|
||||
let boardTitle: String
|
||||
|
||||
init(
|
||||
provider: PrintSourceProvider,
|
||||
profiles: PrintProfileStore,
|
||||
cardFolder: URL?,
|
||||
jobTitle: String,
|
||||
boardTitle: String
|
||||
) {
|
||||
self.provider = provider
|
||||
self.profiles = profiles
|
||||
self.cardFolder = cardFolder
|
||||
self.jobTitle = jobTitle
|
||||
self.boardTitle = boardTitle
|
||||
// **⌘P opens on what the user did last**, which is the reserved pseudo-profile's whole purpose
|
||||
// (`PrintProfile.lastUsedName`) — and on the factory defaults the very first time, which is the
|
||||
// same thing said about a store with nothing in it.
|
||||
selectedProfileName = PrintProfile.lastUsedName
|
||||
options = profiles.lastUsed ?? PrintOptions()
|
||||
}
|
||||
|
||||
// MARK: The document
|
||||
|
||||
/// The blocks this print would lay out, given the options as they stand — one call, so the accessory's
|
||||
/// summary, the page count and the drawing can never describe three different documents.
|
||||
func blocks() -> [PrintBlock] {
|
||||
PrintDocumentBuilder.blocks(
|
||||
from: provider.source(includingComments: options.includesComments),
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Profiles
|
||||
|
||||
/// Applies a profile's options wholesale. A name that resolves to nothing leaves the options alone
|
||||
/// and the selection where it was — the honest answer for a menu that raced a deletion.
|
||||
func selectProfile(named name: String) {
|
||||
guard let applied = profiles.options(named: name) else { return }
|
||||
selectedProfileName = name
|
||||
options = applied
|
||||
}
|
||||
|
||||
/// Saves the live options under `name` and selects it. `false` is a refused name (blank, or the
|
||||
/// reserved one), which changes nothing.
|
||||
@discardableResult
|
||||
func saveProfile(named name: String) -> Bool {
|
||||
guard profiles.save(options, as: name) else { return false }
|
||||
selectedProfileName = PrintProfileCatalog.normalized(name)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func renameSelectedProfile(to newName: String) -> Bool {
|
||||
guard profiles.rename(selectedProfileName, to: newName) else { return false }
|
||||
selectedProfileName = PrintProfileCatalog.normalized(newName)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Deletes the selected profile and falls back to the reserved row — **keeping the options**. The
|
||||
/// user deleted a saved *name*, not the settings they are looking at, and resetting the panel under
|
||||
/// them would be the destructive reading of a management gesture.
|
||||
func deleteSelectedProfile() {
|
||||
guard !PrintProfile.isReserved(selectedProfileName) else { return }
|
||||
profiles.delete(selectedProfileName)
|
||||
selectedProfileName = PrintProfile.lastUsedName
|
||||
}
|
||||
|
||||
/// Whether the selected row can be renamed or deleted — the reserved pseudo-profile cannot
|
||||
/// (`PrintProfile.lastUsedName`), and neither can a selection that names nothing.
|
||||
var canManageSelection: Bool {
|
||||
!PrintProfile.isReserved(selectedProfileName) && profiles.catalog.contains(selectedProfileName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user