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
207 lines
10 KiB
Swift
207 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - CardWindowView
|
|
|
|
/// The card window's content: **two full-height, independently scrolling columns** — a wide body
|
|
/// column leading, a narrow attributes sidebar trailing (05-card-window.md ▸ Composition).
|
|
///
|
|
/// ### What this milestone builds, and what it deliberately does not
|
|
///
|
|
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
|
/// renderings of what the loader already knows — the card's title, its created/modified line, and
|
|
/// its body as plain text. Everything that reads or writes beyond that is later work and is marked
|
|
/// where it lands:
|
|
///
|
|
/// - the title as an editable field (commit on Return / focus loss, Escape abandons),
|
|
/// - 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
|
|
/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
|
/// underneath without the composition moving.
|
|
///
|
|
/// ### The width rule, in one line
|
|
///
|
|
/// 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
|
|
/// together when the system text size changes.
|
|
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
bodyColumn
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
Divider()
|
|
|
|
sidebar
|
|
// Fixed, and the one place it comes from.
|
|
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.background(.background.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Body column
|
|
|
|
/// Title, the quiet created/modified line, then the body — 05's top-to-bottom order.
|
|
private var bodyColumn: some View {
|
|
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.
|
|
Text(card.title.value ?? "Untitled")
|
|
.font(.largeTitle)
|
|
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the
|
|
// same secondary treatment the face gives it.
|
|
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
|
.textSelection(.enabled)
|
|
|
|
if let dateLine {
|
|
Text(dateLine)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.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**
|
|
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
|
///
|
|
/// The "by" segment renders only with the self-reported provenance stamp present
|
|
/// (01-storage-format.md), which is the point of showing it at all: provenance made visible where
|
|
/// git history may not exist.
|
|
private var dateLine: String? {
|
|
var parts: [String] = []
|
|
if let created = card.created.value {
|
|
parts.append("Created \(Self.dateText(created))")
|
|
}
|
|
if let modified = card.modified.value {
|
|
parts.append("Modified \(Self.dateText(modified))")
|
|
}
|
|
if let by = card.modifiedBy.value, !by.isEmpty {
|
|
parts.append("by \(by)")
|
|
}
|
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
|
}
|
|
|
|
private static func dateText(_ date: Date) -> String {
|
|
date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
|
|
// MARK: - Attributes sidebar
|
|
|
|
/// The sidebar's sections, **in 05's settled order**, as headers over empty space.
|
|
///
|
|
/// Two of them are conditional once they have content — Details appears only when the card
|
|
/// carries unknown frontmatter keys, and History is absent on boards without app-managed git —
|
|
/// and the shell shows them unconditionally because it has neither the key inventory nor a git
|
|
/// mode to consult yet. That is the one place these placeholders are not yet the final
|
|
/// composition, and it resolves when the sections do.
|
|
private var sidebar: some View {
|
|
ScrollView(.vertical) {
|
|
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
|
|
// m6-card-attachments: every top-level file of `attachments/`, compact rows with a
|
|
// QuickLook thumbnail, keyboard-navigable.
|
|
section("Attachments")
|
|
// m6-card-sidebar: the embedded style editor — the same component the board popover
|
|
// and Style… already host (`StyleEditor`).
|
|
section("Style")
|
|
// m6-card-sidebar: read-only key/value rows for every unknown frontmatter key, in
|
|
// file order.
|
|
section("Details")
|
|
// m7-git: the card's commit trail, read-only; absent on mode none / repo-nested.
|
|
section("History")
|
|
// m6-card-sidebar: Delete (tombstones, the window then dismisses itself) and Reveal
|
|
// in Finder.
|
|
section("Actions")
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
}
|
|
}
|
|
|
|
/// A stacked small-caps header over the space its section will occupy (05: "Stacked sections
|
|
/// under small-caps headers").
|
|
private func section(_ title: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.textCase(.uppercase)
|
|
.foregroundStyle(.secondary)
|
|
Divider()
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|