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:
2026-08-08 22:42:11 -04:00
parent cdc91d669d
commit 7651e40318
17 changed files with 3997 additions and 6 deletions
+329
View File
@@ -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)
}
}