import AppKit import SwiftUI // MARK: - CardBodySurface /// The body column's text surface: **one hosted `NSTextView` serving both modes** — the rendered /// Preview and the raw-Markdown Edit editor. /// /// ### Why AppKit, and not `Text(…).textSelection(.enabled)` /// /// Four of Preview's settled rules are things a SwiftUI `Text` cannot do, and each of them is /// normative rather than nice-to-have: /// /// - **⌘F is find-in-text here** (05-card-window.md ▸ Preview; 11-command-nexus.md scopes ⌘F to /// find-in-text in the card window). The standard find bar is `NSTextFinder` over a text view in /// a scroll view; SwiftUI's text selection offers no find at all, and a hand-rolled search UI /// would be a second, worse find bar in an app whose whole posture is to use the system's. /// - **Clicking never edits, but a checkbox does something.** A text view already hit-tests /// characters, already distinguishes a click from a selection drag, and already reports the one /// it decided on to its delegate. That machinery is exactly the "selectable everywhere, live in /// one place" grammar, and re-deriving it from a SwiftUI gesture over a `Text` would mean /// re-deriving text selection. /// - **Links open things.** `.link` attributes plus `textView(_:clickedOnLink:at:)` is the whole of /// "external URLs open in the browser; relative links open the target with its default app". /// - **Tables.** `NSTextTable`'s automatic layout *is* the browser sizing rule the design names, /// and it is a TextKit 1 construct — which is why the stack below is built by hand rather than /// taken from `NSTextView(frame:)`, whose modern default is TextKit 2. /// /// ### One substrate, two modes /// /// Preview and Edit differ in three things and nothing else: whether the view is editable, what it /// is handed (a rendered attributed string, or the raw text under a highlighting pass), and which /// key means "flip". Everything else — ⌘F, selection, copying, the find bar, the scroll position — /// belongs to the substrate and is therefore identical in both, without either mode implementing it. /// /// **One view rather than two representables**, deliberately: `CardBodyPresentation.findInText` holds /// a closure over *this* text view, and two views swapping across a mode flip would race to own it — /// ⌘F would work or not depending on the order SwiftUI happened to mount them in. One view has one /// text view for the window's life, and the flip is a reconfiguration. /// /// ### What the editor writes, and when /// /// Nothing here writes to disk. The text view reports every change to `CardBodyEditSession`, which /// owns the ~700 ms debounce, the three write gates and the flush; this file's whole responsibility /// is that the buffer and the view agree, and that the view never has text replaced under the user's /// cursor (the view half of dirty-buffer-wins — the session's half is `adopt(diskBody:)`). struct CardBodySurface: NSViewRepresentable { /// The text to show: `CardBodyEditSession.text`, which is the buffer in Edit and — because a /// clean buffer follows disk — the card's body in Preview. One string for both modes is what /// makes "the preview never lags the text that produced it" (05 ▸ Mode grammar) fall out rather /// than need arranging. let body: String let mode: CardBodyMode /// The card's own folder: what relative images and links resolve against. let cardFolder: URL? /// The window's body handle — this view fills in its `findInText`. let presentation: CardBodyPresentation /// The buffer this surface edits. Keystrokes go in through `edited(_:)`; nothing else here /// touches it. let session: CardBodyEditSession /// Whether a checkbox click may write. `false` under the read-only lock, where "the controls /// disable in place" (05 ▸ Preview; 02-architecture.md § the lock's scope). let isTaskToggleEnabled: Bool /// Byte offset and the state the user saw — straight through to `BoardStore.toggleTaskMarker`. let onToggleTask: (Int, Bool) -> Void func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSScrollView { // TextKit 1, explicitly: `NSTextView(frame:)` would give a TextKit 2 stack, in which // `NSTextTable` does not lay out. Building the stack by hand is the supported way to ask // for the older one, and it is the only reason this is not a one-line construction. 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 = CardBodyTextView(frame: .zero, textContainer: container) textView.delegate = context.coordinator textView.isEditable = false textView.isSelectable = true textView.isRichText = true textView.drawsBackground = false textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.autoresizingMask = NSView.AutoresizingMask.width textView.minSize = CGSize(width: 0, height: 0) textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) // Nothing about a card body is the app's to rewrite as the user reads it — or as they type // it: "the text is the raw Markdown, character for character — no hidden transforms, no // smart substitutions" (05 ▸ Edit) is exactly this list, and in Edit it is normative rather // than merely tidy. A smart quote substituted into a fenced code block would be the app // silently corrupting the user's file. textView.isAutomaticLinkDetectionEnabled = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false textView.isAutomaticDataDetectionEnabled = false textView.smartInsertDeleteEnabled = false // ⌘Z is the editor's own undo (05 ▸ Edit). `allowsUndo` turns it on; the *session* scoping is // the coordinator's `undoManager(for:)`, below. textView.allowsUndo = true // The renderer already coloured links and checkboxes; the only thing the text view should // add is the pointer, so the two do not fight over the run's appearance. let linkAttributes: [NSAttributedString.Key: Any] = [.cursor: NSCursor.pointingHand] textView.linkTextAttributes = linkAttributes textView.displaysLinkToolTips = true textView.usesFindBar = true textView.isIncrementalSearchingEnabled = true let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize) textView.textContainerInset = CGSize(width: gutter, height: gutter) // The two fixed keys of the mode grammar, on the surface that owns the keyboard while they // are pressed: "Return in Preview also enters Edit … Escape in Edit returns to Preview" // (05 ▸ Mode grammar). They are the text view's rather than a SwiftUI `.onKeyPress` because // the text view *is* the first responder in both modes — a key handler above it would only // see what the editor declined to eat. let presentation = presentation textView.onReturnInPreview = { presentation.setMode(.edit) } textView.onEscapeInEdit = { presentation.setMode(.preview) } 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 context.coordinator.session = session context.coordinator.onToggleTask = onToggleTask context.coordinator.isTaskToggleEnabled = isTaskToggleEnabled // Deferred one turn: `makeNSView` runs inside SwiftUI's update, and `presentation` is // observed by a menu item (Edit ▸ Find), so writing to it here would be a mutation during // an update of the very graph that reads it. Task { @MainActor [weak textView] in presentation.findInText = { [weak textView] in guard let textView else { return } textView.window?.makeFirstResponder(textView) // `performTextFinderAction` takes its verb from the sender's `tag`, which is how // the standard Edit ▸ Find menu item drives it; a menu item made for the purpose // says the same thing from a closure. let sender = NSMenuItem() sender.tag = NSTextFinder.Action.showFindInterface.rawValue textView.performTextFinderAction(sender) } } return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { let coordinator = context.coordinator coordinator.onToggleTask = onToggleTask coordinator.isTaskToggleEnabled = isTaskToggleEnabled coordinator.session = session guard let textView = scrollView.documentView as? CardBodyTextView else { return } let pointSize = CardWindowMetrics.bodyPointSize if coordinator.mode != mode { coordinator.enter(mode, in: textView) } let key = Coordinator.RenderKey(body: body, mode: mode, cardFolder: cardFolder, pointSize: pointSize) // **Rebuild only when the inputs changed.** SwiftUI re-runs this on every unrelated state // change in the window; re-laying out the whole body each time would throw away the scroll // position and the selection — the reader's place in a document they are reading. guard coordinator.rendered != key else { return } coordinator.rendered = key switch mode { case .preview: let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder) textView.textStorage?.setAttributedString( BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context) ) case .edit: coordinator.show(body, in: textView, pointSize: pointSize) } } // MARK: - Coordinator /// The delegate, the render cache, and the editor's session-scoped undo. @MainActor final class Coordinator: NSObject, NSTextViewDelegate { /// What the text view currently shows, as the inputs that produced it. struct RenderKey: Equatable { let body: String let mode: CardBodyMode let cardFolder: URL? let pointSize: CGFloat } weak var textView: NSTextView? var rendered: RenderKey? var session: CardBodyEditSession? var onToggleTask: ((Int, Bool) -> Void)? var isTaskToggleEnabled = true /// Which mode the view is currently *configured* for — `nil` until the first update, which is /// what makes the initial configuration a mode entry like any other (and is why a card that /// opens straight into Edit gets the caret without a special case). private(set) var mode: CardBodyMode? /// **The editor's own undo manager, and the whole of "session-scoped"** (05 ▸ Edit: "⌘Z here /// is the text view's own undo — session-scoped, ending when the editor loses focus or the /// mode flips"). /// /// Without this the text view would use the *window's* undo manager, whose stack outlives /// every mode flip and is shared with anything else in the window that registers an /// undoable action — so ⌘Z after leaving Edit could reach back into text the user had /// already committed. Owning one here makes the scoping structural: `removeAllActions()` at /// the two moments 05 names is then an emptying of a stack nothing else can see. private let editorUndoManager = UndoManager() /// Set while this coordinator is replacing the view's text, so the resulting change /// notification is not mistaken for typing. private var isSettingText = false // MARK: Mode /// Reconfigures the view for a mode — the only place editability, the undo stack and first /// responder change. func enter(_ newMode: CardBodyMode, in textView: CardBodyTextView) { mode = newMode // The session's undo stack ends with the mode, per 05. Emptied on the way *in* as well // as out, so an Edit session never opens on top of the previous one's actions. editorUndoManager.removeAllActions() switch newMode { case .preview: textView.isEditable = false case .edit: textView.isEditable = true textView.typingAttributes = MarkdownHighlighter.baseAttributes( pointSize: CardWindowMetrics.bodyPointSize ) // "**Empty body opens in Edit** with the cursor ready" (05 ▸ Mode grammar) — and the // same courtesy for a deliberate ⌘E, which is a request to type. Deferred a turn: // this runs inside a SwiftUI update, and making a view first responder re-enters // AppKit's responder machinery. Task { @MainActor [weak textView] in guard let textView, textView.isEditable else { return } textView.window?.makeFirstResponder(textView) } } } /// Puts `text` in the editor and highlights it — **never replacing what the user is looking /// at unless it actually differs**. /// /// The equality guard is load-bearing rather than an optimization: this runs on every /// keystroke (the buffer changed, so SwiftUI re-ran the update), and replacing the storage /// with the string it already holds would collapse the selection, scroll the view, and throw /// away the undo stack — on every character typed. func show(_ text: String, in textView: CardBodyTextView, pointSize: CGFloat) { guard let storage = textView.textStorage else { return } if storage.string != text { // A foreign edit arriving under a *clean* buffer, or the first fill of the editor. // The selection is preserved where it still fits; a caret past the new end clamps // rather than disappearing. let selected = textView.selectedRange() // The undo stack described text that no longer exists — an agent or a hand edit // replaced it — and ⌘Z restoring a run of it would be this app inventing a merge. editorUndoManager.removeAllActions() isSettingText = true storage.setAttributedString(NSAttributedString( string: text, attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize) )) isSettingText = false let length = (text as NSString).length textView.setSelectedRange(NSRange( location: min(selected.location, length), length: min(selected.length, max(0, length - min(selected.location, length))) )) } MarkdownHighlighter.highlight(storage, pointSize: pointSize) textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize) } // MARK: NSTextViewDelegate /// The editor's undo manager — see `editorUndoManager`. func undoManager(for view: NSTextView) -> UndoManager? { editorUndoManager } /// Every keystroke, straight into the buffer. The session decides what that costs: a /// restarted debounce, or a cancelled one when the change happened to restore the file's own /// text. func textDidChange(_ notification: Notification) { guard !isSettingText, mode == .edit, let textView = notification.object as? NSTextView else { return } session?.edited(textView.string) } /// Focus leaving the editor ends the undo session (05 ▸ Edit), and is *not* a save: the /// debounce is still running and will land on its own, which is what keeps clicking into the /// sidebar from being a commit point the design never named. func textDidEndEditing(_ notification: Notification) { editorUndoManager.removeAllActions() } /// The click grammar, in one method: a checkbox writes, anything else opens, and the return /// value is always `true` so the text view never falls back to its own link handling. func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { guard let url = Self.url(from: link) else { return false } if let task = CardBodyLink.parseTask(url) { // Disabled in place under the read-only lock: the click is swallowed rather than // attempted, because a write that would be refused should not post a banner the // standing lock row already explains. guard isTaskToggleEnabled else { return true } onToggleTask?(task.offset, task.isChecked) return true } // External URLs go to the browser and relative ones — already resolved to file URLs by // the renderer — go to their default app. `NSWorkspace.open` is both of those sentences // (05 ▸ Preview ▸ Links). NSWorkspace.shared.open(url) return true } private static func url(from link: Any) -> URL? { switch link { case let url as URL: url case let string as String: URL(string: string) default: nil } } } } // MARK: - CardBodyTextView /// The body surface's text view, subclassed for exactly two keys. /// /// Return in Preview and Escape in Edit are **fixed grammar, not menu items** (05-card-window.md ▸ /// Mode grammar: "Return in Preview also enters Edit — the board's edit key applied to the body; /// fixed grammar like the board's Return, not a menu item"), so they have to be intercepted where /// the keyboard actually is. Both are guarded by editability, which is the mode: a Return in Edit is /// a newline like any other, and an Escape in Preview means nothing here. final class CardBodyTextView: NSTextView { var onReturnInPreview: (() -> Void)? var onEscapeInEdit: (() -> Void)? /// Preview is not editable, so AppKit would send this nowhere — the mode's own key handling has /// to come before `super`, which for a read-only text view merely beeps. override func keyDown(with event: NSEvent) { let isPlainReturn = event.keyCode == 36 || event.keyCode == 76 let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.function, .numericPad, .capsLock]) if !isEditable, isPlainReturn, modifiers.isEmpty { onReturnInPreview?() return } super.keyDown(with: event) } /// Escape. Intercepted before `NSTextView`'s own meaning for it (text completion), and only /// while editing — with the find bar up the bar is first responder and never reaches this. override func cancelOperation(_ sender: Any?) { guard isEditable, let onEscapeInEdit else { super.cancelOperation(sender) return } onEscapeInEdit() } /// **A file drop is never the editor's** (05-card-window.md ▸ Attachments, settled: "file drops /// import as attachments anywhere in the window — Edit mode included, the text editor never /// intercepts a file drop; dragged *text* lands in the Edit editor at the caret"). /// /// An editable, rich `NSTextView` would otherwise happily take a dragged file and turn it into a /// path or an attachment cell inside the user's Markdown. Dropping the *file* types from what /// this view accepts lets that drag fall through to the window, which is where the attachment /// import belongs. Every text type — a plain-text drag, a URL dragged out of a browser — is left /// exactly as AppKit offers it, so the other half of the rule is the default behaviour rather /// than a re-implementation of it. /// // m6-card-attachments: the window-level drop surface that catches what this declines. override var acceptableDragTypes: [NSPasteboard.PasteboardType] { let fileTypes: Set = [ .fileURL, // The Carbon-era name AppKit still puts on a Finder drag alongside the modern one. NSPasteboard.PasteboardType("NSFilenamesPboardType") ] return super.acceptableDragTypes.filter { !fileTypes.contains($0) } } }