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