Files
lanework/Kanban/UI/Card/CardBodySurface.swift
T
rzen 6dc84176fb Build Preview mode rendering
The card body's resting state: swift-markdown (pinned 0.8.0, smart
typography off — Preview renders the bytes on disk) parsed into a pure
BodyMarkup model with UTF-8 source offsets, rendered on one hosted
TextKit 1 NSTextView — chosen because find-in-text is NSTextFinder,
checkbox clicks reuse AppKit character hit-testing, links are .link
attributes, and NSTextTable's automatic layout is exactly the
columns-sized-to-contents rule. The GFM subset renders per 05; HTML
stays verbatim code-styled text; relative images resolve against the
card folder while remote URLs are never fetched, drawing a quiet chip
instead. Task checkboxes are live: a click flips exactly one byte
through a fresh-read, refuse-uneditable, stamp, atomic-replace write —
the app's only offset-addressed write, so a moved target refuses as
staleTarget and what the user saw decides the direction, netting one
toggle on a double-click. Empty bodies open in Edit per CardBodyMode's
opening rule, applied once; the Edit surface itself stays an honest
read-only stub until its card. FindCommand prefers the card body's
find over board search when a card window is focused.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 09:59:18 -04:00

197 lines
9.8 KiB
Swift

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