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:
@@ -83,13 +83,25 @@ struct FindCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
@FocusedValue(\.cardBody) private var cardBody
|
||||
|
||||
/// **The card window wins when it is the focused scene**, which is the whole of 11's split
|
||||
/// ("Board window: board search; card window: find-in-text"): the two never both publish, so
|
||||
/// this reads as a preference only because a scene value is `nil` in the scene that does not
|
||||
/// have one. The card side is the standard find bar over its body surface
|
||||
/// (`CardBodyPresentation.findInText`); the board side focuses the search field.
|
||||
private var findInText: (() -> Void)? { cardBody?.findInText }
|
||||
|
||||
var body: some View {
|
||||
Button("Find") {
|
||||
search?.focusField?()
|
||||
if let findInText {
|
||||
findInText()
|
||||
} else {
|
||||
search?.focusField?()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
.disabled(store == nil || search?.focusField == nil)
|
||||
.disabled(findInText == nil && (store == nil || search?.focusField == nil))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - The clickable-run codec
|
||||
|
||||
/// The two kinds of thing a click in Preview can land on, encoded as URLs so that **AppKit's own
|
||||
/// link hit-testing** is the whole of the click grammar (05-card-window.md ▸ Preview: "clicking the
|
||||
/// rendered body selects text … and nothing else. The one interactive exception is task-list
|
||||
/// checkboxes").
|
||||
///
|
||||
/// Encoding the checkbox as a link rather than tracking mouse events by hand is the point: a text
|
||||
/// view already knows which character was clicked, already tells its delegate, already declines to
|
||||
/// fire when the user was dragging out a selection, and already leaves every other character
|
||||
/// selectable. Re-implementing that from `mouseDown` would be re-implementing text selection.
|
||||
enum CardBodyLink {
|
||||
|
||||
/// A scheme nothing on the system claims, so a checkbox can never be handed to `NSWorkspace`
|
||||
/// by a path that forgot to check.
|
||||
static let taskScheme = "x-lanework-task"
|
||||
|
||||
/// `x-lanework-task:<byte offset>/<0 or 1>` — the offset the flip will aim at, and the state
|
||||
/// the user is looking at, which is what `BoardWriter.toggleTaskMarker` re-verifies against
|
||||
/// disk before it writes.
|
||||
static func task(offset: Int, isChecked: Bool) -> URL? {
|
||||
URL(string: "\(taskScheme):\(offset)/\(isChecked ? 1 : 0)")
|
||||
}
|
||||
|
||||
/// The inverse. `nil` for every URL that is not one of ours — which is every ordinary link in
|
||||
/// a card body, and is how the delegate tells the two apart.
|
||||
static func parseTask(_ url: URL) -> (offset: Int, isChecked: Bool)? {
|
||||
guard url.scheme == taskScheme else { return nil }
|
||||
let parts = url.absoluteString.dropFirst(taskScheme.count + 1).split(separator: "/")
|
||||
guard parts.count == 2, let offset = Int(parts[0]), let state = Int(parts[1]) else { return nil }
|
||||
return (offset, state == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BodyMarkupRenderer
|
||||
|
||||
/// `BodyMarkup` → `NSAttributedString`: the drawing half of Preview, and **only** the drawing half.
|
||||
///
|
||||
/// Every decision that is not typography was already made in `BodyMarkup` — what is HTML, what is a
|
||||
/// remote image, where a checkbox's byte lives — which is what keeps this file free of policy and
|
||||
/// makes it the one part of Preview that is legitimately unverifiable by a unit test. What it does
|
||||
/// own is the app's type scale: body text in the system body font (the same `CardWindowMetrics`
|
||||
/// point size the whole window derives from), code in a monospaced face, and every indent, padding
|
||||
/// and image cap a multiple of that one size, so Preview grows with the system text size like the
|
||||
/// rest of the window (10-accessibility.md ▸ Text).
|
||||
@MainActor
|
||||
enum BodyMarkupRenderer {
|
||||
|
||||
/// What a render needs beyond the markup itself: how big the body font is, and where the card's
|
||||
/// own folder is — the anchor every relative image and link resolves against (05 ▸ Preview).
|
||||
struct Context {
|
||||
var pointSize: CGFloat
|
||||
var cardFolder: URL?
|
||||
|
||||
init(pointSize: CGFloat, cardFolder: URL?) {
|
||||
self.pointSize = pointSize
|
||||
self.cardFolder = cardFolder
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Entry point
|
||||
|
||||
static func attributedString(for markup: BodyMarkup, context: Context) -> NSAttributedString {
|
||||
let output = NSMutableAttributedString()
|
||||
var frame = Frame(context: context)
|
||||
append(markup.blocks, into: output, frame: &frame)
|
||||
return output
|
||||
}
|
||||
|
||||
/// The raw Markdown as the **Edit placeholder** shows it: monospaced, unhighlighted, and
|
||||
/// character for character what is on disk.
|
||||
///
|
||||
/// Here rather than in the placeholder view because the two surfaces share one substrate and
|
||||
/// therefore one input type — an attributed string — and because "the text is the raw Markdown,
|
||||
/// character for character — no hidden transforms, no smart substitutions" (05 ▸ Edit) is a
|
||||
/// promise about *this* function: it sets attributes and never touches a character.
|
||||
static func rawText(_ body: String, context: Context) -> NSAttributedString {
|
||||
NSAttributedString(string: body, attributes: [
|
||||
.font: monospacedFont(context.pointSize),
|
||||
.foregroundColor: NSColor.labelColor
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: Block layout state
|
||||
|
||||
/// Where in the block structure the walk currently is: which text blocks enclose it (quotes and
|
||||
/// table cells nest by *appending* to this), how far it is indented, and whether a list marker
|
||||
/// is owed to the next paragraph.
|
||||
private struct Frame {
|
||||
let context: Context
|
||||
var enclosing: [NSTextBlock] = []
|
||||
var indent: CGFloat = 0
|
||||
/// The bullet, number or checkbox that belongs on the first line of the next block — set
|
||||
/// when a list item starts and consumed by whichever block opens it.
|
||||
var pendingMarker: NSAttributedString?
|
||||
}
|
||||
|
||||
private static func append(_ blocks: [BodyBlock], into output: NSMutableAttributedString, frame: inout Frame) {
|
||||
for block in blocks {
|
||||
append(block, into: output, frame: &frame)
|
||||
}
|
||||
}
|
||||
|
||||
private static func append(_ block: BodyBlock, into output: NSMutableAttributedString, frame: inout Frame) {
|
||||
let size = frame.context.pointSize
|
||||
switch block {
|
||||
case let .heading(level, inlines, _):
|
||||
let style = paragraphStyle(frame: frame)
|
||||
style.paragraphSpacingBefore = size * (level <= 2 ? 1.0 : 0.75)
|
||||
style.paragraphSpacing = size * 0.25
|
||||
appendParagraph(
|
||||
inlines: inlines,
|
||||
base: [.font: headingFont(level: level, size: size), .foregroundColor: NSColor.labelColor],
|
||||
style: style,
|
||||
into: output,
|
||||
frame: &frame
|
||||
)
|
||||
|
||||
case let .paragraph(inlines, _):
|
||||
let style = paragraphStyle(frame: frame)
|
||||
style.paragraphSpacing = size * 0.5
|
||||
appendParagraph(inlines: inlines, base: bodyAttributes(size), style: style, into: output, frame: &frame)
|
||||
|
||||
case let .code(code, _, _):
|
||||
let style = paragraphStyle(frame: frame)
|
||||
style.paragraphSpacing = size * 0.5
|
||||
style.firstLineHeadIndent += CardWindowMetrics.previewPadding(bodyPointSize: size)
|
||||
style.headIndent += CardWindowMetrics.previewPadding(bodyPointSize: size)
|
||||
appendLiteral(
|
||||
trimmingTrailingNewlines(code),
|
||||
attributes: [
|
||||
.font: monospacedFont(size),
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.backgroundColor: NSColor.quaternarySystemFill
|
||||
],
|
||||
style: style,
|
||||
into: output,
|
||||
frame: &frame
|
||||
)
|
||||
|
||||
case let .html(raw, _):
|
||||
// Verbatim, and styled as what it is: text the author typed, not a document the app
|
||||
// interpreted (05 ▸ Preview; 00-vision.md's no-web-tech stance).
|
||||
let style = paragraphStyle(frame: frame)
|
||||
style.paragraphSpacing = size * 0.5
|
||||
appendLiteral(
|
||||
trimmingTrailingNewlines(raw),
|
||||
attributes: [
|
||||
.font: monospacedFont(size),
|
||||
.foregroundColor: NSColor.secondaryLabelColor,
|
||||
.backgroundColor: NSColor.quaternarySystemFill
|
||||
],
|
||||
style: style,
|
||||
into: output,
|
||||
frame: &frame
|
||||
)
|
||||
|
||||
case .thematicBreak:
|
||||
let rule = NSTextBlock()
|
||||
rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .minY)
|
||||
rule.setBorderColor(.separatorColor)
|
||||
let style = paragraphStyle(frame: frame)
|
||||
style.textBlocks = frame.enclosing + [rule]
|
||||
style.paragraphSpacingBefore = size * 0.5
|
||||
style.paragraphSpacing = size * 0.5
|
||||
// A near-empty line carrying the rule: the border is the mark, the character is only
|
||||
// something for the layout manager to hang it on.
|
||||
output.append(NSAttributedString(string: "\u{00A0}\n", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 1),
|
||||
.paragraphStyle: style
|
||||
]))
|
||||
|
||||
case let .quote(blocks, _):
|
||||
let bar = NSTextBlock()
|
||||
bar.setWidth(size * 0.2, type: .absoluteValueType, for: .border, edge: .minX)
|
||||
bar.setBorderColor(.tertiaryLabelColor)
|
||||
bar.setWidth(CardWindowMetrics.previewPadding(bodyPointSize: size), type: .absoluteValueType, for: .padding, edge: .minX)
|
||||
|
||||
var inner = frame
|
||||
inner.enclosing.append(bar)
|
||||
append(blocks, into: output, frame: &inner)
|
||||
frame.pendingMarker = inner.pendingMarker
|
||||
|
||||
case let .list(list, _):
|
||||
append(list, into: output, frame: &frame)
|
||||
|
||||
case let .table(table, _):
|
||||
append(table, into: output, frame: &frame)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Lists
|
||||
|
||||
private static func append(_ list: BodyList, into output: NSMutableAttributedString, frame: inout Frame) {
|
||||
let size = frame.context.pointSize
|
||||
for (offset, item) in list.items.enumerated() {
|
||||
var inner = frame
|
||||
inner.indent += CardWindowMetrics.previewIndent(bodyPointSize: size)
|
||||
inner.pendingMarker = marker(for: item, list: list, number: list.start + offset, size: size)
|
||||
|
||||
if item.blocks.isEmpty {
|
||||
// An empty item still has a marker to show — a bare `- [ ]` is a checkbox the user
|
||||
// has not written a label for yet, and swallowing it would lose a live control.
|
||||
let style = paragraphStyle(frame: inner)
|
||||
appendParagraph(inlines: [], base: bodyAttributes(size), style: style, into: output, frame: &inner)
|
||||
} else {
|
||||
append(item.blocks, into: output, frame: &inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A list item's leading run: a checkbox when the item is a task, otherwise a bullet or its
|
||||
/// number.
|
||||
///
|
||||
/// The checkbox carries a `.link` attribute — see `CardBodyLink` — which is what makes it the
|
||||
/// one clickable thing in an otherwise read-only surface. When the parse could not locate its
|
||||
/// source byte the box still renders, without the link: inert rather than absent, because the
|
||||
/// box is content the user wrote.
|
||||
private static func marker(
|
||||
for item: BodyListItem,
|
||||
list: BodyList,
|
||||
number: Int,
|
||||
size: CGFloat
|
||||
) -> NSAttributedString {
|
||||
guard let task = item.task else {
|
||||
let text = list.isOrdered ? "\(number)." : "•"
|
||||
return NSAttributedString(string: text + "\t", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: size),
|
||||
.foregroundColor: NSColor.secondaryLabelColor
|
||||
])
|
||||
}
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.systemFont(ofSize: size * 1.1),
|
||||
.foregroundColor: task.isChecked ? NSColor.controlAccentColor : NSColor.secondaryLabelColor
|
||||
]
|
||||
if let offset = task.markerOffset,
|
||||
let url = CardBodyLink.task(offset: offset, isChecked: task.isChecked) {
|
||||
attributes[.link] = url
|
||||
attributes[.cursor] = NSCursor.pointingHand
|
||||
}
|
||||
|
||||
let box = NSMutableAttributedString(string: task.isChecked ? "\u{2611}" : "\u{2610}", attributes: attributes)
|
||||
box.append(NSAttributedString(string: "\t", attributes: [.font: NSFont.systemFont(ofSize: size)]))
|
||||
return box
|
||||
}
|
||||
|
||||
// MARK: Tables
|
||||
|
||||
/// A GFM table as an `NSTextTable`, whose automatic layout algorithm **is** the "columns sized
|
||||
/// to contents with the browser sizing rule" the design asks for — the same rule, implemented
|
||||
/// once by AppKit rather than approximated here with measured tab stops.
|
||||
///
|
||||
/// This is also the reason the whole surface runs on TextKit 1 (`CardBodySurfaceView`):
|
||||
/// `NSTextTable` is a TextKit 1 construct, and a table drawn with tab stops would lose both
|
||||
/// its rules and its wrapping.
|
||||
private static func append(_ table: BodyTable, into output: NSMutableAttributedString, frame: inout Frame) {
|
||||
let columns = max(1, table.columnCount)
|
||||
|
||||
let textTable = NSTextTable()
|
||||
textTable.numberOfColumns = columns
|
||||
textTable.layoutAlgorithm = .automaticLayoutAlgorithm
|
||||
textTable.collapsesBorders = true
|
||||
textTable.hidesEmptyCells = false
|
||||
|
||||
var row = 0
|
||||
appendRow(table.header, isHeader: true, row: &row, of: textTable, table: table, into: output, frame: &frame)
|
||||
for cells in table.rows {
|
||||
appendRow(cells, isHeader: false, row: &row, of: textTable, table: table, into: output, frame: &frame)
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendRow(
|
||||
_ cells: [BodyTableCell],
|
||||
isHeader: Bool,
|
||||
row: inout Int,
|
||||
of textTable: NSTextTable,
|
||||
table: BodyTable,
|
||||
into output: NSMutableAttributedString,
|
||||
frame: inout Frame
|
||||
) {
|
||||
guard !cells.isEmpty else { return }
|
||||
let size = frame.context.pointSize
|
||||
let padding = CardWindowMetrics.previewPadding(bodyPointSize: size)
|
||||
|
||||
var column = 0
|
||||
for cell in cells where column < max(1, table.columnCount) {
|
||||
let span = max(1, min(cell.colspan, max(1, table.columnCount) - column))
|
||||
let block = NSTextTableBlock(
|
||||
table: textTable,
|
||||
startingRow: row,
|
||||
rowSpan: 1,
|
||||
startingColumn: column,
|
||||
columnSpan: span
|
||||
)
|
||||
block.setBorderColor(.separatorColor)
|
||||
block.setWidth(1, type: .absoluteValueType, for: .border)
|
||||
block.setWidth(padding, type: .absoluteValueType, for: .padding)
|
||||
if isHeader { block.backgroundColor = .quaternarySystemFill }
|
||||
|
||||
let style = paragraphStyle(frame: frame)
|
||||
style.textBlocks = frame.enclosing + [block]
|
||||
style.alignment = alignment(table.alignments.indices.contains(column) ? table.alignments[column] : .unspecified)
|
||||
// Cells never inherit the surrounding indent: the table block already places them.
|
||||
style.firstLineHeadIndent = 0
|
||||
style.headIndent = 0
|
||||
|
||||
var base = bodyAttributes(size)
|
||||
if isHeader { base[.font] = NSFont.systemFont(ofSize: size, weight: .semibold) }
|
||||
|
||||
var cellFrame = frame
|
||||
cellFrame.pendingMarker = nil
|
||||
appendParagraph(inlines: cell.inlines, base: base, style: style, into: output, frame: &cellFrame)
|
||||
|
||||
column += span
|
||||
}
|
||||
row += 1
|
||||
}
|
||||
|
||||
private static func alignment(_ alignment: BodyTable.Alignment) -> NSTextAlignment {
|
||||
switch alignment {
|
||||
case .unspecified, .leading: .natural
|
||||
case .center: .center
|
||||
case .trailing: .right
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Paragraph assembly
|
||||
|
||||
private static func appendParagraph(
|
||||
inlines: [BodyInline],
|
||||
base: [NSAttributedString.Key: Any],
|
||||
style: NSMutableParagraphStyle,
|
||||
into output: NSMutableAttributedString,
|
||||
frame: inout Frame
|
||||
) {
|
||||
let paragraph = NSMutableAttributedString()
|
||||
if let marker = frame.pendingMarker {
|
||||
paragraph.append(marker)
|
||||
frame.pendingMarker = nil
|
||||
}
|
||||
append(inlines, into: paragraph, base: base, frame: frame)
|
||||
paragraph.append(NSAttributedString(string: "\n", attributes: base))
|
||||
paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length))
|
||||
output.append(paragraph)
|
||||
}
|
||||
|
||||
/// A literal block — code, HTML — where the text's own newlines are content and no inline
|
||||
/// parsing ever ran over it.
|
||||
private static func appendLiteral(
|
||||
_ text: String,
|
||||
attributes: [NSAttributedString.Key: Any],
|
||||
style: NSMutableParagraphStyle,
|
||||
into output: NSMutableAttributedString,
|
||||
frame: inout Frame
|
||||
) {
|
||||
let paragraph = NSMutableAttributedString()
|
||||
if let marker = frame.pendingMarker {
|
||||
paragraph.append(marker)
|
||||
frame.pendingMarker = nil
|
||||
}
|
||||
paragraph.append(NSAttributedString(string: text + "\n", attributes: attributes))
|
||||
paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length))
|
||||
output.append(paragraph)
|
||||
}
|
||||
|
||||
private static func paragraphStyle(frame: Frame) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.textBlocks = frame.enclosing
|
||||
style.firstLineHeadIndent = frame.indent
|
||||
style.headIndent = frame.indent
|
||||
style.lineSpacing = frame.context.pointSize * 0.15
|
||||
// One tab stop, where a list item's text begins — which is what turns "marker, tab, text"
|
||||
// into a hanging indent rather than a ragged one.
|
||||
style.tabStops = [NSTextTab(textAlignment: .left, location: frame.indent, options: [:])]
|
||||
style.defaultTabInterval = CardWindowMetrics.previewIndent(bodyPointSize: frame.context.pointSize)
|
||||
return style
|
||||
}
|
||||
|
||||
// MARK: Inlines
|
||||
|
||||
/// The inline traits carried down the recursion — bold and italic compose, so they are state
|
||||
/// rather than a font chosen at each node.
|
||||
private struct InlineTraits {
|
||||
var isBold = false
|
||||
var isItalic = false
|
||||
var isStruck = false
|
||||
}
|
||||
|
||||
private static func append(
|
||||
_ inlines: [BodyInline],
|
||||
into output: NSMutableAttributedString,
|
||||
base: [NSAttributedString.Key: Any],
|
||||
frame: Frame,
|
||||
traits: InlineTraits = InlineTraits()
|
||||
) {
|
||||
for inline in inlines {
|
||||
append(inline, into: output, base: base, frame: frame, traits: traits)
|
||||
}
|
||||
}
|
||||
|
||||
private static func append(
|
||||
_ inline: BodyInline,
|
||||
into output: NSMutableAttributedString,
|
||||
base: [NSAttributedString.Key: Any],
|
||||
frame: Frame,
|
||||
traits: InlineTraits
|
||||
) {
|
||||
let size = frame.context.pointSize
|
||||
switch inline {
|
||||
case let .text(text):
|
||||
output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits)))
|
||||
|
||||
case let .emphasis(children):
|
||||
var inner = traits
|
||||
inner.isItalic = true
|
||||
append(children, into: output, base: base, frame: frame, traits: inner)
|
||||
|
||||
case let .strong(children):
|
||||
var inner = traits
|
||||
inner.isBold = true
|
||||
append(children, into: output, base: base, frame: frame, traits: inner)
|
||||
|
||||
case let .strikethrough(children):
|
||||
var inner = traits
|
||||
inner.isStruck = true
|
||||
append(children, into: output, base: base, frame: frame, traits: inner)
|
||||
|
||||
case let .code(code):
|
||||
var attributes = attributes(base, traits: traits)
|
||||
attributes[.font] = monospacedFont(size)
|
||||
attributes[.backgroundColor] = NSColor.quaternarySystemFill
|
||||
output.append(NSAttributedString(string: code, attributes: attributes))
|
||||
|
||||
case let .html(raw):
|
||||
// Same rule as the block form, one line down: `<br>` is four characters and a `>`.
|
||||
var attributes = attributes(base, traits: traits)
|
||||
attributes[.font] = monospacedFont(size)
|
||||
attributes[.foregroundColor] = NSColor.secondaryLabelColor
|
||||
attributes[.backgroundColor] = NSColor.quaternarySystemFill
|
||||
output.append(NSAttributedString(string: raw, attributes: attributes))
|
||||
|
||||
case .lineBreak, .softBreak:
|
||||
// A soft break is a newline in the source and a space to the reader; a hard break is a
|
||||
// line of its own. `NSAttributedString` has no soft-wrap character, so the difference
|
||||
// is exactly this.
|
||||
let text = if case .lineBreak = inline { "\u{2028}" } else { " " }
|
||||
output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits)))
|
||||
|
||||
case let .link(target, children):
|
||||
var attributes = attributes(base, traits: traits)
|
||||
if let url = target.resolve(inCardFolder: frame.context.cardFolder) {
|
||||
attributes[.link] = url
|
||||
attributes[.foregroundColor] = NSColor.linkColor
|
||||
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
||||
}
|
||||
let start = output.length
|
||||
append(children, into: output, base: base, frame: frame, traits: traits)
|
||||
if output.length == start {
|
||||
output.append(NSAttributedString(string: target.text, attributes: attributes))
|
||||
} else {
|
||||
output.addAttributes(attributes, range: NSRange(location: start, length: output.length - start))
|
||||
}
|
||||
|
||||
case let .image(image):
|
||||
output.append(rendered(image, frame: frame, base: base, traits: traits))
|
||||
}
|
||||
}
|
||||
|
||||
/// An image: **loaded from the card's folder, or a chip that says what was not loaded.**
|
||||
///
|
||||
/// The fork is `BodyTarget`'s, made in the model; what happens here is only the consequence.
|
||||
/// Nothing in this function opens a socket — a `.absolute` target is never handed to a loader
|
||||
/// at all, which is how "Preview does no networking" is enforced rather than merely intended
|
||||
/// (05 ▸ Preview).
|
||||
private static func rendered(
|
||||
_ image: BodyImage,
|
||||
frame: Frame,
|
||||
base: [NSAttributedString.Key: Any],
|
||||
traits: InlineTraits
|
||||
) -> NSAttributedString {
|
||||
let size = frame.context.pointSize
|
||||
if case .relative = image.target,
|
||||
let url = image.target.resolve(inCardFolder: frame.context.cardFolder),
|
||||
let loaded = NSImage(contentsOf: url) {
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = loaded
|
||||
let cap = CardWindowMetrics.previewImageMaximumWidth(bodyPointSize: size)
|
||||
let scale = loaded.size.width > cap && loaded.size.width > 0 ? cap / loaded.size.width : 1
|
||||
attachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: (loaded.size.width * scale).rounded(),
|
||||
height: (loaded.size.height * scale).rounded()
|
||||
)
|
||||
let string = NSMutableAttributedString(attachment: attachment)
|
||||
// The alt text as the tool tip: it is the one place an inline attachment can say what
|
||||
// it is, and it is also what the text view reads out when nothing else describes it.
|
||||
string.addAttribute(
|
||||
.toolTip,
|
||||
value: image.alt.isEmpty ? image.target.text : image.alt,
|
||||
range: NSRange(location: 0, length: string.length)
|
||||
)
|
||||
return string
|
||||
}
|
||||
|
||||
// The quiet placeholder chip: alt text where there is any, the URL where there is not, so
|
||||
// the reader always learns *what* is not being shown.
|
||||
let label = image.alt.isEmpty ? image.target.text : image.alt
|
||||
var attributes = attributes(base, traits: traits)
|
||||
attributes[.font] = NSFont.systemFont(ofSize: size * 0.9)
|
||||
attributes[.foregroundColor] = NSColor.secondaryLabelColor
|
||||
attributes[.backgroundColor] = NSColor.quaternarySystemFill
|
||||
return NSAttributedString(string: " \u{1F5BC}\u{FE0E} \(label) ", attributes: attributes)
|
||||
}
|
||||
|
||||
// MARK: Fonts
|
||||
|
||||
private static func attributes(
|
||||
_ base: [NSAttributedString.Key: Any],
|
||||
traits: InlineTraits
|
||||
) -> [NSAttributedString.Key: Any] {
|
||||
var attributes = base
|
||||
if let font = base[.font] as? NSFont {
|
||||
attributes[.font] = styled(font, traits: traits)
|
||||
}
|
||||
if traits.isStruck {
|
||||
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
private static func styled(_ font: NSFont, traits: InlineTraits) -> NSFont {
|
||||
var mask: NSFontTraitMask = []
|
||||
if traits.isBold { mask.insert(.boldFontMask) }
|
||||
if traits.isItalic { mask.insert(.italicFontMask) }
|
||||
guard !mask.isEmpty else { return font }
|
||||
return NSFontManager.shared.convert(font, toHaveTrait: mask)
|
||||
}
|
||||
|
||||
private static func bodyAttributes(_ size: CGFloat) -> [NSAttributedString.Key: Any] {
|
||||
[.font: NSFont.systemFont(ofSize: size), .foregroundColor: NSColor.labelColor]
|
||||
}
|
||||
|
||||
/// The heading ladder, in multiples of the body size so it scales with everything else. Levels
|
||||
/// past four stop growing and stay merely emphasized, which is what they are.
|
||||
private static func headingFont(level: Int, size: CGFloat) -> NSFont {
|
||||
let scale: CGFloat = switch level {
|
||||
case 1: 1.8
|
||||
case 2: 1.5
|
||||
case 3: 1.25
|
||||
case 4: 1.1
|
||||
default: 1.0
|
||||
}
|
||||
return NSFont.systemFont(ofSize: (size * scale).rounded(), weight: level <= 2 ? .bold : .semibold)
|
||||
}
|
||||
|
||||
private static func monospacedFont(_ size: CGFloat) -> NSFont {
|
||||
NSFont.monospacedSystemFont(ofSize: (size * 0.95).rounded(), weight: .regular)
|
||||
}
|
||||
|
||||
private static func trimmingTrailingNewlines(_ text: String) -> String {
|
||||
var text = text
|
||||
while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() }
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The mode
|
||||
|
||||
/// Which of the body column's two surfaces is showing (05-card-window.md ▸ Mode grammar).
|
||||
///
|
||||
/// **Two cases, not three.** The raw-source outlet swaps the *entire content area* — title, body
|
||||
/// and sidebar — so it is a state of the window, not of the body column, and it does not belong in
|
||||
/// this enum. Edit Body disabling while raw source is active (11-command-nexus.md) is that
|
||||
/// window-level state's rule to enforce over this one.
|
||||
public enum CardBodyMode: Equatable, Sendable {
|
||||
/// The rendered, selectable preview — **the resting state**.
|
||||
case preview
|
||||
/// The raw-Markdown editor.
|
||||
case edit
|
||||
|
||||
/// The mode a window opens its body in: **Preview, unless the body is empty** (05 ▸ Mode
|
||||
/// grammar: "a card opens in Preview — unless its body is empty, which opens straight into
|
||||
/// Edit with the cursor ready (a new card has nothing to preview, so ⌘↩ during creation flows
|
||||
/// title → body without a mode stop)").
|
||||
///
|
||||
/// Whitespace is empty (`BodyMarkup.isEmpty`): a body holding one newline previews as a blank
|
||||
/// page, and stopping the user at a blank preview of a blank body is precisely the ceremony
|
||||
/// the rule removes.
|
||||
public static func opening(body: String) -> CardBodyMode {
|
||||
BodyMarkup.isEmpty(body) ? .edit : .preview
|
||||
}
|
||||
|
||||
/// ⌘E — View ▸ Edit Body's checkmark toggle. Also the whole of "Return in Preview enters Edit"
|
||||
/// and "Escape in Edit returns to Preview": three gestures, one flip, so they cannot drift.
|
||||
public var toggled: CardBodyMode {
|
||||
self == .preview ? .edit : .preview
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The window's body surface, as a handle
|
||||
|
||||
/// One card window's body column, reduced to what things *outside* it need: which mode it is in,
|
||||
/// and how to put a find bar over whichever surface currently holds the keyboard.
|
||||
///
|
||||
/// `BoardSearchPresentation`'s shape and for its reason — one per window, `@State` in the host,
|
||||
/// published through the focus system so a **menu item** (Edit ▸ Find, ⌘F) can reach the frontmost
|
||||
/// card window without anyone keeping a which-window-is-key register. It is deliberately not on
|
||||
/// `BoardStore`: the store is the *board's*, shared by every window on it, and two card windows
|
||||
/// open on two cards of one board are in two different modes.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CardBodyPresentation {
|
||||
|
||||
/// The surface showing right now. Starts in Preview and is settled by `openIfNeeded(body:)`
|
||||
/// the first time the window has a body to judge.
|
||||
public var mode: CardBodyMode = .preview
|
||||
|
||||
/// Puts the standard find bar over the focused body surface — **Edit ▸ Find (⌘F) is
|
||||
/// find-in-text here** (05 ▸ Preview; 11-command-nexus.md scopes ⌘F "Board window: board
|
||||
/// search; card window: find-in-text"). Filled in by the surface itself, which is the only
|
||||
/// thing that holds a text view to hand the action to; `nil` until one exists, which is also
|
||||
/// exactly when ⌘F has nothing to find in.
|
||||
public var findInText: (() -> Void)?
|
||||
|
||||
/// Whether the opening rule has already run for this window.
|
||||
///
|
||||
/// **Once, not per snapshot.** The rule is about *opening* a card, and the body it judges
|
||||
/// arrives with the first snapshot — but snapshots keep arriving (a watcher reload, a lane
|
||||
/// move, another window's edit). Re-running it would drag a reader back into Edit the moment
|
||||
/// someone else emptied the file, and would fight a user who had just pressed ⌘E.
|
||||
private var hasOpened = false
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Applies the opening rule the first time it is called, and does nothing on every call after.
|
||||
@discardableResult
|
||||
public func openIfNeeded(body: String) -> CardBodyMode {
|
||||
guard !hasOpened else { return mode }
|
||||
hasOpened = true
|
||||
mode = CardBodyMode.opening(body: body)
|
||||
return mode
|
||||
}
|
||||
|
||||
/// ⌘E, Return in Preview, Escape in Edit — see `CardBodyMode.toggled`.
|
||||
public func toggleMode() {
|
||||
mode = mode.toggled
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused card window's body column, beside `FocusedValues.boardSearch` — see
|
||||
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
|
||||
struct FocusedCardBodyKey: FocusedValueKey {
|
||||
typealias Value = CardBodyPresentation
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var cardBody: CardBodyPresentation? {
|
||||
get { self[FocusedCardBodyKey.self] }
|
||||
set { self[FocusedCardBodyKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,32 @@ enum CardWindowMetrics {
|
||||
columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The rendered body
|
||||
|
||||
/// One step of structural indent in Preview — a list level, a quote level. One and a half ems,
|
||||
/// which is wide enough for a bullet plus its space and narrow enough that four nested levels
|
||||
/// still leave a measure worth reading.
|
||||
static func previewIndent(bodyPointSize: CGFloat) -> CGFloat {
|
||||
(bodyPointSize * 1.5).rounded()
|
||||
}
|
||||
|
||||
/// The inside padding of a table cell and the inset of a code block — half a gutter, so the
|
||||
/// rendered body's rhythm is the column's rhythm halved rather than a second, unrelated one.
|
||||
static func previewPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||
(gutter(bodyPointSize: bodyPointSize) / 2).rounded()
|
||||
}
|
||||
|
||||
/// The widest an inline image is drawn at.
|
||||
///
|
||||
/// A number rather than "the text container's width" on purpose: a `NSTextAttachment`'s bounds
|
||||
/// are fixed at build time, so an image sized to the window would have to be rebuilt on every
|
||||
/// resize — and the body column is resizable by contract. A generous cap keeps a screenshot
|
||||
/// legible without letting a 4000-pixel-wide one push the measure around, and an image narrower
|
||||
/// than the cap is never enlarged.
|
||||
static func previewImageMaximumWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
columnWidth(characters: 56, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The window
|
||||
|
||||
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user