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
This commit is contained in:
2026-08-09 09:14:51 -04:00
parent 55663e855a
commit d905e73960
11 changed files with 301 additions and 28 deletions
+48 -6
View File
@@ -6,6 +6,7 @@ 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
@@ -15,9 +16,12 @@ extension NSToolbarItem.Identifier {
/// "**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.
/// 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 three items are the three menu rows, predicates included
/// ### 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
@@ -31,6 +35,12 @@ extension NSToolbarItem.Identifier {
/// `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).
@@ -39,17 +49,28 @@ enum CardToolbar {
static let identifier = "dev.rzen.indie.Kanban.card"
/// "The catalog is the same trio" so the defaults are the catalog, in 03's order.
/// "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
attachments: CardAttachments,
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
) -> [ToolbarItemSpec] {
[
.mirroring(
@@ -90,17 +111,38 @@ enum CardToolbar {
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
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),
specs: specs(
body: body,
rawSource: rawSource,
attachments: attachments,
isSidebarShown: isSidebarShown,
setSidebarShown: setSidebarShown
),
defaults: defaultItems
)
}
+8 -2
View File
@@ -258,6 +258,11 @@ enum CardWindowMetrics {
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
/// for a title, its date line and a few lines of body.
///
/// - Parameter sidebar: whether the attributes sidebar is currently shown View Show Sidebar
/// (05-card-window.md Composition, extended for the toggle). Hidden, it costs the window no
/// width at all, the comments column's own "the pane costs the window no width while it is not
/// mounted" rule applied to the sidebar's own visibility. Defaulted to `true` so every caller
/// that predates the toggle still asks the same question it always did.
/// - Parameter commentsColumn: whether the comments pane is currently mounted **beside** the body
/// "the window's minimum width grows only while the column is shown side-by-side"
/// (05-card-window.md Composition). Stacked, or hidden, the pane costs the window no width at
@@ -267,10 +272,11 @@ enum CardWindowMetrics {
///
/// Defaulted to `false` so every caller that predates the comments pane still asks the same
/// question it always did.
static func minimumSize(bodyPointSize: CGFloat, commentsColumn: Bool = false) -> CGSize {
static func minimumSize(bodyPointSize: CGFloat, sidebar: Bool = true, commentsColumn: Bool = false) -> CGSize {
let sidebarComponent = sidebar ? sidebarWidth(bodyPointSize: bodyPointSize) : 0
let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0
return CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize)
width: sidebarComponent
+ bodyMinimumWidth(bodyPointSize: bodyPointSize)
+ comments,
height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
+79 -11
View File
@@ -17,11 +17,18 @@ import UniformTypeIdentifiers
/// parameter and the body column takes none either. This view puts one of them in a frame; the
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
///
/// The sidebar is unchanged by any of it it is a third pane, it has always been fixed-width, and
/// the resize flex still goes to the body and never to the sidebar. **The comments column is no
/// longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the beside
/// mount, so the flex the body and the comments pane split is now the user's to move, within the two
/// floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing.
/// The sidebar is unchanged by any of it it is a third pane, it has always been fixed-width when
/// shown, and the resize flex still goes to the body and never to the sidebar. **The comments column
/// is no longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the
/// beside mount, so the flex the body and the comments pane split is now the user's to move, within
/// the two floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing.
///
/// **Whether it shows at all is its own bit** (View Show Sidebar, extended for the toolbar-toggle
/// card): app-wide, persisted `showSidebar` below, mirrored by the card window's toolbar
/// (`CardToolbar`). Hidden, the pane and its divider are simply absent from the `HStack` and the body
/// column takes the width back through its own `.infinity` frame no third state, no collapsed
/// sliver, the comments pane's own "costs the window no width while it is not mounted" rule applied
/// to the sidebar's visibility instead of the comments pane's mount.
///
/// ### What this milestone builds, and what it deliberately does not
///
@@ -117,6 +124,18 @@ struct CardWindowView: View {
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
/// **View Show Sidebar** the trailing attributes sidebar's own visibility bit, `showComments`'s
/// neighbour and shape exactly (05-card-window.md Composition, extended for the toolbar toggle):
/// app-wide, persisted, read here rather than passed in so this pane and the toolbar item that
/// mirrors it (`CardToolbar`) and the View-menu row that mirrors it too (`ShowSidebarCommand`) are
/// provably the same bit.
@AppStorage(AppPreferences.showCardSidebarKey) private var showSidebar = true
/// Reduce Motion, read from the environment because this is a view `Motion`'s own split between
/// callers that have one and callers (a menu command, a toolbar item) that reach `Motion
/// .prefersReducedMotion` instead.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// **The comments column's user-set width**, in points the divider's memory (05-card-window.md
/// The comments column, extended 2026-08-09). `@AppStorage` for the same reason its two
/// neighbours above are: every open card window's divider answers to one figure. `0` is the
@@ -203,14 +222,25 @@ struct CardWindowView: View {
contentPanes
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Divider()
// **View Show Sidebar** (`ShowSidebarCommand`, `CardToolbar`): the whole trailing
// column, divider included, comes and goes with the one bit the body/comments
// columns above take the width back through their own `.infinity` frames, exactly as
// they do while the pane is merely narrow. A structural reflow, the Show Trash
// re-divide's own voice (`Motion.structural`), with the pane itself sliding off the
// trailing edge it lives on (`Motion.cardSidebarTransition`) rather than just
// vanishing.
if showSidebar {
Divider()
sidebar
// Fixed, and the one place it comes from.
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
.frame(maxHeight: .infinity, alignment: .top)
.background(.background.secondary)
sidebar
// Fixed, and the one place it comes from.
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
.frame(maxHeight: .infinity, alignment: .top)
.background(.background.secondary)
.transition(Motion.cardSidebarTransition(reduced: reduceMotion))
}
}
.animation(Motion.structural(reduced: reduceMotion), value: showSidebar)
}
}
@@ -397,6 +427,44 @@ struct CardWindowView: View {
}
}
// MARK: - View Show Sidebar
/// View Show Sidebar (checkmark toggle, **no default chord**) the trailing attributes sidebar's
/// visibility (05-card-window.md Composition, extended for the toolbar toggle).
///
/// `ShowCommentsCommand`'s shape and reasons exactly: one bit, app-wide and persisted, the checkmark
/// reading exactly that bit so the row never lies, and it mirrors the toolbar's own Show Sidebar item
/// (`CardToolbar`) the way View Show Trash mirrors its toolbar twin a menu command with a second
/// face, never two implementations of one.
///
/// Validation is **scope and nothing else**, `ShowCommentsCommand`'s own posture: with no card window
/// in front there is no focused value and the row disables, and the read-only lock plays no part
/// showing or hiding a pane is not a mutation. `\.cardAttachments` is read only as the "a card window
/// is frontmost" signal `AddAttachmentCommand` already uses; the sidebar this toggles shows attachments
/// among its other sections, but the row does not otherwise touch that handle.
struct ShowSidebarCommand: View {
@FocusedValue(\.cardAttachments) private var attachments
@AppStorage(AppPreferences.showCardSidebarKey) private var isShown = true
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
attachments != nil
}
/// The toggle's binding, `ShowTrashCommand.isVisible`'s own shape: the setter routes through
/// `AppPreferences.setShowCardSidebar` the one write path this bit's two faces share rather
/// than writing `$isShown` directly, which is what makes the animated reflow the toolbar item
/// gets true of this row too.
private var isVisible: Binding<Bool> {
Binding(get: { isShown }, set: { AppPreferences.setShowCardSidebar($0) })
}
var body: some View {
Toggle("Show Sidebar", isOn: isVisible)
.disabled(!Self.isEnabled(attachments))
}
}
// MARK: - The window-wide drop
/// Attaches the whole-window file drop, or nothing at all.
+13
View File
@@ -182,6 +182,19 @@ enum Motion {
transientSearchAppearance(reduced: reduced).transition
}
/// The card window's attributes sidebar, arriving or leaving View Show Sidebar's one visible
/// consequence (05-card-window.md Composition The attributes sidebar). Slides from the
/// trailing edge, where the sidebar itself sits `transientSearchAppearance`'s own reasoning
/// (the surface arrives from the edge it lives on) applied to this one's edge instead of the
/// toolbar's.
static func cardSidebarAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .slideAndFade(from: .trailing)
}
static func cardSidebarTransition(reduced: Bool) -> AnyTransition {
cardSidebarAppearance(reduced: reduced).transition
}
// MARK: - The AppKit face
/// The lane-resize window animation's duration, for `NSAnimationContext` which takes a number