Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
54792e8
chore: temporary PerfTrace typing instrumentation (Debug-only)
luca-chen198 Jul 7, 2026
b39f61c
docs: typing-perf hot-path implementation plan
luca-chen198 Jul 7, 2026
5e0b3b6
perf(tables): cache rendered table images keyed by source+font+appear…
luca-chen198 Jul 8, 2026
c7dc809
perf(wiki): splice keystroke edits into the storage form incrementally
luca-chen198 Jul 8, 2026
40c8508
perf(parse): count code fences with one UTF-16 scan, hoist tv.string
luca-chen198 Jul 8, 2026
1ea2712
perf(layout): ensureVisibleLayout walks from the viewport, not the do…
luca-chen198 Jul 8, 2026
e4d4df3
perf(parse): O(1) parsedDocument cache hits, single UTF-16 extraction
luca-chen198 Jul 8, 2026
60faf0b
fix(perf): close three semantic gaps the hot-path commits opened
luca-chen198 Jul 11, 2026
9a5a78a
perf(parse): descriptor-driven incremental parse state, O(edit) backt…
luca-chen198 Jul 11, 2026
be58a94
chore: TEMP table-shift diagnostics (docH/scrollY/vpStart in layout n…
luca-chen198 Jul 11, 2026
60c945a
fix(layout): walk ensureVisibleLayout from the document head again; s…
luca-chen198 Jul 11, 2026
f7f02b7
chore: TEMP flicker/shift diagnostics (scroll watcher, restyle triggers)
luca-chen198 Jul 11, 2026
8c8090d
perf(restyle): scope token passes to the edit, memoize per-keystroke …
luca-chen198 Jul 11, 2026
39e45a1
perf(parse): O(1) cache hit by skipping the redundant generation bump…
luca-chen198 Jul 11, 2026
8866b6e
perf(restyle): scope NSImage styler passes via per-kind indexed tokens
luca-chen198 Jul 12, 2026
51c2849
perf(diag): open the PERF frame at shouldChangeTextIn; gate the 1/64 …
luca-chen198 Jul 12, 2026
63f10c3
perf(input): answer 'is the caret in code?' from the keystroke's pars…
luca-chen198 Jul 12, 2026
cf9680b
perf(hotpath): parse pre-edit BEFORE the generation bump; one string …
luca-chen198 Jul 12, 2026
a3e0d52
perf(restyle): scope caret-move formula candidates; binary-search the…
luca-chen198 Jul 12, 2026
288c590
perf(restyle): hand the keystroke's block list to the styler — no per…
luca-chen198 Jul 12, 2026
8ce3e12
perf(tables): skip substring+hash and the unclipped spellingState wri…
luca-chen198 Jul 12, 2026
d6632f1
perf(input): keep interceptor edits on the trusted fast paths
luca-chen198 Jul 12, 2026
a479348
perf(parse): splice interior fence/$$ edits incrementally instead of …
luca-chen198 Jul 12, 2026
16735b6
perf(tables): skip by rendering need, not length uniqueness — 533-sim…
luca-chen198 Jul 12, 2026
6f7d8f1
perf(parse): binary-search the block windows; instrument the parse-st…
luca-chen198 Jul 12, 2026
ff5be7b
fix(parse): line-expand the block-delimiter guard — indented $$ opene…
luca-chen198 Jul 12, 2026
69d7e8b
diag: attribute the invisible frame share — reveal/settle + spell cal…
luca-chen198 Jul 12, 2026
20f80a2
diag: checkpoint the callback boundaries; stamp the overlay presence …
luca-chen198 Jul 12, 2026
4ce0bbd
diag: count+time the TextKit-2 fragment provider; call counts on accu…
luca-chen198 Jul 12, 2026
fe449b8
chore: trim perf tests to the core cases, drop the implementation pla…
luca-chen198 Jul 12, 2026
3e8cdfb
feat(tables): wrap cell text to the container width instead of growin…
luca-chen198 Jul 13, 2026
be098b9
Revert "feat(tables): wrap cell text to the container width instead o…
luca-chen198 Jul 13, 2026
d1fe160
Reapply "feat(tables): wrap cell text to the container width instead …
luca-chen198 Jul 13, 2026
69fe28f
feat(tables): W3C auto-layout minimums — never below the longest word…
luca-chen198 Jul 13, 2026
6efe6b7
fix(spelling): suppress spell squiggles inside tables — the stray red…
luca-chen198 Jul 13, 2026
6627a59
fix(cursor): arrow instead of I-beam over wide-table scroll overlays
luca-chen198 Jul 13, 2026
f4ade73
chore: drop the spell-suppression unit test and the red-dot diagnostics
luca-chen198 Jul 13, 2026
082fda7
fix(cursor): limit the arrow to the wide-table overlay's horizontal s…
luca-chen198 Jul 13, 2026
917370e
Propagate lists config to the live editor in updateNSView
luca-chen198 Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Demo/MarkdownEngineDemo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,34 @@ private var sampleMarkdown: String {
[
markdownHeader,
inlineFormattingSection,
tableSection,
latexSection,
codeSection,
markdownFooter,
].joined(separator: "\n\n")
}

/// Table layout demo: the first table's cells WRAP to the available width
/// (CSS auto-layout style); the second has so many columns that even the
/// longest-word minimums don't fit — it stays wide and scrolls horizontally.
private let tableSection = """
## Tables

Cells wrap to the available width:

| Rechtsform | Gründungskosten | Laufende Kosten/Jahr |
|---|---|---|
| Einzelunternehmen (Kleingewerbe) | 20–60€ (Gewerbeanmeldung) | ~0€ (nur Steuerberater optional, 300–800€) |
| GbR (mit zwei Gesellschaftern) | 20–60€ x Anzahl Gesellschafter (jeder meldet einzeln an) | Gesellschaftervertrag empfohlen (Anwalt: 500–1.500€ einmalig) |
| UG (haftungsbeschränkt) | Notar + Handelsregister: ~300–500€ (Musterprotokoll) bis 1.000€+ | IHK-Beitrag (~150–400€), Steuerberater fast Pflicht |

Too many columns → horizontal scroll instead of crushed cells:

| Rechtsformvergleich | Gründungskostenaufstellung | Haftungsbeschränkung | Steuerberaterkosten | Handelsregistereintrag | Stammkapitalanforderung |
|---|---|---|---|---|---|
| Einzelunternehmen | Gewerbeanmeldung | unbeschränkt | optional | nein | keines |
"""

private let markdownHeader = """
# MarkdownEngine

Expand Down
135 changes: 135 additions & 0 deletions Sources/MarkdownEngine/Diagnostics/PerfTrace.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//
// PerfTrace.swift
// MarkdownEngine
//
// Created by Luca Chen on 07.07.26.
//
// TEMP diagnostics (typing performance). Prints one compact line per keystroke
// with a per-phase breakdown plus the current document length, so we can see
// which costs grow with file size instead of staying constant. The whole point:
// type in a short file, then a long one, and compare `total` for the same edit.
//
// Toggle: set the env var MD_PERF=0 in the run scheme to silence.
// Debug-only — the whole thing compiles out in Release.
// Remove before shipping (this file + the `PerfTrace.` call sites).
//

import Foundation

enum PerfTrace {
#if DEBUG
static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0"
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
/// backtick census, parse buffer). They run 3× O(doc) work synchronously
/// on every 64th keystroke — periodic spikes that pollute the PERF
/// numbers — so they stay off unless explicitly requested.
static let verifyEnabled = ProcessInfo.processInfo.environment["MD_PERF_VERIFY"] == "1"
#else
static let enabled = false
static let verifyEnabled = false
#endif

// All call sites run on the main thread (the coordinator + text view are
// main-actor), so plain static state is safe under the package's Swift 5 mode.
private static var active = false
private static var frameStart: UInt64 = 0
private static var docLength = 0
private static var phases: [(String, Double)] = []
private static var notes: [String] = []
/// Summed costs for code that runs MANY times per frame or from inside
/// AppKit callbacks (caret reveal, spell-checker callbacks) — printed as
/// `+label=…(×n)` after the sequential phases.
private static var accumulated: [(String, Double, Int)] = []

private static func nowMs() -> Double {
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000
}

/// Open a per-keystroke frame. Every `measure`/`note` until `end()` attaches to it.
/// A frame already opened this keystroke is CONTINUED, not reset:
/// shouldChangeTextIn opens the frame (so the pre-edit parse and the
/// smart-input interceptors are counted — they used to run before the
/// frame and were invisible), the mid-edit selection change and
/// textDidChange attach to it. A frame left open by an edit that never
/// reached textDidChange is considered stale after 1s and reset.
static func begin(docLength len: Int) {
guard enabled else { return }
let now = DispatchTime.now().uptimeNanoseconds
docLength = len
if active, Double(now - frameStart) / 1_000_000 < 1_000 { return }
active = true
phases.removeAll(keepingCapacity: true)
notes.removeAll(keepingCapacity: true)
accumulated.removeAll(keepingCapacity: true)
frameStart = now
}

/// Like `measure`, but SUMS repeated calls under one label instead of
/// appending a phase per call — for work triggered from inside AppKit
/// (caret reveal, spell-checker callbacks) that can fire several times
/// per keystroke and would otherwise stay invisible in the frame.
@discardableResult
static func accumulate<T>(_ label: String, _ body: () -> T) -> T {
guard enabled, active else { return body() }
let t0 = nowMs()
let result = body()
let dt = nowMs() - t0
if let i = accumulated.firstIndex(where: { $0.0 == label }) {
accumulated[i].1 += dt
accumulated[i].2 += 1
} else {
accumulated.append((label, dt, 1))
}
return result
}

/// Time one sequential top-level phase of the current frame.
@discardableResult
static func measure<T>(_ label: String, _ body: () -> T) -> T {
guard enabled, active else { return body() }
let t0 = nowMs()
let result = body()
phases.append((label, nowMs() - t0))
return result
}

/// Attach a free-form detail line (e.g. how many tables were re-rendered).
/// The closure only runs when tracing is active, so it costs nothing when off.
static func note(_ make: () -> String) {
guard enabled, active else { return }
notes.append(make())
}

/// Record a named timestamp (offset from frame start) inline in the
/// breakdown, printed as `@label=12.34`. The gaps BETWEEN checkpoints and
/// the measured spans locate work the spans don't cover (AppKit edit
/// application, layout, notification dispatch between our callbacks).
static func checkpoint(_ label: String) {
guard enabled, active else { return }
phases.append(("@" + label, Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000))
}

/// Close the frame and print total + per-phase breakdown + notes.
/// `other` = total − Σ(phases + accumulated): time inside the frame that
/// no span covers (AppKit edit processing, layout, unmeasured code).
static func end() {
guard enabled, active else { return }
active = false
let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000
var breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ")
if !accumulated.isEmpty {
breakdown += " " + accumulated.map { String(format: "+%@=%.2f(×%d)", $0.0, $0.1, $0.2) }.joined(separator: " ")
}
let covered = phases.filter { !$0.0.hasPrefix("@") }.reduce(0) { $0 + $1.1 }
+ accumulated.reduce(0) { $0 + $1.1 }
print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@ other=%.2f", docLength, total, breakdown, total - covered))
for note in notes { print(" └─ \(note)") }
}

/// Standalone timing print for a cost that runs *outside* the keystroke frame
/// (e.g. the async wide-table overlay reconcile fired after the edit settles).
static func stamp(_ label: String, _ ms: Double, _ detail: @autoclosure () -> String = "") {
guard enabled else { return }
print(String(format: "⏱️ PERF %@ %.2fms %@", label, ms, detail()))
}
}
15 changes: 13 additions & 2 deletions Sources/MarkdownEngine/Input/MarkdownInputHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,26 @@ import AppKit

enum MarkdownInputHandler {

static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?) -> Bool {
return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange, replacementString: replacementString)
/// `codeTokens` (codeBlock + inlineCode, from the keystroke's existing
/// parse) answers "is the caret in code?" without the O(doc) document
/// scan the handler otherwise runs on every space/Enter/Tab.
static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, codeTokens: [MarkdownToken]? = nil) -> Bool {
let isInsideCodeBlock = codeTokens.map {
MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, codeTokens: $0)
}
return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock)
}

// MARK: - Block LaTeX Auto-Wrap

private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) {
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = true
// Replaces a suppressed keystroke that never applied — reset its
// pending count so this edit registers as the cycle's single
// tracked edit and textDidChange keeps the trusted fast paths.
coord.pendingEditCount = 0
}
textView.insertText(text, replacementRange: range)
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
Expand Down
44 changes: 35 additions & 9 deletions Sources/MarkdownEngine/Input/MarkdownListHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ struct MarkdownLists {
let len = min(range.length, max(0, maxLen))
let safeRange = NSRange(location: loc, length: len)

if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = true }
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = true
// This edit REPLACES a suppressed keystroke that never applied.
// Dropping its pending count lets the shouldChangeText below
// re-register as the cycle's single tracked edit, so textDidChange
// keeps the trusted fast paths (the descriptor is refreshed for
// every proposed edit and describes THIS transition exactly).
coord.pendingEditCount = 0
}
defer {
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = false }
}
Expand Down Expand Up @@ -87,10 +95,14 @@ struct MarkdownLists {

// MARK: - Input Handling

static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?) -> Bool {
/// `isInsideCodeBlock` is the caller's pre-parsed answer for
/// `affectedCharRange.location` (the coordinator derives it from the
/// keystroke's existing parse). `nil` — direct callers without a parse —
/// falls back to deriving it here, which walks the whole document.
static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, isInsideCodeBlock: Bool? = nil) -> Bool {
guard let replacementString = replacementString else { return true }

// Fast path: skip the expensive isInsideCodeBlock scan for ordinary typing.
// Fast path: plain characters never trigger list/pair/arrow handling.
if replacementString.count == 1,
let ch = replacementString.first,
ch != ">" && ch != "[" && ch != "(" && ch != "{" &&
Expand All @@ -109,9 +121,11 @@ struct MarkdownLists {
return false
}

let isInCodeBlock = textView.string.contains("`")
? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string)
: false
let isInCodeBlock = isInsideCodeBlock ?? (
textView.string.contains("`")
? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string)
: false
)

if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock {
let insertionLocation = affectedCharRange.location
Expand Down Expand Up @@ -204,12 +218,24 @@ struct MarkdownLists {
// Horizontal rules render via the styler; source stays literal `---` so files round-trip.

if currentLine.range(of: "^```\\w*$", options: .regularExpression) != nil {
let textBeforeLine = nsText.substring(to: currentLineRange.location)
let openingCount = textBeforeLine.components(separatedBy: "```").count - 1
// Non-overlapping ``` count before the line (what
// components(separatedBy:).count-1 computed, without
// materializing an O(doc) substring array).
var openingCount = 0
var searchLocation = 0
while searchLocation < currentLineRange.location {
let found = nsText.range(of: "```", options: [],
range: NSRange(location: searchLocation,
length: currentLineRange.location - searchLocation))
if found.location == NSNotFound { break }
openingCount += 1
searchLocation = NSMaxRange(found)
}
let afterLineStart = currentLineRange.location + currentLineRange.length
let hasClosingAfter: Bool = {
guard afterLineStart < nsText.length else { return false }
return nsText.substring(from: afterLineStart).contains("```")
let after = NSRange(location: afterLineStart, length: nsText.length - afterLineStart)
return nsText.range(of: "```", options: [], range: after).location != NSNotFound
}()
let lineEnd = currentLineRange.location + max(0, currentLineRange.length - 1)
let cursorAtLineEnd = affectedCharRange.location >= lineEnd
Expand Down
Loading
Loading