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
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user