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 { // `#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 { 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 { Binding( get: { session.options.fontFamily ?? Self.systemFace }, set: { session.options.fontFamily = $0 == Self.systemFace ? nil : $0 } ) } private var sizeSelection: Binding { 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(_ keyPath: WritableKeyPath) -> Binding { 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 } }