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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user