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
+32 -2
View File
@@ -89,6 +89,9 @@ struct CardWindowHost: View {
@State private var windowController = HostedWindowController() @State private var windowController = HostedWindowController()
@State private var session = CardWindowSession() @State private var session = CardWindowSession()
@State private var phase: Phase = .opening @State private var phase: Phase = .opening
/// This window's body column the Preview/Edit mode, and the F hook a menu item reaches
/// through the focus system (`CardBodyPresentation`).
@State private var bodyPresentation = CardBodyPresentation()
private enum Phase { private enum Phase {
case opening case opening
@@ -149,6 +152,10 @@ struct CardWindowHost: View {
// rename retitles the window and a lane move re-subtitles it with no notification of // rename retitles the window and a lane move re-subtitles it with no notification of
// our own (05-card-window.md Window). // our own (05-card-window.md Window).
.navigationSubtitle(windowSubtitle) .navigationSubtitle(windowSubtitle)
// Edit Find (F) is find-in-text in a card window (11-command-nexus.md) the menu
// item reaches the frontmost one's body surface through this, exactly as board-window
// items reach their window's store (`FocusedBoardStoreKey`).
.focusedSceneValue(\.cardBody, bodyPresentation)
.task { start() } .task { start() }
.onChange(of: shouldDismiss, initial: true) { _, dismisses in .onChange(of: shouldDismiss, initial: true) { _, dismisses in
guard dismisses else { return } guard dismisses else { return }
@@ -163,14 +170,37 @@ struct CardWindowHost: View {
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if let placement { if case let .open(store) = phase, let placement {
CardWindowView(card: placement.card) CardWindowView(
card: placement.card,
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
bodyPresentation: bodyPresentation,
// "Under the read-only lock the controls disable in place an in-content mutation
// menu validation can't reach" (05 Preview). The checkbox is that control, and
// the store's own lock is the whole predicate.
isEditable: !store.isReadOnly,
onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
}
)
} else { } else {
// Nothing to render and nothing worth animating: this window is on its way out. // Nothing to render and nothing worth animating: this window is on its way out.
Color.clear Color.clear
} }
} }
/// `<root>/<lane>/<card>` the card's own folder, which is what its body's relative images and
/// links resolve against (05-card-window.md Preview).
///
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for
/// `BoardStore.liveItem`'s reason: a mid-session folder rename moves the board, and a preview
/// resolving images against where the board used to be would quietly stop showing them.
static func cardFolder(root: URL, placement: CardPlacement) -> URL {
root
.appendingPathComponent(placement.lane.id.rawValue, isDirectory: true)
.appendingPathComponent(placement.card.id.rawValue, isDirectory: true)
}
/// Where this window's card is in this board's snapshot, or `nil` when it is not which is the /// Where this window's card is in this board's snapshot, or `nil` when it is not which is the
/// same condition `shouldDismiss` reads, one moment before the window goes. /// same condition `shouldDismiss` reads, one moment before the window goes.
private var placement: CardPlacement? { private var placement: CardPlacement? {
+7
View File
@@ -641,6 +641,11 @@ public final class BannerCenter {
// failure the user did not provoke is exactly the one they have no other way to learn // failure the user did not provoke is exactly the one they have no other way to learn
// about. // about.
"Couldn't move '\(filename)' into attachments" "Couldn't move '\(filename)' into attachments"
case let .toggleTask(title):
// The user's word for it, not the file's: they ticked a box. The card is named where
// the read that preceded the flip learned its title, so a body write that refused says
// *which* card refused it a card window is not always the frontmost thing on screen.
if let title { "Couldn't tick the checkbox in '\(title)'" } else { "Couldn't tick the checkbox" }
} }
} }
@@ -655,6 +660,8 @@ public final class BannerCenter {
"this file's frontmatter can't be edited in place (\(shape.description))" "this file's frontmatter can't be edited in place (\(shape.description))"
case let .io(message): case let .io(message):
message message
case let .staleTarget(message):
message
} }
return trimmed(text) return trimmed(text)
} }
+30
View File
@@ -1127,6 +1127,36 @@ public final class BoardStore {
return nil return nil
} }
// MARK: - Task checkboxes
/// Ticks or unticks a Preview task-list checkbox **the app's one write into a card's body**
/// (05-card-window.md Preview), and otherwise an entirely ordinary one: the same
/// `performWrite` bracket, the same banner on failure, the same one-way flow back through the
/// watcher. "A toggle is an ordinary user edit the standard atomic write, auto-committed and
/// undoable on git boards."
///
/// `bodyOffset` is the UTF-8 byte offset the parse handed the renderer (`BodyTask
/// .markerOffset`) and `checked` is the state the user was looking at; both travel to
/// `BoardWriter.toggleTaskMarker`, which re-verifies them against the file it reads and refuses
/// rather than write blind. Nothing here inspects the body: the store never re-parses to
/// second-guess the click, because its own snapshot is exactly as stale as the render was.
///
/// **A checkbox in a card that has gone writes nothing** the vanished-target guard every
/// gesture in this file makes, ancestor-walked through `liveItem`: the card window would be
/// dismissing itself in the same breath, and the reload that removed the card is the authority.
/// The read-only lock is `performWrite`'s refusal, which is also why the controls disable in
/// place on the Preview side rather than failing here (02-architecture.md § the lock's scope).
public func toggleTaskMarker(inCard cardID: ItemID, bodyOffset: Int, checked: Bool) {
guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.toggleTaskMarker(inItemFolder: folder, bodyOffset: bodyOffset, checked: checked)
}
}
// MARK: - Board rename // MARK: - Board rename
/// Writes the board's own `title` the board popover's rename field (03-board-ui.md § Board /// Writes the board's own `title` the board popover's rename field (03-board-ui.md § Board
+84
View File
@@ -979,6 +979,68 @@ public enum BoardWriter: Sendable {
} }
} }
// MARK: - Task checkboxes
/// Flips one task-list checkbox in an item's body **a single-byte edit, and the only write
/// in the app that touches the body at all** (05-card-window.md Preview: "clicking a
/// `- [ ]` / `- [x]` checkbox flips exactly that marker in the source a single-character
/// textual edit; every other byte of the body is untouched").
///
/// It is `updateIndex`'s four steps with one addition, and the addition is why it is written
/// out here rather than expressed as an `edits` closure: the flip can **refuse**, and
/// `updateIndex`'s closure cannot. Everything else is identical and deliberately so read
/// fresh from disk, refuse an uneditable frontmatter shape, edit, stamp `modified` and clear
/// `modified-by`, replace atomically. A toggle is "an ordinary user edit the standard atomic
/// write, auto-committed and undoable on git boards" (05), not a special case of anything.
///
/// ### The offset, and why it is re-checked
///
/// `bodyOffset` is a UTF-8 byte offset into the **body** (`FrontmatterDocument.body`, the text
/// after the closing delimiter) naming the character *between* the brackets the offset
/// `BodyTask.markerOffset` carried out of the parse that drew the box the user clicked.
///
/// That parse ran against a snapshot; this call reads disk. In between, an agent, a hand edit
/// or a pull may have rewritten the file the same staleness `updateIndex`'s read-fresh rule
/// exists for, except that here the *target* is a byte offset rather than a key, and a stale
/// key merely rewrites the wrong value while a stale offset would drop an `x` into the middle
/// of a sentence. So `BodyMarkup.flippingTaskMarker` re-verifies the brackets, the marker, and
/// the state the user saw before anything is written, and `.staleTarget` refuses when any of
/// the three has moved. The refusal is loud (the banner) rather than silent: the user clicked
/// a box and it did not tick.
///
/// **`checked` is what the user saw, not what they want** the flip's direction is derived
/// from it, which is what makes a double-click land on one net toggle instead of racing.
public static func toggleTaskMarker(
inItemFolder folder: URL,
bodyOffset: Int,
checked: Bool
) throws(BoardWriteError) {
var operation = WriteOperation.toggleTask(title: nil)
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
// Lanes and cards both have bodies; a board root's is its description, and no surface
// previews it. The same shape guard every other item write leans on keeps this call off it.
try checkIsUUIDShaped(folder, operation: operation)
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
guard let flipped = BodyMarkup.flippingTaskMarker(in: document.body, at: bodyOffset, expecting: checked) else {
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .staleTarget(message: "this checkbox is no longer where it was — the file changed")
)
}
document.body = flipped
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
}
// MARK: - Attachments // MARK: - Attachments
/// The one folder this app ever creates under a card every other subfolder under /// The one folder this app ever creates under a card every other subfolder under
@@ -1496,6 +1558,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// never the Finder-renamed one it would have landed under. /// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String) case relocateLooseFile(filename: String)
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md Preview).
///
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
/// vocabulary's standing reasoning: the user clicked a checkbox, and a banner telling them the
/// app could not "restyle" or "rename" the card would name a gesture that never happened. It is
/// also the app's only *body* write, which is worth being able to see in a log at a glance.
case toggleTask(title: String?)
/// Fills in the title once the Writer has read it off the document the operation is acting /// Fills in the title once the Writer has read it off the document the operation is acting
/// on identity for the six cases with no title slot at all: `createBoard`/`createLane`/ /// on identity for the six cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the /// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
@@ -1520,6 +1590,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .resize: .resize(title: title) case .resize: .resize(title: title)
case .rename: .rename(title: title) case .rename: .rename(title: title)
case .duplicateBoard: .duplicateBoard(title: title) case .duplicateBoard: .duplicateBoard(title: title)
case .toggleTask: .toggleTask(title: title)
} }
} }
@@ -1548,6 +1619,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .listAttachments: "list attachments" case .listAttachments: "list attachments"
case .renumberChildren: "renumber children" case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'" case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
} }
} }
@@ -1592,6 +1664,16 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
/// permissions, volume error. The destination still holds its previous bytes. /// permissions, volume error. The destination still holds its previous bytes.
case io(message: String) case io(message: String)
/// **The bytes the edit aimed at are not what the caller was shown.** The file read
/// cleanly and its frontmatter parsed this is not `.unreadable` but the surgical
/// target moved: the checkbox at that offset is gone, or is already in the state the
/// click would have produced (05-card-window.md Preview, `toggleTaskMarker`).
///
/// Its own reason because the app's one *offset-addressed* write is the one place where
/// "read fresh from disk" is not enough on its own: every other write names a key, and a
/// key that moved is still the same key.
case staleTarget(message: String)
public var description: String { public var description: String {
switch self { switch self {
case let .unreadable(message): case let .unreadable(message):
@@ -1600,6 +1682,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
"frontmatter cannot be edited in place: \(shape.description)" "frontmatter cannot be edited in place: \(shape.description)"
case let .io(message): case let .io(message):
message message
case let .staleTarget(message):
message
} }
} }
} }
+556
View File
@@ -0,0 +1,556 @@
import Foundation
import Markdown
// MARK: - BodyMarkup
/// A card body parsed into the shapes Preview renders **the whole of the Markdown subset
/// 05-card-window.md Preview settles, and nothing else**.
///
/// ### Why a model at all, rather than parser attributed string
///
/// Three of Preview's rules are decisions, not drawing, and every one of them is invisible in a
/// pile of `NSAttributedString` attributes:
///
/// - **HTML is literal text.** `<b>x</b>` in a body is code-styled characters, never a rendered
/// bold "never interpreted no web view, per 00-vision.md's no-web-tech stance". That is a
/// *classification*, and `BodyInline.html` is where it is made once.
/// - **An image is local or it is not.** A relative path resolves against the card's own folder and
/// renders inline; anything carrying a URL scheme is **never fetched** Preview does no
/// networking and renders as a quiet placeholder chip. `BodyTarget` is that fork, and it is the
/// same fork a link takes (browser vs. default app), which is why one type serves both.
/// - **A task checkbox knows where it came from.** Clicking one flips *exactly that character* in
/// the source, every other byte untouched, so the model has to carry the source offset the write
/// will aim at (`BodyTask.markerOffset`) a render that lost it could only re-serialize the
/// whole body, which is precisely what the storage contract forbids.
///
/// Keeping those three in a value type also makes them testable without a window, which is the
/// other half of the reason: `BodyMarkupTests` asserts the mapping construct by construct, and the
/// renderer beneath it is then only ever wrong about *typography*.
///
/// ### Offsets are UTF-8 byte offsets into the body
///
/// Not `String.Index`, not character counts. Two reasons, and they agree:
/// swift-markdown's `SourceLocation.column` is itself "the number of bytes in UTF-8 encoding from
/// the start of the line", so byte offsets are the units the parser already speaks; and the write
/// this model feeds is a **byte** edit (`BodyMarkup.flippingTaskMarker`), so anything else would
/// have to be converted at the one point where being off by one corrupts a file.
///
/// Offsets are into the **body** the text after the frontmatter's closing delimiter, which is
/// exactly `FrontmatterDocument.body` never into the whole `index.md`.
public struct BodyMarkup: Equatable, Sendable {
/// The body's top-level blocks, in document order.
public let blocks: [BodyBlock]
public init(blocks: [BodyBlock]) {
self.blocks = blocks
}
/// Whether there is nothing to preview **the mode rule's input** (05 Mode grammar: a card
/// opens in Preview "unless its body is empty, which opens straight into Edit"). Whitespace
/// counts as empty: a body of one newline previews as a blank page, and sending the user to a
/// blank *preview* of a blank body is the ceremony the rule exists to remove.
public static func isEmpty(_ body: String) -> Bool {
body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Parses a card body.
///
/// **Total** there is no error case. A body is whatever the user (or an agent, or a hand
/// edit) put there; CommonMark has no parse failures, and a file that reached this point
/// already passed the loader's strict UTF-8 and frontmatter gates. Anything the subset does not
/// model degrades to its plain text rather than disappearing.
///
/// **Smart typography is off** (`.disableSmartOpts`). Preview renders the bytes on disk: a
/// `--` that silently became an en dash would be a preview of a document the file does not
/// contain, and would also put the rendered text out of step with F over it.
public static func parse(_ body: String) -> BodyMarkup {
let document = Document(parsing: body, options: [.disableSmartOpts])
let builder = Builder(body: body)
return BodyMarkup(blocks: builder.blocks(of: document))
}
}
// MARK: - Blocks
/// A span of the body, in UTF-8 byte offsets `start..<end`, the parser's own extent for the
/// construct.
///
/// Carried on blocks and on task markers, and deliberately **not** on inlines: the only consumer
/// that needs an offset to be exact is the checkbox flip, which is a list item's, and per-inline
/// ranges from cmark are an approximation nothing here would be entitled to write through. Block
/// ranges pay for themselves in the other direction they are what lets a test say "this heading
/// is these bytes" rather than trusting a rendering to look right.
public struct BodySpan: Equatable, Sendable {
public let start: Int
public let end: Int
public init(start: Int, end: Int) {
self.start = start
self.end = end
}
}
/// One block of a rendered body. The case list *is* the settled subset (05 Preview): headings,
/// paragraphs, fenced and indented code, verbatim HTML, thematic breaks, nested quotes, bullet /
/// ordered / task lists, and GFM tables.
public enum BodyBlock: Equatable, Sendable {
case heading(level: Int, inlines: [BodyInline], range: BodySpan?)
case paragraph(inlines: [BodyInline], range: BodySpan?)
/// Fenced *and* indented alike the fence is syntax, and what reaches the reader is code.
/// `language` is the fence's info string when it has one, `nil` for an indented block or a
/// bare fence; it is never used to highlight (Preview does not colour code), only to label.
case code(code: String, language: String?, range: BodySpan?)
/// **Verbatim.** The raw HTML as written, rendered as code-styled literal text.
case html(raw: String, range: BodySpan?)
case thematicBreak(range: BodySpan?)
/// Nests: a quote's children are blocks, quotes included.
case quote(blocks: [BodyBlock], range: BodySpan?)
case list(BodyList, range: BodySpan?)
case table(BodyTable, range: BodySpan?)
}
/// A bullet, ordered, or task list. **Task lists are not a separate case**, because GFM does not
/// make them one: a task list is a list whose items happen to carry a checkbox, and a list may
/// legitimately mix the two.
public struct BodyList: Equatable, Sendable {
public let isOrdered: Bool
/// The first number of an ordered list (`3.` starts at 3); `1` for a bullet list.
public let start: Int
public let items: [BodyListItem]
public init(isOrdered: Bool, start: Int, items: [BodyListItem]) {
self.isOrdered = isOrdered
self.start = start
self.items = items
}
}
public struct BodyListItem: Equatable, Sendable {
/// The item's checkbox, or `nil` for an ordinary list item.
public let task: BodyTask?
public let blocks: [BodyBlock]
public init(task: BodyTask?, blocks: [BodyBlock]) {
self.task = task
self.blocks = blocks
}
}
/// A live task-list checkbox: its state, and **where its one character lives in the source**.
///
/// `markerOffset` is the UTF-8 byte offset of the character *between* the brackets the space in
/// `- [ ]`, the `x` in `- [x]`. That single byte is the entire write a click performs (05
/// Preview: "clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker in the source every
/// other byte of the body is untouched").
///
/// It is optional because locating it is a *scan*, not a parser guarantee: cmark reports the list
/// item's start, and the marker is found by reading forward from there. A body shaped so the scan
/// comes up empty still renders its checkbox inert, rather than absent, since the box is
/// content the user wrote and hiding it would be the bigger lie.
public struct BodyTask: Equatable, Sendable {
public let isChecked: Bool
public let markerOffset: Int?
public init(isChecked: Bool, markerOffset: Int?) {
self.isChecked = isChecked
self.markerOffset = markerOffset
}
}
/// A GFM table: per-column alignment, a header row, and body rows.
public struct BodyTable: Equatable, Sendable {
public enum Alignment: Equatable, Sendable {
/// No `:` on either side the column follows the reader's default, which is leading.
case unspecified
case leading
case center
case trailing
}
/// One entry per column, padded to `columnCount` a delimiter row shorter than the widest row
/// leaves the extra columns `.unspecified` rather than making the array a different length
/// from the cells it describes.
public let alignments: [Alignment]
public let header: [BodyTableCell]
public let rows: [[BodyTableCell]]
public var columnCount: Int { alignments.count }
public init(alignments: [Alignment], header: [BodyTableCell], rows: [[BodyTableCell]]) {
self.alignments = alignments
self.header = header
self.rows = rows
}
}
public struct BodyTableCell: Equatable, Sendable {
public let inlines: [BodyInline]
/// GFM's cell span. Preserved because cmark reports it; a renderer that ignores it merely
/// draws a narrower cell, never a wrong one.
public let colspan: Int
public init(inlines: [BodyInline], colspan: Int = 1) {
self.inlines = inlines
self.colspan = colspan
}
}
// MARK: - Inlines
/// One inline run. Nesting is by array rather than by `indirect`, which is enough: every container
/// case holds *several* children, so the recursion is already behind a heap allocation.
public enum BodyInline: Equatable, Sendable {
case text(String)
case emphasis([BodyInline])
case strong([BodyInline])
case strikethrough([BodyInline])
case code(String)
/// **Verbatim**, exactly like `BodyBlock.html`: `<br>` is five code-styled characters.
case html(String)
/// A hard break (two trailing spaces, or a backslash).
case lineBreak
/// An ordinary newline inside a paragraph.
case softBreak
case link(target: BodyTarget, inlines: [BodyInline])
case image(BodyImage)
}
/// An image reference. `alt` is the bracket text the placeholder chip's label for an image
/// Preview will not fetch, and the accessibility description for one it will.
public struct BodyImage: Equatable, Sendable {
public let target: BodyTarget
public let alt: String
public let title: String?
public init(target: BodyTarget, alt: String, title: String? = nil) {
self.target = target
self.alt = alt
self.title = title
}
}
/// Where a link or an image points **the one classification both need**, and the reason it is
/// one type: a link and an image ask the same question of a destination (does it carry a URL
/// scheme?) and act on the two answers differently.
///
/// - `.absolute` the destination has a scheme: `https:`, `mailto:`, `file:`, `data:`. A **link**
/// opens it with the system (external URLs open in the browser); an **image** is *never fetched*
/// Preview does no networking, sandbox-quiet and files-first and renders as a quiet
/// placeholder chip carrying its alt text or the URL (05 Preview).
/// - `.relative` no scheme: a path resolved against the card's own folder. This is the supported
/// image story (`![](attachments/shot.png)` renders inline) and, for links, the "open the target
/// file with its default app" path.
///
/// An empty destination is `.relative("")`: it names nothing, resolves to nothing, and both
/// surfaces already have to cope with a path that does not exist on disk.
public enum BodyTarget: Equatable, Sendable {
case absolute(String)
case relative(String)
/// The classifier itself. A scheme is what `URLComponents` finds *and* what a human would call
/// one: `URL(string:)` alone would happily read `attachments/shot.png` as a relative URL and
/// report no scheme, which is the answer we want, but it also tolerates shapes that differ
/// between OS versions asking for the scheme explicitly keeps the question narrow.
///
/// A Windows-style `C:\path` is *not* treated as a scheme: a single-letter scheme is
/// vanishingly unlikely to be a real URL and overwhelmingly likely to be a path.
public static func classify(_ destination: String?) -> BodyTarget {
let text = destination ?? ""
guard let scheme = URLComponents(string: text)?.scheme, scheme.count > 1 else {
return .relative(text)
}
return .absolute(text)
}
/// The destination as written, whichever case it took.
public var text: String {
switch self {
case let .absolute(text), let .relative(text): text
}
}
/// The URL this destination names, given the card's own folder the **one** place a body's
/// text becomes something the system can be asked to open or read.
///
/// - `.absolute` is handed to `URL(string:)` as written. A malformed one is `nil`, which is
/// the honest answer: a link that is not a URL opens nothing.
/// - `.relative` resolves against `cardFolder` 05-card-window.md's rule for both images
/// ("resolved against the card's own folder") and links ("relative links open the target file
/// with its default app resolved against the card folder, like images"). Percent-encoding
/// is undone first, because `attachments/my%20shot.png` names a file with a space in it.
/// - A **rooted** path (`/Users/`) is taken as the absolute file path it plainly is rather
/// than being glued onto the card folder, which would name a file nobody meant.
///
/// `nil` for an empty destination and for a relative one with no card folder to stand on.
/// Deliberately **not** confined to the card's folder: `../sibling/notes.md` is a link a
/// files-first app has no business silently refusing, and the app opens files with the system
/// rather than reading them into itself.
func resolve(inCardFolder cardFolder: URL?) -> URL? {
switch self {
case let .absolute(text):
return URL(string: text)
case let .relative(text):
guard !text.isEmpty else { return nil }
let decoded = text.removingPercentEncoding ?? text
if decoded.hasPrefix("/") { return URL(fileURLWithPath: decoded).standardizedFileURL }
guard let cardFolder else { return nil }
return URL(fileURLWithPath: decoded, relativeTo: cardFolder).standardizedFileURL
}
}
}
// MARK: - Building the model
/// The swift-markdown `BodyMarkup` conversion, kept private so the model above is the only
/// vocabulary anything else sees. A `Markup` tree is a reference-flavoured API with a `_data`
/// escape hatch on every node; letting it past this file would put a second, richer, mutable
/// representation of a card body into the app for no gain.
private struct Builder {
/// The body's bytes, and where each 1-based line starts in them the two facts every
/// `SourceLocation` byte-offset conversion needs, computed once for the whole parse.
private let utf8: [UInt8]
private let lineStarts: [Int]
init(body: String) {
utf8 = Array(body.utf8)
lineStarts = Self.lineStarts(of: utf8)
}
/// Byte offsets at which each line begins, `lineStarts[0]` being line 1.
///
/// Every ending is honoured `\n`, `\r\n`, and a lone `\r` because a card body is whatever
/// its author's editor writes and "line endings are preserved per line, never normalized"
/// (01-storage-format.md Fractal layout Rules). A CRLF file's `\r` sits at the end of the
/// line it terminates, which is exactly where cmark's columns leave it.
private static func lineStarts(of utf8: [UInt8]) -> [Int] {
var starts = [0]
var index = 0
while index < utf8.count {
if utf8[index] == 0x0A {
starts.append(index + 1)
} else if utf8[index] == 0x0D {
let isCRLF = index + 1 < utf8.count && utf8[index + 1] == 0x0A
starts.append(index + (isCRLF ? 2 : 1))
if isCRLF { index += 1 }
}
index += 1
}
return starts
}
/// A parser location as a byte offset into the body, or `nil` when it names a line that is not
/// there which a well-formed parse never produces, and a defensive `nil` is cheaper than a
/// crash if it ever did.
private func offset(of location: SourceLocation) -> Int? {
let line = location.line - 1
guard line >= 0, line < lineStarts.count else { return nil }
let offset = lineStarts[line] + max(0, location.column - 1)
return offset <= utf8.count ? offset : utf8.count
}
private func span(of markup: Markup) -> BodySpan? {
guard let range = markup.range,
let start = offset(of: range.lowerBound),
let end = offset(of: range.upperBound)
else { return nil }
return BodySpan(start: start, end: max(start, end))
}
// MARK: Blocks
func blocks(of parent: Markup) -> [BodyBlock] {
parent.children.compactMap(block(_:))
}
private func block(_ markup: Markup) -> BodyBlock? {
switch markup {
case let heading as Heading:
.heading(level: heading.level, inlines: inlines(of: heading), range: span(of: heading))
case let paragraph as Paragraph:
.paragraph(inlines: inlines(of: paragraph), range: span(of: paragraph))
case let code as CodeBlock:
.code(code: code.code, language: code.language, range: span(of: code))
case let html as HTMLBlock:
.html(raw: html.rawHTML, range: span(of: html))
case let rule as ThematicBreak:
.thematicBreak(range: span(of: rule))
case let quote as BlockQuote:
.quote(blocks: blocks(of: quote), range: span(of: quote))
case let list as UnorderedList:
.list(BodyList(isOrdered: false, start: 1, items: items(of: list)), range: span(of: list))
case let list as OrderedList:
.list(BodyList(isOrdered: true, start: Int(list.startIndex), items: items(of: list)), range: span(of: list))
case let table as Table:
.table(self.table(table), range: span(of: table))
case let container as BlockContainer:
// Nothing else in the subset nests, so anything that lands here is a shape the parser
// produced and Preview does not model (a block directive, a custom block). Rendering
// its children keeps the user's words on screen instead of swallowing them.
.quote(blocks: blocks(of: container), range: span(of: container))
default:
// A leaf outside the subset degrades to its own plain text, same reasoning.
markup.childCount == 0
? nil
: .paragraph(inlines: inlines(of: markup), range: span(of: markup))
}
}
private func items(of list: ListItemContainer) -> [BodyListItem] {
list.listItems.map { item in
BodyListItem(task: task(of: item), blocks: blocks(of: item))
}
}
/// The item's checkbox, located in the source.
///
/// cmark hands over the *state* and the item's start; the marker's own offset is found by
/// scanning forward from that start for the first `[ ]`/`[x]`/`[X]` on the item's opening line
/// (`BodyMarkup.taskMarkerOffset`). Scanning rather than arithmetic because the distance from
/// the item's start to its bracket is not fixed `-`, `*`, `+`, `1.`, `12)` and any amount of
/// indentation all lead the same checkbox.
private func task(of item: ListItem) -> BodyTask? {
guard let checkbox = item.checkbox else { return nil }
let isChecked = checkbox == .checked
guard let range = item.range, let start = offset(of: range.lowerBound) else {
return BodyTask(isChecked: isChecked, markerOffset: nil)
}
return BodyTask(
isChecked: isChecked,
markerOffset: BodyMarkup.taskMarkerOffset(in: utf8, scanningFrom: start)
)
}
private func table(_ table: Table) -> BodyTable {
let header = cells(of: table.head)
let rows: [[BodyTableCell]] = table.body.rows.map { cells(of: $0) }
let width = max(table.maxColumnCount, max(header.count, rows.map(\.count).max() ?? 0))
var alignments = table.columnAlignments.map(Self.alignment(_:))
if alignments.count < width {
alignments.append(contentsOf: Array(repeating: BodyTable.Alignment.unspecified, count: width - alignments.count))
} else if alignments.count > width {
alignments = Array(alignments.prefix(width))
}
return BodyTable(alignments: alignments, header: header, rows: rows)
}
private static func alignment(_ alignment: Table.ColumnAlignment?) -> BodyTable.Alignment {
switch alignment {
case .none: .unspecified
case .some(.left): .leading
case .some(.center): .center
case .some(.right): .trailing
}
}
private func cells(of container: any TableCellContainer) -> [BodyTableCell] {
container.cells.map { cell in
BodyTableCell(inlines: inlines(of: cell), colspan: Int(cell.colspan))
}
}
// MARK: Inlines
private func inlines(of parent: Markup) -> [BodyInline] {
parent.children.compactMap(inline(_:))
}
private func inline(_ markup: Markup) -> BodyInline? {
switch markup {
case let text as Markdown.Text:
.text(text.string)
case let emphasis as Emphasis:
.emphasis(inlines(of: emphasis))
case let strong as Strong:
.strong(inlines(of: strong))
case let struck as Strikethrough:
.strikethrough(inlines(of: struck))
case let code as InlineCode:
.code(code.code)
case let html as InlineHTML:
// The rule, in one line: what the author typed is what the reader sees.
.html(html.rawHTML)
case is LineBreak:
.lineBreak
case is SoftBreak:
.softBreak
case let image as Image:
.image(BodyImage(
target: BodyTarget.classify(image.source),
alt: image.plainText,
title: image.title
))
case let link as Link:
.link(target: BodyTarget.classify(link.destination), inlines: inlines(of: link))
case let plain as PlainTextConvertibleMarkup:
// Anything else the parser can still say in words (a symbol link, a custom inline).
.text(plain.plainText)
default:
nil
}
}
}
// MARK: - The task marker, found and flipped
/// The two halves of the checkbox contract that are **pure byte work** finding the marker, and
/// flipping it kept together and kept out of the renderer, because the write side
/// (`BoardWriter.toggleTaskMarker`) needs the second one and must not import a Markdown parser to
/// get it.
public extension BodyMarkup {
/// Whether a byte is a checkbox marker, and what it means: `" "` unchecked, `x`/`X` checked.
static func markerState(_ byte: UInt8) -> Bool? {
switch byte {
case 0x20: false
case 0x78, 0x58: true
default: nil
}
}
/// The offset of the character between the first `[]` checkbox brackets at or after `start`,
/// **on that line only** the scan `BodyTask.markerOffset` is built from.
///
/// Line-scoped because a task item's checkbox is by definition the first thing on the item's
/// own line; letting the scan run past the newline would let a later line's literal `[x]` be
/// mistaken for this item's box.
static func taskMarkerOffset(in utf8: [UInt8], scanningFrom start: Int) -> Int? {
var index = max(0, start)
while index + 2 < utf8.count {
let byte = utf8[index]
if byte == 0x0A || byte == 0x0D { return nil }
if byte == 0x5B, markerState(utf8[index + 1]) != nil, utf8[index + 2] == 0x5D {
return index + 1
}
index += 1
}
return nil
}
/// `body` with the checkbox at `offset` flipped **one byte changed, and nothing else** or
/// `nil` when the bytes there are not the checkbox the caller was shown.
///
/// The `expecting` guard is the reason this returns an optional rather than a `String`. A
/// Preview click carries the state the *user saw*; between the render and the write the file
/// may have been rewritten by an agent, a hand edit, or a pull. Flipping blindly at a stale
/// offset would put an `x` in the middle of whatever now lives there. So the write refuses
/// unless three things still hold: the brackets are where they were, what sits between them is
/// a marker, and it reads the way the user saw it.
///
/// Rebuilding through `String(decoding:as:)` is lossless here by construction: the bytes came
/// from a `String`, and the one byte replaced is ASCII in both directions.
static func flippingTaskMarker(in body: String, at offset: Int, expecting checked: Bool) -> String? {
var bytes = Array(body.utf8)
guard offset > 0, offset + 1 < bytes.count,
bytes[offset - 1] == 0x5B, bytes[offset + 1] == 0x5D,
let state = markerState(bytes[offset]), state == checked
else { return nil }
bytes[offset] = checked ? 0x20 : 0x78
return String(decoding: bytes, as: UTF8.self)
}
}
+13 -1
View File
@@ -83,13 +83,25 @@ struct FindCommand: View {
@FocusedValue(\.boardStore) private var store @FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardSearch) private var search @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 { var body: some View {
Button("Find") { Button("Find") {
if let findInText {
findInText()
} else {
search?.focusField?() search?.focusField?()
} }
}
.keyboardShortcut("f", modifiers: .command) .keyboardShortcut("f", modifiers: .command)
.disabled(store == nil || search?.focusField == nil) .disabled(findInText == nil && (store == nil || search?.focusField == nil))
} }
} }
+570
View File
@@ -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
}
}
+98
View File
@@ -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 }
}
}
+196
View File
@@ -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
}
}
}
}
+26
View File
@@ -79,6 +79,32 @@ enum CardWindowMetrics {
columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize) 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 // MARK: - The window
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough /// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
+62 -15
View File
@@ -13,7 +13,7 @@ import SwiftUI
/// where it lands: /// where it lands:
/// ///
/// - the title as an editable field (commit on Return / focus loss, Escape abandons), /// - 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 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 /// 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 /// 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 /// 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. /// 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 { struct CardWindowView: View {
let card: Card 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 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 /// 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. /// Title, the quiet created/modified line, then the body 05's top-to-bottom order.
private var bodyColumn: some View { private var bodyColumn: some View {
ScrollView(.vertical) { VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: bodyPointSize * 0.75) { VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
// m6-card-body: the title *field* large and borderless, committing to frontmatter // 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 // 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. // the on-disk title. Read-only here; the placeholder rendering is already final.
@@ -71,20 +90,48 @@ struct CardWindowView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.textSelection(.enabled) .textSelection(.enabled)
} }
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
// m6-card-body: Preview/Edit proper a rendered preview with live task-list if bodyPresentation.mode == .edit {
// checkboxes, and a syntax-highlighted raw editor behind E. Plain selectable text editPlaceholderNotice
// until then: honest about being unrendered rather than half-rendering Markdown. }
if !card.body.isEmpty {
Text(card.body) CardBodySurface(
.font(.body) body: card.body,
.textSelection(.enabled) 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) .frame(maxWidth: .infinity, alignment: .leading)
} .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
} .padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: bodyPointSize))
.frame(maxWidth: .infinity, alignment: .leading) .background(.background.secondary)
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
}
} }
/// "Created date · Modified date · by modified-by", **omitting whichever keys are absent** /// "Created date · Modified date · by modified-by", **omitting whichever keys are absent**
+517
View File
@@ -0,0 +1,517 @@
import Foundation
import Testing
@testable import Kanban
/// The executable spec for the card body's parse render **model** (05-card-window.md Preview).
///
/// The renderer beneath it draws pixels a unit test cannot see; what it draws *from* is this model,
/// and every rule 05 settles about Preview is a fact about the model rather than about the drawing:
/// which constructs are in the subset, that HTML is literal text rather than markup, that an image
/// with a scheme is never fetched, that a link knows whether it is the browser's or the Finder's,
/// and the one that reaches disk where a task checkbox's single byte lives in the source.
///
/// So this file is where those hold still. A regression here is a rendering that would be *wrong*;
/// a regression in the renderer is one that would be ugly.
// MARK: - Reading the model
/// The first block of a parsed body most cases here are one construct, and naming the unwrap once
/// keeps each test to its claim.
private func firstBlock(_ source: String) -> BodyBlock? {
BodyMarkup.parse(source).blocks.first
}
/// Every `BodyInline` flattened to the plain text it carries, containers descended into what the
/// reader ends up seeing, with the structure taken away.
private func plainText(_ inlines: [BodyInline]) -> String {
inlines.map { inline in
switch inline {
case let .text(text): text
case let .code(code): code
case let .html(raw): raw
case let .emphasis(children), let .strong(children), let .strikethrough(children):
plainText(children)
case let .link(_, children): plainText(children)
case let .image(image): image.alt
case .lineBreak: "\n"
case .softBreak: " "
}
}.joined()
}
/// The body slice a block's source range names the assertion that a range is *the* range rather
/// than merely plausible.
private func slice(_ body: String, _ span: BodySpan?) -> String? {
guard let span else { return nil }
let bytes = Array(body.utf8)
guard span.start <= span.end, span.end <= bytes.count else { return nil }
return String(decoding: bytes[span.start ..< span.end], as: UTF8.self)
}
private func span(of block: BodyBlock?) -> BodySpan? {
switch block {
case let .heading(_, _, range), let .paragraph(_, range), let .code(_, _, range),
let .html(_, range), let .thematicBreak(range), let .quote(_, range),
let .list(_, range), let .table(_, range):
range
case nil:
nil
}
}
// MARK: - The subset, construct by construct
@Suite("Body markup — the GFM subset")
struct BodyMarkupSubsetTests {
@Test("Headings carry their level and their inlines")
func headings() {
guard case let .heading(level, inlines, _)? = firstBlock("## Fix *login*\n") else {
Issue.record("expected a heading")
return
}
#expect(level == 2)
#expect(plainText(inlines) == "Fix login")
// The emphasis is structure, not a string: a renderer that lost it would still pass a
// plain-text check.
#expect(inlines.contains { if case .emphasis = $0 { true } else { false } })
}
@Test("Bold, italic, inline code and strikethrough are each their own inline")
func inlineRuns() {
guard case let .paragraph(inlines, _)? = firstBlock("**a** _b_ `c` ~~d~~\n") else {
Issue.record("expected a paragraph")
return
}
#expect(inlines.contains { if case .strong = $0 { true } else { false } })
#expect(inlines.contains { if case .emphasis = $0 { true } else { false } })
#expect(inlines.contains { if case .code("c") = $0 { true } else { false } })
#expect(inlines.contains { if case .strikethrough = $0 { true } else { false } })
}
@Test("A fenced block keeps its language; an indented one is code all the same")
func codeBlocks() {
guard case let .code(fenced, language, _)? = firstBlock("```swift\nlet x = 1\n```\n") else {
Issue.record("expected a fenced code block")
return
}
#expect(language == "swift")
#expect(fenced.hasPrefix("let x = 1"))
// The indented form is the same construct with nothing to label it 05 lists "fenced +
// indented code" as one line for a reason.
guard case let .code(indented, indentedLanguage, _)? = firstBlock(" let y = 2\n") else {
Issue.record("expected an indented code block")
return
}
#expect(indentedLanguage == nil)
#expect(indented.hasPrefix("let y = 2"))
}
@Test("Quotes nest")
func nestedQuotes() {
guard case let .quote(outer, _)? = firstBlock("> outer\n>\n> > inner\n") else {
Issue.record("expected a block quote")
return
}
let inner = outer.compactMap { block -> [BodyBlock]? in
if case let .quote(blocks, _) = block { return blocks }
return nil
}
#expect(inner.count == 1, "a quote inside a quote is a quote inside a quote, not a flattened one")
if case let .paragraph(inlines, _)? = inner.first?.first {
#expect(plainText(inlines) == "inner")
} else {
Issue.record("expected the nested quote's paragraph")
}
}
@Test("An ordered list remembers where it starts; a bullet list does not pretend to")
func lists() {
guard case let .list(ordered, _)? = firstBlock("3. three\n4. four\n") else {
Issue.record("expected an ordered list")
return
}
#expect(ordered.isOrdered)
#expect(ordered.start == 3)
#expect(ordered.items.count == 2)
guard case let .list(bullets, _)? = firstBlock("- one\n- two\n") else {
Issue.record("expected a bullet list")
return
}
#expect(!bullets.isOrdered)
#expect(bullets.items.allSatisfy { $0.task == nil })
}
@Test("A thematic break is its own block")
func thematicBreak() {
guard case .thematicBreak? = firstBlock("---\n") else {
Issue.record("expected a thematic break")
return
}
}
@Test("A GFM table carries per-column alignment, its header, and its rows")
func tables() {
let source = """
| Left | Middle | Right |
| :--- | :----: | ----: |
| a | b | c |
| d | e | f |
"""
guard case let .table(table, _)? = firstBlock(source) else {
Issue.record("expected a table")
return
}
#expect(table.columnCount == 3)
#expect(table.alignments == [.leading, .center, .trailing])
#expect(table.header.map { plainText($0.inlines) } == ["Left", "Middle", "Right"])
#expect(table.rows.count == 2)
#expect(table.rows.first?.map { plainText($0.inlines) } == ["a", "b", "c"])
}
@Test("A table with no alignment row markers leaves its columns unspecified")
func tableWithoutAlignments() {
let source = """
| One | Two |
| --- | --- |
| a | b |
"""
guard case let .table(table, _)? = firstBlock(source) else {
Issue.record("expected a table")
return
}
#expect(table.alignments == [.unspecified, .unspecified])
// One entry per column, always a renderer indexes this array by column and must not have
// to guard every access.
#expect(table.alignments.count == table.columnCount)
}
}
// MARK: - HTML is text
@Suite("Body markup — HTML is never interpreted")
struct BodyMarkupHTMLTests {
@Test("An HTML block is carried verbatim, as literal text")
func htmlBlockIsVerbatim() {
guard case let .html(raw, _)? = firstBlock("<div class=\"x\">\n <b>bold</b>\n</div>\n") else {
Issue.record("expected an HTML block")
return
}
// Verbatim: the angle brackets, the attribute quoting and the inner tag are all still
// characters. Nothing here is a `<b>` the app is about to honour (05 Preview; 00-vision's
// no-web-tech stance).
#expect(raw.contains("<div class=\"x\">"))
#expect(raw.contains("<b>bold</b>"))
}
@Test("Inline HTML is an inline of its own, never a rendered tag")
func inlineHTMLIsVerbatim() {
guard case let .paragraph(inlines, _)? = firstBlock("before <b>x</b> after\n") else {
Issue.record("expected a paragraph")
return
}
let html = inlines.compactMap { inline -> String? in
if case let .html(raw) = inline { return raw }
return nil
}
#expect(html == ["<b>", "</b>"])
// And crucially *not* a strong run: the tags are text and "x" is text beside them.
#expect(!inlines.contains { if case .strong = $0 { true } else { false } })
#expect(plainText(inlines) == "before <b>x</b> after")
}
}
// MARK: - Where a link or an image points
@Suite("Body markup — link and image classification")
struct BodyTargetTests {
@Test("A path with no scheme is relative; anything with one is absolute")
func classification() {
#expect(BodyTarget.classify("attachments/shot.png") == .relative("attachments/shot.png"))
#expect(BodyTarget.classify("./notes.md") == .relative("./notes.md"))
#expect(BodyTarget.classify("../sibling/index.md") == .relative("../sibling/index.md"))
#expect(BodyTarget.classify("https://example.com/x.png") == .absolute("https://example.com/x.png"))
#expect(BodyTarget.classify("mailto:[email protected]") == .absolute("mailto:[email protected]"))
#expect(BodyTarget.classify("file:///tmp/x.png") == .absolute("file:///tmp/x.png"))
// Nothing to point at is a relative nothing, not an absolute one.
#expect(BodyTarget.classify(nil) == .relative(""))
#expect(BodyTarget.classify("") == .relative(""))
}
@Test("A relative image resolves against the card's own folder; a remote one is never fetched")
func imageClassification() {
guard case let .paragraph(local, _)? = firstBlock("![a shot](attachments/shot.png)\n"),
case let .image(localImage)? = local.first
else {
Issue.record("expected a local image")
return
}
#expect(localImage.target == .relative("attachments/shot.png"))
#expect(localImage.alt == "a shot")
guard case let .paragraph(remote, _)? = firstBlock("![alt text](https://example.com/x.png)\n"),
case let .image(remoteImage)? = remote.first
else {
Issue.record("expected a remote image")
return
}
// `.absolute` is the whole of "Preview does no networking": nothing downstream is given a
// loader for this case at all it renders as a chip carrying the alt text (05 Preview).
#expect(remoteImage.target == .absolute("https://example.com/x.png"))
#expect(remoteImage.alt == "alt text")
}
@Test("A link keeps both its destination's kind and its own text")
func linkClassification() {
guard case let .paragraph(inlines, _)? = firstBlock("see [the notes](notes.md) and [home](https://example.com)\n")
else {
Issue.record("expected a paragraph")
return
}
let links = inlines.compactMap { inline -> (BodyTarget, String)? in
if case let .link(target, children) = inline { return (target, plainText(children)) }
return nil
}
#expect(links.count == 2)
#expect(links.first?.0 == .relative("notes.md"))
#expect(links.first?.1 == "the notes")
#expect(links.last?.0 == .absolute("https://example.com"))
}
@Test("Resolution is against the card folder, and a rooted path is taken as written")
func resolution() {
let folder = URL(fileURLWithPath: "/tmp/board/lane/card", isDirectory: true)
#expect(
BodyTarget.relative("attachments/shot.png").resolve(inCardFolder: folder)?.path
== "/tmp/board/lane/card/attachments/shot.png"
)
// Percent-encoding is the author's escaping of a filename, not part of the filename.
#expect(
BodyTarget.relative("attachments/my%20shot.png").resolve(inCardFolder: folder)?.path
== "/tmp/board/lane/card/attachments/my shot.png"
)
// A rooted path names itself; gluing it onto the card folder would name a file nobody meant.
#expect(BodyTarget.relative("/etc/hosts").resolve(inCardFolder: folder)?.path == "/etc/hosts")
#expect(BodyTarget.absolute("https://example.com/x").resolve(inCardFolder: folder)?.scheme == "https")
// Nothing to resolve, and nothing to resolve against.
#expect(BodyTarget.relative("").resolve(inCardFolder: folder) == nil)
#expect(BodyTarget.relative("notes.md").resolve(inCardFolder: nil) == nil)
}
}
// MARK: - Task checkboxes
@Suite("Body markup — task checkboxes")
struct BodyTaskTests {
/// Two items, one of each state, with a nested one under the second.
private static let source = """
- [ ] first
- [x] second
- [ ] nested
"""
private func tasks(of blocks: [BodyBlock]) -> [BodyTask] {
blocks.flatMap { block -> [BodyTask] in
switch block {
case let .list(list, _):
list.items.flatMap { item in (item.task.map { [$0] } ?? []) + tasks(of: item.blocks) }
case let .quote(children, _):
tasks(of: children)
default:
[]
}
}
}
@Test("A task item's checkbox state and its source byte both survive the parse")
func markersAreLocated() {
let markup = BodyMarkup.parse(Self.source)
let found = tasks(of: markup.blocks)
#expect(found.map(\.isChecked) == [false, true, false])
// The offsets, counted by hand from the source above the one number in this model that a
// write will aim at, so it is asserted as a number rather than as "somewhere in the line":
// "- [ ] first\n" `[` at 2, marker at 3
// "- [x] second\n" line starts at 12, marker at 15
// " - [ ] nested\n" line starts at 25, marker at 30
#expect(found.map(\.markerOffset) == [3, 15, 30])
// And each one really is the character between the brackets.
let bytes = Array(Self.source.utf8)
for task in found {
guard let offset = task.markerOffset else {
Issue.record("a parsed checkbox with no located marker")
continue
}
#expect(bytes[offset - 1] == UInt8(ascii: "["))
#expect(bytes[offset + 1] == UInt8(ascii: "]"))
#expect(BodyMarkup.markerState(bytes[offset]) == task.isChecked)
}
}
@Test("An ordinary list item has no checkbox, and a literal [x] in prose is not one")
func nonTasksAreNotTasks() {
#expect(tasks(of: BodyMarkup.parse("- plain\n- also plain\n").blocks).isEmpty)
// The scan is line-scoped and starts at a *task item*: prose that happens to contain the
// three characters is prose (`taskMarkerOffset`).
#expect(tasks(of: BodyMarkup.parse("A sentence with a [x] in it.\n").blocks).isEmpty)
}
@Test("A mixed list keeps its plain items plain")
func mixedList() {
guard case let .list(list, _)? = firstBlock("- [ ] a task\n- not a task\n") else {
Issue.record("expected a list")
return
}
#expect(list.items.count == 2)
#expect(list.items.first?.task?.isChecked == false)
#expect(list.items.last?.task == nil)
}
}
// MARK: - The flip
@Suite("Body markup — flipping a task marker")
struct BodyMarkerFlipTests {
private static let body = "- [ ] first\n- [x] second\n"
@Test("A flip changes exactly one byte, in each direction")
func flipsOneByte() {
guard let ticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: false) else {
Issue.record("expected the flip to land")
return
}
#expect(ticked == "- [x] first\n- [x] second\n")
guard let unticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 15, expecting: true) else {
Issue.record("expected the flip to land")
return
}
#expect(unticked == "- [ ] first\n- [ ] second\n")
// The one-byte claim, stated as a byte count rather than inferred from the strings.
let before = Array(Self.body.utf8)
let after = Array(ticked.utf8)
#expect(before.count == after.count)
#expect(zip(before, after).filter { $0 != $1 }.count == 1)
}
@Test("A flip round-trips")
func roundTrips() {
let ticked = BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: false)
let back = ticked.flatMap { BodyMarkup.flippingTaskMarker(in: $0, at: 3, expecting: true) }
#expect(back == Self.body)
}
@Test("An uppercase X reads as checked and unticks to a space")
func uppercaseMarker() {
#expect(BodyMarkup.flippingTaskMarker(in: "- [X] one\n", at: 3, expecting: true) == "- [ ] one\n")
}
@Test("A stale offset, a stale state, or a byte that is not a marker all refuse")
func refusals() {
// The state the user saw no longer matches disk an external edit ticked it first.
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 3, expecting: true) == nil)
// Not between brackets at all.
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 7, expecting: false) == nil)
// Past the end, and before the beginning.
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 9_999, expecting: false) == nil)
#expect(BodyMarkup.flippingTaskMarker(in: Self.body, at: 0, expecting: false) == nil)
}
@Test("Multibyte text before the marker does not move it")
func multibyteIsCountedInBytes() {
// "é" is two UTF-8 bytes and "" is three: an offset counted in Characters would be wrong
// by five here, which is exactly the class of bug that puts an `x` in the middle of a word.
let body = "prosé →\n\n- [ ] task\n"
let markup = BodyMarkup.parse(body)
let task = markup.blocks.compactMap { block -> BodyTask? in
if case let .list(list, _) = block { return list.items.first?.task }
return nil
}.first
guard let offset = task?.markerOffset else {
Issue.record("expected a located marker")
return
}
#expect(Array(body.utf8)[offset] == UInt8(ascii: " "))
#expect(BodyMarkup.flippingTaskMarker(in: body, at: offset, expecting: false) == "prosé →\n\n- [x] task\n")
}
}
// MARK: - Source ranges
@Suite("Body markup — source ranges")
struct BodySpanTests {
@Test("A block's range names the bytes it was parsed from")
func blockRangesSlice() {
let source = "# Title\n\nA paragraph.\n"
let blocks = BodyMarkup.parse(source).blocks
#expect(blocks.count == 2)
#expect(slice(source, span(of: blocks.first)) == "# Title")
#expect(slice(source, span(of: blocks.last)) == "A paragraph.")
}
@Test("Ranges are byte offsets, so multibyte text does not shift them")
func rangesAreBytes() {
let source = "# Café\n\n→ next\n"
let blocks = BodyMarkup.parse(source).blocks
#expect(slice(source, span(of: blocks.first)) == "# Café")
#expect(slice(source, span(of: blocks.last)) == "→ next")
}
}
// MARK: - The empty-body question
@Suite("Body markup — emptiness")
struct BodyEmptinessTests {
@Test("Whitespace is empty; anything else is not")
func emptiness() {
// The input to 05's opening rule: "a card opens in Preview unless its body is empty".
#expect(BodyMarkup.isEmpty(""))
#expect(BodyMarkup.isEmpty("\n"))
#expect(BodyMarkup.isEmpty(" \n\n\t"))
#expect(!BodyMarkup.isEmpty("x"))
#expect(!BodyMarkup.isEmpty("\n# Heading\n"))
}
}
// MARK: - The clickable-run codec
@Suite("Body markup — the checkbox link codec")
@MainActor
struct CardBodyLinkTests {
@Test("A checkbox round-trips through its URL, in both states")
func taskURLsRoundTrip() {
for (offset, checked) in [(0, false), (3, true), (12_345, false)] {
guard let url = CardBodyLink.task(offset: offset, isChecked: checked) else {
Issue.record("expected a URL for \(offset)/\(checked)")
continue
}
let parsed = CardBodyLink.parseTask(url)
#expect(parsed?.offset == offset)
#expect(parsed?.isChecked == checked)
}
}
@Test("An ordinary link is not a checkbox")
func ordinaryLinksAreNotTasks() {
// How the delegate tells the two apart a body link must never be mistaken for a write.
#expect(CardBodyLink.parseTask(URL(string: "https://example.com")!) == nil)
#expect(CardBodyLink.parseTask(URL(fileURLWithPath: "/tmp/x.md")) == nil)
}
}
+62
View File
@@ -189,6 +189,68 @@ struct CardWindowMetricsTests {
} }
} }
// MARK: - The body column's mode
/// 05-card-window.md Mode grammar, as the two rules a unit test can hold: **Preview is the resting
/// state unless the body is empty**, and **the flip is one flip** whichever key produced it.
///
/// Both are silent failures of exactly the kind this file exists for. A card that opened in Preview
/// with an empty body would look like a working window showing nothing, and the user would have to
/// discover E to write the first word of a card they just made which is the ceremony the rule was
/// settled to remove ("a new card has nothing to preview, so during creation flows title body
/// without a mode stop").
@MainActor
@Suite("Card body mode")
struct CardBodyModeTests {
@Test("A card with a body opens in Preview")
func aCardWithABodyOpensInPreview() {
#expect(CardBodyMode.opening(body: "# Notes\n") == .preview)
#expect(CardBodyMode.opening(body: "x") == .preview)
}
@Test("An empty body opens straight into Edit — whitespace included")
func anEmptyBodyOpensInEdit() {
#expect(CardBodyMode.opening(body: "") == .edit)
#expect(CardBodyMode.opening(body: "\n") == .edit)
// A card the app itself just minted has exactly this body: `BoardWriter.newDocumentText`
// writes frontmatter and nothing after the closing delimiter.
#expect(CardBodyMode.opening(body: " \n\t\n") == .edit)
}
@Test("⌘E, Return in Preview and Escape in Edit are one flip")
func theToggleIsSymmetric() {
#expect(CardBodyMode.preview.toggled == .edit)
#expect(CardBodyMode.edit.toggled == .preview)
#expect(CardBodyMode.preview.toggled.toggled == .preview)
}
@Test("The opening rule runs once, not on every snapshot")
func theOpeningRuleIsAppliedOnce() {
let presentation = CardBodyPresentation()
#expect(presentation.openIfNeeded(body: "# Notes\n") == .preview)
// The user presses E
presentation.toggleMode()
#expect(presentation.mode == .edit)
// and a watcher reload arrives with the same body. Re-deciding here would throw the user
// out of the surface they just asked for.
#expect(presentation.openIfNeeded(body: "# Notes\n") == .edit)
#expect(presentation.mode == .edit)
}
@Test("A window that opened into Edit is not dragged back to Preview when the body fills up")
func aLaterBodyDoesNotReopenTheRule() {
let presentation = CardBodyPresentation()
#expect(presentation.openIfNeeded(body: "") == .edit)
// The user types; the reload brings the text back. The rule is about *opening* a card, and
// this window is already open.
#expect(presentation.openIfNeeded(body: "first words") == .edit)
}
}
// MARK: - Identity and frames // MARK: - Identity and frames
@MainActor @MainActor
+346
View File
@@ -0,0 +1,346 @@
import Foundation
import Testing
@testable import Kanban
/// The card window's **one write into a body**: a Preview task-list checkbox being ticked
/// (05-card-window.md Preview "clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker
/// in the source a single-character textual edit; every other byte of the body is untouched").
///
/// Every other write in the app edits *frontmatter* by line span, and the round-trip guarantee falls
/// out of never re-serializing the body at all. This one edits the body, which means the guarantee
/// has to be earned rather than inherited so the assertions here are byte comparisons of the body
/// against a literal expectation, not "the checkbox reads as ticked afterwards".
///
/// Like the rest of the write suites this drives real files in a temp board and reads back **raw
/// bytes**, never a snapshot: the claim is about what is on disk. `WriterFixture`, `Ident` and
/// `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixture
/// A body with one of each checkbox state, a nested one, and deliberately a literal `[x]` in
/// prose that no flip may ever touch.
private let checklistBody = """
# Tasks
- [ ] first
- [x] second
- [ ] nested
Trailing prose with a [x] literal.
"""
/// The card, with everything a write must leave alone around the body: an unknown key carrying an
/// inline comment, a `created` from before today, and a foreign `modified-by`.
private let checklistCard = """
---
schema: 1
title: Checklist
order: 1024
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
\(checklistBody)
"""
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
private let siblingPath = "\(Ident.lane1)/\(Ident.card2)"
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(cardPath, checklistCard)
try fixture.item(siblingPath, Item.rich(order: "2048", title: "Untouched"))
return fixture
}
/// The card's body as it is on disk right now split off at the closing delimiter by the same
/// parser the writer used, so "the body" means the same thing in the test as in the app.
private func body(of fixture: WriterFixture, _ relativePath: String) throws -> String {
try FrontmatterDocument.parse(fixture.indexText(relativePath)).body
}
/// The file's frontmatter lines, minus the two the stamp owns what has to be identical, comment
/// and key order included.
private func frontmatterLines(_ text: String) -> [String] {
let lines = text.components(separatedBy: "\n")
guard let closing = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "---" })
else { return lines }
return lines[0 ..< closing].filter { !$0.hasPrefix("modified:") && !$0.hasPrefix("modified-by:") }
}
/// The marker offsets the *parse* produces for a body the app's own path from a rendered checkbox
/// to the byte a click will flip, used here rather than hand-counted numbers so the two halves of
/// the feature are tested joined up.
private func markerOffsets(in body: String) -> [Int] {
func walk(_ blocks: [BodyBlock]) -> [Int] {
blocks.flatMap { block -> [Int] in
switch block {
case let .list(list, _):
list.items.flatMap { ($0.task?.markerOffset.map { [$0] } ?? []) + walk($0.blocks) }
case let .quote(children, _):
walk(children)
default:
[]
}
}
}
return walk(BodyMarkup.parse(body).blocks)
}
private func modifiedDate(_ fixture: WriterFixture, _ relativePath: String) throws -> Date? {
try FrontmatterDocument.parse(fixture.indexText(relativePath)).modified.value
}
// MARK: - The flip
@MainActor
@Suite("BoardWriter ▸ toggleTaskMarker")
struct ToggleTaskMarkerTests {
@Test("Ticking a box changes exactly that byte of the body, and nothing else in the file")
func aFlipIsOneByte() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexText(cardPath)
let offsets = markerOffsets(in: checklistBody)
#expect(offsets.count == 3, "the fixture's three checkboxes should all be located")
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
// The body, byte for byte, against a literal: the heading, the blank lines, the nested item,
// the trailing prose's literal `[x]` and the trailing newline are all still exactly there.
#expect(try body(of: fixture, cardPath) == """
# Tasks
- [x] first
- [x] second
- [ ] nested
Trailing prose with a [x] literal.
""")
// And the frontmatter is untouched but for the two keys every app write owns: key order,
// the unknown `project` key, its inline comment and `created` all survive.
let after = try fixture.indexText(cardPath)
#expect(frontmatterLines(after) == frontmatterLines(before))
#expect(after.contains("project: lanework # agent overlay"))
#expect(after.contains("created: 2026-01-01T09:00:00Z"))
}
@Test("The write stamps modified and clears a foreign modified-by")
func theWriteStamps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let offsets = markerOffsets(in: checklistBody)
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[1], checked: true)
// A toggle is "an ordinary user edit the standard atomic write" (05), so it carries the
// same stamps every other app write does (01-storage-format.md § Frontmatter).
let stamped = try #require(try modifiedDate(fixture, cardPath))
#expect(stamped.timeIntervalSinceNow > -30)
#expect(!(try fixture.indexText(cardPath).contains("modified-by")))
}
@Test("Flipping twice restores the file's body byte for byte")
func aFlipRoundTrips() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let original = try body(of: fixture, cardPath)
let offsets = markerOffsets(in: checklistBody)
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
#expect(try body(of: fixture, cardPath) != original)
// The second flip reads the file fresh and finds the marker where the first one left it
// the offset is stable because the edit changed a byte's *value*, never the body's length.
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: true)
#expect(try body(of: fixture, cardPath) == original)
#expect(try Data(body(of: fixture, cardPath).utf8) == Data(original.utf8))
}
@Test("A nested checkbox flips, and its parent does not")
func nestedMarkersAreTheirOwn() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let offsets = markerOffsets(in: checklistBody)
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[2], checked: false)
#expect(try body(of: fixture, cardPath) == """
# Tasks
- [ ] first
- [x] second
- [x] nested
Trailing prose with a [x] literal.
""")
}
@Test("No other file is opened, let alone rewritten")
func siblingsAreUntouched() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let siblingURL = fixture.url(siblingPath).appendingPathComponent("index.md")
let siblingBefore = try Data(contentsOf: siblingURL)
let siblingModified = try FileManager.default.attributesOfItem(atPath: siblingURL.path)[.modificationDate] as? Date
let laneBefore = try fixture.indexData(Ident.lane1)
let offsets = markerOffsets(in: checklistBody)
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
#expect(try Data(contentsOf: siblingURL) == siblingBefore)
// The filesystem's own "did anything happen here" signal, not just the bytes.
let siblingAfter = try FileManager.default.attributesOfItem(atPath: siblingURL.path)[.modificationDate] as? Date
#expect(siblingAfter == siblingModified)
#expect(try fixture.indexData(Ident.lane1) == laneBefore)
}
@Test("A successful flip leaves no temp file behind")
func noResidue() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let offsets = markerOffsets(in: checklistBody)
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
// Hidden entries included the writer's temps are dot-prefixed, so only a listing that
// sees them can prove there is none.
#expect(try fixture.entryNames(cardPath) == ["index.md"])
}
}
// MARK: - Refusals
@MainActor
@Suite("BoardWriter ▸ toggleTaskMarker refusals")
struct ToggleTaskMarkerRefusalTests {
@Test("A state the file no longer agrees with refuses, and writes nothing")
func aStaleStateRefuses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexData(cardPath)
let offsets = markerOffsets(in: checklistBody)
// The user saw an unticked box; disk says it is ticked. Flipping would undo somebody else's
// edit instead of performing this one, so it refuses (`toggleTaskMarker`'s re-verification).
let error = writeFailure {
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[1], checked: false)
}
#expect(error?.operation == .toggleTask(title: "Checklist"))
if case .staleTarget = error?.reason {} else {
Issue.record("expected a staleTarget refusal, got \(String(describing: error?.reason))")
}
#expect(try fixture.indexData(cardPath) == before, "a refused write is a write that did not happen")
}
@Test("An offset that is not a checkbox refuses")
func aStaleOffsetRefuses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexData(cardPath)
for offset in [0, 7, 999_999] {
let error = writeFailure {
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offset, checked: false)
}
if case .staleTarget = error?.reason {} else {
Issue.record("expected a staleTarget refusal at \(offset), got \(String(describing: error?.reason))")
}
}
#expect(try fixture.indexData(cardPath) == before)
}
@Test("Frontmatter that cannot be edited in place refuses before the body is touched")
func uneditableFrontmatterRefuses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// A whole-frontmatter flow mapping: readable, renderable, and unwritable the settled
// readable-but-uneditable rule, which a body edit is no exemption from, because the write
// still has to stamp `modified` through the span editor.
let path = "\(Ident.lane1)/\(Ident.card3)"
try fixture.item(path, "---\n{schema: 1, order: 3072}\n---\n- [ ] task\n")
let before = try fixture.indexData(path)
let error = writeFailure {
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(path), bodyOffset: 3, checked: false)
}
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(try fixture.indexData(path) == before)
}
@Test("A folder that is not a lane or a card refuses")
func strayFoldersRefuse() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// The board root is the case that matters: a board's body is its description, no surface
// previews it, and the shape guard is what makes this call structurally unable to reach it.
let error = writeFailure {
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.root, bodyOffset: 3, checked: false)
}
if case .unreadable = error?.reason {} else {
Issue.record("expected an unreadable refusal, got \(String(describing: error?.reason))")
}
}
}
// MARK: - Through the store
@MainActor
@Suite("BoardStore ▸ toggleTaskMarker")
struct StoreToggleTaskMarkerTests {
@Test("A click through the store lands on disk")
func theStoreWritesThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let offsets = markerOffsets(in: checklistBody)
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false)
// Read back through the loader, never through the store's snapshot: the one-way flow means
// the snapshot only catches up when the watcher's reload lands (02-architecture.md).
#expect(try body(of: fixture, cardPath).contains("- [x] first"))
}
@Test("A card that is not in the snapshot is not written to")
func aVanishedCardWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(cardPath)
// An id the board does not hold the vanished-target guard every gesture in the store
// makes, here standing in for a card window whose card left under the click.
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card4), bodyOffset: 3, checked: false)
#expect(try fixture.indexData(cardPath) == before)
}
@Test("A tombstoned card's checkbox does not write")
func aTombstonedCardWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteItem(at: fixture.url(cardPath))
let store = try BoardStore(rootURL: fixture.root)
let before = try body(of: fixture, cardPath)
let offsets = markerOffsets(in: checklistBody)
// Effective liveness, ancestor-walked (`BoardStore.liveItem`): a card in the trash renders
// nowhere, so nothing may write through a preview of it.
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false)
#expect(try body(of: fixture, cardPath) == before)
}
}
+9
View File
@@ -10,6 +10,13 @@ packages:
Yams: Yams:
url: https://github.com/jpsim/Yams.git url: https://github.com/jpsim/Yams.git
from: 6.0.0 from: 6.0.0
# apple/swift-markdown — the card window's Preview parser (05-card-window.md ▸ Preview).
# Pinned to the next *minor* rather than the next major because the package is pre-1.0: a
# `from:` range here would silently accept 0.9's breaking changes, which is not what `from:`
# means for Yams at 6.x.
swift-markdown:
url: https://github.com/apple/swift-markdown.git
minorVersion: 0.8.0
settings: settings:
base: base:
@@ -26,6 +33,8 @@ targets:
- Kanban - Kanban
dependencies: dependencies:
- package: Yams - package: Yams
- package: swift-markdown
product: Markdown
postBuildScripts: postBuildScripts:
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
name: Update Build Info name: Update Build Info