From 6dc84176fb53a47bd5c22a8c1cd147d1d3b73496 Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 09:59:18 -0400 Subject: [PATCH] Build Preview mode rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/CardWindowHost.swift | 34 +- Kanban/LiveStore/BannerCenter.swift | 7 + Kanban/LiveStore/BoardStore.swift | 30 ++ Kanban/Storage/BoardWriter.swift | 84 ++++ Kanban/Storage/BodyMarkup.swift | 556 ++++++++++++++++++++++ Kanban/UI/Board/BoardSearchField.swift | 16 +- Kanban/UI/Card/BodyMarkupRenderer.swift | 570 +++++++++++++++++++++++ Kanban/UI/Card/CardBodyMode.swift | 98 ++++ Kanban/UI/Card/CardBodySurface.swift | 196 ++++++++ Kanban/UI/Card/CardWindowMetrics.swift | 26 ++ Kanban/UI/Card/CardWindowView.swift | 75 ++- KanbanTests/BodyMarkupTests.swift | 517 ++++++++++++++++++++ KanbanTests/CardWindowShellTests.swift | 62 +++ KanbanTests/TaskCheckboxWriteTests.swift | 346 ++++++++++++++ project.yml | 9 + 15 files changed, 2608 insertions(+), 18 deletions(-) create mode 100644 Kanban/Storage/BodyMarkup.swift create mode 100644 Kanban/UI/Card/BodyMarkupRenderer.swift create mode 100644 Kanban/UI/Card/CardBodyMode.swift create mode 100644 Kanban/UI/Card/CardBodySurface.swift create mode 100644 KanbanTests/BodyMarkupTests.swift create mode 100644 KanbanTests/TaskCheckboxWriteTests.swift diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index dc8462d..0de5444 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -89,6 +89,9 @@ struct CardWindowHost: View { @State private var windowController = HostedWindowController() @State private var session = CardWindowSession() @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 { case opening @@ -149,6 +152,10 @@ struct CardWindowHost: View { // rename retitles the window and a lane move re-subtitles it with no notification of // our own (05-card-window.md ▸ Window). .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() } .onChange(of: shouldDismiss, initial: true) { _, dismisses in guard dismisses else { return } @@ -163,14 +170,37 @@ struct CardWindowHost: View { @ViewBuilder private var content: some View { - if let placement { - CardWindowView(card: placement.card) + if case let .open(store) = phase, let placement { + 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 { // Nothing to render and nothing worth animating: this window is on its way out. Color.clear } } + /// `//` — 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 /// same condition `shouldDismiss` reads, one moment before the window goes. private var placement: CardPlacement? { diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index fe244b6..4c40116 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -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 // about. "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))" case let .io(message): message + case let .staleTarget(message): + message } return trimmed(text) } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 1bf506b..8691d5e 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1127,6 +1127,36 @@ public final class BoardStore { 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 /// Writes the board's own `title` — the board popover's rename field (03-board-ui.md § Board diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 4d97fa2..601f77f 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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 /// 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. 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 /// 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 @@ -1520,6 +1590,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .resize: .resize(title: title) case .rename: .rename(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 .renumberChildren: "renumber children" 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. 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 { switch self { case let .unreadable(message): @@ -1600,6 +1682,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib "frontmatter cannot be edited in place: \(shape.description)" case let .io(message): message + case let .staleTarget(message): + message } } } diff --git a/Kanban/Storage/BodyMarkup.swift b/Kanban/Storage/BodyMarkup.swift new file mode 100644 index 0000000..8d11113 --- /dev/null +++ b/Kanban/Storage/BodyMarkup.swift @@ -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.** `x` 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..` 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) + } +} diff --git a/Kanban/UI/Board/BoardSearchField.swift b/Kanban/UI/Board/BoardSearchField.swift index 43aeca9..618f2a2 100644 --- a/Kanban/UI/Board/BoardSearchField.swift +++ b/Kanban/UI/Board/BoardSearchField.swift @@ -83,13 +83,25 @@ struct FindCommand: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardSearch) private var search + @FocusedValue(\.cardBody) private var cardBody + + /// **The card window wins when it is the focused scene**, which is the whole of 11's split + /// ("Board window: board search; card window: find-in-text"): the two never both publish, so + /// this reads as a preference only because a scene value is `nil` in the scene that does not + /// have one. The card side is the standard find bar over its body surface + /// (`CardBodyPresentation.findInText`); the board side focuses the search field. + private var findInText: (() -> Void)? { cardBody?.findInText } var body: some View { Button("Find") { - search?.focusField?() + if let findInText { + findInText() + } else { + search?.focusField?() + } } .keyboardShortcut("f", modifiers: .command) - .disabled(store == nil || search?.focusField == nil) + .disabled(findInText == nil && (store == nil || search?.focusField == nil)) } } diff --git a/Kanban/UI/Card/BodyMarkupRenderer.swift b/Kanban/UI/Card/BodyMarkupRenderer.swift new file mode 100644 index 0000000..d9947c6 --- /dev/null +++ b/Kanban/UI/Card/BodyMarkupRenderer.swift @@ -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:/<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: `
` 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 + } +} diff --git a/Kanban/UI/Card/CardBodyMode.swift b/Kanban/UI/Card/CardBodyMode.swift new file mode 100644 index 0000000..636af2d --- /dev/null +++ b/Kanban/UI/Card/CardBodyMode.swift @@ -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 } + } +} diff --git a/Kanban/UI/Card/CardBodySurface.swift b/Kanban/UI/Card/CardBodySurface.swift new file mode 100644 index 0000000..21c38d8 --- /dev/null +++ b/Kanban/UI/Card/CardBodySurface.swift @@ -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 + } + } + } +} diff --git a/Kanban/UI/Card/CardWindowMetrics.swift b/Kanban/UI/Card/CardWindowMetrics.swift index 7b6490f..e9231e6 100644 --- a/Kanban/UI/Card/CardWindowMetrics.swift +++ b/Kanban/UI/Card/CardWindowMetrics.swift @@ -79,6 +79,32 @@ enum CardWindowMetrics { columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize) } + // MARK: - The rendered body + + /// One step of structural indent in Preview — a list level, a quote level. One and a half ems, + /// which is wide enough for a bullet plus its space and narrow enough that four nested levels + /// still leave a measure worth reading. + static func previewIndent(bodyPointSize: CGFloat) -> CGFloat { + (bodyPointSize * 1.5).rounded() + } + + /// The inside padding of a table cell and the inset of a code block — half a gutter, so the + /// rendered body's rhythm is the column's rhythm halved rather than a second, unrelated one. + static func previewPadding(bodyPointSize: CGFloat) -> CGFloat { + (gutter(bodyPointSize: bodyPointSize) / 2).rounded() + } + + /// The widest an inline image is drawn at. + /// + /// A number rather than "the text container's width" on purpose: a `NSTextAttachment`'s bounds + /// are fixed at build time, so an image sized to the window would have to be rebuilt on every + /// resize — and the body column is resizable by contract. A generous cap keeps a screenshot + /// legible without letting a 4000-pixel-wide one push the measure around, and an image narrower + /// than the cap is never enlarged. + static func previewImageMaximumWidth(bodyPointSize: CGFloat) -> CGFloat { + columnWidth(characters: 56, bodyPointSize: bodyPointSize) + } + // MARK: - The window /// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index a7d40e2..ce062fe 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -13,7 +13,7 @@ import SwiftUI /// where it lands: /// /// - the title as an editable field (commit on Return / focus loss, Escape abandons), -/// - the body's Preview/Edit pairing and the raw-source outlet, +/// - the raw-source outlet, /// - the sidebar's five sections, which are section *headers* here and nothing more. /// /// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are @@ -25,9 +25,28 @@ import SwiftUI /// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That /// is the whole of "the window's resize flex goes to the body" — no split view, no stored divider /// position, nothing for a drag to disagree with. +/// +/// ### Why the title no longer scrolls with the body +/// +/// The body surface is a hosted `NSScrollView` (`CardBodySurface`), because ⌘F's find bar lives in +/// one — "Edit ▸ Find (⌘F) is find-in-text here … the standard find bar" (05 ▸ Preview). A scroll +/// view inside a scroll view is a scroll view that fights, so the column's header — the title and +/// its created/modified line — sits above the body's scroller rather than inside it. 05 fixes the +/// column's *order* ("Body column, top to bottom") and the columns' independent scrolling, and both +/// still hold; which of the two things scrolls the title away was never settled, and pinning the +/// card's name over its own body is the better reading of a window whose subtitle already follows it. struct CardWindowView: View { let card: Card + /// The card's folder on disk — what relative images and links in the body resolve against + /// (05 ▸ Preview). `nil` only where a caller has no board root to build it from. + let cardFolder: URL? + /// This window's body-column state: which mode it is in, and the find-bar hook. + let bodyPresentation: CardBodyPresentation + /// Whether a checkbox may write — `false` under the board's read-only lock. + let isEditable: Bool + /// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw. + let onToggleTask: (Int, Bool) -> Void /// The body font's point size, read once per body evaluation: every measurement in this view — /// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale @@ -53,8 +72,8 @@ struct CardWindowView: View { /// Title, the quiet created/modified line, then the body — 05's top-to-bottom order. private var bodyColumn: some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: bodyPointSize * 0.75) { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: bodyPointSize * 0.5) { // m6-card-body: the title *field* — large and borderless, committing to frontmatter // on Return or focus loss, clearing to remove the `title` key, Escape abandoning to // the on-disk title. Read-only here; the placeholder rendering is already final. @@ -71,20 +90,48 @@ struct CardWindowView: View { .foregroundStyle(.secondary) .textSelection(.enabled) } - - // m6-card-body: Preview/Edit proper — a rendered preview with live task-list - // checkboxes, and a syntax-highlighted raw editor behind ⌘E. Plain selectable text - // until then: honest about being unrendered rather than half-rendering Markdown. - if !card.body.isEmpty { - Text(card.body) - .font(.body) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) + .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) + .padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) + + if bodyPresentation.mode == .edit { + editPlaceholderNotice + } + + CardBodySurface( + body: card.body, + mode: bodyPresentation.mode, + cardFolder: cardFolder, + presentation: bodyPresentation, + isTaskToggleEnabled: isEditable, + onToggleTask: onToggleTask + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + // **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its + // body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it + // "once" — a later reload that empties the file must not drag a reader into Edit. + .task { bodyPresentation.openIfNeeded(body: card.body) } + } + + /// The Edit mode's honest placeholder. + /// + /// **The mode is real; the editor is not.** 05's opening rule is not a rendering detail that can + /// wait — it decides which surface a brand-new card lands on — so this milestone implements the + /// *state* (`CardBodyMode`, the opening rule, the toggle) and leaves the editor itself to the + /// Edit card. What shows meanwhile is the raw Markdown, monospaced and read-only, over a line + /// that says so: a text view that looked editable and silently discarded keystrokes would be a + /// worse lie than an empty pane, and one that saved would be this milestone building the thing + /// it deliberately is not building. + private var editPlaceholderNotice: some View { + Text("Body editing arrives with the Edit surface — this is the raw Markdown, read-only.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) + .padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: bodyPointSize)) + .background(.background.secondary) } /// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent** diff --git a/KanbanTests/BodyMarkupTests.swift b/KanbanTests/BodyMarkupTests.swift new file mode 100644 index 0000000..4d4cd91 --- /dev/null +++ b/KanbanTests/BodyMarkupTests.swift @@ -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("
\n bold\n
\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 `` the app is about to honour (05 ▸ Preview; 00-vision's + // no-web-tech stance). + #expect(raw.contains("
")) + #expect(raw.contains("bold")) + } + + @Test("Inline HTML is an inline of its own, never a rendered tag") + func inlineHTMLIsVerbatim() { + guard case let .paragraph(inlines, _)? = firstBlock("before x 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 == ["", ""]) + // 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 x 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:a@b.c") == .absolute("mailto:a@b.c")) + #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) + } +} diff --git a/KanbanTests/CardWindowShellTests.swift b/KanbanTests/CardWindowShellTests.swift index 7bdfaa2..a14b113 100644 --- a/KanbanTests/CardWindowShellTests.swift +++ b/KanbanTests/CardWindowShellTests.swift @@ -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 @MainActor diff --git a/KanbanTests/TaskCheckboxWriteTests.swift b/KanbanTests/TaskCheckboxWriteTests.swift new file mode 100644 index 0000000..61742e4 --- /dev/null +++ b/KanbanTests/TaskCheckboxWriteTests.swift @@ -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) + } +} diff --git a/project.yml b/project.yml index c3aea20..1e1a8da 100644 --- a/project.yml +++ b/project.yml @@ -10,6 +10,13 @@ packages: Yams: url: https://github.com/jpsim/Yams.git 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: base: @@ -26,6 +33,8 @@ targets: - Kanban dependencies: - package: Yams + - package: swift-markdown + product: Markdown postBuildScripts: - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' name: Update Build Info