import SwiftUI import UniformTypeIdentifiers // MARK: - CardWindowView /// The card window's content: **two full-height, independently scrolling columns** — a wide body /// column leading, a narrow attributes sidebar trailing (05-card-window.md ▸ Composition). /// /// ### What this milestone builds, and what it deliberately does not /// /// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only /// 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. Everything that /// reads or writes beyond that is later work and is marked where it lands: /// /// - the title as an editable field (commit on Return / focus loss, Escape abandons), /// - the sidebar's History section, whose place in the stack is reserved and whose content waits on /// a git mode to be honest about (`historySlot`). /// /// The placeholders 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" — no split view, no stored divider /// position, nothing for a drag to disagree with. /// /// ### 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 sidebar is why: the Style section 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), and the Actions section's Delete is the store's own /// move-to-trash. Routing either through a closure of this window's own would be a second /// card-styling or card-deleting path to keep in step with the first — exactly what "one /// component, one behavior" and "exactly the ⌫ delete" forbid. 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 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 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 thumbnail memory, held by the host so it outlives a snapshot. let thumbnails: AttachmentThumbnailCache /// 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 /// 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 } /// 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 { CardRawSourceView(session: rawSource, presentation: bodyPresentation) } else { HStack(spacing: 0) { bodyColumn .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) Divider() sidebar // Fixed, and the one place it comes from. .frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)) .frame(maxHeight: .infinity, alignment: .top) .background(.background.secondary) } } } // 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) { // m6-card-body: the title *field* — large and borderless, committing to frontmatter // on Return or focus loss, clearing to remove the `title` key, Escape abandoning to // the on-disk title. Read-only here; the placeholder rendering is already final. Text(card.title.value ?? "Untitled") .font(.largeTitle) // "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the // same secondary treatment the face gives it. .foregroundStyle(card.title.value == nil ? .secondary : .primary) .textSelection(.enabled) 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 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) } } /// "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 where /// git history may not exist. 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, **in 05's settled order**: Attachments, Style, Details, History, /// Actions. /// /// Two of the five are conditional, and both conditions are 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"), and **History** is absent on boards without app-managed git. /// Everything else in the stack is unconditional, so the composition a user learns on one card is /// the composition they get on the next. private var sidebar: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) CardStyleSection(store: store, recents: recents, cardID: card.id) // 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)) historySlot CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder) } .frame(maxWidth: .infinity, alignment: .leading) .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) } } /// **The History section's reserved place in the stack** — between Details and Actions, 05's /// order (05 ▸ History: "the card's commit trail, read-only … newest first — semantic subject, /// relative date, author"). /// /// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not /// exist here, so a header over empty space would claim a commit trail on every board — and on /// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one /// that can never arrive. What the slot reserves is the **position**, so filling it in moves /// nothing above or below it. /// // m7-git: the trail itself, plus the two rules that come with it — absence on boards without // app-managed git (the same honesty rule as the board popover's git section, 06-history-undo.md) // and View ▸ History, which focuses this section (11-command-nexus.md). @ViewBuilder private var historySlot: some View { EmptyView() } } // 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 } } }