import AppKit import SwiftUI // MARK: - CardBodySurface /// The body column's text surface: **one hosted `NSTextView` serving both modes** — the rendered /// Preview and, until the Edit card lands, the read-only raw-Markdown placeholder. /// /// ### 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 the Edit placeholder differ only in the attributed string they are handed /// (`BodyMarkupRenderer.attributedString` vs `.rawText`). That is deliberate: it means ⌘F, text /// selection and copying behave identically on both surfaces without either one implementing them, /// and it leaves the Edit card a seam whose shape is already known — make this view editable, give /// it a debounced save, and swap `.rawText` for a highlighting pass. struct CardBodySurface: NSViewRepresentable { /// The card's body, verbatim — the source both renderings are made from. 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 /// 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 = NSTextView(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. textView.isAutomaticLinkDetectionEnabled = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false // 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) 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.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. let presentation = presentation 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 guard let textView = scrollView.documentView as? NSTextView else { return } let pointSize = CardWindowMetrics.bodyPointSize 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 let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder) let content: NSAttributedString = switch mode { case .preview: BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context) case .edit: BodyMarkupRenderer.rawText(body, context: context) } textView.textStorage?.setAttributedString(content) } // MARK: - Coordinator /// The delegate, and the render cache. @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 onToggleTask: ((Int, Bool) -> Void)? var isTaskToggleEnabled = true /// 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 } } } }