⌘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
330 lines
16 KiB
Swift
330 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|