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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user