import AppKit // MARK: - Identifiers extension NSToolbarItem.Identifier { static let cardEditBody = Self("card.editBody") static let cardRawSource = Self("card.rawSource") static let cardAddAttachment = Self("card.addAttachment") static let cardShowSidebar = Self("card.showSidebar") static let cardDeleteCard = Self("card.deleteCard") static let cardRevealInFinder = Self("card.revealInFinder") } // MARK: - The card window's toolbar /// The card window's toolbar (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸ Window). /// /// "**Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed /// functions, all discoverable from its toolbar." **Delete Card joined them** (05 ▸ Actions, retired — /// Pipeline card bcd3b323): the sidebar's Actions section is gone, and its destructive Delete moved here /// (separated by a `.flexibleSpace` from the creation-and-view items ahead) — HIG's "destructive actions /// read as separate from the everyday cluster", `Mail.app`'s own toolbar Delete being the nearest system /// precedent for a one-click, no-confirm, recoverable-by-trash button. **Show Sidebar joined them too** /// (05-card-window.md ▸ Composition, the toggle added beside the toolbar-customization work), positioned /// rightmost as the conventional macOS place for a trailing-sidebar toggle — the `Show Trash` precedent /// applied to the card window's one collapsible pane. **Reveal in Finder joined the catalog** the same /// day, catalog-only — it already has a menu-bar twin with no default chord (`RevealInFinderCommand`), /// so nothing was unreachable before this; the toolbar item is Customize's shortcut to it, not its only path. /// /// ### The six items are six menu rows, predicates included /// /// - **Edit Body** is "a single toggle button (on-state in Edit — mirroring the View ▸ Edit Body /// checkmark)", and it disables while source mode is active. That clause is not restated here: the /// predicate is `EditBodyCommand.isEnabled(body:rawSource:)`, the menu row's own, handed to the /// item verbatim. The pathfinder's segmented Preview|Edit is retired, so this is one button. /// - **Raw Source** is "likewise a toggle showing on-state", and its two directions are not /// symmetric: on enters, off *applies* (`CardRawSourceSession.applyAndLeave`). A refused Apply /// leaves source mode open, and the item's on-state simply fails to clear — the same way the menu /// row's checkmark does, because both read `isActive`. /// - **Add Attachment** "stays enabled in every mode — attachment operations never touch /// `index.md`, so they're safe alongside a raw edit". Its predicate is likewise the row's own /// (`AddAttachmentCommand.isEnabled`), which is scope plus the read-only lock and says nothing /// about the body's mode. /// - **Show Sidebar** is a toggle showing on-state too, mirroring the View ▸ Show Sidebar checkmark /// (`ShowSidebarCommand`) — and it is the one item here with no window-scoped handle behind it: /// its predicate reads `AppPreferences.showCardSidebar` straight off `UserDefaults`, the same /// app-wide, persisted bit `CardWindowView` renders from and every card window's toolbar answers /// to alike. Always enabled, `ShowCommentsCommand`'s own posture: showing or hiding a pane is not a /// mutation, so the read-only lock has no say in it. /// - **Delete Card** is a push button carrying `trash`, mirroring **File ▸ Delete Card** /// (`DeleteCardCommand`) — a new, distinctly titled row (never "Delete": 11-command-nexus.md files /// that title as the board-scope ⌘⌫ row's own singleton). Its predicate is the sidebar button's own /// read-only check (`CardWindowActions.isDeletable`), and firing it calls `BoardStore.deleteCard(_:)` /// directly — the same write, same bracket, same stamps ⌫ and the sidebar button always used. **No /// confirmation**, matching the delete flow it replaces exactly: recovery is the board's trash lane, /// so the alert 03 reserves is for the permanent purge, not this move. /// - **Reveal in Finder** mirrors the existing card-window branch of `RevealInFinderCommand` — same /// computation (`CardAttachments.revealURLs`), same `NSWorkspace` call, so the toolbar item and the /// menu row can never disagree about what "the selected attachment, or the card's folder" means. /// /// Labels are the menu titles minus a trailing ellipsis, so File ▸ Add Attachment… labels as **Add /// Attachment** (03's own example). @MainActor enum CardToolbar { static let identifier = "dev.rzen.indie.Kanban.card" /// The trio plus Show Sidebar plus Delete Card — the whole catalog except Reveal in Finder, which /// is catalog-only (already reachable off its own menu row, no default chord). The trailing /// `.flexibleSpace` separates the creation-side defaults from Delete Card, and Show Sidebar sits /// rightmost (the conventional macOS position for a trailing-sidebar toggle). static let defaultItems: [NSToolbarItem.Identifier] = [ .cardEditBody, .cardRawSource, .cardAddAttachment, .flexibleSpace, .cardDeleteCard, .cardShowSidebar, ] /// - Parameters: /// - actions: Delete Card's window-scoped handle (`CardWindowActions`) — the same object /// `DeleteCardCommand` reads through the focus system, handed here directly since the toolbar /// is installed once at window-open time rather than reactively (`CardWindowHost /// .configureWindow`). /// - isSidebarShown: Show Sidebar's read, defaulted to the real bit /// (`AppPreferences.showCardSidebar`). Injectable so a test can drive the item without /// touching the developer's own `UserDefaults.standard` domain — `StyleRecents`' own caution, /// applied to the one item here with no window-scoped handle to hold a scratch value instead. /// - setSidebarShown: Show Sidebar's write, defaulted to the real setter /// (`AppPreferences.setShowCardSidebar`), for the same reason. static func specs( body: CardBodyPresentation, rawSource: CardRawSourceSession, attachments: CardAttachments, actions: CardWindowActions, isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } ) -> [ToolbarItemSpec] { [ .mirroring( menuTitle: "Edit Body", identifier: .cardEditBody, symbol: "square.and.pencil", behavior: .toggle( isEnabled: { [weak body, weak rawSource] in EditBodyCommand.isEnabled(body: body, rawSource: rawSource) }, isOn: { [weak body] in body?.mode == .edit }, setOn: { [weak body] isOn in body?.setMode(isOn ? .edit : .preview) } ) ), .mirroring( menuTitle: "Raw Source", identifier: .cardRawSource, symbol: "doc.plaintext", behavior: .toggle( isEnabled: { [weak rawSource] in rawSource != nil }, isOn: { [weak rawSource] in rawSource?.isActive == true }, setOn: { [weak rawSource] isOn in guard let rawSource else { return } if isOn { rawSource.enter() } else { rawSource.applyAndLeave() } } ) ), .mirroring( menuTitle: "Add Attachment…", identifier: .cardAddAttachment, symbol: "paperclip", behavior: .button( isEnabled: { [weak attachments] in AddAttachmentCommand.isEnabled(attachments) }, perform: { [weak attachments] in attachments?.add() } ) ), // **The one item here with no window-scoped handle** — its read and write are the two // injected closures above, defaulted to `AppPreferences.showCardSidebar` / // `.setShowCardSidebar`, the plain `UserDefaults`-backed bit every card window's sidebar // (`CardWindowView`) and View ▸ Show Sidebar row (`ShowSidebarCommand`) already read and // write the same way. `sidebar.right` is the trailing variant: the sidebar this toggles // sits on the window's trailing edge (05-card-window.md ▸ Composition), never the leading // one the plain `sidebar.left` glyph would imply. .mirroring( menuTitle: "Show Sidebar", identifier: .cardShowSidebar, symbol: "sidebar.right", behavior: .toggle(isEnabled: { true }, isOn: isSidebarShown, setOn: setSidebarShown) ), // **The sidebar's former Actions ▸ Delete** (05 ▸ Actions, retired) — same write, same // no-confirmation posture: recovery is the board's trash lane, so there is nothing here // for an alert to guard. Placed last, after the trailing `.flexibleSpace` in // `defaultItems`, so it never sits adjacent to the four creation/view items ahead of it. .mirroring( menuTitle: "Delete Card", identifier: .cardDeleteCard, symbol: "trash", behavior: .button( isEnabled: { [weak actions] in DeleteCardCommand.isEnabled(actions) }, perform: { [weak actions] in actions?.deleteCard?() } ) ), // **The sidebar's former Actions ▸ Reveal in Finder** (05 ▸ Actions, retired) — catalog // only, since it already has a menu-bar twin with no default chord // (`RevealInFinderCommand`); Customize is this button's whole reason to exist, not its // only path. The computation is that row's card-window branch, verbatim, so the two // surfaces can never disagree about what "the selected attachment, or the card's folder" // means. .mirroring( menuTitle: "Reveal in Finder", identifier: .cardRevealInFinder, symbol: "folder", behavior: .button( isEnabled: { [weak attachments] in guard let attachments else { return false } return !Self.revealURLs(attachments).isEmpty }, perform: { [weak attachments] in guard let attachments else { return } NSWorkspace.shared.activateFileViewerSelecting(Self.revealURLs(attachments)) } ) ), ] } /// `RevealInFinderCommand`'s own card-window branch, read here rather than re-derived — the /// selected attachment's file when the section has the keyboard, the card's folder otherwise /// (`CardAttachments.revealURLs`'s own rule). private static func revealURLs(_ attachments: CardAttachments) -> [URL] { CardAttachments.revealURLs( cardFolder: attachments.cardFolder, selectedURL: attachments.selectedURL, isSectionFocused: attachments.isFocused ) } static func controller( body: CardBodyPresentation, rawSource: CardRawSourceSession, attachments: CardAttachments, actions: CardWindowActions, isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } ) -> WindowToolbarController { WindowToolbarController( identifier: identifier, specs: specs( body: body, rawSource: rawSource, attachments: attachments, actions: actions, isSidebarShown: isSidebarShown, setSidebarShown: setSidebarShown ), defaults: defaultItems ) } }