Files
lanework/Kanban/UI/Print/PrintDocumentView.swift
T
rzen 7651e40318 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
2026-08-08 22:42:11 -04:00

330 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
/// **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)
}
}