Files
lanework/Kanban/UI/Card/CardRawSourceView.swift
T
rzen 40c0a75c24 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
2026-07-28 11:25:25 -04:00

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()
}
}