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:
@@ -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