Files
lanework/Kanban/UI/Print/PrintOptionsAccessory.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

396 lines
17 KiB
Swift

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
}
}