Build Edit mode with debounced, byte-honest saves
The editing surface: the same hosted TextKit-1 text view gains an editable branch with a per-keystroke line-scanner highlighter — chosen over a parser re-parse because a mid-typing buffer is usually invalid Markdown and 05 wants the delimiters themselves dimmed; apply only sets attributes, so presentation-never-transforms is structural. Saves ride a ~700ms injectable debounce through BoardWriter.writeBody — toggleTaskMarker's idiom widened to the body span, frontmatter bytes untouched, refusing to write when disk already holds that body, which enforces all three gates (untouched, reverted, echo) at the layer that owns the bytes with one isDirty predicate above it. Mode grammar lands whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape returns, and every flip flushes first; window close flushes through the existing retry/save-copy/discard modal, and the dismissal flush deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always follows the snapshot, the buffer only when clean, both surfaces render the buffer. Undo is the editor's own session-scoped NSUndoManager; endEditSession names the pro-m1 one-commit-per-session boundary. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -680,8 +680,13 @@ public final class AppModel {
|
||||
storeFlush: { [weak self] in
|
||||
await self?.sessions[ref]?.store.awaitQuiescence()
|
||||
},
|
||||
// editorFlush / committerFlush stay nil until m6 and m7 have something to flush; the
|
||||
// slots exist so their order is already decided when they do.
|
||||
// `editorFlush` stays nil, and now deliberately rather than for want of an editor: the
|
||||
// card windows' debounced body saves flush in **step 1**, inside each window's
|
||||
// `endSession()` (`CardWindowSession`), which is both earlier than this slot and where
|
||||
// 02-architecture.md puts them ("each open Edit session ends with its normal session
|
||||
// commit", then pending work). The slot stays for a board-level editor with no card
|
||||
// window of its own — the raw-source buffer is the candidate — so that the order
|
||||
// relative to `committerFlush` (m7) is already decided when one arrives.
|
||||
recordClose: { [weak self] in
|
||||
guard let self, let session = sessions[ref] else { return }
|
||||
let counts = Self.liveCounts(of: session.store.snapshot)
|
||||
|
||||
+134
-12
@@ -29,27 +29,67 @@ public enum CardWindowFate: Equatable {
|
||||
|
||||
// MARK: - The session seam
|
||||
|
||||
/// A card window's editor session — still a no-op stand-in for the thing 05-card-window.md will
|
||||
/// build, and deliberately so: **the shell has no Edit session to flush yet.**
|
||||
/// A card window's editor session: the thing the close flush ends, and now the thing it ends *with
|
||||
/// something in it* — the window's Edit buffer (05-card-window.md ▸ Edit).
|
||||
///
|
||||
/// It exists so the close flush has something real to call and something real to be *ordered against*
|
||||
/// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending
|
||||
/// twice does nothing the second time, which matters because two paths legitimately end a session —
|
||||
/// the board's close flush drives it for every card window, and a card window closed on its own runs
|
||||
/// it from its disappear.
|
||||
/// The ordering it exists for is unchanged (see `CardSessionFlushing`): the board's close flush
|
||||
/// drives `endSession()` for every card window before any of the board's own pending work is
|
||||
/// flushed, and a card window closed on its own runs it from its disappear. Ending twice does
|
||||
/// nothing the second time, which is what makes those two paths safe to both exist.
|
||||
///
|
||||
/// **What ending means now**: the Edit buffer's debounce is cancelled and its text written — the
|
||||
/// "window close" third of 05's flush rule, and on a dismissal caused by a tombstone the surgical
|
||||
/// body write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the tombstoned
|
||||
/// card's folder before the window dismisses ... so the keystrokes survive Put Back").
|
||||
/// `BoardStore.writeCardBody` resolves tombstoned cards on purpose for exactly this.
|
||||
///
|
||||
/// A *failing* close flush is not this object's problem to solve: it is `DirtyBufferGuard`'s modal
|
||||
/// moment, which the host runs earlier, on `windowShouldClose`, while there is still a window to
|
||||
/// present over. By the time this runs on a window that is genuinely going away, the honest thing
|
||||
/// left to do is try.
|
||||
@MainActor
|
||||
final class CardWindowSession: CardSessionFlushing {
|
||||
|
||||
/// The window's Edit buffer. Created with the window and handed its save target once the window
|
||||
/// has joined its board (`CardWindowHost.start()`); a session with no target keeps its text
|
||||
/// rather than pretending to have written it.
|
||||
let body: CardBodyEditSession
|
||||
|
||||
/// The close-time save-or-lose moment, over this window's buffer (02-architecture.md §
|
||||
/// Write-failure surfacing: "the one modal moment on the write-failure path").
|
||||
let bufferGuard: DirtyBufferGuard
|
||||
|
||||
private var hasEnded = false
|
||||
|
||||
/// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
|
||||
/// and a stored property cannot be referenced from another's initializer.
|
||||
///
|
||||
/// (They are also `let` rather than `lazy var` for a SwiftUI reason worth recording: `@State`
|
||||
/// projects a `Binding` through dynamic member lookup for every *settable* property of its
|
||||
/// value, so a `lazy var` here would make `session.bufferGuard` at the call site resolve to a
|
||||
/// binding rather than to the guard.)
|
||||
init() {
|
||||
let body = CardBodyEditSession()
|
||||
self.body = body
|
||||
bufferGuard = DirtyBufferGuard(
|
||||
attemptSave: { () throws(BoardWriteError) -> Void in try body.flushOrThrow() },
|
||||
// "Save a copy elsewhere" writes the *buffer*, not the card: the destination is
|
||||
// somewhere outside the board the user picked in a panel, so what lands there is the
|
||||
// text they were typing, as a file, and nothing about frontmatter or identity travels
|
||||
// with it.
|
||||
writeCopy: { url in try Data(body.text.utf8).write(to: url) }
|
||||
)
|
||||
}
|
||||
|
||||
func endSession() async {
|
||||
guard !hasEnded else { return }
|
||||
hasEnded = true
|
||||
// m6-card-body: commit the open Edit session here (06-history-undo.md's session
|
||||
// granularity), flushing the debounced body save first — and, on a dismissal caused by a
|
||||
// tombstone, the surgical body write 05 ▸ Deletion & lifecycle promises ("dismissal never
|
||||
// eats typed work silently where a save can land"). Nothing exists to flush until the
|
||||
// editor does; the hook's *position* in the sequence is what this milestone pins.
|
||||
// pro-m1: this is the boundary the auto-committer coalesces on — one commit per Edit
|
||||
// session, "never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). The debounced
|
||||
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
|
||||
// the committer's own debounce outlives them and this call is where the session is known to
|
||||
// be over.
|
||||
body.endEditSession()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +132,9 @@ struct CardWindowHost: View {
|
||||
/// 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()
|
||||
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
|
||||
/// flush; cleared by the resolution that lets the close resume.
|
||||
@State private var isClosePending = false
|
||||
|
||||
private enum Phase {
|
||||
case opening
|
||||
@@ -156,11 +199,23 @@ struct CardWindowHost: View {
|
||||
// item reaches the frontmost one's body surface through this, exactly as board-window
|
||||
// items reach their window's store (`FocusedBoardStoreKey`).
|
||||
.focusedSceneValue(\.cardBody, bodyPresentation)
|
||||
// The one modal moment (02-architecture.md § Write-failure surfacing), presented over
|
||||
// the window whose close it is holding up — which is why it hangs here and not on the
|
||||
// board: the text being saved is this window's.
|
||||
.dirtyBufferAlert(session.bufferGuard) { Self.copyDestination(named: windowTitle) }
|
||||
.task { start() }
|
||||
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
|
||||
guard dismisses else { return }
|
||||
dismissWindow(id: WindowID.card, value: ref)
|
||||
}
|
||||
// The modal resolved — by a retry that landed, a copy saved elsewhere, or a knowing
|
||||
// discard — so the close it was holding may finish. `DirtyBufferGuard` has no fourth
|
||||
// "leave it open" branch by design, so reaching `.idle` always means the close resumes.
|
||||
.onChange(of: session.bufferGuard.phase) { _, phase in
|
||||
guard isClosePending, phase == .idle else { return }
|
||||
isClosePending = false
|
||||
windowController.closeAfterFlush()
|
||||
}
|
||||
.onDisappear { finish() }
|
||||
}
|
||||
|
||||
@@ -175,6 +230,7 @@ struct CardWindowHost: View {
|
||||
card: placement.card,
|
||||
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
||||
bodyPresentation: bodyPresentation,
|
||||
bodySession: session.body,
|
||||
// "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.
|
||||
@@ -267,9 +323,31 @@ struct CardWindowHost: View {
|
||||
|
||||
appModel.registerCardWindow(ref, session: session)
|
||||
phase = .open(store)
|
||||
configureSession(store: store)
|
||||
configureWindow()
|
||||
}
|
||||
|
||||
/// Points this window's Edit buffer at its card, and the mode flip at the buffer.
|
||||
///
|
||||
/// Both are seams the two types deliberately leave open (`CardBodyEditSession.save`,
|
||||
/// `CardBodyPresentation.flushEdits`) so that neither the buffer nor the mode has to know what a
|
||||
/// board is — this is the one place that knows both, which is also the only place that could
|
||||
/// wire them wrongly, and it is four lines long.
|
||||
///
|
||||
/// The store is captured **weakly**: it outlives this window by refcount, not by ownership, and a
|
||||
/// debounced save that fired after the board had gone should write nothing rather than resurrect
|
||||
/// a store the registry has released.
|
||||
private func configureSession(store: BoardStore) {
|
||||
let cardID = ref.cardIdentity
|
||||
session.body.save = { [weak store] text in
|
||||
guard let store else { return .vanished }
|
||||
return store.writeCardBody(inCard: cardID, body: text)
|
||||
}
|
||||
bodyPresentation.flushEdits = { [session] in
|
||||
session.body.endEditSession()
|
||||
}
|
||||
}
|
||||
|
||||
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
|
||||
/// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
|
||||
/// card across relaunch where state restoration allows").
|
||||
@@ -314,6 +392,14 @@ struct CardWindowHost: View {
|
||||
windowController.onAttach?(window)
|
||||
}
|
||||
|
||||
// **The close flushes first** (05-card-window.md ▸ Edit: "flushed on leaving Edit, entering
|
||||
// source mode, and window close"). Intercepting `windowShouldClose` rather than saving from
|
||||
// `onDisappear` is what makes the failure case possible at all: by the time a window has
|
||||
// disappeared there is nothing left to present a modal over, and the design's one modal
|
||||
// moment is precisely a close that could not save (02-architecture.md § Write-failure
|
||||
// surfacing).
|
||||
windowController.onCloseRequested = { closeAfterFlushing() }
|
||||
|
||||
windowController.onFrameChanged = { frame in
|
||||
if let recordID {
|
||||
appModel.boardRegistry.updateCardWindowFrame(
|
||||
@@ -331,6 +417,42 @@ struct CardWindowHost: View {
|
||||
|
||||
// MARK: - Closing
|
||||
|
||||
/// The close, held open exactly as long as the buffer needs.
|
||||
///
|
||||
/// A clean buffer closes immediately — which is every window that was only read, and every
|
||||
/// window whose last keystroke was more than the debounce ago. A dirty one is flushed through
|
||||
/// `DirtyBufferGuard`, and only a genuine write *failure* stops the close: a suspended save
|
||||
/// (read-only lock) and a vanished card do not, because neither has anywhere for the text to
|
||||
/// land and both were already visible to the user as the standing lock row or a card that left
|
||||
/// the board (`CardBodyEditSession.flushOrThrow`).
|
||||
private func closeAfterFlushing() {
|
||||
guard session.body.isDirty else {
|
||||
windowController.closeAfterFlush()
|
||||
return
|
||||
}
|
||||
if session.bufferGuard.beginClose() {
|
||||
windowController.closeAfterFlush()
|
||||
} else {
|
||||
// The alert is presenting; the close resumes from the phase change, above.
|
||||
isClosePending = true
|
||||
}
|
||||
}
|
||||
|
||||
/// The save panel behind the modal's "Save a Copy…". Pre-filled with the card's name and a `.md`
|
||||
/// extension, because what it writes is the Markdown body the user was typing — not the card,
|
||||
/// which cannot exist outside a board.
|
||||
private static func copyDestination(named title: String) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "\(title.isEmpty ? "Untitled" : title).md"
|
||||
panel.canCreateDirectories = true
|
||||
panel.isExtensionHidden = false
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.prompt = "Save"
|
||||
panel.message = "Choose where to keep these changes."
|
||||
guard panel.runModal() == .OK else { return nil }
|
||||
return panel.url
|
||||
}
|
||||
|
||||
/// Leaves the session and lets the store go.
|
||||
///
|
||||
/// The release rides **behind** the session's end rather than beside it: a session that has
|
||||
|
||||
@@ -108,14 +108,20 @@ struct FindSteppingCommands: View {
|
||||
/// View ▸ Edit Body (⌘E) / Raw Source (⌥⌘E) / History — the card window's three view-state rows
|
||||
/// (11-command-nexus.md).
|
||||
///
|
||||
// m6-card-window: Edit Body and Raw Source are checkmark toggles reading the window's edit-mode
|
||||
// state ("Edit Body disables while Raw Source is active" — 05-card-window.md); History is a plain
|
||||
// command that focuses the sidebar's History section and disables outright on mode `none` /
|
||||
// repo-nested boards once that section exists (05-card-window.md, 07-sync-collab.md). All three are
|
||||
// unconditionally disabled here — there is no card-window mode state anywhere yet.
|
||||
/// **Edit Body is live** (`EditBodyCommand`, beside the focused value it reads): the body column's
|
||||
/// Preview/Edit toggle, checkmark state and all. Its diff was the one `FutureCommand` promises —
|
||||
/// the title and the chord did not move, the validation and the action filled in.
|
||||
///
|
||||
// m6-raw-source: Raw Source is the same shape one card later — a checkmark toggle over a
|
||||
// window-level mode, which also adds the "Edit Body disables while Raw Source is active" clause to
|
||||
// the row above (05-card-window.md).
|
||||
//
|
||||
// m6-card-sidebar: History is a plain command that focuses the sidebar's History section, and
|
||||
// disables outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
|
||||
// 07-sync-collab.md). Both remain unconditionally disabled here — neither surface exists yet.
|
||||
struct CardViewCommands: View {
|
||||
var body: some View {
|
||||
FutureToggleCommand(title: "Edit Body", key: "e", modifiers: .command)
|
||||
EditBodyCommand()
|
||||
FutureToggleCommand(title: "Raw Source", key: "e", modifiers: [.option, .command])
|
||||
FutureCommand(title: "History")
|
||||
}
|
||||
|
||||
@@ -646,6 +646,12 @@ public final class BannerCenter {
|
||||
// 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" }
|
||||
case let .editBody(title):
|
||||
// **Save**, because that is the word for what just failed: the Edit→Preview flip is the
|
||||
// effective Save button (05-card-window.md ▸ Edit), and the debounced tick is the same
|
||||
// act happening on its own. The keystrokes are still in the buffer — the banner says the
|
||||
// app could not put them on disk, not that they are gone.
|
||||
if let title { "Couldn't save '\(title)'" } else { "Couldn't save the card" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,41 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a card-body save — the card window's Edit buffer meeting disk
|
||||
/// (05-card-window.md ▸ Edit; `BoardStore.writeCardBody(inCard:body:)`).
|
||||
///
|
||||
/// A returned value rather than a thrown error, because **four of the five cases are not failures**
|
||||
/// and the caller's response to each differs: only `.written` and `.unchanged` mean the buffer may
|
||||
/// stop being held dirty. Making them one enum is what keeps that decision in one `switch` rather
|
||||
/// than spread across a `try?` and two guards.
|
||||
public enum CardBodyWriteOutcome: Sendable, Equatable {
|
||||
/// The bytes landed. The buffer matches disk; the echoing reload is now on its way.
|
||||
case written
|
||||
|
||||
/// **Nothing to write** — the body on disk already reads exactly like the buffer. The three-gate
|
||||
/// write rule's outcome (05 ▸ Write rules: untouched, reverted, or the echo of an external
|
||||
/// edit), and as good as `.written` from the buffer's point of view: disk says what the user
|
||||
/// means it to say, and nothing was re-serialized to make that true.
|
||||
case unchanged
|
||||
|
||||
/// The board is locked read-only, so the save is **suspended, not failed** (02-architecture.md §
|
||||
/// the lock's scope: "editor buffers kept but their debounced saves suspended"). The buffer stays
|
||||
/// dirty, the standing lock row already explains why, and nothing is posted — a banner per
|
||||
/// suppressed tick would bury the row that matters under echoes of itself.
|
||||
case suspended(ReadOnlyLockReason)
|
||||
|
||||
/// The card is not in this board's tree at all any more — hard-deleted in Finder, or moved to
|
||||
/// another board. **Not a failure either**: there is nowhere for the text to land, which is 05 ▸
|
||||
/// Deletion & lifecycle's own answer ("A card hard-deleted externally (folder gone) discards
|
||||
/// both — nowhere left to write"). A *tombstoned* card is not this case; it is still on disk and
|
||||
/// is written to.
|
||||
case vanished
|
||||
|
||||
/// The write was attempted and failed. The banner has already been posted by `performWrite`; the
|
||||
/// buffer must stay dirty, and a close standing on it is `DirtyBufferGuard`'s modal moment.
|
||||
case failed(BoardWriteError)
|
||||
}
|
||||
|
||||
/// What a cross-board drop is doing to the items it carries — the **effective** operation the
|
||||
/// locality model resolved (04-interactions.md ▸ Drag and drop, settled).
|
||||
///
|
||||
@@ -1157,6 +1192,76 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card body
|
||||
|
||||
/// Saves a card window's Edit buffer — the debounced tick, the flush that leaves Edit, and the
|
||||
/// flush that closes the window (05-card-window.md ▸ Edit).
|
||||
///
|
||||
/// An ordinary store write in every mechanical respect: one `performWrite` bracket, so the churn
|
||||
/// rounds back as a single app-mediated reload (and, on git boards, sits inside the session's
|
||||
/// one commit — see `CardBodyEditSession` for that seam); the banner posts itself on failure;
|
||||
/// the snapshot is never touched here, because the watcher's reload is what brings the text
|
||||
/// back.
|
||||
///
|
||||
/// **It reports rather than swallows**, which is the one way it differs from every other write
|
||||
/// in this file. `toggleTaskMarker` and its neighbours are one-shot gestures whose failure the
|
||||
/// banner fully describes, so they `try?` and move on. This one has a *buffer* behind it: the
|
||||
/// caller has to know whether the text landed, because on success it may stop holding it dirty
|
||||
/// and on failure it must keep holding it — the whole of "nothing is lost while the window stays
|
||||
/// open" (02-architecture.md § Write-failure surfacing). Hence an outcome, not a `Void`.
|
||||
///
|
||||
/// **Tombstones are writable here, deliberately.** The folder is resolved by
|
||||
/// `cardBodyTarget(_:in:)` — a walk that does *not* skip tombstoned cards or lanes — because 05
|
||||
/// ▸ Deletion & lifecycle requires exactly that: "a dirty Edit buffer flushes into the
|
||||
/// tombstoned card's folder before the window dismisses ... so the keystrokes survive Put Back".
|
||||
/// The write is surgical (`BoardWriter.writeBody` replaces the body span and nothing else), so
|
||||
/// the `deleted:` key it lands beside is left standing and the card is not resurrected.
|
||||
public func writeCardBody(inCard cardID: ItemID, body: String) -> CardBodyWriteOutcome {
|
||||
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished }
|
||||
let folder = rootURL
|
||||
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(target.cardID.rawValue, isDirectory: true)
|
||||
|
||||
do {
|
||||
// The closure's signature is spelled out because it returns a value — the inference wart
|
||||
// `performWrite`'s doc comment records.
|
||||
let wrote = try performWrite { () throws(BoardWriteError) -> Bool in
|
||||
try BoardWriter.writeBody(inItemFolder: folder, body: body)
|
||||
}
|
||||
return wrote ? .written : .unchanged
|
||||
} catch let refusal as BoardStoreWriteRefusal {
|
||||
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
|
||||
return .suspended(reason)
|
||||
} catch let error as BoardWriteError {
|
||||
return .failed(error)
|
||||
} catch {
|
||||
// `performWrite`'s `throws` is untyped only because its two error types have not been
|
||||
// unified yet (`BoardStoreWriteRefusal`); there is no third thing it can throw.
|
||||
Self.logger.error("unexpected error saving a card body: \(String(describing: error), privacy: .public)")
|
||||
return .unchanged
|
||||
}
|
||||
}
|
||||
|
||||
/// Which folder a card's body write lands in — **the one card walk that ignores liveness**.
|
||||
///
|
||||
/// Every other resolution in this file goes through `liveItem`, whose ancestor-walked liveness is
|
||||
/// what keeps gestures off vanished targets. This one deliberately does not: the card window's
|
||||
/// dismissal flush has to reach a card that was tombstoned *out from under the buffer* (05 ▸
|
||||
/// Deletion & lifecycle), and to `liveItem` that card is already gone. A card whose folder is
|
||||
/// genuinely no longer in the tree — hard-deleted, or moved to another board — still resolves to
|
||||
/// `nil`, which is the case 05 answers with "nowhere left to write".
|
||||
nonisolated static func cardBodyTarget(
|
||||
_ id: ItemID,
|
||||
in snapshot: BoardModel
|
||||
) -> (laneID: ItemID, cardID: ItemID)? {
|
||||
for lane in snapshot.lanes {
|
||||
if let card = lane.cards.first(where: { $0.id == id }) {
|
||||
return (laneID: lane.id, cardID: card.id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Board rename
|
||||
|
||||
/// Writes the board's own `title` — the board popover's rename field (03-board-ui.md § Board
|
||||
|
||||
@@ -1041,6 +1041,68 @@ public enum BoardWriter: Sendable {
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
// MARK: - Card body
|
||||
|
||||
/// Replaces an item's **body span** — everything after the frontmatter's closing delimiter — and
|
||||
/// leaves every frontmatter byte exactly as it was. The card window's Edit buffer landing on
|
||||
/// disk (05-card-window.md ▸ Edit: "Saved on a ~700 ms debounce; flushed on leaving Edit,
|
||||
/// entering source mode, and window close").
|
||||
///
|
||||
/// ### Byte-honest by the same construction as everything else here
|
||||
///
|
||||
/// `FrontmatterDocument` keeps the file's raw text and re-emits it as
|
||||
/// `openingDelimiter + spans + closingDelimiter + body`, so assigning `body` is *only* a
|
||||
/// replacement of the body span: unknown keys, their order, comments, blank lines and line
|
||||
/// endings above the delimiter are the same bytes they were, because nothing re-serialized them.
|
||||
/// That is `toggleTaskMarker`'s idiom exactly — this call is that one widened from a single
|
||||
/// character to the whole span, and it shares its four steps: read fresh from disk, refuse an
|
||||
/// uneditable frontmatter shape, edit, stamp `modified` and clear `modified-by`, replace
|
||||
/// atomically.
|
||||
///
|
||||
/// **The stamp is not optional and not a policy choice here**: a body rewrite *is* an `index.md`
|
||||
/// rewrite, and every app-mediated `index.md` rewrite stamps (01-storage-format.md §
|
||||
/// Frontmatter). The raw-source Apply is the one path that keeps a `modified-by`, and it does
|
||||
/// not come through here.
|
||||
///
|
||||
/// ### The gate, and why it lives in the Writer as well as in the session
|
||||
///
|
||||
/// **An untouched body is never re-serialized** (05 ▸ Write rules): if the text on disk already
|
||||
/// equals `body`, this returns `false` having opened the file and touched nothing — no stamp, no
|
||||
/// temp file, no rename, and therefore an untouched `mtime`. The card window's Edit session
|
||||
/// gates on the same comparison before it ever calls (its three gates: untouched, reverted, and
|
||||
/// the echo of an external edit), so in practice this one never fires; it is here because the
|
||||
/// guarantee is about *bytes on disk*, and the layer that owns the bytes is the layer that can
|
||||
/// promise it against every caller, including a future one.
|
||||
///
|
||||
/// **It is not a staleness check.** A body that changed under the buffer is written over
|
||||
/// deliberately — "dirty buffer wins ... deliberate last-writer-wins" (05 ▸ Write rules) — which
|
||||
/// is why nothing here compares against what the caller last saw. Only *equality* refuses, and
|
||||
/// equality refuses because the write would be a no-op that stamped `modified` anyway.
|
||||
///
|
||||
/// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
|
||||
@discardableResult
|
||||
public static func writeBody(inItemFolder folder: URL, body: String) throws(BoardWriteError) -> Bool {
|
||||
var operation = WriteOperation.editBody(title: nil)
|
||||
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
|
||||
// The same shape guard `toggleTaskMarker` leans on, for its reason: a board root's body is
|
||||
// its description and no editor in this app opens it, so only lanes and cards are reachable.
|
||||
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 document.body != body else { return false }
|
||||
|
||||
document.body = body
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
/// The one folder this app ever creates under a card — every other subfolder under
|
||||
@@ -1566,6 +1628,16 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// also the app's only *body* write, which is worth being able to see in a log at a glance.
|
||||
case toggleTask(title: String?)
|
||||
|
||||
/// The card window's Edit buffer being saved — the debounced tick, the flush that leaves Edit,
|
||||
/// and the flush that closes the window (05-card-window.md ▸ Edit).
|
||||
///
|
||||
/// Its own case beside `.toggleTask` rather than folded into it, on the vocabulary's standing
|
||||
/// reasoning: both write a body, but one is a checkbox the user ticked and the other is prose
|
||||
/// they typed, and a banner telling someone the app "couldn't tick the checkbox" after they
|
||||
/// wrote three paragraphs would name a gesture that never happened. `title` is the card's title
|
||||
/// as the read that preceded the write found it — the name on the window they are typing in.
|
||||
case editBody(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
|
||||
@@ -1591,6 +1663,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .rename: .rename(title: title)
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .toggleTask: .toggleTask(title: title)
|
||||
case .editBody: .editBody(title: title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,6 +1693,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,19 +70,11 @@ enum BodyMarkupRenderer {
|
||||
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
|
||||
])
|
||||
}
|
||||
// The Edit surface's raw, monospaced rendering used to live here as `rawText`, while Edit was a
|
||||
// read-only placeholder sharing this file's substrate. It is now `MarkdownHighlighter`'s —
|
||||
// base attributes plus a span pass — and the promise it carried travelled with it: the
|
||||
// highlighter emits ranges, never a string, so "the text is the raw Markdown, character for
|
||||
// character" (05 ▸ Edit) is structural rather than a convention.
|
||||
|
||||
// MARK: Block layout state
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
// MARK: - CardBodyEditSession
|
||||
|
||||
/// One card window's Edit buffer: the text the user is typing, what disk last said, and the
|
||||
/// debounced save between them (05-card-window.md ▸ Edit, ▸ Write rules).
|
||||
///
|
||||
/// ### One comparison is the whole write rule
|
||||
///
|
||||
/// 05 states three gates — "untouched → never re-serialized; reverted → not written; echo of an
|
||||
/// external edit → not written back" — and they are three faces of a single predicate: **write if
|
||||
/// and only if the buffer differs from what is on disk**.
|
||||
///
|
||||
/// - *Untouched*: the user opened Edit, read, and left. `text == disk`, so nothing is written and
|
||||
/// the file stays byte-identical, `mtime` included.
|
||||
/// - *Reverted*: they typed and undid it. The debounce is cancelled the moment `text` matches `disk`
|
||||
/// again, so the timer that was going to write does not survive the revert.
|
||||
/// - *Echo*: our own save lands, the watcher reloads, and the snapshot arrives carrying the text we
|
||||
/// just wrote. `adopt(diskBody:)` moves `disk` to it, the buffer is already equal, and nothing is
|
||||
/// written back — which is what stops a save from ringing forever through the one-way flow.
|
||||
///
|
||||
/// `BoardWriter.writeBody` re-checks the same equality against the bytes it reads fresh, so the
|
||||
/// guarantee holds even against a caller that skipped this type. Belt and braces on purpose: this is
|
||||
/// the promise a file-backed app cannot afford to get subtly wrong.
|
||||
///
|
||||
/// ### Dirty-buffer-wins, as one branch
|
||||
///
|
||||
/// "A dirty Edit buffer is never reloaded under the cursor: while the user has unsaved keystrokes,
|
||||
/// watcher reloads update everything else (board, Preview, other windows) but leave the buffer
|
||||
/// alone; the debounced save then writes it — deliberate last-writer-wins. A clean buffer follows
|
||||
/// disk" (05 ▸ Write rules). That is `adopt(diskBody:)`'s single `if`: `disk` always follows the
|
||||
/// snapshot, and `text` follows it only when the two agreed before the snapshot arrived.
|
||||
///
|
||||
/// Keeping `disk` current *even while dirty* is the deliberate half. It means "dirty" reads as
|
||||
/// "differs from the file", not "differs from what the file said when I started" — so a foreign edit
|
||||
/// that happens to arrive at the text the user typed lands the buffer clean and writes nothing,
|
||||
/// rather than re-stamping a file that already says the right thing.
|
||||
///
|
||||
/// ### The undo and commit seams
|
||||
///
|
||||
/// ⌘Z is the *editor's* undo and lives in the text view (`CardBodySurface` gives it an
|
||||
/// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other
|
||||
/// half of 05 ▸ Edit's undo sentence: the **session**, whose end is the effective Save.
|
||||
/// `endEditSession()` is that moment — the Edit→Preview flip, raw-source entry, or the window
|
||||
/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because
|
||||
/// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its
|
||||
/// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save
|
||||
/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). On the base edition there is no committer, so
|
||||
/// the two calls do the same work today; the seam is what keeps them from having to be pulled apart
|
||||
/// later.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CardBodyEditSession {
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// What the editor is showing — and, once the window has opened, the truest text there is: it is
|
||||
/// the buffer when the buffer is dirty and disk when it is not, which is precisely the order 05
|
||||
/// settles. Preview renders it too, so "the preview never lags the text that produced it" needs
|
||||
/// no separate mechanism.
|
||||
public private(set) var text: String = ""
|
||||
|
||||
/// What the last snapshot said is on disk. The write gate's other half; never shown.
|
||||
public private(set) var disk: String = ""
|
||||
|
||||
/// Whether the buffer holds keystrokes the file does not.
|
||||
public var isDirty: Bool { text != disk }
|
||||
|
||||
// MARK: Seams
|
||||
|
||||
/// The debounce interval — **~700 ms** (05 ▸ Edit), and settable so a test does not have to
|
||||
/// spend it. `DragSession.holdTimeout`'s precedent: a production default on the property, and
|
||||
/// the suite dialling it down to milliseconds.
|
||||
@ObservationIgnored
|
||||
public var debounceInterval: Duration = .milliseconds(700)
|
||||
|
||||
/// Where a save goes. Filled in by the window once it has a store and a card to aim at
|
||||
/// (`CardWindowHost`), which is also why it is a closure rather than a store reference: this type
|
||||
/// is a buffer and a clock, and it stays testable by having no idea what a board is.
|
||||
///
|
||||
/// `nil` is a session with nowhere to write — before the window has joined its board, and after
|
||||
/// it has left. A flush then keeps the buffer dirty rather than reporting success.
|
||||
@ObservationIgnored
|
||||
public var save: ((String) -> CardBodyWriteOutcome)?
|
||||
|
||||
@ObservationIgnored
|
||||
private var pending: Task<Void, Never>?
|
||||
|
||||
/// How many saves have actually been attempted through `save` — the debounce's own testimony,
|
||||
/// which a test would otherwise have to infer from `mtime`s.
|
||||
@ObservationIgnored
|
||||
public private(set) var saveAttempts = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Disk → buffer
|
||||
|
||||
/// A snapshot arrived. **Dirty-buffer-wins**: `disk` always follows it; `text` follows it only
|
||||
/// when the buffer had nothing unsaved.
|
||||
///
|
||||
/// Called on every snapshot the window renders, including the first, which is how the buffer is
|
||||
/// filled at all — a card window opens by adopting its card's body.
|
||||
public func adopt(diskBody: String) {
|
||||
let wasDirty = isDirty
|
||||
disk = diskBody
|
||||
guard !wasDirty else { return }
|
||||
// Assigning an equal string would still notify observers, and an observer here is a text
|
||||
// view that would replace its contents under the cursor.
|
||||
if text != diskBody { text = diskBody }
|
||||
}
|
||||
|
||||
// MARK: - Buffer → disk
|
||||
|
||||
/// The editor changed. Restarts the debounce — or cancels it outright, when the change brought
|
||||
/// the buffer back to what disk already says (05's *reverted* gate: a revert must not leave a
|
||||
/// timer standing that would then write nothing but a `modified` stamp).
|
||||
public func edited(_ newText: String) {
|
||||
guard text != newText else { return }
|
||||
text = newText
|
||||
guard isDirty else {
|
||||
cancelPending()
|
||||
return
|
||||
}
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/// Saves now if there is anything to save, cancelling the pending debounce first — "leaving Edit
|
||||
/// flushes the debounce (mode flip, raw-source entry, window close)" (05 ▸ Mode grammar).
|
||||
///
|
||||
/// Synchronous, because the write is: `BoardWriter` is a temp file and a rename, and a flush that
|
||||
/// returned before the bytes landed would be no flush at all — the close path in particular has
|
||||
/// to know the answer before it lets the window go.
|
||||
@discardableResult
|
||||
public func flush() -> CardBodyWriteOutcome {
|
||||
cancelPending()
|
||||
return saveNow()
|
||||
}
|
||||
|
||||
/// The end of one Edit session — the flip back to Preview, raw-source entry, or the window
|
||||
/// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's
|
||||
/// doc comment).
|
||||
@discardableResult
|
||||
public func endEditSession() -> CardBodyWriteOutcome {
|
||||
flush()
|
||||
}
|
||||
|
||||
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
|
||||
/// reported.
|
||||
///
|
||||
/// The three non-failures deliberately do *not* throw, because each of them is a state in which
|
||||
/// blocking the close would be dishonest:
|
||||
///
|
||||
/// - `.written` / `.unchanged` — the text is on disk.
|
||||
/// - `.vanished` — the card's folder is gone, so there is nowhere for the save to land; 05 ▸
|
||||
/// Deletion & lifecycle answers exactly this case with "nowhere left to write", and a modal
|
||||
/// offering Try Again against a deleted folder would be a button that can only fail.
|
||||
/// - `.suspended` — the board is locked read-only, which is 05's "where a save can land"
|
||||
/// qualifier failing rather than a write failing: no write was attempted, the lock row has been
|
||||
/// standing the whole time the user was typing, and the lock's own clearing rule (a successful
|
||||
/// reload) is not something a close can wait on.
|
||||
public func flushOrThrow() throws(BoardWriteError) {
|
||||
switch flush() {
|
||||
case .written, .unchanged, .vanished, .suspended:
|
||||
return
|
||||
case let .failed(error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func scheduleSave() {
|
||||
cancelPending()
|
||||
let interval = debounceInterval
|
||||
pending = Task { [weak self] in
|
||||
try? await Task.sleep(for: interval)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.pending = nil
|
||||
_ = self.saveNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPending() {
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
}
|
||||
|
||||
/// The gate, and the one place `save` is called.
|
||||
///
|
||||
/// A successful landing moves `disk` up to the text that landed, so the echo arriving a reload
|
||||
/// later finds the buffer already clean. A failure, a suspension and a vanished card all leave
|
||||
/// `disk` where it was, which keeps the buffer dirty — and therefore keeps the text, which is the
|
||||
/// whole point.
|
||||
private func saveNow() -> CardBodyWriteOutcome {
|
||||
guard isDirty else { return .unchanged }
|
||||
guard let save else { return .vanished }
|
||||
|
||||
saveAttempts += 1
|
||||
let outcome = save(text)
|
||||
switch outcome {
|
||||
case .written, .unchanged:
|
||||
disk = text
|
||||
case .suspended, .vanished, .failed:
|
||||
break
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ public final class CardBodyPresentation {
|
||||
/// exactly when ⌘F has nothing to find in.
|
||||
public var findInText: (() -> Void)?
|
||||
|
||||
/// Flushes the Edit buffer — **"leaving Edit flushes the debounce"** (05 ▸ Mode grammar), and
|
||||
/// the reason the flip goes through `setMode(_:)` rather than being three separate assignments.
|
||||
///
|
||||
/// Filled in by the window with its edit session's `endEditSession()`. It hangs here rather than
|
||||
/// on the session because *this* is the type every path that leaves Edit already holds: the menu
|
||||
/// item's toggle, Escape in the editor, and Return in Preview all flip the mode through one
|
||||
/// object, so attaching the flush to the flip is what makes "always" true by construction rather
|
||||
/// than by three call sites remembering.
|
||||
public var flushEdits: (() -> 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
|
||||
@@ -80,7 +90,59 @@ public final class CardBodyPresentation {
|
||||
|
||||
/// ⌘E, Return in Preview, Escape in Edit — see `CardBodyMode.toggled`.
|
||||
public func toggleMode() {
|
||||
mode = mode.toggled
|
||||
setMode(mode.toggled)
|
||||
}
|
||||
|
||||
/// The one place the mode changes, and therefore the one place **leaving Edit flushes** (05 ▸
|
||||
/// Mode grammar: "Leaving Edit flushes the debounce (mode flip, raw-source entry, window close)
|
||||
/// — the preview never lags the text that produced it, and neither does disk").
|
||||
///
|
||||
/// The flush runs *before* the flip, not after: Preview reads the same buffer the editor was
|
||||
/// writing, so a flip that rendered first and saved second would be indistinguishable on screen
|
||||
/// — but a failure in that order would leave the user reading text the app had just failed to
|
||||
/// save, with the mode already changed under them. Saving first means the banner (and, on a
|
||||
/// close, the modal) arrives while the editor is still the thing on screen.
|
||||
///
|
||||
/// Setting the mode it already has does nothing at all, which is what keeps a redundant
|
||||
/// menu-item validation pass or a re-published focus value from flushing an untouched buffer.
|
||||
public func setMode(_ newMode: CardBodyMode) {
|
||||
guard newMode != mode else { return }
|
||||
if mode == .edit { flushEdits?() }
|
||||
mode = newMode
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View ▸ Edit Body
|
||||
|
||||
/// View ▸ Edit Body (⌘E) — the body column's mode toggle, with checkmark state (11-command-nexus.md;
|
||||
/// 05-card-window.md ▸ Mode grammar).
|
||||
///
|
||||
/// **A `Toggle`, because the row is a checkmark row**: 11 files it as "(checkmark toggle)", and
|
||||
/// 04-interactions.md ▸ Configurable bindings requires that such a row keep "one stable title,
|
||||
/// checkmark state only" — so the title is the same string it was while the row was disabled, and
|
||||
/// what changed with this milestone is the validation and the action, exactly as `FutureCommands`
|
||||
/// predicts.
|
||||
///
|
||||
/// Validation is scope: with no card window in front there is no `cardBody` focused value, and the
|
||||
/// row disables. The read-only lock is deliberately **not** part of it — entering Edit is not a
|
||||
/// mutation, and 02-architecture.md § the lock's scope keeps editor buffers alive under the lock
|
||||
/// (only their saves suspend), so a locked board can still be read in the editor and its text
|
||||
/// copied out.
|
||||
///
|
||||
// m6-raw-source: "View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar
|
||||
// item" (05 ▸ Raw source outlet). That is one more clause on `isDisabled` once a window-level raw
|
||||
// mode exists to read; the row, its title and its chord do not move.
|
||||
struct EditBodyCommand: View {
|
||||
|
||||
@FocusedValue(\.cardBody) private var cardBody
|
||||
|
||||
var body: some View {
|
||||
Toggle("Edit Body", isOn: Binding(
|
||||
get: { cardBody?.mode == .edit },
|
||||
set: { isOn in cardBody?.setMode(isOn ? .edit : .preview) }
|
||||
))
|
||||
.keyboardShortcut("e", modifiers: .command)
|
||||
.disabled(cardBody == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
/// Preview and the raw-Markdown Edit editor.
|
||||
///
|
||||
/// ### Why AppKit, and not `Text(…).textSelection(.enabled)`
|
||||
///
|
||||
@@ -28,20 +28,37 @@ import SwiftUI
|
||||
///
|
||||
/// ### 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.
|
||||
/// Preview and Edit differ in three things and nothing else: whether the view is editable, what it
|
||||
/// is handed (a rendered attributed string, or the raw text under a highlighting pass), and which
|
||||
/// key means "flip". Everything else — ⌘F, selection, copying, the find bar, the scroll position —
|
||||
/// belongs to the substrate and is therefore identical in both, without either mode implementing it.
|
||||
///
|
||||
/// **One view rather than two representables**, deliberately: `CardBodyPresentation.findInText` holds
|
||||
/// a closure over *this* text view, and two views swapping across a mode flip would race to own it —
|
||||
/// ⌘F would work or not depending on the order SwiftUI happened to mount them in. One view has one
|
||||
/// text view for the window's life, and the flip is a reconfiguration.
|
||||
///
|
||||
/// ### What the editor writes, and when
|
||||
///
|
||||
/// Nothing here writes to disk. The text view reports every change to `CardBodyEditSession`, which
|
||||
/// owns the ~700 ms debounce, the three write gates and the flush; this file's whole responsibility
|
||||
/// is that the buffer and the view agree, and that the view never has text replaced under the user's
|
||||
/// cursor (the view half of dirty-buffer-wins — the session's half is `adopt(diskBody:)`).
|
||||
struct CardBodySurface: NSViewRepresentable {
|
||||
|
||||
/// The card's body, verbatim — the source both renderings are made from.
|
||||
/// The text to show: `CardBodyEditSession.text`, which is the buffer in Edit and — because a
|
||||
/// clean buffer follows disk — the card's body in Preview. One string for both modes is what
|
||||
/// makes "the preview never lags the text that produced it" (05 ▸ Mode grammar) fall out rather
|
||||
/// than need arranging.
|
||||
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
|
||||
/// The buffer this surface edits. Keystrokes go in through `edited(_:)`; nothing else here
|
||||
/// touches it.
|
||||
let session: CardBodyEditSession
|
||||
/// 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
|
||||
@@ -63,7 +80,7 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
container.widthTracksTextView = true
|
||||
layoutManager.addTextContainer(container)
|
||||
|
||||
let textView = NSTextView(frame: .zero, textContainer: container)
|
||||
let textView = CardBodyTextView(frame: .zero, textContainer: container)
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = true
|
||||
@@ -74,12 +91,21 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
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.
|
||||
// Nothing about a card body is the app's to rewrite as the user reads it — or as they type
|
||||
// it: "the text is the raw Markdown, character for character — no hidden transforms, no
|
||||
// smart substitutions" (05 ▸ Edit) is exactly this list, and in Edit it is normative rather
|
||||
// than merely tidy. A smart quote substituted into a fenced code block would be the app
|
||||
// silently corrupting the user's file.
|
||||
textView.isAutomaticLinkDetectionEnabled = false
|
||||
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||
textView.isAutomaticDashSubstitutionEnabled = false
|
||||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||
textView.isAutomaticDataDetectionEnabled = false
|
||||
textView.smartInsertDeleteEnabled = false
|
||||
// ⌘Z is the editor's own undo (05 ▸ Edit). `allowsUndo` turns it on; the *session* scoping is
|
||||
// the coordinator's `undoManager(for:)`, below.
|
||||
textView.allowsUndo = true
|
||||
// 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]
|
||||
@@ -91,6 +117,15 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
textView.textContainerInset = CGSize(width: gutter, height: gutter)
|
||||
|
||||
// The two fixed keys of the mode grammar, on the surface that owns the keyboard while they
|
||||
// are pressed: "Return in Preview also enters Edit … Escape in Edit returns to Preview"
|
||||
// (05 ▸ Mode grammar). They are the text view's rather than a SwiftUI `.onKeyPress` because
|
||||
// the text view *is* the first responder in both modes — a key handler above it would only
|
||||
// see what the editor declined to eat.
|
||||
let presentation = presentation
|
||||
textView.onReturnInPreview = { presentation.setMode(.edit) }
|
||||
textView.onEscapeInEdit = { presentation.setMode(.preview) }
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.documentView = textView
|
||||
scrollView.hasVerticalScroller = true
|
||||
@@ -100,13 +135,13 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
scrollView.findBarPosition = .aboveContent
|
||||
|
||||
context.coordinator.textView = textView
|
||||
context.coordinator.session = session
|
||||
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 }
|
||||
@@ -127,9 +162,15 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
let coordinator = context.coordinator
|
||||
coordinator.onToggleTask = onToggleTask
|
||||
coordinator.isTaskToggleEnabled = isTaskToggleEnabled
|
||||
coordinator.session = session
|
||||
|
||||
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||
guard let textView = scrollView.documentView as? CardBodyTextView else { return }
|
||||
let pointSize = CardWindowMetrics.bodyPointSize
|
||||
|
||||
if coordinator.mode != mode {
|
||||
coordinator.enter(mode, in: textView)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -137,17 +178,21 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
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)
|
||||
switch mode {
|
||||
case .preview:
|
||||
let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder)
|
||||
textView.textStorage?.setAttributedString(
|
||||
BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context)
|
||||
)
|
||||
|
||||
case .edit:
|
||||
coordinator.show(body, in: textView, pointSize: pointSize)
|
||||
}
|
||||
textView.textStorage?.setAttributedString(content)
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
/// The delegate, and the render cache.
|
||||
/// The delegate, the render cache, and the editor's session-scoped undo.
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
|
||||
@@ -161,9 +206,117 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
|
||||
weak var textView: NSTextView?
|
||||
var rendered: RenderKey?
|
||||
var session: CardBodyEditSession?
|
||||
var onToggleTask: ((Int, Bool) -> Void)?
|
||||
var isTaskToggleEnabled = true
|
||||
|
||||
/// Which mode the view is currently *configured* for — `nil` until the first update, which is
|
||||
/// what makes the initial configuration a mode entry like any other (and is why a card that
|
||||
/// opens straight into Edit gets the caret without a special case).
|
||||
private(set) var mode: CardBodyMode?
|
||||
|
||||
/// **The editor's own undo manager, and the whole of "session-scoped"** (05 ▸ Edit: "⌘Z here
|
||||
/// is the text view's own undo — session-scoped, ending when the editor loses focus or the
|
||||
/// mode flips").
|
||||
///
|
||||
/// Without this the text view would use the *window's* undo manager, whose stack outlives
|
||||
/// every mode flip and is shared with anything else in the window that registers an
|
||||
/// undoable action — so ⌘Z after leaving Edit could reach back into text the user had
|
||||
/// already committed. Owning one here makes the scoping structural: `removeAllActions()` at
|
||||
/// the two moments 05 names is then an emptying of a stack nothing else can see.
|
||||
private let editorUndoManager = UndoManager()
|
||||
|
||||
/// Set while this coordinator is replacing the view's text, so the resulting change
|
||||
/// notification is not mistaken for typing.
|
||||
private var isSettingText = false
|
||||
|
||||
// MARK: Mode
|
||||
|
||||
/// Reconfigures the view for a mode — the only place editability, the undo stack and first
|
||||
/// responder change.
|
||||
func enter(_ newMode: CardBodyMode, in textView: CardBodyTextView) {
|
||||
mode = newMode
|
||||
// The session's undo stack ends with the mode, per 05. Emptied on the way *in* as well
|
||||
// as out, so an Edit session never opens on top of the previous one's actions.
|
||||
editorUndoManager.removeAllActions()
|
||||
|
||||
switch newMode {
|
||||
case .preview:
|
||||
textView.isEditable = false
|
||||
|
||||
case .edit:
|
||||
textView.isEditable = true
|
||||
textView.typingAttributes = MarkdownHighlighter.baseAttributes(
|
||||
pointSize: CardWindowMetrics.bodyPointSize
|
||||
)
|
||||
// "**Empty body opens in Edit** with the cursor ready" (05 ▸ Mode grammar) — and the
|
||||
// same courtesy for a deliberate ⌘E, which is a request to type. Deferred a turn:
|
||||
// this runs inside a SwiftUI update, and making a view first responder re-enters
|
||||
// AppKit's responder machinery.
|
||||
Task { @MainActor [weak textView] in
|
||||
guard let textView, textView.isEditable else { return }
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts `text` in the editor and highlights it — **never replacing what the user is looking
|
||||
/// at unless it actually differs**.
|
||||
///
|
||||
/// The equality guard is load-bearing rather than an optimization: this runs on every
|
||||
/// keystroke (the buffer changed, so SwiftUI re-ran the update), and replacing the storage
|
||||
/// with the string it already holds would collapse the selection, scroll the view, and throw
|
||||
/// away the undo stack — on every character typed.
|
||||
func show(_ text: String, in textView: CardBodyTextView, pointSize: CGFloat) {
|
||||
guard let storage = textView.textStorage else { return }
|
||||
|
||||
if storage.string != text {
|
||||
// A foreign edit arriving under a *clean* buffer, or the first fill of the editor.
|
||||
// The selection is preserved where it still fits; a caret past the new end clamps
|
||||
// rather than disappearing.
|
||||
let selected = textView.selectedRange()
|
||||
// The undo stack described text that no longer exists — an agent or a hand edit
|
||||
// replaced it — and ⌘Z restoring a run of it would be this app inventing a merge.
|
||||
editorUndoManager.removeAllActions()
|
||||
isSettingText = true
|
||||
storage.setAttributedString(NSAttributedString(
|
||||
string: text,
|
||||
attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
||||
))
|
||||
isSettingText = false
|
||||
let length = (text as NSString).length
|
||||
textView.setSelectedRange(NSRange(
|
||||
location: min(selected.location, length),
|
||||
length: min(selected.length, max(0, length - min(selected.location, length)))
|
||||
))
|
||||
}
|
||||
|
||||
MarkdownHighlighter.highlight(storage, pointSize: pointSize)
|
||||
textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
||||
}
|
||||
|
||||
// MARK: NSTextViewDelegate
|
||||
|
||||
/// The editor's undo manager — see `editorUndoManager`.
|
||||
func undoManager(for view: NSTextView) -> UndoManager? {
|
||||
editorUndoManager
|
||||
}
|
||||
|
||||
/// Every keystroke, straight into the buffer. The session decides what that costs: a
|
||||
/// restarted debounce, or a cancelled one when the change happened to restore the file's own
|
||||
/// text.
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !isSettingText, mode == .edit, let textView = notification.object as? NSTextView else { return }
|
||||
session?.edited(textView.string)
|
||||
}
|
||||
|
||||
/// Focus leaving the editor ends the undo session (05 ▸ Edit), and is *not* a save: the
|
||||
/// debounce is still running and will land on its own, which is what keeps clicking into the
|
||||
/// sidebar from being a commit point the design never named.
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
editorUndoManager.removeAllActions()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -194,3 +347,62 @@ struct CardBodySurface: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CardBodyTextView
|
||||
|
||||
/// The body surface's text view, subclassed for exactly two keys.
|
||||
///
|
||||
/// Return in Preview and Escape in Edit are **fixed grammar, not menu items** (05-card-window.md ▸
|
||||
/// Mode grammar: "Return in Preview also enters Edit — the board's edit key applied to the body;
|
||||
/// fixed grammar like the board's Return, not a menu item"), so they have to be intercepted where
|
||||
/// the keyboard actually is. Both are guarded by editability, which is the mode: a Return in Edit is
|
||||
/// a newline like any other, and an Escape in Preview means nothing here.
|
||||
final class CardBodyTextView: NSTextView {
|
||||
|
||||
var onReturnInPreview: (() -> Void)?
|
||||
var onEscapeInEdit: (() -> Void)?
|
||||
|
||||
/// Preview is not editable, so AppKit would send this nowhere — the mode's own key handling has
|
||||
/// to come before `super`, which for a read-only text view merely beeps.
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let isPlainReturn = event.keyCode == 36 || event.keyCode == 76
|
||||
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.function, .numericPad, .capsLock])
|
||||
if !isEditable, isPlainReturn, modifiers.isEmpty {
|
||||
onReturnInPreview?()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
/// Escape. Intercepted before `NSTextView`'s own meaning for it (text completion), and only
|
||||
/// while editing — with the find bar up the bar is first responder and never reaches this.
|
||||
override func cancelOperation(_ sender: Any?) {
|
||||
guard isEditable, let onEscapeInEdit else {
|
||||
super.cancelOperation(sender)
|
||||
return
|
||||
}
|
||||
onEscapeInEdit()
|
||||
}
|
||||
|
||||
/// **A file drop is never the editor's** (05-card-window.md ▸ Attachments, settled: "file drops
|
||||
/// import as attachments anywhere in the window — Edit mode included, the text editor never
|
||||
/// intercepts a file drop; dragged *text* lands in the Edit editor at the caret").
|
||||
///
|
||||
/// An editable, rich `NSTextView` would otherwise happily take a dragged file and turn it into a
|
||||
/// path or an attachment cell inside the user's Markdown. Dropping the *file* types from what
|
||||
/// this view accepts lets that drag fall through to the window, which is where the attachment
|
||||
/// import belongs. Every text type — a plain-text drag, a URL dragged out of a browser — is left
|
||||
/// exactly as AppKit offers it, so the other half of the rule is the default behaviour rather
|
||||
/// than a re-implementation of it.
|
||||
///
|
||||
// m6-card-attachments: the window-level drop surface that catches what this declines.
|
||||
override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
|
||||
let fileTypes: Set<NSPasteboard.PasteboardType> = [
|
||||
.fileURL,
|
||||
// The Carbon-era name AppKit still puts on a Finder drag alongside the modern one.
|
||||
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||
]
|
||||
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import SwiftUI
|
||||
/// ### What this milestone builds, and what it deliberately does not
|
||||
///
|
||||
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
||||
/// renderings of what the loader already knows — the card's title, its created/modified line, and
|
||||
/// its body as plain text. Everything that reads or writes beyond that is later work and is marked
|
||||
/// where it lands:
|
||||
/// renderings of what the loader already knows — the card's title and its created/modified line —
|
||||
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`). Everything that
|
||||
/// reads or writes beyond that is later work and is marked where it lands:
|
||||
///
|
||||
/// - the title as an editable field (commit on Return / focus loss, Escape abandons),
|
||||
/// - the raw-source outlet,
|
||||
@@ -43,6 +43,10 @@ struct CardWindowView: View {
|
||||
let cardFolder: URL?
|
||||
/// This window's body-column state: which mode it is in, and the find-bar hook.
|
||||
let bodyPresentation: CardBodyPresentation
|
||||
/// This window's Edit buffer. It holds the text **both** surfaces show: the editor writes into
|
||||
/// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say —
|
||||
/// which is exactly the point at which dirty-buffer-wins is decided.
|
||||
let bodySession: CardBodyEditSession
|
||||
/// 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.
|
||||
@@ -95,45 +99,34 @@ struct CardWindowView: View {
|
||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
||||
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
||||
|
||||
if bodyPresentation.mode == .edit {
|
||||
editPlaceholderNotice
|
||||
}
|
||||
|
||||
CardBodySurface(
|
||||
body: card.body,
|
||||
// The session's text, never `card.body` directly: a dirty buffer outranks the
|
||||
// snapshot (05 ▸ Write rules) and a flushed one is ahead of it by a reload, so the
|
||||
// buffer is the truer of the two in both modes — which is also how Preview shows the
|
||||
// text that produced it the instant Edit is left.
|
||||
body: bodySession.text,
|
||||
mode: bodyPresentation.mode,
|
||||
cardFolder: cardFolder,
|
||||
presentation: bodyPresentation,
|
||||
session: bodySession,
|
||||
isTaskToggleEnabled: isEditable,
|
||||
onToggleTask: onToggleTask
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
// **Dirty-buffer-wins, applied on every snapshot** (05 ▸ Write rules): the session takes
|
||||
// disk's word for what the file says, and takes it into the editor only when the buffer has
|
||||
// nothing unsaved. `initial: true` is also how the buffer is filled at all — a window opens
|
||||
// by adopting its card's body.
|
||||
.onChange(of: card.body, initial: true) { _, body in
|
||||
bodySession.adopt(diskBody: body)
|
||||
}
|
||||
// **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
|
||||
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
|
||||
// "once" — a later reload that empties the file must not drag a reader into Edit.
|
||||
.task { bodyPresentation.openIfNeeded(body: card.body) }
|
||||
}
|
||||
|
||||
/// The Edit mode's honest placeholder.
|
||||
///
|
||||
/// **The mode is real; the editor is not.** 05's opening rule is not a rendering detail that can
|
||||
/// wait — it decides which surface a brand-new card lands on — so this milestone implements the
|
||||
/// *state* (`CardBodyMode`, the opening rule, the toggle) and leaves the editor itself to the
|
||||
/// Edit card. What shows meanwhile is the raw Markdown, monospaced and read-only, over a line
|
||||
/// that says so: a text view that looked editable and silently discarded keystrokes would be a
|
||||
/// worse lie than an empty pane, and one that saved would be this milestone building the thing
|
||||
/// it deliberately is not building.
|
||||
private var editPlaceholderNotice: some View {
|
||||
Text("Body editing arrives with the Edit surface — this is the raw Markdown, read-only.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
||||
.padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: bodyPointSize))
|
||||
.background(.background.secondary)
|
||||
}
|
||||
|
||||
/// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
|
||||
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - MarkdownHighlighter
|
||||
|
||||
/// The Edit editor's **lightweight Markdown syntax highlighting** (05-card-window.md ▸ Edit:
|
||||
/// "headings emphasized, bold/italic rendered as such, code tinted, link targets and structural
|
||||
/// markers dimmed").
|
||||
///
|
||||
/// ### It emits ranges, and that is the whole safety argument
|
||||
///
|
||||
/// "Highlighting is presentation only: the text is the raw Markdown, character for character — no
|
||||
/// hidden transforms, no smart substitutions" (05 ▸ Edit). A highlighter that returned a string
|
||||
/// could break that promise; one that returns `[Span]` — offsets into the text it was handed —
|
||||
/// structurally cannot. `apply(_:to:pointSize:)` is the only part that touches a text storage, and it
|
||||
/// calls nothing but `setAttributes`/`addAttribute`.
|
||||
///
|
||||
/// ### Why a line scanner rather than a swift-markdown re-parse
|
||||
///
|
||||
/// Preview parses with swift-markdown (`BodyMarkup`) because it renders *structure* — tables, nested
|
||||
/// quotes, list nesting — and structure is what a parser is for. The editor needs something
|
||||
/// different, and the difference is decisive:
|
||||
///
|
||||
/// - **It runs on every keystroke.** A full CommonMark parse per character, on the main actor,
|
||||
/// buys a document tree that is thrown away immediately; a single pass of a handful of
|
||||
/// line-anchored regexes is what the job actually needs.
|
||||
/// - **The text is usually invalid.** Half the time an editor's buffer holds `**bo` or `[label](`,
|
||||
/// because the user is mid-word. A parser resolves those to *paragraph text*, so emphasis would
|
||||
/// pop into existence on the closing asterisk and structure would flicker with every keystroke.
|
||||
/// A scanner highlights what is there: the delimiter dims as it is typed, and the run styles when
|
||||
/// it closes.
|
||||
/// - **Delimiters are the point here.** 05 asks for the markers themselves to be dimmed, and a
|
||||
/// parsed tree deliberately discards them — swift-markdown gives the emphasized *content*, not the
|
||||
/// asterisks around it.
|
||||
///
|
||||
/// The cost is that the scanner is line-local: it knows fenced code blocks (a running state), and
|
||||
/// nothing else spanning lines. A `**bold` opened on one line and closed on the next is not styled,
|
||||
/// which is a fair trade for highlighting that never lies about half-typed markup and never re-parses
|
||||
/// a document to draw one line of it.
|
||||
///
|
||||
/// ### Scope of a pass
|
||||
///
|
||||
/// A pass rebuilds the whole body's attributes. That is honest for the input this app has — a card
|
||||
/// body is a card, not a book — and it is what keeps the fenced-code state correct without tracking
|
||||
/// which line invalidated which: the state is recomputed from the top, every time, in one linear
|
||||
/// walk over the text.
|
||||
enum MarkdownHighlighter {
|
||||
|
||||
// MARK: - Vocabulary
|
||||
|
||||
/// What a run of characters *is* — the five things 05 names, plus the structural markers it asks
|
||||
/// to have dimmed.
|
||||
enum Token: Equatable, Sendable {
|
||||
/// A heading's text (`# ` already excluded — that is `.structural`).
|
||||
case heading(level: Int)
|
||||
/// `**bold**`'s content.
|
||||
case strong
|
||||
/// `*italic*`'s content.
|
||||
case emphasis
|
||||
/// `~~struck~~`'s content.
|
||||
case strikethrough
|
||||
/// An inline code span's content, or a fenced/indented code line.
|
||||
case code
|
||||
/// A list's bullet, number, or task checkbox — the marker itself.
|
||||
case listMarker
|
||||
/// A link or image's visible text.
|
||||
case linkText
|
||||
/// A link or image's target — "link targets … dimmed" (05 ▸ Edit).
|
||||
case linkTarget
|
||||
/// Every delimiter: `#`, `**`, backticks, brackets, parens, `>`, a thematic break.
|
||||
case structural
|
||||
}
|
||||
|
||||
/// One run of text and what it is. `range` is in **UTF-16 units** (`NSRange`), because its only
|
||||
/// consumer is `NSTextStorage` — the same reason `BodyMarkup` uses UTF-8 byte offsets and this
|
||||
/// does not: each carries the offsets its own consumer speaks.
|
||||
struct Span: Equatable, Sendable {
|
||||
var range: NSRange
|
||||
var token: Token
|
||||
}
|
||||
|
||||
// MARK: - The scan
|
||||
|
||||
/// Every styled run in `text`, in ascending order and never overlapping.
|
||||
///
|
||||
/// Pure, total, and allocation-light: any string is valid input, including one that is malformed
|
||||
/// Markdown in every way at once, and the result is always a partition-compatible set of ranges
|
||||
/// inside `text`.
|
||||
static func spans(in text: String) -> [Span] {
|
||||
let ns = text as NSString
|
||||
guard ns.length > 0 else { return [] }
|
||||
|
||||
var spans: [Span] = []
|
||||
var fence: String?
|
||||
|
||||
forEachLine(in: ns) { line in
|
||||
if let open = fence {
|
||||
// Inside a fenced block every line is code, and only the matching fence closes it.
|
||||
if let closing = fenceRun(in: ns, line: line), closing.marker == open {
|
||||
spans.append(Span(range: closing.range, token: .structural))
|
||||
fence = nil
|
||||
} else {
|
||||
spans.append(Span(range: line, token: .code))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let opening = fenceRun(in: ns, line: line) {
|
||||
spans.append(Span(range: opening.range, token: .structural))
|
||||
// The info string (` ```swift `) is part of the fence, not of the code.
|
||||
let info = NSRange(
|
||||
location: opening.range.upperBound,
|
||||
length: line.upperBound - opening.range.upperBound
|
||||
)
|
||||
if info.length > 0 { spans.append(Span(range: info, token: .linkTarget)) }
|
||||
fence = opening.marker
|
||||
return
|
||||
}
|
||||
|
||||
scanLine(line, in: ns, into: &spans)
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
/// One line, outside any fence. Block markers first — they decide what the rest of the line even
|
||||
/// is — then the inline pass over whatever is left.
|
||||
private static func scanLine(_ line: NSRange, in ns: NSString, into spans: inout [Span]) {
|
||||
// An indented code block: four spaces (or a tab) with content behind them.
|
||||
if firstMatch(Patterns.indentedCode, in: ns, range: line) != nil {
|
||||
spans.append(Span(range: line, token: .code))
|
||||
return
|
||||
}
|
||||
|
||||
if let rule = firstMatch(Patterns.thematicBreak, in: ns, range: line) {
|
||||
spans.append(Span(range: rule.range, token: .structural))
|
||||
return
|
||||
}
|
||||
|
||||
var content = line
|
||||
|
||||
if let heading = firstMatch(Patterns.heading, in: ns, range: content) {
|
||||
let hashes = heading.range(at: 1)
|
||||
spans.append(Span(range: hashes, token: .structural))
|
||||
let level = hashes.length
|
||||
let rest = NSRange(location: hashes.upperBound, length: content.upperBound - hashes.upperBound)
|
||||
if rest.length > 0 {
|
||||
spans.append(Span(range: rest, token: .heading(level: level)))
|
||||
}
|
||||
// **A heading's text takes no inline pass.** It is already emphasized, and layering a
|
||||
// body-sized bold run inside a larger heading font would make `# A **bold** title` read
|
||||
// as a heading with a hole in it. Dimming the `#` and emphasizing the rest is the whole
|
||||
// of what 05 asks for here.
|
||||
return
|
||||
}
|
||||
|
||||
if let quote = firstMatch(Patterns.blockQuote, in: ns, range: content) {
|
||||
spans.append(Span(range: quote.range(at: 1), token: .structural))
|
||||
content = NSRange(
|
||||
location: quote.range.upperBound,
|
||||
length: content.upperBound - quote.range.upperBound
|
||||
)
|
||||
}
|
||||
|
||||
if let item = firstMatch(Patterns.listItem, in: ns, range: content) {
|
||||
spans.append(Span(range: item.range(at: 2), token: .listMarker))
|
||||
var after = NSRange(
|
||||
location: item.range.upperBound,
|
||||
length: content.upperBound - item.range.upperBound
|
||||
)
|
||||
// A task checkbox is part of the marker, not of the text: `- [x] done`.
|
||||
if let box = firstMatch(Patterns.taskBox, in: ns, range: after) {
|
||||
spans.append(Span(range: box.range, token: .listMarker))
|
||||
after = NSRange(location: box.range.upperBound, length: after.upperBound - box.range.upperBound)
|
||||
}
|
||||
content = after
|
||||
}
|
||||
|
||||
scanInlines(content, in: ns, into: &spans)
|
||||
}
|
||||
|
||||
/// The inline pass, in precedence order — a code span wins over everything inside it, a link's
|
||||
/// target is never emphasis, and `**` is tried before `*` so bold does not read as two italics.
|
||||
///
|
||||
/// Claiming is by intersection against what earlier passes already took, which is what makes the
|
||||
/// order meaningful and the output non-overlapping.
|
||||
private static func scanInlines(_ range: NSRange, in ns: NSString, into spans: inout [Span]) {
|
||||
guard range.length > 0 else { return }
|
||||
var claimed: [NSRange] = []
|
||||
|
||||
func claim(_ match: NSTextCheckingResult, emit: (NSTextCheckingResult) -> [Span]) {
|
||||
guard !claimed.contains(where: { NSIntersectionRange($0, match.range).length > 0 }) else { return }
|
||||
claimed.append(match.range)
|
||||
spans.append(contentsOf: emit(match))
|
||||
}
|
||||
|
||||
for match in matches(Patterns.codeSpan, in: ns, range: range) {
|
||||
claim(match) { match in
|
||||
[
|
||||
Span(range: match.range(at: 1), token: .structural),
|
||||
Span(range: match.range(at: 2), token: .code),
|
||||
Span(range: match.range(at: 3), token: .structural)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
for match in matches(Patterns.link, in: ns, range: range) {
|
||||
claim(match) { match in
|
||||
var emitted: [Span] = []
|
||||
// The `!` of an image, the brackets and the parens: all dimmed structure.
|
||||
let openText = NSRange(location: match.range.location, length: match.range(at: 1).location - match.range.location)
|
||||
if openText.length > 0 { emitted.append(Span(range: openText, token: .structural)) }
|
||||
emitted.append(Span(range: match.range(at: 1), token: .linkText))
|
||||
let between = NSRange(
|
||||
location: match.range(at: 1).upperBound,
|
||||
length: match.range(at: 2).location - match.range(at: 1).upperBound
|
||||
)
|
||||
if between.length > 0 { emitted.append(Span(range: between, token: .structural)) }
|
||||
emitted.append(Span(range: match.range(at: 2), token: .linkTarget))
|
||||
let close = NSRange(
|
||||
location: match.range(at: 2).upperBound,
|
||||
length: match.range.upperBound - match.range(at: 2).upperBound
|
||||
)
|
||||
if close.length > 0 { emitted.append(Span(range: close, token: .structural)) }
|
||||
return emitted
|
||||
}
|
||||
}
|
||||
|
||||
for match in matches(Patterns.autolink, in: ns, range: range) {
|
||||
claim(match) { match in
|
||||
[Span(range: match.range, token: .linkTarget)]
|
||||
}
|
||||
}
|
||||
|
||||
for (pattern, token) in [
|
||||
(Patterns.strong, Token.strong),
|
||||
(Patterns.strikethrough, Token.strikethrough),
|
||||
(Patterns.emphasis, Token.emphasis)
|
||||
] {
|
||||
for match in matches(pattern, in: ns, range: range) {
|
||||
claim(match) { match in
|
||||
[
|
||||
Span(range: match.range(at: 1), token: .structural),
|
||||
Span(range: match.range(at: 2), token: token),
|
||||
Span(range: match.range(at: 3), token: .structural)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spans.sort { $0.range.location < $1.range.location }
|
||||
}
|
||||
|
||||
// MARK: - Application
|
||||
|
||||
/// Lays the pass over a text storage: base attributes everywhere, then each span's own on top.
|
||||
///
|
||||
/// **The only mutation is attributes.** `setAttributes` resets the whole body to the base run so
|
||||
/// deleted markup cannot leave its styling behind, and `addAttribute` layers each span — no
|
||||
/// character is inserted, removed, or replaced, which is 05's "presentation only" enforced by
|
||||
/// what this function is able to call.
|
||||
///
|
||||
/// Wrapped in `beginEditing`/`endEditing` so the layout manager relays once for the whole pass
|
||||
/// rather than once per span.
|
||||
@MainActor
|
||||
static func apply(_ spans: [Span], to storage: NSTextStorage, pointSize: CGFloat) {
|
||||
let full = NSRange(location: 0, length: storage.length)
|
||||
storage.beginEditing()
|
||||
storage.setAttributes(baseAttributes(pointSize: pointSize), range: full)
|
||||
for span in spans {
|
||||
let range = NSIntersectionRange(span.range, full)
|
||||
guard range.length > 0 else { continue }
|
||||
for (key, value) in attributes(for: span.token, pointSize: pointSize) {
|
||||
storage.addAttribute(key, value: value, range: range)
|
||||
}
|
||||
}
|
||||
storage.endEditing()
|
||||
}
|
||||
|
||||
/// Highlights `storage`'s current string in place — the editor's per-keystroke call.
|
||||
@MainActor
|
||||
static func highlight(_ storage: NSTextStorage, pointSize: CGFloat) {
|
||||
apply(spans(in: storage.string), to: storage, pointSize: pointSize)
|
||||
}
|
||||
|
||||
/// The unstyled run: monospaced, at the body size, in the label colour. Also the editor's
|
||||
/// `typingAttributes`, so a character typed at the end of a styled run starts out plain and the
|
||||
/// next pass — one keystroke later — decides what it really is.
|
||||
@MainActor
|
||||
static func baseAttributes(pointSize: CGFloat) -> [NSAttributedString.Key: Any] {
|
||||
[
|
||||
.font: monospaced(pointSize, weight: .regular),
|
||||
.foregroundColor: NSColor.labelColor
|
||||
]
|
||||
}
|
||||
|
||||
/// One token's presentation. Deliberately restrained — this is an editor, not a preview: the type
|
||||
/// stays monospaced throughout so columns line up, and the differences are weight, slant and
|
||||
/// colour.
|
||||
@MainActor
|
||||
static func attributes(for token: Token, pointSize: CGFloat) -> [NSAttributedString.Key: Any] {
|
||||
switch token {
|
||||
case let .heading(level):
|
||||
// Emphasized, and larger for the top two levels only — enough to read as a heading in a
|
||||
// monospaced grid without turning the editor into a preview.
|
||||
let scale: CGFloat = level <= 1 ? 1.25 : (level == 2 ? 1.12 : 1.0)
|
||||
return [
|
||||
.font: monospaced((pointSize * scale).rounded(), weight: .bold),
|
||||
.foregroundColor: NSColor.labelColor
|
||||
]
|
||||
case .strong:
|
||||
return [.font: monospaced(pointSize, weight: .bold)]
|
||||
case .emphasis:
|
||||
return [.font: italic(monospaced(pointSize, weight: .regular))]
|
||||
case .strikethrough:
|
||||
return [
|
||||
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
|
||||
.foregroundColor: NSColor.secondaryLabelColor
|
||||
]
|
||||
case .code:
|
||||
// Tinted rather than boxed: a background behind every code line in an editor makes the
|
||||
// caret hard to find.
|
||||
return [.foregroundColor: NSColor.systemTeal]
|
||||
case .listMarker:
|
||||
return [.foregroundColor: NSColor.controlAccentColor]
|
||||
case .linkText:
|
||||
return [.foregroundColor: NSColor.linkColor]
|
||||
case .linkTarget:
|
||||
return [.foregroundColor: NSColor.tertiaryLabelColor]
|
||||
case .structural:
|
||||
return [.foregroundColor: NSColor.tertiaryLabelColor]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func monospaced(_ size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
||||
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func italic(_ font: NSFont) -> NSFont {
|
||||
NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask)
|
||||
}
|
||||
|
||||
// MARK: - Line walking
|
||||
|
||||
/// Every line's range, newline **excluded** — a line's terminator is not part of anything it
|
||||
/// carries, and including it would let a heading's colour bleed onto the next line's start in a
|
||||
/// wrapped layout.
|
||||
///
|
||||
/// **`location < length`, strictly.** A position *at* the end of a text that does not end in a
|
||||
/// newline is still inside the last line, so `lineRange(for:)` answers with that line's range —
|
||||
/// which starts before the position asked about. Walking to `<=` therefore re-visits the last
|
||||
/// line forever on any text whose final line is unterminated, which in an editor is every text
|
||||
/// the user is in the middle of typing. The `upperBound > location` guard below is the same
|
||||
/// promise made twice: the walk advances or it stops.
|
||||
private static func forEachLine(in ns: NSString, _ visit: (NSRange) -> Void) {
|
||||
var location = 0
|
||||
while location < ns.length {
|
||||
let line = ns.lineRange(for: NSRange(location: location, length: 0))
|
||||
var content = line
|
||||
// Strip the terminator (`\n`, `\r\n`, `\r`, or a Unicode line separator).
|
||||
while content.length > 0 {
|
||||
let last = ns.character(at: content.upperBound - 1)
|
||||
guard last == 0x0A || last == 0x0D || last == 0x2028 || last == 0x2029 else { break }
|
||||
content.length -= 1
|
||||
}
|
||||
if content.length > 0 { visit(content) }
|
||||
guard line.upperBound > location else { return }
|
||||
location = line.upperBound
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's opening or closing code fence, if it has one: the run of backticks or tildes, and
|
||||
/// which of the two it is (a ``` block is not closed by a ~~~ line).
|
||||
private static func fenceRun(in ns: NSString, line: NSRange) -> (range: NSRange, marker: String)? {
|
||||
guard let match = firstMatch(Patterns.fence, in: ns, range: line) else { return nil }
|
||||
let run = match.range(at: 1)
|
||||
return (range: match.range, marker: ns.substring(with: NSRange(location: run.location, length: 1)))
|
||||
}
|
||||
|
||||
// MARK: - Regex plumbing
|
||||
|
||||
private static func matches(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> [NSTextCheckingResult] {
|
||||
pattern.matches(in: ns as String, options: [], range: range)
|
||||
}
|
||||
|
||||
private static func firstMatch(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> NSTextCheckingResult? {
|
||||
pattern.firstMatch(in: ns as String, options: [], range: range)
|
||||
}
|
||||
|
||||
/// Compiled once. Each is anchored the way its construct is anchored in Markdown — block
|
||||
/// patterns at the start of a line, inline patterns anywhere in it.
|
||||
///
|
||||
/// `try!` is load-bearing rather than lazy: these are literals, so a failure here is a typo that
|
||||
/// would fail on the first launch of a debug build, not a runtime condition a user can reach.
|
||||
private enum Patterns {
|
||||
static let heading = regex("^ {0,3}(#{1,6})(?:[ \t]|$)")
|
||||
static let thematicBreak = regex("^ {0,3}(?:(?:\\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
|
||||
static let blockQuote = regex("^[ \t]*(>+)[ \t]?")
|
||||
static let listItem = regex("^([ \t]*)([-*+]|\\d{1,9}[.)])(?=[ \t])")
|
||||
static let taskBox = regex("^[ \t]*\\[[ xX]\\]")
|
||||
static let fence = regex("^ {0,3}(`{3,}|~{3,})")
|
||||
static let indentedCode = regex("^(?: {4}|\t)[ \t]*\\S")
|
||||
static let codeSpan = regex("(`+)([^`]*)(\\1)")
|
||||
static let link = regex("!?\\[([^\\]\\n]*)\\]\\(([^)\\n]*)\\)")
|
||||
static let autolink = regex("<(?:https?|mailto|file):[^>\\s]*>")
|
||||
static let strong = regex("(\\*\\*|__)((?:(?!\\1).)+)(\\1)")
|
||||
static let emphasis = regex("(?<![*_\\w])([*_])((?:(?!\\1)[^\\s])(?:(?!\\1).)*)(\\1)(?![*_\\w])")
|
||||
static let strikethrough = regex("(~~)((?:(?!~~).)+)(~~)")
|
||||
|
||||
private static func regex(_ pattern: String) -> NSRegularExpression {
|
||||
// swiftlint:disable:next force_try
|
||||
try! NSRegularExpression(pattern: pattern, options: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user