Files
lanework/Kanban/UI/Card/CardToolbar.swift
T
rzen d905e73960 A card window toolbar control shows and hides the trailing sidebar — View ▸ Show Sidebar joins Edit Body, Raw Source and Add Attachment
The card window's trailing attributes sidebar has always been unconditional
since m6; this adds View ▸ Show Sidebar (a checkmark toggle, ShowComments'
shape) and a matching toolbar item on the customizable card toolbar
(NSToolbar/WindowToolbarController), a fourth default beside Edit Body,
Raw Source and Add Attachment. sidebar.right for the trailing pane; one
shared animated write path (AppPreferences.setShowCardSidebar) both faces
call, structural-voice reflow with a trailing slide-and-fade transition
(Motion.cardSidebarTransition), Reduce Motion respected throughout.

CardWindowMetrics.minimumSize gains a sidebar: Bool = true parameter so a
hidden sidebar shrinks the window's floor, composing with the existing
commentsColumn parameter. The toolbar item's read/write are injectable
closures (defaulted to the real UserDefaults-backed pair) so its plumbing
is testable without touching the developer's own preferences domain.
WindowToolbarController's observation tracking only sees @Observable
reads, so a small HostedWindowController.revalidateToolbar() plus an
onChange nudge keeps the toolbar button's on-state in step with the
View-menu row's write.

Scope held narrowly to visibility, per the card: no sidebar section
reordering, no action-moving.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 09:14:51 -04:00

150 lines
7.4 KiB
Swift

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")
}
// 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; the catalog is the same trio." So the default set
/// *is* the catalog here, and Customize offers rearrangement and removal rather than a choice of
/// items — which is exactly what a window with three functions should offer. **Show Sidebar joined
/// them** (05-card-window.md ▸ Composition, the toggle added beside the toolbar-customization work):
/// a fourth default item, the trailing sidebar's own show/hide control, the `Show Trash` precedent
/// applied to the card window's one collapsible pane.
///
/// ### The four items are the four 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.
///
/// 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 catalog is the same trio" plus Show Sidebar — so the defaults are the whole catalog, in
/// this file's order.
static let defaultItems: [NSToolbarItem.Identifier] = [
.cardEditBody,
.cardRawSource,
.cardAddAttachment,
.cardShowSidebar,
]
/// - Parameters:
/// - 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,
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)
),
]
}
static func controller(
body: CardBodyPresentation,
rawSource: CardRawSourceSession,
attachments: CardAttachments,
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
) -> WindowToolbarController {
WindowToolbarController(
identifier: identifier,
specs: specs(
body: body,
rawSource: rawSource,
attachments: attachments,
isSidebarShown: isSidebarShown,
setSidebarShown: setSidebarShown
),
defaults: defaultItems
)
}
}