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:
@@ -127,6 +127,41 @@ public enum AppPreferences {
|
|||||||
/// `double(forKey:)` reading safe here in a way `boardZoomLevel`'s note warns it usually is not.
|
/// `double(forKey:)` reading safe here in a way `boardZoomLevel`'s note warns it usually is not.
|
||||||
public static let commentsColumnWidthKey = "commentsColumnWidth"
|
public static let commentsColumnWidthKey = "commentsColumnWidth"
|
||||||
|
|
||||||
|
// MARK: The attributes sidebar's visibility
|
||||||
|
|
||||||
|
/// **View ▸ Show Sidebar** — the card window's trailing attributes sidebar (05-card-window.md ▸
|
||||||
|
/// Composition), `showComments`'s shape and reasons exactly: one bit, app-wide, persisted across
|
||||||
|
/// restarts, and the checkmark (and the toolbar toggle that mirrors it, `CardToolbar`) reads
|
||||||
|
/// exactly this value so neither ever lies.
|
||||||
|
///
|
||||||
|
/// **Default on.** The sidebar has been part of every card window since m6; an unset key must
|
||||||
|
/// read as the window everyone already knows rather than as a surprise collapse the first time
|
||||||
|
/// this preference exists to read.
|
||||||
|
public static let showCardSidebarKey = "showCardSidebar"
|
||||||
|
|
||||||
|
/// Read outside a view — `CardToolbar`'s toggle item, which is AppKit rather than SwiftUI and so
|
||||||
|
/// has no `@AppStorage` of its own (`lastCardWindowSize`'s reason).
|
||||||
|
public static var showCardSidebar: Bool {
|
||||||
|
UserDefaults.standard.object(forKey: showCardSidebarKey) as? Bool ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show/Hide Sidebar's whole behaviour — **the one write path both of its faces share**
|
||||||
|
/// (`BoardStore.setTrashVisible`'s precedent: "a toolbar item is a menu command with a different
|
||||||
|
/// face, never a second implementation of it"), so `ShowSidebarCommand`'s Toggle and
|
||||||
|
/// `CardToolbar`'s item call this rather than writing `UserDefaults` each in their own words.
|
||||||
|
///
|
||||||
|
/// **A user-initiated structural change**, so it animates in the structural voice and goes
|
||||||
|
/// instant under Reduce Motion — the Show Trash re-divide's own reasoning, applied to a pane
|
||||||
|
/// leaving a window instead of a lane leaving a strip. Reduce Motion is read from AppKit rather
|
||||||
|
/// than from `@Environment` for the same reason `setTrashVisible` reads it that way: a toolbar
|
||||||
|
/// item's action and a menu row's both run outside any rendered hierarchy.
|
||||||
|
@MainActor
|
||||||
|
public static func setShowCardSidebar(_ shown: Bool) {
|
||||||
|
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||||
|
UserDefaults.standard.set(shown, forKey: showCardSidebarKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
||||||
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
||||||
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
||||||
|
|||||||
@@ -302,6 +302,12 @@ struct CardWindowHost: View {
|
|||||||
/// threading the pair through a view that would then have to publish them back up.
|
/// threading the pair through a view that would then have to publish them back up.
|
||||||
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
||||||
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
||||||
|
/// The sidebar's own visibility bit, read here for `showComments`' one reason: the window's
|
||||||
|
/// **minimum size** depends on it too (`minimumSize`). The toolbar item reads
|
||||||
|
/// `AppPreferences.showCardSidebar` directly (`CardToolbar`, an AppKit seam with no `@AppStorage`
|
||||||
|
/// of its own), and `CardWindowView` reads this same key again itself — three readers of one
|
||||||
|
/// `UserDefaults` key, `showComments`' own arrangement.
|
||||||
|
@AppStorage(AppPreferences.showCardSidebarKey) private var showSidebar = true
|
||||||
|
|
||||||
private enum Phase {
|
private enum Phase {
|
||||||
case opening
|
case opening
|
||||||
@@ -426,15 +432,23 @@ struct CardWindowHost: View {
|
|||||||
isClosePending = false
|
isClosePending = false
|
||||||
windowController.closeAfterFlush()
|
windowController.closeAfterFlush()
|
||||||
}
|
}
|
||||||
|
// **The toolbar's Show Sidebar item, nudged fresh** — `WindowToolbarController`'s own
|
||||||
|
// observation tracking only sees `@Observable` reads (its header explains why: menu
|
||||||
|
// commands and AppKit have no `@Environment` to poll), and `AppPreferences.showCardSidebar`
|
||||||
|
// is a plain `UserDefaults` read, not one. The toolbar's own click already revalidates
|
||||||
|
// itself (`toggleFired`); this is the same nudge for the View-menu row's write, which is
|
||||||
|
// the only other place this bit changes.
|
||||||
|
.onChange(of: showSidebar) { _, _ in windowController.revalidateToolbar() }
|
||||||
.onDisappear { finish() }
|
.onDisappear { finish() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md ▸
|
/// **The minimum grows only while the comments pane is beside the body, and shrinks while the
|
||||||
/// Composition) — which is the whole reason the stacked mount exists, so a narrow display keeps
|
/// sidebar is hidden** (05-card-window.md ▸ Composition) — the stacked mount's own reason, and
|
||||||
/// the minimum it always had.
|
/// View ▸ Show Sidebar's mirror of it: a window with neither costs the width of neither.
|
||||||
private var minimumSize: CGSize {
|
private var minimumSize: CGSize {
|
||||||
CardWindowMetrics.minimumSize(
|
CardWindowMetrics.minimumSize(
|
||||||
bodyPointSize: CardWindowMetrics.bodyPointSize,
|
bodyPointSize: CardWindowMetrics.bodyPointSize,
|
||||||
|
sidebar: showSidebar,
|
||||||
commentsColumn: showComments && commentsBesideBody
|
commentsColumn: showComments && commentsBesideBody
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -915,8 +929,11 @@ struct CardWindowHost: View {
|
|||||||
|
|
||||||
// The window's customizable toolbar — Edit Body · Raw Source · Add Attachment, "the
|
// The window's customizable toolbar — Edit Body · Raw Source · Add Attachment, "the
|
||||||
// window's three committed functions" (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸
|
// window's three committed functions" (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸
|
||||||
// Window). It carries the three window-scoped handles above rather than a store, which is
|
// Window), joined by Show Sidebar (below). It carries the three window-scoped handles above
|
||||||
// why it is installed here and not at attach: those are this window's, and so is it.
|
// rather than a store, which is why it is installed here and not at attach: those are this
|
||||||
|
// window's, and so is it. Show Sidebar needs none of them — its read and write default to
|
||||||
|
// `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, the same app-wide bit every card
|
||||||
|
// window answers to, so this call leaves the two injectable parameters unnamed.
|
||||||
windowController.installToolbar(
|
windowController.installToolbar(
|
||||||
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
|
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ struct FindSteppingCommands: View {
|
|||||||
/// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments
|
/// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments
|
||||||
/// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits,
|
/// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits,
|
||||||
/// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`).
|
/// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`).
|
||||||
|
///
|
||||||
|
/// **Show Sidebar joined at the end** (05-card-window.md ▸ Composition, the toolbar-toggle card):
|
||||||
|
/// the trailing attributes sidebar's own visibility bit, `ShowCommentsCommand`'s shape exactly and
|
||||||
|
/// mirroring the toolbar's own item (`CardToolbar`) the way View ▸ Show Trash mirrors its toolbar
|
||||||
|
/// twin. Appended rather than interleaved with the comments pair, to leave the block 11 already
|
||||||
|
/// documents (Edit Body · Show Comments · Comments Beside Body · Raw Source) in the order it names —
|
||||||
|
/// this row is additive, not a resequencing.
|
||||||
struct CardViewCommands: View {
|
struct CardViewCommands: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
EditBodyCommand()
|
EditBodyCommand()
|
||||||
@@ -114,5 +121,6 @@ struct CardViewCommands: View {
|
|||||||
CommentsBesideBodyCommand()
|
CommentsBesideBodyCommand()
|
||||||
RawSourceCommand()
|
RawSourceCommand()
|
||||||
FutureCommand(title: "History")
|
FutureCommand(title: "History")
|
||||||
|
ShowSidebarCommand()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
window.toolbar = nil
|
window.toolbar = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-validates this window's toolbar items against their current predicates, on demand.
|
||||||
|
///
|
||||||
|
/// `WindowToolbarController`'s own observation tracking (`trackValidationState`) only notices
|
||||||
|
/// `@Observable` reads changing; a predicate that reads a plain `UserDefaults`-backed bit instead
|
||||||
|
/// (`AppPreferences.showCardSidebar`, `CardToolbar`) is invisible to it. A no-op before a toolbar
|
||||||
|
/// exists, which covers every window kind that has none.
|
||||||
|
func revalidateToolbar() {
|
||||||
|
toolbarController?.revalidate()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Title visibility
|
// MARK: Title visibility
|
||||||
|
|
||||||
/// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today
|
/// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ extension NSToolbarItem.Identifier {
|
|||||||
static let cardEditBody = Self("card.editBody")
|
static let cardEditBody = Self("card.editBody")
|
||||||
static let cardRawSource = Self("card.rawSource")
|
static let cardRawSource = Self("card.rawSource")
|
||||||
static let cardAddAttachment = Self("card.addAttachment")
|
static let cardAddAttachment = Self("card.addAttachment")
|
||||||
|
static let cardShowSidebar = Self("card.showSidebar")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The card window's toolbar
|
// 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
|
/// "**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
|
/// 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
|
/// *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
|
/// - **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
|
/// 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
|
/// `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
|
/// (`AddAttachmentCommand.isEnabled`), which is scope plus the read-only lock and says nothing
|
||||||
/// about the body's mode.
|
/// 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
|
/// Labels are the menu titles minus a trailing ellipsis, so File ▸ Add Attachment… labels as **Add
|
||||||
/// Attachment** (03's own example).
|
/// Attachment** (03's own example).
|
||||||
@@ -39,17 +49,28 @@ enum CardToolbar {
|
|||||||
|
|
||||||
static let identifier = "dev.rzen.indie.Kanban.card"
|
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] = [
|
static let defaultItems: [NSToolbarItem.Identifier] = [
|
||||||
.cardEditBody,
|
.cardEditBody,
|
||||||
.cardRawSource,
|
.cardRawSource,
|
||||||
.cardAddAttachment,
|
.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(
|
static func specs(
|
||||||
body: CardBodyPresentation,
|
body: CardBodyPresentation,
|
||||||
rawSource: CardRawSourceSession,
|
rawSource: CardRawSourceSession,
|
||||||
attachments: CardAttachments
|
attachments: CardAttachments,
|
||||||
|
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
|
||||||
|
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
|
||||||
) -> [ToolbarItemSpec] {
|
) -> [ToolbarItemSpec] {
|
||||||
[
|
[
|
||||||
.mirroring(
|
.mirroring(
|
||||||
@@ -90,17 +111,38 @@ enum CardToolbar {
|
|||||||
perform: { [weak attachments] in attachments?.add() }
|
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(
|
static func controller(
|
||||||
body: CardBodyPresentation,
|
body: CardBodyPresentation,
|
||||||
rawSource: CardRawSourceSession,
|
rawSource: CardRawSourceSession,
|
||||||
attachments: CardAttachments
|
attachments: CardAttachments,
|
||||||
|
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
|
||||||
|
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
|
||||||
) -> WindowToolbarController {
|
) -> WindowToolbarController {
|
||||||
WindowToolbarController(
|
WindowToolbarController(
|
||||||
identifier: identifier,
|
identifier: identifier,
|
||||||
specs: specs(body: body, rawSource: rawSource, attachments: attachments),
|
specs: specs(
|
||||||
|
body: body,
|
||||||
|
rawSource: rawSource,
|
||||||
|
attachments: attachments,
|
||||||
|
isSidebarShown: isSidebarShown,
|
||||||
|
setSidebarShown: setSidebarShown
|
||||||
|
),
|
||||||
defaults: defaultItems
|
defaults: defaultItems
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,11 @@ enum CardWindowMetrics {
|
|||||||
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
|
/// 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.
|
/// 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
|
/// - 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"
|
/// — "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
|
/// (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
|
/// Defaulted to `false` so every caller that predates the comments pane still asks the same
|
||||||
/// question it always did.
|
/// 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
|
let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0
|
||||||
return CGSize(
|
return CGSize(
|
||||||
width: sidebarWidth(bodyPointSize: bodyPointSize)
|
width: sidebarComponent
|
||||||
+ bodyMinimumWidth(bodyPointSize: bodyPointSize)
|
+ bodyMinimumWidth(bodyPointSize: bodyPointSize)
|
||||||
+ comments,
|
+ comments,
|
||||||
height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
|
height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
|
||||||
|
|||||||
@@ -17,11 +17,18 @@ import UniformTypeIdentifiers
|
|||||||
/// parameter and the body column takes none either. This view puts one of them in a frame; the
|
/// 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.
|
/// 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 sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width when
|
||||||
/// the resize flex still goes to the body and never to the sidebar. **The comments column is no
|
/// shown, and the resize flex still goes to the body and never to the sidebar. **The comments column
|
||||||
/// longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the beside
|
/// is no longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the
|
||||||
/// mount, so the flex the body and the comments pane split is now the user's to move, within the two
|
/// beside mount, so the flex the body and the comments pane split is now the user's to move, within
|
||||||
/// floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing.
|
/// 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
|
/// ### 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.showCommentsKey) private var showComments = true
|
||||||
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = 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'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
|
/// ▸ 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
|
/// neighbours above are: every open card window's divider answers to one figure. `0` is the
|
||||||
@@ -203,14 +222,25 @@ struct CardWindowView: View {
|
|||||||
contentPanes
|
contentPanes
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.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
|
sidebar
|
||||||
// Fixed, and the one place it comes from.
|
// Fixed, and the one place it comes from.
|
||||||
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
|
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
|
||||||
.frame(maxHeight: .infinity, alignment: .top)
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
.background(.background.secondary)
|
.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
|
// MARK: - The window-wide drop
|
||||||
|
|
||||||
/// Attaches the whole-window file drop, or nothing at all.
|
/// Attaches the whole-window file drop, or nothing at all.
|
||||||
|
|||||||
@@ -182,6 +182,19 @@ enum Motion {
|
|||||||
transientSearchAppearance(reduced: reduced).transition
|
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
|
// MARK: - The AppKit face
|
||||||
|
|
||||||
/// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number
|
/// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number
|
||||||
|
|||||||
@@ -178,6 +178,33 @@ struct CardWindowMetricsTests {
|
|||||||
#expect(CardWindowMetrics.minimumSize(bodyPointSize: size).height > 0)
|
#expect(CardWindowMetrics.minimumSize(bodyPointSize: size).height > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Hiding the sidebar shrinks the window's minimum by exactly its width")
|
||||||
|
func hidingTheSidebarShrinksTheMinimum() {
|
||||||
|
// View ▸ Show Sidebar's own mirror of the comments column's own rule
|
||||||
|
// (`CommentsWindowMinimumTests.theMinimumGrowsOnlyBeside`): a pane costs the window no width
|
||||||
|
// while it is not shown.
|
||||||
|
let size: CGFloat = 13
|
||||||
|
let shown = CardWindowMetrics.minimumSize(bodyPointSize: size)
|
||||||
|
let hidden = CardWindowMetrics.minimumSize(bodyPointSize: size, sidebar: false)
|
||||||
|
|
||||||
|
#expect(shown.width == hidden.width + CardWindowMetrics.sidebarWidth(bodyPointSize: size))
|
||||||
|
#expect(hidden.width == CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size))
|
||||||
|
#expect(hidden.height == shown.height, "hiding the sidebar costs width, never height")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Hiding the sidebar composes with the comments column's own minimum")
|
||||||
|
func hidingTheSidebarComposesWithTheCommentsColumn() {
|
||||||
|
let size: CGFloat = 13
|
||||||
|
let minimum = CardWindowMetrics.minimumSize(bodyPointSize: size, sidebar: false, commentsColumn: true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
minimum.width
|
||||||
|
== CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size)
|
||||||
|
+ CardWindowMetrics.commentsMinimumWidth(bodyPointSize: size),
|
||||||
|
"sidebar hidden and comments beside: the minimum is the body's floor plus the comments floor, nothing else"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("A first card window opens larger than the minimum")
|
@Test("A first card window opens larger than the minimum")
|
||||||
func theDefaultSizeIsRoomier() {
|
func theDefaultSizeIsRoomier() {
|
||||||
let size: CGFloat = 13
|
let size: CGFloat = 13
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ struct ReduceMotionVariantTests {
|
|||||||
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
|
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
|
||||||
#expect(Motion.laneAppearance(reduced: true) == .crossfade)
|
#expect(Motion.laneAppearance(reduced: true) == .crossfade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The card window's sidebar slides from the trailing edge it lives on — View ▸ Show Sidebar's
|
||||||
|
/// one visible consequence — and, like every slide-and-fade here, crossfades instead under
|
||||||
|
/// Reduce Motion.
|
||||||
|
@Test func theSidebarSlidesFromTheTrailingEdgeItLivesOn() {
|
||||||
|
#expect(Motion.cardSidebarAppearance(reduced: false) == .slideAndFade(from: .trailing))
|
||||||
|
#expect(Motion.cardSidebarAppearance(reduced: true) == .crossfade)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The create handoff
|
// MARK: - The create handoff
|
||||||
|
|||||||
@@ -598,16 +598,16 @@ struct CardToolbarTests {
|
|||||||
return (body, raw, attachments)
|
return (body, raw, attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The default set is the whole catalog — the trio, in 03's order")
|
@Test("The default set is the whole catalog — the trio plus Show Sidebar, in this file's order")
|
||||||
func defaultsAreTheCatalog() {
|
func defaultsAreTheCatalog() {
|
||||||
let (body, raw, attachments) = makeHandles()
|
let (body, raw, attachments) = makeHandles()
|
||||||
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
||||||
|
|
||||||
// "Card window default: Edit Body · Raw Source · Add Attachment … the catalog is the same
|
// "Card window default: Edit Body · Raw Source · Add Attachment … the catalog is the same
|
||||||
// trio."
|
// trio" — joined by Show Sidebar, the fourth default item the toolbar-toggle card added.
|
||||||
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment])
|
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar])
|
||||||
#expect(specs.map(\.identifier) == CardToolbar.defaultItems)
|
#expect(specs.map(\.identifier) == CardToolbar.defaultItems)
|
||||||
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment"])
|
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment", "Show Sidebar"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Every item's symbol resolves on this system")
|
@Test("Every item's symbol resolves on this system")
|
||||||
@@ -711,6 +711,45 @@ struct CardToolbarTests {
|
|||||||
#expect(!addAttachment.isEnabled)
|
#expect(!addAttachment.isEnabled)
|
||||||
#expect(addAttachment.isEnabled == AddAttachmentCommand.isEnabled(attachments))
|
#expect(addAttachment.isEnabled == AddAttachmentCommand.isEnabled(attachments))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Show Sidebar is always enabled and drives its own injected write path** — the two closures
|
||||||
|
/// `CardToolbar.specs` defaults to `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, held
|
||||||
|
/// here over a local `Bool` instead so this test never touches the developer's own
|
||||||
|
/// `UserDefaults.standard` domain (`CardSessionUndoTests`' quick-style caution, applied to the one
|
||||||
|
/// item here with no window-scoped handle to hold a scratch value instead).
|
||||||
|
@Test("Show Sidebar toggles its own bit, always enabled, on-state matching the read")
|
||||||
|
func showSidebarTogglesItsOwnBit() {
|
||||||
|
let (body, raw, attachments) = makeHandles()
|
||||||
|
var shown = true
|
||||||
|
let specs = CardToolbar.specs(
|
||||||
|
body: body,
|
||||||
|
rawSource: raw,
|
||||||
|
attachments: attachments,
|
||||||
|
isSidebarShown: { shown },
|
||||||
|
setSidebarShown: { shown = $0 }
|
||||||
|
)
|
||||||
|
guard let showSidebar = specs.spec(.cardShowSidebar) else {
|
||||||
|
Issue.record("no Show Sidebar item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(showSidebar.isOn == true)
|
||||||
|
#expect(showSidebar.isEnabled, "showing or hiding a pane is not a mutation the read-only lock gates")
|
||||||
|
|
||||||
|
showSidebar.activate()
|
||||||
|
#expect(shown == false, "the item drives the same bit the injected closures read")
|
||||||
|
#expect(showSidebar.isOn == false)
|
||||||
|
|
||||||
|
showSidebar.activate()
|
||||||
|
#expect(shown == true)
|
||||||
|
#expect(showSidebar.isOn == true)
|
||||||
|
|
||||||
|
// Raw source and the read-only lock are both the *other* items' predicates — Show Sidebar
|
||||||
|
// reads neither.
|
||||||
|
raw.enter()
|
||||||
|
attachments.isEditable = false
|
||||||
|
#expect(showSidebar.isEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ⌘F and the search field's two homes
|
// MARK: - ⌘F and the search field's two homes
|
||||||
|
|||||||
Reference in New Issue
Block a user