Build the Raw Source outlet

The escape hatch: View > Raw Source (opt-cmd-E) unmounts the whole
content area for the literal on-disk index.md in a plain monospaced
editor with Cancel/Apply. Raw source is window-level state, not a third
body mode — entry rides setMode(.preview), which flushes the Edit
session by construction, then reads the file fresh; exit reveals
Preview, and an empty body after Apply does not reopen Edit (openIfNeeded
already ran). Apply validates the proposed bytes through the loader's
own card checks — parseDocument's strict UTF-8/BOM rejection, schema,
order — deliberately skipping the uneditable-shape refusal, since a
flow-mapping card is exactly what the hatch repairs; invalid bytes
alert in place with the loader's own error and no bracket opens. The
write is byte-for-byte with no modified stamp and no modified-by clear,
per 01's explicit carve-out — the verbatim contract outranks stamping —
and identical bytes write nothing. Escape cancels, cmd-Return applies,
toggle-off applies too, and cmd-E disables while raw is active via a
testable predicate. Tombstoned targets refuse as vanished: a foreign
delete is never reverted by a stale buffer.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 11:25:25 -04:00
parent e989c1f26e
commit 40c0a75c24
12 changed files with 1733 additions and 65 deletions
+24 -11
View File
@@ -7,8 +7,10 @@ import SwiftUI
///
/// **Two cases, not three.** The raw-source outlet swaps the *entire content area* title, body
/// and sidebar so it is a state of the window, not of the body column, and it does not belong in
/// this enum. Edit Body disabling while raw source is active (11-command-nexus.md) is that
/// window-level state's rule to enforce over this one.
/// this enum. It lives in `CardRawSourceSession`, which is also where the grammar's two open
/// questions are settled (which mode a raw exit lands in, and what an empty body after Apply does).
/// Edit Body disabling while raw source is active (11-command-nexus.md) is that window-level state's
/// rule over this one, and it is enforced on the row: `EditBodyCommand.isEnabled(body:rawSource:)`.
public enum CardBodyMode: Equatable, Sendable {
/// The rendered, selectable preview **the resting state**.
case preview
@@ -123,18 +125,29 @@ public final class CardBodyPresentation {
/// what changed with this milestone is the validation and the action, exactly as `FutureCommands`
/// predicts.
///
/// Validation is scope: with no card window in front there is no `cardBody` focused value, and the
/// row disables. The read-only lock is deliberately **not** part of it entering Edit is not a
/// mutation, and 02-architecture.md § the lock's scope keeps editor buffers alive under the lock
/// (only their saves suspend), so a locked board can still be read in the editor and its text
/// copied out.
/// Validation is scope **plus the raw-source clause**: with no card window in front there is no
/// `cardBody` focused value, and the row disables; with source mode active it disables too "View
/// Edit Body (E) disables while source mode is active, matching its toolbar item" (05 Raw source
/// outlet; 11-command-nexus.md files the same clause on the row). The reason is that the two would
/// be editing the same bytes from two surfaces: while the whole `index.md` is open as text, a mode
/// flip in the body column beneath it has nothing to flip *to* the column is not on screen and
/// its buffer's next debounced save would write a body the raw buffer is also about to overwrite.
/// Cancel and Apply own the exits (03-board-ui.md Toolbar).
///
// m6-raw-source: "View Edit Body (E) disables while source mode is active, matching its toolbar
// item" (05 Raw source outlet). That is one more clause on `isDisabled` once a window-level raw
// mode exists to read; the row, its title and its chord do not move.
/// The read-only lock is deliberately **not** part of it entering Edit is not a mutation, and
/// 02-architecture.md § the lock's scope keeps editor buffers alive under the lock (only their saves
/// suspend), so a locked board can still be read in the editor and its text copied out.
struct EditBodyCommand: View {
@FocusedValue(\.cardBody) private var cardBody
@FocusedValue(\.cardRawSource) private var rawSource
/// The row's validation, as a value a test can hold: a menu item's `.disabled` is otherwise only
/// observable by driving the menu bar, and "E disables while raw source is active" is precisely
/// the kind of clause that regresses silently.
static func isEnabled(body: CardBodyPresentation?, rawSource: CardRawSourceSession?) -> Bool {
body != nil && rawSource?.isActive != true
}
var body: some View {
Toggle("Edit Body", isOn: Binding(
@@ -142,7 +155,7 @@ struct EditBodyCommand: View {
set: { isOn in cardBody?.setMode(isOn ? .edit : .preview) }
))
.keyboardShortcut("e", modifiers: .command)
.disabled(cardBody == nil)
.disabled(!Self.isEnabled(body: cardBody, rawSource: rawSource))
}
}
+270
View File
@@ -0,0 +1,270 @@
import Observation
import SwiftUI
// MARK: - CardRawSourceSession
/// One card window's raw-source outlet: whether it is showing, the text in it, and the three seams
/// through which it reaches the file (05-card-window.md Raw source outlet).
///
/// ### Why this is a window state and not a third body mode
///
/// `CardBodyMode` says it in its own doc comment, and this type is the other half of that sentence:
/// the outlet "swaps the **entire content area title, body, and sidebar ** for the literal on-disk
/// `index.md`", so it is a state of the *window*, not of the body column. Folding it into the mode
/// enum would have made every `switch` over Preview/Edit answer a question about the sidebar.
///
/// ### The mode grammar, settled where 05 leaves it open
///
/// 05 fixes the keys (E in and out, Escape is Cancel, is Apply, toggling off applies) and says
/// nothing about which body mode the window returns to. Two decisions record themselves here:
///
/// - **Leaving raw source lands in Preview.** Not a fresh choice a consequence: 05 Mode grammar
/// lists "raw-source entry" among the three events that *leave Edit* ("Leaving Edit flushes the
/// debounce (mode flip, raw-source entry, window close)"). Entering therefore genuinely leaves
/// Edit, and Preview is the resting state it leaves to; exiting simply reveals what was already
/// there. It reads right, too: after an Apply that may have rewritten the body wholesale, the
/// rendered result is the useful thing to show, not an editor over text the user just retyped.
/// - **An empty body after Apply does not open Edit.** The empty-body rule is about *opening a card*
/// and has already run for this window (`CardBodyPresentation.openIfNeeded`); a raw exit is not an
/// open. A user who emptied the body in raw source gets the blank Preview they wrote, and E.
///
/// ### The seams, and why they are closures
///
/// `flushPendingEdits`, `read` and `apply` are filled in by the window once it has a store and a card
/// (`CardWindowHost.configureSession`) `CardBodyEditSession.save`'s precedent, and for its reason:
/// this type is a buffer and a state machine, and it stays testable by having no idea what a board
/// is. `nil` seams mean a window that has not joined its board yet, and every one of them fails
/// closed the outlet does not open, and an Apply writes nothing.
@MainActor
@Observable
public final class CardRawSourceSession {
// MARK: State
/// Whether the outlet is showing the whole of "View Raw Source's checkmark", "the content
/// area is swapped", and "Edit Body disables while source mode is active".
public private(set) var isActive = false
/// The editor's buffer: the file as it was read, plus whatever the user has typed since.
///
/// **Never reconciled with disk while it is open.** A watcher reload arriving mid-session updates
/// everything else and leaves this alone 05's dirty-buffer rule, and here it is not even a
/// question of dirtiness: the buffer is the *whole file*, so "following disk" would mean throwing
/// away the user's edit the moment anything on the board changed. "A pull landing mid-session
/// neither blocks on the open buffer nor invalidates it Apply stays last-writer-wins" (05).
public var text = ""
/// The alert waiting to be shown, if any a validation refusal on Apply, or a file that could
/// not be opened as source. Cleared by the OK that dismisses it, which returns the user to the
/// text they were editing (05: "a failed validation keeps source mode open (toggle stays
/// checked) with the alert").
public private(set) var alert: CardRawSourceAlert?
// MARK: Seams
/// Flushes pending title and body edits "Entering source mode flushes any pending title/body
/// edits first, **then** reads the file fresh from disk" (05). The ordering is the contract: the
/// read must see the flushed body, or the outlet would open on a file the app was about to
/// overwrite from a buffer the user could no longer see.
///
/// The window wires this to the body column's own flush *and* to the Preview flip, which is the
/// same call: `CardBodyPresentation.setMode(.preview)` flushes on its way out of Edit by
/// construction.
@ObservationIgnored
public var flushPendingEdits: (() -> Void)?
/// Reads the file fresh `BoardStore.readCardSource(inCard:)`.
@ObservationIgnored
public var read: (() -> RawSourceReadOutcome)?
/// Validates and writes the buffer `BoardStore.applyCardSource(inCard:text:)`.
@ObservationIgnored
public var apply: ((String) -> RawSourceApplyOutcome)?
/// How many Applies have actually reached the seam. The state machine's own testimony: "Cancel
/// wrote nothing" is otherwise an inference from bytes that a passing test could reach by
/// accident.
@ObservationIgnored
public private(set) var applyAttempts = 0
public init() {}
// MARK: - Entering
/// E on a window that is not in source mode: flush, then read fresh, then show.
///
/// **A file that cannot be read does not open the outlet** (settled here 05 covers Apply's
/// failure and is silent on entry's). The alternative, opening an editor over an error message or
/// over lossily-decoded bytes, would make the escape hatch's one promise false: whatever is in
/// that editor is what is in the file, and Apply writes it back. A refusal keeps the toggle
/// unchecked and says why.
///
/// - Returns: whether source mode opened.
@discardableResult
public func enter() -> Bool {
guard !isActive else { return true }
flushPendingEdits?()
guard let read else { return false }
switch read() {
case let .read(source):
text = source
alert = nil
isActive = true
return true
case .vanished:
// The window is dismissing itself; an alert about a card that is already gone from the
// board would outlive the thing it is about.
return false
case let .failed(error):
alert = .unreadable(error)
return false
}
}
// MARK: - Leaving
/// , the Apply button, and **E toggling off** "leaving-by-toggle commits, mirroring
/// leaving-Edit-flushes" (05).
///
/// Three outcomes close the outlet and three keep it open; see `RawSourceApplyOutcome`. The two
/// that keep it open without an alert of their own (a failed write, a locked board) are already
/// spoken for by the banner strip and the standing lock row, and what matters here is the same in
/// both cases: the text stays on screen, because it is the only place it exists.
///
/// - Returns: whether source mode closed.
@discardableResult
public func applyAndLeave() -> Bool {
guard isActive else { return false }
guard let apply else { return false }
applyAttempts += 1
switch apply(text) {
case .applied, .unchanged:
close()
return true
case .vanished:
// "A card hard-deleted externally (folder gone) discards both nowhere left to write"
// (05 Deletion & lifecycle).
close()
return true
case let .invalid(error):
alert = .invalid(error)
return false
case .suspended, .failed:
return false
}
}
/// Escape, the Cancel button, and the window closing **"discards without ceremony"** (05).
///
/// No flush, no confirmation, no `DirtyBufferGuard`: the raw buffer was never a debounced session
/// with saves owed to it, and its Apply is an explicit act the user did not perform. The close
/// path needs no call at all the window goes and the buffer with it which is exactly why this
/// is safe to be a one-liner.
public func cancel() {
close()
}
/// OK on the alert back to the text, which was never touched.
public func dismissAlert() {
alert = nil
}
private func close() {
isActive = false
alert = nil
text = ""
}
}
// MARK: - The alert
/// The raw-source outlet's one modal surface: **the detailed alert** 05 requires on a failed
/// validation, plus the entry refusal that shares its shape.
///
/// It carries the errors rather than strings so the phrasing stays in one place and the taxonomy
/// stays undiluted the loader's own message, line number included, is what makes an alert
/// "detailed" rather than "sorry, something is wrong".
public enum CardRawSourceAlert: Sendable, Equatable {
/// Apply refused: the text would not load (`BoardLoader.validateCardIndex`).
case invalid(BoardLoadError)
/// The file could not be opened as source unreadable, or not UTF-8.
case unreadable(BoardWriteError)
/// The alert's title what happened, in the user's vocabulary (Apply; opening the source).
public var title: String {
switch self {
case .invalid: "These source changes can't be applied"
case .unreadable: "This card's source can't be opened"
}
}
/// The detail, plus the reassurance that nothing was written. Both cases end in the same place
/// the file on disk is exactly as it was which is the fact that makes the alert dismissible
/// with a single OK.
public var message: String {
switch self {
case let .invalid(error):
"\(error.reason.description)\n\nThe file on disk is unchanged."
case let .unreadable(error):
"\(error.reason.description)\n\nThe file on disk is unchanged."
}
}
}
// MARK: - View Raw Source
/// View Raw Source (E) the card window's raw-source toggle, with checkmark state
/// (11-command-nexus.md: "Raw Source (checkmark toggle; toggling off = Apply)"; 05-card-window.md
/// Raw source outlet).
///
/// **A `Toggle` whose two directions are not symmetric**, which is the row's whole subtlety: on
/// enters, off *applies*. 04-interactions.md Configurable bindings requires one stable title with
/// checkmark state only, so the row cannot say "Apply" when it is checked the asymmetry lives in
/// the action, and the checkmark simply fails to clear when a validation refuses (05: "a failed
/// validation keeps source mode open (toggle stays checked) with the alert"). That falls out for
/// free: the checkmark reads `isActive`, and `applyAndLeave()` leaves it standing.
///
/// Validation is scope alone a card window in front. The read-only lock is deliberately not part of
/// it, `EditBodyCommand`'s reasoning: entering source mode is a *read*, and 02-architecture.md § the
/// lock's scope keeps editor buffers alive under the lock so a locked board's file can still be
/// opened and copied out. The Apply is the mutation, and `performWrite` refuses it there.
struct RawSourceCommand: View {
@FocusedValue(\.cardRawSource) private var rawSource
var body: some View {
Toggle("Raw Source", isOn: Binding(
get: { rawSource?.isActive == true },
set: { isOn in
guard let rawSource else { return }
if isOn {
rawSource.enter()
} else {
rawSource.applyAndLeave()
}
}
))
.keyboardShortcut("e", modifiers: [.option, .command])
.disabled(rawSource == nil)
}
}
/// The focused card window's raw-source outlet, beside `FocusedValues.cardBody` see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
///
/// Its own focused value rather than a field on `CardBodyPresentation`: the two are read by different
/// rows (E and E), and one of them Edit Body needs *both*, which is exactly the shape a
/// separate key expresses and a merged object would hide.
struct FocusedCardRawSourceKey: FocusedValueKey {
typealias Value = CardRawSourceSession
}
extension FocusedValues {
var cardRawSource: CardRawSourceSession? {
get { self[FocusedCardRawSourceKey.self] }
set { self[FocusedCardRawSourceKey.self] = newValue }
}
}
+244
View File
@@ -0,0 +1,244 @@
import AppKit
import SwiftUI
// MARK: - CardRawSourceView
/// The raw-source outlet's content: a monospaced editor over the literal `index.md`, with Cancel and
/// Apply beneath it (05-card-window.md Raw source outlet).
///
/// It replaces **the whole content area** title, body and sidebar rather than sitting beside
/// them, which is the design's own word for it and the reason the interactive controls are gone
/// while it is up: "the same frontmatter is being edited as raw text, so interactive controls over it
/// would fight the raw edit". The window keeps its title bar, its subtitle and its toolbar; nothing
/// inside the window survives.
///
/// ### No syntax highlighting, deliberately
///
/// 05 gives the Edit editor "lightweight Markdown syntax highlighting" and gives this one exactly
/// "a monospaced editor". The asymmetry is honest rather than an omission: this file is YAML *and*
/// Markdown with a delimiter between them, and a Markdown pass run over the frontmatter would tint
/// `---` as a thematic break and a `# comment` as a heading dressing the file up as something it
/// is not, in the one surface whose promise is that it shows the file as it is. What the editor does
/// borrow is the *font*: `MarkdownHighlighter.baseAttributes` is the app's one monospaced run, so the
/// two editors match without either owning a font.
///
/// ### What it shares with the body surface, and why
///
/// F, the find bar, selection, copying, session-scoped undo, and the suppression of every automatic
/// substitution are all here too the same reasons `CardBodySurface` gives, and one more that is
/// specific to this surface: a smart quote substituted into YAML would be the app corrupting the
/// user's frontmatter as they typed it.
struct CardRawSourceView: View {
let session: CardRawSourceSession
/// The window's body handle the raw editor takes over its `findInText` while it is on screen,
/// because the body surface it normally points at has been unmounted by the swap.
let presentation: CardBodyPresentation
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
VStack(spacing: 0) {
CardRawSourceEditor(session: session, presentation: presentation)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
HStack(spacing: bodyPointSize * 0.75) {
Spacer()
// Escape. `.cancelAction` and the text view's own `cancelOperation` both aim here
// belt and braces, because which of the two sees the key depends on where focus is
// and `cancel()` is idempotent, so a double hit is one discard.
Button("Cancel") { session.cancel() }
.keyboardShortcut(.cancelAction)
// **, never plain Return** "Return just types it's an editor" (05). Which is
// also why this is not `.defaultAction`: that would bind Return, and the first
// newline the user typed in their frontmatter would apply the file instead.
Button("Apply") { session.applyAndLeave() }
.keyboardShortcut(.return, modifiers: .command)
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
.padding(.vertical, bodyPointSize * 0.6)
}
}
}
// MARK: - The editor
/// The hosted text view: TextKit's own editor, configured for a file rather than for prose.
private struct CardRawSourceEditor: NSViewRepresentable {
let session: CardRawSourceSession
let presentation: CardBodyPresentation
func makeCoordinator() -> Coordinator {
Coordinator(session: session)
}
func makeNSView(context: Context) -> NSScrollView {
// The stack is built by hand for `CardBodySurface`'s reason `NSTextView(frame:)` hands back
// a TextKit 2 view, and this app's text machinery (its find bar, its storage access) is
// written against TextKit 1 throughout. One substrate, not two.
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
storage.addLayoutManager(layoutManager)
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
container.widthTracksTextView = true
layoutManager.addTextContainer(container)
let textView = CardRawSourceTextView(frame: .zero, textContainer: container)
textView.delegate = context.coordinator
textView.isEditable = true
textView.isSelectable = true
// Plain text in every sense: no attributes the user can introduce, and none the app adds
// beyond the monospaced base run.
textView.isRichText = false
textView.drawsBackground = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = .width
textView.minSize = CGSize(width: 0, height: 0)
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
// The list `CardBodySurface` explains, with more at stake: a smart quote or an en dash
// substituted into YAML is not a cosmetic liberty, it is the app rewriting the user's
// frontmatter behind the cursor.
textView.isAutomaticLinkDetectionEnabled = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.isAutomaticDataDetectionEnabled = false
textView.smartInsertDeleteEnabled = false
textView.isContinuousSpellCheckingEnabled = false
textView.allowsUndo = true
textView.usesFindBar = true
textView.isIncrementalSearchingEnabled = true
// One run, three properties: a plain-text `NSTextView` drives display off `font` and
// `textColor` rather than off `typingAttributes`, so all three are filled from the app's
// single monospaced run `MarkdownHighlighter.baseAttributes`, borrowed for its font rather
// than for its highlighting, which this editor deliberately does not do.
let base = MarkdownHighlighter.baseAttributes(pointSize: CardWindowMetrics.bodyPointSize)
textView.typingAttributes = base
textView.font = base[.font] as? NSFont
textView.textColor = base[.foregroundColor] as? NSColor
let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
textView.textContainerInset = CGSize(width: gutter, height: gutter)
textView.onCancel = { [session] in session.cancel() }
let scrollView = NSScrollView()
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.drawsBackground = false
scrollView.findBarPosition = .aboveContent
context.coordinator.textView = textView
// Deferred one turn, `CardBodySurface`'s reason: this runs inside a SwiftUI update and both
// writes below re-enter graphs that update is already walking.
Task { @MainActor [weak textView] in
guard let textView else { return }
// F is find-in-text "over the focused body surface (Preview's selectable text, the Edit
// editor, **raw source**)" 05 Preview names this surface explicitly. The body
// surface's own closure died with the view the swap unmounted; this one replaces it, and
// the body surface reclaims it when the swap goes the other way.
presentation.findInText = { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
let sender = NSMenuItem()
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
textView.performTextFinderAction(sender)
}
textView.window?.makeFirstResponder(textView)
}
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.session = session
guard let textView = scrollView.documentView as? CardRawSourceTextView,
let storage = textView.textStorage
else { return }
// The equality guard is load-bearing, not an optimization: this runs on every keystroke (the
// buffer changed, so SwiftUI re-ran the update), and re-setting the storage to the string it
// already holds would collapse the selection and drop the undo stack per character typed.
// In practice it therefore only fires once the initial fill, where the text flows the other
// way for the only time in the session.
guard storage.string != session.text else { return }
let selected = textView.selectedRange()
storage.setAttributedString(NSAttributedString(
string: session.text,
attributes: MarkdownHighlighter.baseAttributes(pointSize: CardWindowMetrics.bodyPointSize)
))
let length = (session.text as NSString).length
textView.setSelectedRange(NSRange(
location: min(selected.location, length),
length: min(selected.length, max(0, length - min(selected.location, length)))
))
}
// MARK: Coordinator
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
var session: CardRawSourceSession
weak var textView: NSTextView?
/// **The editor's own undo manager** `CardBodySurface`'s rule, applied to the surface 06
/// names in the same breath: "While a text-editing surface is focused (card title field, body
/// Edit mode, **raw source**, board inline rename), Z/Z are that editor's own text undo
/// standard, transient, session-scoped" (06-history-undo.md Undo routing). Owning one here
/// rather than borrowing the window's is what keeps Z after a Cancel from reaching back into
/// a buffer that was deliberately discarded.
private let editorUndoManager = UndoManager()
init(session: CardRawSourceSession) {
self.session = session
}
func undoManager(for view: NSTextView) -> UndoManager? {
editorUndoManager
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
session.text = textView.string
}
/// Focus leaving the editor ends the undo session (06's "session-scoped"), and is not a
/// commit: only Apply writes, and it is a button and a chord, never a side effect of clicking
/// somewhere else.
func textDidEndEditing(_ notification: Notification) {
editorUndoManager.removeAllActions()
}
}
}
// MARK: - CardRawSourceTextView
/// The raw editor's text view, subclassed for exactly one key.
///
/// **Escape is Cancel** (05 Raw source outlet), and it has to be caught here because an editable
/// `NSTextView` has its own meaning for it (text completion) and would swallow it before the button's
/// key equivalent ever ran. With the find bar up the bar is first responder and this is never
/// reached which is correct: Escape closes the find bar, exactly as it does everywhere else in
/// macOS.
final class CardRawSourceTextView: NSTextView {
var onCancel: (() -> Void)?
override func cancelOperation(_ sender: Any?) {
guard let onCancel else {
super.cancelOperation(sender)
return
}
onCancel()
}
}
+31 -11
View File
@@ -9,11 +9,11 @@ import SwiftUI
///
/// 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`). Everything that
/// 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 raw-source outlet,
/// - the sidebar's five sections, which are section *headers* here and nothing more.
///
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
@@ -47,6 +47,9 @@ struct CardWindowView: View {
/// 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
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
@@ -57,18 +60,35 @@ struct CardWindowView: View {
/// 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 {
HStack(spacing: 0) {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
if rawSource.isActive {
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
} else {
HStack(spacing: 0) {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Divider()
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)
}
}
}