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