import SwiftUI import UniformTypeIdentifiers // MARK: - CardWindowView /// The card window's content: **three componentized panes** — a wide body pane leading, the comments /// pane in the middle when it is shown, and the narrow attributes sidebar trailing /// (05-card-window.md ▸ Composition, re-composed 2026-07-29). /// /// ### The composition arranges; the panes do not know about each other /// /// > each an independent component with its own scroll, arranged by the window's layout rather than /// > wired to each other; componentization is the rule, so the comments pane mounts beside the body /// > or below it (the layout option) without either pane knowing which. /// /// That is enforced here by there being nothing to enforce: `CardCommentsPane` takes no layout /// 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 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 /// /// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the header's /// renderings of what the loader already knows — the card's title and its created/modified line — /// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the /// raw-source outlet swapping the pair of columns out entirely when it is active. /// /// **The title is an editable field in Edit mode, a static rendering in Preview** (m6-card-body, /// widened): it rides the body column's own mode rather than opening a second editor of its own, so /// "Edit mode" means both surfaces at once, and its own session (`titleSession`, `CardTitleEditSession`) /// begins and ends with the body's — see that type's doc comment for the whole of what that buys. /// /// The placeholders that remain are structural rather than apologetic: the sidebar's inventory and its /// order are settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in /// underneath without the composition moving. /// /// ### The width rule, in one line /// /// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That /// is the whole of "the window's resize flex goes to the body, never the sidebar" — no split view /// between body and sidebar, no stored divider position there, nothing for a drag to disagree with. /// /// **The comments column keeps its own, separate width rule** (added 2026-08-09, /// `CommentsColumnDivider`): a real divider, a drag, and a stored width — the exception this /// sentence used to have none of, and still does not have between the body and the *sidebar*. /// /// ### Why the title no longer scrolls with the body /// /// The body surface is a hosted `NSScrollView` (`CardBodySurface`), because ⌘F's find bar lives in /// one — "Edit ▸ Find (⌘F) is find-in-text here … the standard find bar" (05 ▸ Preview). A scroll /// view inside a scroll view is a scroll view that fights, so the column's header — the title and /// its created/modified line — sits above the body's scroller rather than inside it. 05 fixes the /// column's *order* ("Body column, top to bottom") and the columns' independent scrolling, and both /// still hold; which of the two things scrolls the title away was never settled, and pinning the /// card's name over its own body is the better reading of a window whose subtitle already follows it. struct CardWindowView: View { let card: Card /// The board this card belongs to. /// /// The one place in this window a whole store is handed to a view rather than a narrow seam, and /// the Style section is why: it hosts the **shared** style editor, whose API is store-shaped by /// design (it reads the target set's current values and writes through the one `applyStyle` /// bracket every anchor shares). Routing it through a closure of this window's own would be a /// second card-styling path to keep in step with the first — exactly what "one component, one /// behavior" forbids. Delete moved off the sidebar's own Actions section and onto the card /// window's toolbar (`CardToolbar`, `CardWindowActions`) — still the store's own move-to-trash, /// still "exactly the ⌫ delete", just reached through the host's toolbar wiring rather than this /// view. let store: BoardStore /// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md ▸ Styling ▸ /// Controls) — app state, not board state, which is why it arrives beside the store rather than /// on it. let recents: StyleRecents /// The app-wide recently-applied labels the Labels section feeds (`LabelRecents`; /// `FrontmatterKeys.labels`) — `recents`' neighbour in every respect, app state rather than board /// state, arriving beside the store for that field's reason exactly. let labelRecents: LabelRecents /// The card's folder on disk — what relative images and links in the body resolve against /// (05 ▸ Preview). `nil` only where a caller has no board root to build it from. let cardFolder: URL? /// This window's body-column state: which mode it is in, and the find-bar hook. let bodyPresentation: CardBodyPresentation /// This window's Edit buffer. It holds the text **both** surfaces show: the editor writes into /// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say — /// which is exactly the point at which dirty-buffer-wins is decided. let bodySession: CardBodyEditSession /// This window's title field — `bodySession`'s sibling, over the header's single line rather than /// the body span (m6-card-body, `CardTitleEditSession`). Read in both modes for `bodySession`'s /// own reason: a flushed buffer is ahead of the snapshot by a reload, so it is the truer of the /// two whether the header is showing static text or the editable field. let titleSession: CardTitleEditSession /// This window's raw-source outlet. While it is active the two columns are gone entirely — see /// `body`. let rawSource: CardRawSourceSession /// Whether a checkbox may write — `false` under the board's read-only lock. let isEditable: Bool /// This window's attachments section: the listing, the selection, and the two writes it starts /// (05 ▸ Attachments). let attachments: CardAttachments /// This window's comments pane: the thread, the composer's draft buffer, and the one open inline /// edit session (05 ▸ The comments column). It lives on the window's *session* so the close flush /// can reach it, which is why it arrives here rather than being made here. let comments: CardComments /// This window's thumbnail memory, held by the host so it outlives a snapshot. let thumbnails: AttachmentThumbnailCache /// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels). It arrives for exactly /// one consumer — the Style section, the one sidebar anchor that *writes* — because a gesture /// issued in this window registers on this window's stack. It lives on the window's session so /// the close can fold it, which is why it arrives here rather than being made here. let undo: CardWindowUndo /// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole /// window**"). `nil` only where a caller has no store to import through. let fileDrop: CardWindowDropDelegate? /// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw. let onToggleTask: (Int, Bool) -> Void /// **View ▸ Show Comments** and **View ▸ Comments Beside Body** — app-wide, persisted, read here /// rather than passed in (05 ▸ The comments column; ▸ Composition). /// /// `@AppStorage` because the two bits genuinely are app-wide: every open card window obeys the /// same pair, so a window that took them as parameters would need something above it keeping /// every window in step with a value that has exactly one instance. It is also what makes the /// menu rows' checkmarks and these panes provably the same bit (`ShowCommentsCommand`). @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 /// "never dragged" reading (`AppPreferences.commentsColumnWidthKey`'s doc) — a real drag can /// never land there, since `CardWindowMetrics.clampedCommentsColumnWidth`'s floor is always /// positive. @AppStorage(AppPreferences.commentsColumnWidthKey) private var storedCommentsColumnWidth: Double = 0 /// **The divider's in-flight width**, live only while a drag is running — `nil` the rest of the /// time. `LaneResizeSession.liveWidth`'s same split applied to a much smaller session: the drag /// tracks the cursor in memory on every tick (`CommentsColumnDivider.onChange`), and only the /// *release* writes `storedCommentsColumnWidth` (`.onCommit`), so a flick that fires dozens of /// events writes `UserDefaults` — and every sibling card window's divider — exactly once. @State private var commentsColumnWidthOverride: CGFloat? /// The body font's point size, read once per body evaluation: every measurement in this view — /// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale /// together when the system text size changes. private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize } /// Where the comments pane sits, when it is shown at all. private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) } /// The comments column's width before this render's clamp: the live drag's own width while one is /// running, the persisted figure once the divider has ever been dragged and released, and /// `CardWindowMetrics.commentsColumnWidth`'s font-derived default before either has ever happened. /// Unclamped on purpose: clamping needs the beside `HStack`'s live container width, which only the /// `.beside` render branch has (`clampedCommentsColumnWidth(in:)`), so this stays the one place /// that decides *which number* to clamp rather than also deciding *how far*. private var commentsColumnWidth: CGFloat { if let commentsColumnWidthOverride { return commentsColumnWidthOverride } return storedCommentsColumnWidth > 0 ? CGFloat(storedCommentsColumnWidth) : CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize) } /// `commentsColumnWidth` clamped against `containerWidth` — the beside `HStack`'s own width this /// render (`CardWindowMetrics.clampedCommentsColumnWidth`). Re-derived every render rather than /// written back into `storedCommentsColumnWidth`: a window too narrow for the persisted figure /// (a smaller display, say) shows the clamped width without silently shrinking the preference a /// wider window will want back. private func clampedCommentsColumnWidth(in containerWidth: CGFloat) -> CGFloat { CardWindowMetrics.clampedCommentsColumnWidth( commentsColumnWidth, bodyPointSize: bodyPointSize, containerWidth: containerWidth ) } /// The two columns — **or the raw-source editor in place of both of them**. /// /// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the /// **entire content area — title, body, and sidebar —** for the literal on-disk `index.md`") and /// what the rule underneath it requires: the same frontmatter is being edited as raw text, so a /// sidebar still offering to restyle the card, or a title field still writing to `title`, would /// be two editors racing for one file. Unmounting them is the only version of "they can't fight" /// that cannot be got wrong later. /// /// The cost is one thing and it is accepted: the body editor's scroll position and selection do /// not survive a round trip through source mode, because its text view genuinely goes away. What /// does survive is the buffer, which is the Edit session's, not the view's — and it was flushed /// to disk on the way in regardless. var body: some View { content // **The drop surface is the whole window** (05 ▸ Attachments), which is why it hangs // here — outside the raw-source swap, so a file dropped while the source outlet is open // still imports — rather than on the attachments section it fills. The body editor lets // file drags through to this by declining the file types // (`CardBodyTextView.acceptableDragTypes`); dragged *text* never matches `.fileURL` and // so is never offered here at all, which is the other half of the payload split. .modifier(WindowFileDrop(delegate: fileDrop)) } @ViewBuilder private var content: some View { if rawSource.isActive { // **All three panes**, comments included: "Raw Source still swaps the entire content area // — all panes, comments included; the raw outlet's rule is unchanged" (05 ▸ The comments // column). The swap encloses the whole composition below rather than any one pane, which // is what keeps that true as the composition grows. CardRawSourceView(session: rawSource, presentation: bodyPresentation) } else { HStack(spacing: 0) { contentPanes .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // **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) .transition(Motion.cardSidebarTransition(reduced: reduceMotion)) } } .animation(Motion.structural(reduced: reduceMotion), value: showSidebar) } } /// The body pane and the comments pane, in whichever of the two mounts is current — or the body /// pane alone, when Show Comments is off. /// /// **The thread stays visible through body Edit in either mount**, and needs no rule of its own: /// Edit swaps the content of the body pane (`CardBodySurface`), which is *inside* the body column /// here, so nothing about the composition changes when the mode flips. That is the sidebar's own /// precedent, which 05 names when it states the rule. @ViewBuilder private var contentPanes: some View { if showComments { switch mount { case .beside: // The `GeometryReader` is new here for the divider's sake — a resizable column needs // the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`), // exactly the reason the stacked branch below already reaches for one. GeometryReader { proxy in HStack(spacing: 0) { bodyColumn .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) CommentsColumnDivider( bodyPointSize: bodyPointSize, containerWidth: proxy.size.width, currentWidth: clampedCommentsColumnWidth(in: proxy.size.width), onChange: { commentsColumnWidthOverride = $0 }, onCommit: { width in storedCommentsColumnWidth = Double(width) // The persisted figure now says the same thing the override does — // clearing it hands `commentsColumnWidth` back to one source of truth // rather than two that happen to agree. commentsColumnWidthOverride = nil } ) commentsPane // **User-resizable since 2026-08-09** — the sidebar's "resize flex // always goes to the body" (05 ▸ Composition) still holds between the // body and the *sidebar*; the body and the *comments column* now split // it by the divider's drag instead, clamped so neither pane's own floor // gives way (`CardWindowMetrics.clampedCommentsColumnWidth`). .frame(width: clampedCommentsColumnWidth(in: proxy.size.width)) .frame(maxHeight: .infinity, alignment: .top) } } case .stacked: // The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to // have one — `layoutPriority` and flexible frames express *preferences*, and this is // a ratio the design fixes. The body takes its share; the comments pane takes the // remainder, so the divider between them can never leave a gap or overlap. GeometryReader { proxy in VStack(spacing: 0) { bodyColumn .frame(height: mount.bodyHeight(in: proxy.size.height)) .frame(maxWidth: .infinity, alignment: .topLeading) Divider() commentsPane .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } } } else { bodyColumn .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } private var commentsPane: some View { CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails) } // MARK: - Body column /// Title, the quiet created/modified line, then the body — 05's top-to-bottom order. private var bodyColumn: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: bodyPointSize * 0.5) { titleRow if let dateLine { Text(dateLine) .font(.caption) .foregroundStyle(.secondary) .textSelection(.enabled) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) .padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) CardBodySurface( // The session's text, never `card.body` directly: a dirty buffer outranks the // snapshot (05 ▸ Write rules) and a flushed one is ahead of it by a reload, so the // buffer is the truer of the two in both modes — which is also how Preview shows the // text that produced it the instant Edit is left. body: bodySession.text, mode: bodyPresentation.mode, cardFolder: cardFolder, presentation: bodyPresentation, session: bodySession, isTaskToggleEnabled: isEditable, onToggleTask: onToggleTask ) .frame(maxWidth: .infinity, maxHeight: .infinity) } // **Dirty-buffer-wins, applied on every snapshot** (05 ▸ Write rules): the session takes // disk's word for what the file says, and takes it into the editor only when the buffer has // nothing unsaved. `initial: true` is also how the buffer is filled at all — a window opens // by adopting its card's body. .onChange(of: card.body, initial: true) { _, body in bodySession.adopt(diskBody: body) } // The title field's own dirty-buffer-wins, `bodySession`'s exact rule one property over — see // `titleSession`'s doc comment. .onChange(of: card.title.value, initial: true) { _, title in titleSession.adopt(diskTitle: title) } // **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its // body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it // "once" — a later reload that empties the file must not drag a reader into Edit. .task { bodyPresentation.openIfNeeded(body: card.body) } } /// The header's title — large and borderless in both readings (m6-card-body). **Edit mode makes it /// an editable field; Preview keeps it static text** — the same surfaces `CardBodySurface` gives /// the body one column down, over the smaller session one property to the side. /// /// Both branches read `titleSession.text`, never `card.title.value` directly, for the reason /// `titleSession`'s doc comment gives: the buffer outranks the snapshot while it is dirty, and /// outruns it by exactly one reload the instant a save lands, so reading the snapshot in Preview /// would show a stale title for that reload's length — precisely what a card window must never do /// (05-card-window.md ▸ Window). @ViewBuilder private var titleRow: some View { if bodyPresentation.mode == .edit { TextField( "Card title", text: Binding( get: { titleSession.text }, set: { titleSession.edited($0) } ) ) .textFieldStyle(.plain) .lineLimit(1) .font(.largeTitle) // Return commits the field rather than exiting Edit mode — the title's own session ends // with the body's (mode exit, window close), not with this key. `.onSubmit` never sees a // literal newline in the first place: a plain `TextField` treats Return as a submit, which // is the other half of "no newlines" beside `CardTitleEditSession.edited(_:)`'s paste // filter. .onSubmit { titleSession.flush() } } else { // Trimmed for the read-only rendering only — the field above shows `titleSession.text` // raw, so a mid-typed run of padding never has its cursor position disturbed // (`CardTitleEditSession.saveNow()` never rewrites the live buffer either, for the same // reason). Preview only ever renders while nothing is being typed, so trimming purely for // display is free of that hazard and is what keeps a title committed down to nothing // (spaces alone) reading as "Untitled" here rather than as a run of blank space. let displayed = titleSession.text.trimmingCharacters(in: .whitespacesAndNewlines) Text(displayed.isEmpty ? "Untitled" : displayed) .font(.largeTitle) // "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the same // secondary treatment the face gives it. .foregroundStyle(displayed.isEmpty ? .secondary : .primary) .textSelection(.enabled) } } /// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent** /// (05 ▸ Composition) — the whole line disappears when the card carries none of the three. /// /// The "by" segment renders only with the self-reported provenance stamp present /// (01-storage-format.md), which is the point of showing it at all: provenance made visible with /// no commit trail to read it from. private var dateLine: String? { var parts: [String] = [] if let created = card.created.value { parts.append("Created \(Self.dateText(created))") } if let modified = card.modified.value { parts.append("Modified \(Self.dateText(modified))") } if let by = card.modifiedBy.value, !by.isEmpty { parts.append("by \(by)") } return parts.isEmpty ? nil : parts.joined(separator: " · ") } private static func dateText(_ date: Date) -> String { date.formatted(date: .abbreviated, time: .shortened) } // MARK: - Attributes sidebar /// The sidebar's sections: **Style, Labels, Details, Attachments** — Attachments at the bottom of /// the stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same /// day, Pipeline card bcd3b323): its Delete and Reveal in Finder are now the card window's own /// toolbar items (`CardToolbar`), reachable from Customize and, for Delete, on by default. This /// is exactly the `VStack`'s child order, so keyboard Tab order and VoiceOver's reading order /// follow it for free; ⇧⌘A (`AddAttachmentCommand`) still opens the same file panel regardless of /// where Attachments sits in the stack, since it reaches the window through the focus system /// rather than through this view's layout. /// /// 05-card-window.md ▸ The attributes sidebar still documents the pathfinder's four-section order /// with Actions at the bottom — an owed amendment, tracked on the two cards' own threads rather /// than made here. /// /// One of the four sections is conditional, and the condition is the section's own rather than a /// rule restated here: **Details** renders nothing when the card carries no unknown frontmatter /// keys ("shown only when any exist"). Style, Labels and Attachments are unconditional, so the /// composition a user learns on one card is the composition they get on the next. /// /// **Labels joined 2026-08-09** (Pipeline card a4462d28), sitting second — see /// `CardLabelsSection`'s own doc comment for why it goes after Style and, load-bearingly, *above* /// Details: `labels` stopped being an unknown key that day, and Details is the section for keys the /// app does not own. /// /// The History section that once sat between Details and Actions left with app-managed git /// (strategy/01-git-excision.md, 2026-08-08); View ▸ History (`FutureCommands.swift`) is the only /// surviving reservation of that slot, and it anticipates the foreign-change journal successor. private var sidebar: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo) // The snapshot's own reading, not a store lookup: this window's `Card` is the value // the last reload produced, and every other section here renders off it. CardLabelsSection( store: store, recents: labelRecents, cardID: card.id, labels: card.labels.value ?? [], undo: undo ) // The snapshot's own document, not a re-read: the loader parsed this file, unknown // keys and their order included, and `Card` has carried it since (`BoardModel`). CardDetailsSection(rows: CardDetails.rows(of: card.document)) CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) } .frame(maxWidth: .infinity, alignment: .leading) .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) } } } // 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 { 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. /// /// A modifier rather than an `if` inside `body` because `.onDrop` has to be applied to the *same* /// view identity in both cases: a window whose store arrives a turn after its view would otherwise /// re-mount its entire content when the drop target appeared, throwing away the body's scroll /// position for nothing. private struct WindowFileDrop: ViewModifier { let delegate: CardWindowDropDelegate? func body(content: Content) -> some View { if let delegate { // `.fileURL` alone: a text drag never matches, so it is never offered here and falls to // the Edit editor, where `NSTextView` inserts it at the caret (05 ▸ Attachments). content.onDrop(of: [.fileURL], delegate: delegate) } else { content } } }