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:
2026-07-28 09:59:18 -04:00
parent 7f1adf47c5
commit 6dc84176fb
15 changed files with 2608 additions and 18 deletions
+61 -14
View File
@@ -13,7 +13,7 @@ import SwiftUI
/// where it lands:
///
/// - the title as an editable field (commit on Return / focus loss, Escape abandons),
/// - the body's Preview/Edit pairing and the raw-source outlet,
/// - the raw-source outlet,
/// - the sidebar's five sections, which are section *headers* here and nothing more.
///
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
@@ -25,9 +25,28 @@ import SwiftUI
/// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That
/// is the whole of "the window's resize flex goes to the body" no split view, no stored divider
/// position, nothing for a drag to disagree with.
///
/// ### Why the title no longer scrolls with the body
///
/// The body surface is a hosted `NSScrollView` (`CardBodySurface`), because F's find bar lives in
/// one "Edit Find (F) is find-in-text here the standard find bar" (05 Preview). A scroll
/// view inside a scroll view is a scroll view that fights, so the column's header the title and
/// its created/modified line sits above the body's scroller rather than inside it. 05 fixes the
/// column's *order* ("Body column, top to bottom") and the columns' independent scrolling, and both
/// still hold; which of the two things scrolls the title away was never settled, and pinning the
/// card's name over its own body is the better reading of a window whose subtitle already follows it.
struct CardWindowView: View {
let card: Card
/// The card's folder on disk what relative images and links in the body resolve against
/// (05 Preview). `nil` only where a caller has no board root to build it from.
let cardFolder: URL?
/// This window's body-column state: which mode it is in, and the find-bar hook.
let bodyPresentation: CardBodyPresentation
/// Whether a checkbox may write `false` under the board's read-only lock.
let isEditable: Bool
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
let onToggleTask: (Int, Bool) -> Void
/// The body font's point size, read once per body evaluation: every measurement in this view
/// the sidebar's width, both gutters, the vertical rhythm is derived from it, so they scale
@@ -53,8 +72,8 @@ struct CardWindowView: View {
/// Title, the quiet created/modified line, then the body 05's top-to-bottom order.
private var bodyColumn: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: bodyPointSize * 0.75) {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
// m6-card-body: the title *field* large and borderless, committing to frontmatter
// on Return or focus loss, clearing to remove the `title` key, Escape abandoning to
// the on-disk title. Read-only here; the placeholder rendering is already final.
@@ -71,20 +90,48 @@ struct CardWindowView: View {
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
// m6-card-body: Preview/Edit proper a rendered preview with live task-list
// checkboxes, and a syntax-highlighted raw editor behind E. Plain selectable text
// until then: honest about being unrendered rather than half-rendering Markdown.
if !card.body.isEmpty {
Text(card.body)
.font(.body)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
if bodyPresentation.mode == .edit {
editPlaceholderNotice
}
CardBodySurface(
body: card.body,
mode: bodyPresentation.mode,
cardFolder: cardFolder,
presentation: bodyPresentation,
isTaskToggleEnabled: isEditable,
onToggleTask: onToggleTask
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// **The opening rule, applied once** (05 Mode grammar): a card opens in Preview unless its
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
// "once" a later reload that empties the file must not drag a reader into Edit.
.task { bodyPresentation.openIfNeeded(body: card.body) }
}
/// The Edit mode's honest placeholder.
///
/// **The mode is real; the editor is not.** 05's opening rule is not a rendering detail that can
/// wait it decides which surface a brand-new card lands on so this milestone implements the
/// *state* (`CardBodyMode`, the opening rule, the toggle) and leaves the editor itself to the
/// Edit card. What shows meanwhile is the raw Markdown, monospaced and read-only, over a line
/// that says so: a text view that looked editable and silently discarded keystrokes would be a
/// worse lie than an empty pane, and one that saved would be this milestone building the thing
/// it deliberately is not building.
private var editPlaceholderNotice: some View {
Text("Body editing arrives with the Edit surface — this is the raw Markdown, read-only.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
.padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: bodyPointSize))
.background(.background.secondary)
}
/// "Created date · Modified date · by modified-by", **omitting whichever keys are absent**