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
245 lines
12 KiB
Swift
245 lines
12 KiB
Swift
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()
|
|
}
|
|
}
|