chore: import upstream snapshot with attribution
CI / test (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:34:54 +08:00
commit 5b99bf6bca
226 changed files with 42157 additions and 0 deletions
@@ -0,0 +1,365 @@
---
name: edmund-architecture-contract
description: >
The load-bearing design contract of the Edmund Markdown editor. Load BEFORE
any non-trivial code change in this repo; when asking "why is it built this
way"; before proposing a new mechanism, subsystem, or refactor; whenever you
are tempted to insert/strip display characters, use NSTextAttachment, touch
NSTextView.layoutManager, store NSTextBlock/NSTextTable attributes, or add a
new overlay/decoration; and before designing anything that syncs storage,
selection, undo, or the viewport. Covers the two hard invariants
(storage == rawSource; TextKit 2 only), the render pipeline, edit/undo flow,
the TextKit 2 drawing model, the read-mode contract, and the known weak
points. Not for build/run/release mechanics, debugging triage, or live-repro
drivers — see "When NOT to use this skill".
---
# Edmund architecture contract
Edmund is a native macOS Markdown editor with live preview: AppKit +
TextKit 2, SwiftPM, macOS 14+. Two targets (`Package.swift`):
| Target | Role |
| --- | --- |
| `EdmundCore` | Library: parsing, rendering, `EditorTextView`, all tests. Most work happens here. |
| `edmd` | Executable: `NSDocument` app shell, Settings (SwiftUI), menus. Note: `edmd` is the Mach-O binary name; the app is "Edmund". |
Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of
Markdown editors". Bias toward polish of the editing experience over feature
count. The hardest live problem class to date is **delete-drift**
(caret/selection integrity, 6 investigation rounds); the costliest failures
were delete-drift and undo/redo viewport drift. Every rule below traces to one
of those scars.
Ground truth this file distills: `docs/ARCHITECTURE.md` (repo root).
Treat that doc as authoritative if the two ever disagree, and fix this skill.
## Glossary (each term defined once)
- **rawSource** — the document's Markdown text, the single source of truth
(`EditorTextView.rawSource`).
- **storage** — the `NSTextStorage` the text view displays
(`EditorTextStorage`, `Sources/EdmundCore/TextView/EditorTextStorage.swift`).
- **Block** — one logical Markdown block (paragraph, heading, list run, code
fence, table, quote/callout run). Model: `Sources/EdmundCore/Model/Block.swift`.
- **Active block** — the block under the caret; it renders its *raw* markdown
(delimiters visible/editable) while all others render styled.
- **Recompose** — restyling storage from `blocks`
(`Sources/EdmundCore/TextView/EditorTextView+Composition.swift`).
- **Fragment** — an `NSTextLayoutFragment`, TextKit 2's per-paragraph layout
unit. Off-screen fragments have *estimated* heights until laid out.
- **Overlay** — an image or stroked path drawn at a character's laid-out
position by the custom fragment class (see §4), replacing what
`NSTextAttachment` would do in TextKit 1.
- **Delete-drift** — the bug class where `rawSource`/storage/selection desync
and every later edit lands the caret in the wrong place. Chronicle:
`docs/investigations/delete-drift-investigation.md`.
- **IME composition** — an input method's provisional "marked text"
(`hasMarkedText()`), present in storage before the user commits it.
---
## 1. The two non-negotiable invariants
Break either and the editor misbehaves in subtle, delayed ways. Every design
review starts here.
### Invariant 1 — storage == rawSource (attribute-only rendering)
The displayed text storage is *always* character-identical to `rawSource`.
Rendering only ever adds/changes **attributes**. Delimiters (`**`, `` ` ``,
`[!note]`, …) are **hidden, never stripped**: `hiddenFont` (0.01pt system
font, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55`) plus a
clear `foregroundColor` makes them invisible without touching the string.
**Rationale.** Identity mapping between display offsets and raw offsets means
there is no offset-translation layer — caret math, selection, undo diffs,
incremental reparse, and autosave all operate on one coordinate system.
**Consequences you must respect:**
| Consequence | Why |
| --- | --- |
| No `NSTextAttachment`, ever | TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which `rawSource` never contains. Images, math, bullets, checkboxes, icons are drawn as **overlays** instead (§4). |
| No inserted display characters | A synthesized `<br>`, bullet glyph, or padding character would desync offsets. Use attributes (`.kern`, paragraph styles) or overlays. |
| Never mutate storage while IME is composing | During composition, storage holds marked text so the invariant is *transiently* false and `didChangeText` defers syncing. Styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard `!hasMarkedText()`. |
**The incident.** The original delete-drift bug: an async restyle fired during
IME composition, stranded the marked text, `didChangeText` kept bailing on its
own guard, and the invariant stayed silently broken — caret drift on every
subsequent edit. `becomeFirstResponder` now resyncs from storage as a
catch-all. Full write-up: `docs/investigations/delete-drift-investigation.md`.
### Invariant 2 — TextKit 2 only
Never touch `NSTextView.layoutManager` (the TextKit 1 `NSLayoutManager`
accessor) and never store `NSTextBlock`/`NSTextTable` attributes. Either one
**silently and permanently reverts the view to TextKit 1** — no error, no log,
just different (and wrong-for-us) layout from then on.
**Rationale.** All custom drawing rides `NSTextLayoutFragment` subclassing
(§4), and viewport-based layout (only on-screen content laid out) is what
makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback
kills them.
**The tripwire.** DEBUG builds observe
`NSTextView.willSwitchToNSLayoutManagerNotification` and `assertionFailure`
if the fallback ever triggers —
`textKit1FallbackTripwire(_:)`,
`Sources/EdmundCore/TextView/EditorTextView.swift:272-303`. If you see that
assertion, some code path you touched used a TK1 API or attribute. Find it;
do not suppress the assert.
Corollary: Edit-mode tables cannot use `NSTextTable`. Alignment is done by
distributing slack via `.kern` on hidden pipe glyphs —
`Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift`.
---
## 2. Render pipeline
```
rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage
└─ SyntaxHighlighter (swift-markdown walker
+ custom parsers: callouts, ==highlight==,
wikilinks, comments, footnotes, math,
backslash escapes, inline HTML tags)
```
| Stage | Where |
| --- | --- |
| Block splitting | `Sources/EdmundCore/Parsing/BlockParser.swift` — `parse(_:previous:)`, `parseWithDiff(...)` |
| Span production | `Sources/EdmundCore/Parsing/SyntaxHighlighter.swift` + `+Walker.swift` / `+WalkerInline.swift` / `+CustomParsers.swift` |
| One-block render | `styleBlock(_:cursorPosition:...)`, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151`; per-feature extensions in `Sources/EdmundCore/Rendering/` (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks) |
| Orchestration | `Sources/EdmundCore/TextView/EditorTextView+Composition.swift` |
Recompose entry points (pick the narrowest that works — a full `recompose`
resets every fragment height to an estimate, see §6):
| Function | Scope | Used for |
| --- | --- | --- |
| `recompose(cursorInRaw:)` | Whole document | Load, indent — never for undo (§3) |
| `recomposeDirty(_:cursorInRaw:)` | A set of block indices, in place | The workhorse; attribute-only |
| `recomposeIncremental(cursorInRaw:...)` | The block(s) the caret moved between | Most cursor moves |
| `recomposeReplacing(oldRange:with:...)` | One contiguous text span | Undo/redo restore |
**Lazy styling** (`Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift`):
a large dirty set styles only the viewport synchronously; the rest is finished
by the **idle drain** (time-budgeted main-thread slices) and **scroll
promotion** (style blocks as they enter the viewport).
Gotcha: attribute-only changes do **not** re-measure geometry in TextKit 2.
If a restyle changed a block's height/indent, call `invalidateLayout(for:)`
on its range or the fragment keeps a stale frame. `recomposeDirty` and the
idle drain already do this; any new path must too.
---
## 3. Edit flow & undo
Normal edit: `shouldChangeText` (records a coalesced undo snapshot) →
NSTextView mutates storage → `didChangeText` syncs `rawSource` and restyles
the edited block(s) (`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`,
`+Composition.swift`). Edits capture a `pendingEdit` on `EditorTextStorage`
and reparse a window, not the whole document.
**Undo/redo is custom** (`Sources/EdmundCore/TextView/EditorTextView+Undo.swift`):
stacks of `rawSource` snapshots, bypassing NSTextView's built-in undo.
Restoring diffs the snapshot against current text (`textDiff(old:new:)`,
single contiguous span) and applies it with the range-bounded
`recomposeReplacing` — **never a full `recompose`**, because a full recompose
resets every fragment to a TextKit 2 height estimate and the follow-up scroll
lands wrong (this was the undo/redo viewport-drift failure). The changed text
drives the viewport: hold if any of it is on-screen, else center it.
**AppKit does NOT pair every storage mutation with `didChangeText`.** Proven
incident (delete-drift round 4): a drag-move of selected text dropped on no
valid target deletes the dragged range via `shouldChangeText` →
`replaceCharacters` and never calls `didChangeText` — silently freezing
`rawSource`/`blocks`; every later edit drifts the caret and autosave writes
stale text. The heal: `shouldChangeText` schedules a next-run-loop
**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`) — a
`pendingEdit` still unconsumed by then means the closing `didChangeText`
never came, and the editor runs the same sync itself. Breadcrumb in
`~/.edmund/logs`: `healing storage edit that bypassed didChangeText`.
**Never build a sync path on the assumption that `didChangeText` follows
every edit.**
Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection
fixup (`_fixSelectionAfterChangeInCharacterRange:`) queued; it fires at the
**next** `endEditing` — even an attribute-only restyle — and leaps the caret
blocks away, moving even a freshly set valid caret. The heal sets the caret
before the sync **and re-asserts it after** (`+EditFlow.swift`). This class
does not reproduce headless; see the routing in §8.
---
## 4. TextKit 2 drawing model
All custom visuals are drawn by `DecoratedTextLayoutFragment` (custom
`NSTextLayoutFragment`,
`Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160`), vended via
the layout-manager delegate. Two custom attribute keys (same file, lines
28/32):
| Attribute | Level | Draws | Rules |
| --- | --- | --- | --- |
| `.blockDecoration` | Paragraph | Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds | Fragments **tile vertically** so a multi-line run reads as one continuous box/bar. A box's `bottomPad` grows the **last** fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space. |
| `.fragmentOverlay` | Character | An image **or stroked vector path** at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) | The anchor glyph is hidden (`hiddenFont` + clear color) and `.kern` reserves the drawing's advance width — the same trick the table renderer uses. |
**The image-wedge constraint (open, not solved).** Drawing an *image* overlay
on a *multi-line (wrapping)* fragment re-triggers a layout pass that wedges
the fragment to one line. Drawing a *shape* (stroked `CGPath`) does not.
That is why the wrapping callout custom-title icon is a stroked path parsed
from vendored Lucide geometry, never an image. **Any new overlay that could
share a line with wrapping text must be a shape, not an image.** Full saga:
`docs/investigations/archives/callout-title-wrap-investigation.md`.
---
## 5. Read mode contract
Read mode is a separate `WKWebView`, not an editor styling mode
(`Sources/EdmundCore/Export/`). The contract: **one parser, two back-ends** —
the *same* swift-markdown `Document` the editor parses is walked by
`SyntaxHighlighter.SpanCollector` (→ editor attributes) and by `HTMLRenderer`
(→ HTML), themed from the *same* `EditorTheme` via `HTMLTheme`, so the two
renderings cannot drift. When adding a feature, implement it in **both**
back-ends or document the divergence.
Hard properties of the web view (keep them):
- **JavaScript disabled**; every asset inlined (math as high-DPI PNG data
URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs
no file/network reach. Remote images off by default
(`Sources/EdmundCore/Export/ReadRenderOptions.swift`).
- **Private URL schemes** route navigation without JS:
`x-edmund-wiki:` / `x-edmund-link:`
(`Sources/EdmundCore/Export/HTMLRenderer.swift:26,31`).
- Inline HTML: only the whitelist `SyntaxHighlighter.htmlFormatTags`
(`u`/`kbd`/`mark`/`sub`/`sup`,
`Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22`) renders in either
mode; everything else stays escaped/color-only. A real `<br>` break would
need to mutate storage — forbidden by Invariant 1.
- Export/Print run the same HTML through `WKWebView.printOperation`
(`Sources/EdmundCore/Export/MarkdownPrinter.swift`) for vector text.
---
## 6. Known weak points (open as of 2026-07-05)
State these plainly when designing near them; none is solved.
1. **TextKit 2 height estimates** are the root of most viewport glitches: an
off-screen fragment's frame (and the total document height) is an estimate
until layout reaches it — scroller jumps, scroll-to-target lands wrong (a
documented TK2 limitation; TextEdit shows it too). Mitigations in place,
not cures: documents ≤ `fullLayoutMaxLength` (100k UTF-16,
`Sources/EdmundCore/TextView/EditorTextView.swift:80`) are kept fully laid
out by `scheduleFullLayoutSettle()` wrapped in `preservingViewportAnchor`
(`+LazyStyling.swift:121`, `+TypewriterScroll.swift:22`);
`repairContentAboveOrigin()` (`+LazyStyling.swift:151`) fixes content
stranded above y=0; `centerViewportOnCaret` re-measures after its first
scroll. **Never trust an off-screen fragment's y-coordinate without laying
out the span first.**
2. **The image-wedge constraint** (§4) applies to every new overlay.
3. **Open bugs** (`misc/backlog.md`): callout at end-of-file renders an extra
un-prefixed line in the callout color (live incremental-restyle path, not
static rendering); footnotes don't render in either mode; attached images
create blank space below; math doesn't render in read mode (and has wrong
padding in edit mode); delete caret drift and viewport-estimate glitches
remain on the ongoing list.
4. **Crash reporter endpoint is a placeholder**:
`CrashReporter.reportingEndpoint` is `https://REPLACE-ME.invalid/crash`
(`Sources/EdmundCore/Diagnostics/CrashReporter.swift:27`) and the
Settings ▸ Advanced toggle is commented out
(`Sources/edmd/Settings/AdvancedSettingsView.swift`). Do not treat crash
uploading as live.
---
## 7. Before you design something new — checklist
Run this before proposing any new mechanism, subsystem, or refactor:
- [ ] **Invariant 1**: does it insert/strip characters, use
`NSTextAttachment`, or mutate storage outside the
shouldChangeText→didChangeText path (or during IME composition)? If
yes, redesign as attributes/overlays.
- [ ] **Invariant 2**: does it touch `NSTextView.layoutManager` or store
`NSTextBlock`/`NSTextTable`? If yes, stop.
- [ ] **Sync assumptions**: does it assume `didChangeText` follows every
mutation, or that a set caret stays put across the next `endEditing`?
Both assumptions are proven false (§3).
- [ ] **Geometry**: does it read an off-screen fragment frame, or restyle
without `invalidateLayout(for:)` when height changed? (§2, §6.)
- [ ] **Both back-ends**: does a rendering feature cover Edit *and* Read
(§5)?
- [ ] **Prior art**: check ARCHITECTURE.md §14 — especially
[nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine),
an independent AppKit+TextKit 2 live-preview engine solving the same
problems — before inventing a new mechanism for an editing-experience
problem.
- [ ] **Weak points** (§6): does the design lean on anything listed there?
Label it as such; unproven mitigations are "open/candidate", never
"fixed".
- [ ] **Verification plan**: unit test if headless can repro; otherwise plan
a live repro (ReproScript) — do not ship a caret/IME/viewport fix on
reasoning alone. Visual claims are measured from `screencapture`
pixels, not eyeballed.
Process rules live in sibling skills, but never contradict them here: branch
per fix off `main`; never auto-push/PR/merge; `swift test` green + visual
verification before commit; never blanket `pkill -x edmd` (the maintainer's
daily-driver app shares the binary name — `pgrep` and kill only your own
PID); never request macOS Computer Access permissions.
---
## When NOT to use this skill
| You need… | Use instead |
| --- | --- |
| Whether/how to gate a change, commit discipline, scope control | `edmund-change-control` |
| A symptom → cause triage path for a bug you're seeing | `edmund-debugging-playbook` |
| The blow-by-blow history of a past investigation | `edmund-failure-archaeology` |
| TextKit 2 / AppKit theory beyond Edmund's specific contract | `textkit2-appkit-reference` |
| Launch flags, debug bundles, defaults keys | `edmund-config-and-flags` |
| Build, stale-binary cures, screencapture mechanics, environment setup | `edmund-build-and-env` |
| Cutting a release, Sparkle/appcast/CI | `edmund-release-and-operate` |
| Driving a live repro (ReproScript, CGEvent, log tracing) | `edmund-live-repro-and-diagnostics` |
| Test-writing patterns, QA passes | `edmund-validation-and-qa` |
| Docs style, ARCHITECTURE.md upkeep | `edmund-docs-and-writing` |
| Positioning, comparisons, marketing claims | `edmund-external-positioning` |
| The caret/selection-integrity campaign specifically | `edmund-caret-integrity-campaign` |
| How to investigate an unknown (method, not facts) | `edmund-research-methodology` / `edmund-research-frontier` |
---
## Provenance and maintenance
Facts verified against the repo on 2026-07-05. If a grep below stops
matching, the fact drifted — update this file and cite the new location.
```bash
# Invariant 2 tripwire still present
grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift
# Recompose entry points
grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift
# Bypass heal
grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
# Custom draw attributes + fragment class
grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift
# hiddenFont hiding trick
grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift
# Viewport mitigations
grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift
grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift
# Read-mode schemes + HTML whitelist
grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift
grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift
# Crash-reporter placeholder (delete §6.4 once this is a real URL)
grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift
# Open-bug list
sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md
```
@@ -0,0 +1,258 @@
---
name: edmund-build-and-env
description: >
Build, environment, and toolchain runbook for the Edmund repo (native macOS
Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting
up the environment from scratch (fresh clone, new machine, CI mirror); a
build fails or a "successful" build behaves stale (change "doesn't take",
old code runs, Build complete! but nothing changed); the app crashes on
launch or the instant it renders LaTeX; you need to construct the debug
bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just
built for a visual check or repro run. Covers swift build/test,
build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement),
the stale-build disease and its detection, safe launch/kill hygiene around
the user's live instance, and the CI environment.
---
# Edmund — build & environment runbook
All paths relative to the repo root.
Facts date-stamped 2026-07-05 are volatile — re-verify per the last section.
## When NOT to use this skill
| You actually need | Go to |
|---|---|
| The storage==rawSource / TextKit-2-only invariants, render pipeline | `edmund-architecture-contract` |
| Branch/commit/PR rules, what you may touch | `edmund-change-control` |
| Diagnosing a bug (not the build) | `edmund-debugging-playbook` |
| ReproScript / CGEvent live-repro driving | `edmund-live-repro-and-diagnostics` |
| Launch flags, defaults keys, settings | `edmund-config-and-flags` |
| Cutting a release, DMG, Sparkle appcast | `edmund-release-and-operate` |
| Screencapture verification method, test policy | `edmund-validation-and-qa` |
## 1. Environment from scratch
Requirements (verified 2026-07-05):
| Tool | Version | Why | Check |
|---|---|---|---|
| macOS | 14+ | `Package.swift` platforms `.macOS(.v14)` | `sw_vers` |
| Xcode | 16+ (full Xcode, not just CLT) | `swift-tools-version: 6.0`; `build-app.sh` needs `actool` | `swift --version` (local: Swift 6.0.3) |
| gh CLI | any recent | releases, PR ops | `gh --version` |
| Node | ≥20 | releases only | `node --version` |
| create-dmg | **npm** package | releases only | `npm install --global create-dmg` |
**create-dmg trap**: install via npm, NOT Homebrew. `brew install create-dmg`
is a *different tool* with an incompatible CLI. Not needed for dev work —
only for cutting releases (see `edmund-release-and-operate`).
Dependencies are fetched by SPM on first build — nothing to install by hand
(verified against `Package.swift` / `Package.resolved`, 2026-07-05):
- `swift-markdown` ≥0.5.0 (CommonMark/GFM parsing; pulls `swift-cmark` transitively)
- `SwiftMath` ≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3)
- `Sparkle` ≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4)
Two SPM targets: **EdmundCore** (library + all tests; most work happens here)
and **edmd** (the app shell executable). The binary is named `edmd` even
though the app presents as "Edmund" — deliberate, see the comment in
`Package.swift`.
## 2. Core commands
```bash
swift build # debug build of both targets
swift test # full suite: ~750+ tests, ~10s (2026-07-05)
swift test --filter Callout # one suite
./scripts/build-app.sh # release build → build/Edmund.app
```
`swift test` also runs automatically as a Stop hook after code-touching turns.
## 3. What build-app.sh actually does (and why the order matters)
`scripts/build-app.sh``build/Edmund.app`. Steps, in order:
1. `swift build -c release`
2. Assemble `build/Edmund.app/Contents/{MacOS,Resources}`; copy
`.build/release/edmd`, `Info.plist`, `Resources/AppIcon.icns`.
3. Compile `Resources/Assets.xcassets` with `actool` (falls back to
`/Applications/Xcode.app/.../actool` if xcode-select points at the CLT).
4. Embed `Sparkle.framework` into `Contents/Frameworks/` (found under
`.build/`; SwiftPM links Sparkle but never copies the framework — without
it the updater crashes on first check) and `install_name_tool -add_rpath
"@executable_path/../Frameworks"` so `@rpath` resolves post-install.
5. Codesign **inside-out**: Sparkle.framework first (nested XPC helpers must
be signed before macOS will launch them), then the whole `.app` (ad-hoc,
`--deep`, identifier `com.i7t5.edmd`). Sealing the *bundle* — not just the
binary — is what Sparkle's update validator requires.
6. **Only after sealing**: copy `.build/release/*.bundle` (SwiftMath's math
fonts) into the `.app` **root**.
Why step 6 is last and at the root — two constraints collide:
- `codesign` refuses to seal a bundle with any extra item at the `.app` root
("unsealed contents present in the bundle root"), so the seal must happen
while the root holds only `Contents/`.
- SwiftMath's generated `Bundle.module` accessor hardcodes
`Bundle.main.bundleURL` — the `.app` root — with only a hardcoded absolute
`.build` path as fallback. So the bundle *must* sit at the root.
Resolution: seal first, copy after. The one unsealed root item makes
`codesign --verify` and `--strict` complain, but Sparkle's actual check is
non-strict and tolerates it (verified end-to-end; details in
`docs/ARCHITECTURE.md` §8).
**Missing SwiftMath bundle = instant crash the moment the app renders any
LaTeX.** App launches fine, opens documents fine, dies on the first math
block. If you see that crash, check `ls build/Edmund.app/*.bundle` first.
## 4. Debug bundle fast path (EdmundDbg.app)
For live runs of a *debug* build, skip `build-app.sh` and hand-assemble
(from `docs/dev-guides/live-repro-guide.md` §4):
```bash
swift build
mkdir -p build/EdmundDbg.app/Contents/MacOS
cp Info.plist build/EdmundDbg.app/Contents/
cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/
cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/
```
- **Sparkle.framework must sit next to the binary** — dyld aborts without it.
- A bare `.build/debug/edmd` runs but **never creates a window**. It needs
the bundle (Info.plist) around it.
- **Launch by direct exec of the bundle binary, never `open -a`**:
```bash
build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \
-ApplePersistenceIgnoreState YES &
```
LaunchServices (`open -a`) can silently run a stale cached/translocated
copy — you'd be executing last hour's code. Direct exec runs exactly the
binary you just copied. `-ApplePersistenceIgnoreState YES` stops state
restoration from reopening previous (possibly mutated) documents.
- Recreate the test document fresh before every run — autosave mutates it.
## 5. THE STALE BUILD DISEASE
The single most expensive trap in this repo: it has produced entire wrong
debugging conclusions ("my fix doesn't work" when the fix was never in the
binary).
**Symptom**: `swift build` prints `Build complete!` having compiled a changed
file but **not relinked `edmd`**. The app then runs old code. Release builds
(`swift build -c release` / `build-app.sh`) reuse stale objects too.
**Detection — before trusting ANY binary you just built:**
```bash
# 1. Grep for a LONG string literal unique to your new code:
strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal'
# 2. Hash before/after the build:
shasum .build/arm64-apple-macosx/debug/edmd
```
**Literal length matters**: string literals ≤15 bytes are stored inline in
the Mach-O on arm64 and *never appear* in `strings` output. A short probe
literal gives a false "stale" verdict. Use a long one (a distinctive log
message works well).
**Cure:**
```bash
swift package clean # first resort
rm -rf .build # visual change "doesn't take" → nuke it all
```
**Never hand-delete `.build/…/edmd.build/`** — that corrupts SwiftPM's
output-file-map and wedges the target until a full clean anyway.
## 6. Running for visual checks — launch/kill hygiene
**The user's daily-driver app has the same binary name (`edmd`).** A blanket
`pkill -x edmd` kills their live session. Always, in order:
```bash
# 1. Who is running, and since when?
pgrep -lx edmd
ps -o lstart=,command= -p <pid>
# 2. Kill ONLY your own PID — or, if you launched the debug bundle:
pkill -f EdmundDbg
```
Other run gotchas:
- `open Edmund.app` **foregrounds a running instance instead of
relaunching** — you'll be looking at the old binary. Kill your instance
first or direct-exec the binary.
- Always pass `-ApplePersistenceIgnoreState YES` (see §4).
- After many rapid launch/kill cycles the window server can glitch (tiny
windows, broken state restoration):
`rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState`
and relaunch.
- Verification method (window-id screencapture, offscreen render fallback):
`edmund-validation-and-qa` and `docs/ARCHITECTURE.md` §8.
## 7. CI environment
`.github/workflows/ci.yml` (verified 2026-07-05): runs `swift test` on
`macos-14` with `latest-stable` Xcode (Swift 6.0 needs Xcode 16+), triggered
on PRs and pushes to `main`.
- **SPM cache**: `.build` is cached keyed on
`spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}`. The `v2`
token exists because the repo rename `md``Edmund` changed the checkout
path and invalidated absolute paths baked into the cached module cache —
bump the token to discard a poisoned cache.
- **Concurrency**: `cancel-in-progress: true` per branch/PR — private-repo
macOS minutes bill at 10x, so superseded commits' runs are cancelled.
- Release pipeline (`release.yml`, tag-triggered) is a separate beast:
`edmund-release-and-operate` / `docs/ARCHITECTURE.md` §13.
## 8. Checklists
### Fresh clone to green
- [ ] `sw_vers` — macOS 14+; `swift --version` — Swift 6.x (Xcode 16+)
- [ ] `git clone` + `cd Edmund`
- [ ] `swift build` — SPM fetches swift-markdown, SwiftMath, Sparkle
- [ ] `swift test` — ~750+ tests green in ~10s
- [ ] Read `docs/ARCHITECTURE.md` (mandated by AGENTS.md before non-trivial work)
- [ ] Visual work planned? `./scripts/build-app.sh`, confirm
`build/Edmund.app` exists and `ls build/Edmund.app/*.bundle` shows the
SwiftMath bundle
- [ ] Releases planned? `node --version` ≥20, `npm install --global create-dmg`
### Before trusting any run
- [ ] Binary is fresh: `strings <binary> | grep '<long unique literal>'`
and/or `shasum` changed since the edit (§5)
- [ ] `pgrep -lx edmd` — user's live instance identified; you will kill only
your own PID (§6)
- [ ] Launched by direct exec, not `open -a` (§4)
- [ ] `-ApplePersistenceIgnoreState YES` passed
- [ ] Test document recreated fresh (autosave mutated the last one)
- [ ] Debug bundle: Sparkle.framework sits next to the binary
- [ ] Release bundle: SwiftMath `*.bundle` at the `.app` root (or the first
LaTeX render crashes)
## Provenance and maintenance
Every claim above was read from the files below on 2026-07-05. Re-verify:
- Toolchain/deps: `cat Package.swift` (tools-version, platforms, dep
versions); `grep identity Package.resolved`
- Build anatomy: `cat scripts/build-app.sh` (step order, sealing comments)
- Test count/time: `swift test 2>&1 | tail -3`
- Debug bundle recipe: `docs/dev-guides/live-repro-guide.md` §4
- Stale-build disease, launch gotchas: `docs/ARCHITECTURE.md` §8
("Stale release builds", "open Edmund.app", savedState)
- CI facts: `cat .github/workflows/ci.yml` (cache key comment, concurrency)
- create-dmg quirks: `docs/ARCHITECTURE.md` §8 ("create-dmg — npm only")
If a command here disagrees with those files, the files win — update this
skill.
@@ -0,0 +1,267 @@
---
name: edmund-caret-integrity-campaign
description: >
The executable, decision-gated campaign for Edmund's hardest live problem:
the delete-drift class (caret/selection integrity in the live NSTextView /
TextKit 2 / input-context layer). Load when the caret or selection lands in
the wrong place after a delete/type/IME/drag interaction, when text edits
corrupt or desync, when autosave writes stale content, or when logs show
⚠︎LEN-MISMATCH or the "healing storage edit that bypassed didChangeText"
breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify,
fence off six settled battles, capture evidence, build a deterministic repro,
pick from a ranked solution menu with proof obligations, then validate and
promote through change control. Not for viewport/scroll drift (that's
edmund-debugging-playbook → viewport docs) unless the caret itself moves.
---
# Caret-integrity campaign (the delete-drift class)
Six rounds are settled; **new rounds are expected**. This class does **not
reproduce in headless tests** — the harness runs AppKit's deferred machinery
synchronously, so a green unit test proves nothing here. Success is measured by
`PASS/FAIL` grep and byte counts, **never by eye or by reasoning**.
Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md` (456 lines) and
the code. Read that doc fully before a deep dive.
---
## QUICK CARD (the whole loop)
```
0. CLASSIFY grep logs for the 4 signatures. Not this class? → debugging-playbook.
1. FENCE check the 6 settled battles + guards still hold. Don't re-fight them.
2. EVIDENCE run with verbose diags; find FIRST bad line; walk BACKWARDS;
traceSelectionOrigin names who moved the caret; rebuild the doc.
3. REPRO ReproScript: needles not offsets; real events; replicate internal
call sequences verbatim. Freeze a script whose logsel/assertcaret
DISCRIMINATES broken vs current build. No repro → hypothesis wrong.
4. SOLVE rank candidates (missing guard < extend heal < reorder fixup <
structural). Each states what it must PROVE.
5. PROMOTE fix flips frozen repro to PASS same run → soak 45 cycles,
byte-identical rawLen → swift test green → add test (locks headless
contract even if non-discriminating) + keep .repro → update the
investigation doc → branch/commit via change-control. Never ship on
reasoning alone.
```
---
## PHASE 0 — Classify: is it actually this class?
Grep `~/.edmund/logs/edmund-<date>.log` (run the app with
`-settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES`):
| Signature | Meaning |
|---|---|
| `healing storage edit that bypassed didChangeText` | a bypass fired (round-4 class) |
| **persisting** `⚠︎LEN-MISMATCH` (not just transient between shouldChangeText→synced) | storage/rawSource desync stuck |
| `selectionDidChange` with `up=Y` at a surprising position | selection moved mid-recompose (round-6 class) |
| a `shouldChangeText` with **no** `synced`/`SKIPPED`/`DEFERRED` after it | a bypassed `didChangeText` |
`scripts/grep-trace.sh` in **edmund-live-repro-and-diagnostics** surfaces all
four at once.
**If none match and the symptom is scroll/viewport-shaped** (jump, wrong
landing, can't-scroll-up) with the caret *not* leaping → this is the viewport
class, not caret integrity → **edmund-debugging-playbook** +
`docs/investigations/viewport-glitch-investigation.md`. Do not run this campaign on a viewport
bug.
---
## PHASE 1 — Fence off the six settled battles
Confirm each shipped guard **still holds** before hypothesizing a new mechanism.
The most likely round-7 shape is a **new code path that lacks an existing
guard**, not a new mechanism.
| Round | Mechanism (settled) | The shipped guard — confirm it still covers your path |
|---|---|---|
| 13 | IME **marked-text stranding**: styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` while `hasMarkedText()` strands the composition; `didChangeText` then bails forever and every edit drifts | **every storage-touching styling path guards `!hasMarkedText()`** — including async ones scheduled *before* composition began (`+SelectionTracking` caret-move restyle). `becomeFirstResponder` resyncs as catch-all |
| 4 | **Drag-move bypass**: a drag-move whose drop has no valid target deletes via `shouldChangeText``replaceCharacters` with **no `didChangeText`**; `rawSource`/`blocks` freeze; autosave writes stale text | `scheduleBypassedEditSyncCheck` (`+EditFlow.swift`): a next-run-loop check finds an unconsumed storage `pendingEdit` and runs the sync (breadcrumb above) |
| 5 | **Heal leaped the caret**: the round-4 heal ran against a **stale** selection and moved the caret | heal collapses/derives the caret from the `pendingEdit` hull before syncing |
| 6 | **Queued selection fixup**: TK2's `_fixSelectionAfterChangeInCharacterRange` stays queued after a bypass and fires at the **next `endEditing`** (even an attribute-only restyle), remapping the **stale** selection and leaping even a *freshly set, valid* caret | heal sets the caret from the pendingEdit hull **before** the sync **AND re-asserts it after** (`+EditFlow.swift`) |
| 7 | **Same queued fixup on the NORMAL edit path** (not the heal): armed by a **cross-block caret move** (schedules the async caret-move restyle), the fixup fires during `syncRawSourceFromDisplay``recomposeDirty`'s `endEditing` on an ordinary keystroke and leaps the caret to the **block boundary**; the normal path styles `settingSelection=false` and never re-asserts, so it **persists** | `syncRawSourceFromDisplay` captures the pendingEdit-hull caret before `consumePendingEdit`, re-asserts it after `recomposeDirty` if the fixup moved it (`+EditFlow.swift`; breadcrumb `re-asserting caret after fixup leap (normal path)`) — **fix not yet confirmed by a deterministic repro**, live-verifiable via the breadcrumb |
Confirm the guards exist:
```bash
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
```
### FENCED WRONG PATHS (each proven dead — do not retry)
- **Fixing by nudging the caret at symptom time.** The symptom is armed
seconds-to-minutes *earlier* (round 6: drift at 22:13 armed at 22:11:57). You'd
patch the wrong instant.
- **Trusting a headless regression test.** The round-6 test passes **with and
without** the fix. Headless runs the fixup synchronously.
- **Shipping on reasoning alone.** Rounds 15 all came back. Round 6's first fix
candidate "worked by reasoning" and **failed in the repro within a minute**.
- **Assuming `didChangeText` pairs with every mutation.** AppKit violates this
(round 4).
- **Mutating storage mid-composition.** That is the original bug (rounds 13).
- **Expecting a scripted keystroke replay to arm round 7.** Round 7 was replayed
faithfully from a reconstructed `t0` (every keystroke + capped pauses through
the whole drift window) and it did **not** produce the block-end leap.
Programmatic caret moves (`clickoff`) and imprecise synthetic clicks
(`realclickoff` — lands off-by-a-few, diverges, crashes) do not fully arm it.
The arming needs **real mouse clicks at real positions** and probably the
real session's **two-document window switching** (`becomeFirstResponder`
resync is the prime suspect). Round-8 lead: two open docs + real clicks, or
instrument `becomeFirstResponder`/the caret-move restyle to catch the next
live occurrence. Compressed replays also coalesce a bypass with the next
keystroke into one `pendingEdit` hull (never happens live — the heal runs
first), producing off-by-one breadcrumb noise; don't trust it as a repro.
---
## PHASE 2 — Capture evidence
1. Launch with verbose diagnostics (debug bundle; file arg is `argv[1]`):
```bash
build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-ApplePersistenceIgnoreState YES
```
2. **Decode the trace fields:** `sel/active/marked/up/undo/blocks/storLen/rawLen`.
`up=Y` = event arrived mid-recompose (suspicious). Healthy ordering:
`shouldChangeText` → `selectionDidChange (up=N)` → `synced`; a transient
`⚠︎LEN-MISMATCH` between those is normal, a persisting one is not.
3. **Find the FIRST bad line, then walk BACKWARDS.** The visible symptom is often
the second half of a two-part mechanism.
4. **`traceSelectionOrigin`** logs the call stack of whoever moved the selection
mid-recompose — this is what named `_fixSelectionAfterChange` in round 6. If
the caret moves and you don't know who moved it, this answers it in one run.
5. **Reconstruct the document.** Wrapped-paragraph geometry and block kinds
matter — repro against a lookalike, never `"hello world"`.
**Gate:** you have a candidate trigger hypothesis + the first-bad-line timestamp.
Otherwise **instrument** (add a `Log.shouldTrace` breadcrumb — one call stack or
state dump beats ten speculative fixes) and wait for the next occurrence.
---
## PHASE 3 — Build the deterministic repro
Use the in-process **ReproScript** driver (`Sources/edmd/App/ReproScript.swift`,
DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands:
`sleep / caret / type / backspace / bypassdelete / assertcaret / logsel`.
**Worked example — the round-6 minimal repro** (the first deterministic repro in
six rounds):
```
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken build: 321 fixed build: 290
backspace 2
logsel
```
The deciding output was `logsel` **321 → 290** with the fix, every run, window
not even visible.
Rules that make repros survive editing:
- **Needles, not offsets** — offsets go stale the instant the script edits.
- **Real events, not method shortcuts** — `insertText("")` skips
`deleteBackward`'s selection machinery, exactly where round 6 lived.
- **Replicate AppKit-internal call sequences verbatim** — `bypassdelete` does
`shouldChangeText`→`replaceCharacters`, no `didChangeText`, not an
approximation. For a *new* internal path, pin its real sequence from a
`traceSelectionOrigin` stack first, then replay it (extend ReproScript ~10
lines).
**Gate:** a **frozen** script (exact commands + document) whose `logsel` /
`assertcaret PASS/FAIL` output **discriminates** the broken build from the
current one.
**No repro after honest attempts?** The hypothesis is wrong, or fidelity is too
low — a mouse-only path (real drag-select/drag-move) needs the **CGEvent driver**
(edmund-live-repro-and-diagnostics §4). Loop back to Phase 2.
---
## PHASE 4 — Solution menu (ranked; each with a proof obligation)
Pick by the observation pattern. Cheaper is higher.
1. **Add a missing guard on an existing invariant** (cheapest, most likely for a
new round). Guard inventory: `!hasMarkedText()` on the offending styling path;
bypass-check scheduling on a new mutation entry point; caret re-assertion
after a restyle. *Proof:* the frozen repro flips to PASS **and** no other
`.repro` regresses. *Selects when:* a specific new code path shows the
round-1/4/6 signature that its siblings already guard.
2. **Extend the heal to a new bypass source.** *Proof:* first add a ReproScript
command that replays the new source's **exact** call sequence, show it
reproduces the freeze, then show the extended heal fixes it. *Selects when:*
trace shows a `shouldChangeText` with no close-out from a path other than
drag-move.
3. **Intercept/reorder the queued fixup.** *Proof obligation:* explain **when**
TK2 queues and fires `_fixSelectionAfterChange` and show the reorder does not
fight AppKit's machinery (no oscillation, no double-move). *Selects when:*
`traceSelectionOrigin` names the fixup and the caret is valid before it fires.
Higher risk — you are stepping into private AppKit ordering.
4. **Structural: make rawSource sync independent of `didChangeText` pairing**
(e.g. a storage-version counter that reconciles regardless of which callbacks
fired). Biggest change; **candidate, unproven** — route through
**edmund-research-frontier** (frontier item "caret integrity by
construction"). *Proof:* all historical `.repro` scripts + a randomized
bypass-fuzzer soak stay green with the callback-pairing assumption removed.
---
## PHASE 5 — Validate and promote
1. Fix candidate **flips the frozen repro to PASS within the same run**. (If it
only "works by reasoning," it is not done — round history.)
2. **Soak** (edmund-live-repro-and-diagnostics §3): one script, one run, 45
trigger cycles at different document positions with ordinary editing between,
`assertcaret` after every predictable step. Green across all cycles **and
byte-identical final `rawLen` across repeated runs** = deterministic.
3. **`swift test` green** (also the Stop hook).
4. **Add a regression test even if it can't discriminate the live mechanism** —
it locks the headless contract (assert `rawSource == string`; see the
`BypassedEditSyncTests` / `MarkedTextDesyncTests` family). **Keep the `.repro`
script** in the repo for the live half.
5. **Update `docs/investigations/delete-drift-investigation.md`** with the new round (symptom →
root cause → repro recipe → fix → status), and `docs/ARCHITECTURE.md` §8 if an
invariant changed — **same PR**.
6. Route through **edmund-change-control**: branch `fix/…` off `main`, small
commits, **never auto-push/PR/merge**. (No screencapture unless the fix also
draws.)
**Never promote a fix that only "works by reasoning."**
---
## When NOT to use this skill
- Viewport/scroll drift where the caret itself does not leap →
**edmund-debugging-playbook** + `docs/investigations/viewport-glitch-investigation.md`.
- Just need the repro driver mechanics / trace decoding →
**edmund-live-repro-and-diagnostics**.
- The AppKit theory behind the mechanisms → **textkit2-appkit-reference**.
- The historical record of prior rounds → **edmund-failure-archaeology**.
- The structural (round-∞) redesign as a research project →
**edmund-research-frontier** + **edmund-research-methodology**.
---
## Provenance and maintenance
Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`,
`Sources/edmd/App/ReproScript.swift`, and
`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`.
```bash
grep -nE '^## Round' docs/investigations/delete-drift-investigation.md # round chronicle
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
# round-6 discriminating numbers (321 broken / 290 fixed):
grep -n '321\|290' docs/investigations/delete-drift-investigation.md
```
When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list.
@@ -0,0 +1,210 @@
---
name: edmund-change-control
description: >
How changes are classified, gated, and reviewed in the Edmund repo. Load at
the START of any task that will modify the repo; before committing, branching,
or proposing a merge or release; when deciding whether a change needs a test,
a screencapture visual check, or a live repro; when unsure what requires
explicitly asking the maintainer (push, PR, merge, release, deleting
uncommitted work, touching test-files/, adding dependencies). Contains the
non-negotiable rules with the historical incident behind each one.
---
# Edmund change control
Last verified: 2026-07-05, against `main` @ fe8a1f5 (release 0.1.3).
This is the gatekeeping doc: what class of change you are making, which gate it
must pass, and the rules that are never traded away. The rules exist because
each was paid for — the incident column is not decoration.
## 0. Before you start (task setup)
```
[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule)
[ ] git status — note any uncommitted work; never clobber it
[ ] Not on main? Fine. On main? Branch NOW, before the first edit
[ ] Classify the change (§1) so you know the gate before you write code
[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't
reproduce the bug, you can't prove the fix (§3, last row)
```
## 1. Classify the change, apply the gate
Classify FIRST, before writing code. The class decides the verification bar.
| Class | Examples | Gate before commit |
|---|---|---|
| Docs-only | ARCHITECTURE.md, README, docs/*.md, comments | None beyond review. `swift test` still runs as a Stop hook; ignore no failures it surfaces. |
| Code (logic) change | Parser, block model, helpers, non-drawing refactor | `swift test` green. New behavior or bug fix → add a test that fails without the change. |
| Visually-drawing change | Anything in `Rendering/`, overlays, decorations, padding, fonts, layout fragments | All of the above, PLUS build the app and `screencapture` the result (window-by-id method — see `edmund-live-repro-and-diagnostics`). Headless layout is not proof for anything that draws. |
| Edit-pipeline / selection behavior | `+EditFlow`, `+Composition`, `+SelectionTracking`, `+Undo`, caret, IME, drag, viewport timing | All of the above, PLUS a live repro or soak script (`-debug.reproScript`, see `edmund-caret-integrity-campaign` and `docs/dev-guides/live-repro-guide.md`). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 15 of delete-drift shipped on tests + reasoning; all recurred. |
| Release | Version bump, tag, appcast | Run `misc/before-you-release.md` top to bottom, then `misc/how-to-release.md`. See `edmund-release-and-operate`. Never start a release without being asked. |
Notes on the gates:
- `swift test` (~750+ tests, ~10s) also runs automatically as a **Stop hook**
(`.Codex/settings.json`: `swift test 2>&1 | tail -5`) at the end of every
turn. That is a safety net, not the gate — run it yourself before committing
so the failure is yours to see, not the hook's.
- A change can be in multiple classes. Apply the union of gates. A caret fix
that also moves a decoration needs test + screencapture + live repro.
- "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a
human could see the diff, screenshot it.
Classification edge cases that have gone wrong before:
- "It's just an attribute change" is NOT automatically the code-logic class.
If the attributes change measured geometry (font size, paragraph spacing,
hidden-delimiter width), it draws AND it needs `invalidateLayout(for:)`
see §3.
- "It's just a restyle helper" that runs `beginEditing`/`setAttributes` on
storage is edit-pipeline class if it can fire during IME composition or
after a bypassed edit. When in doubt, grep for `hasMarkedText` guards on
the sibling paths and match them.
- A test-only change is docs-class for gating purposes (nothing to screenshot),
but the Stop hook still must pass — a broken test is a broken commit.
- Release-adjacent edits (`Info.plist` versions, `CHANGELOG.md`, `appcast.xml`,
`scripts/release.sh`, `.github/workflows/`) are release class even when tiny.
The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step,
not from app code.
## 2. Git discipline
From AGENTS.md + ARCHITECTURE §12 + the repo's own history (`git branch -a`).
- **Branch off `main` for every fix. Never commit straight to `main`.**
One feature/fix per branch.
- Branch prefixes actually in use (verified): `fix/`, `feat/`, `feature/`,
`docs/`, `chore/`, `uiux/`, `ui/`, `ux/`, `markdown/`, `bug/`, `refactor/`,
`ci/`, `release/`. Prefer `fix/`, `feat/`, `docs/`, `chore/` for new work;
`uiux/` for visual polish; `markdown/` for syntax-feature work.
- **Small, logical commits; commit frequently.** A commit that mixes the fix
with a drive-by refactor is two commits done wrong.
- **NEVER auto-push, open a PR, or merge.** Only when the maintainer
explicitly asks. No exceptions for "it's just docs."
- **Never discard uncommitted changes.** No `git checkout -- .`,
`git reset --hard`, `git clean` on a dirty tree without explicit permission.
- Commit messages follow the observed style: `fix(editor): …`, `docs: …`,
`ui: …`, `chore: …` — short imperative subject.
## 3. The non-negotiables
Each rule was established by an incident. Verify against the cited doc before
arguing an exception.
| Rule | Rationale | Incident |
|---|---|---|
| Text storage always equals `rawSource`; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. | Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. | The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. `docs/investigations/delete-drift-investigation.md`. |
| TextKit 2 only. Never touch `NSTextView.layoutManager`; never store `NSTextBlock`/`NSTextTable` attributes. | Either one **silently and permanently** reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. | ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible. |
| No `NSTextAttachment`. Images/icons are drawn as overlays. | TK2 only honors attachments on U+FFFC, which `rawSource` never contains (see rule 1). | ARCHITECTURE §2, §5. |
| Never draw images on wrapping (multi-line) fragments; use stroked `CGPath`s instead. | A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. | The callout custom-title icon: `docs/investigations/archives/callout-title-wrap-investigation.md`; fix in ae61644 (stroked path, not image). |
| Every storage-touching styling path guards `!hasMarkedText()` — including async paths scheduled before composition began. | Mutating storage mid-IME-composition strands the marked text; `didChangeText` then bails forever on its own guard and every later edit drifts. | Delete-drift rounds 12 (IME stranding cascade). `docs/investigations/delete-drift-investigation.md`. |
| Never assume `didChangeText` follows every edit. Sync paths must survive a bypassed edit. | AppKit's drag-move-to-nowhere deletes via `shouldChangeText``replaceCharacters` and never calls `didChangeText`, silently freezing `rawSource`. The heal (`scheduleBypassedEditSyncCheck` in `+EditFlow`) exists for this. | Delete-drift round 4 (9f99795); rounds 56 hardened the heal itself. |
| Attribute-only restyles that change geometry must `invalidateLayout(for:)` the range. | TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. | ARCHITECTURE §8; `recomposeDirty` and the idle drain already do this — new paths must too. |
| Undo restore is diff-based `recomposeReplacing` — never a full `recompose`. | Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. | Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (`fix(undo): select + center the changed text; diff-based snapshot restore`). |
| Never blanket `pkill -x edmd`. `pgrep -x edmd` first, check start times, kill only the PID you launched. | The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's `pkill -x edmd` shorthand predates this rule — don't copy it.) | Established after the maintainer's own instance was killed during a debugging session. |
| Never request macOS Computer Access (Screen Recording / Accessibility). | Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. | AGENTS.md "Environment"; the `-debug.reproScript` driver exists precisely so repros need no new TCC grants. |
| Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. | Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. | Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches). |
| Files in `test-files/` are the maintainer's manual test corpus. Never rewrite them for automation; `test-files/todo.md` especially is owner-edited. | They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or `Tests/`. | Standing maintainer rule. |
| Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. | This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. | Delete-drift rounds 15 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. `docs/dev-guides/live-repro-guide.md`. |
### The two incidents that shaped this table
Worth knowing as stories, because the rules read as pedantry until you see
the cost:
- **Delete-drift (six rounds).** The hardest live problem this repo has had.
A caret that drifted after deletes. Round 1 blamed IME stranding — plausible
fix, shipped, recurred. Round 2 disabled remaining marked-text sources —
recurred. Round 3 stopped guessing and built diagnostics (selection tracing,
event logs). Round 4's diagnostics caught a drag-move deleting storage with
no `didChangeText` — the heal was born. Round 5: the heal itself leaped the
caret via a stale selection. Round 6 found the actual drift mechanism —
TextKit 2's queued `_fixSelectionAfterChange` firing at the *next*
`endEditing` — and held only because the ReproScript driver could replay the
exact keystroke sequence deterministically. Five shipped fixes failed; the
one preceded by a frozen repro stuck. That asymmetry IS the change-control
policy for this bug class.
- **Undo/redo viewport drift.** Undo restored a snapshot via full `recompose`,
which reset every fragment to a TK2 height estimate; the follow-up
scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40)
diffs the snapshot against current text and applies only the changed span
with `recomposeReplacing`. Moral: in TK2, layout state is part of the
document state you must preserve — "re-render everything" is never the safe
fallback here, it is the bug.
## 4. Review expectations
- **ARCHITECTURE.md is updated in the same PR** whenever you learn something
non-obvious or change an invariant. The doc's own header demands this; the
gotchas in §8 all arrived this way.
- **Quirks are documented as comments at the code site** — the edge case, the
workaround, the *why*. Not in commit messages, not in AGENTS.md.
- **New known issues** go in ARCHITECTURE §9 with a one-line repro and a
pointer to any deeper write-up in `docs/`.
- Big investigations (multi-round bugs) get a `docs/*-investigation.md`
chronicle — see `edmund-failure-archaeology` for the pattern.
## 5. Pre-commit checklist (copy-paste)
The workflow that worked (ARCHITECTURE §12 + AGENTS.md). Run it verbatim:
```
[ ] swift test — all green (also enforced by the Stop hook; don't rely on it)
[ ] New behavior / bug fix → a test exists that fails without the change
[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG
[ ] Edit-pipeline / selection change? → live repro or soak script passed
[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main
[ ] Diff touches only what the task needs; style matches surroundings
[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change
[ ] Quirk introduced/found? → comment at the code site
[ ] Commit is small and logical; message matches repo style (fix(scope): …)
[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked)
```
## 6. Ask the maintainer first — always
Never do these unprompted; ask and wait for an explicit yes:
| Action | Why it's gated |
|---|---|
| `git push`, opening a PR, merging anything | Standing rule in AGENTS.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges. |
| Starting or tagging a release | A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly. |
| Deleting anything uncommitted (files, stashes, working-tree changes) | "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree. |
| Editing anything in `test-files/` | Manual test corpus; `todo.md` there is owner-edited. Make fixtures elsewhere. |
| Adding a dependency | Current set is deliberately three (`swift-markdown`, `SwiftMath`, `Sparkle`); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8). |
| Changing `.Codex/settings.json` hooks or permissions | Alters what runs automatically on the maintainer's machine. |
## When NOT to use this skill
| You need… | Go to |
|---|---|
| The invariants' full technical statement and render pipeline | `edmund-architecture-contract` |
| To debug a failure, read traces/logs | `edmund-debugging-playbook` |
| The history of a past incident in depth | `edmund-failure-archaeology` |
| TextKit 2 / AppKit API behavior details | `textkit2-appkit-reference` |
| Launch flags, debug bundle, settings | `edmund-config-and-flags` |
| Build issues, stale binaries, environment | `edmund-build-and-env` |
| Executing a release / operating the app | `edmund-release-and-operate` |
| The screencapture / ReproScript mechanics | `edmund-live-repro-and-diagnostics` |
| Test-writing patterns and QA strategy | `edmund-validation-and-qa` |
| Writing docs / chronicles | `edmund-docs-and-writing` |
| Caret/selection bug-class specifics | `edmund-caret-integrity-campaign` |
## Provenance and maintenance
- Sources: `AGENTS.md` (repo root), `docs/ARCHITECTURE.md` §1 §2 §8 §9 §12,
`.Codex/settings.json` (Stop hook), `misc/before-you-release.md`,
`misc/how-to-release.md`, `docs/investigations/delete-drift-investigation.md` (rounds 16),
`docs/investigations/archives/callout-title-wrap-investigation.md`, `git log` / `git branch -a` as of
fe8a1f5 (2026-07-05).
- Commits cited were verified in `git log`: 5bb2b40 (diff-based undo restore),
9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a
(round-6 caret re-assert).
- Two rules rest on maintainer statements rather than repo docs: the
measure-from-pixels rule and the `test-files/` ownership rule. If either gets
written into ARCHITECTURE.md, point at it here.
- Maintain: when a new incident produces a new rule, add a row to §3 with the
incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When
the Stop hook in `.Codex/settings.json` changes, update §1's note.
@@ -0,0 +1,220 @@
---
name: edmund-config-and-flags
description: >
Catalog of every configuration axis in the Edmund Markdown editor — user
settings (UserDefaults keys, defaults, where consumed), launch arguments
(diagnostic + repro flags), compile-time gates, and logging config. Load
when adding or changing a setting, hunting which flag controls a behavior,
launching the app with debug flags, auditing defaults, or wiring a new
preference into the live editor. This skill drifts fastest of the set —
every table ends with a re-verification grep. Not for the invariants
(see edmund-architecture-contract), release flags (edmund-release-and-operate),
or how to READ the logs (edmund-live-repro-and-diagnostics).
---
# Edmund configuration & flags
Ground-truth catalog of every knob. **Code wins over docs** — every value
below was read from source on 2026-07-05; re-verify with the greps at the end
before trusting a value in a decision.
Two source-of-truth files:
- `Sources/edmd/Settings/AppSettings.swift` — every UserDefaults key + typed accessor.
- `Sources/edmd/Settings/*View.swift` (Appearance / General / Advanced) + `FontSettings` — the SwiftUI panes (`@AppStorage`).
Definitions used below: **UserDefaults** = macOS per-app persisted key/value store; **argument domain** = passing `-<key> <value>` on the command line overrides that default for one launch; **`@AppStorage`** = SwiftUI wrapper binding a view to a UserDefaults key.
---
## 1. User settings (UserDefaults keys)
Every key is a `static let` in `AppSettings.swift`. The key **string** (not the
Swift name) is what you pass as a launch arg.
| Swift name | Key string | Purpose |
|---|---|---|
| `reopenWindows` | `settings.general.reopenWindows` | Reopen last windows on launch |
| `startupAction` | `settings.general.startupAction` | What to do at startup (new doc / reopen / nothing) |
| `autoSaveWithVersions` | `settings.general.autoSaveWithVersions` | NSDocument autosave-in-place vs versions |
| `conflictResolution` | `settings.general.conflictResolution` | File-changed-on-disk handling |
| `suppressInconsistentLineEndingWarning` | `settings.general.suppressInconsistentLineEndingWarning` | Silence mixed-line-ending warning |
| `diagnosticLogging` | `settings.general.diagnosticLogging` | **On/off for file logging** (opt-out; see §4) |
| `logRetention` | `settings.general.logRetention` | Days of logs to keep (pruned on configure) |
| `appearanceMode` | `settings.appearance.mode` | Light / dark / system |
| `maxContentWidthCm` | `settings.appearance.maxContentWidthCm` | **Max column width, stored in CENTIMETRES** (see note) |
| `contentWidthUnit` | `settings.appearance.contentWidthUnit` | Display unit only (cm/in); the stored value is always cm |
| `renderBlankLinesAsBreaks` | `settings.reading.renderBlankLinesAsBreaks` | Read-mode blank-line handling |
| `sourceMode` | `settings.view.sourceMode` | When on, **Source replaces Edit** in the ⌘E toggle; honored on open |
| `verboseEditorDiagnostics` | `settings.advanced.verboseEditorDiagnostics` | **Verbose editor trace** (see §4; pairs with diagnosticLogging) |
| `sendCrashLogs` | `settings.advanced.sendCrashLogs` | Opt-in crash upload — **currently INERT** (see note) |
| `sentCrashReports` | `settings.advanced.sentCrashReports` | Dedup set of already-uploaded `.ips` filenames |
| `lastWindowHeight` | `settings.window.lastHeight` | Persisted window sizing (see the frame-not-content trap) |
| `automaticallyChecksForUpdates` | `SUAutomaticallyChecksForUpdates` | Sparkle's own key (not namespaced) |
**Content width (the physical-column design):** persisted as **centimetres**
(`maxContentWidthCm`); `contentWidthUnit` is a display unit only. The column is
an **absolute physical** cap converted to points via the display's real PPI
(`NSScreen.physicalPPI`, from `CGDisplayScreenSize`), applied as a symmetric
`textContainerInset.width`. Default is locale-aware — **5 in (US) / 12 cm
(elsewhere)** — and doubles as the slider's magnetic snap point. Recomputed on
resize and on `NSWindow.didChangeScreenNotification`. Consumer path lives in
`EditorTextView+ContentWidth.swift`.
**Window sizing trap:** persistence must round-trip the **frame** size, not the
`contentView.bounds` size — reapplying content size grows the window by the
title-bar + toolbar height on every reopen, and heights below `minSize` get
silently rejected. Save `window.frame.size`, reapply with `window.setFrame(_:)`
**after the toolbar is installed**. (Note: the key on disk is
`settings.window.lastHeight` — code, not the `lastWindowSize` some docs say.)
**Crash uploading is inert:** `sendCrashLogs` defaults off AND the Settings ▸
Advanced toggle is **commented out** in `AdvancedSettingsView.swift`, and
`CrashReporter.reportingEndpoint` is a `REPLACE-ME.invalid` placeholder
(`CrashReporter.swift:27`, `// TODO: real server`). Nothing uploads today.
Un-inert it only when a receiving server exists (see edmund-release-and-operate).
---
## 2. Launch arguments
macOS reads `-<UserDefaults-key> <value>` into the argument domain. Pass the
**key string** from §1, not the Swift name. The **file to open must be
`argv[1]`** (before the flags).
```bash
build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript SCRIPT.repro \
-ApplePersistenceIgnoreState YES
```
| Flag | Effect |
|---|---|
| `-settings.general.diagnosticLogging YES` | Turn on file logging for this run |
| `-settings.advanced.verboseEditorDiagnostics YES` | Emit the verbose editor trace (sel/active/marked/up/…) |
| `-debug.reproScript <path>` | **DEBUG builds only** — replay a keystroke script (`ReproScript.swift`) |
| `-ApplePersistenceIgnoreState YES` | Apple's flag — stop state restoration reopening mutated docs |
`-debug.reproScript` is the only Edmund-specific *debug* key; it does not have
an `AppSettings` accessor — it is read directly in `ReproScript.swift`. It is
compiled out of release builds.
---
## 3. Compile-time axes
`#if DEBUG` gates live in: `EditorTextView.swift`, `EditorTextView+EditFlow.swift`,
`Diagnostics/Log.swift`, `edmd/App/main.swift`, `edmd/App/ReproScript.swift`.
What they gate:
- **The TextKit-1 tripwire** (`EditorTextView.swift:273`+): a DEBUG observer on
`NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the view
ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is
silent and permanent (see edmund-architecture-contract).
- **ReproScript** — the whole in-process keystroke driver.
- **Log level threshold** — `Log.swift` compiles a lower floor in DEBUG
(`debug`+) than release (`info`+); see §4.
Named tuning constants (not user-facing, but they behave like knobs):
| Constant | Value | Where | Meaning |
|---|---|---|---|
| `fullLayoutMaxLength` | `100_000` | `EditorTextView.swift:80` | Docs ≤ this many UTF-16 units are kept **fully laid out** (below the TK2 estimate regime). Consumed at `+LazyStyling.swift:133`. |
---
## 4. Logging config
Read `Sources/EdmundCore/Diagnostics/Log.swift` and
`EditorTextView+Diagnostics.swift`.
- API: `Log.{debug,info,error}(_:category:)`, `Log.measure(_:) { … }`.
- File: `~/.edmund/logs/edmund-YYYY-MM-DD.log`, written on a private serial queue.
- Config flow: `AppSettings.applyLogging()` pushes the toggle + retention into
`Log.configure` at launch and on change; retention pruning happens there.
- **Two independent switches**: `diagnosticLogging` (writes anything at all) and
`verboseEditorDiagnostics` (adds the per-event editor trace). For a live
repro you almost always want **both** on. Verbose lines are gated behind
`Log.shouldTrace`.
- The log is **opt-out** (on by default), retention-pruned; the user only
toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics.
Trace-field decoding (`sel/active/marked/up/undo/blocks/storLen/rawLen`,
`⚠︎LEN-MISMATCH`, `traceSelectionOrigin`) is covered in
**edmund-live-repro-and-diagnostics** and **edmund-debugging-playbook** — one
home per fact; this skill only says which flags turn the trace on.
---
## 5. ReproScript command surface
`Sources/edmd/App/ReproScript.swift`, DEBUG only. One command per line, `#`
comments allowed.
| Command | Effect |
|---|---|
| `sleep <ms>` | wait before next command |
| `caret <needle>` | place caret before first occurrence of `<needle>` |
| `type <text>` | one real key event per char (~80 ms apart) |
| `backspace <n>` | n real delete keystrokes (~300 ms apart) |
| `bypassdelete <needle>` | simulate drag-move source deletion: `shouldChangeText` + storage mutation, **no `didChangeText`** |
| `assertcaret <needle>` | log `PASS/FAIL` iff caret sits exactly before `<needle>` |
| `logsel` | log selection, rawSource length, doc count |
Adding a command is ~10 lines in `ReproScript.swift`. Usage, soak patterns, and
launch recipe: **edmund-live-repro-and-diagnostics**.
---
## 6. How to ADD a setting (checklist)
Worked from an existing real path (`sourceMode` / content width). To add a
user-facing setting:
1. Add a `static let` key + typed accessor in `AppSettings.swift` (namespace the
key string: `settings.<area>.<name>`).
2. Bind it in the relevant SwiftUI pane with `@AppStorage(AppSettings.<key>)`.
3. If it must affect **open documents live**, add/extend an `applyTo…` broadcast
(see the font/line-height/content-width `applyTo…` helpers) so every open
`Document.editor` picks it up — a setting that only takes effect on next open
is usually a bug.
4. Pick a sane default (register it, or make the accessor default when absent).
5. New behavior needs a test + (if it draws) a screencapture check — route
through **edmund-change-control** and **edmund-validation-and-qa**.
---
## When NOT to use this skill
- Understanding *why* an invariant exists → **edmund-architecture-contract**.
- Release/signing/appcast flags, `RELEASE_TOKEN`, Sparkle keys → **edmund-release-and-operate**.
- Interpreting log output / running a repro → **edmund-live-repro-and-diagnostics**.
- Which change needs which gate → **edmund-change-control**.
- Build-time flags in the toolchain sense (stale builds, `swift package clean`) → **edmund-build-and-env**.
---
## Provenance and maintenance
Verified 2026-07-05 against source. This skill drifts fastest — re-verify each
table before relying on it:
```bash
# §1 all keys + strings:
grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift
# §1 crash toggle still commented out / endpoint still placeholder:
grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift
grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift
# §2 repro flag key:
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
# §3 tripwire + constant:
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
# §5 repro commands:
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
```
**Known doc-vs-code discrepancies (code wins):** ARCHITECTURE §7 calls the
window-size key `lastWindowSize`; the code key is `settings.window.lastHeight`
(`lastWindowHeight`). If you touch window persistence, trust the code.
@@ -0,0 +1,305 @@
---
name: edmund-debugging-playbook
description: >-
Load this FIRST when any bug report or unexpected behavior arrives in Edmund:
caret lands in the wrong place after delete/typing, viewport jumps or
scroll-to-target misses, rendering is wrong or missing, empty bands or clipped
lines, everything suddenly renders as plain text, app crashes or won't launch,
a code change "doesn't take" after rebuild, undo/redo lands the viewport
wrong, IME/CJK/accent input misbehaves, right-click shows the wrong menu,
window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage
table, the traps that cost real debugging time, discriminating trace checks,
the repro escalation ladder, and the open-bug inventory so known bugs are not
rediscovered fresh.
---
# Edmund debugging playbook
Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the
triage table, run the discriminating first check *before* forming a theory,
and check the open-bug inventory before declaring a discovery.
## When NOT to use
- Making a change, not chasing a bug → `edmund-change-control`.
- You already know the bug is live-only and need to build a deterministic
repro → `edmund-live-repro-and-diagnostics` (full ladder detail; summary in
§5 here).
- Caret-drift class specifically, with its six-round history →
`edmund-caret-integrity-campaign`.
- Build/toolchain/stale-binary mechanics beyond the quick checks here →
`edmund-build-and-env`.
- Release, signing, appcast, Sparkle pipeline → `edmund-release-and-operate`.
- TextKit 2 / AppKit API semantics reference → `textkit2-appkit-reference`.
- Launch flags and settings keys reference → `edmund-config-and-flags`.
- How past investigations were run and why → `edmund-failure-archaeology`,
`edmund-research-methodology`.
- Pre-merge verification of a fix → `edmund-validation-and-qa`.
## 1. First 15 minutes — any new bug
Run this checklist before hypothesizing. Every step is cheap; skipping them
is how rounds 15 of delete-drift shipped fixes that came back.
1. **Check the open-bug inventory (§6).** If the symptom matches a known open
bug, you are done triaging — link the backlog entry and its repro asset.
2. **Get the logs.** `ls -t ~/.edmund/logs/` and read the day's file
(`edmund-YYYY-MM-DD.log`). Grep for the three permanent breadcrumbs:
```bash
grep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.log
```
Also grep for `invariant:` (the always-on storage==rawSource tripwire) and
`⚠︎LEN-MISMATCH`.
3. **Find the first bad line and walk BACKWARDS.** The user-visible symptom
is often the second half of a two-part mechanism — the round-6 caret drift
was armed by a silent bypass 80 seconds and dozens of healthy edits before
the leap. Never start reading at the symptom timestamp.
4. **Rule out a stale build** if this follows a rebuild: grep
`strings .build/arm64-apple-macosx/debug/edmd` for a long string literal
unique to the new code (≤15-byte literals are inlined on arm64 and never
appear); `shasum` the binary. See §3c.
5. **Check git history for prior art.** `git log --oneline -- <suspect file>`
plus the investigation docs in `docs/`. The viewport-glitch investigation
found four earlier fixes all working around the same unnamed root cause.
6. **Reconstruct the document.** Get the user's file or rebuild it from the
trace's block counts/lengths. Wrapped-paragraph geometry and block kinds
matter; do not repro against "hello world".
7. **Before touching any running app**: `pgrep -lx edmd` then
`ps -o lstart=,command= -p <pid>`. The user's daily-driver app shares the
binary name. Never blanket `pkill -x edmd` — kill only the PID you
launched.
8. Row found in §2 → run its discriminating check. No row → escalate per §5,
and add the new row here when it's understood.
## 2. The triage table
| Symptom | Likely mechanism | Discriminating first check | Where next |
|---|---|---|---|
| Caret lands blocks away after delete or typing; text itself correct | Delete-drift class: a storage edit bypassed `didChangeText` (drag-move to no valid target), or TextKit 2's queued `_fixSelectionAfterChangeInCharacterRange` fired at a later `endEditing` | `grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log` and read the trace around it; look for `selectionDidChange` with `up=Y` at a surprising position | `edmund-caret-integrity-campaign`; `docs/investigations/delete-drift-investigation.md` |
| Every delete drifts, persistently, until an app switch fixes it | Stranded IME composition: `hasMarkedText()` stuck true, `didChangeText` bails forever, model frozen | Grep logs for `recovered stranded desync on focus regain`; check `storage.string == rawSource` | `docs/investigations/delete-drift-investigation.md` rounds 12 |
| Scroller jumps; scroll-to-target misses; content shifts on scroll | TextKit 2 height *estimates* — off-screen frames are guesses corrected as layout reaches them | Doc length vs `fullLayoutMaxLength` (100k UTF-16, `EditorTextView.swift`) — ≤100k should be fully laid out by the settle; >100k is estimate territory | `docs/investigations/viewport-glitch-investigation.md` |
| First line unreachable above the top; scroller already at 0 | TK2 strands fragments at negative y after a top-of-document edit | `grep "repairing content above origin" ~/.edmund/logs/edmund-*.log` — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause | `docs/investigations/viewport-glitch-investigation.md` Bug 2 (repair unconfirmed live) |
| Undo/redo lands viewport in the wrong place; changed text not selected | Regression of the diff-based restore contract (`5bb2b40`): a full `recompose` resets every fragment to an estimate, then the scroll measures the estimates | Confirm `restoreSnapshot` still routes through range-bounded `recomposeReplacing`, never full `recompose` (`+Undo.swift`); check the changed range, not the stored caret, drives the viewport | `docs/investigations/viewport-glitch-investigation.md` Bug 1 |
| Code/visual change "doesn't take" after rebuild | STALE BUILD — SwiftPM printed `Build complete!` without relinking `edmd` | `strings .build/arm64-apple-macosx/debug/edmd \| grep "<long new literal>"`; `shasum` before/after | `edmund-build-and-env`; §3c |
| App crashes the instant any LaTeX renders | SwiftMath `*.bundle` missing from the `.app` root (its `Bundle.module` is hardcoded to `Bundle.main.bundleURL`) | `ls build/Edmund.app/*.bundle` | `scripts/build-app.sh` copy step; ARCHITECTURE §8 |
| Everything suddenly renders as plain text; all styling gone | Silent, permanent TextKit 1 reversion: an `NSLayoutManager` API was touched or an `NSTextBlock`/`NSTextTable` attribute entered storage | DEBUG builds assert via the tripwire (`textKit1FallbackTripwire`, `willSwitchToNSLayoutManagerNotification`); audit recent diffs for `layoutManager` / `NSTextBlock` | ARCHITECTURE §2; `textkit2-appkit-reference` |
| Empty bands or clipped lines after a restyle | Attribute-only change without `invalidateLayout(for:)` — TK2 keeps the stale fragment frame | Is the misbehaving path a *new* styling path? `recomposeDirty` and the idle drain already invalidate; new paths must too | ARCHITECTURE §8 |
| Weird behavior only while composing CJK / accents / emoji | A storage-touching styling path missing the `!hasMarkedText()` guard (including async work scheduled *before* composition began) | Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition | ARCHITECTURE §8; `docs/investigations/delete-drift-investigation.md` |
| Callout at end of file shows an extra colored line not prefixed by `>` | KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) | Confirm against `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` | `misc/backlog.md` |
| Image leaves a large blank space below it | KNOWN OPEN BUG | `misc/bug-repros/image-blank-after.mov` | `misc/backlog.md` |
| Footnotes don't render (edit or read mode) | KNOWN OPEN BUG | — | `misc/backlog.md` |
| Right-click on the toolbar view-mode button shows "Customize Toolbar…" | `NSToolbar` with `allowsUserCustomization` claims every secondary click over the toolbar, beating view-level handlers | Verify the `DocumentWindow.sendEvent(_:)` intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen | ARCHITECTURE §8 |
| Window grows by title-bar height on every reopen | Frame-vs-content-size persistence trap: saving `contentView.bounds.size` and re-applying as `contentRect` | Confirm `lastWindowSize` round-trips `window.frame.size` via `setFrame` *after* the toolbar is installed (`Document.swift`) | ARCHITECTURE §8 |
| Sparkle update fails: "The update is improperly signed and could not be validated" | Bundle not sealed: signing only the main binary leaves no `_CodeSignature/CodeResources`; or the EdDSA keypair diverged from `SUPublicEDKey` | Does `build-app.sh` still seal the whole `.app` before copying the SwiftMath bundle in? | `edmund-release-and-operate`; ARCHITECTURE §8/§13 |
| Callout/overlay icon wedges a wrapping line down to one line | TK2 image-on-multiline-fragment wedge: drawing an *image* on a wrapping fragment collapses its layout; shapes do not | Is the overlay an `NSImage` on a fragment that can wrap? Convert to a stroked `CGPath` (the custom-title callout icon fix) | `docs/investigations/archives/callout-title-wrap-investigation.md` |
| Dragging produces no visible selection at all | Not a bug: a selection (possibly whole-document) was already active, and dragging *inside* an existing selection is AppKit's drag-*move* gesture | Trace: was there a `selectionDidChange` with a large `sel` before the drag began? | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 1 |
| Viewport oscillates up/down during a steady drag-select | Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection | Confirm the `scrollRangeToVisible` override still reveals the selection's *nearest* end (`+TypewriterScroll.swift`, commit `340fcbc`) | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 2 |
| Edits do nothing at all (not drift — dropped) | `isUpdating` stuck true would make `shouldChangeText` return false | Trace shows `shouldChangeText` never returning OK; distinct from the drift signature | `+EditFlow.swift` |
| Launching via `open` shows old behavior | LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy | `pgrep -lx edmd` first; launch by direct exec of the bundle binary | §3e; `edmund-build-and-env` |
## 3. The traps that cost real time
Each of these burned hours to days. Read before shipping any fix.
**(a) Shipping caret fixes on reasoning alone.** Delete-drift rounds 15 each
shipped a plausible, well-argued fix — and each came back. Only round 6, the
first with a frozen deterministic live repro (`bypassdelete` script), named
the actual mechanism (`_fixSelectionAfterChangeInCharacterRange` queued by a
bypassed edit, firing at the next `endEditing`) — and its first fix attempt
*failed in the repro within a minute*, which reasoning would never have
caught. Lesson: time spent making the failure cheap to observe beats time
spent reasoning about the fix. Freeze the repro before writing the fix.
**(b) Undo/redo viewport drift.** Two defects hid behind one symptom:
`restoreSnapshot` ran a full `recompose` (discarding all TK2 layout, so the
follow-up scroll measured freshly manufactured estimates) and `performUndo`
recorded the redo snapshot with the caret *at undo-invocation time*, not at
the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain
stale-state bug stacked; fix and verify them separately. The contract since
`5bb2b40`: diff the snapshot, apply via `recomposeReplacing`, select the
changed text, let the changed range drive the viewport.
**(c) Stale binaries produce false conclusions.** In round 6, `swift build`
twice printed `Build complete!` while linking a stale `edmd` — the compile
ran, the relink silently didn't — and two "failed" fix iterations were
phantoms. Detect: grep `strings` on the binary for a *long* literal unique to
the new code. Cure: `swift package clean` (or `rm -rf .build` for release
weirdness). Never hand-delete `.build/…/edmd.build/` — that corrupts the
output-file-map and wedges the target until a full clean.
**(d) Headless-green ≠ fixed for input-layer bugs.** The round-6 unit test
reconstructs the exact document and gesture and *passes with and without the
fix*: the test harness runs AppKit's deferred selection fixup synchronously,
so the broken state never forms. A green test proves nothing about the live
NSTextView / TextKit 2 / input-context class. The scripted live repro is the
regression harness; the unit test is only a contract spec.
**(e) `open -a` runs stale cached copies.** LaunchServices can foreground an
already-running instance instead of relaunching, and can execute a stale
cached/translocated copy of the bundle — you debug last hour's code. Always
launch by direct exec: `build/Edmund.app/Contents/MacOS/edmd file.md &`
(after the §1 step-7 pgrep check).
**(f) Trusting off-screen fragment y-coordinates.** A TK2 fragment's frame is
real only once laid out; everything off-screen, plus total document height,
is an estimate. Any code that measures before ensuring layout of the
viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll
gotcha, and most historical viewport glitches). Ensure layout for the target
range first, then align to real geometry — and never verify a visual fix
from headless layout: measure from `screencapture` pixels.
## 4. Discriminating experiments — cheap checks that split hypothesis spaces
**Verbose diagnostics launch** (defaults keys are namespaced; the file must
be argv[1]):
```bash
build/Edmund.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
```
Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor
tracing"). Logs land in `~/.edmund/logs/edmund-YYYY-MM-DD.log`.
**Trace field vocabulary** (source of truth:
`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`,
`diagnosticState`). Every trace line ends with:
| Field | Meaning |
|---|---|
| `sel={loc,len}` | current selection |
| `active=` | active block index (or `nil`) |
| `marked=` | marked-text range, `-` if none (non-`-` outside a live composition = stranded) |
| `up=Y/N` | `isUpdating` — `Y` means the event arrived MID-RECOMPOSE |
| `undo=Y/N` | `isUndoRedoing` |
| `blocks=` | block count |
| `storLen=` / `rawLen=` | storage vs rawSource lengths |
| `⚠︎LEN-MISMATCH` | appended when they differ |
**Healthy vs suspect edit orderings:**
- Healthy: `shouldChangeText` → `selectionDidChange` (`up=N`) → `synced`.
A *transient* `⚠︎LEN-MISMATCH` between those lines is normal (storage moves
before rawSource syncs).
- Suspect: a `selectionDidChange` with `up=Y` at a surprising position; a
*persisting* LEN-MISMATCH; a `shouldChangeText` with no
`synced`/`SKIPPED`/`DEFERRED` line after it (bypassed `didChangeText`); the
`healing storage edit that bypassed didChangeText` breadcrumb.
**`traceSelectionOrigin`**: under verbose tracing, any selection change
arriving mid-recompose (`up=Y`) or with an unconsumed pendingEdit logs a
condensed call stack naming the AppKit path that moved the caret. This is
what named `_fixSelectionAfterChangeInCharacterRange` in round 6. If your bug
moves the caret and you don't know who moved it, this answers it in one run.
**Walk backwards from the first bad line**, not forwards from the symptom.
The round-6 drift was armed 80 seconds before the visible leap. Find the
first line whose state is wrong, then read *earlier*.
**`verifyEditorInvariants`** (same file): the O(1) length check
(`storage.length != rawSource.length`) logs an `error` whenever logging is on
— no verbose toggle needed — so a hard-invariant break always leaves an
`invariant:` line. The full structural checks (string equality, blocks
reconstruct rawSource, block ranges in bounds) run under verbose tracing and
assert in DEBUG. An `invariant:` error in a user's log is a model desync,
full stop; triage as the delete-drift class.
If the existing logging didn't capture the deciding fact, add the log line
first and reproduce again — one breadcrumb beats ten speculative fixes. Keep
good ones behind `Log.shouldTrace` and ship them.
## 5. The escalation ladder (summary)
Full detail, ReproScript command reference, CGEvent driver, and soak-script
method: `edmund-live-repro-and-diagnostics` and `docs/dev-guides/live-repro-guide.md`.
Work down; stop at the first level that reproduces.
1. **Plain unit test** (`makeEditor()`) — model/parsing/styling logic.
2. **Windowed unit test** (NSWindow + NSScrollView, real `deleteBackward`) —
adds layout, viewport, first-responder. **Failure to repro here is
evidence, not defeat**: it points at deferred/queued AppKit state and at
levels 34.
3. **In-process ReproScript** — DEBUG builds accept `-debug.reproScript
<path>` (`Sources/edmd/App/ReproScript.swift`); replays a keystroke script
through the real `window.sendEvent` path. No Accessibility/TCC needed,
works with the window on an invisible Space. Commands: `sleep`, `caret`,
`type`, `backspace`, `bypassdelete`, `assertcaret`, `logsel`. Launch with
`-ApplePersistenceIgnoreState YES` and recreate the test document fresh
each run. **The default for live bugs.**
4. **CGEvent driver** — only for paths that must originate as HID events
(drag-select, drag-move, autoscroll). TCC decides per session; if input
doesn't land after one test click, fall back to level 3 immediately.
5. **Instrumented field occurrence** — can't trigger it yourself: add the
decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days
of latency; make sure the *next* occurrence is decisive.
After a fix: freeze the repro script, run a soak (several trigger cycles in
one app run with `assertcaret` checks), then full `swift test`.
## 6. Open-bug inventory
Known open bugs — check here before "discovering" one. Sources:
`misc/backlog.md` (authoritative list) and `docs/ROADMAP.md` (larger themes,
e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate
glitches are a known, partially-mitigated class). All entries below are OPEN
as of 2026-07-05.
| Bug | Status | Repro asset |
|---|---|---|
| Delete caret drift (class) | Open as a class; rounds 16 fixed, watching for round 7 | `misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov}` + matching logs |
| Inaccurate viewport estimates & related | Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live | — |
| Callout as last element renders an extra colored line | Open; live incremental restyle path only, NOT static rendering | `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` |
| Image creates large empty space below | Open | `misc/bug-repros/image-blank-after.mov` |
| Footnotes don't render (edit or read mode) | Open | — |
| Math environments don't render in read mode | Open | `misc/bug-repros/math-baseline-read-mode-png.png` (related baseline issue) |
| Math environments have wrong padding in edit mode | Open | — |
| Max content width not applied to read mode | Open | — |
| Images should shrink when content size is small | Open | — |
| Tables should wrap when content size is small | Open | — |
| Table cell content wraps out of the cell | Open | — |
| Click-to-select / select+delete sometimes doesn't work | Open, intermittent | — |
| Scroll glitch from height changes outside viewport | Open, unreproduced ("Lurking" in backlog) | — |
| Cursor stuck at indented position after indented editing | Open, unreproduced; awaiting screen recording | — |
| Undo/Redo and Copy/Paste scrolling "failing again" | Open, unreproduced (post-fix recurrence report) | — |
Do not relabel any of these as fixed without a verified repro flip; no
oversell. When you fix one, update `misc/backlog.md` and this table in the
same branch.
## 7. House rules while debugging
- **Never blanket `pkill -x edmd`.** The user's daily-driver app shares the
binary name. `pgrep -lx edmd` + `ps -o lstart=,command= -p <pid>`, then
kill only your own PID (`pkill -f EdmundDbg` if you launched the debug
bundle).
- **Do not request Computer Access** — Screen Recording and Accessibility are
already granted.
- **Measure visuals from `screencapture` pixels** (capture by window id, see
ARCHITECTURE §8), never from headless layout alone.
- **Never mutate storage while `hasMarkedText()`** — including in any
diagnostic or repro code you add.
- **Never auto-push, PR, or merge.** Branch off `main` per fix; commit small
and often.
- Logs in `~/.edmund/logs` are app-owned and fair game to read and quote.
## Provenance and maintenance
Built 2026-07-05 from: `docs/ARCHITECTURE.md` (§2, §8, §9),
`docs/investigations/delete-drift-investigation.md` (rounds 16),
`docs/investigations/viewport-glitch-investigation.md`,
`docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/dev-guides/live-repro-guide.md`,
`misc/backlog.md`, `docs/ROADMAP.md`, and
`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`. Log strings
(`healing storage edit that bypassed didChangeText`, `repairing content above
origin`, `recovered stranded desync on focus regain`), launch-flag keys,
`fullLayoutMaxLength`, ReproScript commands, the TK1 tripwire, and commit
`5bb2b40` were verified against the source tree on that date.
Maintain it like the codebase docs: when a new bug class is understood, add
its triage row; when a trap costs real time, add its story to §3; when a
backlog bug opens or closes, sync §6 with `misc/backlog.md` in the same
branch. If a row's discriminating check stops matching the code (renamed log
string, moved file), fix the row — a stale runbook is worse than none.
Deeper mechanism detail belongs in the sibling skills and `docs/`
investigation files, not here; this file stays a triage surface.
@@ -0,0 +1,268 @@
---
name: edmund-docs-and-writing
description: >
Documentation of record for the Edmund repo: which doc owns which fact, and
how to write in the house style. Load whenever you are writing or updating
ANY project doc — docs/ARCHITECTURE.md, CHANGELOG.md, README.md,
docs/ROADMAP.md, misc/backlog.md, a docs/<topic>-investigation.md
write-up, release docs — or deciding WHERE a newly learned fact, gotcha,
bug, or feature idea belongs. Covers the docs-of-record map, the
fact-routing decision table, the investigation-doc template, CHANGELOG
format (machine-extracted for release notes), commit-message conventions,
and doc maintenance duties. Not for making the code change itself, release
mechanics, or debugging — see "When NOT to use this skill".
---
# Edmund docs and writing
Date-stamped 2026-07-09. Every claim below was verified against the files on
`main` at that date; re-verify paths before trusting this after major
reorganizations.
## When NOT to use this skill
| You are actually doing | Use instead |
| --- | --- |
| Changing code / designing a mechanism | `edmund-architecture-contract` |
| Branch/commit/PR mechanics, pre-commit checklist | `edmund-change-control` |
| Cutting a release, appcast, Sparkle, CI | `edmund-release-and-operate` |
| Diagnosing a bug (not writing it up) | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` |
| Mining past investigations for technique | `edmund-failure-archaeology` |
| Marketing copy, positioning, alternatives research | `edmund-external-positioning` |
| Build flags, env, debug bundle | `edmund-build-and-env`, `edmund-config-and-flags` |
This skill is for prose: what to write, where it lives, how it should read.
## 1. The docs-of-record map — one home per fact
Every fact has exactly one home; everywhere else gets a pointer. All paths
exist and are current as of 2026-07-09.
| Doc | Owns | Notes |
| --- | --- | --- |
| `docs/ARCHITECTURE.md` | HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) | THE agent-onboarding doc. Its own header states the rule: **when you learn something non-obvious or change an invariant, edit this file in the same PR.** |
| `docs/architecture/README.md` | Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of `docs/architecture/`'s deep docs and the sibling `investigations/`/`dev-guides/` folders, common quirks (each a pointer, never a new claim), getting-started commands | The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns. |
| `docs/architecture/<topic>.md` | Deep narrative write-up of one subsystem (e.g. `editor-pipeline.md`, `text-system.md`) | The "deep-doc" pattern: a fact's *statement* lives in `ARCHITECTURE.md`, its *explanation* lives here, each links to the other. |
| `docs/architecture/extensibility.md` | The design-of-record for themes/extensions: vision, current state (verified against `main` and the unmerged `feat/extensions-registry-and-tab` branch), themes/extensions design, staged implementation plan, honest risks | **Design only, not yet implemented on `main`.** `ARCHITECTURE.md` gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet. |
| `docs/architecture/sandboxing.md` | The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the `~/.edmund/` onboarding grant, staged plan (SB0-SB4), open decisions | **Plan only, nothing sandboxed on `main`.** Same design-doc exception as `extensibility.md`: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to `ARCHITECTURE.md` in the same PR. |
| `README.md` | WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" `xattr -dr com.apple.quarantine` workaround), dependencies, alternatives, acknowledgements, license | User-facing; no internals. |
| `CHANGELOG.md` | User-facing version history, Keep-a-Changelog style | `## [x.y.z]` sections are machine-extracted for release notes — exact format matters (§4 below). |
| `docs/ROADMAP.md` | Versioned feature plan: `## v1.0.0`, `## v1.x`, `# v.2.0.0` sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) | Has a `Last updated: YYYY-MM-DD` line under the title — refresh it when you edit. |
| `misc/backlog.md` | The maintainer's working priority list: `## Now (small releases)` (Marketing / On-going bugs / Bugs / UI/UX / Features), `## Next`, `## Later`, roadmap mirrors, `### Lurking (Unreproduceable)`, `## Done` | Stated priority: **Marketing = Bugs >= UI/UX > Features**. Bug entries carry repro pointers (`misc/bug-repros/*.mov`, `.log`, or `~/Desktop` paths). |
| `docs/investigations/<topic>-investigation.md` | Deep multi-round investigation chronicles for active bug classes | Existing: `delete-drift-`, `viewport-glitch-investigation.md`. Template in §5. |
| `docs/investigations/archives/<topic>-investigation.md` | Chronicles for closed/resolved bug classes | Existing: `callout-bottom-line-`, `callout-title-wrap-investigation.md`. |
| `docs/dev-guides/live-repro-guide.md` | Method doc: the escalation ladder for reproducing live-app bugs | Referenced from ARCHITECTURE §11. |
| `misc/before-you-release.md` | Pre-flight readiness checklist | Pairs with `how-to-release.md`; cross-ref `edmund-release-and-operate`. |
| `misc/how-to-release.md` | Release mechanics (CI tag path, local `release.sh`) | Same. |
| `AGENTS.md` (root) | Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule | Short by design; it delegates the "how" to ARCHITECTURE. |
| `LICENSES/` | Vendored license texts (currently `lucide.txt` for the Lucide icon SVGs) | Add one when vendoring third-party assets. |
| `Info.plist` | `CFBundleShortVersionString` + `CFBundleVersion` — the version of record | Must match the CHANGELOG section header at release (see `misc/before-you-release.md` §3). |
Note: `misc/backlog.md` and `docs/ROADMAP.md` currently duplicate the
v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the
public plan; backlog is the working list. When they disagree, treat ROADMAP as
the versioned commitment and backlog as scratch — and mention the drift to the
maintainer rather than silently reconciling.
## 2. Where does a new fact go — decision table
Route the fact FIRST, then write. One home; cross-reference from elsewhere.
| You learned / produced | Home | How |
| --- | --- | --- |
| Code quirk, edge case, workaround, non-obvious *why* | **Comment at the code site** | House rule (root `AGENTS.md`): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file." |
| Architectural gotcha that will bite the next agent | `ARCHITECTURE.md` §8 | Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change. |
| New known issue / structural constraint | `ARCHITECTURE.md` §9 | It has an explicit placeholder: *"Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in `docs/`."* |
| Code debt / incomplete implementation | `ARCHITECTURE.md` §10 | Its footer says: track code-debt here, roadmap items in README/ROADMAP. |
| Changed invariant, new subsystem, new pipeline step | `ARCHITECTURE.md` §2–§7 (the relevant section) | Update in the same PR — header rule. |
| Multi-round investigation (2+ hypothesis cycles, live repro work) | New `docs/<topic>-investigation.md` | Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc. |
| User-visible change (fix/feature/rename) | `CHANGELOG.md` under the next `## [x.y.z]` | Format in §4. Link the issue and any investigation doc. |
| New bug found (reproducible) | `misc/backlog.md` under `Bugs` | `- [ ] Bug: <symptom>. See <repro pointer>.` Drop repro assets (video/log) into `misc/bug-repros/`. |
| New bug found (unreproducible so far) | `misc/backlog.md``### Lurking (Unreproduceable)` | One line + "wait for screen record" style note. |
| Bug that is really code debt (design limitation) | `ARCHITECTURE.md` §9 | e.g. the image-on-wrapping-fragment constraint. |
| Feature idea, near-term (next few small releases) | `misc/backlog.md` (`Now`/`Next`/`Later`) | Sorted by priority + difficulty within category. |
| Feature idea, versioned/strategic | `docs/ROADMAP.md` under the right version | Refresh `Last updated`. |
| Repro method / debugging technique | `docs/dev-guides/live-repro-guide.md` | Method docs, not per-bug chronicles. |
| Release procedure change | `misc/how-to-release.md` / `misc/before-you-release.md` + `ARCHITECTURE.md` §13 | §13 owns the mechanism + failure modes; misc/ owns the operator checklist. |
| Agent workflow improvement | `ARCHITECTURE.md` §12 | Its footer invites this: "If you (the agent) improve this workflow... update this section." |
| Vendored third-party asset | `LICENSES/<name>.txt` + a feature-map note in §6 | Follow the Lucide precedent. |
| Deep explanation of an existing subsystem | `docs/architecture/<topic>.md` | A fact's *statement* lives in `ARCHITECTURE.md`; its *explanation* lives in the deep doc; each links to the other. |
**The same-PR rule is the load-bearing one.** Doc updates that ride the code
PR actually happen (see `cf10741`, `b600e12`, `c4a602b` in history); doc
updates deferred to "later" don't.
## 3. House style
Derived from reading `ARCHITECTURE.md` and the investigation docs. Match it.
- **Dense, specific, evidence-first.** State the mechanism and the proof, not
vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps
and selection ranges quoted verbatim in investigation docs.
- **Bold lead-ins for gotcha bullets**, then the explanation:
`- **Stale release builds**: ...`. Scannable list, detail inline.
- **Backticks for every file, symbol, flag, and command**:
`` `recomposeDirty` ``, `` `+EditFlow` `` (the extension-file shorthand),
`` `-debug.reproScript` ``.
- **One-line repro pointers**, not embedded essays: "See
`misc/bug-repros/image-blank-after.mov`", "grep `~/.edmund/logs` for
`repairing content above origin`".
- **Honest status labels.** The docs say "unconfirmed live", "theory +
targeted repair, not a confirmed kill", "Verification limits (honest
gaps)", "the test documents intent; the leap only reproduces under live
layout". Never claim verification you didn't do. No oversell.
- **Section anchors as cross-refs**: "see §8", "ARCHITECTURE §13" — used
across ARCHITECTURE, AGENTS.md, before-you-release.md. If you renumber
sections, grep the repo for `§` and fix every reference.
- **Address "you", the next agent/engineer**: "will bite you", "Context for
anyone who sees the bug again", "Next time it happens: ...".
- **Record what failed, not just what worked** — investigation docs keep the
overturned theories and the phantom fixes (stale-binary trap) because the
dead ends are the reusable knowledge.
### Commit messages (from `git log --oneline -50`)
Mixed but patterned: conventional prefixes dominate for fixes and docs —
`fix(scope): ...` (scopes seen: `editor`, `layout`, `scroll`, `undo`,
`release-workflow`, `changelog-to-html`), `docs: ...`, occasional
`refactor:`, `appcast: add Edmund X.Y.Z`, `release X.Y.Z`. Chores and README
work often use plain imperative subjects ("Update README", "Add assets for
README"). Branches: `fix/<slug>`, `docs/<slug>`, `chore/<slug>`. When in
doubt: `fix(scope):` for behavior changes, `docs:` for doc-only commits,
plain imperative for chores. Never auto-push, PR, or merge — only when asked.
## 4. CHANGELOG format — machine-read, get it exact
`.github/workflows/release.yml` extracts release notes with:
```
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}"
```
So the section header MUST be `## [x.y.z]` at line start, version matching
`CFBundleShortVersionString` exactly; the section ends at the next `## [`.
`scripts/changelog-to-html.py` converts the same section to HTML for
Sparkle's update dialog (it folds wrapped bullet lines into their `<li>` —
wrapping bullets is safe). Full pipeline: `edmund-release-and-operate`.
House format (verify against the file; current entries follow this):
```markdown
## [0.1.4] — 2026-07-XX
### Fixed
- <User-facing symptom, past tense optional> ([docs](docs/<topic>-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN)
---
```
- Em dash between version and ISO date; `---` separator between versions.
- Subsections used so far: `### Added`, `### Changed`, `### Fixed`
(Keep a Changelog 1.1.0 vocabulary).
- Entries describe the user-visible effect, not the mechanism; mechanism
lives in the linked investigation doc / ARCHITECTURE.
- An optional free-text line under the header is fine (0.1.2 has one) — the
awk extraction includes it.
## 5. The investigation-doc template
Derived from `docs/investigations/delete-drift-investigation.md` (6 rounds) and
`docs/investigations/viewport-glitch-investigation.md`. Both open with why the doc exists
("Context for anyone who sees the bug again... records the trail end to
end") and name the fixing commits/branch up front. Chronicle structure: each
recurrence is a new `## Round N` appended to the same doc — symptom →
diagnosis → root cause → fix → verification, with limits stated.
Skeleton (copy-paste):
```markdown
# <Area> "<bug nickname>" — investigation notes
Context for anyone who sees this again. <One line on why it was hard:
intermittent / state-dependent / looked nothing like its cause.>
Fixed on branch `fix/<slug>`, commits: `<sha>` — <subject>, ...
## Symptom
<Exact user-visible behavior. Bulleted key properties, each a discriminating
fact ("caret-only, text fine"; "never right after launch"). Evidence
pointers: `misc/bug-repros/<file>`, `~/.edmund/logs/...`.>
## How it was diagnosed
1. <Numbered steps in the order they happened, including overturned
theories and WHY each clue narrowed the space.>
## Root cause
<The mechanism, in bold where it matters. Explain why every symptom
property follows from it.>
## The fix
<What changed, in which file, and why that shape (defenses tried and
rejected count too).>
## Verification
<Tests added, live repro results, suite count. Then an honest limits
subsection: what was NOT reproduced/confirmed, and the breadcrumb to grep
for if it recurs.>
## If it ever recurs
<Ordered checks for the next investigator: which invariant/log/flag to
inspect first.>
## Round 2: <one-line summary> ← append on recurrence, same structure
```
After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a
pointer, add the CHANGELOG entry with a `([docs](docs/...))` link, and check
the corresponding `misc/backlog.md` box (or move it under `On-going bugs`).
## 6. Maintenance duties
Do these whenever you touch the relevant doc; they rot otherwise.
- **ARCHITECTURE placeholders**: §9 and §10 end with italic *"Add new ones
here"* / *"track code-debt here"* lines — keep them last in their lists so
the invitation stays visible.
- **ROADMAP `Last updated:`** — bump the date on any edit.
- **Backlog hygiene**: check `- [x]` boxes when a fix ships (move to
`## Done` only if following the existing pattern — completed items live
there); keep repro pointers valid; don't reorder the maintainer's priority
sorting.
- **README's inline HTML comments** are the maintainer's own edit notes
(e.g. `<!-- Replace "minimal" with ... -->`) — leave them unless acting on
them.
- **At release**: CHANGELOG section header ↔ `Info.plist` version ↔ appcast
`<item>` must agree; the checklist is `misc/before-you-release.md`, the
mechanics `edmund-release-and-operate`.
- **Section renumbering** in ARCHITECTURE: grep the whole repo (docs, misc,
AGENTS.md, skills) for `§` references before and after.
- **Never edit `test-files/todo.md`** — the maintainer owns it.
## Provenance and maintenance
Written 2026-07-05 against `main` at `fe8a1f5` (release 0.1.3). Sources, all
read directly: `docs/ARCHITECTURE.md` (header, §8–§13),
`README.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `misc/backlog.md`,
`docs/investigations/delete-drift-investigation.md`, `docs/investigations/viewport-glitch-investigation.md`,
`docs/dev-guides/live-repro-guide.md` (§1), `misc/before-you-release.md`,
`misc/how-to-release.md`, root `AGENTS.md`,
`.github/workflows/release.yml` (awk extraction quoted verbatim),
`git log --oneline -50` (commit-style tally), directory listings of
`docs/` (`architecture/`, `investigations/` incl. `archives/`, `dev-guides/`),
`misc/`, `misc/bug-repros/`, `LICENSES/`.
§1 map re-verified 2026-07-09 against the `docs/` reorg (investigation docs
split into `docs/investigations/` + `docs/investigations/archives/`;
`docs/live-repro-guide.md` moved to `docs/dev-guides/`).
Maintain this skill when: a doc of record moves or splits (update the §1
map), ARCHITECTURE sections are renumbered (fix every § reference here),
the CHANGELOG extraction in `release.yml` changes (§4 quotes it), or a new
investigation doc establishes a better template. Keep the one-home-per-fact
rule itself stable — it is the point of the skill.
@@ -0,0 +1,204 @@
---
name: edmund-external-positioning
description: >
Load when writing anything an outsider will read about Edmund — README edits,
blog posts, release notes, marketing copy, social posts, webpage text, Show HN
drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit,
Nodes), deciding what may be publicly claimed vs. what is still unproven,
labeling a technique "novel", or planning ecosystem work (licenses,
attribution, notarization messaging, appcast, issue templates, discovery
listings). This skill is the overclaim firewall: what the positioning is,
what evidence backs each claim, and what must exist before a claim gets
stronger.
---
# Edmund external positioning — what we claim, what we can prove
Governing rule: **nothing may be claimed publicly that an outsider cannot
reproduce from the repo + a release.** Unproven = "candidate", never "novel"
or "first". This skill exists to prevent overselling a beta.
## When NOT to use this skill
| You are doing | Use instead |
| --- | --- |
| Actually cutting a release (tags, appcast, Sparkle, CI) | edmund-release-and-operate |
| Internal docs, ARCHITECTURE.md, investigation chronicles | edmund-docs-and-writing |
| Understanding the invariants / render pipeline itself | edmund-architecture-contract |
| Deciding whether a code change is allowed | edmund-change-control |
| Reproducing or debugging a bug | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Judging research novelty for internal direction (not public claims) | edmund-research-frontier, edmund-research-methodology |
| Verifying behavior before shipping | edmund-validation-and-qa |
## 1. The positioning (quote it exactly)
Source of truth: `README.md`. As of 2026-07-05:
- One-liner: **"Edmund is a minimal, file-based, native Markdown editor for
macOS with inline live preview."**
- README carries its own TODO comment on this line: *"Replace 'minimal' with
'customizable' or 'lightweight' once more features are implemented"* — do
not do that replacement early; "minimal" is the honest word today.
- Goal statement: **"Our goal is to be the [CotEditor](https://coteditor.com)
of Markdown editors, i.e. elegant, powerful, configurable, and native inside
out."**
- Beta warning: **"⚠️ Edmund is currently in beta."** — this must stay visible
in the README and any landing page until v1.0.0 ships.
- Maintainer's blog post: <https://i7t5.com/posts/2026-06-26-edmund/> ("more of
the motivation and design philosophy"). Cite it; do not paraphrase or invent
its content without fetching it.
- Ambition framing: **product-first**. "Beyond state of the art" means product
excellence — the TextKit 2 techniques are means, not ends. Never lead public
copy with internal mechanism names; lead with what the user gets.
### The six claimed differentiators (README, verbatim, 2026-07-05)
| # | Claim (verbatim) |
| --- | --- |
| 1 | "Live preview: Typora/Obsidian-style WYSIWYG." |
| 2 | "File-based: Open `.md` files from anywhere. No vaults or dedicated folders required." |
| 3 | "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies." |
| 4 | "Fast: Handles ~1-2MB files with ease. No launch lag." |
| 5 | "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!" |
| 6 | "Private: Offline by default. Optional blocking of external links and HTML sanitization." |
README also has a TODO comment after the list: *"Move 'Fast' and 'Extensible'?
Add 'integrations' section to Native after implementation"* — the list is
known-provisional; keep quotes synced to the file when you edit copy.
## 2. Claims discipline
Before strengthening any claim publicly, the evidence in the middle column
must be upgraded to the right column. Status as of 2026-07-05.
| Claim | Current evidence | Required before strengthening |
| --- | --- | --- |
| Fast: "~1-2MB files with ease. No launch lag" | `Tests/EdmundTests/PerfHarnessTests.swift` (gated `MD_PERF=1`, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with `fullLayoutMaxLength = 100_000` regime (`EditorTextView.swift:80`) | A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (`MD_PERF=1 swift test`). No comparative "faster than X" claims without benchmarking X the same way. |
| Extensible: "Extensions system coming soon!" | Extensions API is `docs/ROADMAP.md` v1.0.0 — **not shipped**. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" | Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense. |
| Private: "Offline by default" | Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (`ReadRenderOptions.allowRemoteImages = false`); inline HTML whitelisted via `HTMLRenderer.sanitizeInlineHTML` | Note: the "Block external images" Settings checkbox is a `misc/backlog.md` item, **not shipped**; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE). |
| Native: "100% Swift … No Electron. Minimal dependencies" | True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" | Nothing; this claim is safe. Just never inflate to "zero dependencies". |
| Live preview: "Typora/Obsidian-style" | Shipped and demoed (README video, screenshots) | Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first. |
| File-based: "No vaults" | Shipped by design | Safe. |
| Beta status | v0.1.3 (2026-07-04) | Must stay visible everywhere until v1.0.0. |
## 3. Novel vs. known — the honest inventory
When writing a craft blog post or comparison, keep this ledger straight.
"Candidate" means blog-worthy pending proof; it is not "proven novel".
### Known / prior art (never claim novelty here)
- Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is
the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience").
- TextKit 2 viewport virtualization for Markdown:
[nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine)
(Apache 2.0, macOS 14+) solves the same problems — viewport virtualization,
live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14:
consult it **before inventing a new mechanism** and as a technique source
(drag-select autoscroll, overscroll). Its existence caps any "first TK2
live-preview engine" claim at zero.
- The README's own Alternatives section credits ~15 editors. Public copy that
ignores them reads as either ignorant or dishonest.
### Distinctive candidates (label as such; each needs proof before publishing)
| Candidate | Why it might be blog-worthy | Proof needed first |
| --- | --- | --- |
| Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) | A clean architectural answer to the classic WYSIWYG mapping problem | A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples |
| Stroked-`CGPath` overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) | A concrete, reproducible TK2 bug + workaround — the classic useful engineering post | A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces |
| Bypassed-`didChangeText` heal + caret re-assertion (round-6 mechanism: TK2 leaves a `_fixSelectionAfterChange` queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) | Deep TK2 internals nobody has documented; the delete-drift chronicle (`docs/investigations/delete-drift-investigation.md`) already exists as raw material | The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us) |
| Diff-based undo restore that preserves TK2 layout (snapshot restore *diffs* rather than replaces, bypassing NSTextView undo) | Practical fix for a visible TK2 pain (undo viewport yank) | Before/after measurements (layout work saved, viewport stability) on a pinned document |
| In-process ReproScript methodology (`-debug.reproScript`, keystroke replay without CGEvents/TCC; `docs/dev-guides/live-repro-guide.md`) | Reusable testing methodology for any AppKit text app | Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard |
Reproducibility standard for any technical post: a reader with the repo and
the post must be able to reproduce every claim — frozen repro scripts, pinned
document fixtures, named macOS versions, measured numbers with the command
that produced them. If a claim can't meet that, cut it or mark it anecdotal.
## 4. Ecosystem and license hygiene
Verified against the repo, 2026-07-05:
- **License: Apache 2.0** (`LICENSE`, README "License" section, and the 0.1.0
changelog entry all agree). Say "Apache 2.0", never "MIT".
- **Lucide icons: vendored, ISC** (`LICENSES/lucide.txt`, © 2026 Lucide Icons
and Contributors; parts derived from Feather). Attribution duty: keep
`LICENSES/lucide.txt` shipping and credit Lucide where icons are discussed.
- **Why Lucide in both modes (SF Symbols constraint):** ARCHITECTURE §6 —
SF Symbols **cannot ship in exported PDFs** (license), so callout headers use
Lucide in both Read (inline SVG, `currentColor`-tinted) and Edit (rasterized
tinted `NSImage` overlay). App-chrome SF Symbols (toolbar/settings) are fine;
Edit-mode task checkboxes still use SF Symbols on-screen only. Don't
"simplify" copy or code in a way that breaks this split.
- **Dependencies to credit:** swift-markdown, SwiftMath, Sparkle (README
"Dependencies"). Acknowledgements section additionally credits
swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit,
and others — preserve it when restructuring the README.
- **Not notarized (2026-07-05):** ad-hoc signed; users hit Gatekeeper
("damaged app"). README's WARNING block gives the two workarounds
(System Settings → Privacy & Security → Open Anyway; or
`xattr -dr com.apple.quarantine /Applications/Edmund.app`, maybe `sudo`).
Keep those instructions accurate in every venue that mentions installing.
`misc/marketing/MARKETING.md` gates Show HN on fixing this (notarize, or
make the workaround idiot-proof).
- **GitHub issue templates exist:** `.github/ISSUE_TEMPLATE/bug_report.md`,
`feature_request.md`. Point users there, not at email.
- **`appcast.xml` is a public artifact** served raw from the repo (`SUFeedURL`
points at the raw GitHub URL). Anything committed to it is user-visible in
Sparkle's update dialog. Pipeline details: edmund-release-and-operate.
## 5. Release-notes and public-writing style
- **Pipeline:** `CHANGELOG.md` sections become both the GitHub release notes
(awk-extracted) and Sparkle's update-dialog HTML
(`scripts/changelog-to-html.py` → appcast `<description>`). A CHANGELOG
entry IS public copy — write it that way. Mechanics: edmund-release-and-operate.
- **Actual house style** (read `CHANGELOG.md` 0.1.00.1.3 before writing):
Keep-a-Changelog headers (`### Added / Changed / Fixed`); one line per item,
sentence case, no trailing period enforced; links to issues (`[#156]`) and
investigation docs (`([docs](docs/investigations/delete-drift-investigation.md))`);
user-visible phrasing ("Redo now jumps to where changed text was instead of
caret") not internal jargon; occasional first-person maintainer notes with
personality ("trying to have Fable 5 fix all the big bugs while I still have
it with me"); 0.1.0 used bold **Feature** — one-line descriptions. Match this
voice: plain, specific, lightly informal, zero hype.
- **Screenshots/videos:** README embeds live in `docs/assets/`
(`v0.1.0_*.png`, `installation.png`, `v0.1.0_video.mp4`, `AppIcon/`).
Raw/source marketing assets live in `misc/marketing/`: `MARKETING.md`
(the plan), `reddit-post.md`, `demo-slide-v0.1.key`, `demo-src-files/`,
demo videos (`demo.mov`, `demo-video-v0.1-brown.mp4`), `_raw` screenshot
masters, `social-preview_figma.png`. New public screenshots: polished copy
`docs/assets/`, raw master → `misc/marketing/`, versioned filenames.
## 6. Marketing priority context (2026-07-05)
From `misc/backlog.md` "Now": **"Priority: Marketing = Bugs >= UI/UX >
Features"** — marketing work is tied for top priority with bug fixes.
Open marketing items: screenshots/files and a webpage (reference:
kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart;
`misc/marketing/MARKETING.md` names GitHub stars (~69 at writing) as the goal
and metric, audience "developers who value craft", and holds Show HN in
reserve until first-run friction and a landing page are fixed. Its "craft
months" deep-dives are exactly the Section 3 candidates — which is why the
proof bar there matters.
## Provenance and maintenance
- Sources verified 2026-07-05 against: `README.md`, `docs/ROADMAP.md`
(last updated 2026-07-03), `misc/backlog.md`, `docs/ARCHITECTURE.md`
(§2, §6, §8, §13, §14), `CHANGELOG.md` (0.1.00.1.3), `LICENSE`,
`LICENSES/lucide.txt`, `.github/ISSUE_TEMPLATE/`, `misc/marketing/`,
`Tests/EdmundTests/PerfHarnessTests.swift`,
`Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift`,
`Sources/EdmundCore/TextView/EditorTextView.swift`.
- Volatile facts are date-stamped inline: version (0.1.3), beta status,
notarization status, shipped-vs-roadmap feature split, star count, README
wording. Re-verify each against the file before repeating it publicly.
- When README differentiators or the one-liner change, update the verbatim
quotes in §1 and re-run the §2 evidence check.
- If a §3 candidate ships as a published post, move it out of "candidate" and
link the post + its frozen repro.
- Cross-references: edmund-release-and-operate (release/appcast mechanics),
edmund-docs-and-writing (internal doc style), edmund-research-frontier
(novelty judgment for research direction), edmund-architecture-contract
(the invariants quoted here).
@@ -0,0 +1,442 @@
---
name: edmund-failure-archaeology
description: >
The chronicle of every major bug investigation, dead end, rejected fix, and
revert in the Edmund Markdown editor. Load BEFORE re-investigating any
caret/selection symptom (drift, jump, desync), any viewport/scroll glitch
(lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge
(clipped wrap, one-line collapse, blank space), or any release/update
failure (signing, appcast, Sparkle "improperly signed"). Load before
proposing a fix that might already have been tried and reverted, when a bug
report "looks familiar", when a test passes but the live app still misbehaves,
or when wondering why the code does something weird (a guard, a re-assert, a
deliberately-missing icon). Every entry: symptom, root cause, evidence
(commit hashes, docs), status, and what NOT to retry.
---
# Edmund failure archaeology
Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives
symptom → root cause → evidence → status. Hashes are on `main` unless noted.
Dates are commit dates. Status vocabulary: **settled** (root-caused, fix
verified), **mitigated-unconfirmed** (fix shipped, never seen killing a live
occurrence), **open** (in `misc/backlog.md`), **reverted-pending-redo**.
## When NOT to use this skill
- Designing a change / asking "why is it built this way" → `edmund-architecture-contract`.
- Actively debugging a NEW symptom (method, not history) → `edmund-debugging-playbook`.
- Driving the live app to reproduce something → `edmund-live-repro-and-diagnostics` (and `docs/dev-guides/live-repro-guide.md`).
- TextKit 2 / AppKit API semantics → `textkit2-appkit-reference`.
- Cutting or fixing a release → `edmund-release-and-operate` (this file only records how 0.1.0 broke).
- Build environment, stale-link traps in depth → `edmund-build-and-env`.
- Deciding whether a change is safe to make at all → `edmund-change-control`.
- Test strategy / what the suite can and cannot catch → `edmund-validation-and-qa`.
- The ongoing caret-integrity program (forward-looking) → `edmund-caret-integrity-campaign`.
- Debug flags (`-debug.reproScript`, verbose tracing toggles) → `edmund-config-and-flags`.
---
## 1. The delete-drift saga (rounds 16) — issue #156
The hardest bug in the project's history: pressing Delete moved the caret to a
different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04.
Full trail: `docs/investigations/delete-drift-investigation.md` (read it before touching
`+EditFlow` / `+SelectionTracking` / the heal). One symptom, FOUR distinct
root causes stacked on top of each other — each fix was real, and each round's
recurrence was a different mechanism underneath.
**STATUS: settled through round 6** (shipped in 0.1.3, 2026-07-04). The class
— live-only caret/selection desync — remains the project's hardest problem;
new rounds are possible. Backlog still lists "Delete caret drift" under
On-going bugs as a class to watch, not a known unfixed defect.
### Round 1 — stranded IME marked text (2026-06-26, `386604b` + docs `ef3d87e`)
- **Symptom:** once it started, EVERY delete drifted; never at launch; cleared
by switching apps and back. Text stayed correct — caret-only desync.
- **Root cause:** every styling path bails on `hasMarkedText()` (correct during
live IME composition). A *stranded* composition (`hasMarkedText()` stuck true,
no live composition) made `didChangeText` bail forever → `rawSource`/`blocks`
froze while storage kept mutating → all caret math ran against a stale model.
Strander: the async active-block restyle in `+SelectionTracking` re-checked
`isUpdating` but not `hasMarkedText()`, so it could run `recomposeDirty` over
a live composition scheduled one turn earlier.
- **Fix:** (a) add the missing `!hasMarkedText()` guard to the async restyle;
(b) `becomeFirstResponder` recovery hook — `unmarkText()` + resync when the
invariant is broken on focus regain (made the user's accidental focus-switch
cure deterministic).
- **Why it came back:** the guard closed one strander; other marked-text
sources existed (round 2), and other desync mechanisms entirely (rounds 46).
### Round 2 — marked text without "IME" (2026-06-27, `a1f3219`)
- **Symptom:** recurred with no CJK/accent/emoji input. Focus-switch still cured it.
- **Root cause (by elimination, documented in the doc):** still stranded marked
text — from **automatic text completion / inline predictions**, which inject
provisional marked text on plain typing.
- **Fix:** `isAutomaticTextCompletionEnabled = false`, `inlinePredictionType = .no`
in `commonInit`, plus a permanent `Log.info` breadcrumb in the recovery hook.
- **Why it came back:** the next recurrence wasn't marked text at all.
### Round 3 — no fix; built diagnostics instead (2026-06-28, `5dae387`, `3aaeb04`, PR #139)
- **Symptom:** recurred on a build with rounds 12. NO `recovered stranded desync`
log line; a headless probe of the exact gesture showed the model was CORRECT.
Only appeared after minutes of editing in one window.
- **Conclusion:** the model/parse layer is sound; the drift is a live
NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing
it blind was declared the wrong move.
- **Shipped:** verbose editor tracing (Settings ▸ Advanced, `Log.trace`,
category `.edit`, per-event live-state prefix) + an always-on O(1) invariant
tripwire (`verifyEditorInvariants`, logs `error` on length mismatch). This
instrumentation is what cracked rounds 46. Lesson: when a live-only bug
resists reproduction, ship diagnostics, not guesses.
### Round 4 — drag-move deletes with NO `didChangeText` (2026-07-02, `9f99795`, PR #163)
- **Symptom (from the round-3 trace):** drifting deletes showed
`shouldChangeText`*nothing*`selectionDidChange` mid-recompose with a
stale caret. Origin event: a drag-select, then `shouldChangeText OK repl=""`,
then LEN-MISMATCH forever — `didChangeText` never fired.
- **Root cause:** AppKit's drag-**move** gesture, when the drop lands past the
end of the document (or fumbles), performs the source deletion via
`shouldChangeText``replaceCharacters` and **never calls `didChangeText`**.
`rawSource` silently froze — and **autosave wrote the stale bytes**: this was
a data-corruption bug, not just a caret bug.
- **Fix:** `shouldChangeText` schedules a next-run-loop bypass check
(`RunLoop.main.perform`): a `pendingEdit` still unconsumed one pass later ==
didChangeText was bypassed → run the same sync it would have, log
`healing storage edit that bypassed didChangeText` (release too). Exempt
while `isUpdating`/`isUndoRedoing`/`hasMarkedText()` (IME legitimately defers).
- **Why it came back:** the heal restored the *model* but rounds 56 found the
heal itself could move the caret.
### Round 5 — the heal leaped the caret (stale selection) (2026-07-03, `422498f`, docs `c4a602b`, merged `222dd86`, PR #166)
- **Symptom:** heal fired, invariant restored, bytes correct — but the caret
leaped to the END of the document at the heal moment.
- **Root cause:** when the bypassed deletion removes the *selected* text, AppKit
also skips its usual selection fix, so at heal time the selection still spans
deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes
AppKit re-resolve the invalid selection → clamps to document end.
- **Fix:** before syncing, the heal **collapses an out-of-bounds selection** to
the edit point. (Headless NSTextView clamps this itself — the test documents
intent; only live layout reproduces the leap.)
- **Why it came back:** this out-of-bounds clamp was a special case of the real
mechanism, found in round 6.
### Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, `1b1420a`, branch `fix/wrapped-paragraph-caret-drift`, merged `218d922`, PR #169)
- **Symptom:** typing mid wrapped paragraph, one backspace leaped the caret +43
("two viewport-lines down"); drift no longer continuous — one delete drifts,
the next ones don't. Model fine; a heal had fired 80 seconds EARLIER.
- **Root cause (named via a `traceSelectionOrigin` stack capture):** a normal
edit runs TextKit 2's `_fixSelectionAfterChangeInCharacterRange` synchronously
inside its own transaction. A didChangeText-bypassing mutation skips that too,
so the fixup **stays queued and fires at the NEXT `endEditing`** — the heal's
attribute-only restyle — where it maps the stale selection against post-edit
coordinates and drops the caret blocks away. Fires exactly once (state is
then synchronized), explaining "drifts once, then fine". Round 5's clamp was
the sub-case where the stale selection ran past the shrunk document end.
- **Fix:** the heal derives the correct caret from the pendingEdit hull and
sets it **both before AND after** `syncRawSourceFromDisplay()`. The
before-only version still leaped — **the queued fixer moves even a freshly
set, fully valid caret** during the sync's `endEditing`. The post-sync
re-assert is the load-bearing half; the pre-set keeps `cursorRaw`/active-block
styling correct.
- **The breakthrough repro** (first deterministic one in six rounds):
`ReproScript.swift` (DEBUG-only, `-debug.reproScript <path>`) replays
keystrokes in-process through `window.sendEvent(_:)` — no TCC, works on an
invisible Space. `bypassdelete <needle>` simulates the drag-move deletion
exactly; one bypass beforehand → the next delete always drifts. Typing alone
never drifts. See `edmund-live-repro-and-diagnostics`.
### Do not retry (proven dead across the saga)
- **Headless/unit tests for this class.** The test harness runs TextKit 2's
selection fixups synchronously, so the deferred-fixup state never forms. The
round-6 unit test **passes with and without the fix** — it is a contract
spec, not a regression guard. Only the ReproScript live repro discriminates.
Do not "add a test to catch it" and call the class covered.
- **CGEvent injection as the default live driver.** Round 6's session dropped
the events (per-session TCC), and the app's windows launch on an inactive
Space. Use ReproScript first.
- **DEBUG assertion in `didChangeText`'s marked-text guard** — rejected in
round 1: the invariant is legitimately broken during composition; it
false-fires on every IME keystroke.
- **"No explicit selection repair needed"** (round 4's claim) — wrong twice.
Any new heal-like path must handle selection explicitly, before and after.
- **`swift build` trusted after "Build complete!"** — round 6 hit a stale-link
relink failure TWICE; two "failed" fix iterations were phantoms running old
code. Verify with `strings` on a LONG literal (≤15-byte literals inline on
arm64 and never show), cure with `swift package clean`, never hand-delete
`edmd.build/`. Details: `edmund-build-and-env`.
---
## 2. Undo/redo viewport drift — the costliest failure
**STATUS: settled** (2026-07-02, `5bb2b40`, part of PR #164) — with one caveat:
`misc/backlog.md` "Lurking (Unreproduceable)" carries a later note "Undo/Redo
and Copy/Paste scrolling is failing again". No repro exists. Treat the
*mechanism* below as settled and any new report as a NEW investigation that
starts from this entry.
- **Symptom:** undo scrolled too far down; redo centered on wherever the caret
sat before the undo; changed text never selected.
- **Root cause (two defects, found by code read before any experiment):**
1. `restoreSnapshot` ran a **full `recompose`** — replacing the entire
storage discards every TextKit 2 layout fragment, resetting ALL geometry
to height estimates; the subsequent centering math measured estimates.
2. `performUndo` recorded the redo snapshot with the caret *at undo
invocation time* (stale), and redo centered on it.
- **Fix:** `textDiff(old:new:)` single contiguous changed span → range-bounded
`recomposeReplacing` (layout outside the span stays real); the **changed
range** — never a stored caret — is selected and drives the viewport (hold if
visible, else center).
- **Load-bearing contract: never full-recompose on undo/redo.** Anyone
"simplifying" `restoreSnapshot` back to `recompose` reintroduces the bug.
Guarded by `TextDiffTests`, `UndoRedoSelectionTests`, `UndoRedoViewportTests`.
- **Prior art that treated symptoms without naming the estimate problem:**
`9aaa11b` (undo hold-or-center), `2778d6e`/`21cc284` (cursor-move lurch),
`84123e4` (pin scroll above viewport), `c49cd5c` (lazy viewport-first
styling). All sound, all workarounds; `5bb2b40` removed the manufactured
estimates at the source.
---
## 3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02)
Full trail: `docs/investigations/viewport-glitch-investigation.md`. Three reported symptoms,
one root cause: **every off-screen TextKit 2 frame is an estimate**; code that
discards layout, trusts off-screen y, or runs two scroll policies at once turns
estimate churn into visible jumps. Community-documented (Krzyżanowski /
STTextView, Apple forums) — even TextEdit exhibits it.
- **Bug 1 (undo/redo):** entry 2 above. **STATUS: settled** (same caveat).
- **Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up).**
Theory: TK2 assigns fragments **negative y** when estimates above the
viewport correct downward; scroller clamps at 0. Mitigations `217da5f`
(documents ≤100k UTF-16 kept **fully laid out** via a deferred
`scheduleFullLayoutSettle` — estimates never exist) and `8b4ecfe`
(`repairContentAboveOrigin`: first fragment `minY < -0.5` → re-lay
start→viewport-end inside `preservingViewportAnchor`; breadcrumb
`repairing content above origin`).
**STATUS: mitigated-unconfirmed.** The doc is explicit: Bug 2 was **never
reproduced live**; the repair had not been confirmed against a live
occurrence as of this writing (2026-07-05). If it recurs: grep
`~/.edmund/logs` for the breadcrumb — present means diagnosis confirmed but
repair raced/undersized; absent means different cause (scroller-only
estimate jumps, or `textContainerOrigin`).
- **Bug 3 (viewport oscillates during a steady drag-select).** Two scrollers
fighting: drag autoscroll pulling down vs the `scrollRangeToVisible`
override always revealing the selection **top** once the selection outgrew
the viewport. Fix `340fcbc`: reveal the **nearest** end. **STATUS: settled
by geometry/reasoning** — a live drag was never synthesized (that session
couldn't arm AppKit selection). Phase 1 of the same report ("can't select")
was NOT a bug: a whole-doc selection was active, so the drag was AppKit's
drag-move gesture — same family as delete-drift round 4.
- **Do not retry:** raising `fullLayoutMaxLength` without measuring
`ensureLayout` cost (full layout on large docs is the process-killing path
that motivated the lazy pipeline); running a full layout *inside* a caller's
`preservingViewportAnchor` (poisons its before/after measurement — the
tab-indent stability test caught a 366pt compensation; that's why the settle
is deferred).
- Backlog keeps "Inaccurate viewport estimates and things related" under
On-going bugs, plus a lurking "glitch when scrolling" — the estimate class
is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport
stabilization".
---
## 4. Callout-title wrap / the image wedge (settled 2026-07-03, `aa45563` + `ae61644`, PR #165)
Full trail: `docs/investigations/archives/callout-title-wrap-investigation.md` — including a 10-row
matrix of dead ends.
- **Symptom/goal:** custom callout titles (`> [!type] Title`) should render as
real wrapping text with the type icon; any attempt to draw the icon clipped
the wrapped title to one line.
- **Root cause:** **drawing any IMAGE on a multi-line layout fragment wedges
that fragment to a single line** — an unexplained TextKit 2 reentrancy quirk.
Isolated exhaustively: fragment overlay, frame-relative draw, before/after
`super.draw`, raw `CGContext.draw`, editor-level `draw(_:)`, pre-rasterized
bitmap, transparent subview, layer-backed subview, CALayer `contents` — ALL
clip. Controls: no icon → wraps; positions computed but plain rect filled
instead of the image → wraps. Reading layout is fine; drawing a **shape** is
fine; drawing an **image** is not.
- **Fix:** the icon is a **stroked `CGPath`**`SVGPath` parses the vendored
Lucide geometry, `FragmentOverlay` gained a path form,
`DecoratedTextLayoutFragment` strokes it in CG. Verified live: icon renders,
long titles wrap and re-wrap on resize.
- **Standing constraint:** any future overlay that can share a line with
wrapping text MUST use the path form, never the image form. Existing image
overlays (math, bullets, default callout header) survive only because they
sit on single-line fragments.
- **Do not retry:** any image-drawing mechanism from the matrix; bumping the
deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no
evidence, drops Sonoma incl. the dev machine). The whole-header-as-image
alternative is preserved on branch `fix/callout-title-image`
(tip `c7f5170`, unmerged): it sidesteps the wedge but has two unsolved
problems (~2× line height band above the title; a width-timing race).
- Still open nearby (backlog): callout icon baseline / crispness; the
callout-at-end-of-file extra colored line (live-path rendering bug).
---
## 5. The 0.1.0 release failures (2026-06-26 → 2026-07-02)
Reference: ARCHITECTURE §13. Five separate failures shipping and updating the
first releases. **STATUS: all settled**, with one time bomb (PAT expiry).
1. **`sign_update -s` exits 1 for new keys** (`59565f5`, 2026-06-27, PR #135).
Sparkle deprecated `-s <key>`; for keys generated after that change it
prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on
**stdin**`echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>`.
Do not retry `-s`.
2. **Bundle not sealed → EVERY update failed "improperly signed"**
(`5e54b40`, 2026-06-29, issue #158). Sparkle re-validates the Apple code
signature at install (`SUUpdateValidator``SecStaticCodeCheckValidity`);
the build signed only the main binary, never sealed the bundle, so a valid
EdDSA signature didn't save it. Fix: `codesign --deep` the whole `.app`
and because SwiftMath's resource bundle must sit at the `.app` root
(`Bundle.module` hardcodes `Bundle.main.bundleURL`) and codesign won't seal
a bundle with root items, **seal first, copy the SwiftMath bundle in
AFTER sealing**. The lone unsealed root item trips strict
`codesign --verify` but not Sparkle's non-strict check (verified against
that exact API). Do not "fix" the ordering or the failing strict verify.
3. **Appcast push to protected `main` rejected, `GH006`** (`e56a4dd`,
2026-06-28, PR #140). `GITHUB_TOKEN` isn't admin; `enforce_admins: false`
means an admin PAT bypasses the required check. Fix: fine-grained admin PAT
in secret **`RELEASE_TOKEN`**, set on the **checkout step** (not the push
URL — `actions/checkout` persists an `extraheader` credential that
overrides inline-URL creds). **`RELEASE_TOKEN` expires 2027-06-27**; rotate
before then or releases fail at the appcast push.
4. **create-dmg quirks** (`098d8c0`, 2026-06-26, documented in §8): it's the
**npm** create-dmg (sindresorhus), not the Homebrew tool; Node ≥20;
**exit code 2 for unsigned images is success**; space-in-filename
normalization.
5. **Release workflow YAML invalid** (`854f85d`, 2026-07-02, merged `232e6c8`,
PR #162). Literal multi-line bash strings inside a `run: |` block had
unindented lines — invalid in a YAML block scalar; the whole workflow file
failed to parse. Fix: build the strings with `printf` `\n` escapes; also
`$(...)` strips trailing newlines, so the separator newline lives in
`NEW_ITEM`'s format string, not `DESC_BLOCK`.
---
## 6. Reverts and abandoned directions
- **Selection tint** — `ee173f7` (2026-06-26): reverted an experimental
selection color back to **accent-derived (accent @ 30% alpha)**.
**STATUS: settled.** Don't re-hardcode a bespoke selection color.
- **`img.md-image { display:block }` in the export/Read HTML theme** —
`75d2824` (2026-06-25): reverted; it did NOT fix the image blank-space and
broke layout. The commit title itself records the verdict: **image
blank-space is a separate, STILL-OPEN bug** (backlog: "Attached image
padding… creates a large empty space below",
`misc/bug-repros/image-blank-after.mov`). Do not retry display:block for it.
- **Checkbox click-to-toggle + table borders** — `9991413` (2026-06-03):
pulled from a branch for separate passes. **STATUS: partially redone.**
Table work landed later (edit-mode table alignment shipped, per backlog
Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog —
**reverted-pending-redo**. The original attempt lives on stale branch
`feature/checkbox-toggle` (merged history, `d6227e3`).
- **TextKit 2 migration regressions** — `b3a4b29` (2026-06-12): the TK1→TK2
migration silently broke inline-math height, HR spacing, and scroll; fixed
with `RenderingRegressionTests` as the guard. Lesson: TK2 migrations
regress silently in geometry — extend that suite when touching layout.
- **Stale branches that look abandoned but are MERGED** (don't "rescue" them):
`feature/incremental-recompose` (tip `1222f79`, in main) and
`refactor/word-level-rendering` (merged via PR #8, `7eb6a21` — word-level
delimiter hiding became the shipped approach). The genuinely unmerged WIP
branches are `fix/callout-title-image` (entry 4) and dozens of old merged
topic branches never deleted.
---
## 7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6
`fix/wrapped-paragraph-caret-drift` / merge `218d922` IS delete-drift round 6
(entry 1): the branch name comes from the reporting symptom (backspace mid
wrapped paragraph), but the root cause was the queued TextKit 2 selection
fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was
incidental. **STATUS: settled** with `1b1420a`. If a caret drift is reported
"in a wrapped paragraph", do not assume wrapping is the mechanism; check for a
preceding heal breadcrumb in `~/.edmund/logs` first.
---
## 8. Smaller settled battles (one paragraph each; verified in git)
- **Flaky math fit-width test — took TWO rounds** (`5421297` 2026-06-06 branch
`fix/flaky-math-test`, then `352bdc9` PR #65). First fix pinned the text
container width (tracking off) — the flake persisted ~1 in 3 runs. Real
cause: **shared theme-defaults state pollution** between tests; fixed with
isolated defaults. Lesson: a flake "fix" that doesn't name the shared state
isn't done.
- **Emoji rendered as missing-glyph boxes** (`0f5ffba`, 2026-06-06).
`EditorTextStorage.fixAttributes` is a deliberate no-op (so `.attachment` on
real characters survives) — which also disabled font substitution. Fix:
perform substitution manually (Apple Color Emoji per composed-character
sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the framework
`fixAttributes`; it strips the marker attachments.
- **Nested list hanging indent** (`8d2088f`, 2026-06-06, PR #64).
swift-markdown's list-item delimiter excludes leading indentation; the
visible spaces broke the hang. Fix: hide the leading whitespace in the
inactive branch; indentation comes entirely from the paragraph style.
- **Ordered-list deep nesting lost styling** (`b255903`, 2026-06-06).
swift-markdown parses ≥4-space indent as indented code; the rescue regex
only matched `[-*+]`. Extended to `\d{1,9}[.)]`. Any new list-ish syntax
must be added to the rescue parser too.
- **Window size persistence — three commits to get right** (`538ff6e`
`d967bcf``678c5d6`, 2026-06-28, PR #144). Saving content-view size made
windows grow taller on reopen (titlebar double-counted); final form stores
the **full window frame** and restores via `setFrame`.
- **Toolbar right-click interception — three failed view-level attempts**
(`4eb604a`, `6723280`, then `f8472ca`, 2026-06-26). View `.menu`,
`rightMouseDown`, and a gesture recognizer all lost to the toolbar's
"Customize Toolbar…" menu. Working fix: `DocumentWindow` overrides
`NSWindow.sendEvent` — the documented funnel ahead of the toolbar — and
swallows secondary clicks on the view-mode button. Do not retry view-level
interception for anything the titlebar/toolbar claims.
- **Invisible CJK input** (`a3df387`): IME-composed text was invisible until
committed; fixed by keeping marked text visible. Related to (and predating)
the round-1 marked-text rules.
---
## 9. Still OPEN — do not let this chronicle imply otherwise
Per `misc/backlog.md` (cross-checked 2026-07-05): footnotes don't render
(edit or Read); math doesn't render in Read mode; math padding in edit mode;
**image blank-space below attached images** (see the `75d2824` revert);
callout-at-end-of-file extra colored line; max content width not applied to
Read mode; tables don't wrap/shrink at small content sizes; table-cell content
wraps out of the cell; "sometimes click to select / select+delete doesn't
work" (unreproduced); lurking scroll glitch from off-viewport height changes;
lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste
scrolling failing again" (see entry 2 caveat). Delete-caret-drift and
viewport-estimate classes stay on the watch list even though every known
mechanism is fixed.
---
## Provenance and maintenance
- Written 2026-07-05 by mining: `docs/investigations/delete-drift-investigation.md`,
`docs/investigations/viewport-glitch-investigation.md`,
`docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/ARCHITECTURE.md` §13,
`CHANGELOG.md`, `misc/backlog.md`, `docs/ROADMAP.md`, and `git log --all`
(every hash above verified with `git show` on that date).
- **Code and git win over prose.** If this file disagrees with a commit or the
current source, trust the commit, then fix this file.
- **Update triggers:** a new delete-drift round (append to entry 1 — never a
new doc); any live confirmation or refutation of `repairContentAboveOrigin`
(flip entry 3 Bug 2 off mitigated-unconfirmed); `RELEASE_TOKEN` rotation
(entry 5); any revert (entry 6); closing a backlog bug named in entry 9.
- Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed".
No oversell — this file's value is that its claims can be trusted blind.
- Sibling map lives in "When NOT to use" above; keep it in sync as the skill
library grows.
@@ -0,0 +1,287 @@
---
name: edmund-live-repro-and-diagnostics
description: >
How to MEASURE Edmund instead of eyeballing it — the diagnostic tools,
interpretation guides, and working scripts. Load when a bug involves LIVE
behavior (caret, IME, drag, viewport timing), when a unit test cannot
reproduce a report, when you need to read diagnostic traces, drive the
running app with scripted keystrokes, or measure pixels from a screenshot.
Contains the repro escalation ladder, the ReproScript driver, the CGEvent
fallback, and screencapture measurement, plus scripts/ helpers. Not the
symptom→mechanism table (edmund-debugging-playbook), not the campaign
(edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env).
---
# Edmund live repro & diagnostics
A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context
layer (deferred selection fixups, IME composition, drag sessions, event-loop
timing). **Headless tests cannot form the broken state** — the test harness runs
AppKit's deferred machinery synchronously, so a green unit test proves nothing
about this class. This skill is how you make such a bug cheap to observe, then
deterministic. Primary source doc: `docs/dev-guides/live-repro-guide.md`.
Verified 2026-07-05.
---
## 0. Safety preamble (do this every time)
- **Check for the user's live instance first.** The user's daily-driver app has
the same binary name (`edmd`). Run `scripts/check-live-instance.sh`. **Never
blanket `pkill -x edmd`** — kill only your own PID, or use `pkill -f
EdmundDbg` (only your debug bundle matches).
- **Recreate the test document fresh before every run** — autosave mutates it;
run 2 against run 1's leftovers produces garbage offsets.
- **Verify the binary is fresh** before trusting a run (SwiftPM sometimes prints
`Build complete!` without relinking `edmd`) — strings/shasum method in
**edmund-build-and-env**.
- **Do not request macOS Computer Access.** Screen Recording + Accessibility are
already granted for this project; ReproScript (§3) needs neither.
---
## 1. The escalation ladder
Work down; stop at the first level that reproduces. Each is more faithful and
more expensive than the one above.
| # | Technique | Faithful to | Cost | Use when |
|---|---|---|---|---|
| 1 | Plain unit test (`makeEditor()`) | model/parse/style logic | seconds | anything not event-timing-dependent |
| 2 | Windowed unit test (NSWindow + NSScrollView, real `deleteBackward(nil)`) | + layout, viewport, first responder | seconds | viewport/lazy-styling bugs (`LazyRenderingTests` setup) |
| 3 | **In-process ReproScript** (§3) | + real key path, run-loop pacing, real process | ~1 min/run | anything keyboard/edit-pipeline shaped — **the default for live bugs** |
| 4 | CGEvent driver (§4) | + real HID events, real mouse (drags!) | TCC-dependent | mouse-only paths: drag-select, drag-move, autoscroll |
| 5 | Instrumented field occurrence | everything | days | can't trigger it — instrument first (§2), decide on the next hit |
Two levels deserve emphasis:
- **Level 2 failing to repro is evidence, not defeat** — it tells you the bug
needs deferred/queued AppKit state, pointing you at level 34.
- **Level 3 exists because level 4 is unreliable** — background/agent sessions
often have no TCC grant and synthetic keyboard events get dropped silently.
In-process injection needs no permission.
---
## 2. Step 0 — make the trace tell you the trigger
Never script blind. The recipe is usually already in `~/.edmund/logs`, if
verbose diagnostics were on. Launch flags (file arg **must** be `argv[1]`):
```bash
<app>/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
```
**Trace-field decoder** (each verbose line carries these; from
`EditorTextView+Diagnostics.swift`):
| Field | Meaning |
|---|---|
| `sel` | current selection `{location,length}` |
| `active` | active (caret) block index |
| `marked` | is there marked/IME text |
| `up` | `isUpdating`**`up=Y` = event arrived mid-recompose** (suspicious) |
| `undo` | undo-stack depth |
| `blocks` | block count |
| `storLen` / `rawLen` | storage length vs rawSource length |
- **Healthy edit ordering:** `shouldChangeText``selectionDidChange` (up=N) →
`synced`. A transient `⚠︎LEN-MISMATCH` *between* those lines is normal
(storage moves before rawSource syncs).
- **Suspect:** a `selectionDidChange` with `up=Y` at a surprising position; a
**persisting** LEN-MISMATCH; a `shouldChangeText` with **no**
`synced`/`SKIPPED`/`DEFERRED` after it (a bypassed `didChangeText`); the
`healing storage edit that bypassed didChangeText` breadcrumb.
- **`traceSelectionOrigin`** logs a **call stack** for any selection change that
lands mid-recompose — this is what named `_fixSelectionAfterChange` in round 6.
- **Walk BACKWARDS from the first bad line, not forwards from the symptom.** The
round-6 drift was armed ~80 seconds and dozens of healthy edits before the
visible failure. The user-visible symptom is often the *second* half.
If the log didn't capture the deciding fact, **add the log line first** (keep
good ones behind `Log.shouldTrace` and ship them) and reproduce again. Also
**reconstruct the document** — wrapped-paragraph geometry, block boundaries, and
block kinds all matter; repro against a lookalike, never `"hello world"`.
`scripts/grep-trace.sh [YYYY-MM-DD]` surfaces the suspect patterns in one shot.
---
## 3. The in-process ReproScript driver (default for live bugs)
`Sources/edmd/App/ReproScript.swift`, **DEBUG builds only**. Replays a keystroke
script against the front document by synthesizing `NSEvent`s and pushing them
through `window.sendEvent(_:)` — the full authentic key route (keyDown →
`interpretKeyEvents``insertText:` / `deleteBackward:`). No Accessibility, no
visible window required (works on an inactive Space), real run-loop pacing.
Launch: `scripts/launch-debug.sh FILE.md SCRIPT.repro` (assembles EdmundDbg.app,
guards the user's instance, direct-execs with all flags). Or by hand:
```bash
build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript "$SCRIPT.repro" \
-ApplePersistenceIgnoreState YES &
```
**Command surface** (one per line, `#` comments allowed):
| Command | Effect |
|---|---|
| `sleep <ms>` | wait before the next command |
| `caret <needle>` | place caret before the first occurrence of `<needle>` |
| `type <text>` | one real key event per char, ~80 ms apart |
| `backspace <n>` | n real delete keystrokes, ~300 ms apart |
| `bypassdelete <needle>` | simulate the drag-move source deletion: select range, `shouldChangeText` + storage mutation, **no `didChangeText`** |
| `assertcaret <needle>` | log `repro assertcaret PASS/FAIL sel=… want=…` iff caret sits exactly before `<needle>` |
| `logsel` | log selection, rawSource length, doc count |
Round-6 minimal repro (the worked example): the deciding output was **`logsel`
321 (broken) → 290 (fixed)**, every run, window not even visible.
```
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken: {321,…}; fixed: {290,…}
backspace 2
logsel
```
**Design rules — keep them when extending:**
- **Address text by needle, never offset** — offsets go stale the moment a
script edits; needles survive (this is what makes soak scripts possible).
- **Real events over direct method calls** — `insertText("")` shortcuts skip
`deleteBackward`'s selection machinery, the exact place round 6 lived.
- **Simulate AppKit-internal paths by exact call sequence** — `bypassdelete`
replicates `shouldChangeText``replaceCharacters`, no `didChangeText`
*verbatim*, not an approximation. Pin any new internal path's real sequence
from a `traceSelectionOrigin` stack first, then replay it.
- **Asserts inside the app, results in the log** — the harness (you, or a shell
loop) only greps `PASS`/`FAIL`; the app is the oracle.
- New commands are ~10 lines each — extend `ReproScript.swift`, don't work around it.
**Soak scripts** (§6): chain several trigger cycles at different positions with
ordinary editing between them, `assertcaret` after each predictable step, and
compare final `rawLen` across runs (byte-identical = deterministic). A soak green
across 45 cycles is far stronger than one clean repro — it catches bugs needing
*armed state* (round 6's queued fixup).
```
bypassdelete Sizemore,
assertcaret Strang
backspace 2
type xy
bypassdelete widely
assertcaret used in various
logsel
```
---
## 4. CGEvent driver (mouse-only paths, TCC willing)
For paths that must originate as HID events — real drag-select, drag-move,
autoscroll — keyboard replay can't cover them. A ~70-line `ui.swift` (compile
with `swiftc`) posting `CGEvent`s does: `bounds <substr>` (window lookup via
`CGWindowListCopyWindowInfo`), `click x y`, `dragselect`, `dragmove` (mousedown +
**~400 ms hold** before moving, or AppKit never arms the text drag), `key`,
`type`.
Caveats (all hit in practice):
- **TCC decides per session.** Background/agent sessions often can't post
keyboard events (dropped silently) or use System Events. Test with **one click
+ log check**; if input doesn't land, fall back to §3 immediately — don't
iterate on driver variations.
- App windows are on an inactive Space until activated
(`kCGWindowIsOnscreen == false`); `osascript -e 'tell application "<path>.app"
to activate'` (Apple Events, a separate TCC bucket) may work where System
Events is denied.
- Re-activate before every interaction batch; focus is lost between shell calls.
---
## 5. Screencapture measurement
**Visual judgments are measured, not eyeballed** — when the task says "balance
padding" or "align the icon", capture the window and measure pixels.
`scripts/capture-window.sh <window-title-substring> out.png` finds the window id
(JXA → `CGWindowListCopyWindowInfo`) and runs `screencapture -x -o -l<id>`.
Notes:
- Capture **by window id**, reliable even when not frontmost.
- **Crop by the detected window bounds** — the desktop wallpaper defeats
screencapture's brightness-based auto-crop.
- Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for
the first/last colored row of a callout box). Report the pixel numbers, not an
impression.
- Window-server state can glitch (tiny windows, restoration) after many rapid
launch/kill cycles: `rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState`
and relaunch.
---
## 6. The loop, end to end
1. Verbose trace from the occurrence → find the **first bad line**, walk
backwards, form a trigger hypothesis (§2).
2. Reconstruct the document; script the hypothesized trigger (§3).
3. **No repro?** Hypothesis wrong or fidelity too low — move down the ladder
(§1), or instrument and wait for the next hit.
4. **Repro in hand? Freeze it** (exact script + document), then let it falsify
fix candidates — round 6's first "fix by reasoning" failed in the repro within
a minute.
5. Fix verified → **soak** (§3) → full `swift test` → keep the script + new
diagnostics → update the relevant `docs/*-investigation.md`.
**Meta-lesson from six rounds: time spent making the failure cheap to observe
beats time spent reasoning about the fix.** Every round that shipped on reasoning
alone came back; the round that shipped on a deterministic repro named the actual
mechanism.
---
## scripts/ (in this skill dir)
| Script | Purpose | Status |
|---|---|---|
| `check-live-instance.sh` | Report running `edmd`, exit 2 if any; never kills | logic verified; safe by construction |
| `grep-trace.sh [date]` | Surface suspect patterns in today's log | logic verified |
| `capture-window.sh <needle> <out.png>` | Screenshot a window by id + report bounds | **verify on first use** (JXA CGWindowList lookup not executed this session) |
| `launch-debug.sh <file.md> [script.repro]` | Build + assemble EdmundDbg.app + direct-exec with flags | **verify on first use** (assumes arm64 debug triple; guards user instance) |
All four pass `bash -n`. The two "verify on first use" scripts depend on live
system state (window server, build layout) that couldn't be exercised while
authoring; read the header comment before first run.
---
## When NOT to use this skill
- Deciding *which mechanism* a symptom implies → **edmund-debugging-playbook**.
- Running the full caret-integrity investigation → **edmund-caret-integrity-campaign**.
- Stale-binary detection / bundle internals → **edmund-build-and-env**.
- What counts as sufficient evidence to ship → **edmund-validation-and-qa**.
- The AppKit theory behind the fixup/marked-text mechanisms → **textkit2-appkit-reference**.
---
## Provenance and maintenance
Verified 2026-07-05 against `docs/dev-guides/live-repro-guide.md`,
`Sources/edmd/App/ReproScript.swift`, and
`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`.
```bash
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift
grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
```
Re-verify the scripts against `docs/dev-guides/live-repro-guide.md` §4 if the debug-bundle
assembly recipe changes.
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# capture-window.sh <window-name-substring> <out.png>
# Screenshot a specific window by id (reliable even when not frontmost).
#
# Mechanism: osascript(JXA) enumerates on-screen windows via the ObjC bridge
# to CGWindowListCopyWindowInfo, finds the first whose kCGWindowName contains
# the substring, prints "<id> <x> <y> <w> <h>", then `screencapture -x -o
# -l<id>` grabs it. We crop by the detected window bounds (the desktop
# wallpaper defeats screencapture's brightness-based auto-crop).
#
# VERIFY ON FIRST USE: JXA ObjC bridging + the CGWindowList key names are
# stable AppKit API, but this script has not been executed in this session.
# If the JXA lookup prints nothing, the window title didn't match or Screen
# Recording permission is missing (it is granted for this project per
# CLAUDE.md — do NOT request Computer Access to "fix" it).
set -euo pipefail
needle="${1:?usage: capture-window.sh <window-name-substring> <out.png>}"
out="${2:?usage: capture-window.sh <window-name-substring> <out.png>}"
read -r wid x y w h < <(osascript -l JavaScript <<JXA || true
ObjC.import('CoreGraphics');
ObjC.import('Foundation');
const info = \$.CGWindowListCopyWindowInfo(
\$.kCGWindowListOptionOnScreenOnly | \$.kCGWindowListExcludeDesktopElements,
\$.kCGNullWindowID);
const arr = ObjC.deepUnwrap(info);
const needle = "$needle";
for (const win of arr) {
const name = win.kCGWindowName || "";
if (name.indexOf(needle) !== -1) {
const b = win.kCGWindowBounds;
console.log([win.kCGWindowNumber, b.X, b.Y, b.Width, b.Height].join(" "));
break;
}
}
JXA
)
if [[ -z "${wid:-}" ]]; then
echo "No on-screen window title contains: $needle" >&2
exit 1
fi
screencapture -x -o -l"$wid" "$out"
echo "Captured window $wid ($needle) -> $out bounds=${x},${y} ${w}x${h}"
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# check-live-instance.sh — report any running `edmd` processes, refuse to kill.
#
# The user's daily-driver Edmund shares the binary name `edmd`. NEVER blanket
# `pkill -x edmd`. Run this first; if it finds an instance you did not start,
# leave it alone and launch your own debug bundle instead (launch-debug.sh
# uses EdmundDbg so `pkill -f EdmundDbg` only ever hits yours).
#
# Exit: 0 = no edmd running; 2 = at least one edmd running (inspect, don't kill).
set -euo pipefail
pids=$(pgrep -x edmd || true)
if [[ -z "$pids" ]]; then
echo "No running edmd instance."
exit 0
fi
echo "Found running edmd instance(s) — DO NOT pkill -x edmd:"
for pid in $pids; do
ps -o pid=,lstart=,command= -p "$pid"
done
exit 2
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# grep-trace.sh [YYYY-MM-DD] — surface the suspect patterns in today's (or a
# given day's) Edmund diagnostic log. Read-only.
#
# Requires the app to have run with:
# -settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES
set -euo pipefail
day="${1:-$(date +%F)}"
log="$HOME/.edmund/logs/edmund-${day}.log"
if [[ ! -f "$log" ]]; then
echo "No log for ${day} at ${log}" >&2
exit 1
fi
echo "== $log =="
echo "--- bypassed-didChangeText heals ---"
grep -n 'healing storage edit that bypassed didChangeText' "$log" || echo "(none)"
echo "--- content-above-origin repairs ---"
grep -n 'repairing content above origin' "$log" || echo "(none)"
echo "--- persistent length mismatches ---"
grep -n 'LEN-MISMATCH' "$log" || echo "(none)"
echo "--- repro asserts ---"
grep -n 'repro assertcaret' "$log" || echo "(none)"
echo "--- FAILs ---"
grep -n 'FAIL' "$log" || echo "(none)"
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# launch-debug.sh <file.md> [script.repro]
# Assemble/refresh build/EdmundDbg.app, then direct-exec it with diagnostic
# flags (and optionally replay a ReproScript). Kills only its OWN prior debug
# instance, never the user's daily-driver edmd.
#
# Steps (from docs/dev-guides/live-repro-guide.md §4):
# 1. swift build (debug).
# 2. Assemble EdmundDbg.app: Info.plist copy + debug edmd + Sparkle.framework
# (dyld aborts without the framework; a bare .build/debug/edmd never makes
# a window).
# 3. Refuse to run if a non-ours edmd is live (check-live-instance.sh).
# 4. Direct-exec the bundle binary (never `open -a`: LaunchServices can run a
# stale translocated copy).
#
# VERIFY ON FIRST USE: paths assume the standard arm64 debug layout
# `.build/arm64-apple-macosx/debug/`. If your host resolves a different triple,
# adjust BUILD_DIR. Binary-freshness check is a reminder, not enforced here —
# see edmund-build-and-env for the strings/shasum method.
set -euo pipefail
doc="${1:?usage: launch-debug.sh <file.md> [script.repro]}"
repro="${2:-}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" # repo root
cd "$ROOT"
BUILD_DIR=".build/arm64-apple-macosx/debug"
APP="build/EdmundDbg.app"
# 0. Never step on the user's instance.
here="$(dirname "${BASH_SOURCE[0]}")"
if pgrep -x edmd >/dev/null 2>&1; then
# Allow only if it's our own EdmundDbg (safe to replace).
if ! pgrep -f EdmundDbg >/dev/null 2>&1; then
echo "A non-EdmundDbg edmd is running — refusing to launch. Inspect:" >&2
bash "$here/check-live-instance.sh" || true
exit 2
fi
pkill -f EdmundDbg || true
fi
# 1. Build.
swift build
# 2. Assemble the bundle.
mkdir -p "$APP/Contents/MacOS"
cp Info.plist "$APP/Contents/Info.plist"
cp "$BUILD_DIR/edmd" "$APP/Contents/MacOS/edmd"
if [[ -d "$BUILD_DIR/Sparkle.framework" ]]; then
rm -rf "$APP/Contents/MacOS/Sparkle.framework"
cp -R "$BUILD_DIR/Sparkle.framework" "$APP/Contents/MacOS/Sparkle.framework"
else
echo "WARNING: $BUILD_DIR/Sparkle.framework not found; dyld may abort." >&2
fi
# 3. Launch by direct exec, background, with diagnostics on.
args=( "$doc"
-settings.general.diagnosticLogging YES
-settings.advanced.verboseEditorDiagnostics YES
-ApplePersistenceIgnoreState YES )
if [[ -n "$repro" ]]; then
args+=( -debug.reproScript "$repro" )
fi
echo "Launching $APP with: ${args[*]}"
"$APP/Contents/MacOS/edmd" "${args[@]}" &
echo "Launched PID $! — tail with: scripts/grep-trace.sh"
@@ -0,0 +1,400 @@
---
name: edmund-release-and-operate
description: >
Load when cutting or debugging an Edmund release, or operating the shipped
app. Triggers: version bump (Info.plist CFBundleShortVersionString /
CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml
/ release.sh / build-app.sh, appcast.xml or Sparkle update failures
("improperly signed", update never offered), sign_update / EdDSA keys /
SPARKLE_ED_PRIVATE_KEY / RELEASE_TOKEN, create-dmg or DMG naming problems,
Gatekeeper "damaged" reports, launching the built app, reading
~/.edmund/logs, crash reports (edmd-*.ips), or roadmap/priority questions.
---
# Edmund — release & operate
Date-stamped 2026-07-05. Verified against `.github/workflows/release.yml`,
`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`,
`appcast.xml`, `Info.plist`, `CHANGELOG.md`, `docs/ARCHITECTURE.md` §8/§13, and
the Settings/CrashReporter sources. Where a doc and a script disagree, the
script is the truth; disagreements are flagged inline.
**House rule: releases happen only when the maintainer explicitly asks.**
Never tag, push, create a release, or merge on your own initiative — see
`edmund-change-control`. Everything in §1–§4 below is a runbook for when the
maintainer says "cut a release", not a standing instruction.
## When NOT to use this skill
| You actually need | Go to |
|---|---|
| Build/test commands, stale-build cures, launch mechanics in depth | `edmund-build-and-env` |
| Editing invariants, render pipeline, TextKit 2 rules | `edmund-architecture-contract`, `textkit2-appkit-reference` |
| Debugging a bug in the app itself | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` |
| Past incidents and why the sharp edges below exist | `edmund-failure-archaeology` |
| Debug flags / launch arguments | `edmund-config-and-flags` |
| Branch/commit/PR etiquette, what needs maintainer sign-off | `edmund-change-control` |
| Pre-merge QA method | `edmund-validation-and-qa` |
| README/website/positioning copy | `edmund-docs-and-writing`, `edmund-external-positioning` |
---
## 1. Release flow — CI path (the normal one)
Ship via a tag; CI does the rest. In order:
1. **Bump versions in `Info.plist`** — both keys:
- `CFBundleShortVersionString` — marketing version, e.g. `0.1.3`
- `CFBundleVersion` — build number, **monotonic integer** (0.1.3 = `4`)
2. **Add a `## [x.y.z]` section to `CHANGELOG.md`** — format is load-bearing,
see §2. The version MUST match Info.plist exactly.
3. **Merge to `main`** and push (via the normal PR flow).
4. **Tag and push the tag:**
```sh
git tag vX.Y.Z && git push origin vX.Y.Z
```
5. CI (`.github/workflows/release.yml`, trigger `push: tags: 'v*'`, runner
`macos-14`, job `release` / "Build & publish") runs the steps below.
6. Afterwards, verify per §4 post-flight.
### release.yml step anatomy (actual step names)
| Step | What it does | Sharp edge |
|---|---|---|
| `actions/checkout@v5` | `fetch-depth: 0`, `token: ${{ secrets.RELEASE_TOKEN }}` | The PAT must be on **this** step — see §3.4 |
| `maxim-lobanov/setup-xcode@v1` | latest-stable Xcode | |
| **Cache .build** | SPM cache keyed on `Package.resolved` | |
| **Build app bundle** | `./scripts/build-app.sh` — release build, bundle assembly, Sparkle embed, **bundle sealing** | §3.2 |
| `actions/setup-node@v4` | pins Node 20 | create-dmg 8.x needs Node ≥ 20 |
| **Install create-dmg** | `npm install --global create-dmg` (sindresorhus/create-dmg, **not** Homebrew's) | §3.5 |
| **Create DMG** | reads VERSION from Info.plist, `create-dmg build/Edmund.app build/ \|\| true`, renames `"Edmund <v>.dmg"` → `Edmund-<v>.dmg`, fails loudly if no dmg | §3.5 |
| **Sign archive (EdDSA)** | finds `sign_update` in `.build`, key on **stdin** via `--ed-key-file -`, exports `ED_SIG` and `LENGTH` | §3.1 |
| **Create GitHub Release** | awk-extracts the CHANGELOG section → `gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest` | §2 |
| **Update appcast.xml** | builds the new `<item>` (HTML `<description>` via `scripts/changelog-to-html.py`), inserts it before `</channel>`, commits as `github-actions[bot]`, `git push origin HEAD:main` | §3.4 |
### Local path (`scripts/release.sh`)
Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update
appcast.xml **locally** → `gh release create`. Two differences:
- **Signing**: with `SPARKLE_ED_PRIVATE_KEY` in the env it uses the stdin path
(CI-style); otherwise `sign_update` pulls the key from the **login keychain**
(put there by Sparkle's `generate_keys`) with no flag at all.
- **The appcast commit/push is left to you.** The script ends with the exact
commands: `git add appcast.xml && git commit -m 'Release <v>' && git push`.
Prereqs for the local path: `gh auth status` authenticated, npm `create-dmg`
installed, `swift build` has run at least once (so `sign_update` exists under
`.build`).
> **Stale doc**: `misc/how-to-release.md` still says the artifact is a **zip**
> ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG
> switch. The truth is DMG throughout — per `release.yml`, `release.sh`, and
> ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it.
---
## 2. CHANGELOG format contract (release notes are machine-extracted)
Both `release.yml` and `release.sh` extract the GitHub Release body with:
```sh
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md
```
So the section header **must** start at column 0 as `## [x.y.z]` — literally
`## [0.1.3] — 2026-07-04` in house style (em dash + ISO date after the
bracket is fine; the match only requires the `^## \[x.y.z\]` prefix).
Extraction runs until the next `^## [` line. If nothing matches, the release
body falls back to "See CHANGELOG for details." — a silent-ish failure, so get
the header right. (Version dots are unescaped in the regex; harmless in
practice, don't rely on it.)
The Sparkle update-dialog notes come from the **same section** via
`scripts/changelog-to-html.py <version>`, a deliberately tiny converter that
only understands Keep-a-Changelog shapes:
- `### Added` / `### Changed` / `### Fixed` → `<h3>` — **use `###`, not `##`**.
The 0.1.2 appcast item literally shows `<p>## Changed</p>` because the
section used `##` subheads at release time; the converter passed them
through as paragraphs.
- `- ` / `* ` bullets → `<ul><li>`; indented continuation lines fold into the
previous bullet.
- `` `code` `` and `**bold**` are converted. **Markdown links are NOT** —
`[docs](docs/foo.md)` appears literally in the update dialog (see the 0.1.2
item). Keep appcast-facing notes link-free or accept the raw brackets.
- Blank lines and `---` are skipped; anything else becomes a `<p>`.
- Missing section → empty output → the `<description>` is simply omitted.
House format (verified from `CHANGELOG.md`): Keep a Changelog 1.1.0 + SemVer,
newest first, sections separated by `---`.
---
## 3. The sharp edges (each one killed or nearly killed a real release)
### 3.1 `sign_update -s` is FATAL — key goes on stdin
Sparkle deprecated `-s <key>`; for newly generated keys it prints a
deprecation warning and **exits 1** ("no longer supported"). This killed the
first 0.1.0 release. The only correct invocation with a key-in-hand:
```sh
echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>
```
Both `release.yml` and `release.sh` do exactly this. Never "simplify" it back
to `-s`. Output format: `sparkle:edSignature="<sig>" length="<n>"` — the
scripts grep those two attributes out for the appcast item.
### 3.2 Bundle sealing (the "improperly signed" update failure)
At install time Sparkle re-validates the update's **Apple** code signature
(`SUUpdateValidator`), independent of the EdDSA signature. A bundle that is
code-signed but not *sealed* (no `_CodeSignature/CodeResources`) fails that
check and every update dies with "The update is improperly signed and could
not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update
when the old script signed only the bare binary.
`build-app.sh` therefore signs inside-out and in a very deliberate order:
1. `codesign --force --deep --sign - Sparkle.framework` (nested XPC helpers
must be signed before macOS will launch them),
2. `codesign --force --deep --sign - --identifier "com.i7t5.edmd"` on the
whole `.app` **while its root holds only `Contents/`** — codesign refuses
to seal a bundle with extra items at the root,
3. copy the SwiftMath resource bundle to the `.app` root **after** sealing
(its generated `Bundle.module` looks at `Bundle.main.bundleURL`; without it
the app crashes on the first LaTeX render).
Consequence: `codesign --verify` (CLI) and `--strict` **will complain** about
that one unsealed root item. That is expected and fine — Sparkle's actual
check is non-strict (`SecStaticCodeCheckValidityWithErrors` with
`kSecCSCheckAllArchitectures`) and tolerates it; verified end-to-end against
that API. Do not "fix" the verify warning by moving the SwiftMath bundle or
re-signing after the copy.
### 3.3 Keypair discipline
One EdDSA keypair, three places, all of which must agree:
| Place | Used by |
|---|---|
| `Info.plist` `SUPublicEDKey` (`0XdLbbuO…`) | Every shipped app, to verify updates |
| Maintainer's **login keychain** | `release.sh` local signing (no flag) |
| GitHub secret **`SPARKLE_ED_PRIVATE_KEY`** | CI signing |
If the signing key and `SUPublicEDKey` diverge, everything *looks* fine — the
DMG signs without error — but **every user's update fails signature
verification**. Sanity check when in doubt:
`sign_update --verify <dmg> <sig>` against the Info.plist public key.
### 3.4 Appcast push to protected `main` — RELEASE_TOKEN
The workflow's last step commits `appcast.xml` and pushes to `main`, which
requires the `test` status check. The default `GITHUB_TOKEN` /
`github-actions[bot]` is not an admin, so that push is rejected with
`GH006 … protected branch hook declined`. Branch protection has
`enforce_admins: false`, so an admin's push bypasses the check — hence the
fine-grained **admin PAT** in the `RELEASE_TOKEN` secret (Contents:
read/write), set as the `token:` on the **checkout step**, not on the push.
That placement matters: `actions/checkout` persists an
`http.<host>.extraheader` credential that overrides inline-URL credentials,
so rewriting the push URL would keep pushing with the bot token anyway.
**`RELEASE_TOKEN` expires 2027-06-27.** Rotate it before then or every
release fails at the appcast push while the GitHub Release itself succeeds
(a confusing half-shipped state — see §4 post-flight).
### 3.5 create-dmg quirks
- It's the **npm** package `create-dmg` (sindresorhus), installed via
`npm install --global create-dmg`. Homebrew's `create-dmg` is a different
tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it).
- It exits **2** when it can't Developer-ID-sign/notarize the image (Edmund
ships ad-hoc) **but still produces the .dmg**. Both scripts run it with
`|| true` and then verify the file exists, failing loudly only if no dmg
was produced. Don't remove the `|| true`; don't trust the exit code.
- Output is named `"Edmund <version>.dmg"` — with a **space**. Both scripts
rename to `Edmund-<version>.dmg` (hyphen), which is the name the appcast
enclosure URL expects. If a rename is skipped, the release asset URL 404s
for every updater.
---
## 4. Pre-flight and post-flight
### Pre-flight (distilled from `misc/before-you-release.md` — read it too)
- [ ] `swift test` green **on `main`**, not just the branch; `git status` clean.
- [ ] No debug flags / launch args left on (repro drivers, verbose tracing —
see `edmund-config-and-flags`, ARCHITECTURE §8).
- [ ] Visual sanity: build and screencapture the editor in **light and dark**
mode; click through everything the CHANGELOG claims ("fixed X" → actually
reproduce X and confirm).
- [ ] `CHANGELOG.md` has `## [x.y.z] — YYYY-MM-DD` for this release and the
version **matches Info.plist** (`CFBundleShortVersionString`); `###`
subheads, not `##` (§2).
- [ ] `CFBundleVersion` bumped (monotonic int).
- [ ] `RELEASE_TOKEN` not expired (**2027-06-27**).
- [ ] Local path only: `gh auth status` ok; keychain key matches
`SUPublicEDKey` (§3.3).
### Post-flight
- [ ] GitHub Release `vX.Y.Z` exists with the right notes and the
`Edmund-<v>.dmg` asset (hyphenated name).
- [ ] `appcast.xml` on `main` got the new `<item>` — with `<description>`,
correct `sparkle:version` (= CFBundleVersion) and enclosure URL.
- [ ] Nothing to do for user prompts: Sparkle checks roughly daily; existing
users see the update within ~24 h. Don't panic if it isn't instant.
If the Release exists but the appcast commit is missing, the release
half-shipped (usually §3.4). Fix the token, then add the `<item>` manually or
re-run the job.
---
## 5. Gatekeeper story (why users see "damaged")
Edmund is **ad-hoc signed, not notarized** (no $99/yr Developer ID). First
launch of a downloaded copy trips Gatekeeper with the *"app is damaged"*
dialog. This is expected; the app is fine. The README documents both
workarounds (verified, README ~line 53):
- `xattr -dr com.apple.quarantine /Applications/Edmund.app`, or
- right-click → Open.
Known upgrade path (open/candidate, not scheduled): Developer ID certificate
+ notarization would remove the prompt entirely and also clean up the
non-strict-sealing compromise in §3.2. Don't promise it in user-facing text.
---
## 6. Operating the app
### Launching
`open Edmund.app` **foregrounds a running instance instead of relaunching** —
you'll stare at old code. And never `pkill -x edmd` blindly: the maintainer's
own Edmund session may be running (the Mach-O is `edmd` for both). Check
first (`pgrep -x edmd`), kill only PIDs you started, or launch the binary
directly: `build/Edmund.app/Contents/MacOS/edmd file.md &`. Full launch /
stale-build / screencapture mechanics: `edmund-build-and-env`.
### Logs — `~/.edmund/logs/edmund-YYYY-MM-DD.log`
- One file per day, human-readable lines tagged `LEVEL [category]`
(categories: app, document, io, render, compose, selection, lazy, callout,
edit — grep by concern).
- Controlled by **Settings ▸ Advanced ▸ Diagnostics** ("Save diagnostic
logs"). The toggle **defaults OFF** (`AppSettings.diagnosticLogging`
defaults false) — i.e. opt-in in the shipped app, despite `Log.swift`'s
header comment calling it "always-on (opt-out)"; the code is the truth.
(The UserDefaults keys are named `settings.general.*` for legacy reasons;
the UI lives in Advanced.)
- Retention picker ("Clear logs after:") next to the toggle; a separate
"Verbose editor tracing" opt-in gates keystroke-level trace lines — leave
off except during repros (`edmund-live-repro-and-diagnostics`).
- Release builds write `info` and up; DEBUG builds also write `debug`.
- Logs may contain document text; they never leave the machine.
### Crash reports — `~/Library/Logs/DiagnosticReports/edmd-*.ips`
- macOS names crash reports after the **Mach-O executable**: look for
`edmd-<timestamp>.ips`, not "Edmund-…".
- **Uploading is opt-in and currently INERT.** The Settings toggle is
commented out in `Sources/edmd/Settings/AdvancedSettingsView.swift` ("dormant
until the receiving server exists"), and
`CrashReporter.reportingEndpoint` is a placeholder
(`https://REPLACE-ME.invalid/crash`). Nothing is ever sent in shipped
builds. Don't tell users crash reporting exists; don't uncomment the toggle
without a real server. Code: `Sources/EdmundCore/Diagnostics/CrashReporter.swift`.
- Reading works only because Edmund is **not sandboxed**; adopting App
Sandbox would force a MetricKit rewrite (noted in CrashReporter's header).
- **Triage of a user's `.ips`**: it's JSON — a one-line metadata header, then
the report body. Look at `exception` (type/signal), `faultingThread`, and
walk that thread's frames for images named `edmd` or `Sparkle`. Ad-hoc
builds ship no dSYM, so expect addresses rather than symbol names for app
frames; correlate with `~/.edmund/logs` from the same timestamp instead.
`.ips` files embed the user's home path and device model — treat as
mildly personal data.
### Update mechanics (user side)
- `SUFeedURL` = `https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml`
— the raw-GitHub URL of the checked-in appcast; committing to `main` *is*
publishing.
- `SUEnableAutomaticChecks` is true; no custom interval is set, so Sparkle
uses its default ~24 h cadence (plus a check on launch).
- Sparkle downloads the DMG enclosure, verifies EdDSA against `SUPublicEDKey`,
**mounts the DMG**, re-validates the Apple code signature (§3.2), installs.
---
## 7. Versioning & appcast conventions
| Thing | Convention | Current (2026-07-05) |
|---|---|---|
| Git tag | `vX.Y.Z` | `v0.1.3` pending its tag; last released 0.1.2 |
| `CFBundleShortVersionString` | SemVer marketing version | `0.1.3` |
| `CFBundleVersion` | monotonic integer, +1 per release | `4` |
| CHANGELOG | Keep a Changelog 1.1.0, `## [x.y.z] — YYYY-MM-DD`, `###` subheads, `---` separators | — |
`appcast.xml` (checked into repo root): RSS 2.0 with the `sparkle:` namespace.
One `<channel>` (title/link/description/language) containing one `<item>` per
release. Items are inserted **before `</channel>`**, so the file reads oldest
→ newest; Sparkle doesn't care about order — it picks by version. Per item:
```xml
<item>
<title>Edmund 0.1.2</title>
<pubDate>Fri, 03 Jul 2026 19:03:59 +0000</pubDate>
<description><![CDATA[ …HTML from changelog-to-html.py… ]]></description>
<enclosure url="https://github.com/I7T5/Edmund/releases/download/v0.1.2/Edmund-0.1.2.dmg"
sparkle:version="3" <!-- CFBundleVersion -->
sparkle:shortVersionString="0.1.2" <!-- marketing version -->
sparkle:edSignature="…"
length="7608991"
type="application/x-apple-diskimage"/>
</item>
```
`<description>` is optional (omitted when the CHANGELOG section is missing).
---
## 8. Roadmap context (for release-content judgment)
- Edmund is in **beta** (0.1.x line, first public release 0.1.0 on
2026-06-27). Small, frequent releases.
- **v0.2.0 goal: "Polished editing experience"** (`misc/backlog.md` § Now).
- **Priority ordering: Marketing = Bugs >= UI/UX > Features** — when deciding
what makes a release, bug fixes and polish beat new features.
- Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork):
`docs/ROADMAP.md`; working backlog with per-bug detail: `misc/backlog.md`.
---
## Provenance and maintenance
Written 2026-07-05 from direct reads of: `.github/workflows/release.yml`,
`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`,
`appcast.xml`, `CHANGELOG.md`, `Info.plist`, `README.md`,
`docs/ARCHITECTURE.md` (§8, §13), `misc/how-to-release.md`,
`misc/before-you-release.md`, `docs/ROADMAP.md`, `misc/backlog.md`,
`Sources/EdmundCore/Diagnostics/CrashReporter.swift`,
`Sources/EdmundCore/Diagnostics/Log.swift`,
`Sources/edmd/Settings/AdvancedSettingsView.swift`,
`Sources/edmd/Settings/AppSettings.swift`.
Known stale docs at time of writing: `misc/how-to-release.md` (zip vs DMG,
§1); `Log.swift` header ("always-on (opt-out)" vs the actual default-off
toggle, §6). Minor oddity, deliberate: `build-app.sh` signs with
`--identifier "com.i7t5.edmd"` while the bundle id is `com.i7t5.edmund`.
Re-verify when any of these change: `release.yml` step names or secrets,
`build-app.sh` signing order, the CHANGELOG header format (the awk regex in
two places must match it), `SUFeedURL`, `RELEASE_TOKEN` rotation (hard
deadline 2027-06-27), notarization status, or the crash-report server going
live (which un-inerts §6's crash uploading and this skill's wording).
@@ -0,0 +1,196 @@
---
name: edmund-research-frontier
description: >
Open problems where the Edmund Markdown editor could advance the state of the
art — product-first, everything labeled candidate/open, nothing proven. Load
when picking the next big problem, evaluating whether an ambitious idea is
worth starting, or answering "what would move this project beyond state of the
art". Each frontier: why current SOTA fails, Edmund's specific asset, the
first three concrete steps IN THIS REPO, and a falsifiable "you have a result
when…" milestone. Not for running an accepted investigation
(edmund-caret-integrity-campaign), the method of proof
(edmund-research-methodology), or shipping the change (edmund-change-control).
---
# Edmund research frontier
Where Edmund could go past the state of the art. Ambition is **product-first**:
"the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are
**means, not ends** — a technique is worth pursuing when it makes the product
better, and it becomes publishable as a side effect.
**Everything here is candidate / open. Nothing is proven.** Each item routes its
proof through **edmund-research-methodology** (hypothesis predicts numbers) and
its changes through **edmund-change-control**. Verified 2026-07-05 against the
repo; assets cited are real, outcomes are not.
---
## Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16)
**Why SOTA fails:** TK2 lays out only near the viewport; off-screen fragment
heights are **estimates** corrected as layout reaches them. This makes the
scroller jump and scroll-to-target miss in **every** TK2 app, including TextEdit
— a widely documented limitation. Above `fullLayoutMaxLength` (100k) Edmund
enters this regime.
**Edmund's asset:** the mitigations already in `TextView/`
`scheduleFullLayoutSettle`, `preservingViewportAnchor`, `repairContentAboveOrigin`,
`centerViewportOnCaret` re-measure, and the diff-based undo restore that avoids
resetting fragments to estimates; plus the `ScrollStabilityTests` /
`HeightStabilityTests` harnesses. (Note: `repairContentAboveOrigin` is
**mitigated-unconfirmed live** per `docs/investigations/viewport-glitch-investigation.md` — a
real result here would also *retire that honest gap*.)
**First three steps in this repo:**
1. Build a large-doc fixture (`makeLargeMarkdown` in `TestHelpers.swift`) >100k
and a scripted scroll-accuracy metric (ReproScript `caret` + a `logsel`-style
position dump, or extend `PerfHarnessTests`).
2. Quantify the estimate-error distribution: for N scroll-to-target operations,
record predicted vs actual landing pixel offset.
3. Prototype persistent per-fragment height caching across the settle (or across
sessions) and re-measure the same distribution.
**You have a result when:** scroll-to-target lands within a stated pixel budget
on a 1 MB document, measured by script, with **zero** `repairing content above
origin` events across a scroll soak — reproducibly.
---
## Frontier 2 — Caret integrity by construction
**Why SOTA fails:** every `NSTextView` consumer depends on `didChangeText`
pairing that **AppKit itself violates** (the drag-move bypass). The delete-drift
class is the symptom of building sync on a callback contract AppKit doesn't keep.
**Edmund's asset:** the heal machinery, the `pendingEdit` model, six documented
rounds of mechanism knowledge, and the ReproScript + soak methodology. The
campaign skill runs *individual* rounds reactively; this frontier is the
**structural endgame** — eliminate the class.
**First three steps:**
1. Inventory **every** storage-mutation entry point (grep `replaceCharacters`,
`setAttributes`, the edit-flow paths) and tabulate which currently rely on a
callback firing.
2. Design a sync layer keyed on a **storage-version counter** that reconciles
`rawSource` regardless of which callbacks fired (not a new guard per path).
3. Falsify it against **all** historical `.repro` scripts plus a new randomized
bypass-fuzzer script.
**You have a result when:** all historical `.repro` scripts and a randomized
bypass soak stay green **with the callback-pairing assumption deleted from the
code**. **Candidate, big** — likely a multi-PR redesign; do not start without the
methodology skill's evidence bar in front of you.
---
## Frontier 3 — The 10 MB class (performance headroom)
**Why it matters:** README claims "~12 MB files"; native-with-no-Electron is the
differentiator, so headroom is a product claim, not vanity.
**Edmund's asset:** viewport-based lazy styling, the idle drain, incremental
reparse (`pendingEdit` window), `PerfHarnessTests`.
**First three steps:**
1. Extend `PerfHarnessTests` with 5/10 MB fixtures (`makeLargeMarkdown`).
2. Profile the block-parse and restyle hot paths (`Log.measure` single-line
durations are already in place).
3. Set explicit latency budgets for open, first-paint, and per-keystroke restyle
at 10 MB.
**You have a result when:** open + steady-state typing latency on a 10 MB file
meets a stated budget, measured by the harness (not hand-timed).
---
## Frontier 4 — A native extensions API
**Why SOTA fails:** Obsidian/VS Code plugin ecosystems are Electron; there is no
strong precedent for a **native, safe, fast** extension surface for live-preview
Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations,
primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as
"official extension" candidates.
**Edmund's asset:** the custom-parser seam
(`Parsing/SyntaxHighlighter+CustomParsers.swift`), the `BlockKind`/`styleBlock`
architecture, and already-modular opt-in syntax (math, Obsidian syntax).
**First three steps:**
1. Catalog which existing features could be **re-implemented as extensions**
(dogfood: callouts? highlight? wikilinks?) — this defines the real extension
points.
2. Define the minimal seam: block parser? inline parser? theme hook? Draw the
line at what the custom-parser architecture already supports.
3. Spike **one** official extension behind a flag and measure restyle cost vs the
built-in.
**You have a result when:** one built-in syntax feature runs as an extension with
**no measurable restyle regression** against the built-in baseline.
---
## Frontier 5 — Accessibility / RTL / localization as a differentiator
**Why it matters:** native apps *can* excel where Electron editors are weak;
ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width
already ships as precedent that the pipeline can be locale-sensitive.
**Edmund's asset:** the attribute-only pipeline (structure is in the string, not
in inserted characters), the existing locale-aware content-width path.
**First three steps:**
1. VoiceOver audit of `EditorTextView`'s custom drawing — does the accessibility
tree expose headings/lists/callouts, given they're drawn as decorations?
2. Test RTL behavior of the attribute-only styling on a right-to-left document.
3. Scriptable a11y check (structure read-out) as a regression guard.
**You have a result when:** a scripted VoiceOver audit reads document structure
(headings, list items, callouts) correctly on a mixed document.
---
## How to start one
1. **Predict numbers first** (edmund-research-methodology §2) — every milestone
above is a *number*, not a vibe.
2. Instrument to make the current failure/limit cheap to measure.
3. Prototype behind a flag; measure predicted vs observed.
4. Route changes through **edmund-change-control** (branch, tests, no auto-push).
5. When the first milestone lands, the item **graduates to `misc/backlog.md` or
`docs/ROADMAP.md`** and stops being a frontier.
## What NOT to start (no current asset)
- **iOS / iPadOS port** — explicitly **TBD** in ROADMAP; no shared UI layer today.
- **Collaborative / real-time editing** — zero repo support (no CRDT, no sync,
single `NSDocument` model). Would be a new product, not a frontier of this one.
- Anything that requires breaking an invariant (storage == rawSource; TextKit 2
only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract).
---
## When NOT to use this skill
- Running an *accepted* investigation (a known bug) → **edmund-caret-integrity-campaign** / **edmund-debugging-playbook**.
- The method of turning a hunch into proof → **edmund-research-methodology**.
- Whether a public claim is allowed yet → **edmund-external-positioning**.
- Shipping the change → **edmund-change-control**.
---
## Provenance and maintenance
Verified 2026-07-05. Assets exist; **outcomes are unproven by definition**
never quote a milestone here as achieved.
```bash
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift
grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md
grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift
```
When an item's milestone lands, move it to ROADMAP/backlog and delete it here —
a frontier list that keeps solved problems is lying.
@@ -0,0 +1,196 @@
---
name: edmund-research-methodology
description: >
The discipline that turns a hunch into an accepted result in the Edmund
Markdown editor — the evidence bar, hypothesis-predicts-numbers, the
first-principles analysis recipes, the idea lifecycle, and where good ideas
historically came from. Load when forming a hypothesis about a bug's
mechanism, evaluating whether an investigation's conclusion is trustworthy,
deciding if a fix is actually proven, or turning an idea into an accepted
change. This skill also absorbs the proof-and-analysis toolkit (prove it,
don't just install it). Not the caret-integrity campaign itself
(edmund-caret-integrity-campaign), not the repro tooling
(edmund-live-repro-and-diagnostics).
---
# Edmund research methodology
How a hunch becomes something you can ship without it coming back. Every method
here is grounded in a real episode from this repo's history — the discipline was
paid for in the six delete-drift rounds and the viewport work.
Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`,
`docs/investigations/viewport-glitch-investigation.md`, and `docs/dev-guides/live-repro-guide.md`.
---
## 1. The evidence bar
A mechanism is **accepted** only when it clears both bars:
1. **It explains ALL observations, including the negatives.** Not just "the caret
drifts" but *why headless tests pass, why it's intermittent, why it appears
minutes after the trigger.* A mechanism that explains the symptom but not the
negatives is incomplete — and incomplete mechanisms come back.
2. **It survives assigned adversarial refutation.** Before shipping, actively try
to **falsify** the supposed fix: run the frozen repro against it. Rounds 15
of delete-drift shipped on reasoning and **all came back**; round 6's first
fix candidate "worked by reasoning" and **failed in the repro within a
minute** (`docs/investigations/delete-drift-investigation.md` round 6, "the iteration that
proved its shape").
Corollary: **headless-green is not the bar** for live-input bugs — the round-6
regression test passes with and without the fix. See edmund-validation-and-qa
for the evidence hierarchy.
---
## 2. Hypothesis predicts numbers BEFORE running
State the expected observable **before** the experiment. An experiment without a
predicted number is a fishing trip.
- **Worked example:** round-6 `logsel` predicted **321** on the broken build vs
**290** on the fixed build — a specific number, stated before the run, that the
repro then confirmed every time.
- **Determinism as a prediction:** a soak's final `rawLen` must be
**byte-identical** across runs; predict it, then check it.
If you can't name the number your hypothesis predicts, you don't understand the
mechanism well enough to test it yet — go back to instrumentation (§3b).
---
## 3. The analysis recipes (prove it, don't just install it)
Each: when to use, the steps, and the episode that earned it.
### 3a. Trace archaeology — walk BACKWARDS
**When:** any symptom with diagnostics on. **Steps:** find the first *bad* line,
then read *upward/earlier*, not forward from the symptom. **Episode:** the round-6
drift at 22:13 was armed by a bypass at 22:11:57 — 80 seconds and dozens of
healthy edits earlier. Source: `docs/dev-guides/live-repro-guide.md` §2.
### 3b. Make the failure cheap to observe before reasoning about fixes
**When:** you're tempted to guess. **Steps:** add one breadcrumb (a call stack, a
state dump) behind `Log.shouldTrace` and ship it; reproduce; let the log name the
culprit. **Episode:** `traceSelectionOrigin` named `_fixSelectionAfterChange` in
a single run after five rounds of guessing. This is *the* meta-lesson: **time
spent making the failure cheap to observe beats time spent reasoning about the
fix.**
### 3c. The fidelity ladder as an inference tool
**When:** a repro fails. **Steps:** a repro that fails at level N but succeeds at
N+1 **localizes** the mechanism to what N lacks. **Episode:** delete-drift not
reproducing in a windowed unit test (level 2) told the team it needed
deferred/queued AppKit state → pointed straight at level-3 in-process replay and
event-ordering hypotheses. Source: `docs/dev-guides/live-repro-guide.md` §1.
### 3d. Invariant auditing
**When:** the behavior looks impossible. **Steps:** find which invariant
**transiently broke**`storage == rawSource` during IME composition;
`didChangeText` pairing during a drag-move; grep the guard inventory
(`hasMarkedText`, bypass checks). Lock it with an equivalence/fuzz oracle:
`RecomposeEquivalenceTests` (`assertMatchesFullRecomposeOracle` — incremental
restyle must equal a full restyle) and `IncrementalParseFuzzTests` (random edits
keep window-reparse == full reparse).
### 3e. Exact-sequence replication
**When:** simulating an AppKit-internal path. **Steps:** replay its **real call
sequence**, pinned from a stack trace, not an approximation of its *effect*.
**Episode:** `bypassdelete` reproduces `shouldChangeText``replaceCharacters`
with no `didChangeText` **verbatim** — an approximation would have hidden the bug.
### 3f. Differential measurement over eyeballing
**When:** any visual or binary claim. **Steps:** screencapture **pixel**
measurement for visuals (edmund-live-repro-and-diagnostics §5); `shasum`
before/after for "did the binary actually change"; predicted-vs-observed tables
for numbers. **Episode:** the maintainer's rule — when told "balance the padding",
measure the top vs bottom pad in pixels, don't judge by eye.
---
## 4. The idea lifecycle
How an idea moves from hunch to settled (or to a documented dead end):
```
hunch
→ cheap instrumentation / discriminating experiment (§3b, §3c)
→ investigation-doc entry (docs/<topic>-investigation.md, round structure)
→ frozen deterministic repro
→ fix on its own fix/ branch, with test + soak (edmund-validation-and-qa)
→ ARCHITECTURE §8 gotcha entry in the SAME PR (if an invariant changed)
→ settled status in the chronicle (edmund-failure-archaeology)
```
**Retirement path for ideas that fail:** a documented dead end in the
investigation doc with a "do not retry" note and *why* (see the round chronicle
and the viewport doc's "Verification limits (honest gaps)" section — mitigations
that are real but **unconfirmed live** are labeled so, not oversold).
**Where larger ideas are tracked:** `docs/ROADMAP.md` (versioned feature plan)
vs `misc/backlog.md` (near-term working list; priority order **Marketing = Bugs
>= UI/UX > Features**). A frontier idea graduates onto one of these when its
first milestone lands (edmund-research-frontier).
---
## 5. Where good ideas historically came from
Mine these seams first — they've paid out repeatedly:
- **Making failures observable.** The diagnostics accumulated one breadcrumb at a
time; each named a mechanism the previous round guessed at.
- **Reading Apple's *actual* behavior, not the documented ideal.** Sparkle's
update validation is non-strict (`SecStaticCodeCheckValidityWithErrors`) —
discovered by testing the real API, which unblocked the whole update pipeline.
`NSWindow.sendEvent` as the pre-toolbar funnel solved the toolbar right-click
after `menu`/`rightMouseDown`/gestures all failed.
- **Prior art consulted before inventing.** `nodes-app/swift-markdown-engine`
(ARCHITECTURE §14) solves the same TK2 live-preview problems with different
trade-offs — a comparison point and technique source. MarkEdit/CotEditor as
product references (ROADMAP).
- **Field reports with logs + movs.** `misc/bug-repros/` holds the maintainer's
screen recordings and trace logs — the round-4 drag-move mechanism came
straight out of one such trace.
---
## 6. Anti-patterns (each with its historical cost)
| Anti-pattern | Cost paid |
|---|---|
| Fix at symptom time | Patches the wrong instant — symptom armed minutes earlier (round 6) |
| Trust headless green for a live-input bug | Round-6 test passes with *and* without the fix |
| Stack speculative fixes | Rounds 15 each "fixed" it; each came back |
| Skip document reconstruction | Geometry/block-kind-dependent bugs don't fire on `"hello world"` |
| Trust a possibly-stale binary | SwiftPM prints `Build complete!` without relinking → wrong conclusions |
| Declare victory without a soak | One clean repro misses armed-state / degradation bugs |
---
## When NOT to use this skill
- Actually running the caret investigation step by step →
**edmund-caret-integrity-campaign**.
- The repro drivers and trace decoding → **edmund-live-repro-and-diagnostics**.
- What evidence a change type requires + the test suite → **edmund-validation-and-qa**.
- The settled history you're checking against → **edmund-failure-archaeology**.
- Picking the next big problem → **edmund-research-frontier**.
---
## Provenance and maintenance
Verified 2026-07-05.
```bash
grep -nE '^## Round|honest gaps|Verification limits' docs/investigations/delete-drift-investigation.md docs/investigations/viewport-glitch-investigation.md
grep -n '321\|290' docs/investigations/delete-drift-investigation.md # the predicted numbers (§2)
grep -rn 'assertMatchesFullRecomposeOracle' Tests/EdmundTests/
ls misc/bug-repros/ # field-evidence seam (§5)
```
The methods are stable; the *episodes* they cite are the drift risk — if a new
round rewrites the delete-drift narrative, refresh the worked examples here.
@@ -0,0 +1,208 @@
---
name: edmund-validation-and-qa
description: >
What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests.
Load when writing or adjusting tests, deciding what proof a fix needs before
it can ship, judging whether a change is actually "verified", interpreting
the test suite, or adding a golden/regression check. Covers the evidence
hierarchy (headless is necessary but not sufficient for live-input bugs),
the test-suite map, the Swift Testing helpers, how to add a test, the golden
inventory, visual QA, and performance evidence. Not the gating rules
themselves (edmund-change-control) or how to run the live repro
(edmund-live-repro-and-diagnostics).
---
# Edmund validation & QA
The discipline of proof. The central lesson: **a green headless test is
necessary but not sufficient** for the bug classes that cost the most time here.
Know which evidence each change actually requires.
Framework: **Swift Testing** (`import Testing`, `@Test`, `#expect`, `#require`)
— NOT XCTest. Verified 2026-07-05: 810 `@Test` cases across `Tests/EdmundTests/`.
---
## 1. The evidence hierarchy
Ordered weakest → strongest. The rule at the bottom names which class each
change type **requires**.
| Class | What it proves | Blind spot |
|---|---|---|
| (a) Headless unit test (`makeEditor()`) | model/parse/style/undo logic | **Cannot** exercise deferred AppKit machinery — runs it synchronously |
| (b) Windowed unit test (real NSWindow + NSScrollView, real `deleteBackward`) | + layout, viewport, first responder | still not real event-loop pacing / IME / drag |
| (c) Frozen live ReproScript repro (exact script + document) | the live input/timing mechanism | needs a debug build + ~1 min/run |
| (d) Soak script green across 45 cycles, byte-identical final `rawLen` | armed-state + determinism | slow |
| (e) Screencapture pixel measurement | anything that DRAWS | manual |
**The trap that defines this skill:** the delete-drift **round-6 regression test
passes with AND without the fix** — the harness runs the queued selection fixup
synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing
bugs, class (a) is not evidence of a fix; you need (c)+(d).
**Required evidence by change type** (gates enforced in edmund-change-control):
| Change | Requires |
|---|---|
| Pure logic (parse/style/model) | (a) |
| Viewport/lazy-layout | (b), often (e) |
| Anything that draws | (a where testable) + **(e)** in light AND dark |
| Edit-pipeline / selection / IME / undo | (a) to lock the headless contract **+ (c)** frozen repro **+ (d)** soak |
| Release | see edmund-release-and-operate |
---
## 2. Test-suite anatomy
Run: `swift test` (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 `@Test`
cases as of 2026-07-05). One suite: `swift test --filter <Suite>`. **`swift test`
also runs automatically as a Stop hook** (`.Codex/settings.json`) at the end of
any turn touching code, so failures surface before you commit.
Map of `Tests/EdmundTests/` (what the files actually cover):
- **Parsing:** `BlockParserTests`, `SyntaxHighlighterTests`, `IncrementalParseFuzzTests`, `PendingEditTests`, `EscapeRenderingTests`, `HTMLTagRenderingTests`, `EmojiRenderingTests`, `LineEndingTests`.
- **Rendering / styling:** `BlockStylingTests`, `CalloutRenderingTests`, `CalloutTests`, `CommentRenderingTests`, `NestedBlockStylingTests`, `InlineStylingTests`, `MathRenderingTests`, `ImageRenderingTests`, `CodeHighlighterTests`, `FootnoteTests`, `WikiLinkTests`, `TableAlignmentTests`, `RenderingRegressionTests`.
- **Editor behavior:** `EditorIndentationTests`, `ListContinuationTests`, `BlockquoteContinuationTests`, `BlockquoteDeletionTests`, `FormattingTests`, `ActiveBulletMarkerTests`, `NewListItemAlignmentTests`.
- **Edit-pipeline integrity (the costly class):** `BypassedEditSyncTests`, `MarkedTextDesyncTests`, `WrappedParagraphCaretTests`, `EditorDiagnosticsTests`, `UnmatchedDebugTests`, `InternationalInputTests`.
- **Viewport / layout / undo:** `LazyRenderingTests`, `ScrollStabilityTests`, `HeightStabilityTests`, `TypewriterCenteringTests`, `UndoRedoViewportTests`, `EditorUndoTests`, `RecomposeTests`, `RecomposeEquivalenceTests`.
- **Export / read mode:** `HTMLRendererTests`, `DocumentHTMLTests`, `ReadModeWebViewTests`, `HTMLThemeTests`, `EditorThemeTests`, `ViewModeTests`, `ContentWidthTests`.
- **Infra / harness:** `LogTests`, `CrashReporterTests`, `PerfHarnessTests`, `StatusBarPrefsTests`, `FileIntegrationTests`, `EditorDocumentTests`, `TestHelpers.swift`. `_RenderDump.swift` / `_RenderEdit.swift` are **local dev tools (gitignored intent)** that dump Read-mode HTML / edit output for `tmp/sample.md` — not part of the assertion suite.
**Two invariant-guarding patterns worth copying:**
- **Recompose equivalence** (`RecomposeEquivalenceTests`, helper
`assertMatchesFullRecomposeOracle`): an incremental restyle must produce the
**same** result as a full recompose. This is the safety net for the lazy /
incremental styling paths.
- **Incremental-parse fuzz** (`IncrementalParseFuzzTests`): random edits must
keep the parser's window-reparse consistent with a full reparse.
---
## 3. Test helpers (`TestHelpers.swift`)
`@MainActor` helpers you build on (verified exports):
| Helper | Use |
|---|---|
| `makeEditor()` | an `EditorTextView` on the real TK2 chain (mirrors `Document.makeWindowControllers`) |
| `ensureFullLayout(...)` | force layout so geometry is real, not estimated |
| `type(...)`, `paste(...)`, `pressEnter()`, `pressBackspace()` | drive edits |
| `activateBlock(...)` | move the caret / active block |
| `displayText(...)`, `attrs(...)`, `font(...)`, `fgColor(...)` | inspect styled output |
| `isHidden` / `isInvisible` / `isDimmed` | assert delimiter hiding |
| `blockDecoration(...)`, `textBlockDifference(...)` | inspect decorations / catch TK1-reverting table attrs |
| `expectedFullComposition(...)`, `drainAllStyling()`, `assertMatchesFullRecomposeOracle(...)` | equivalence-oracle checks |
| `makeLargeMarkdown(...)`, `sentence(...)` | build big fixtures for perf/viewport |
`styleBlock(_:cursorPosition:)` is a method on `EditorTextView`
(`Rendering/EditorTextView+Rendering.swift`), called from tests — it renders one
block to an attributed string.
---
## 4. How to add a test
Skeleton (Swift Testing, `@MainActor` because the editor is main-thread):
```swift
import Testing
import AppKit
@testable import EdmundCore
@MainActor
@Test func deletingAtCalloutBottomKeepsInvariant() {
let editor = makeEditor()
editor.loadRawSource("> [!note]\n> body\n")
drainAllStyling()
// ... drive the edit ...
#expect(editor.rawSource == editor.string) // storage == rawSource invariant
}
```
Rules:
1. **Every bug fix ships with a test even if it can't discriminate a live-only
mechanism** — it still locks the headless contract so a *future* refactor
can't silently re-break the model half. For the live half, keep the frozen
`.repro` script alongside (edmund-live-repro-and-diagnostics).
2. Assert the **invariant** where you can (`rawSource == string`), not just the
surface symptom — that's what `BypassedEditSyncTests` / `MarkedTextDesyncTests`
do.
3. Name the test for the behavior/bug, put edit-pipeline repros next to their
siblings (the `*Desync*` / `*Bypassed*` / `*CaretTests` families).
4. New drawing behavior additionally needs a screencapture check (§6).
---
## 5. Golden / certified inventory
- **`RenderingRegressionTests`** + `RecomposeEquivalenceTests` are the closest
thing to golden checks — they pin styled output and incremental-vs-full
equivalence.
- **`test-files/*.md`** (`callout.md`, `decorations.md`, `math.md`, `menu.md`,
`test.md`) are the **user's manual test corpus** — hand-testing fodder. **Do
not rewrite them to fit an automated test**; build your own fixtures (helpers
in §3, or a scratch dir).
- **`misc/bug-repros/`** holds field evidence (`.mov` screen recordings + `.log`
traces) for open bugs — reference these when reproducing, don't delete them.
---
## 6. Visual QA
Anything that draws is verified by **screencapture pixel measurement**, in
**light AND dark mode** (per `misc/before-you-release.md`), never by eyeballing
headless layout. Method + scripts: **edmund-live-repro-and-diagnostics** §5.
Headless layout tests (`HeightStabilityTests`, `ScrollStabilityTests`) check
geometry numbers but **cannot** confirm the pixels are right.
---
## 7. Flakiness & CI
- A `fix/flaky-math-test` branch exists in history — math rendering has shown
timing flakiness; if a math test flakes, check that branch's approach before
inventing a new one (verify: `git log --oneline --all -- '*Math*'`).
- CI: `.github/workflows/ci.yml` on `macos-14`, latest-stable Xcode, SPM cache
keyed on `Package.resolved`, `concurrency: cancel-in-progress` (private-repo
macOS minutes bill 10×). CI runs the same `swift test`.
---
## 8. Performance evidence
- **`PerfHarnessTests`** measures the hot paths; `makeLargeMarkdown` builds big
fixtures. Use it (don't hand-time) for any perf claim.
- The README claim "handles ~12MB files" and `fullLayoutMaxLength = 100_000`
UTF-16 (`EditorTextView.swift:80`) mark the boundary between the **full-layout**
regime (≤100k, geometry is real) and the **estimate** regime (>100k, viewport
glitches possible). A perf/viewport claim must state which regime it was
measured in.
---
## When NOT to use this skill
- The gate/branch/commit rules → **edmund-change-control**.
- Running the live repro or measuring pixels → **edmund-live-repro-and-diagnostics**.
- The caret-integrity investigation end to end → **edmund-caret-integrity-campaign**.
- Why a mechanism works → **textkit2-appkit-reference** / **edmund-architecture-contract**.
- Release verification → **edmund-release-and-operate**.
---
## Provenance and maintenance
Verified 2026-07-05.
```bash
grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l # ~810 cases
grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest
grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3)
ls Tests/EdmundTests/ # suite map (§2)
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
```
If a helper in §3 no longer greps it was renamed; update §3 and any test
skeletons. Re-derive the suite map from `ls` if files are added/removed.
@@ -0,0 +1,279 @@
---
name: textkit2-appkit-reference
description: >
Domain-theory pack for the AppKit text system as it applies to the Edmund
Markdown editor. Load when working on layout, selection, IME, undo, drag, or
eventing behavior; when TextKit 2 or NSTextView does something surprising;
or when terms like NSTextLayoutManager, layout fragment, marked text, queued
selection fixup, responder chain, or sendEvent appear and you lack AppKit
text-system background. Explains the mechanisms the invariants and gotchas
are built on. Not the invariants themselves (edmund-architecture-contract),
not a triage table (edmund-debugging-playbook), not the repro drivers
(edmund-live-repro-and-diagnostics).
---
# TextKit 2 / AppKit reference (as it applies to Edmund)
The background a mid-level engineer or Sonnet-class model usually lacks. Each
concept: brief theory, then **where it bites in Edmund** with a verified file
pointer. This is not a textbook — it is only the parts that matter here.
Facts checked against source 2026-07-05. Items labeled *(background)* are
general AppKit/TextKit behavior grounded in Apple's documentation, not directly
grep-able in this repo.
---
## 1. The TextKit 2 object model *(background + repo)*
TextKit 2 replaced the TextKit 1 `NSLayoutManager` stack. The players:
- **`NSTextContentStorage`** — owns the backing string + attributes (the model).
- **`NSTextLayoutManager`** — lays text out (the TK2 analogue of the old layout manager).
- **`NSTextLayoutFragment`** — one laid-out chunk (≈ a paragraph); has a real
geometric frame **only once laid out**.
- **`NSTextElement` / `NSTextParagraph`** — the model-side elements fragments render.
**Viewport-based layout** is the headline difference: TK2 lays out only the
content near the visible viewport, not the whole document. That is what makes
big documents fast — and it is the root of most viewport pain (§2).
**Where it bites:** Edmund subclasses the fragment as `DecoratedTextLayoutFragment`
(`EditorTextView+TextKit2.swift`) to draw callout boxes, bars, and overlays.
---
## 2. Height ESTIMATES — the master cause of viewport glitches
A fragment that has not been laid out yet has an **estimated** height, not a
real one; the total document height is the sum of real + estimated fragment
heights. As layout reaches a fragment, its estimate is replaced by the true
value and everything below shifts. Consequences: the scroller thumb jumps, and
"scroll to offset Y" lands wrong because Y was computed from estimates. This is
a **widely documented TK2 limitation — even TextEdit shows it** *(background)*.
**Where it bites / Edmund mitigations** (verify names by grep; all in `TextView/`):
- `fullLayoutMaxLength = 100_000` (`EditorTextView.swift:80`): documents ≤ 100k
UTF-16 units are kept **fully laid out** (no estimate regime) by a coalesced
next-run-loop settle.
- `scheduleFullLayoutSettle` / `preservingViewportAnchor`: the settle runs inside
an anchor block so corrections never shift what is on screen.
- `repairContentAboveOrigin` (`+LazyStyling.swift`, logs `repairing content
above origin`): fixes the case where an edit near the top strands the first
fragment at negative y (unreachable above the scroller top).
- `centerViewportOnCaret`: re-measures after its first scroll and corrects the
residual estimate error.
**Rule:** never trust an off-screen fragment's y-coordinate without laying out
its span first. Deep write-up: `docs/investigations/viewport-glitch-investigation.md`.
---
## 3. The TextKit 1 fallback trap
An `NSTextView` can silently and **permanently** revert from TK2 to the legacy
TK1 stack. Two known triggers: **accessing `NSTextView.layoutManager`** (the
mere getter engages TK1), and **storing `NSTextBlock`/`NSTextTable`
attributes**. Once reverted, TK2 APIs still exist but do nothing useful, and the
whole editor misbehaves subtly.
**Where it bites:** Edmund ships a **DEBUG tripwire** — an observer on
`NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the switch
happens (`EditorTextView.swift:273`+, message "TextKit 1 fallback triggered").
Never add code that reads `layoutManager` or stores table attributes; draw
tables as decorations instead (`EditorTextView+TableRendering.swift`).
---
## 4. Attribute-only mutation semantics
Edmund renders by writing **attributes** onto the storage, never by inserting or
deleting characters (the storage == rawSource invariant). Two consequences from
the text system:
- **`setAttributes` does not re-measure geometry.** After a restyle that changes
a block's height or indent, you must call `invalidateLayout(for:)` on its
range or the fragment keeps a **stale frame** (empty bands / clipped lines).
`recomposeDirty` and the idle drain already do this; new paths must too.
- **`NSTextAttachment` is only honored on the `U+FFFC` object-replacement
character** *(background)*. `rawSource` never contains `U+FFFC`, so attachments
can't be used — Edmund draws images/icons as **overlays** (§5) instead.
---
## 5. The custom drawing model (fragments, decorations, overlays)
`DecoratedTextLayoutFragment` draws two attribute families behind/over text:
- **`.blockDecoration`** (paragraph-level): callout boxes, quote bars, table
borders, thematic-break rules, code backgrounds. Fragments **tile vertically**,
so a multi-line run renders as one continuous box. A box's `bottomPad` grows
the **last** fragment's frame (TK2 omits trailing paragraph spacing from the
fragment, so padding done otherwise would be dead space).
- **`.fragmentOverlay`** (character-level): an image **or** a stroked vector path
drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes,
callout header icon, the custom-title callout icon (path). The anchor glyph is
**hidden** (≈0.01 pt font + clear color) and `.kern` reserves the drawing's
advance width.
**The image-on-wrapping-fragment wedge:** drawing an **image** overlay on a
**multi-line (wrapping)** fragment re-triggers a layout pass that collapses the
fragment to one line. Drawing a **shape/path** does not. So the wrapping
callout title's icon is a stroked `CGPath` (parsed by `SVGPath` from vendored
Lucide geometry), never an image. Full saga:
`docs/investigations/archives/callout-title-wrap-investigation.md`. This constraint holds for **any new
overlay** that could share a line with wrapping text.
**Hiding text** = `hiddenFont` (≈0.01 pt) + clear `foregroundColor`. This is how
delimiters (`**`, `` ` ``, `[!note]`) vanish without changing the string.
---
## 6. The queued selection fixup (the round-6 delete-drift mechanism)
When you mutate an `NSTextView`'s storage, AppKit queues a private step,
`-[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:]`, that repairs
the selection against the new character coordinates. Normally it fires promptly.
But if an edit **bypasses the normal close-out** (see §7), the fixup stays
**queued** and fires at the **next `endEditing`** — *even an attribute-only
restyle* — where it maps the now-**stale** selection against post-edit
coordinates and **leaps the caret blocks away**. It will move even a freshly set,
valid caret.
**Where it bites:** this is delete-drift round 6. The heal must set the caret
(from the pendingEdit hull) **before** the sync **and re-assert it after**
(`EditorTextView+EditFlow.swift`). Recognize a variant by: a suspicious
selection change arriving mid-recompose (`up=Y` in traces);
`traceSelectionOrigin` will log the call stack of whoever moved it.
**Critical for testing:** a headless test harness runs this deferred fixup
**synchronously**, so this bug class **cannot reproduce in a unit test** — the
round-6 regression test passes with *and* without the fix. Only the live
in-process repro discriminates (edmund-live-repro-and-diagnostics).
---
## 7. The AppKit edit pipeline contract (and where AppKit breaks it)
Normal edit: `shouldChangeText(in:replacementString:)` → the view calls
`replaceCharacters` → `didChangeText()`. Edmund's `didChangeText` syncs
`rawSource` from storage and restyles the edited block(s).
**AppKit does NOT always send `didChangeText`.** A drag-move of selected text
whose drop lands on **no valid target** (e.g. released past the end of the
document) deletes the dragged range via `shouldChangeText` → `replaceCharacters`
and **never calls `didChangeText`** — silently freezing `rawSource`/`blocks`,
after which every edit drifts and autosave writes stale content (delete-drift
round 4). Edmund heals this: `shouldChangeText` schedules a next-run-loop
**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`); an
unconsumed storage `pendingEdit` by then means the close-out never came, and the
editor runs the sync itself (breadcrumb: `healing storage edit that bypassed
didChangeText`).
**Never build a sync path on the assumption that `didChangeText` follows every
edit.**
**The authentic key route** *(background + repo)*: keyDown →
`interpretKeyEvents` → `insertText:` / `deleteBackward:`. This is why the repro
driver synthesizes real `NSEvent`s and pushes them through `window.sendEvent(_:)`
rather than calling `insertText` directly — shortcuts skip `deleteBackward`'s
selection machinery, which is exactly where round 6 lived.
---
## 8. IME / marked text lifecycle
While an input method is composing (e.g. CJK, accents), the view holds
**provisional "marked" text** in storage; `hasMarkedText()` is true. During this
window `storage == rawSource` is **transiently false**, and `didChangeText`
defers syncing until the composition commits.
**The cascade:** any styling path that runs
`beginEditing`/`setAttributes`/`invalidateLayout` **mid-composition** can strand
the marked text in the input context. After that, `didChangeText` keeps bailing
on its own guard and the invariant stays broken — so **every later edit drifts
the caret** (the original delete-drift bug). Therefore **every storage-touching
styling path must guard `!hasMarkedText()`** — including async ones scheduled
*before* composition began (the caret-move restyle in `+SelectionTracking`).
`becomeFirstResponder` resyncs from storage as a catch-all. Full write-up:
`docs/investigations/delete-drift-investigation.md`.
---
## 9. Responder chain & nil-target actions *(background + repo)*
The **responder chain** is AppKit's search order for who handles an action.
Menu items and toolbar buttons with a **nil target** send their action up the
chain until something responds. Edmund's Format menu (`FormatMenu.swift`) is a
declarative command table whose items use nil targets and route to the focused
`EditorTextView`'s `@objc format…` actions — the same wiring as undo/redo. The
first responder is normally the focused `EditorTextView`.
---
## 10. `NSWindow.sendEvent` — the pre-toolbar event funnel
Every event a window receives passes through `sendEvent(_:)` **before** the
toolbar acts. This matters because with `NSToolbar.allowsUserCustomization =
true`, the toolbar claims **any secondary (right/control) click over the
toolbar** — including a custom item view — for its own "Customize Toolbar…" menu,
downstream of view-level handlers (`menu`, `rightMouseDown`, gesture
recognizers all lose). Edmund's fix for the view-mode button: intercept in
`DocumentWindow.sendEvent(_:)`, pop the menu when the click is inside the
button's bounds, and swallow it (`return`); other clicks fall through to
`super`. (Caveat: true fullscreen moves the toolbar to a separate window, so
this main-window hook wouldn't cover it.)
---
## 11. Drag sessions *(background + repo)*
- **Text drag-move arming:** AppKit only starts a text drag after a **mouse-down
hold (~400 ms)**; a CGEvent driver must hold before moving or the drag never
arms.
- **Drag-select autoscroll:** dragging past the viewport edge autoscrolls.
- **Reveal at nearest end:** a selection taller than the viewport must be
revealed at its **nearest** end (Edmund's `scrollRangeToVisible` override) —
always revealing the top fought the drag-select autoscroll and oscillated the
viewport mid-drag.
---
## 12. swift-markdown walker model *(brief)*
Edmund parses with `apple/swift-markdown` (CommonMark/GFM) and walks the
resulting `Document` with **two back-ends**: a `SpanCollector`-style walk that
produces editor attributes, and `HTMLRenderer` that produces HTML for Read mode.
One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark)
syntax — callouts, `==highlight==`, wikilinks, comments, footnotes, math — is
handled by `SyntaxHighlighter+CustomParsers.swift`.
---
## When NOT to use this skill
- The project's **rules/invariants** (what you must not do) → **edmund-architecture-contract**.
- **Which** symptom means which mechanism → **edmund-debugging-playbook**.
- **Reproducing** a live bug / reading traces → **edmund-live-repro-and-diagnostics**.
- The **history** of how these mechanisms were discovered → **edmund-failure-archaeology**.
- Running the caret-integrity campaign → **edmund-caret-integrity-campaign**.
---
## Provenance and maintenance
Verified 2026-07-05. Re-verify the load-bearing identifiers:
```bash
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
```
Items marked *(background)* are AppKit/TextKit behavioral facts documented by
Apple and in `docs/*-investigation.md`, not directly observable by grep. If any
Edmund mitigation name above no longer greps, it was renamed — update this file
and `docs/ARCHITECTURE.md` §5/§8 together.
+48
View File
@@ -0,0 +1,48 @@
---
description: Branch, test, commit, push, open a PR, and enable auto-merge for the current working-tree changes
argument-hint: [short description of the change]
allowed-tools: Bash, Read, Edit
---
Ship the current uncommitted changes in this repo as a self-merging PR. The
change is described as: **$ARGUMENTS**
Run all git/gh commands from the repository root. Follow these steps exactly,
stopping and reporting if any step fails:
1. **Survey.** `git status --short` and `git diff --stat`. Confirm there are
changes to ship. If `$ARGUMENTS` is empty, infer a concise description from
the diff.
2. **Test first (project rule).** Run `swift test`. If anything fails, stop and
show the failure — do not commit.
3. **Branch.** If currently on `main`, create a topic branch named
`fix/…`, `feature/…`, `ci/…`, or `chore/…` as fits the change (kebab-case,
derived from the description). If already on a non-main branch, reuse it.
4. **Commit.** Stage only the files that belong to this change (surgical — no
unrelated files). Write a commit message: a concise imperative subject line,
a body explaining the *why*, and the trailer:
```
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
```
Keep it one logical change per commit.
5. **Push.** `git push -u origin <branch>`.
6. **Open the PR.** `gh pr create` with a title matching the subject and a body
that explains what and why, ending with:
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
7. **Auto-merge.** `gh pr merge <#> --auto --merge --delete-branch`. Branch
protection requires the `test` check, so this queues the PR to merge itself
the moment CI passes — no manual merge needed.
8. **Report.** Print the PR URL and state that it will merge automatically when
CI is green. Do **not** sit and poll CI unless asked.
Never force-push, never touch `main` directly, and never bypass the failing-test
stop in step 2.
@@ -0,0 +1,365 @@
---
name: edmund-architecture-contract
description: >
The load-bearing design contract of the Edmund Markdown editor. Load BEFORE
any non-trivial code change in this repo; when asking "why is it built this
way"; before proposing a new mechanism, subsystem, or refactor; whenever you
are tempted to insert/strip display characters, use NSTextAttachment, touch
NSTextView.layoutManager, store NSTextBlock/NSTextTable attributes, or add a
new overlay/decoration; and before designing anything that syncs storage,
selection, undo, or the viewport. Covers the two hard invariants
(storage == rawSource; TextKit 2 only), the render pipeline, edit/undo flow,
the TextKit 2 drawing model, the read-mode contract, and the known weak
points. Not for build/run/release mechanics, debugging triage, or live-repro
drivers — see "When NOT to use this skill".
---
# Edmund architecture contract
Edmund is a native macOS Markdown editor with live preview: AppKit +
TextKit 2, SwiftPM, macOS 14+. Two targets (`Package.swift`):
| Target | Role |
| --- | --- |
| `EdmundCore` | Library: parsing, rendering, `EditorTextView`, all tests. Most work happens here. |
| `edmd` | Executable: `NSDocument` app shell, Settings (SwiftUI), menus. Note: `edmd` is the Mach-O binary name; the app is "Edmund". |
Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of
Markdown editors". Bias toward polish of the editing experience over feature
count. The hardest live problem class to date is **delete-drift**
(caret/selection integrity, 6 investigation rounds); the costliest failures
were delete-drift and undo/redo viewport drift. Every rule below traces to one
of those scars.
Ground truth this file distills: `docs/ARCHITECTURE.md` (repo root).
Treat that doc as authoritative if the two ever disagree, and fix this skill.
## Glossary (each term defined once)
- **rawSource** — the document's Markdown text, the single source of truth
(`EditorTextView.rawSource`).
- **storage** — the `NSTextStorage` the text view displays
(`EditorTextStorage`, `Sources/EdmundCore/TextView/EditorTextStorage.swift`).
- **Block** — one logical Markdown block (paragraph, heading, list run, code
fence, table, quote/callout run). Model: `Sources/EdmundCore/Model/Block.swift`.
- **Active block** — the block under the caret; it renders its *raw* markdown
(delimiters visible/editable) while all others render styled.
- **Recompose** — restyling storage from `blocks`
(`Sources/EdmundCore/TextView/EditorTextView+Composition.swift`).
- **Fragment** — an `NSTextLayoutFragment`, TextKit 2's per-paragraph layout
unit. Off-screen fragments have *estimated* heights until laid out.
- **Overlay** — an image or stroked path drawn at a character's laid-out
position by the custom fragment class (see §4), replacing what
`NSTextAttachment` would do in TextKit 1.
- **Delete-drift** — the bug class where `rawSource`/storage/selection desync
and every later edit lands the caret in the wrong place. Chronicle:
`docs/investigations/delete-drift-investigation.md`.
- **IME composition** — an input method's provisional "marked text"
(`hasMarkedText()`), present in storage before the user commits it.
---
## 1. The two non-negotiable invariants
Break either and the editor misbehaves in subtle, delayed ways. Every design
review starts here.
### Invariant 1 — storage == rawSource (attribute-only rendering)
The displayed text storage is *always* character-identical to `rawSource`.
Rendering only ever adds/changes **attributes**. Delimiters (`**`, `` ` ``,
`[!note]`, …) are **hidden, never stripped**: `hiddenFont` (0.01pt system
font, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55`) plus a
clear `foregroundColor` makes them invisible without touching the string.
**Rationale.** Identity mapping between display offsets and raw offsets means
there is no offset-translation layer — caret math, selection, undo diffs,
incremental reparse, and autosave all operate on one coordinate system.
**Consequences you must respect:**
| Consequence | Why |
| --- | --- |
| No `NSTextAttachment`, ever | TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which `rawSource` never contains. Images, math, bullets, checkboxes, icons are drawn as **overlays** instead (§4). |
| No inserted display characters | A synthesized `<br>`, bullet glyph, or padding character would desync offsets. Use attributes (`.kern`, paragraph styles) or overlays. |
| Never mutate storage while IME is composing | During composition, storage holds marked text so the invariant is *transiently* false and `didChangeText` defers syncing. Styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard `!hasMarkedText()`. |
**The incident.** The original delete-drift bug: an async restyle fired during
IME composition, stranded the marked text, `didChangeText` kept bailing on its
own guard, and the invariant stayed silently broken — caret drift on every
subsequent edit. `becomeFirstResponder` now resyncs from storage as a
catch-all. Full write-up: `docs/investigations/delete-drift-investigation.md`.
### Invariant 2 — TextKit 2 only
Never touch `NSTextView.layoutManager` (the TextKit 1 `NSLayoutManager`
accessor) and never store `NSTextBlock`/`NSTextTable` attributes. Either one
**silently and permanently reverts the view to TextKit 1** — no error, no log,
just different (and wrong-for-us) layout from then on.
**Rationale.** All custom drawing rides `NSTextLayoutFragment` subclassing
(§4), and viewport-based layout (only on-screen content laid out) is what
makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback
kills them.
**The tripwire.** DEBUG builds observe
`NSTextView.willSwitchToNSLayoutManagerNotification` and `assertionFailure`
if the fallback ever triggers —
`textKit1FallbackTripwire(_:)`,
`Sources/EdmundCore/TextView/EditorTextView.swift:272-303`. If you see that
assertion, some code path you touched used a TK1 API or attribute. Find it;
do not suppress the assert.
Corollary: Edit-mode tables cannot use `NSTextTable`. Alignment is done by
distributing slack via `.kern` on hidden pipe glyphs —
`Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift`.
---
## 2. Render pipeline
```
rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage
└─ SyntaxHighlighter (swift-markdown walker
+ custom parsers: callouts, ==highlight==,
wikilinks, comments, footnotes, math,
backslash escapes, inline HTML tags)
```
| Stage | Where |
| --- | --- |
| Block splitting | `Sources/EdmundCore/Parsing/BlockParser.swift` — `parse(_:previous:)`, `parseWithDiff(...)` |
| Span production | `Sources/EdmundCore/Parsing/SyntaxHighlighter.swift` + `+Walker.swift` / `+WalkerInline.swift` / `+CustomParsers.swift` |
| One-block render | `styleBlock(_:cursorPosition:...)`, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151`; per-feature extensions in `Sources/EdmundCore/Rendering/` (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks) |
| Orchestration | `Sources/EdmundCore/TextView/EditorTextView+Composition.swift` |
Recompose entry points (pick the narrowest that works — a full `recompose`
resets every fragment height to an estimate, see §6):
| Function | Scope | Used for |
| --- | --- | --- |
| `recompose(cursorInRaw:)` | Whole document | Load, indent — never for undo (§3) |
| `recomposeDirty(_:cursorInRaw:)` | A set of block indices, in place | The workhorse; attribute-only |
| `recomposeIncremental(cursorInRaw:...)` | The block(s) the caret moved between | Most cursor moves |
| `recomposeReplacing(oldRange:with:...)` | One contiguous text span | Undo/redo restore |
**Lazy styling** (`Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift`):
a large dirty set styles only the viewport synchronously; the rest is finished
by the **idle drain** (time-budgeted main-thread slices) and **scroll
promotion** (style blocks as they enter the viewport).
Gotcha: attribute-only changes do **not** re-measure geometry in TextKit 2.
If a restyle changed a block's height/indent, call `invalidateLayout(for:)`
on its range or the fragment keeps a stale frame. `recomposeDirty` and the
idle drain already do this; any new path must too.
---
## 3. Edit flow & undo
Normal edit: `shouldChangeText` (records a coalesced undo snapshot) →
NSTextView mutates storage → `didChangeText` syncs `rawSource` and restyles
the edited block(s) (`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`,
`+Composition.swift`). Edits capture a `pendingEdit` on `EditorTextStorage`
and reparse a window, not the whole document.
**Undo/redo is custom** (`Sources/EdmundCore/TextView/EditorTextView+Undo.swift`):
stacks of `rawSource` snapshots, bypassing NSTextView's built-in undo.
Restoring diffs the snapshot against current text (`textDiff(old:new:)`,
single contiguous span) and applies it with the range-bounded
`recomposeReplacing` — **never a full `recompose`**, because a full recompose
resets every fragment to a TextKit 2 height estimate and the follow-up scroll
lands wrong (this was the undo/redo viewport-drift failure). The changed text
drives the viewport: hold if any of it is on-screen, else center it.
**AppKit does NOT pair every storage mutation with `didChangeText`.** Proven
incident (delete-drift round 4): a drag-move of selected text dropped on no
valid target deletes the dragged range via `shouldChangeText` →
`replaceCharacters` and never calls `didChangeText` — silently freezing
`rawSource`/`blocks`; every later edit drifts the caret and autosave writes
stale text. The heal: `shouldChangeText` schedules a next-run-loop
**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`) — a
`pendingEdit` still unconsumed by then means the closing `didChangeText`
never came, and the editor runs the same sync itself. Breadcrumb in
`~/.edmund/logs`: `healing storage edit that bypassed didChangeText`.
**Never build a sync path on the assumption that `didChangeText` follows
every edit.**
Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection
fixup (`_fixSelectionAfterChangeInCharacterRange:`) queued; it fires at the
**next** `endEditing` — even an attribute-only restyle — and leaps the caret
blocks away, moving even a freshly set valid caret. The heal sets the caret
before the sync **and re-asserts it after** (`+EditFlow.swift`). This class
does not reproduce headless; see the routing in §8.
---
## 4. TextKit 2 drawing model
All custom visuals are drawn by `DecoratedTextLayoutFragment` (custom
`NSTextLayoutFragment`,
`Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160`), vended via
the layout-manager delegate. Two custom attribute keys (same file, lines
28/32):
| Attribute | Level | Draws | Rules |
| --- | --- | --- | --- |
| `.blockDecoration` | Paragraph | Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds | Fragments **tile vertically** so a multi-line run reads as one continuous box/bar. A box's `bottomPad` grows the **last** fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space. |
| `.fragmentOverlay` | Character | An image **or stroked vector path** at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) | The anchor glyph is hidden (`hiddenFont` + clear color) and `.kern` reserves the drawing's advance width — the same trick the table renderer uses. |
**The image-wedge constraint (open, not solved).** Drawing an *image* overlay
on a *multi-line (wrapping)* fragment re-triggers a layout pass that wedges
the fragment to one line. Drawing a *shape* (stroked `CGPath`) does not.
That is why the wrapping callout custom-title icon is a stroked path parsed
from vendored Lucide geometry, never an image. **Any new overlay that could
share a line with wrapping text must be a shape, not an image.** Full saga:
`docs/investigations/archives/callout-title-wrap-investigation.md`.
---
## 5. Read mode contract
Read mode is a separate `WKWebView`, not an editor styling mode
(`Sources/EdmundCore/Export/`). The contract: **one parser, two back-ends** —
the *same* swift-markdown `Document` the editor parses is walked by
`SyntaxHighlighter.SpanCollector` (→ editor attributes) and by `HTMLRenderer`
(→ HTML), themed from the *same* `EditorTheme` via `HTMLTheme`, so the two
renderings cannot drift. When adding a feature, implement it in **both**
back-ends or document the divergence.
Hard properties of the web view (keep them):
- **JavaScript disabled**; every asset inlined (math as high-DPI PNG data
URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs
no file/network reach. Remote images off by default
(`Sources/EdmundCore/Export/ReadRenderOptions.swift`).
- **Private URL schemes** route navigation without JS:
`x-edmund-wiki:` / `x-edmund-link:`
(`Sources/EdmundCore/Export/HTMLRenderer.swift:26,31`).
- Inline HTML: only the whitelist `SyntaxHighlighter.htmlFormatTags`
(`u`/`kbd`/`mark`/`sub`/`sup`,
`Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22`) renders in either
mode; everything else stays escaped/color-only. A real `<br>` break would
need to mutate storage — forbidden by Invariant 1.
- Export/Print run the same HTML through `WKWebView.printOperation`
(`Sources/EdmundCore/Export/MarkdownPrinter.swift`) for vector text.
---
## 6. Known weak points (open as of 2026-07-05)
State these plainly when designing near them; none is solved.
1. **TextKit 2 height estimates** are the root of most viewport glitches: an
off-screen fragment's frame (and the total document height) is an estimate
until layout reaches it — scroller jumps, scroll-to-target lands wrong (a
documented TK2 limitation; TextEdit shows it too). Mitigations in place,
not cures: documents ≤ `fullLayoutMaxLength` (100k UTF-16,
`Sources/EdmundCore/TextView/EditorTextView.swift:80`) are kept fully laid
out by `scheduleFullLayoutSettle()` wrapped in `preservingViewportAnchor`
(`+LazyStyling.swift:121`, `+TypewriterScroll.swift:22`);
`repairContentAboveOrigin()` (`+LazyStyling.swift:151`) fixes content
stranded above y=0; `centerViewportOnCaret` re-measures after its first
scroll. **Never trust an off-screen fragment's y-coordinate without laying
out the span first.**
2. **The image-wedge constraint** (§4) applies to every new overlay.
3. **Open bugs** (`misc/backlog.md`): callout at end-of-file renders an extra
un-prefixed line in the callout color (live incremental-restyle path, not
static rendering); footnotes don't render in either mode; attached images
create blank space below; math doesn't render in read mode (and has wrong
padding in edit mode); delete caret drift and viewport-estimate glitches
remain on the ongoing list.
4. **Crash reporter endpoint is a placeholder**:
`CrashReporter.reportingEndpoint` is `https://REPLACE-ME.invalid/crash`
(`Sources/EdmundCore/Diagnostics/CrashReporter.swift:27`) and the
Settings ▸ Advanced toggle is commented out
(`Sources/edmd/Settings/AdvancedSettingsView.swift`). Do not treat crash
uploading as live.
---
## 7. Before you design something new — checklist
Run this before proposing any new mechanism, subsystem, or refactor:
- [ ] **Invariant 1**: does it insert/strip characters, use
`NSTextAttachment`, or mutate storage outside the
shouldChangeText→didChangeText path (or during IME composition)? If
yes, redesign as attributes/overlays.
- [ ] **Invariant 2**: does it touch `NSTextView.layoutManager` or store
`NSTextBlock`/`NSTextTable`? If yes, stop.
- [ ] **Sync assumptions**: does it assume `didChangeText` follows every
mutation, or that a set caret stays put across the next `endEditing`?
Both assumptions are proven false (§3).
- [ ] **Geometry**: does it read an off-screen fragment frame, or restyle
without `invalidateLayout(for:)` when height changed? (§2, §6.)
- [ ] **Both back-ends**: does a rendering feature cover Edit *and* Read
(§5)?
- [ ] **Prior art**: check ARCHITECTURE.md §14 — especially
[nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine),
an independent AppKit+TextKit 2 live-preview engine solving the same
problems — before inventing a new mechanism for an editing-experience
problem.
- [ ] **Weak points** (§6): does the design lean on anything listed there?
Label it as such; unproven mitigations are "open/candidate", never
"fixed".
- [ ] **Verification plan**: unit test if headless can repro; otherwise plan
a live repro (ReproScript) — do not ship a caret/IME/viewport fix on
reasoning alone. Visual claims are measured from `screencapture`
pixels, not eyeballed.
Process rules live in sibling skills, but never contradict them here: branch
per fix off `main`; never auto-push/PR/merge; `swift test` green + visual
verification before commit; never blanket `pkill -x edmd` (the maintainer's
daily-driver app shares the binary name — `pgrep` and kill only your own
PID); never request macOS Computer Access permissions.
---
## When NOT to use this skill
| You need… | Use instead |
| --- | --- |
| Whether/how to gate a change, commit discipline, scope control | `edmund-change-control` |
| A symptom → cause triage path for a bug you're seeing | `edmund-debugging-playbook` |
| The blow-by-blow history of a past investigation | `edmund-failure-archaeology` |
| TextKit 2 / AppKit theory beyond Edmund's specific contract | `textkit2-appkit-reference` |
| Launch flags, debug bundles, defaults keys | `edmund-config-and-flags` |
| Build, stale-binary cures, screencapture mechanics, environment setup | `edmund-build-and-env` |
| Cutting a release, Sparkle/appcast/CI | `edmund-release-and-operate` |
| Driving a live repro (ReproScript, CGEvent, log tracing) | `edmund-live-repro-and-diagnostics` |
| Test-writing patterns, QA passes | `edmund-validation-and-qa` |
| Docs style, ARCHITECTURE.md upkeep | `edmund-docs-and-writing` |
| Positioning, comparisons, marketing claims | `edmund-external-positioning` |
| The caret/selection-integrity campaign specifically | `edmund-caret-integrity-campaign` |
| How to investigate an unknown (method, not facts) | `edmund-research-methodology` / `edmund-research-frontier` |
---
## Provenance and maintenance
Facts verified against the repo on 2026-07-05. If a grep below stops
matching, the fact drifted — update this file and cite the new location.
```bash
# Invariant 2 tripwire still present
grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift
# Recompose entry points
grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift
# Bypass heal
grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
# Custom draw attributes + fragment class
grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift
# hiddenFont hiding trick
grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift
# Viewport mitigations
grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift
grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift
# Read-mode schemes + HTML whitelist
grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift
grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift
# Crash-reporter placeholder (delete §6.4 once this is a real URL)
grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift
# Open-bug list
sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md
```
@@ -0,0 +1,258 @@
---
name: edmund-build-and-env
description: >
Build, environment, and toolchain runbook for the Edmund repo (native macOS
Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting
up the environment from scratch (fresh clone, new machine, CI mirror); a
build fails or a "successful" build behaves stale (change "doesn't take",
old code runs, Build complete! but nothing changed); the app crashes on
launch or the instant it renders LaTeX; you need to construct the debug
bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just
built for a visual check or repro run. Covers swift build/test,
build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement),
the stale-build disease and its detection, safe launch/kill hygiene around
the user's live instance, and the CI environment.
---
# Edmund — build & environment runbook
All paths relative to the repo root.
Facts date-stamped 2026-07-05 are volatile — re-verify per the last section.
## When NOT to use this skill
| You actually need | Go to |
|---|---|
| The storage==rawSource / TextKit-2-only invariants, render pipeline | `edmund-architecture-contract` |
| Branch/commit/PR rules, what you may touch | `edmund-change-control` |
| Diagnosing a bug (not the build) | `edmund-debugging-playbook` |
| ReproScript / CGEvent live-repro driving | `edmund-live-repro-and-diagnostics` |
| Launch flags, defaults keys, settings | `edmund-config-and-flags` |
| Cutting a release, DMG, Sparkle appcast | `edmund-release-and-operate` |
| Screencapture verification method, test policy | `edmund-validation-and-qa` |
## 1. Environment from scratch
Requirements (verified 2026-07-05):
| Tool | Version | Why | Check |
|---|---|---|---|
| macOS | 14+ | `Package.swift` platforms `.macOS(.v14)` | `sw_vers` |
| Xcode | 16+ (full Xcode, not just CLT) | `swift-tools-version: 6.0`; `build-app.sh` needs `actool` | `swift --version` (local: Swift 6.0.3) |
| gh CLI | any recent | releases, PR ops | `gh --version` |
| Node | ≥20 | releases only | `node --version` |
| create-dmg | **npm** package | releases only | `npm install --global create-dmg` |
**create-dmg trap**: install via npm, NOT Homebrew. `brew install create-dmg`
is a *different tool* with an incompatible CLI. Not needed for dev work —
only for cutting releases (see `edmund-release-and-operate`).
Dependencies are fetched by SPM on first build — nothing to install by hand
(verified against `Package.swift` / `Package.resolved`, 2026-07-05):
- `swift-markdown` ≥0.5.0 (CommonMark/GFM parsing; pulls `swift-cmark` transitively)
- `SwiftMath` ≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3)
- `Sparkle` ≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4)
Two SPM targets: **EdmundCore** (library + all tests; most work happens here)
and **edmd** (the app shell executable). The binary is named `edmd` even
though the app presents as "Edmund" — deliberate, see the comment in
`Package.swift`.
## 2. Core commands
```bash
swift build # debug build of both targets
swift test # full suite: ~750+ tests, ~10s (2026-07-05)
swift test --filter Callout # one suite
./scripts/build-app.sh # release build → build/Edmund.app
```
`swift test` also runs automatically as a Stop hook after code-touching turns.
## 3. What build-app.sh actually does (and why the order matters)
`scripts/build-app.sh``build/Edmund.app`. Steps, in order:
1. `swift build -c release`
2. Assemble `build/Edmund.app/Contents/{MacOS,Resources}`; copy
`.build/release/edmd`, `Info.plist`, `Resources/AppIcon.icns`.
3. Compile `Resources/Assets.xcassets` with `actool` (falls back to
`/Applications/Xcode.app/.../actool` if xcode-select points at the CLT).
4. Embed `Sparkle.framework` into `Contents/Frameworks/` (found under
`.build/`; SwiftPM links Sparkle but never copies the framework — without
it the updater crashes on first check) and `install_name_tool -add_rpath
"@executable_path/../Frameworks"` so `@rpath` resolves post-install.
5. Codesign **inside-out**: Sparkle.framework first (nested XPC helpers must
be signed before macOS will launch them), then the whole `.app` (ad-hoc,
`--deep`, identifier `com.i7t5.edmd`). Sealing the *bundle* — not just the
binary — is what Sparkle's update validator requires.
6. **Only after sealing**: copy `.build/release/*.bundle` (SwiftMath's math
fonts) into the `.app` **root**.
Why step 6 is last and at the root — two constraints collide:
- `codesign` refuses to seal a bundle with any extra item at the `.app` root
("unsealed contents present in the bundle root"), so the seal must happen
while the root holds only `Contents/`.
- SwiftMath's generated `Bundle.module` accessor hardcodes
`Bundle.main.bundleURL` — the `.app` root — with only a hardcoded absolute
`.build` path as fallback. So the bundle *must* sit at the root.
Resolution: seal first, copy after. The one unsealed root item makes
`codesign --verify` and `--strict` complain, but Sparkle's actual check is
non-strict and tolerates it (verified end-to-end; details in
`docs/ARCHITECTURE.md` §8).
**Missing SwiftMath bundle = instant crash the moment the app renders any
LaTeX.** App launches fine, opens documents fine, dies on the first math
block. If you see that crash, check `ls build/Edmund.app/*.bundle` first.
## 4. Debug bundle fast path (EdmundDbg.app)
For live runs of a *debug* build, skip `build-app.sh` and hand-assemble
(from `docs/dev-guides/live-repro-guide.md` §4):
```bash
swift build
mkdir -p build/EdmundDbg.app/Contents/MacOS
cp Info.plist build/EdmundDbg.app/Contents/
cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/
cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/
```
- **Sparkle.framework must sit next to the binary** — dyld aborts without it.
- A bare `.build/debug/edmd` runs but **never creates a window**. It needs
the bundle (Info.plist) around it.
- **Launch by direct exec of the bundle binary, never `open -a`**:
```bash
build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \
-ApplePersistenceIgnoreState YES &
```
LaunchServices (`open -a`) can silently run a stale cached/translocated
copy — you'd be executing last hour's code. Direct exec runs exactly the
binary you just copied. `-ApplePersistenceIgnoreState YES` stops state
restoration from reopening previous (possibly mutated) documents.
- Recreate the test document fresh before every run — autosave mutates it.
## 5. THE STALE BUILD DISEASE
The single most expensive trap in this repo: it has produced entire wrong
debugging conclusions ("my fix doesn't work" when the fix was never in the
binary).
**Symptom**: `swift build` prints `Build complete!` having compiled a changed
file but **not relinked `edmd`**. The app then runs old code. Release builds
(`swift build -c release` / `build-app.sh`) reuse stale objects too.
**Detection — before trusting ANY binary you just built:**
```bash
# 1. Grep for a LONG string literal unique to your new code:
strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal'
# 2. Hash before/after the build:
shasum .build/arm64-apple-macosx/debug/edmd
```
**Literal length matters**: string literals ≤15 bytes are stored inline in
the Mach-O on arm64 and *never appear* in `strings` output. A short probe
literal gives a false "stale" verdict. Use a long one (a distinctive log
message works well).
**Cure:**
```bash
swift package clean # first resort
rm -rf .build # visual change "doesn't take" → nuke it all
```
**Never hand-delete `.build/…/edmd.build/`** — that corrupts SwiftPM's
output-file-map and wedges the target until a full clean anyway.
## 6. Running for visual checks — launch/kill hygiene
**The user's daily-driver app has the same binary name (`edmd`).** A blanket
`pkill -x edmd` kills their live session. Always, in order:
```bash
# 1. Who is running, and since when?
pgrep -lx edmd
ps -o lstart=,command= -p <pid>
# 2. Kill ONLY your own PID — or, if you launched the debug bundle:
pkill -f EdmundDbg
```
Other run gotchas:
- `open Edmund.app` **foregrounds a running instance instead of
relaunching** — you'll be looking at the old binary. Kill your instance
first or direct-exec the binary.
- Always pass `-ApplePersistenceIgnoreState YES` (see §4).
- After many rapid launch/kill cycles the window server can glitch (tiny
windows, broken state restoration):
`rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState`
and relaunch.
- Verification method (window-id screencapture, offscreen render fallback):
`edmund-validation-and-qa` and `docs/ARCHITECTURE.md` §8.
## 7. CI environment
`.github/workflows/ci.yml` (verified 2026-07-05): runs `swift test` on
`macos-14` with `latest-stable` Xcode (Swift 6.0 needs Xcode 16+), triggered
on PRs and pushes to `main`.
- **SPM cache**: `.build` is cached keyed on
`spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}`. The `v2`
token exists because the repo rename `md``Edmund` changed the checkout
path and invalidated absolute paths baked into the cached module cache —
bump the token to discard a poisoned cache.
- **Concurrency**: `cancel-in-progress: true` per branch/PR — private-repo
macOS minutes bill at 10x, so superseded commits' runs are cancelled.
- Release pipeline (`release.yml`, tag-triggered) is a separate beast:
`edmund-release-and-operate` / `docs/ARCHITECTURE.md` §13.
## 8. Checklists
### Fresh clone to green
- [ ] `sw_vers` — macOS 14+; `swift --version` — Swift 6.x (Xcode 16+)
- [ ] `git clone` + `cd Edmund`
- [ ] `swift build` — SPM fetches swift-markdown, SwiftMath, Sparkle
- [ ] `swift test` — ~750+ tests green in ~10s
- [ ] Read `docs/ARCHITECTURE.md` (mandated by CLAUDE.md before non-trivial work)
- [ ] Visual work planned? `./scripts/build-app.sh`, confirm
`build/Edmund.app` exists and `ls build/Edmund.app/*.bundle` shows the
SwiftMath bundle
- [ ] Releases planned? `node --version` ≥20, `npm install --global create-dmg`
### Before trusting any run
- [ ] Binary is fresh: `strings <binary> | grep '<long unique literal>'`
and/or `shasum` changed since the edit (§5)
- [ ] `pgrep -lx edmd` — user's live instance identified; you will kill only
your own PID (§6)
- [ ] Launched by direct exec, not `open -a` (§4)
- [ ] `-ApplePersistenceIgnoreState YES` passed
- [ ] Test document recreated fresh (autosave mutated the last one)
- [ ] Debug bundle: Sparkle.framework sits next to the binary
- [ ] Release bundle: SwiftMath `*.bundle` at the `.app` root (or the first
LaTeX render crashes)
## Provenance and maintenance
Every claim above was read from the files below on 2026-07-05. Re-verify:
- Toolchain/deps: `cat Package.swift` (tools-version, platforms, dep
versions); `grep identity Package.resolved`
- Build anatomy: `cat scripts/build-app.sh` (step order, sealing comments)
- Test count/time: `swift test 2>&1 | tail -3`
- Debug bundle recipe: `docs/dev-guides/live-repro-guide.md` §4
- Stale-build disease, launch gotchas: `docs/ARCHITECTURE.md` §8
("Stale release builds", "open Edmund.app", savedState)
- CI facts: `cat .github/workflows/ci.yml` (cache key comment, concurrency)
- create-dmg quirks: `docs/ARCHITECTURE.md` §8 ("create-dmg — npm only")
If a command here disagrees with those files, the files win — update this
skill.
@@ -0,0 +1,267 @@
---
name: edmund-caret-integrity-campaign
description: >
The executable, decision-gated campaign for Edmund's hardest live problem:
the delete-drift class (caret/selection integrity in the live NSTextView /
TextKit 2 / input-context layer). Load when the caret or selection lands in
the wrong place after a delete/type/IME/drag interaction, when text edits
corrupt or desync, when autosave writes stale content, or when logs show
⚠︎LEN-MISMATCH or the "healing storage edit that bypassed didChangeText"
breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify,
fence off six settled battles, capture evidence, build a deterministic repro,
pick from a ranked solution menu with proof obligations, then validate and
promote through change control. Not for viewport/scroll drift (that's
edmund-debugging-playbook → viewport docs) unless the caret itself moves.
---
# Caret-integrity campaign (the delete-drift class)
Six rounds are settled; **new rounds are expected**. This class does **not
reproduce in headless tests** — the harness runs AppKit's deferred machinery
synchronously, so a green unit test proves nothing here. Success is measured by
`PASS/FAIL` grep and byte counts, **never by eye or by reasoning**.
Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md` (456 lines) and
the code. Read that doc fully before a deep dive.
---
## QUICK CARD (the whole loop)
```
0. CLASSIFY grep logs for the 4 signatures. Not this class? → debugging-playbook.
1. FENCE check the 6 settled battles + guards still hold. Don't re-fight them.
2. EVIDENCE run with verbose diags; find FIRST bad line; walk BACKWARDS;
traceSelectionOrigin names who moved the caret; rebuild the doc.
3. REPRO ReproScript: needles not offsets; real events; replicate internal
call sequences verbatim. Freeze a script whose logsel/assertcaret
DISCRIMINATES broken vs current build. No repro → hypothesis wrong.
4. SOLVE rank candidates (missing guard < extend heal < reorder fixup <
structural). Each states what it must PROVE.
5. PROMOTE fix flips frozen repro to PASS same run → soak 45 cycles,
byte-identical rawLen → swift test green → add test (locks headless
contract even if non-discriminating) + keep .repro → update the
investigation doc → branch/commit via change-control. Never ship on
reasoning alone.
```
---
## PHASE 0 — Classify: is it actually this class?
Grep `~/.edmund/logs/edmund-<date>.log` (run the app with
`-settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES`):
| Signature | Meaning |
|---|---|
| `healing storage edit that bypassed didChangeText` | a bypass fired (round-4 class) |
| **persisting** `⚠︎LEN-MISMATCH` (not just transient between shouldChangeText→synced) | storage/rawSource desync stuck |
| `selectionDidChange` with `up=Y` at a surprising position | selection moved mid-recompose (round-6 class) |
| a `shouldChangeText` with **no** `synced`/`SKIPPED`/`DEFERRED` after it | a bypassed `didChangeText` |
`scripts/grep-trace.sh` in **edmund-live-repro-and-diagnostics** surfaces all
four at once.
**If none match and the symptom is scroll/viewport-shaped** (jump, wrong
landing, can't-scroll-up) with the caret *not* leaping → this is the viewport
class, not caret integrity → **edmund-debugging-playbook** +
`docs/investigations/viewport-glitch-investigation.md`. Do not run this campaign on a viewport
bug.
---
## PHASE 1 — Fence off the six settled battles
Confirm each shipped guard **still holds** before hypothesizing a new mechanism.
The most likely round-7 shape is a **new code path that lacks an existing
guard**, not a new mechanism.
| Round | Mechanism (settled) | The shipped guard — confirm it still covers your path |
|---|---|---|
| 13 | IME **marked-text stranding**: styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` while `hasMarkedText()` strands the composition; `didChangeText` then bails forever and every edit drifts | **every storage-touching styling path guards `!hasMarkedText()`** — including async ones scheduled *before* composition began (`+SelectionTracking` caret-move restyle). `becomeFirstResponder` resyncs as catch-all |
| 4 | **Drag-move bypass**: a drag-move whose drop has no valid target deletes via `shouldChangeText``replaceCharacters` with **no `didChangeText`**; `rawSource`/`blocks` freeze; autosave writes stale text | `scheduleBypassedEditSyncCheck` (`+EditFlow.swift`): a next-run-loop check finds an unconsumed storage `pendingEdit` and runs the sync (breadcrumb above) |
| 5 | **Heal leaped the caret**: the round-4 heal ran against a **stale** selection and moved the caret | heal collapses/derives the caret from the `pendingEdit` hull before syncing |
| 6 | **Queued selection fixup**: TK2's `_fixSelectionAfterChangeInCharacterRange` stays queued after a bypass and fires at the **next `endEditing`** (even an attribute-only restyle), remapping the **stale** selection and leaping even a *freshly set, valid* caret | heal sets the caret from the pendingEdit hull **before** the sync **AND re-asserts it after** (`+EditFlow.swift`) |
| 7 | **Same queued fixup on the NORMAL edit path** (not the heal): armed by a **cross-block caret move** (schedules the async caret-move restyle), the fixup fires during `syncRawSourceFromDisplay``recomposeDirty`'s `endEditing` on an ordinary keystroke and leaps the caret to the **block boundary**; the normal path styles `settingSelection=false` and never re-asserts, so it **persists** | `syncRawSourceFromDisplay` captures the pendingEdit-hull caret before `consumePendingEdit`, re-asserts it after `recomposeDirty` if the fixup moved it (`+EditFlow.swift`; breadcrumb `re-asserting caret after fixup leap (normal path)`) — **fix not yet confirmed by a deterministic repro**, live-verifiable via the breadcrumb |
Confirm the guards exist:
```bash
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
```
### FENCED WRONG PATHS (each proven dead — do not retry)
- **Fixing by nudging the caret at symptom time.** The symptom is armed
seconds-to-minutes *earlier* (round 6: drift at 22:13 armed at 22:11:57). You'd
patch the wrong instant.
- **Trusting a headless regression test.** The round-6 test passes **with and
without** the fix. Headless runs the fixup synchronously.
- **Shipping on reasoning alone.** Rounds 15 all came back. Round 6's first fix
candidate "worked by reasoning" and **failed in the repro within a minute**.
- **Assuming `didChangeText` pairs with every mutation.** AppKit violates this
(round 4).
- **Mutating storage mid-composition.** That is the original bug (rounds 13).
- **Expecting a scripted keystroke replay to arm round 7.** Round 7 was replayed
faithfully from a reconstructed `t0` (every keystroke + capped pauses through
the whole drift window) and it did **not** produce the block-end leap.
Programmatic caret moves (`clickoff`) and imprecise synthetic clicks
(`realclickoff` — lands off-by-a-few, diverges, crashes) do not fully arm it.
The arming needs **real mouse clicks at real positions** and probably the
real session's **two-document window switching** (`becomeFirstResponder`
resync is the prime suspect). Round-8 lead: two open docs + real clicks, or
instrument `becomeFirstResponder`/the caret-move restyle to catch the next
live occurrence. Compressed replays also coalesce a bypass with the next
keystroke into one `pendingEdit` hull (never happens live — the heal runs
first), producing off-by-one breadcrumb noise; don't trust it as a repro.
---
## PHASE 2 — Capture evidence
1. Launch with verbose diagnostics (debug bundle; file arg is `argv[1]`):
```bash
build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-ApplePersistenceIgnoreState YES
```
2. **Decode the trace fields:** `sel/active/marked/up/undo/blocks/storLen/rawLen`.
`up=Y` = event arrived mid-recompose (suspicious). Healthy ordering:
`shouldChangeText` → `selectionDidChange (up=N)` → `synced`; a transient
`⚠︎LEN-MISMATCH` between those is normal, a persisting one is not.
3. **Find the FIRST bad line, then walk BACKWARDS.** The visible symptom is often
the second half of a two-part mechanism.
4. **`traceSelectionOrigin`** logs the call stack of whoever moved the selection
mid-recompose — this is what named `_fixSelectionAfterChange` in round 6. If
the caret moves and you don't know who moved it, this answers it in one run.
5. **Reconstruct the document.** Wrapped-paragraph geometry and block kinds
matter — repro against a lookalike, never `"hello world"`.
**Gate:** you have a candidate trigger hypothesis + the first-bad-line timestamp.
Otherwise **instrument** (add a `Log.shouldTrace` breadcrumb — one call stack or
state dump beats ten speculative fixes) and wait for the next occurrence.
---
## PHASE 3 — Build the deterministic repro
Use the in-process **ReproScript** driver (`Sources/edmd/App/ReproScript.swift`,
DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands:
`sleep / caret / type / backspace / bypassdelete / assertcaret / logsel`.
**Worked example — the round-6 minimal repro** (the first deterministic repro in
six rounds):
```
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken build: 321 fixed build: 290
backspace 2
logsel
```
The deciding output was `logsel` **321 → 290** with the fix, every run, window
not even visible.
Rules that make repros survive editing:
- **Needles, not offsets** — offsets go stale the instant the script edits.
- **Real events, not method shortcuts** — `insertText("")` skips
`deleteBackward`'s selection machinery, exactly where round 6 lived.
- **Replicate AppKit-internal call sequences verbatim** — `bypassdelete` does
`shouldChangeText`→`replaceCharacters`, no `didChangeText`, not an
approximation. For a *new* internal path, pin its real sequence from a
`traceSelectionOrigin` stack first, then replay it (extend ReproScript ~10
lines).
**Gate:** a **frozen** script (exact commands + document) whose `logsel` /
`assertcaret PASS/FAIL` output **discriminates** the broken build from the
current one.
**No repro after honest attempts?** The hypothesis is wrong, or fidelity is too
low — a mouse-only path (real drag-select/drag-move) needs the **CGEvent driver**
(edmund-live-repro-and-diagnostics §4). Loop back to Phase 2.
---
## PHASE 4 — Solution menu (ranked; each with a proof obligation)
Pick by the observation pattern. Cheaper is higher.
1. **Add a missing guard on an existing invariant** (cheapest, most likely for a
new round). Guard inventory: `!hasMarkedText()` on the offending styling path;
bypass-check scheduling on a new mutation entry point; caret re-assertion
after a restyle. *Proof:* the frozen repro flips to PASS **and** no other
`.repro` regresses. *Selects when:* a specific new code path shows the
round-1/4/6 signature that its siblings already guard.
2. **Extend the heal to a new bypass source.** *Proof:* first add a ReproScript
command that replays the new source's **exact** call sequence, show it
reproduces the freeze, then show the extended heal fixes it. *Selects when:*
trace shows a `shouldChangeText` with no close-out from a path other than
drag-move.
3. **Intercept/reorder the queued fixup.** *Proof obligation:* explain **when**
TK2 queues and fires `_fixSelectionAfterChange` and show the reorder does not
fight AppKit's machinery (no oscillation, no double-move). *Selects when:*
`traceSelectionOrigin` names the fixup and the caret is valid before it fires.
Higher risk — you are stepping into private AppKit ordering.
4. **Structural: make rawSource sync independent of `didChangeText` pairing**
(e.g. a storage-version counter that reconciles regardless of which callbacks
fired). Biggest change; **candidate, unproven** — route through
**edmund-research-frontier** (frontier item "caret integrity by
construction"). *Proof:* all historical `.repro` scripts + a randomized
bypass-fuzzer soak stay green with the callback-pairing assumption removed.
---
## PHASE 5 — Validate and promote
1. Fix candidate **flips the frozen repro to PASS within the same run**. (If it
only "works by reasoning," it is not done — round history.)
2. **Soak** (edmund-live-repro-and-diagnostics §3): one script, one run, 45
trigger cycles at different document positions with ordinary editing between,
`assertcaret` after every predictable step. Green across all cycles **and
byte-identical final `rawLen` across repeated runs** = deterministic.
3. **`swift test` green** (also the Stop hook).
4. **Add a regression test even if it can't discriminate the live mechanism** —
it locks the headless contract (assert `rawSource == string`; see the
`BypassedEditSyncTests` / `MarkedTextDesyncTests` family). **Keep the `.repro`
script** in the repo for the live half.
5. **Update `docs/investigations/delete-drift-investigation.md`** with the new round (symptom →
root cause → repro recipe → fix → status), and `docs/ARCHITECTURE.md` §8 if an
invariant changed — **same PR**.
6. Route through **edmund-change-control**: branch `fix/…` off `main`, small
commits, **never auto-push/PR/merge**. (No screencapture unless the fix also
draws.)
**Never promote a fix that only "works by reasoning."**
---
## When NOT to use this skill
- Viewport/scroll drift where the caret itself does not leap →
**edmund-debugging-playbook** + `docs/investigations/viewport-glitch-investigation.md`.
- Just need the repro driver mechanics / trace decoding →
**edmund-live-repro-and-diagnostics**.
- The AppKit theory behind the mechanisms → **textkit2-appkit-reference**.
- The historical record of prior rounds → **edmund-failure-archaeology**.
- The structural (round-∞) redesign as a research project →
**edmund-research-frontier** + **edmund-research-methodology**.
---
## Provenance and maintenance
Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`,
`Sources/edmd/App/ReproScript.swift`, and
`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`.
```bash
grep -nE '^## Round' docs/investigations/delete-drift-investigation.md # round chronicle
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
# round-6 discriminating numbers (321 broken / 290 fixed):
grep -n '321\|290' docs/investigations/delete-drift-investigation.md
```
When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list.
@@ -0,0 +1,210 @@
---
name: edmund-change-control
description: >
How changes are classified, gated, and reviewed in the Edmund repo. Load at
the START of any task that will modify the repo; before committing, branching,
or proposing a merge or release; when deciding whether a change needs a test,
a screencapture visual check, or a live repro; when unsure what requires
explicitly asking the maintainer (push, PR, merge, release, deleting
uncommitted work, touching test-files/, adding dependencies). Contains the
non-negotiable rules with the historical incident behind each one.
---
# Edmund change control
Last verified: 2026-07-05, against `main` @ fe8a1f5 (release 0.1.3).
This is the gatekeeping doc: what class of change you are making, which gate it
must pass, and the rules that are never traded away. The rules exist because
each was paid for — the incident column is not decoration.
## 0. Before you start (task setup)
```
[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule)
[ ] git status — note any uncommitted work; never clobber it
[ ] Not on main? Fine. On main? Branch NOW, before the first edit
[ ] Classify the change (§1) so you know the gate before you write code
[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't
reproduce the bug, you can't prove the fix (§3, last row)
```
## 1. Classify the change, apply the gate
Classify FIRST, before writing code. The class decides the verification bar.
| Class | Examples | Gate before commit |
|---|---|---|
| Docs-only | ARCHITECTURE.md, README, docs/*.md, comments | None beyond review. `swift test` still runs as a Stop hook; ignore no failures it surfaces. |
| Code (logic) change | Parser, block model, helpers, non-drawing refactor | `swift test` green. New behavior or bug fix → add a test that fails without the change. |
| Visually-drawing change | Anything in `Rendering/`, overlays, decorations, padding, fonts, layout fragments | All of the above, PLUS build the app and `screencapture` the result (window-by-id method — see `edmund-live-repro-and-diagnostics`). Headless layout is not proof for anything that draws. |
| Edit-pipeline / selection behavior | `+EditFlow`, `+Composition`, `+SelectionTracking`, `+Undo`, caret, IME, drag, viewport timing | All of the above, PLUS a live repro or soak script (`-debug.reproScript`, see `edmund-caret-integrity-campaign` and `docs/dev-guides/live-repro-guide.md`). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 15 of delete-drift shipped on tests + reasoning; all recurred. |
| Release | Version bump, tag, appcast | Run `misc/before-you-release.md` top to bottom, then `misc/how-to-release.md`. See `edmund-release-and-operate`. Never start a release without being asked. |
Notes on the gates:
- `swift test` (~750+ tests, ~10s) also runs automatically as a **Stop hook**
(`.claude/settings.json`: `swift test 2>&1 | tail -5`) at the end of every
turn. That is a safety net, not the gate — run it yourself before committing
so the failure is yours to see, not the hook's.
- A change can be in multiple classes. Apply the union of gates. A caret fix
that also moves a decoration needs test + screencapture + live repro.
- "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a
human could see the diff, screenshot it.
Classification edge cases that have gone wrong before:
- "It's just an attribute change" is NOT automatically the code-logic class.
If the attributes change measured geometry (font size, paragraph spacing,
hidden-delimiter width), it draws AND it needs `invalidateLayout(for:)`
see §3.
- "It's just a restyle helper" that runs `beginEditing`/`setAttributes` on
storage is edit-pipeline class if it can fire during IME composition or
after a bypassed edit. When in doubt, grep for `hasMarkedText` guards on
the sibling paths and match them.
- A test-only change is docs-class for gating purposes (nothing to screenshot),
but the Stop hook still must pass — a broken test is a broken commit.
- Release-adjacent edits (`Info.plist` versions, `CHANGELOG.md`, `appcast.xml`,
`scripts/release.sh`, `.github/workflows/`) are release class even when tiny.
The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step,
not from app code.
## 2. Git discipline
From CLAUDE.md + ARCHITECTURE §12 + the repo's own history (`git branch -a`).
- **Branch off `main` for every fix. Never commit straight to `main`.**
One feature/fix per branch.
- Branch prefixes actually in use (verified): `fix/`, `feat/`, `feature/`,
`docs/`, `chore/`, `uiux/`, `ui/`, `ux/`, `markdown/`, `bug/`, `refactor/`,
`ci/`, `release/`. Prefer `fix/`, `feat/`, `docs/`, `chore/` for new work;
`uiux/` for visual polish; `markdown/` for syntax-feature work.
- **Small, logical commits; commit frequently.** A commit that mixes the fix
with a drive-by refactor is two commits done wrong.
- **NEVER auto-push, open a PR, or merge.** Only when the maintainer
explicitly asks. No exceptions for "it's just docs."
- **Never discard uncommitted changes.** No `git checkout -- .`,
`git reset --hard`, `git clean` on a dirty tree without explicit permission.
- Commit messages follow the observed style: `fix(editor): …`, `docs: …`,
`ui: …`, `chore: …` — short imperative subject.
## 3. The non-negotiables
Each rule was established by an incident. Verify against the cited doc before
arguing an exception.
| Rule | Rationale | Incident |
|---|---|---|
| Text storage always equals `rawSource`; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. | Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. | The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. `docs/investigations/delete-drift-investigation.md`. |
| TextKit 2 only. Never touch `NSTextView.layoutManager`; never store `NSTextBlock`/`NSTextTable` attributes. | Either one **silently and permanently** reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. | ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible. |
| No `NSTextAttachment`. Images/icons are drawn as overlays. | TK2 only honors attachments on U+FFFC, which `rawSource` never contains (see rule 1). | ARCHITECTURE §2, §5. |
| Never draw images on wrapping (multi-line) fragments; use stroked `CGPath`s instead. | A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. | The callout custom-title icon: `docs/investigations/archives/callout-title-wrap-investigation.md`; fix in ae61644 (stroked path, not image). |
| Every storage-touching styling path guards `!hasMarkedText()` — including async paths scheduled before composition began. | Mutating storage mid-IME-composition strands the marked text; `didChangeText` then bails forever on its own guard and every later edit drifts. | Delete-drift rounds 12 (IME stranding cascade). `docs/investigations/delete-drift-investigation.md`. |
| Never assume `didChangeText` follows every edit. Sync paths must survive a bypassed edit. | AppKit's drag-move-to-nowhere deletes via `shouldChangeText``replaceCharacters` and never calls `didChangeText`, silently freezing `rawSource`. The heal (`scheduleBypassedEditSyncCheck` in `+EditFlow`) exists for this. | Delete-drift round 4 (9f99795); rounds 56 hardened the heal itself. |
| Attribute-only restyles that change geometry must `invalidateLayout(for:)` the range. | TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. | ARCHITECTURE §8; `recomposeDirty` and the idle drain already do this — new paths must too. |
| Undo restore is diff-based `recomposeReplacing` — never a full `recompose`. | Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. | Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (`fix(undo): select + center the changed text; diff-based snapshot restore`). |
| Never blanket `pkill -x edmd`. `pgrep -x edmd` first, check start times, kill only the PID you launched. | The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's `pkill -x edmd` shorthand predates this rule — don't copy it.) | Established after the maintainer's own instance was killed during a debugging session. |
| Never request macOS Computer Access (Screen Recording / Accessibility). | Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. | CLAUDE.md "Environment"; the `-debug.reproScript` driver exists precisely so repros need no new TCC grants. |
| Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. | Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. | Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches). |
| Files in `test-files/` are the maintainer's manual test corpus. Never rewrite them for automation; `test-files/todo.md` especially is owner-edited. | They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or `Tests/`. | Standing maintainer rule. |
| Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. | This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. | Delete-drift rounds 15 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. `docs/dev-guides/live-repro-guide.md`. |
### The two incidents that shaped this table
Worth knowing as stories, because the rules read as pedantry until you see
the cost:
- **Delete-drift (six rounds).** The hardest live problem this repo has had.
A caret that drifted after deletes. Round 1 blamed IME stranding — plausible
fix, shipped, recurred. Round 2 disabled remaining marked-text sources —
recurred. Round 3 stopped guessing and built diagnostics (selection tracing,
event logs). Round 4's diagnostics caught a drag-move deleting storage with
no `didChangeText` — the heal was born. Round 5: the heal itself leaped the
caret via a stale selection. Round 6 found the actual drift mechanism —
TextKit 2's queued `_fixSelectionAfterChange` firing at the *next*
`endEditing` — and held only because the ReproScript driver could replay the
exact keystroke sequence deterministically. Five shipped fixes failed; the
one preceded by a frozen repro stuck. That asymmetry IS the change-control
policy for this bug class.
- **Undo/redo viewport drift.** Undo restored a snapshot via full `recompose`,
which reset every fragment to a TK2 height estimate; the follow-up
scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40)
diffs the snapshot against current text and applies only the changed span
with `recomposeReplacing`. Moral: in TK2, layout state is part of the
document state you must preserve — "re-render everything" is never the safe
fallback here, it is the bug.
## 4. Review expectations
- **ARCHITECTURE.md is updated in the same PR** whenever you learn something
non-obvious or change an invariant. The doc's own header demands this; the
gotchas in §8 all arrived this way.
- **Quirks are documented as comments at the code site** — the edge case, the
workaround, the *why*. Not in commit messages, not in CLAUDE.md.
- **New known issues** go in ARCHITECTURE §9 with a one-line repro and a
pointer to any deeper write-up in `docs/`.
- Big investigations (multi-round bugs) get a `docs/*-investigation.md`
chronicle — see `edmund-failure-archaeology` for the pattern.
## 5. Pre-commit checklist (copy-paste)
The workflow that worked (ARCHITECTURE §12 + CLAUDE.md). Run it verbatim:
```
[ ] swift test — all green (also enforced by the Stop hook; don't rely on it)
[ ] New behavior / bug fix → a test exists that fails without the change
[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG
[ ] Edit-pipeline / selection change? → live repro or soak script passed
[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main
[ ] Diff touches only what the task needs; style matches surroundings
[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change
[ ] Quirk introduced/found? → comment at the code site
[ ] Commit is small and logical; message matches repo style (fix(scope): …)
[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked)
```
## 6. Ask the maintainer first — always
Never do these unprompted; ask and wait for an explicit yes:
| Action | Why it's gated |
|---|---|
| `git push`, opening a PR, merging anything | Standing rule in CLAUDE.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges. |
| Starting or tagging a release | A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly. |
| Deleting anything uncommitted (files, stashes, working-tree changes) | "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree. |
| Editing anything in `test-files/` | Manual test corpus; `todo.md` there is owner-edited. Make fixtures elsewhere. |
| Adding a dependency | Current set is deliberately three (`swift-markdown`, `SwiftMath`, `Sparkle`); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8). |
| Changing `.claude/settings.json` hooks or permissions | Alters what runs automatically on the maintainer's machine. |
## When NOT to use this skill
| You need… | Go to |
|---|---|
| The invariants' full technical statement and render pipeline | `edmund-architecture-contract` |
| To debug a failure, read traces/logs | `edmund-debugging-playbook` |
| The history of a past incident in depth | `edmund-failure-archaeology` |
| TextKit 2 / AppKit API behavior details | `textkit2-appkit-reference` |
| Launch flags, debug bundle, settings | `edmund-config-and-flags` |
| Build issues, stale binaries, environment | `edmund-build-and-env` |
| Executing a release / operating the app | `edmund-release-and-operate` |
| The screencapture / ReproScript mechanics | `edmund-live-repro-and-diagnostics` |
| Test-writing patterns and QA strategy | `edmund-validation-and-qa` |
| Writing docs / chronicles | `edmund-docs-and-writing` |
| Caret/selection bug-class specifics | `edmund-caret-integrity-campaign` |
## Provenance and maintenance
- Sources: `CLAUDE.md` (repo root), `docs/ARCHITECTURE.md` §1 §2 §8 §9 §12,
`.claude/settings.json` (Stop hook), `misc/before-you-release.md`,
`misc/how-to-release.md`, `docs/investigations/delete-drift-investigation.md` (rounds 16),
`docs/investigations/archives/callout-title-wrap-investigation.md`, `git log` / `git branch -a` as of
fe8a1f5 (2026-07-05).
- Commits cited were verified in `git log`: 5bb2b40 (diff-based undo restore),
9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a
(round-6 caret re-assert).
- Two rules rest on maintainer statements rather than repo docs: the
measure-from-pixels rule and the `test-files/` ownership rule. If either gets
written into ARCHITECTURE.md, point at it here.
- Maintain: when a new incident produces a new rule, add a row to §3 with the
incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When
the Stop hook in `.claude/settings.json` changes, update §1's note.
@@ -0,0 +1,220 @@
---
name: edmund-config-and-flags
description: >
Catalog of every configuration axis in the Edmund Markdown editor — user
settings (UserDefaults keys, defaults, where consumed), launch arguments
(diagnostic + repro flags), compile-time gates, and logging config. Load
when adding or changing a setting, hunting which flag controls a behavior,
launching the app with debug flags, auditing defaults, or wiring a new
preference into the live editor. This skill drifts fastest of the set —
every table ends with a re-verification grep. Not for the invariants
(see edmund-architecture-contract), release flags (edmund-release-and-operate),
or how to READ the logs (edmund-live-repro-and-diagnostics).
---
# Edmund configuration & flags
Ground-truth catalog of every knob. **Code wins over docs** — every value
below was read from source on 2026-07-05; re-verify with the greps at the end
before trusting a value in a decision.
Two source-of-truth files:
- `Sources/edmd/Settings/AppSettings.swift` — every UserDefaults key + typed accessor.
- `Sources/edmd/Settings/*View.swift` (Appearance / General / Advanced) + `FontSettings` — the SwiftUI panes (`@AppStorage`).
Definitions used below: **UserDefaults** = macOS per-app persisted key/value store; **argument domain** = passing `-<key> <value>` on the command line overrides that default for one launch; **`@AppStorage`** = SwiftUI wrapper binding a view to a UserDefaults key.
---
## 1. User settings (UserDefaults keys)
Every key is a `static let` in `AppSettings.swift`. The key **string** (not the
Swift name) is what you pass as a launch arg.
| Swift name | Key string | Purpose |
|---|---|---|
| `reopenWindows` | `settings.general.reopenWindows` | Reopen last windows on launch |
| `startupAction` | `settings.general.startupAction` | What to do at startup (new doc / reopen / nothing) |
| `autoSaveWithVersions` | `settings.general.autoSaveWithVersions` | NSDocument autosave-in-place vs versions |
| `conflictResolution` | `settings.general.conflictResolution` | File-changed-on-disk handling |
| `suppressInconsistentLineEndingWarning` | `settings.general.suppressInconsistentLineEndingWarning` | Silence mixed-line-ending warning |
| `diagnosticLogging` | `settings.general.diagnosticLogging` | **On/off for file logging** (opt-out; see §4) |
| `logRetention` | `settings.general.logRetention` | Days of logs to keep (pruned on configure) |
| `appearanceMode` | `settings.appearance.mode` | Light / dark / system |
| `maxContentWidthCm` | `settings.appearance.maxContentWidthCm` | **Max column width, stored in CENTIMETRES** (see note) |
| `contentWidthUnit` | `settings.appearance.contentWidthUnit` | Display unit only (cm/in); the stored value is always cm |
| `renderBlankLinesAsBreaks` | `settings.reading.renderBlankLinesAsBreaks` | Read-mode blank-line handling |
| `sourceMode` | `settings.view.sourceMode` | When on, **Source replaces Edit** in the ⌘E toggle; honored on open |
| `verboseEditorDiagnostics` | `settings.advanced.verboseEditorDiagnostics` | **Verbose editor trace** (see §4; pairs with diagnosticLogging) |
| `sendCrashLogs` | `settings.advanced.sendCrashLogs` | Opt-in crash upload — **currently INERT** (see note) |
| `sentCrashReports` | `settings.advanced.sentCrashReports` | Dedup set of already-uploaded `.ips` filenames |
| `lastWindowHeight` | `settings.window.lastHeight` | Persisted window sizing (see the frame-not-content trap) |
| `automaticallyChecksForUpdates` | `SUAutomaticallyChecksForUpdates` | Sparkle's own key (not namespaced) |
**Content width (the physical-column design):** persisted as **centimetres**
(`maxContentWidthCm`); `contentWidthUnit` is a display unit only. The column is
an **absolute physical** cap converted to points via the display's real PPI
(`NSScreen.physicalPPI`, from `CGDisplayScreenSize`), applied as a symmetric
`textContainerInset.width`. Default is locale-aware — **5 in (US) / 12 cm
(elsewhere)** — and doubles as the slider's magnetic snap point. Recomputed on
resize and on `NSWindow.didChangeScreenNotification`. Consumer path lives in
`EditorTextView+ContentWidth.swift`.
**Window sizing trap:** persistence must round-trip the **frame** size, not the
`contentView.bounds` size — reapplying content size grows the window by the
title-bar + toolbar height on every reopen, and heights below `minSize` get
silently rejected. Save `window.frame.size`, reapply with `window.setFrame(_:)`
**after the toolbar is installed**. (Note: the key on disk is
`settings.window.lastHeight` — code, not the `lastWindowSize` some docs say.)
**Crash uploading is inert:** `sendCrashLogs` defaults off AND the Settings ▸
Advanced toggle is **commented out** in `AdvancedSettingsView.swift`, and
`CrashReporter.reportingEndpoint` is a `REPLACE-ME.invalid` placeholder
(`CrashReporter.swift:27`, `// TODO: real server`). Nothing uploads today.
Un-inert it only when a receiving server exists (see edmund-release-and-operate).
---
## 2. Launch arguments
macOS reads `-<UserDefaults-key> <value>` into the argument domain. Pass the
**key string** from §1, not the Swift name. The **file to open must be
`argv[1]`** (before the flags).
```bash
build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript SCRIPT.repro \
-ApplePersistenceIgnoreState YES
```
| Flag | Effect |
|---|---|
| `-settings.general.diagnosticLogging YES` | Turn on file logging for this run |
| `-settings.advanced.verboseEditorDiagnostics YES` | Emit the verbose editor trace (sel/active/marked/up/…) |
| `-debug.reproScript <path>` | **DEBUG builds only** — replay a keystroke script (`ReproScript.swift`) |
| `-ApplePersistenceIgnoreState YES` | Apple's flag — stop state restoration reopening mutated docs |
`-debug.reproScript` is the only Edmund-specific *debug* key; it does not have
an `AppSettings` accessor — it is read directly in `ReproScript.swift`. It is
compiled out of release builds.
---
## 3. Compile-time axes
`#if DEBUG` gates live in: `EditorTextView.swift`, `EditorTextView+EditFlow.swift`,
`Diagnostics/Log.swift`, `edmd/App/main.swift`, `edmd/App/ReproScript.swift`.
What they gate:
- **The TextKit-1 tripwire** (`EditorTextView.swift:273`+): a DEBUG observer on
`NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the view
ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is
silent and permanent (see edmund-architecture-contract).
- **ReproScript** — the whole in-process keystroke driver.
- **Log level threshold** — `Log.swift` compiles a lower floor in DEBUG
(`debug`+) than release (`info`+); see §4.
Named tuning constants (not user-facing, but they behave like knobs):
| Constant | Value | Where | Meaning |
|---|---|---|---|
| `fullLayoutMaxLength` | `100_000` | `EditorTextView.swift:80` | Docs ≤ this many UTF-16 units are kept **fully laid out** (below the TK2 estimate regime). Consumed at `+LazyStyling.swift:133`. |
---
## 4. Logging config
Read `Sources/EdmundCore/Diagnostics/Log.swift` and
`EditorTextView+Diagnostics.swift`.
- API: `Log.{debug,info,error}(_:category:)`, `Log.measure(_:) { … }`.
- File: `~/.edmund/logs/edmund-YYYY-MM-DD.log`, written on a private serial queue.
- Config flow: `AppSettings.applyLogging()` pushes the toggle + retention into
`Log.configure` at launch and on change; retention pruning happens there.
- **Two independent switches**: `diagnosticLogging` (writes anything at all) and
`verboseEditorDiagnostics` (adds the per-event editor trace). For a live
repro you almost always want **both** on. Verbose lines are gated behind
`Log.shouldTrace`.
- The log is **opt-out** (on by default), retention-pruned; the user only
toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics.
Trace-field decoding (`sel/active/marked/up/undo/blocks/storLen/rawLen`,
`⚠︎LEN-MISMATCH`, `traceSelectionOrigin`) is covered in
**edmund-live-repro-and-diagnostics** and **edmund-debugging-playbook** — one
home per fact; this skill only says which flags turn the trace on.
---
## 5. ReproScript command surface
`Sources/edmd/App/ReproScript.swift`, DEBUG only. One command per line, `#`
comments allowed.
| Command | Effect |
|---|---|
| `sleep <ms>` | wait before next command |
| `caret <needle>` | place caret before first occurrence of `<needle>` |
| `type <text>` | one real key event per char (~80 ms apart) |
| `backspace <n>` | n real delete keystrokes (~300 ms apart) |
| `bypassdelete <needle>` | simulate drag-move source deletion: `shouldChangeText` + storage mutation, **no `didChangeText`** |
| `assertcaret <needle>` | log `PASS/FAIL` iff caret sits exactly before `<needle>` |
| `logsel` | log selection, rawSource length, doc count |
Adding a command is ~10 lines in `ReproScript.swift`. Usage, soak patterns, and
launch recipe: **edmund-live-repro-and-diagnostics**.
---
## 6. How to ADD a setting (checklist)
Worked from an existing real path (`sourceMode` / content width). To add a
user-facing setting:
1. Add a `static let` key + typed accessor in `AppSettings.swift` (namespace the
key string: `settings.<area>.<name>`).
2. Bind it in the relevant SwiftUI pane with `@AppStorage(AppSettings.<key>)`.
3. If it must affect **open documents live**, add/extend an `applyTo…` broadcast
(see the font/line-height/content-width `applyTo…` helpers) so every open
`Document.editor` picks it up — a setting that only takes effect on next open
is usually a bug.
4. Pick a sane default (register it, or make the accessor default when absent).
5. New behavior needs a test + (if it draws) a screencapture check — route
through **edmund-change-control** and **edmund-validation-and-qa**.
---
## When NOT to use this skill
- Understanding *why* an invariant exists → **edmund-architecture-contract**.
- Release/signing/appcast flags, `RELEASE_TOKEN`, Sparkle keys → **edmund-release-and-operate**.
- Interpreting log output / running a repro → **edmund-live-repro-and-diagnostics**.
- Which change needs which gate → **edmund-change-control**.
- Build-time flags in the toolchain sense (stale builds, `swift package clean`) → **edmund-build-and-env**.
---
## Provenance and maintenance
Verified 2026-07-05 against source. This skill drifts fastest — re-verify each
table before relying on it:
```bash
# §1 all keys + strings:
grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift
# §1 crash toggle still commented out / endpoint still placeholder:
grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift
grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift
# §2 repro flag key:
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
# §3 tripwire + constant:
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
# §5 repro commands:
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
```
**Known doc-vs-code discrepancies (code wins):** ARCHITECTURE §7 calls the
window-size key `lastWindowSize`; the code key is `settings.window.lastHeight`
(`lastWindowHeight`). If you touch window persistence, trust the code.
@@ -0,0 +1,305 @@
---
name: edmund-debugging-playbook
description: >-
Load this FIRST when any bug report or unexpected behavior arrives in Edmund:
caret lands in the wrong place after delete/typing, viewport jumps or
scroll-to-target misses, rendering is wrong or missing, empty bands or clipped
lines, everything suddenly renders as plain text, app crashes or won't launch,
a code change "doesn't take" after rebuild, undo/redo lands the viewport
wrong, IME/CJK/accent input misbehaves, right-click shows the wrong menu,
window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage
table, the traps that cost real debugging time, discriminating trace checks,
the repro escalation ladder, and the open-bug inventory so known bugs are not
rediscovered fresh.
---
# Edmund debugging playbook
Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the
triage table, run the discriminating first check *before* forming a theory,
and check the open-bug inventory before declaring a discovery.
## When NOT to use
- Making a change, not chasing a bug → `edmund-change-control`.
- You already know the bug is live-only and need to build a deterministic
repro → `edmund-live-repro-and-diagnostics` (full ladder detail; summary in
§5 here).
- Caret-drift class specifically, with its six-round history →
`edmund-caret-integrity-campaign`.
- Build/toolchain/stale-binary mechanics beyond the quick checks here →
`edmund-build-and-env`.
- Release, signing, appcast, Sparkle pipeline → `edmund-release-and-operate`.
- TextKit 2 / AppKit API semantics reference → `textkit2-appkit-reference`.
- Launch flags and settings keys reference → `edmund-config-and-flags`.
- How past investigations were run and why → `edmund-failure-archaeology`,
`edmund-research-methodology`.
- Pre-merge verification of a fix → `edmund-validation-and-qa`.
## 1. First 15 minutes — any new bug
Run this checklist before hypothesizing. Every step is cheap; skipping them
is how rounds 15 of delete-drift shipped fixes that came back.
1. **Check the open-bug inventory (§6).** If the symptom matches a known open
bug, you are done triaging — link the backlog entry and its repro asset.
2. **Get the logs.** `ls -t ~/.edmund/logs/` and read the day's file
(`edmund-YYYY-MM-DD.log`). Grep for the three permanent breadcrumbs:
```bash
grep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.log
```
Also grep for `invariant:` (the always-on storage==rawSource tripwire) and
`⚠︎LEN-MISMATCH`.
3. **Find the first bad line and walk BACKWARDS.** The user-visible symptom
is often the second half of a two-part mechanism — the round-6 caret drift
was armed by a silent bypass 80 seconds and dozens of healthy edits before
the leap. Never start reading at the symptom timestamp.
4. **Rule out a stale build** if this follows a rebuild: grep
`strings .build/arm64-apple-macosx/debug/edmd` for a long string literal
unique to the new code (≤15-byte literals are inlined on arm64 and never
appear); `shasum` the binary. See §3c.
5. **Check git history for prior art.** `git log --oneline -- <suspect file>`
plus the investigation docs in `docs/`. The viewport-glitch investigation
found four earlier fixes all working around the same unnamed root cause.
6. **Reconstruct the document.** Get the user's file or rebuild it from the
trace's block counts/lengths. Wrapped-paragraph geometry and block kinds
matter; do not repro against "hello world".
7. **Before touching any running app**: `pgrep -lx edmd` then
`ps -o lstart=,command= -p <pid>`. The user's daily-driver app shares the
binary name. Never blanket `pkill -x edmd` — kill only the PID you
launched.
8. Row found in §2 → run its discriminating check. No row → escalate per §5,
and add the new row here when it's understood.
## 2. The triage table
| Symptom | Likely mechanism | Discriminating first check | Where next |
|---|---|---|---|
| Caret lands blocks away after delete or typing; text itself correct | Delete-drift class: a storage edit bypassed `didChangeText` (drag-move to no valid target), or TextKit 2's queued `_fixSelectionAfterChangeInCharacterRange` fired at a later `endEditing` | `grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log` and read the trace around it; look for `selectionDidChange` with `up=Y` at a surprising position | `edmund-caret-integrity-campaign`; `docs/investigations/delete-drift-investigation.md` |
| Every delete drifts, persistently, until an app switch fixes it | Stranded IME composition: `hasMarkedText()` stuck true, `didChangeText` bails forever, model frozen | Grep logs for `recovered stranded desync on focus regain`; check `storage.string == rawSource` | `docs/investigations/delete-drift-investigation.md` rounds 12 |
| Scroller jumps; scroll-to-target misses; content shifts on scroll | TextKit 2 height *estimates* — off-screen frames are guesses corrected as layout reaches them | Doc length vs `fullLayoutMaxLength` (100k UTF-16, `EditorTextView.swift`) — ≤100k should be fully laid out by the settle; >100k is estimate territory | `docs/investigations/viewport-glitch-investigation.md` |
| First line unreachable above the top; scroller already at 0 | TK2 strands fragments at negative y after a top-of-document edit | `grep "repairing content above origin" ~/.edmund/logs/edmund-*.log` — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause | `docs/investigations/viewport-glitch-investigation.md` Bug 2 (repair unconfirmed live) |
| Undo/redo lands viewport in the wrong place; changed text not selected | Regression of the diff-based restore contract (`5bb2b40`): a full `recompose` resets every fragment to an estimate, then the scroll measures the estimates | Confirm `restoreSnapshot` still routes through range-bounded `recomposeReplacing`, never full `recompose` (`+Undo.swift`); check the changed range, not the stored caret, drives the viewport | `docs/investigations/viewport-glitch-investigation.md` Bug 1 |
| Code/visual change "doesn't take" after rebuild | STALE BUILD — SwiftPM printed `Build complete!` without relinking `edmd` | `strings .build/arm64-apple-macosx/debug/edmd \| grep "<long new literal>"`; `shasum` before/after | `edmund-build-and-env`; §3c |
| App crashes the instant any LaTeX renders | SwiftMath `*.bundle` missing from the `.app` root (its `Bundle.module` is hardcoded to `Bundle.main.bundleURL`) | `ls build/Edmund.app/*.bundle` | `scripts/build-app.sh` copy step; ARCHITECTURE §8 |
| Everything suddenly renders as plain text; all styling gone | Silent, permanent TextKit 1 reversion: an `NSLayoutManager` API was touched or an `NSTextBlock`/`NSTextTable` attribute entered storage | DEBUG builds assert via the tripwire (`textKit1FallbackTripwire`, `willSwitchToNSLayoutManagerNotification`); audit recent diffs for `layoutManager` / `NSTextBlock` | ARCHITECTURE §2; `textkit2-appkit-reference` |
| Empty bands or clipped lines after a restyle | Attribute-only change without `invalidateLayout(for:)` — TK2 keeps the stale fragment frame | Is the misbehaving path a *new* styling path? `recomposeDirty` and the idle drain already invalidate; new paths must too | ARCHITECTURE §8 |
| Weird behavior only while composing CJK / accents / emoji | A storage-touching styling path missing the `!hasMarkedText()` guard (including async work scheduled *before* composition began) | Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition | ARCHITECTURE §8; `docs/investigations/delete-drift-investigation.md` |
| Callout at end of file shows an extra colored line not prefixed by `>` | KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) | Confirm against `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` | `misc/backlog.md` |
| Image leaves a large blank space below it | KNOWN OPEN BUG | `misc/bug-repros/image-blank-after.mov` | `misc/backlog.md` |
| Footnotes don't render (edit or read mode) | KNOWN OPEN BUG | — | `misc/backlog.md` |
| Right-click on the toolbar view-mode button shows "Customize Toolbar…" | `NSToolbar` with `allowsUserCustomization` claims every secondary click over the toolbar, beating view-level handlers | Verify the `DocumentWindow.sendEvent(_:)` intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen | ARCHITECTURE §8 |
| Window grows by title-bar height on every reopen | Frame-vs-content-size persistence trap: saving `contentView.bounds.size` and re-applying as `contentRect` | Confirm `lastWindowSize` round-trips `window.frame.size` via `setFrame` *after* the toolbar is installed (`Document.swift`) | ARCHITECTURE §8 |
| Sparkle update fails: "The update is improperly signed and could not be validated" | Bundle not sealed: signing only the main binary leaves no `_CodeSignature/CodeResources`; or the EdDSA keypair diverged from `SUPublicEDKey` | Does `build-app.sh` still seal the whole `.app` before copying the SwiftMath bundle in? | `edmund-release-and-operate`; ARCHITECTURE §8/§13 |
| Callout/overlay icon wedges a wrapping line down to one line | TK2 image-on-multiline-fragment wedge: drawing an *image* on a wrapping fragment collapses its layout; shapes do not | Is the overlay an `NSImage` on a fragment that can wrap? Convert to a stroked `CGPath` (the custom-title callout icon fix) | `docs/investigations/archives/callout-title-wrap-investigation.md` |
| Dragging produces no visible selection at all | Not a bug: a selection (possibly whole-document) was already active, and dragging *inside* an existing selection is AppKit's drag-*move* gesture | Trace: was there a `selectionDidChange` with a large `sel` before the drag began? | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 1 |
| Viewport oscillates up/down during a steady drag-select | Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection | Confirm the `scrollRangeToVisible` override still reveals the selection's *nearest* end (`+TypewriterScroll.swift`, commit `340fcbc`) | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 2 |
| Edits do nothing at all (not drift — dropped) | `isUpdating` stuck true would make `shouldChangeText` return false | Trace shows `shouldChangeText` never returning OK; distinct from the drift signature | `+EditFlow.swift` |
| Launching via `open` shows old behavior | LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy | `pgrep -lx edmd` first; launch by direct exec of the bundle binary | §3e; `edmund-build-and-env` |
## 3. The traps that cost real time
Each of these burned hours to days. Read before shipping any fix.
**(a) Shipping caret fixes on reasoning alone.** Delete-drift rounds 15 each
shipped a plausible, well-argued fix — and each came back. Only round 6, the
first with a frozen deterministic live repro (`bypassdelete` script), named
the actual mechanism (`_fixSelectionAfterChangeInCharacterRange` queued by a
bypassed edit, firing at the next `endEditing`) — and its first fix attempt
*failed in the repro within a minute*, which reasoning would never have
caught. Lesson: time spent making the failure cheap to observe beats time
spent reasoning about the fix. Freeze the repro before writing the fix.
**(b) Undo/redo viewport drift.** Two defects hid behind one symptom:
`restoreSnapshot` ran a full `recompose` (discarding all TK2 layout, so the
follow-up scroll measured freshly manufactured estimates) and `performUndo`
recorded the redo snapshot with the caret *at undo-invocation time*, not at
the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain
stale-state bug stacked; fix and verify them separately. The contract since
`5bb2b40`: diff the snapshot, apply via `recomposeReplacing`, select the
changed text, let the changed range drive the viewport.
**(c) Stale binaries produce false conclusions.** In round 6, `swift build`
twice printed `Build complete!` while linking a stale `edmd` — the compile
ran, the relink silently didn't — and two "failed" fix iterations were
phantoms. Detect: grep `strings` on the binary for a *long* literal unique to
the new code. Cure: `swift package clean` (or `rm -rf .build` for release
weirdness). Never hand-delete `.build/…/edmd.build/` — that corrupts the
output-file-map and wedges the target until a full clean.
**(d) Headless-green ≠ fixed for input-layer bugs.** The round-6 unit test
reconstructs the exact document and gesture and *passes with and without the
fix*: the test harness runs AppKit's deferred selection fixup synchronously,
so the broken state never forms. A green test proves nothing about the live
NSTextView / TextKit 2 / input-context class. The scripted live repro is the
regression harness; the unit test is only a contract spec.
**(e) `open -a` runs stale cached copies.** LaunchServices can foreground an
already-running instance instead of relaunching, and can execute a stale
cached/translocated copy of the bundle — you debug last hour's code. Always
launch by direct exec: `build/Edmund.app/Contents/MacOS/edmd file.md &`
(after the §1 step-7 pgrep check).
**(f) Trusting off-screen fragment y-coordinates.** A TK2 fragment's frame is
real only once laid out; everything off-screen, plus total document height,
is an estimate. Any code that measures before ensuring layout of the
viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll
gotcha, and most historical viewport glitches). Ensure layout for the target
range first, then align to real geometry — and never verify a visual fix
from headless layout: measure from `screencapture` pixels.
## 4. Discriminating experiments — cheap checks that split hypothesis spaces
**Verbose diagnostics launch** (defaults keys are namespaced; the file must
be argv[1]):
```bash
build/Edmund.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
```
Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor
tracing"). Logs land in `~/.edmund/logs/edmund-YYYY-MM-DD.log`.
**Trace field vocabulary** (source of truth:
`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`,
`diagnosticState`). Every trace line ends with:
| Field | Meaning |
|---|---|
| `sel={loc,len}` | current selection |
| `active=` | active block index (or `nil`) |
| `marked=` | marked-text range, `-` if none (non-`-` outside a live composition = stranded) |
| `up=Y/N` | `isUpdating` — `Y` means the event arrived MID-RECOMPOSE |
| `undo=Y/N` | `isUndoRedoing` |
| `blocks=` | block count |
| `storLen=` / `rawLen=` | storage vs rawSource lengths |
| `⚠︎LEN-MISMATCH` | appended when they differ |
**Healthy vs suspect edit orderings:**
- Healthy: `shouldChangeText` → `selectionDidChange` (`up=N`) → `synced`.
A *transient* `⚠︎LEN-MISMATCH` between those lines is normal (storage moves
before rawSource syncs).
- Suspect: a `selectionDidChange` with `up=Y` at a surprising position; a
*persisting* LEN-MISMATCH; a `shouldChangeText` with no
`synced`/`SKIPPED`/`DEFERRED` line after it (bypassed `didChangeText`); the
`healing storage edit that bypassed didChangeText` breadcrumb.
**`traceSelectionOrigin`**: under verbose tracing, any selection change
arriving mid-recompose (`up=Y`) or with an unconsumed pendingEdit logs a
condensed call stack naming the AppKit path that moved the caret. This is
what named `_fixSelectionAfterChangeInCharacterRange` in round 6. If your bug
moves the caret and you don't know who moved it, this answers it in one run.
**Walk backwards from the first bad line**, not forwards from the symptom.
The round-6 drift was armed 80 seconds before the visible leap. Find the
first line whose state is wrong, then read *earlier*.
**`verifyEditorInvariants`** (same file): the O(1) length check
(`storage.length != rawSource.length`) logs an `error` whenever logging is on
— no verbose toggle needed — so a hard-invariant break always leaves an
`invariant:` line. The full structural checks (string equality, blocks
reconstruct rawSource, block ranges in bounds) run under verbose tracing and
assert in DEBUG. An `invariant:` error in a user's log is a model desync,
full stop; triage as the delete-drift class.
If the existing logging didn't capture the deciding fact, add the log line
first and reproduce again — one breadcrumb beats ten speculative fixes. Keep
good ones behind `Log.shouldTrace` and ship them.
## 5. The escalation ladder (summary)
Full detail, ReproScript command reference, CGEvent driver, and soak-script
method: `edmund-live-repro-and-diagnostics` and `docs/dev-guides/live-repro-guide.md`.
Work down; stop at the first level that reproduces.
1. **Plain unit test** (`makeEditor()`) — model/parsing/styling logic.
2. **Windowed unit test** (NSWindow + NSScrollView, real `deleteBackward`) —
adds layout, viewport, first-responder. **Failure to repro here is
evidence, not defeat**: it points at deferred/queued AppKit state and at
levels 34.
3. **In-process ReproScript** — DEBUG builds accept `-debug.reproScript
<path>` (`Sources/edmd/App/ReproScript.swift`); replays a keystroke script
through the real `window.sendEvent` path. No Accessibility/TCC needed,
works with the window on an invisible Space. Commands: `sleep`, `caret`,
`type`, `backspace`, `bypassdelete`, `assertcaret`, `logsel`. Launch with
`-ApplePersistenceIgnoreState YES` and recreate the test document fresh
each run. **The default for live bugs.**
4. **CGEvent driver** — only for paths that must originate as HID events
(drag-select, drag-move, autoscroll). TCC decides per session; if input
doesn't land after one test click, fall back to level 3 immediately.
5. **Instrumented field occurrence** — can't trigger it yourself: add the
decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days
of latency; make sure the *next* occurrence is decisive.
After a fix: freeze the repro script, run a soak (several trigger cycles in
one app run with `assertcaret` checks), then full `swift test`.
## 6. Open-bug inventory
Known open bugs — check here before "discovering" one. Sources:
`misc/backlog.md` (authoritative list) and `docs/ROADMAP.md` (larger themes,
e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate
glitches are a known, partially-mitigated class). All entries below are OPEN
as of 2026-07-05.
| Bug | Status | Repro asset |
|---|---|---|
| Delete caret drift (class) | Open as a class; rounds 16 fixed, watching for round 7 | `misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov}` + matching logs |
| Inaccurate viewport estimates & related | Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live | — |
| Callout as last element renders an extra colored line | Open; live incremental restyle path only, NOT static rendering | `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` |
| Image creates large empty space below | Open | `misc/bug-repros/image-blank-after.mov` |
| Footnotes don't render (edit or read mode) | Open | — |
| Math environments don't render in read mode | Open | `misc/bug-repros/math-baseline-read-mode-png.png` (related baseline issue) |
| Math environments have wrong padding in edit mode | Open | — |
| Max content width not applied to read mode | Open | — |
| Images should shrink when content size is small | Open | — |
| Tables should wrap when content size is small | Open | — |
| Table cell content wraps out of the cell | Open | — |
| Click-to-select / select+delete sometimes doesn't work | Open, intermittent | — |
| Scroll glitch from height changes outside viewport | Open, unreproduced ("Lurking" in backlog) | — |
| Cursor stuck at indented position after indented editing | Open, unreproduced; awaiting screen recording | — |
| Undo/Redo and Copy/Paste scrolling "failing again" | Open, unreproduced (post-fix recurrence report) | — |
Do not relabel any of these as fixed without a verified repro flip; no
oversell. When you fix one, update `misc/backlog.md` and this table in the
same branch.
## 7. House rules while debugging
- **Never blanket `pkill -x edmd`.** The user's daily-driver app shares the
binary name. `pgrep -lx edmd` + `ps -o lstart=,command= -p <pid>`, then
kill only your own PID (`pkill -f EdmundDbg` if you launched the debug
bundle).
- **Do not request Computer Access** — Screen Recording and Accessibility are
already granted.
- **Measure visuals from `screencapture` pixels** (capture by window id, see
ARCHITECTURE §8), never from headless layout alone.
- **Never mutate storage while `hasMarkedText()`** — including in any
diagnostic or repro code you add.
- **Never auto-push, PR, or merge.** Branch off `main` per fix; commit small
and often.
- Logs in `~/.edmund/logs` are app-owned and fair game to read and quote.
## Provenance and maintenance
Built 2026-07-05 from: `docs/ARCHITECTURE.md` (§2, §8, §9),
`docs/investigations/delete-drift-investigation.md` (rounds 16),
`docs/investigations/viewport-glitch-investigation.md`,
`docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/dev-guides/live-repro-guide.md`,
`misc/backlog.md`, `docs/ROADMAP.md`, and
`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`. Log strings
(`healing storage edit that bypassed didChangeText`, `repairing content above
origin`, `recovered stranded desync on focus regain`), launch-flag keys,
`fullLayoutMaxLength`, ReproScript commands, the TK1 tripwire, and commit
`5bb2b40` were verified against the source tree on that date.
Maintain it like the codebase docs: when a new bug class is understood, add
its triage row; when a trap costs real time, add its story to §3; when a
backlog bug opens or closes, sync §6 with `misc/backlog.md` in the same
branch. If a row's discriminating check stops matching the code (renamed log
string, moved file), fix the row — a stale runbook is worse than none.
Deeper mechanism detail belongs in the sibling skills and `docs/`
investigation files, not here; this file stays a triage surface.
@@ -0,0 +1,268 @@
---
name: edmund-docs-and-writing
description: >
Documentation of record for the Edmund repo: which doc owns which fact, and
how to write in the house style. Load whenever you are writing or updating
ANY project doc — docs/ARCHITECTURE.md, CHANGELOG.md, README.md,
docs/ROADMAP.md, misc/backlog.md, a docs/<topic>-investigation.md
write-up, release docs — or deciding WHERE a newly learned fact, gotcha,
bug, or feature idea belongs. Covers the docs-of-record map, the
fact-routing decision table, the investigation-doc template, CHANGELOG
format (machine-extracted for release notes), commit-message conventions,
and doc maintenance duties. Not for making the code change itself, release
mechanics, or debugging — see "When NOT to use this skill".
---
# Edmund docs and writing
Date-stamped 2026-07-09. Every claim below was verified against the files on
`main` at that date; re-verify paths before trusting this after major
reorganizations.
## When NOT to use this skill
| You are actually doing | Use instead |
| --- | --- |
| Changing code / designing a mechanism | `edmund-architecture-contract` |
| Branch/commit/PR mechanics, pre-commit checklist | `edmund-change-control` |
| Cutting a release, appcast, Sparkle, CI | `edmund-release-and-operate` |
| Diagnosing a bug (not writing it up) | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` |
| Mining past investigations for technique | `edmund-failure-archaeology` |
| Marketing copy, positioning, alternatives research | `edmund-external-positioning` |
| Build flags, env, debug bundle | `edmund-build-and-env`, `edmund-config-and-flags` |
This skill is for prose: what to write, where it lives, how it should read.
## 1. The docs-of-record map — one home per fact
Every fact has exactly one home; everywhere else gets a pointer. All paths
exist and are current as of 2026-07-09.
| Doc | Owns | Notes |
| --- | --- | --- |
| `docs/ARCHITECTURE.md` | HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) | THE agent-onboarding doc. Its own header states the rule: **when you learn something non-obvious or change an invariant, edit this file in the same PR.** |
| `docs/architecture/README.md` | Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of `docs/architecture/`'s deep docs and the sibling `investigations/`/`dev-guides/` folders, common quirks (each a pointer, never a new claim), getting-started commands | The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns. |
| `docs/architecture/<topic>.md` | Deep narrative write-up of one subsystem (e.g. `editor-pipeline.md`, `text-system.md`) | The "deep-doc" pattern: a fact's *statement* lives in `ARCHITECTURE.md`, its *explanation* lives here, each links to the other. |
| `docs/architecture/extensibility.md` | The design-of-record for themes/extensions: vision, current state (verified against `main` and the unmerged `feat/extensions-registry-and-tab` branch), themes/extensions design, staged implementation plan, honest risks | **Design only, not yet implemented on `main`.** `ARCHITECTURE.md` gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet. |
| `docs/architecture/sandboxing.md` | The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the `~/.edmund/` onboarding grant, staged plan (SB0-SB4), open decisions | **Plan only, nothing sandboxed on `main`.** Same design-doc exception as `extensibility.md`: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to `ARCHITECTURE.md` in the same PR. |
| `README.md` | WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" `xattr -dr com.apple.quarantine` workaround), dependencies, alternatives, acknowledgements, license | User-facing; no internals. |
| `CHANGELOG.md` | User-facing version history, Keep-a-Changelog style | `## [x.y.z]` sections are machine-extracted for release notes — exact format matters (§4 below). |
| `docs/ROADMAP.md` | Versioned feature plan: `## v1.0.0`, `## v1.x`, `# v.2.0.0` sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) | Has a `Last updated: YYYY-MM-DD` line under the title — refresh it when you edit. |
| `misc/backlog.md` | The maintainer's working priority list: `## Now (small releases)` (Marketing / On-going bugs / Bugs / UI/UX / Features), `## Next`, `## Later`, roadmap mirrors, `### Lurking (Unreproduceable)`, `## Done` | Stated priority: **Marketing = Bugs >= UI/UX > Features**. Bug entries carry repro pointers (`misc/bug-repros/*.mov`, `.log`, or `~/Desktop` paths). |
| `docs/investigations/<topic>-investigation.md` | Deep multi-round investigation chronicles for active bug classes | Existing: `delete-drift-`, `viewport-glitch-investigation.md`. Template in §5. |
| `docs/investigations/archives/<topic>-investigation.md` | Chronicles for closed/resolved bug classes | Existing: `callout-bottom-line-`, `callout-title-wrap-investigation.md`. |
| `docs/dev-guides/live-repro-guide.md` | Method doc: the escalation ladder for reproducing live-app bugs | Referenced from ARCHITECTURE §11. |
| `misc/before-you-release.md` | Pre-flight readiness checklist | Pairs with `how-to-release.md`; cross-ref `edmund-release-and-operate`. |
| `misc/how-to-release.md` | Release mechanics (CI tag path, local `release.sh`) | Same. |
| `CLAUDE.md` (root) | Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule | Short by design; it delegates the "how" to ARCHITECTURE. |
| `LICENSES/` | Vendored license texts (currently `lucide.txt` for the Lucide icon SVGs) | Add one when vendoring third-party assets. |
| `Info.plist` | `CFBundleShortVersionString` + `CFBundleVersion` — the version of record | Must match the CHANGELOG section header at release (see `misc/before-you-release.md` §3). |
Note: `misc/backlog.md` and `docs/ROADMAP.md` currently duplicate the
v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the
public plan; backlog is the working list. When they disagree, treat ROADMAP as
the versioned commitment and backlog as scratch — and mention the drift to the
maintainer rather than silently reconciling.
## 2. Where does a new fact go — decision table
Route the fact FIRST, then write. One home; cross-reference from elsewhere.
| You learned / produced | Home | How |
| --- | --- | --- |
| Code quirk, edge case, workaround, non-obvious *why* | **Comment at the code site** | House rule (root `CLAUDE.md`): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file." |
| Architectural gotcha that will bite the next agent | `ARCHITECTURE.md` §8 | Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change. |
| New known issue / structural constraint | `ARCHITECTURE.md` §9 | It has an explicit placeholder: *"Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in `docs/`."* |
| Code debt / incomplete implementation | `ARCHITECTURE.md` §10 | Its footer says: track code-debt here, roadmap items in README/ROADMAP. |
| Changed invariant, new subsystem, new pipeline step | `ARCHITECTURE.md` §2–§7 (the relevant section) | Update in the same PR — header rule. |
| Multi-round investigation (2+ hypothesis cycles, live repro work) | New `docs/<topic>-investigation.md` | Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc. |
| User-visible change (fix/feature/rename) | `CHANGELOG.md` under the next `## [x.y.z]` | Format in §4. Link the issue and any investigation doc. |
| New bug found (reproducible) | `misc/backlog.md` under `Bugs` | `- [ ] Bug: <symptom>. See <repro pointer>.` Drop repro assets (video/log) into `misc/bug-repros/`. |
| New bug found (unreproducible so far) | `misc/backlog.md``### Lurking (Unreproduceable)` | One line + "wait for screen record" style note. |
| Bug that is really code debt (design limitation) | `ARCHITECTURE.md` §9 | e.g. the image-on-wrapping-fragment constraint. |
| Feature idea, near-term (next few small releases) | `misc/backlog.md` (`Now`/`Next`/`Later`) | Sorted by priority + difficulty within category. |
| Feature idea, versioned/strategic | `docs/ROADMAP.md` under the right version | Refresh `Last updated`. |
| Repro method / debugging technique | `docs/dev-guides/live-repro-guide.md` | Method docs, not per-bug chronicles. |
| Release procedure change | `misc/how-to-release.md` / `misc/before-you-release.md` + `ARCHITECTURE.md` §13 | §13 owns the mechanism + failure modes; misc/ owns the operator checklist. |
| Agent workflow improvement | `ARCHITECTURE.md` §12 | Its footer invites this: "If you (the agent) improve this workflow... update this section." |
| Vendored third-party asset | `LICENSES/<name>.txt` + a feature-map note in §6 | Follow the Lucide precedent. |
| Deep explanation of an existing subsystem | `docs/architecture/<topic>.md` | A fact's *statement* lives in `ARCHITECTURE.md`; its *explanation* lives in the deep doc; each links to the other. |
**The same-PR rule is the load-bearing one.** Doc updates that ride the code
PR actually happen (see `cf10741`, `b600e12`, `c4a602b` in history); doc
updates deferred to "later" don't.
## 3. House style
Derived from reading `ARCHITECTURE.md` and the investigation docs. Match it.
- **Dense, specific, evidence-first.** State the mechanism and the proof, not
vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps
and selection ranges quoted verbatim in investigation docs.
- **Bold lead-ins for gotcha bullets**, then the explanation:
`- **Stale release builds**: ...`. Scannable list, detail inline.
- **Backticks for every file, symbol, flag, and command**:
`` `recomposeDirty` ``, `` `+EditFlow` `` (the extension-file shorthand),
`` `-debug.reproScript` ``.
- **One-line repro pointers**, not embedded essays: "See
`misc/bug-repros/image-blank-after.mov`", "grep `~/.edmund/logs` for
`repairing content above origin`".
- **Honest status labels.** The docs say "unconfirmed live", "theory +
targeted repair, not a confirmed kill", "Verification limits (honest
gaps)", "the test documents intent; the leap only reproduces under live
layout". Never claim verification you didn't do. No oversell.
- **Section anchors as cross-refs**: "see §8", "ARCHITECTURE §13" — used
across ARCHITECTURE, CLAUDE.md, before-you-release.md. If you renumber
sections, grep the repo for `§` and fix every reference.
- **Address "you", the next agent/engineer**: "will bite you", "Context for
anyone who sees the bug again", "Next time it happens: ...".
- **Record what failed, not just what worked** — investigation docs keep the
overturned theories and the phantom fixes (stale-binary trap) because the
dead ends are the reusable knowledge.
### Commit messages (from `git log --oneline -50`)
Mixed but patterned: conventional prefixes dominate for fixes and docs —
`fix(scope): ...` (scopes seen: `editor`, `layout`, `scroll`, `undo`,
`release-workflow`, `changelog-to-html`), `docs: ...`, occasional
`refactor:`, `appcast: add Edmund X.Y.Z`, `release X.Y.Z`. Chores and README
work often use plain imperative subjects ("Update README", "Add assets for
README"). Branches: `fix/<slug>`, `docs/<slug>`, `chore/<slug>`. When in
doubt: `fix(scope):` for behavior changes, `docs:` for doc-only commits,
plain imperative for chores. Never auto-push, PR, or merge — only when asked.
## 4. CHANGELOG format — machine-read, get it exact
`.github/workflows/release.yml` extracts release notes with:
```
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}"
```
So the section header MUST be `## [x.y.z]` at line start, version matching
`CFBundleShortVersionString` exactly; the section ends at the next `## [`.
`scripts/changelog-to-html.py` converts the same section to HTML for
Sparkle's update dialog (it folds wrapped bullet lines into their `<li>` —
wrapping bullets is safe). Full pipeline: `edmund-release-and-operate`.
House format (verify against the file; current entries follow this):
```markdown
## [0.1.4] — 2026-07-XX
### Fixed
- <User-facing symptom, past tense optional> ([docs](docs/<topic>-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN)
---
```
- Em dash between version and ISO date; `---` separator between versions.
- Subsections used so far: `### Added`, `### Changed`, `### Fixed`
(Keep a Changelog 1.1.0 vocabulary).
- Entries describe the user-visible effect, not the mechanism; mechanism
lives in the linked investigation doc / ARCHITECTURE.
- An optional free-text line under the header is fine (0.1.2 has one) — the
awk extraction includes it.
## 5. The investigation-doc template
Derived from `docs/investigations/delete-drift-investigation.md` (6 rounds) and
`docs/investigations/viewport-glitch-investigation.md`. Both open with why the doc exists
("Context for anyone who sees the bug again... records the trail end to
end") and name the fixing commits/branch up front. Chronicle structure: each
recurrence is a new `## Round N` appended to the same doc — symptom →
diagnosis → root cause → fix → verification, with limits stated.
Skeleton (copy-paste):
```markdown
# <Area> "<bug nickname>" — investigation notes
Context for anyone who sees this again. <One line on why it was hard:
intermittent / state-dependent / looked nothing like its cause.>
Fixed on branch `fix/<slug>`, commits: `<sha>` — <subject>, ...
## Symptom
<Exact user-visible behavior. Bulleted key properties, each a discriminating
fact ("caret-only, text fine"; "never right after launch"). Evidence
pointers: `misc/bug-repros/<file>`, `~/.edmund/logs/...`.>
## How it was diagnosed
1. <Numbered steps in the order they happened, including overturned
theories and WHY each clue narrowed the space.>
## Root cause
<The mechanism, in bold where it matters. Explain why every symptom
property follows from it.>
## The fix
<What changed, in which file, and why that shape (defenses tried and
rejected count too).>
## Verification
<Tests added, live repro results, suite count. Then an honest limits
subsection: what was NOT reproduced/confirmed, and the breadcrumb to grep
for if it recurs.>
## If it ever recurs
<Ordered checks for the next investigator: which invariant/log/flag to
inspect first.>
## Round 2: <one-line summary> ← append on recurrence, same structure
```
After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a
pointer, add the CHANGELOG entry with a `([docs](docs/...))` link, and check
the corresponding `misc/backlog.md` box (or move it under `On-going bugs`).
## 6. Maintenance duties
Do these whenever you touch the relevant doc; they rot otherwise.
- **ARCHITECTURE placeholders**: §9 and §10 end with italic *"Add new ones
here"* / *"track code-debt here"* lines — keep them last in their lists so
the invitation stays visible.
- **ROADMAP `Last updated:`** — bump the date on any edit.
- **Backlog hygiene**: check `- [x]` boxes when a fix ships (move to
`## Done` only if following the existing pattern — completed items live
there); keep repro pointers valid; don't reorder the maintainer's priority
sorting.
- **README's inline HTML comments** are the maintainer's own edit notes
(e.g. `<!-- Replace "minimal" with ... -->`) — leave them unless acting on
them.
- **At release**: CHANGELOG section header ↔ `Info.plist` version ↔ appcast
`<item>` must agree; the checklist is `misc/before-you-release.md`, the
mechanics `edmund-release-and-operate`.
- **Section renumbering** in ARCHITECTURE: grep the whole repo (docs, misc,
CLAUDE.md, skills) for `§` references before and after.
- **Never edit `test-files/todo.md`** — the maintainer owns it.
## Provenance and maintenance
Written 2026-07-05 against `main` at `fe8a1f5` (release 0.1.3). Sources, all
read directly: `docs/ARCHITECTURE.md` (header, §8–§13),
`README.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `misc/backlog.md`,
`docs/investigations/delete-drift-investigation.md`, `docs/investigations/viewport-glitch-investigation.md`,
`docs/dev-guides/live-repro-guide.md` (§1), `misc/before-you-release.md`,
`misc/how-to-release.md`, root `CLAUDE.md`,
`.github/workflows/release.yml` (awk extraction quoted verbatim),
`git log --oneline -50` (commit-style tally), directory listings of
`docs/` (`architecture/`, `investigations/` incl. `archives/`, `dev-guides/`),
`misc/`, `misc/bug-repros/`, `LICENSES/`.
§1 map re-verified 2026-07-09 against the `docs/` reorg (investigation docs
split into `docs/investigations/` + `docs/investigations/archives/`;
`docs/live-repro-guide.md` moved to `docs/dev-guides/`).
Maintain this skill when: a doc of record moves or splits (update the §1
map), ARCHITECTURE sections are renumbered (fix every § reference here),
the CHANGELOG extraction in `release.yml` changes (§4 quotes it), or a new
investigation doc establishes a better template. Keep the one-home-per-fact
rule itself stable — it is the point of the skill.
@@ -0,0 +1,204 @@
---
name: edmund-external-positioning
description: >
Load when writing anything an outsider will read about Edmund — README edits,
blog posts, release notes, marketing copy, social posts, webpage text, Show HN
drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit,
Nodes), deciding what may be publicly claimed vs. what is still unproven,
labeling a technique "novel", or planning ecosystem work (licenses,
attribution, notarization messaging, appcast, issue templates, discovery
listings). This skill is the overclaim firewall: what the positioning is,
what evidence backs each claim, and what must exist before a claim gets
stronger.
---
# Edmund external positioning — what we claim, what we can prove
Governing rule: **nothing may be claimed publicly that an outsider cannot
reproduce from the repo + a release.** Unproven = "candidate", never "novel"
or "first". This skill exists to prevent overselling a beta.
## When NOT to use this skill
| You are doing | Use instead |
| --- | --- |
| Actually cutting a release (tags, appcast, Sparkle, CI) | edmund-release-and-operate |
| Internal docs, ARCHITECTURE.md, investigation chronicles | edmund-docs-and-writing |
| Understanding the invariants / render pipeline itself | edmund-architecture-contract |
| Deciding whether a code change is allowed | edmund-change-control |
| Reproducing or debugging a bug | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Judging research novelty for internal direction (not public claims) | edmund-research-frontier, edmund-research-methodology |
| Verifying behavior before shipping | edmund-validation-and-qa |
## 1. The positioning (quote it exactly)
Source of truth: `README.md`. As of 2026-07-05:
- One-liner: **"Edmund is a minimal, file-based, native Markdown editor for
macOS with inline live preview."**
- README carries its own TODO comment on this line: *"Replace 'minimal' with
'customizable' or 'lightweight' once more features are implemented"* — do
not do that replacement early; "minimal" is the honest word today.
- Goal statement: **"Our goal is to be the [CotEditor](https://coteditor.com)
of Markdown editors, i.e. elegant, powerful, configurable, and native inside
out."**
- Beta warning: **"⚠️ Edmund is currently in beta."** — this must stay visible
in the README and any landing page until v1.0.0 ships.
- Maintainer's blog post: <https://i7t5.com/posts/2026-06-26-edmund/> ("more of
the motivation and design philosophy"). Cite it; do not paraphrase or invent
its content without fetching it.
- Ambition framing: **product-first**. "Beyond state of the art" means product
excellence — the TextKit 2 techniques are means, not ends. Never lead public
copy with internal mechanism names; lead with what the user gets.
### The six claimed differentiators (README, verbatim, 2026-07-05)
| # | Claim (verbatim) |
| --- | --- |
| 1 | "Live preview: Typora/Obsidian-style WYSIWYG." |
| 2 | "File-based: Open `.md` files from anywhere. No vaults or dedicated folders required." |
| 3 | "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies." |
| 4 | "Fast: Handles ~1-2MB files with ease. No launch lag." |
| 5 | "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!" |
| 6 | "Private: Offline by default. Optional blocking of external links and HTML sanitization." |
README also has a TODO comment after the list: *"Move 'Fast' and 'Extensible'?
Add 'integrations' section to Native after implementation"* — the list is
known-provisional; keep quotes synced to the file when you edit copy.
## 2. Claims discipline
Before strengthening any claim publicly, the evidence in the middle column
must be upgraded to the right column. Status as of 2026-07-05.
| Claim | Current evidence | Required before strengthening |
| --- | --- | --- |
| Fast: "~1-2MB files with ease. No launch lag" | `Tests/EdmundTests/PerfHarnessTests.swift` (gated `MD_PERF=1`, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with `fullLayoutMaxLength = 100_000` regime (`EditorTextView.swift:80`) | A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (`MD_PERF=1 swift test`). No comparative "faster than X" claims without benchmarking X the same way. |
| Extensible: "Extensions system coming soon!" | Extensions API is `docs/ROADMAP.md` v1.0.0 — **not shipped**. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" | Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense. |
| Private: "Offline by default" | Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (`ReadRenderOptions.allowRemoteImages = false`); inline HTML whitelisted via `HTMLRenderer.sanitizeInlineHTML` | Note: the "Block external images" Settings checkbox is a `misc/backlog.md` item, **not shipped**; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE). |
| Native: "100% Swift … No Electron. Minimal dependencies" | True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" | Nothing; this claim is safe. Just never inflate to "zero dependencies". |
| Live preview: "Typora/Obsidian-style" | Shipped and demoed (README video, screenshots) | Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first. |
| File-based: "No vaults" | Shipped by design | Safe. |
| Beta status | v0.1.3 (2026-07-04) | Must stay visible everywhere until v1.0.0. |
## 3. Novel vs. known — the honest inventory
When writing a craft blog post or comparison, keep this ledger straight.
"Candidate" means blog-worthy pending proof; it is not "proven novel".
### Known / prior art (never claim novelty here)
- Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is
the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience").
- TextKit 2 viewport virtualization for Markdown:
[nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine)
(Apache 2.0, macOS 14+) solves the same problems — viewport virtualization,
live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14:
consult it **before inventing a new mechanism** and as a technique source
(drag-select autoscroll, overscroll). Its existence caps any "first TK2
live-preview engine" claim at zero.
- The README's own Alternatives section credits ~15 editors. Public copy that
ignores them reads as either ignorant or dishonest.
### Distinctive candidates (label as such; each needs proof before publishing)
| Candidate | Why it might be blog-worthy | Proof needed first |
| --- | --- | --- |
| Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) | A clean architectural answer to the classic WYSIWYG mapping problem | A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples |
| Stroked-`CGPath` overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) | A concrete, reproducible TK2 bug + workaround — the classic useful engineering post | A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces |
| Bypassed-`didChangeText` heal + caret re-assertion (round-6 mechanism: TK2 leaves a `_fixSelectionAfterChange` queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) | Deep TK2 internals nobody has documented; the delete-drift chronicle (`docs/investigations/delete-drift-investigation.md`) already exists as raw material | The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us) |
| Diff-based undo restore that preserves TK2 layout (snapshot restore *diffs* rather than replaces, bypassing NSTextView undo) | Practical fix for a visible TK2 pain (undo viewport yank) | Before/after measurements (layout work saved, viewport stability) on a pinned document |
| In-process ReproScript methodology (`-debug.reproScript`, keystroke replay without CGEvents/TCC; `docs/dev-guides/live-repro-guide.md`) | Reusable testing methodology for any AppKit text app | Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard |
Reproducibility standard for any technical post: a reader with the repo and
the post must be able to reproduce every claim — frozen repro scripts, pinned
document fixtures, named macOS versions, measured numbers with the command
that produced them. If a claim can't meet that, cut it or mark it anecdotal.
## 4. Ecosystem and license hygiene
Verified against the repo, 2026-07-05:
- **License: Apache 2.0** (`LICENSE`, README "License" section, and the 0.1.0
changelog entry all agree). Say "Apache 2.0", never "MIT".
- **Lucide icons: vendored, ISC** (`LICENSES/lucide.txt`, © 2026 Lucide Icons
and Contributors; parts derived from Feather). Attribution duty: keep
`LICENSES/lucide.txt` shipping and credit Lucide where icons are discussed.
- **Why Lucide in both modes (SF Symbols constraint):** ARCHITECTURE §6 —
SF Symbols **cannot ship in exported PDFs** (license), so callout headers use
Lucide in both Read (inline SVG, `currentColor`-tinted) and Edit (rasterized
tinted `NSImage` overlay). App-chrome SF Symbols (toolbar/settings) are fine;
Edit-mode task checkboxes still use SF Symbols on-screen only. Don't
"simplify" copy or code in a way that breaks this split.
- **Dependencies to credit:** swift-markdown, SwiftMath, Sparkle (README
"Dependencies"). Acknowledgements section additionally credits
swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit,
and others — preserve it when restructuring the README.
- **Not notarized (2026-07-05):** ad-hoc signed; users hit Gatekeeper
("damaged app"). README's WARNING block gives the two workarounds
(System Settings → Privacy & Security → Open Anyway; or
`xattr -dr com.apple.quarantine /Applications/Edmund.app`, maybe `sudo`).
Keep those instructions accurate in every venue that mentions installing.
`misc/marketing/MARKETING.md` gates Show HN on fixing this (notarize, or
make the workaround idiot-proof).
- **GitHub issue templates exist:** `.github/ISSUE_TEMPLATE/bug_report.md`,
`feature_request.md`. Point users there, not at email.
- **`appcast.xml` is a public artifact** served raw from the repo (`SUFeedURL`
points at the raw GitHub URL). Anything committed to it is user-visible in
Sparkle's update dialog. Pipeline details: edmund-release-and-operate.
## 5. Release-notes and public-writing style
- **Pipeline:** `CHANGELOG.md` sections become both the GitHub release notes
(awk-extracted) and Sparkle's update-dialog HTML
(`scripts/changelog-to-html.py` → appcast `<description>`). A CHANGELOG
entry IS public copy — write it that way. Mechanics: edmund-release-and-operate.
- **Actual house style** (read `CHANGELOG.md` 0.1.00.1.3 before writing):
Keep-a-Changelog headers (`### Added / Changed / Fixed`); one line per item,
sentence case, no trailing period enforced; links to issues (`[#156]`) and
investigation docs (`([docs](docs/investigations/delete-drift-investigation.md))`);
user-visible phrasing ("Redo now jumps to where changed text was instead of
caret") not internal jargon; occasional first-person maintainer notes with
personality ("trying to have Fable 5 fix all the big bugs while I still have
it with me"); 0.1.0 used bold **Feature** — one-line descriptions. Match this
voice: plain, specific, lightly informal, zero hype.
- **Screenshots/videos:** README embeds live in `docs/assets/`
(`v0.1.0_*.png`, `installation.png`, `v0.1.0_video.mp4`, `AppIcon/`).
Raw/source marketing assets live in `misc/marketing/`: `MARKETING.md`
(the plan), `reddit-post.md`, `demo-slide-v0.1.key`, `demo-src-files/`,
demo videos (`demo.mov`, `demo-video-v0.1-brown.mp4`), `_raw` screenshot
masters, `social-preview_figma.png`. New public screenshots: polished copy
`docs/assets/`, raw master → `misc/marketing/`, versioned filenames.
## 6. Marketing priority context (2026-07-05)
From `misc/backlog.md` "Now": **"Priority: Marketing = Bugs >= UI/UX >
Features"** — marketing work is tied for top priority with bug fixes.
Open marketing items: screenshots/files and a webpage (reference:
kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart;
`misc/marketing/MARKETING.md` names GitHub stars (~69 at writing) as the goal
and metric, audience "developers who value craft", and holds Show HN in
reserve until first-run friction and a landing page are fixed. Its "craft
months" deep-dives are exactly the Section 3 candidates — which is why the
proof bar there matters.
## Provenance and maintenance
- Sources verified 2026-07-05 against: `README.md`, `docs/ROADMAP.md`
(last updated 2026-07-03), `misc/backlog.md`, `docs/ARCHITECTURE.md`
(§2, §6, §8, §13, §14), `CHANGELOG.md` (0.1.00.1.3), `LICENSE`,
`LICENSES/lucide.txt`, `.github/ISSUE_TEMPLATE/`, `misc/marketing/`,
`Tests/EdmundTests/PerfHarnessTests.swift`,
`Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift`,
`Sources/EdmundCore/TextView/EditorTextView.swift`.
- Volatile facts are date-stamped inline: version (0.1.3), beta status,
notarization status, shipped-vs-roadmap feature split, star count, README
wording. Re-verify each against the file before repeating it publicly.
- When README differentiators or the one-liner change, update the verbatim
quotes in §1 and re-run the §2 evidence check.
- If a §3 candidate ships as a published post, move it out of "candidate" and
link the post + its frozen repro.
- Cross-references: edmund-release-and-operate (release/appcast mechanics),
edmund-docs-and-writing (internal doc style), edmund-research-frontier
(novelty judgment for research direction), edmund-architecture-contract
(the invariants quoted here).
@@ -0,0 +1,442 @@
---
name: edmund-failure-archaeology
description: >
The chronicle of every major bug investigation, dead end, rejected fix, and
revert in the Edmund Markdown editor. Load BEFORE re-investigating any
caret/selection symptom (drift, jump, desync), any viewport/scroll glitch
(lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge
(clipped wrap, one-line collapse, blank space), or any release/update
failure (signing, appcast, Sparkle "improperly signed"). Load before
proposing a fix that might already have been tried and reverted, when a bug
report "looks familiar", when a test passes but the live app still misbehaves,
or when wondering why the code does something weird (a guard, a re-assert, a
deliberately-missing icon). Every entry: symptom, root cause, evidence
(commit hashes, docs), status, and what NOT to retry.
---
# Edmund failure archaeology
Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives
symptom → root cause → evidence → status. Hashes are on `main` unless noted.
Dates are commit dates. Status vocabulary: **settled** (root-caused, fix
verified), **mitigated-unconfirmed** (fix shipped, never seen killing a live
occurrence), **open** (in `misc/backlog.md`), **reverted-pending-redo**.
## When NOT to use this skill
- Designing a change / asking "why is it built this way" → `edmund-architecture-contract`.
- Actively debugging a NEW symptom (method, not history) → `edmund-debugging-playbook`.
- Driving the live app to reproduce something → `edmund-live-repro-and-diagnostics` (and `docs/dev-guides/live-repro-guide.md`).
- TextKit 2 / AppKit API semantics → `textkit2-appkit-reference`.
- Cutting or fixing a release → `edmund-release-and-operate` (this file only records how 0.1.0 broke).
- Build environment, stale-link traps in depth → `edmund-build-and-env`.
- Deciding whether a change is safe to make at all → `edmund-change-control`.
- Test strategy / what the suite can and cannot catch → `edmund-validation-and-qa`.
- The ongoing caret-integrity program (forward-looking) → `edmund-caret-integrity-campaign`.
- Debug flags (`-debug.reproScript`, verbose tracing toggles) → `edmund-config-and-flags`.
---
## 1. The delete-drift saga (rounds 16) — issue #156
The hardest bug in the project's history: pressing Delete moved the caret to a
different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04.
Full trail: `docs/investigations/delete-drift-investigation.md` (read it before touching
`+EditFlow` / `+SelectionTracking` / the heal). One symptom, FOUR distinct
root causes stacked on top of each other — each fix was real, and each round's
recurrence was a different mechanism underneath.
**STATUS: settled through round 6** (shipped in 0.1.3, 2026-07-04). The class
— live-only caret/selection desync — remains the project's hardest problem;
new rounds are possible. Backlog still lists "Delete caret drift" under
On-going bugs as a class to watch, not a known unfixed defect.
### Round 1 — stranded IME marked text (2026-06-26, `386604b` + docs `ef3d87e`)
- **Symptom:** once it started, EVERY delete drifted; never at launch; cleared
by switching apps and back. Text stayed correct — caret-only desync.
- **Root cause:** every styling path bails on `hasMarkedText()` (correct during
live IME composition). A *stranded* composition (`hasMarkedText()` stuck true,
no live composition) made `didChangeText` bail forever → `rawSource`/`blocks`
froze while storage kept mutating → all caret math ran against a stale model.
Strander: the async active-block restyle in `+SelectionTracking` re-checked
`isUpdating` but not `hasMarkedText()`, so it could run `recomposeDirty` over
a live composition scheduled one turn earlier.
- **Fix:** (a) add the missing `!hasMarkedText()` guard to the async restyle;
(b) `becomeFirstResponder` recovery hook — `unmarkText()` + resync when the
invariant is broken on focus regain (made the user's accidental focus-switch
cure deterministic).
- **Why it came back:** the guard closed one strander; other marked-text
sources existed (round 2), and other desync mechanisms entirely (rounds 46).
### Round 2 — marked text without "IME" (2026-06-27, `a1f3219`)
- **Symptom:** recurred with no CJK/accent/emoji input. Focus-switch still cured it.
- **Root cause (by elimination, documented in the doc):** still stranded marked
text — from **automatic text completion / inline predictions**, which inject
provisional marked text on plain typing.
- **Fix:** `isAutomaticTextCompletionEnabled = false`, `inlinePredictionType = .no`
in `commonInit`, plus a permanent `Log.info` breadcrumb in the recovery hook.
- **Why it came back:** the next recurrence wasn't marked text at all.
### Round 3 — no fix; built diagnostics instead (2026-06-28, `5dae387`, `3aaeb04`, PR #139)
- **Symptom:** recurred on a build with rounds 12. NO `recovered stranded desync`
log line; a headless probe of the exact gesture showed the model was CORRECT.
Only appeared after minutes of editing in one window.
- **Conclusion:** the model/parse layer is sound; the drift is a live
NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing
it blind was declared the wrong move.
- **Shipped:** verbose editor tracing (Settings ▸ Advanced, `Log.trace`,
category `.edit`, per-event live-state prefix) + an always-on O(1) invariant
tripwire (`verifyEditorInvariants`, logs `error` on length mismatch). This
instrumentation is what cracked rounds 46. Lesson: when a live-only bug
resists reproduction, ship diagnostics, not guesses.
### Round 4 — drag-move deletes with NO `didChangeText` (2026-07-02, `9f99795`, PR #163)
- **Symptom (from the round-3 trace):** drifting deletes showed
`shouldChangeText`*nothing*`selectionDidChange` mid-recompose with a
stale caret. Origin event: a drag-select, then `shouldChangeText OK repl=""`,
then LEN-MISMATCH forever — `didChangeText` never fired.
- **Root cause:** AppKit's drag-**move** gesture, when the drop lands past the
end of the document (or fumbles), performs the source deletion via
`shouldChangeText``replaceCharacters` and **never calls `didChangeText`**.
`rawSource` silently froze — and **autosave wrote the stale bytes**: this was
a data-corruption bug, not just a caret bug.
- **Fix:** `shouldChangeText` schedules a next-run-loop bypass check
(`RunLoop.main.perform`): a `pendingEdit` still unconsumed one pass later ==
didChangeText was bypassed → run the same sync it would have, log
`healing storage edit that bypassed didChangeText` (release too). Exempt
while `isUpdating`/`isUndoRedoing`/`hasMarkedText()` (IME legitimately defers).
- **Why it came back:** the heal restored the *model* but rounds 56 found the
heal itself could move the caret.
### Round 5 — the heal leaped the caret (stale selection) (2026-07-03, `422498f`, docs `c4a602b`, merged `222dd86`, PR #166)
- **Symptom:** heal fired, invariant restored, bytes correct — but the caret
leaped to the END of the document at the heal moment.
- **Root cause:** when the bypassed deletion removes the *selected* text, AppKit
also skips its usual selection fix, so at heal time the selection still spans
deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes
AppKit re-resolve the invalid selection → clamps to document end.
- **Fix:** before syncing, the heal **collapses an out-of-bounds selection** to
the edit point. (Headless NSTextView clamps this itself — the test documents
intent; only live layout reproduces the leap.)
- **Why it came back:** this out-of-bounds clamp was a special case of the real
mechanism, found in round 6.
### Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, `1b1420a`, branch `fix/wrapped-paragraph-caret-drift`, merged `218d922`, PR #169)
- **Symptom:** typing mid wrapped paragraph, one backspace leaped the caret +43
("two viewport-lines down"); drift no longer continuous — one delete drifts,
the next ones don't. Model fine; a heal had fired 80 seconds EARLIER.
- **Root cause (named via a `traceSelectionOrigin` stack capture):** a normal
edit runs TextKit 2's `_fixSelectionAfterChangeInCharacterRange` synchronously
inside its own transaction. A didChangeText-bypassing mutation skips that too,
so the fixup **stays queued and fires at the NEXT `endEditing`** — the heal's
attribute-only restyle — where it maps the stale selection against post-edit
coordinates and drops the caret blocks away. Fires exactly once (state is
then synchronized), explaining "drifts once, then fine". Round 5's clamp was
the sub-case where the stale selection ran past the shrunk document end.
- **Fix:** the heal derives the correct caret from the pendingEdit hull and
sets it **both before AND after** `syncRawSourceFromDisplay()`. The
before-only version still leaped — **the queued fixer moves even a freshly
set, fully valid caret** during the sync's `endEditing`. The post-sync
re-assert is the load-bearing half; the pre-set keeps `cursorRaw`/active-block
styling correct.
- **The breakthrough repro** (first deterministic one in six rounds):
`ReproScript.swift` (DEBUG-only, `-debug.reproScript <path>`) replays
keystrokes in-process through `window.sendEvent(_:)` — no TCC, works on an
invisible Space. `bypassdelete <needle>` simulates the drag-move deletion
exactly; one bypass beforehand → the next delete always drifts. Typing alone
never drifts. See `edmund-live-repro-and-diagnostics`.
### Do not retry (proven dead across the saga)
- **Headless/unit tests for this class.** The test harness runs TextKit 2's
selection fixups synchronously, so the deferred-fixup state never forms. The
round-6 unit test **passes with and without the fix** — it is a contract
spec, not a regression guard. Only the ReproScript live repro discriminates.
Do not "add a test to catch it" and call the class covered.
- **CGEvent injection as the default live driver.** Round 6's session dropped
the events (per-session TCC), and the app's windows launch on an inactive
Space. Use ReproScript first.
- **DEBUG assertion in `didChangeText`'s marked-text guard** — rejected in
round 1: the invariant is legitimately broken during composition; it
false-fires on every IME keystroke.
- **"No explicit selection repair needed"** (round 4's claim) — wrong twice.
Any new heal-like path must handle selection explicitly, before and after.
- **`swift build` trusted after "Build complete!"** — round 6 hit a stale-link
relink failure TWICE; two "failed" fix iterations were phantoms running old
code. Verify with `strings` on a LONG literal (≤15-byte literals inline on
arm64 and never show), cure with `swift package clean`, never hand-delete
`edmd.build/`. Details: `edmund-build-and-env`.
---
## 2. Undo/redo viewport drift — the costliest failure
**STATUS: settled** (2026-07-02, `5bb2b40`, part of PR #164) — with one caveat:
`misc/backlog.md` "Lurking (Unreproduceable)" carries a later note "Undo/Redo
and Copy/Paste scrolling is failing again". No repro exists. Treat the
*mechanism* below as settled and any new report as a NEW investigation that
starts from this entry.
- **Symptom:** undo scrolled too far down; redo centered on wherever the caret
sat before the undo; changed text never selected.
- **Root cause (two defects, found by code read before any experiment):**
1. `restoreSnapshot` ran a **full `recompose`** — replacing the entire
storage discards every TextKit 2 layout fragment, resetting ALL geometry
to height estimates; the subsequent centering math measured estimates.
2. `performUndo` recorded the redo snapshot with the caret *at undo
invocation time* (stale), and redo centered on it.
- **Fix:** `textDiff(old:new:)` single contiguous changed span → range-bounded
`recomposeReplacing` (layout outside the span stays real); the **changed
range** — never a stored caret — is selected and drives the viewport (hold if
visible, else center).
- **Load-bearing contract: never full-recompose on undo/redo.** Anyone
"simplifying" `restoreSnapshot` back to `recompose` reintroduces the bug.
Guarded by `TextDiffTests`, `UndoRedoSelectionTests`, `UndoRedoViewportTests`.
- **Prior art that treated symptoms without naming the estimate problem:**
`9aaa11b` (undo hold-or-center), `2778d6e`/`21cc284` (cursor-move lurch),
`84123e4` (pin scroll above viewport), `c49cd5c` (lazy viewport-first
styling). All sound, all workarounds; `5bb2b40` removed the manufactured
estimates at the source.
---
## 3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02)
Full trail: `docs/investigations/viewport-glitch-investigation.md`. Three reported symptoms,
one root cause: **every off-screen TextKit 2 frame is an estimate**; code that
discards layout, trusts off-screen y, or runs two scroll policies at once turns
estimate churn into visible jumps. Community-documented (Krzyżanowski /
STTextView, Apple forums) — even TextEdit exhibits it.
- **Bug 1 (undo/redo):** entry 2 above. **STATUS: settled** (same caveat).
- **Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up).**
Theory: TK2 assigns fragments **negative y** when estimates above the
viewport correct downward; scroller clamps at 0. Mitigations `217da5f`
(documents ≤100k UTF-16 kept **fully laid out** via a deferred
`scheduleFullLayoutSettle` — estimates never exist) and `8b4ecfe`
(`repairContentAboveOrigin`: first fragment `minY < -0.5` → re-lay
start→viewport-end inside `preservingViewportAnchor`; breadcrumb
`repairing content above origin`).
**STATUS: mitigated-unconfirmed.** The doc is explicit: Bug 2 was **never
reproduced live**; the repair had not been confirmed against a live
occurrence as of this writing (2026-07-05). If it recurs: grep
`~/.edmund/logs` for the breadcrumb — present means diagnosis confirmed but
repair raced/undersized; absent means different cause (scroller-only
estimate jumps, or `textContainerOrigin`).
- **Bug 3 (viewport oscillates during a steady drag-select).** Two scrollers
fighting: drag autoscroll pulling down vs the `scrollRangeToVisible`
override always revealing the selection **top** once the selection outgrew
the viewport. Fix `340fcbc`: reveal the **nearest** end. **STATUS: settled
by geometry/reasoning** — a live drag was never synthesized (that session
couldn't arm AppKit selection). Phase 1 of the same report ("can't select")
was NOT a bug: a whole-doc selection was active, so the drag was AppKit's
drag-move gesture — same family as delete-drift round 4.
- **Do not retry:** raising `fullLayoutMaxLength` without measuring
`ensureLayout` cost (full layout on large docs is the process-killing path
that motivated the lazy pipeline); running a full layout *inside* a caller's
`preservingViewportAnchor` (poisons its before/after measurement — the
tab-indent stability test caught a 366pt compensation; that's why the settle
is deferred).
- Backlog keeps "Inaccurate viewport estimates and things related" under
On-going bugs, plus a lurking "glitch when scrolling" — the estimate class
is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport
stabilization".
---
## 4. Callout-title wrap / the image wedge (settled 2026-07-03, `aa45563` + `ae61644`, PR #165)
Full trail: `docs/investigations/archives/callout-title-wrap-investigation.md` — including a 10-row
matrix of dead ends.
- **Symptom/goal:** custom callout titles (`> [!type] Title`) should render as
real wrapping text with the type icon; any attempt to draw the icon clipped
the wrapped title to one line.
- **Root cause:** **drawing any IMAGE on a multi-line layout fragment wedges
that fragment to a single line** — an unexplained TextKit 2 reentrancy quirk.
Isolated exhaustively: fragment overlay, frame-relative draw, before/after
`super.draw`, raw `CGContext.draw`, editor-level `draw(_:)`, pre-rasterized
bitmap, transparent subview, layer-backed subview, CALayer `contents` — ALL
clip. Controls: no icon → wraps; positions computed but plain rect filled
instead of the image → wraps. Reading layout is fine; drawing a **shape** is
fine; drawing an **image** is not.
- **Fix:** the icon is a **stroked `CGPath`**`SVGPath` parses the vendored
Lucide geometry, `FragmentOverlay` gained a path form,
`DecoratedTextLayoutFragment` strokes it in CG. Verified live: icon renders,
long titles wrap and re-wrap on resize.
- **Standing constraint:** any future overlay that can share a line with
wrapping text MUST use the path form, never the image form. Existing image
overlays (math, bullets, default callout header) survive only because they
sit on single-line fragments.
- **Do not retry:** any image-drawing mechanism from the matrix; bumping the
deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no
evidence, drops Sonoma incl. the dev machine). The whole-header-as-image
alternative is preserved on branch `fix/callout-title-image`
(tip `c7f5170`, unmerged): it sidesteps the wedge but has two unsolved
problems (~2× line height band above the title; a width-timing race).
- Still open nearby (backlog): callout icon baseline / crispness; the
callout-at-end-of-file extra colored line (live-path rendering bug).
---
## 5. The 0.1.0 release failures (2026-06-26 → 2026-07-02)
Reference: ARCHITECTURE §13. Five separate failures shipping and updating the
first releases. **STATUS: all settled**, with one time bomb (PAT expiry).
1. **`sign_update -s` exits 1 for new keys** (`59565f5`, 2026-06-27, PR #135).
Sparkle deprecated `-s <key>`; for keys generated after that change it
prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on
**stdin**`echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>`.
Do not retry `-s`.
2. **Bundle not sealed → EVERY update failed "improperly signed"**
(`5e54b40`, 2026-06-29, issue #158). Sparkle re-validates the Apple code
signature at install (`SUUpdateValidator``SecStaticCodeCheckValidity`);
the build signed only the main binary, never sealed the bundle, so a valid
EdDSA signature didn't save it. Fix: `codesign --deep` the whole `.app`
and because SwiftMath's resource bundle must sit at the `.app` root
(`Bundle.module` hardcodes `Bundle.main.bundleURL`) and codesign won't seal
a bundle with root items, **seal first, copy the SwiftMath bundle in
AFTER sealing**. The lone unsealed root item trips strict
`codesign --verify` but not Sparkle's non-strict check (verified against
that exact API). Do not "fix" the ordering or the failing strict verify.
3. **Appcast push to protected `main` rejected, `GH006`** (`e56a4dd`,
2026-06-28, PR #140). `GITHUB_TOKEN` isn't admin; `enforce_admins: false`
means an admin PAT bypasses the required check. Fix: fine-grained admin PAT
in secret **`RELEASE_TOKEN`**, set on the **checkout step** (not the push
URL — `actions/checkout` persists an `extraheader` credential that
overrides inline-URL creds). **`RELEASE_TOKEN` expires 2027-06-27**; rotate
before then or releases fail at the appcast push.
4. **create-dmg quirks** (`098d8c0`, 2026-06-26, documented in §8): it's the
**npm** create-dmg (sindresorhus), not the Homebrew tool; Node ≥20;
**exit code 2 for unsigned images is success**; space-in-filename
normalization.
5. **Release workflow YAML invalid** (`854f85d`, 2026-07-02, merged `232e6c8`,
PR #162). Literal multi-line bash strings inside a `run: |` block had
unindented lines — invalid in a YAML block scalar; the whole workflow file
failed to parse. Fix: build the strings with `printf` `\n` escapes; also
`$(...)` strips trailing newlines, so the separator newline lives in
`NEW_ITEM`'s format string, not `DESC_BLOCK`.
---
## 6. Reverts and abandoned directions
- **Selection tint** — `ee173f7` (2026-06-26): reverted an experimental
selection color back to **accent-derived (accent @ 30% alpha)**.
**STATUS: settled.** Don't re-hardcode a bespoke selection color.
- **`img.md-image { display:block }` in the export/Read HTML theme** —
`75d2824` (2026-06-25): reverted; it did NOT fix the image blank-space and
broke layout. The commit title itself records the verdict: **image
blank-space is a separate, STILL-OPEN bug** (backlog: "Attached image
padding… creates a large empty space below",
`misc/bug-repros/image-blank-after.mov`). Do not retry display:block for it.
- **Checkbox click-to-toggle + table borders** — `9991413` (2026-06-03):
pulled from a branch for separate passes. **STATUS: partially redone.**
Table work landed later (edit-mode table alignment shipped, per backlog
Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog —
**reverted-pending-redo**. The original attempt lives on stale branch
`feature/checkbox-toggle` (merged history, `d6227e3`).
- **TextKit 2 migration regressions** — `b3a4b29` (2026-06-12): the TK1→TK2
migration silently broke inline-math height, HR spacing, and scroll; fixed
with `RenderingRegressionTests` as the guard. Lesson: TK2 migrations
regress silently in geometry — extend that suite when touching layout.
- **Stale branches that look abandoned but are MERGED** (don't "rescue" them):
`feature/incremental-recompose` (tip `1222f79`, in main) and
`refactor/word-level-rendering` (merged via PR #8, `7eb6a21` — word-level
delimiter hiding became the shipped approach). The genuinely unmerged WIP
branches are `fix/callout-title-image` (entry 4) and dozens of old merged
topic branches never deleted.
---
## 7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6
`fix/wrapped-paragraph-caret-drift` / merge `218d922` IS delete-drift round 6
(entry 1): the branch name comes from the reporting symptom (backspace mid
wrapped paragraph), but the root cause was the queued TextKit 2 selection
fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was
incidental. **STATUS: settled** with `1b1420a`. If a caret drift is reported
"in a wrapped paragraph", do not assume wrapping is the mechanism; check for a
preceding heal breadcrumb in `~/.edmund/logs` first.
---
## 8. Smaller settled battles (one paragraph each; verified in git)
- **Flaky math fit-width test — took TWO rounds** (`5421297` 2026-06-06 branch
`fix/flaky-math-test`, then `352bdc9` PR #65). First fix pinned the text
container width (tracking off) — the flake persisted ~1 in 3 runs. Real
cause: **shared theme-defaults state pollution** between tests; fixed with
isolated defaults. Lesson: a flake "fix" that doesn't name the shared state
isn't done.
- **Emoji rendered as missing-glyph boxes** (`0f5ffba`, 2026-06-06).
`EditorTextStorage.fixAttributes` is a deliberate no-op (so `.attachment` on
real characters survives) — which also disabled font substitution. Fix:
perform substitution manually (Apple Color Emoji per composed-character
sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the framework
`fixAttributes`; it strips the marker attachments.
- **Nested list hanging indent** (`8d2088f`, 2026-06-06, PR #64).
swift-markdown's list-item delimiter excludes leading indentation; the
visible spaces broke the hang. Fix: hide the leading whitespace in the
inactive branch; indentation comes entirely from the paragraph style.
- **Ordered-list deep nesting lost styling** (`b255903`, 2026-06-06).
swift-markdown parses ≥4-space indent as indented code; the rescue regex
only matched `[-*+]`. Extended to `\d{1,9}[.)]`. Any new list-ish syntax
must be added to the rescue parser too.
- **Window size persistence — three commits to get right** (`538ff6e`
`d967bcf``678c5d6`, 2026-06-28, PR #144). Saving content-view size made
windows grow taller on reopen (titlebar double-counted); final form stores
the **full window frame** and restores via `setFrame`.
- **Toolbar right-click interception — three failed view-level attempts**
(`4eb604a`, `6723280`, then `f8472ca`, 2026-06-26). View `.menu`,
`rightMouseDown`, and a gesture recognizer all lost to the toolbar's
"Customize Toolbar…" menu. Working fix: `DocumentWindow` overrides
`NSWindow.sendEvent` — the documented funnel ahead of the toolbar — and
swallows secondary clicks on the view-mode button. Do not retry view-level
interception for anything the titlebar/toolbar claims.
- **Invisible CJK input** (`a3df387`): IME-composed text was invisible until
committed; fixed by keeping marked text visible. Related to (and predating)
the round-1 marked-text rules.
---
## 9. Still OPEN — do not let this chronicle imply otherwise
Per `misc/backlog.md` (cross-checked 2026-07-05): footnotes don't render
(edit or Read); math doesn't render in Read mode; math padding in edit mode;
**image blank-space below attached images** (see the `75d2824` revert);
callout-at-end-of-file extra colored line; max content width not applied to
Read mode; tables don't wrap/shrink at small content sizes; table-cell content
wraps out of the cell; "sometimes click to select / select+delete doesn't
work" (unreproduced); lurking scroll glitch from off-viewport height changes;
lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste
scrolling failing again" (see entry 2 caveat). Delete-caret-drift and
viewport-estimate classes stay on the watch list even though every known
mechanism is fixed.
---
## Provenance and maintenance
- Written 2026-07-05 by mining: `docs/investigations/delete-drift-investigation.md`,
`docs/investigations/viewport-glitch-investigation.md`,
`docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/ARCHITECTURE.md` §13,
`CHANGELOG.md`, `misc/backlog.md`, `docs/ROADMAP.md`, and `git log --all`
(every hash above verified with `git show` on that date).
- **Code and git win over prose.** If this file disagrees with a commit or the
current source, trust the commit, then fix this file.
- **Update triggers:** a new delete-drift round (append to entry 1 — never a
new doc); any live confirmation or refutation of `repairContentAboveOrigin`
(flip entry 3 Bug 2 off mitigated-unconfirmed); `RELEASE_TOKEN` rotation
(entry 5); any revert (entry 6); closing a backlog bug named in entry 9.
- Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed".
No oversell — this file's value is that its claims can be trusted blind.
- Sibling map lives in "When NOT to use" above; keep it in sync as the skill
library grows.
@@ -0,0 +1,287 @@
---
name: edmund-live-repro-and-diagnostics
description: >
How to MEASURE Edmund instead of eyeballing it — the diagnostic tools,
interpretation guides, and working scripts. Load when a bug involves LIVE
behavior (caret, IME, drag, viewport timing), when a unit test cannot
reproduce a report, when you need to read diagnostic traces, drive the
running app with scripted keystrokes, or measure pixels from a screenshot.
Contains the repro escalation ladder, the ReproScript driver, the CGEvent
fallback, and screencapture measurement, plus scripts/ helpers. Not the
symptom→mechanism table (edmund-debugging-playbook), not the campaign
(edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env).
---
# Edmund live repro & diagnostics
A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context
layer (deferred selection fixups, IME composition, drag sessions, event-loop
timing). **Headless tests cannot form the broken state** — the test harness runs
AppKit's deferred machinery synchronously, so a green unit test proves nothing
about this class. This skill is how you make such a bug cheap to observe, then
deterministic. Primary source doc: `docs/dev-guides/live-repro-guide.md`.
Verified 2026-07-05.
---
## 0. Safety preamble (do this every time)
- **Check for the user's live instance first.** The user's daily-driver app has
the same binary name (`edmd`). Run `scripts/check-live-instance.sh`. **Never
blanket `pkill -x edmd`** — kill only your own PID, or use `pkill -f
EdmundDbg` (only your debug bundle matches).
- **Recreate the test document fresh before every run** — autosave mutates it;
run 2 against run 1's leftovers produces garbage offsets.
- **Verify the binary is fresh** before trusting a run (SwiftPM sometimes prints
`Build complete!` without relinking `edmd`) — strings/shasum method in
**edmund-build-and-env**.
- **Do not request macOS Computer Access.** Screen Recording + Accessibility are
already granted for this project; ReproScript (§3) needs neither.
---
## 1. The escalation ladder
Work down; stop at the first level that reproduces. Each is more faithful and
more expensive than the one above.
| # | Technique | Faithful to | Cost | Use when |
|---|---|---|---|---|
| 1 | Plain unit test (`makeEditor()`) | model/parse/style logic | seconds | anything not event-timing-dependent |
| 2 | Windowed unit test (NSWindow + NSScrollView, real `deleteBackward(nil)`) | + layout, viewport, first responder | seconds | viewport/lazy-styling bugs (`LazyRenderingTests` setup) |
| 3 | **In-process ReproScript** (§3) | + real key path, run-loop pacing, real process | ~1 min/run | anything keyboard/edit-pipeline shaped — **the default for live bugs** |
| 4 | CGEvent driver (§4) | + real HID events, real mouse (drags!) | TCC-dependent | mouse-only paths: drag-select, drag-move, autoscroll |
| 5 | Instrumented field occurrence | everything | days | can't trigger it — instrument first (§2), decide on the next hit |
Two levels deserve emphasis:
- **Level 2 failing to repro is evidence, not defeat** — it tells you the bug
needs deferred/queued AppKit state, pointing you at level 34.
- **Level 3 exists because level 4 is unreliable** — background/agent sessions
often have no TCC grant and synthetic keyboard events get dropped silently.
In-process injection needs no permission.
---
## 2. Step 0 — make the trace tell you the trigger
Never script blind. The recipe is usually already in `~/.edmund/logs`, if
verbose diagnostics were on. Launch flags (file arg **must** be `argv[1]`):
```bash
<app>/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
```
**Trace-field decoder** (each verbose line carries these; from
`EditorTextView+Diagnostics.swift`):
| Field | Meaning |
|---|---|
| `sel` | current selection `{location,length}` |
| `active` | active (caret) block index |
| `marked` | is there marked/IME text |
| `up` | `isUpdating`**`up=Y` = event arrived mid-recompose** (suspicious) |
| `undo` | undo-stack depth |
| `blocks` | block count |
| `storLen` / `rawLen` | storage length vs rawSource length |
- **Healthy edit ordering:** `shouldChangeText``selectionDidChange` (up=N) →
`synced`. A transient `⚠︎LEN-MISMATCH` *between* those lines is normal
(storage moves before rawSource syncs).
- **Suspect:** a `selectionDidChange` with `up=Y` at a surprising position; a
**persisting** LEN-MISMATCH; a `shouldChangeText` with **no**
`synced`/`SKIPPED`/`DEFERRED` after it (a bypassed `didChangeText`); the
`healing storage edit that bypassed didChangeText` breadcrumb.
- **`traceSelectionOrigin`** logs a **call stack** for any selection change that
lands mid-recompose — this is what named `_fixSelectionAfterChange` in round 6.
- **Walk BACKWARDS from the first bad line, not forwards from the symptom.** The
round-6 drift was armed ~80 seconds and dozens of healthy edits before the
visible failure. The user-visible symptom is often the *second* half.
If the log didn't capture the deciding fact, **add the log line first** (keep
good ones behind `Log.shouldTrace` and ship them) and reproduce again. Also
**reconstruct the document** — wrapped-paragraph geometry, block boundaries, and
block kinds all matter; repro against a lookalike, never `"hello world"`.
`scripts/grep-trace.sh [YYYY-MM-DD]` surfaces the suspect patterns in one shot.
---
## 3. The in-process ReproScript driver (default for live bugs)
`Sources/edmd/App/ReproScript.swift`, **DEBUG builds only**. Replays a keystroke
script against the front document by synthesizing `NSEvent`s and pushing them
through `window.sendEvent(_:)` — the full authentic key route (keyDown →
`interpretKeyEvents``insertText:` / `deleteBackward:`). No Accessibility, no
visible window required (works on an inactive Space), real run-loop pacing.
Launch: `scripts/launch-debug.sh FILE.md SCRIPT.repro` (assembles EdmundDbg.app,
guards the user's instance, direct-execs with all flags). Or by hand:
```bash
build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript "$SCRIPT.repro" \
-ApplePersistenceIgnoreState YES &
```
**Command surface** (one per line, `#` comments allowed):
| Command | Effect |
|---|---|
| `sleep <ms>` | wait before the next command |
| `caret <needle>` | place caret before the first occurrence of `<needle>` |
| `type <text>` | one real key event per char, ~80 ms apart |
| `backspace <n>` | n real delete keystrokes, ~300 ms apart |
| `bypassdelete <needle>` | simulate the drag-move source deletion: select range, `shouldChangeText` + storage mutation, **no `didChangeText`** |
| `assertcaret <needle>` | log `repro assertcaret PASS/FAIL sel=… want=…` iff caret sits exactly before `<needle>` |
| `logsel` | log selection, rawSource length, doc count |
Round-6 minimal repro (the worked example): the deciding output was **`logsel`
321 (broken) → 290 (fixed)**, every run, window not even visible.
```
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken: {321,…}; fixed: {290,…}
backspace 2
logsel
```
**Design rules — keep them when extending:**
- **Address text by needle, never offset** — offsets go stale the moment a
script edits; needles survive (this is what makes soak scripts possible).
- **Real events over direct method calls** — `insertText("")` shortcuts skip
`deleteBackward`'s selection machinery, the exact place round 6 lived.
- **Simulate AppKit-internal paths by exact call sequence** — `bypassdelete`
replicates `shouldChangeText``replaceCharacters`, no `didChangeText`
*verbatim*, not an approximation. Pin any new internal path's real sequence
from a `traceSelectionOrigin` stack first, then replay it.
- **Asserts inside the app, results in the log** — the harness (you, or a shell
loop) only greps `PASS`/`FAIL`; the app is the oracle.
- New commands are ~10 lines each — extend `ReproScript.swift`, don't work around it.
**Soak scripts** (§6): chain several trigger cycles at different positions with
ordinary editing between them, `assertcaret` after each predictable step, and
compare final `rawLen` across runs (byte-identical = deterministic). A soak green
across 45 cycles is far stronger than one clean repro — it catches bugs needing
*armed state* (round 6's queued fixup).
```
bypassdelete Sizemore,
assertcaret Strang
backspace 2
type xy
bypassdelete widely
assertcaret used in various
logsel
```
---
## 4. CGEvent driver (mouse-only paths, TCC willing)
For paths that must originate as HID events — real drag-select, drag-move,
autoscroll — keyboard replay can't cover them. A ~70-line `ui.swift` (compile
with `swiftc`) posting `CGEvent`s does: `bounds <substr>` (window lookup via
`CGWindowListCopyWindowInfo`), `click x y`, `dragselect`, `dragmove` (mousedown +
**~400 ms hold** before moving, or AppKit never arms the text drag), `key`,
`type`.
Caveats (all hit in practice):
- **TCC decides per session.** Background/agent sessions often can't post
keyboard events (dropped silently) or use System Events. Test with **one click
+ log check**; if input doesn't land, fall back to §3 immediately — don't
iterate on driver variations.
- App windows are on an inactive Space until activated
(`kCGWindowIsOnscreen == false`); `osascript -e 'tell application "<path>.app"
to activate'` (Apple Events, a separate TCC bucket) may work where System
Events is denied.
- Re-activate before every interaction batch; focus is lost between shell calls.
---
## 5. Screencapture measurement
**Visual judgments are measured, not eyeballed** — when the task says "balance
padding" or "align the icon", capture the window and measure pixels.
`scripts/capture-window.sh <window-title-substring> out.png` finds the window id
(JXA → `CGWindowListCopyWindowInfo`) and runs `screencapture -x -o -l<id>`.
Notes:
- Capture **by window id**, reliable even when not frontmost.
- **Crop by the detected window bounds** — the desktop wallpaper defeats
screencapture's brightness-based auto-crop.
- Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for
the first/last colored row of a callout box). Report the pixel numbers, not an
impression.
- Window-server state can glitch (tiny windows, restoration) after many rapid
launch/kill cycles: `rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState`
and relaunch.
---
## 6. The loop, end to end
1. Verbose trace from the occurrence → find the **first bad line**, walk
backwards, form a trigger hypothesis (§2).
2. Reconstruct the document; script the hypothesized trigger (§3).
3. **No repro?** Hypothesis wrong or fidelity too low — move down the ladder
(§1), or instrument and wait for the next hit.
4. **Repro in hand? Freeze it** (exact script + document), then let it falsify
fix candidates — round 6's first "fix by reasoning" failed in the repro within
a minute.
5. Fix verified → **soak** (§3) → full `swift test` → keep the script + new
diagnostics → update the relevant `docs/*-investigation.md`.
**Meta-lesson from six rounds: time spent making the failure cheap to observe
beats time spent reasoning about the fix.** Every round that shipped on reasoning
alone came back; the round that shipped on a deterministic repro named the actual
mechanism.
---
## scripts/ (in this skill dir)
| Script | Purpose | Status |
|---|---|---|
| `check-live-instance.sh` | Report running `edmd`, exit 2 if any; never kills | logic verified; safe by construction |
| `grep-trace.sh [date]` | Surface suspect patterns in today's log | logic verified |
| `capture-window.sh <needle> <out.png>` | Screenshot a window by id + report bounds | **verify on first use** (JXA CGWindowList lookup not executed this session) |
| `launch-debug.sh <file.md> [script.repro]` | Build + assemble EdmundDbg.app + direct-exec with flags | **verify on first use** (assumes arm64 debug triple; guards user instance) |
All four pass `bash -n`. The two "verify on first use" scripts depend on live
system state (window server, build layout) that couldn't be exercised while
authoring; read the header comment before first run.
---
## When NOT to use this skill
- Deciding *which mechanism* a symptom implies → **edmund-debugging-playbook**.
- Running the full caret-integrity investigation → **edmund-caret-integrity-campaign**.
- Stale-binary detection / bundle internals → **edmund-build-and-env**.
- What counts as sufficient evidence to ship → **edmund-validation-and-qa**.
- The AppKit theory behind the fixup/marked-text mechanisms → **textkit2-appkit-reference**.
---
## Provenance and maintenance
Verified 2026-07-05 against `docs/dev-guides/live-repro-guide.md`,
`Sources/edmd/App/ReproScript.swift`, and
`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`.
```bash
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift
grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
```
Re-verify the scripts against `docs/dev-guides/live-repro-guide.md` §4 if the debug-bundle
assembly recipe changes.
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# capture-window.sh <window-name-substring> <out.png>
# Screenshot a specific window by id (reliable even when not frontmost).
#
# Mechanism: osascript(JXA) enumerates on-screen windows via the ObjC bridge
# to CGWindowListCopyWindowInfo, finds the first whose kCGWindowName contains
# the substring, prints "<id> <x> <y> <w> <h>", then `screencapture -x -o
# -l<id>` grabs it. We crop by the detected window bounds (the desktop
# wallpaper defeats screencapture's brightness-based auto-crop).
#
# VERIFY ON FIRST USE: JXA ObjC bridging + the CGWindowList key names are
# stable AppKit API, but this script has not been executed in this session.
# If the JXA lookup prints nothing, the window title didn't match or Screen
# Recording permission is missing (it is granted for this project per
# CLAUDE.md — do NOT request Computer Access to "fix" it).
set -euo pipefail
needle="${1:?usage: capture-window.sh <window-name-substring> <out.png>}"
out="${2:?usage: capture-window.sh <window-name-substring> <out.png>}"
read -r wid x y w h < <(osascript -l JavaScript <<JXA || true
ObjC.import('CoreGraphics');
ObjC.import('Foundation');
const info = \$.CGWindowListCopyWindowInfo(
\$.kCGWindowListOptionOnScreenOnly | \$.kCGWindowListExcludeDesktopElements,
\$.kCGNullWindowID);
const arr = ObjC.deepUnwrap(info);
const needle = "$needle";
for (const win of arr) {
const name = win.kCGWindowName || "";
if (name.indexOf(needle) !== -1) {
const b = win.kCGWindowBounds;
console.log([win.kCGWindowNumber, b.X, b.Y, b.Width, b.Height].join(" "));
break;
}
}
JXA
)
if [[ -z "${wid:-}" ]]; then
echo "No on-screen window title contains: $needle" >&2
exit 1
fi
screencapture -x -o -l"$wid" "$out"
echo "Captured window $wid ($needle) -> $out bounds=${x},${y} ${w}x${h}"
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# check-live-instance.sh — report any running `edmd` processes, refuse to kill.
#
# The user's daily-driver Edmund shares the binary name `edmd`. NEVER blanket
# `pkill -x edmd`. Run this first; if it finds an instance you did not start,
# leave it alone and launch your own debug bundle instead (launch-debug.sh
# uses EdmundDbg so `pkill -f EdmundDbg` only ever hits yours).
#
# Exit: 0 = no edmd running; 2 = at least one edmd running (inspect, don't kill).
set -euo pipefail
pids=$(pgrep -x edmd || true)
if [[ -z "$pids" ]]; then
echo "No running edmd instance."
exit 0
fi
echo "Found running edmd instance(s) — DO NOT pkill -x edmd:"
for pid in $pids; do
ps -o pid=,lstart=,command= -p "$pid"
done
exit 2
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# grep-trace.sh [YYYY-MM-DD] — surface the suspect patterns in today's (or a
# given day's) Edmund diagnostic log. Read-only.
#
# Requires the app to have run with:
# -settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES
set -euo pipefail
day="${1:-$(date +%F)}"
log="$HOME/.edmund/logs/edmund-${day}.log"
if [[ ! -f "$log" ]]; then
echo "No log for ${day} at ${log}" >&2
exit 1
fi
echo "== $log =="
echo "--- bypassed-didChangeText heals ---"
grep -n 'healing storage edit that bypassed didChangeText' "$log" || echo "(none)"
echo "--- content-above-origin repairs ---"
grep -n 'repairing content above origin' "$log" || echo "(none)"
echo "--- persistent length mismatches ---"
grep -n 'LEN-MISMATCH' "$log" || echo "(none)"
echo "--- repro asserts ---"
grep -n 'repro assertcaret' "$log" || echo "(none)"
echo "--- FAILs ---"
grep -n 'FAIL' "$log" || echo "(none)"
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# launch-debug.sh <file.md> [script.repro]
# Assemble/refresh build/EdmundDbg.app, then direct-exec it with diagnostic
# flags (and optionally replay a ReproScript). Kills only its OWN prior debug
# instance, never the user's daily-driver edmd.
#
# Steps (from docs/dev-guides/live-repro-guide.md §4):
# 1. swift build (debug).
# 2. Assemble EdmundDbg.app: Info.plist copy + debug edmd + Sparkle.framework
# (dyld aborts without the framework; a bare .build/debug/edmd never makes
# a window).
# 3. Refuse to run if a non-ours edmd is live (check-live-instance.sh).
# 4. Direct-exec the bundle binary (never `open -a`: LaunchServices can run a
# stale translocated copy).
#
# VERIFY ON FIRST USE: paths assume the standard arm64 debug layout
# `.build/arm64-apple-macosx/debug/`. If your host resolves a different triple,
# adjust BUILD_DIR. Binary-freshness check is a reminder, not enforced here —
# see edmund-build-and-env for the strings/shasum method.
set -euo pipefail
doc="${1:?usage: launch-debug.sh <file.md> [script.repro]}"
repro="${2:-}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" # repo root
cd "$ROOT"
BUILD_DIR=".build/arm64-apple-macosx/debug"
APP="build/EdmundDbg.app"
# 0. Never step on the user's instance.
here="$(dirname "${BASH_SOURCE[0]}")"
if pgrep -x edmd >/dev/null 2>&1; then
# Allow only if it's our own EdmundDbg (safe to replace).
if ! pgrep -f EdmundDbg >/dev/null 2>&1; then
echo "A non-EdmundDbg edmd is running — refusing to launch. Inspect:" >&2
bash "$here/check-live-instance.sh" || true
exit 2
fi
pkill -f EdmundDbg || true
fi
# 1. Build.
swift build
# 2. Assemble the bundle.
mkdir -p "$APP/Contents/MacOS"
cp Info.plist "$APP/Contents/Info.plist"
cp "$BUILD_DIR/edmd" "$APP/Contents/MacOS/edmd"
if [[ -d "$BUILD_DIR/Sparkle.framework" ]]; then
rm -rf "$APP/Contents/MacOS/Sparkle.framework"
cp -R "$BUILD_DIR/Sparkle.framework" "$APP/Contents/MacOS/Sparkle.framework"
else
echo "WARNING: $BUILD_DIR/Sparkle.framework not found; dyld may abort." >&2
fi
# 3. Launch by direct exec, background, with diagnostics on.
args=( "$doc"
-settings.general.diagnosticLogging YES
-settings.advanced.verboseEditorDiagnostics YES
-ApplePersistenceIgnoreState YES )
if [[ -n "$repro" ]]; then
args+=( -debug.reproScript "$repro" )
fi
echo "Launching $APP with: ${args[*]}"
"$APP/Contents/MacOS/edmd" "${args[@]}" &
echo "Launched PID $! — tail with: scripts/grep-trace.sh"
@@ -0,0 +1,400 @@
---
name: edmund-release-and-operate
description: >
Load when cutting or debugging an Edmund release, or operating the shipped
app. Triggers: version bump (Info.plist CFBundleShortVersionString /
CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml
/ release.sh / build-app.sh, appcast.xml or Sparkle update failures
("improperly signed", update never offered), sign_update / EdDSA keys /
SPARKLE_ED_PRIVATE_KEY / RELEASE_TOKEN, create-dmg or DMG naming problems,
Gatekeeper "damaged" reports, launching the built app, reading
~/.edmund/logs, crash reports (edmd-*.ips), or roadmap/priority questions.
---
# Edmund — release & operate
Date-stamped 2026-07-05. Verified against `.github/workflows/release.yml`,
`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`,
`appcast.xml`, `Info.plist`, `CHANGELOG.md`, `docs/ARCHITECTURE.md` §8/§13, and
the Settings/CrashReporter sources. Where a doc and a script disagree, the
script is the truth; disagreements are flagged inline.
**House rule: releases happen only when the maintainer explicitly asks.**
Never tag, push, create a release, or merge on your own initiative — see
`edmund-change-control`. Everything in §1–§4 below is a runbook for when the
maintainer says "cut a release", not a standing instruction.
## When NOT to use this skill
| You actually need | Go to |
|---|---|
| Build/test commands, stale-build cures, launch mechanics in depth | `edmund-build-and-env` |
| Editing invariants, render pipeline, TextKit 2 rules | `edmund-architecture-contract`, `textkit2-appkit-reference` |
| Debugging a bug in the app itself | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` |
| Past incidents and why the sharp edges below exist | `edmund-failure-archaeology` |
| Debug flags / launch arguments | `edmund-config-and-flags` |
| Branch/commit/PR etiquette, what needs maintainer sign-off | `edmund-change-control` |
| Pre-merge QA method | `edmund-validation-and-qa` |
| README/website/positioning copy | `edmund-docs-and-writing`, `edmund-external-positioning` |
---
## 1. Release flow — CI path (the normal one)
Ship via a tag; CI does the rest. In order:
1. **Bump versions in `Info.plist`** — both keys:
- `CFBundleShortVersionString` — marketing version, e.g. `0.1.3`
- `CFBundleVersion` — build number, **monotonic integer** (0.1.3 = `4`)
2. **Add a `## [x.y.z]` section to `CHANGELOG.md`** — format is load-bearing,
see §2. The version MUST match Info.plist exactly.
3. **Merge to `main`** and push (via the normal PR flow).
4. **Tag and push the tag:**
```sh
git tag vX.Y.Z && git push origin vX.Y.Z
```
5. CI (`.github/workflows/release.yml`, trigger `push: tags: 'v*'`, runner
`macos-14`, job `release` / "Build & publish") runs the steps below.
6. Afterwards, verify per §4 post-flight.
### release.yml step anatomy (actual step names)
| Step | What it does | Sharp edge |
|---|---|---|
| `actions/checkout@v5` | `fetch-depth: 0`, `token: ${{ secrets.RELEASE_TOKEN }}` | The PAT must be on **this** step — see §3.4 |
| `maxim-lobanov/setup-xcode@v1` | latest-stable Xcode | |
| **Cache .build** | SPM cache keyed on `Package.resolved` | |
| **Build app bundle** | `./scripts/build-app.sh` — release build, bundle assembly, Sparkle embed, **bundle sealing** | §3.2 |
| `actions/setup-node@v4` | pins Node 20 | create-dmg 8.x needs Node ≥ 20 |
| **Install create-dmg** | `npm install --global create-dmg` (sindresorhus/create-dmg, **not** Homebrew's) | §3.5 |
| **Create DMG** | reads VERSION from Info.plist, `create-dmg build/Edmund.app build/ \|\| true`, renames `"Edmund <v>.dmg"` → `Edmund-<v>.dmg`, fails loudly if no dmg | §3.5 |
| **Sign archive (EdDSA)** | finds `sign_update` in `.build`, key on **stdin** via `--ed-key-file -`, exports `ED_SIG` and `LENGTH` | §3.1 |
| **Create GitHub Release** | awk-extracts the CHANGELOG section → `gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest` | §2 |
| **Update appcast.xml** | builds the new `<item>` (HTML `<description>` via `scripts/changelog-to-html.py`), inserts it before `</channel>`, commits as `github-actions[bot]`, `git push origin HEAD:main` | §3.4 |
### Local path (`scripts/release.sh`)
Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update
appcast.xml **locally** → `gh release create`. Two differences:
- **Signing**: with `SPARKLE_ED_PRIVATE_KEY` in the env it uses the stdin path
(CI-style); otherwise `sign_update` pulls the key from the **login keychain**
(put there by Sparkle's `generate_keys`) with no flag at all.
- **The appcast commit/push is left to you.** The script ends with the exact
commands: `git add appcast.xml && git commit -m 'Release <v>' && git push`.
Prereqs for the local path: `gh auth status` authenticated, npm `create-dmg`
installed, `swift build` has run at least once (so `sign_update` exists under
`.build`).
> **Stale doc**: `misc/how-to-release.md` still says the artifact is a **zip**
> ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG
> switch. The truth is DMG throughout — per `release.yml`, `release.sh`, and
> ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it.
---
## 2. CHANGELOG format contract (release notes are machine-extracted)
Both `release.yml` and `release.sh` extract the GitHub Release body with:
```sh
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md
```
So the section header **must** start at column 0 as `## [x.y.z]` — literally
`## [0.1.3] — 2026-07-04` in house style (em dash + ISO date after the
bracket is fine; the match only requires the `^## \[x.y.z\]` prefix).
Extraction runs until the next `^## [` line. If nothing matches, the release
body falls back to "See CHANGELOG for details." — a silent-ish failure, so get
the header right. (Version dots are unescaped in the regex; harmless in
practice, don't rely on it.)
The Sparkle update-dialog notes come from the **same section** via
`scripts/changelog-to-html.py <version>`, a deliberately tiny converter that
only understands Keep-a-Changelog shapes:
- `### Added` / `### Changed` / `### Fixed` → `<h3>` — **use `###`, not `##`**.
The 0.1.2 appcast item literally shows `<p>## Changed</p>` because the
section used `##` subheads at release time; the converter passed them
through as paragraphs.
- `- ` / `* ` bullets → `<ul><li>`; indented continuation lines fold into the
previous bullet.
- `` `code` `` and `**bold**` are converted. **Markdown links are NOT** —
`[docs](docs/foo.md)` appears literally in the update dialog (see the 0.1.2
item). Keep appcast-facing notes link-free or accept the raw brackets.
- Blank lines and `---` are skipped; anything else becomes a `<p>`.
- Missing section → empty output → the `<description>` is simply omitted.
House format (verified from `CHANGELOG.md`): Keep a Changelog 1.1.0 + SemVer,
newest first, sections separated by `---`.
---
## 3. The sharp edges (each one killed or nearly killed a real release)
### 3.1 `sign_update -s` is FATAL — key goes on stdin
Sparkle deprecated `-s <key>`; for newly generated keys it prints a
deprecation warning and **exits 1** ("no longer supported"). This killed the
first 0.1.0 release. The only correct invocation with a key-in-hand:
```sh
echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>
```
Both `release.yml` and `release.sh` do exactly this. Never "simplify" it back
to `-s`. Output format: `sparkle:edSignature="<sig>" length="<n>"` — the
scripts grep those two attributes out for the appcast item.
### 3.2 Bundle sealing (the "improperly signed" update failure)
At install time Sparkle re-validates the update's **Apple** code signature
(`SUUpdateValidator`), independent of the EdDSA signature. A bundle that is
code-signed but not *sealed* (no `_CodeSignature/CodeResources`) fails that
check and every update dies with "The update is improperly signed and could
not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update
when the old script signed only the bare binary.
`build-app.sh` therefore signs inside-out and in a very deliberate order:
1. `codesign --force --deep --sign - Sparkle.framework` (nested XPC helpers
must be signed before macOS will launch them),
2. `codesign --force --deep --sign - --identifier "com.i7t5.edmd"` on the
whole `.app` **while its root holds only `Contents/`** — codesign refuses
to seal a bundle with extra items at the root,
3. copy the SwiftMath resource bundle to the `.app` root **after** sealing
(its generated `Bundle.module` looks at `Bundle.main.bundleURL`; without it
the app crashes on the first LaTeX render).
Consequence: `codesign --verify` (CLI) and `--strict` **will complain** about
that one unsealed root item. That is expected and fine — Sparkle's actual
check is non-strict (`SecStaticCodeCheckValidityWithErrors` with
`kSecCSCheckAllArchitectures`) and tolerates it; verified end-to-end against
that API. Do not "fix" the verify warning by moving the SwiftMath bundle or
re-signing after the copy.
### 3.3 Keypair discipline
One EdDSA keypair, three places, all of which must agree:
| Place | Used by |
|---|---|
| `Info.plist` `SUPublicEDKey` (`0XdLbbuO…`) | Every shipped app, to verify updates |
| Maintainer's **login keychain** | `release.sh` local signing (no flag) |
| GitHub secret **`SPARKLE_ED_PRIVATE_KEY`** | CI signing |
If the signing key and `SUPublicEDKey` diverge, everything *looks* fine — the
DMG signs without error — but **every user's update fails signature
verification**. Sanity check when in doubt:
`sign_update --verify <dmg> <sig>` against the Info.plist public key.
### 3.4 Appcast push to protected `main` — RELEASE_TOKEN
The workflow's last step commits `appcast.xml` and pushes to `main`, which
requires the `test` status check. The default `GITHUB_TOKEN` /
`github-actions[bot]` is not an admin, so that push is rejected with
`GH006 … protected branch hook declined`. Branch protection has
`enforce_admins: false`, so an admin's push bypasses the check — hence the
fine-grained **admin PAT** in the `RELEASE_TOKEN` secret (Contents:
read/write), set as the `token:` on the **checkout step**, not on the push.
That placement matters: `actions/checkout` persists an
`http.<host>.extraheader` credential that overrides inline-URL credentials,
so rewriting the push URL would keep pushing with the bot token anyway.
**`RELEASE_TOKEN` expires 2027-06-27.** Rotate it before then or every
release fails at the appcast push while the GitHub Release itself succeeds
(a confusing half-shipped state — see §4 post-flight).
### 3.5 create-dmg quirks
- It's the **npm** package `create-dmg` (sindresorhus), installed via
`npm install --global create-dmg`. Homebrew's `create-dmg` is a different
tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it).
- It exits **2** when it can't Developer-ID-sign/notarize the image (Edmund
ships ad-hoc) **but still produces the .dmg**. Both scripts run it with
`|| true` and then verify the file exists, failing loudly only if no dmg
was produced. Don't remove the `|| true`; don't trust the exit code.
- Output is named `"Edmund <version>.dmg"` — with a **space**. Both scripts
rename to `Edmund-<version>.dmg` (hyphen), which is the name the appcast
enclosure URL expects. If a rename is skipped, the release asset URL 404s
for every updater.
---
## 4. Pre-flight and post-flight
### Pre-flight (distilled from `misc/before-you-release.md` — read it too)
- [ ] `swift test` green **on `main`**, not just the branch; `git status` clean.
- [ ] No debug flags / launch args left on (repro drivers, verbose tracing —
see `edmund-config-and-flags`, ARCHITECTURE §8).
- [ ] Visual sanity: build and screencapture the editor in **light and dark**
mode; click through everything the CHANGELOG claims ("fixed X" → actually
reproduce X and confirm).
- [ ] `CHANGELOG.md` has `## [x.y.z] — YYYY-MM-DD` for this release and the
version **matches Info.plist** (`CFBundleShortVersionString`); `###`
subheads, not `##` (§2).
- [ ] `CFBundleVersion` bumped (monotonic int).
- [ ] `RELEASE_TOKEN` not expired (**2027-06-27**).
- [ ] Local path only: `gh auth status` ok; keychain key matches
`SUPublicEDKey` (§3.3).
### Post-flight
- [ ] GitHub Release `vX.Y.Z` exists with the right notes and the
`Edmund-<v>.dmg` asset (hyphenated name).
- [ ] `appcast.xml` on `main` got the new `<item>` — with `<description>`,
correct `sparkle:version` (= CFBundleVersion) and enclosure URL.
- [ ] Nothing to do for user prompts: Sparkle checks roughly daily; existing
users see the update within ~24 h. Don't panic if it isn't instant.
If the Release exists but the appcast commit is missing, the release
half-shipped (usually §3.4). Fix the token, then add the `<item>` manually or
re-run the job.
---
## 5. Gatekeeper story (why users see "damaged")
Edmund is **ad-hoc signed, not notarized** (no $99/yr Developer ID). First
launch of a downloaded copy trips Gatekeeper with the *"app is damaged"*
dialog. This is expected; the app is fine. The README documents both
workarounds (verified, README ~line 53):
- `xattr -dr com.apple.quarantine /Applications/Edmund.app`, or
- right-click → Open.
Known upgrade path (open/candidate, not scheduled): Developer ID certificate
+ notarization would remove the prompt entirely and also clean up the
non-strict-sealing compromise in §3.2. Don't promise it in user-facing text.
---
## 6. Operating the app
### Launching
`open Edmund.app` **foregrounds a running instance instead of relaunching** —
you'll stare at old code. And never `pkill -x edmd` blindly: the maintainer's
own Edmund session may be running (the Mach-O is `edmd` for both). Check
first (`pgrep -x edmd`), kill only PIDs you started, or launch the binary
directly: `build/Edmund.app/Contents/MacOS/edmd file.md &`. Full launch /
stale-build / screencapture mechanics: `edmund-build-and-env`.
### Logs — `~/.edmund/logs/edmund-YYYY-MM-DD.log`
- One file per day, human-readable lines tagged `LEVEL [category]`
(categories: app, document, io, render, compose, selection, lazy, callout,
edit — grep by concern).
- Controlled by **Settings ▸ Advanced ▸ Diagnostics** ("Save diagnostic
logs"). The toggle **defaults OFF** (`AppSettings.diagnosticLogging`
defaults false) — i.e. opt-in in the shipped app, despite `Log.swift`'s
header comment calling it "always-on (opt-out)"; the code is the truth.
(The UserDefaults keys are named `settings.general.*` for legacy reasons;
the UI lives in Advanced.)
- Retention picker ("Clear logs after:") next to the toggle; a separate
"Verbose editor tracing" opt-in gates keystroke-level trace lines — leave
off except during repros (`edmund-live-repro-and-diagnostics`).
- Release builds write `info` and up; DEBUG builds also write `debug`.
- Logs may contain document text; they never leave the machine.
### Crash reports — `~/Library/Logs/DiagnosticReports/edmd-*.ips`
- macOS names crash reports after the **Mach-O executable**: look for
`edmd-<timestamp>.ips`, not "Edmund-…".
- **Uploading is opt-in and currently INERT.** The Settings toggle is
commented out in `Sources/edmd/Settings/AdvancedSettingsView.swift` ("dormant
until the receiving server exists"), and
`CrashReporter.reportingEndpoint` is a placeholder
(`https://REPLACE-ME.invalid/crash`). Nothing is ever sent in shipped
builds. Don't tell users crash reporting exists; don't uncomment the toggle
without a real server. Code: `Sources/EdmundCore/Diagnostics/CrashReporter.swift`.
- Reading works only because Edmund is **not sandboxed**; adopting App
Sandbox would force a MetricKit rewrite (noted in CrashReporter's header).
- **Triage of a user's `.ips`**: it's JSON — a one-line metadata header, then
the report body. Look at `exception` (type/signal), `faultingThread`, and
walk that thread's frames for images named `edmd` or `Sparkle`. Ad-hoc
builds ship no dSYM, so expect addresses rather than symbol names for app
frames; correlate with `~/.edmund/logs` from the same timestamp instead.
`.ips` files embed the user's home path and device model — treat as
mildly personal data.
### Update mechanics (user side)
- `SUFeedURL` = `https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml`
— the raw-GitHub URL of the checked-in appcast; committing to `main` *is*
publishing.
- `SUEnableAutomaticChecks` is true; no custom interval is set, so Sparkle
uses its default ~24 h cadence (plus a check on launch).
- Sparkle downloads the DMG enclosure, verifies EdDSA against `SUPublicEDKey`,
**mounts the DMG**, re-validates the Apple code signature (§3.2), installs.
---
## 7. Versioning & appcast conventions
| Thing | Convention | Current (2026-07-05) |
|---|---|---|
| Git tag | `vX.Y.Z` | `v0.1.3` pending its tag; last released 0.1.2 |
| `CFBundleShortVersionString` | SemVer marketing version | `0.1.3` |
| `CFBundleVersion` | monotonic integer, +1 per release | `4` |
| CHANGELOG | Keep a Changelog 1.1.0, `## [x.y.z] — YYYY-MM-DD`, `###` subheads, `---` separators | — |
`appcast.xml` (checked into repo root): RSS 2.0 with the `sparkle:` namespace.
One `<channel>` (title/link/description/language) containing one `<item>` per
release. Items are inserted **before `</channel>`**, so the file reads oldest
→ newest; Sparkle doesn't care about order — it picks by version. Per item:
```xml
<item>
<title>Edmund 0.1.2</title>
<pubDate>Fri, 03 Jul 2026 19:03:59 +0000</pubDate>
<description><![CDATA[ …HTML from changelog-to-html.py… ]]></description>
<enclosure url="https://github.com/I7T5/Edmund/releases/download/v0.1.2/Edmund-0.1.2.dmg"
sparkle:version="3" <!-- CFBundleVersion -->
sparkle:shortVersionString="0.1.2" <!-- marketing version -->
sparkle:edSignature="…"
length="7608991"
type="application/x-apple-diskimage"/>
</item>
```
`<description>` is optional (omitted when the CHANGELOG section is missing).
---
## 8. Roadmap context (for release-content judgment)
- Edmund is in **beta** (0.1.x line, first public release 0.1.0 on
2026-06-27). Small, frequent releases.
- **v0.2.0 goal: "Polished editing experience"** (`misc/backlog.md` § Now).
- **Priority ordering: Marketing = Bugs >= UI/UX > Features** — when deciding
what makes a release, bug fixes and polish beat new features.
- Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork):
`docs/ROADMAP.md`; working backlog with per-bug detail: `misc/backlog.md`.
---
## Provenance and maintenance
Written 2026-07-05 from direct reads of: `.github/workflows/release.yml`,
`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`,
`appcast.xml`, `CHANGELOG.md`, `Info.plist`, `README.md`,
`docs/ARCHITECTURE.md` (§8, §13), `misc/how-to-release.md`,
`misc/before-you-release.md`, `docs/ROADMAP.md`, `misc/backlog.md`,
`Sources/EdmundCore/Diagnostics/CrashReporter.swift`,
`Sources/EdmundCore/Diagnostics/Log.swift`,
`Sources/edmd/Settings/AdvancedSettingsView.swift`,
`Sources/edmd/Settings/AppSettings.swift`.
Known stale docs at time of writing: `misc/how-to-release.md` (zip vs DMG,
§1); `Log.swift` header ("always-on (opt-out)" vs the actual default-off
toggle, §6). Minor oddity, deliberate: `build-app.sh` signs with
`--identifier "com.i7t5.edmd"` while the bundle id is `com.i7t5.edmund`.
Re-verify when any of these change: `release.yml` step names or secrets,
`build-app.sh` signing order, the CHANGELOG header format (the awk regex in
two places must match it), `SUFeedURL`, `RELEASE_TOKEN` rotation (hard
deadline 2027-06-27), notarization status, or the crash-report server going
live (which un-inerts §6's crash uploading and this skill's wording).
@@ -0,0 +1,196 @@
---
name: edmund-research-frontier
description: >
Open problems where the Edmund Markdown editor could advance the state of the
art — product-first, everything labeled candidate/open, nothing proven. Load
when picking the next big problem, evaluating whether an ambitious idea is
worth starting, or answering "what would move this project beyond state of the
art". Each frontier: why current SOTA fails, Edmund's specific asset, the
first three concrete steps IN THIS REPO, and a falsifiable "you have a result
when…" milestone. Not for running an accepted investigation
(edmund-caret-integrity-campaign), the method of proof
(edmund-research-methodology), or shipping the change (edmund-change-control).
---
# Edmund research frontier
Where Edmund could go past the state of the art. Ambition is **product-first**:
"the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are
**means, not ends** — a technique is worth pursuing when it makes the product
better, and it becomes publishable as a side effect.
**Everything here is candidate / open. Nothing is proven.** Each item routes its
proof through **edmund-research-methodology** (hypothesis predicts numbers) and
its changes through **edmund-change-control**. Verified 2026-07-05 against the
repo; assets cited are real, outcomes are not.
---
## Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16)
**Why SOTA fails:** TK2 lays out only near the viewport; off-screen fragment
heights are **estimates** corrected as layout reaches them. This makes the
scroller jump and scroll-to-target miss in **every** TK2 app, including TextEdit
— a widely documented limitation. Above `fullLayoutMaxLength` (100k) Edmund
enters this regime.
**Edmund's asset:** the mitigations already in `TextView/`
`scheduleFullLayoutSettle`, `preservingViewportAnchor`, `repairContentAboveOrigin`,
`centerViewportOnCaret` re-measure, and the diff-based undo restore that avoids
resetting fragments to estimates; plus the `ScrollStabilityTests` /
`HeightStabilityTests` harnesses. (Note: `repairContentAboveOrigin` is
**mitigated-unconfirmed live** per `docs/investigations/viewport-glitch-investigation.md` — a
real result here would also *retire that honest gap*.)
**First three steps in this repo:**
1. Build a large-doc fixture (`makeLargeMarkdown` in `TestHelpers.swift`) >100k
and a scripted scroll-accuracy metric (ReproScript `caret` + a `logsel`-style
position dump, or extend `PerfHarnessTests`).
2. Quantify the estimate-error distribution: for N scroll-to-target operations,
record predicted vs actual landing pixel offset.
3. Prototype persistent per-fragment height caching across the settle (or across
sessions) and re-measure the same distribution.
**You have a result when:** scroll-to-target lands within a stated pixel budget
on a 1 MB document, measured by script, with **zero** `repairing content above
origin` events across a scroll soak — reproducibly.
---
## Frontier 2 — Caret integrity by construction
**Why SOTA fails:** every `NSTextView` consumer depends on `didChangeText`
pairing that **AppKit itself violates** (the drag-move bypass). The delete-drift
class is the symptom of building sync on a callback contract AppKit doesn't keep.
**Edmund's asset:** the heal machinery, the `pendingEdit` model, six documented
rounds of mechanism knowledge, and the ReproScript + soak methodology. The
campaign skill runs *individual* rounds reactively; this frontier is the
**structural endgame** — eliminate the class.
**First three steps:**
1. Inventory **every** storage-mutation entry point (grep `replaceCharacters`,
`setAttributes`, the edit-flow paths) and tabulate which currently rely on a
callback firing.
2. Design a sync layer keyed on a **storage-version counter** that reconciles
`rawSource` regardless of which callbacks fired (not a new guard per path).
3. Falsify it against **all** historical `.repro` scripts plus a new randomized
bypass-fuzzer script.
**You have a result when:** all historical `.repro` scripts and a randomized
bypass soak stay green **with the callback-pairing assumption deleted from the
code**. **Candidate, big** — likely a multi-PR redesign; do not start without the
methodology skill's evidence bar in front of you.
---
## Frontier 3 — The 10 MB class (performance headroom)
**Why it matters:** README claims "~12 MB files"; native-with-no-Electron is the
differentiator, so headroom is a product claim, not vanity.
**Edmund's asset:** viewport-based lazy styling, the idle drain, incremental
reparse (`pendingEdit` window), `PerfHarnessTests`.
**First three steps:**
1. Extend `PerfHarnessTests` with 5/10 MB fixtures (`makeLargeMarkdown`).
2. Profile the block-parse and restyle hot paths (`Log.measure` single-line
durations are already in place).
3. Set explicit latency budgets for open, first-paint, and per-keystroke restyle
at 10 MB.
**You have a result when:** open + steady-state typing latency on a 10 MB file
meets a stated budget, measured by the harness (not hand-timed).
---
## Frontier 4 — A native extensions API
**Why SOTA fails:** Obsidian/VS Code plugin ecosystems are Electron; there is no
strong precedent for a **native, safe, fast** extension surface for live-preview
Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations,
primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as
"official extension" candidates.
**Edmund's asset:** the custom-parser seam
(`Parsing/SyntaxHighlighter+CustomParsers.swift`), the `BlockKind`/`styleBlock`
architecture, and already-modular opt-in syntax (math, Obsidian syntax).
**First three steps:**
1. Catalog which existing features could be **re-implemented as extensions**
(dogfood: callouts? highlight? wikilinks?) — this defines the real extension
points.
2. Define the minimal seam: block parser? inline parser? theme hook? Draw the
line at what the custom-parser architecture already supports.
3. Spike **one** official extension behind a flag and measure restyle cost vs the
built-in.
**You have a result when:** one built-in syntax feature runs as an extension with
**no measurable restyle regression** against the built-in baseline.
---
## Frontier 5 — Accessibility / RTL / localization as a differentiator
**Why it matters:** native apps *can* excel where Electron editors are weak;
ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width
already ships as precedent that the pipeline can be locale-sensitive.
**Edmund's asset:** the attribute-only pipeline (structure is in the string, not
in inserted characters), the existing locale-aware content-width path.
**First three steps:**
1. VoiceOver audit of `EditorTextView`'s custom drawing — does the accessibility
tree expose headings/lists/callouts, given they're drawn as decorations?
2. Test RTL behavior of the attribute-only styling on a right-to-left document.
3. Scriptable a11y check (structure read-out) as a regression guard.
**You have a result when:** a scripted VoiceOver audit reads document structure
(headings, list items, callouts) correctly on a mixed document.
---
## How to start one
1. **Predict numbers first** (edmund-research-methodology §2) — every milestone
above is a *number*, not a vibe.
2. Instrument to make the current failure/limit cheap to measure.
3. Prototype behind a flag; measure predicted vs observed.
4. Route changes through **edmund-change-control** (branch, tests, no auto-push).
5. When the first milestone lands, the item **graduates to `misc/backlog.md` or
`docs/ROADMAP.md`** and stops being a frontier.
## What NOT to start (no current asset)
- **iOS / iPadOS port** — explicitly **TBD** in ROADMAP; no shared UI layer today.
- **Collaborative / real-time editing** — zero repo support (no CRDT, no sync,
single `NSDocument` model). Would be a new product, not a frontier of this one.
- Anything that requires breaking an invariant (storage == rawSource; TextKit 2
only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract).
---
## When NOT to use this skill
- Running an *accepted* investigation (a known bug) → **edmund-caret-integrity-campaign** / **edmund-debugging-playbook**.
- The method of turning a hunch into proof → **edmund-research-methodology**.
- Whether a public claim is allowed yet → **edmund-external-positioning**.
- Shipping the change → **edmund-change-control**.
---
## Provenance and maintenance
Verified 2026-07-05. Assets exist; **outcomes are unproven by definition**
never quote a milestone here as achieved.
```bash
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift
grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md
grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift
```
When an item's milestone lands, move it to ROADMAP/backlog and delete it here —
a frontier list that keeps solved problems is lying.
@@ -0,0 +1,196 @@
---
name: edmund-research-methodology
description: >
The discipline that turns a hunch into an accepted result in the Edmund
Markdown editor — the evidence bar, hypothesis-predicts-numbers, the
first-principles analysis recipes, the idea lifecycle, and where good ideas
historically came from. Load when forming a hypothesis about a bug's
mechanism, evaluating whether an investigation's conclusion is trustworthy,
deciding if a fix is actually proven, or turning an idea into an accepted
change. This skill also absorbs the proof-and-analysis toolkit (prove it,
don't just install it). Not the caret-integrity campaign itself
(edmund-caret-integrity-campaign), not the repro tooling
(edmund-live-repro-and-diagnostics).
---
# Edmund research methodology
How a hunch becomes something you can ship without it coming back. Every method
here is grounded in a real episode from this repo's history — the discipline was
paid for in the six delete-drift rounds and the viewport work.
Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`,
`docs/investigations/viewport-glitch-investigation.md`, and `docs/dev-guides/live-repro-guide.md`.
---
## 1. The evidence bar
A mechanism is **accepted** only when it clears both bars:
1. **It explains ALL observations, including the negatives.** Not just "the caret
drifts" but *why headless tests pass, why it's intermittent, why it appears
minutes after the trigger.* A mechanism that explains the symptom but not the
negatives is incomplete — and incomplete mechanisms come back.
2. **It survives assigned adversarial refutation.** Before shipping, actively try
to **falsify** the supposed fix: run the frozen repro against it. Rounds 15
of delete-drift shipped on reasoning and **all came back**; round 6's first
fix candidate "worked by reasoning" and **failed in the repro within a
minute** (`docs/investigations/delete-drift-investigation.md` round 6, "the iteration that
proved its shape").
Corollary: **headless-green is not the bar** for live-input bugs — the round-6
regression test passes with and without the fix. See edmund-validation-and-qa
for the evidence hierarchy.
---
## 2. Hypothesis predicts numbers BEFORE running
State the expected observable **before** the experiment. An experiment without a
predicted number is a fishing trip.
- **Worked example:** round-6 `logsel` predicted **321** on the broken build vs
**290** on the fixed build — a specific number, stated before the run, that the
repro then confirmed every time.
- **Determinism as a prediction:** a soak's final `rawLen` must be
**byte-identical** across runs; predict it, then check it.
If you can't name the number your hypothesis predicts, you don't understand the
mechanism well enough to test it yet — go back to instrumentation (§3b).
---
## 3. The analysis recipes (prove it, don't just install it)
Each: when to use, the steps, and the episode that earned it.
### 3a. Trace archaeology — walk BACKWARDS
**When:** any symptom with diagnostics on. **Steps:** find the first *bad* line,
then read *upward/earlier*, not forward from the symptom. **Episode:** the round-6
drift at 22:13 was armed by a bypass at 22:11:57 — 80 seconds and dozens of
healthy edits earlier. Source: `docs/dev-guides/live-repro-guide.md` §2.
### 3b. Make the failure cheap to observe before reasoning about fixes
**When:** you're tempted to guess. **Steps:** add one breadcrumb (a call stack, a
state dump) behind `Log.shouldTrace` and ship it; reproduce; let the log name the
culprit. **Episode:** `traceSelectionOrigin` named `_fixSelectionAfterChange` in
a single run after five rounds of guessing. This is *the* meta-lesson: **time
spent making the failure cheap to observe beats time spent reasoning about the
fix.**
### 3c. The fidelity ladder as an inference tool
**When:** a repro fails. **Steps:** a repro that fails at level N but succeeds at
N+1 **localizes** the mechanism to what N lacks. **Episode:** delete-drift not
reproducing in a windowed unit test (level 2) told the team it needed
deferred/queued AppKit state → pointed straight at level-3 in-process replay and
event-ordering hypotheses. Source: `docs/dev-guides/live-repro-guide.md` §1.
### 3d. Invariant auditing
**When:** the behavior looks impossible. **Steps:** find which invariant
**transiently broke**`storage == rawSource` during IME composition;
`didChangeText` pairing during a drag-move; grep the guard inventory
(`hasMarkedText`, bypass checks). Lock it with an equivalence/fuzz oracle:
`RecomposeEquivalenceTests` (`assertMatchesFullRecomposeOracle` — incremental
restyle must equal a full restyle) and `IncrementalParseFuzzTests` (random edits
keep window-reparse == full reparse).
### 3e. Exact-sequence replication
**When:** simulating an AppKit-internal path. **Steps:** replay its **real call
sequence**, pinned from a stack trace, not an approximation of its *effect*.
**Episode:** `bypassdelete` reproduces `shouldChangeText``replaceCharacters`
with no `didChangeText` **verbatim** — an approximation would have hidden the bug.
### 3f. Differential measurement over eyeballing
**When:** any visual or binary claim. **Steps:** screencapture **pixel**
measurement for visuals (edmund-live-repro-and-diagnostics §5); `shasum`
before/after for "did the binary actually change"; predicted-vs-observed tables
for numbers. **Episode:** the maintainer's rule — when told "balance the padding",
measure the top vs bottom pad in pixels, don't judge by eye.
---
## 4. The idea lifecycle
How an idea moves from hunch to settled (or to a documented dead end):
```
hunch
→ cheap instrumentation / discriminating experiment (§3b, §3c)
→ investigation-doc entry (docs/<topic>-investigation.md, round structure)
→ frozen deterministic repro
→ fix on its own fix/ branch, with test + soak (edmund-validation-and-qa)
→ ARCHITECTURE §8 gotcha entry in the SAME PR (if an invariant changed)
→ settled status in the chronicle (edmund-failure-archaeology)
```
**Retirement path for ideas that fail:** a documented dead end in the
investigation doc with a "do not retry" note and *why* (see the round chronicle
and the viewport doc's "Verification limits (honest gaps)" section — mitigations
that are real but **unconfirmed live** are labeled so, not oversold).
**Where larger ideas are tracked:** `docs/ROADMAP.md` (versioned feature plan)
vs `misc/backlog.md` (near-term working list; priority order **Marketing = Bugs
>= UI/UX > Features**). A frontier idea graduates onto one of these when its
first milestone lands (edmund-research-frontier).
---
## 5. Where good ideas historically came from
Mine these seams first — they've paid out repeatedly:
- **Making failures observable.** The diagnostics accumulated one breadcrumb at a
time; each named a mechanism the previous round guessed at.
- **Reading Apple's *actual* behavior, not the documented ideal.** Sparkle's
update validation is non-strict (`SecStaticCodeCheckValidityWithErrors`) —
discovered by testing the real API, which unblocked the whole update pipeline.
`NSWindow.sendEvent` as the pre-toolbar funnel solved the toolbar right-click
after `menu`/`rightMouseDown`/gestures all failed.
- **Prior art consulted before inventing.** `nodes-app/swift-markdown-engine`
(ARCHITECTURE §14) solves the same TK2 live-preview problems with different
trade-offs — a comparison point and technique source. MarkEdit/CotEditor as
product references (ROADMAP).
- **Field reports with logs + movs.** `misc/bug-repros/` holds the maintainer's
screen recordings and trace logs — the round-4 drag-move mechanism came
straight out of one such trace.
---
## 6. Anti-patterns (each with its historical cost)
| Anti-pattern | Cost paid |
|---|---|
| Fix at symptom time | Patches the wrong instant — symptom armed minutes earlier (round 6) |
| Trust headless green for a live-input bug | Round-6 test passes with *and* without the fix |
| Stack speculative fixes | Rounds 15 each "fixed" it; each came back |
| Skip document reconstruction | Geometry/block-kind-dependent bugs don't fire on `"hello world"` |
| Trust a possibly-stale binary | SwiftPM prints `Build complete!` without relinking → wrong conclusions |
| Declare victory without a soak | One clean repro misses armed-state / degradation bugs |
---
## When NOT to use this skill
- Actually running the caret investigation step by step →
**edmund-caret-integrity-campaign**.
- The repro drivers and trace decoding → **edmund-live-repro-and-diagnostics**.
- What evidence a change type requires + the test suite → **edmund-validation-and-qa**.
- The settled history you're checking against → **edmund-failure-archaeology**.
- Picking the next big problem → **edmund-research-frontier**.
---
## Provenance and maintenance
Verified 2026-07-05.
```bash
grep -nE '^## Round|honest gaps|Verification limits' docs/investigations/delete-drift-investigation.md docs/investigations/viewport-glitch-investigation.md
grep -n '321\|290' docs/investigations/delete-drift-investigation.md # the predicted numbers (§2)
grep -rn 'assertMatchesFullRecomposeOracle' Tests/EdmundTests/
ls misc/bug-repros/ # field-evidence seam (§5)
```
The methods are stable; the *episodes* they cite are the drift risk — if a new
round rewrites the delete-drift narrative, refresh the worked examples here.
@@ -0,0 +1,208 @@
---
name: edmund-validation-and-qa
description: >
What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests.
Load when writing or adjusting tests, deciding what proof a fix needs before
it can ship, judging whether a change is actually "verified", interpreting
the test suite, or adding a golden/regression check. Covers the evidence
hierarchy (headless is necessary but not sufficient for live-input bugs),
the test-suite map, the Swift Testing helpers, how to add a test, the golden
inventory, visual QA, and performance evidence. Not the gating rules
themselves (edmund-change-control) or how to run the live repro
(edmund-live-repro-and-diagnostics).
---
# Edmund validation & QA
The discipline of proof. The central lesson: **a green headless test is
necessary but not sufficient** for the bug classes that cost the most time here.
Know which evidence each change actually requires.
Framework: **Swift Testing** (`import Testing`, `@Test`, `#expect`, `#require`)
— NOT XCTest. Verified 2026-07-05: 810 `@Test` cases across `Tests/EdmundTests/`.
---
## 1. The evidence hierarchy
Ordered weakest → strongest. The rule at the bottom names which class each
change type **requires**.
| Class | What it proves | Blind spot |
|---|---|---|
| (a) Headless unit test (`makeEditor()`) | model/parse/style/undo logic | **Cannot** exercise deferred AppKit machinery — runs it synchronously |
| (b) Windowed unit test (real NSWindow + NSScrollView, real `deleteBackward`) | + layout, viewport, first responder | still not real event-loop pacing / IME / drag |
| (c) Frozen live ReproScript repro (exact script + document) | the live input/timing mechanism | needs a debug build + ~1 min/run |
| (d) Soak script green across 45 cycles, byte-identical final `rawLen` | armed-state + determinism | slow |
| (e) Screencapture pixel measurement | anything that DRAWS | manual |
**The trap that defines this skill:** the delete-drift **round-6 regression test
passes with AND without the fix** — the harness runs the queued selection fixup
synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing
bugs, class (a) is not evidence of a fix; you need (c)+(d).
**Required evidence by change type** (gates enforced in edmund-change-control):
| Change | Requires |
|---|---|
| Pure logic (parse/style/model) | (a) |
| Viewport/lazy-layout | (b), often (e) |
| Anything that draws | (a where testable) + **(e)** in light AND dark |
| Edit-pipeline / selection / IME / undo | (a) to lock the headless contract **+ (c)** frozen repro **+ (d)** soak |
| Release | see edmund-release-and-operate |
---
## 2. Test-suite anatomy
Run: `swift test` (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 `@Test`
cases as of 2026-07-05). One suite: `swift test --filter <Suite>`. **`swift test`
also runs automatically as a Stop hook** (`.claude/settings.json`) at the end of
any turn touching code, so failures surface before you commit.
Map of `Tests/EdmundTests/` (what the files actually cover):
- **Parsing:** `BlockParserTests`, `SyntaxHighlighterTests`, `IncrementalParseFuzzTests`, `PendingEditTests`, `EscapeRenderingTests`, `HTMLTagRenderingTests`, `EmojiRenderingTests`, `LineEndingTests`.
- **Rendering / styling:** `BlockStylingTests`, `CalloutRenderingTests`, `CalloutTests`, `CommentRenderingTests`, `NestedBlockStylingTests`, `InlineStylingTests`, `MathRenderingTests`, `ImageRenderingTests`, `CodeHighlighterTests`, `FootnoteTests`, `WikiLinkTests`, `TableAlignmentTests`, `RenderingRegressionTests`.
- **Editor behavior:** `EditorIndentationTests`, `ListContinuationTests`, `BlockquoteContinuationTests`, `BlockquoteDeletionTests`, `FormattingTests`, `ActiveBulletMarkerTests`, `NewListItemAlignmentTests`.
- **Edit-pipeline integrity (the costly class):** `BypassedEditSyncTests`, `MarkedTextDesyncTests`, `WrappedParagraphCaretTests`, `EditorDiagnosticsTests`, `UnmatchedDebugTests`, `InternationalInputTests`.
- **Viewport / layout / undo:** `LazyRenderingTests`, `ScrollStabilityTests`, `HeightStabilityTests`, `TypewriterCenteringTests`, `UndoRedoViewportTests`, `EditorUndoTests`, `RecomposeTests`, `RecomposeEquivalenceTests`.
- **Export / read mode:** `HTMLRendererTests`, `DocumentHTMLTests`, `ReadModeWebViewTests`, `HTMLThemeTests`, `EditorThemeTests`, `ViewModeTests`, `ContentWidthTests`.
- **Infra / harness:** `LogTests`, `CrashReporterTests`, `PerfHarnessTests`, `StatusBarPrefsTests`, `FileIntegrationTests`, `EditorDocumentTests`, `TestHelpers.swift`. `_RenderDump.swift` / `_RenderEdit.swift` are **local dev tools (gitignored intent)** that dump Read-mode HTML / edit output for `tmp/sample.md` — not part of the assertion suite.
**Two invariant-guarding patterns worth copying:**
- **Recompose equivalence** (`RecomposeEquivalenceTests`, helper
`assertMatchesFullRecomposeOracle`): an incremental restyle must produce the
**same** result as a full recompose. This is the safety net for the lazy /
incremental styling paths.
- **Incremental-parse fuzz** (`IncrementalParseFuzzTests`): random edits must
keep the parser's window-reparse consistent with a full reparse.
---
## 3. Test helpers (`TestHelpers.swift`)
`@MainActor` helpers you build on (verified exports):
| Helper | Use |
|---|---|
| `makeEditor()` | an `EditorTextView` on the real TK2 chain (mirrors `Document.makeWindowControllers`) |
| `ensureFullLayout(...)` | force layout so geometry is real, not estimated |
| `type(...)`, `paste(...)`, `pressEnter()`, `pressBackspace()` | drive edits |
| `activateBlock(...)` | move the caret / active block |
| `displayText(...)`, `attrs(...)`, `font(...)`, `fgColor(...)` | inspect styled output |
| `isHidden` / `isInvisible` / `isDimmed` | assert delimiter hiding |
| `blockDecoration(...)`, `textBlockDifference(...)` | inspect decorations / catch TK1-reverting table attrs |
| `expectedFullComposition(...)`, `drainAllStyling()`, `assertMatchesFullRecomposeOracle(...)` | equivalence-oracle checks |
| `makeLargeMarkdown(...)`, `sentence(...)` | build big fixtures for perf/viewport |
`styleBlock(_:cursorPosition:)` is a method on `EditorTextView`
(`Rendering/EditorTextView+Rendering.swift`), called from tests — it renders one
block to an attributed string.
---
## 4. How to add a test
Skeleton (Swift Testing, `@MainActor` because the editor is main-thread):
```swift
import Testing
import AppKit
@testable import EdmundCore
@MainActor
@Test func deletingAtCalloutBottomKeepsInvariant() {
let editor = makeEditor()
editor.loadRawSource("> [!note]\n> body\n")
drainAllStyling()
// ... drive the edit ...
#expect(editor.rawSource == editor.string) // storage == rawSource invariant
}
```
Rules:
1. **Every bug fix ships with a test even if it can't discriminate a live-only
mechanism** — it still locks the headless contract so a *future* refactor
can't silently re-break the model half. For the live half, keep the frozen
`.repro` script alongside (edmund-live-repro-and-diagnostics).
2. Assert the **invariant** where you can (`rawSource == string`), not just the
surface symptom — that's what `BypassedEditSyncTests` / `MarkedTextDesyncTests`
do.
3. Name the test for the behavior/bug, put edit-pipeline repros next to their
siblings (the `*Desync*` / `*Bypassed*` / `*CaretTests` families).
4. New drawing behavior additionally needs a screencapture check (§6).
---
## 5. Golden / certified inventory
- **`RenderingRegressionTests`** + `RecomposeEquivalenceTests` are the closest
thing to golden checks — they pin styled output and incremental-vs-full
equivalence.
- **`test-files/*.md`** (`callout.md`, `decorations.md`, `math.md`, `menu.md`,
`test.md`) are the **user's manual test corpus** — hand-testing fodder. **Do
not rewrite them to fit an automated test**; build your own fixtures (helpers
in §3, or a scratch dir).
- **`misc/bug-repros/`** holds field evidence (`.mov` screen recordings + `.log`
traces) for open bugs — reference these when reproducing, don't delete them.
---
## 6. Visual QA
Anything that draws is verified by **screencapture pixel measurement**, in
**light AND dark mode** (per `misc/before-you-release.md`), never by eyeballing
headless layout. Method + scripts: **edmund-live-repro-and-diagnostics** §5.
Headless layout tests (`HeightStabilityTests`, `ScrollStabilityTests`) check
geometry numbers but **cannot** confirm the pixels are right.
---
## 7. Flakiness & CI
- A `fix/flaky-math-test` branch exists in history — math rendering has shown
timing flakiness; if a math test flakes, check that branch's approach before
inventing a new one (verify: `git log --oneline --all -- '*Math*'`).
- CI: `.github/workflows/ci.yml` on `macos-14`, latest-stable Xcode, SPM cache
keyed on `Package.resolved`, `concurrency: cancel-in-progress` (private-repo
macOS minutes bill 10×). CI runs the same `swift test`.
---
## 8. Performance evidence
- **`PerfHarnessTests`** measures the hot paths; `makeLargeMarkdown` builds big
fixtures. Use it (don't hand-time) for any perf claim.
- The README claim "handles ~12MB files" and `fullLayoutMaxLength = 100_000`
UTF-16 (`EditorTextView.swift:80`) mark the boundary between the **full-layout**
regime (≤100k, geometry is real) and the **estimate** regime (>100k, viewport
glitches possible). A perf/viewport claim must state which regime it was
measured in.
---
## When NOT to use this skill
- The gate/branch/commit rules → **edmund-change-control**.
- Running the live repro or measuring pixels → **edmund-live-repro-and-diagnostics**.
- The caret-integrity investigation end to end → **edmund-caret-integrity-campaign**.
- Why a mechanism works → **textkit2-appkit-reference** / **edmund-architecture-contract**.
- Release verification → **edmund-release-and-operate**.
---
## Provenance and maintenance
Verified 2026-07-05.
```bash
grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l # ~810 cases
grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest
grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3)
ls Tests/EdmundTests/ # suite map (§2)
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
```
If a helper in §3 no longer greps it was renamed; update §3 and any test
skeletons. Re-derive the suite map from `ls` if files are added/removed.
@@ -0,0 +1,279 @@
---
name: textkit2-appkit-reference
description: >
Domain-theory pack for the AppKit text system as it applies to the Edmund
Markdown editor. Load when working on layout, selection, IME, undo, drag, or
eventing behavior; when TextKit 2 or NSTextView does something surprising;
or when terms like NSTextLayoutManager, layout fragment, marked text, queued
selection fixup, responder chain, or sendEvent appear and you lack AppKit
text-system background. Explains the mechanisms the invariants and gotchas
are built on. Not the invariants themselves (edmund-architecture-contract),
not a triage table (edmund-debugging-playbook), not the repro drivers
(edmund-live-repro-and-diagnostics).
---
# TextKit 2 / AppKit reference (as it applies to Edmund)
The background a mid-level engineer or Sonnet-class model usually lacks. Each
concept: brief theory, then **where it bites in Edmund** with a verified file
pointer. This is not a textbook — it is only the parts that matter here.
Facts checked against source 2026-07-05. Items labeled *(background)* are
general AppKit/TextKit behavior grounded in Apple's documentation, not directly
grep-able in this repo.
---
## 1. The TextKit 2 object model *(background + repo)*
TextKit 2 replaced the TextKit 1 `NSLayoutManager` stack. The players:
- **`NSTextContentStorage`** — owns the backing string + attributes (the model).
- **`NSTextLayoutManager`** — lays text out (the TK2 analogue of the old layout manager).
- **`NSTextLayoutFragment`** — one laid-out chunk (≈ a paragraph); has a real
geometric frame **only once laid out**.
- **`NSTextElement` / `NSTextParagraph`** — the model-side elements fragments render.
**Viewport-based layout** is the headline difference: TK2 lays out only the
content near the visible viewport, not the whole document. That is what makes
big documents fast — and it is the root of most viewport pain (§2).
**Where it bites:** Edmund subclasses the fragment as `DecoratedTextLayoutFragment`
(`EditorTextView+TextKit2.swift`) to draw callout boxes, bars, and overlays.
---
## 2. Height ESTIMATES — the master cause of viewport glitches
A fragment that has not been laid out yet has an **estimated** height, not a
real one; the total document height is the sum of real + estimated fragment
heights. As layout reaches a fragment, its estimate is replaced by the true
value and everything below shifts. Consequences: the scroller thumb jumps, and
"scroll to offset Y" lands wrong because Y was computed from estimates. This is
a **widely documented TK2 limitation — even TextEdit shows it** *(background)*.
**Where it bites / Edmund mitigations** (verify names by grep; all in `TextView/`):
- `fullLayoutMaxLength = 100_000` (`EditorTextView.swift:80`): documents ≤ 100k
UTF-16 units are kept **fully laid out** (no estimate regime) by a coalesced
next-run-loop settle.
- `scheduleFullLayoutSettle` / `preservingViewportAnchor`: the settle runs inside
an anchor block so corrections never shift what is on screen.
- `repairContentAboveOrigin` (`+LazyStyling.swift`, logs `repairing content
above origin`): fixes the case where an edit near the top strands the first
fragment at negative y (unreachable above the scroller top).
- `centerViewportOnCaret`: re-measures after its first scroll and corrects the
residual estimate error.
**Rule:** never trust an off-screen fragment's y-coordinate without laying out
its span first. Deep write-up: `docs/investigations/viewport-glitch-investigation.md`.
---
## 3. The TextKit 1 fallback trap
An `NSTextView` can silently and **permanently** revert from TK2 to the legacy
TK1 stack. Two known triggers: **accessing `NSTextView.layoutManager`** (the
mere getter engages TK1), and **storing `NSTextBlock`/`NSTextTable`
attributes**. Once reverted, TK2 APIs still exist but do nothing useful, and the
whole editor misbehaves subtly.
**Where it bites:** Edmund ships a **DEBUG tripwire** — an observer on
`NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the switch
happens (`EditorTextView.swift:273`+, message "TextKit 1 fallback triggered").
Never add code that reads `layoutManager` or stores table attributes; draw
tables as decorations instead (`EditorTextView+TableRendering.swift`).
---
## 4. Attribute-only mutation semantics
Edmund renders by writing **attributes** onto the storage, never by inserting or
deleting characters (the storage == rawSource invariant). Two consequences from
the text system:
- **`setAttributes` does not re-measure geometry.** After a restyle that changes
a block's height or indent, you must call `invalidateLayout(for:)` on its
range or the fragment keeps a **stale frame** (empty bands / clipped lines).
`recomposeDirty` and the idle drain already do this; new paths must too.
- **`NSTextAttachment` is only honored on the `U+FFFC` object-replacement
character** *(background)*. `rawSource` never contains `U+FFFC`, so attachments
can't be used — Edmund draws images/icons as **overlays** (§5) instead.
---
## 5. The custom drawing model (fragments, decorations, overlays)
`DecoratedTextLayoutFragment` draws two attribute families behind/over text:
- **`.blockDecoration`** (paragraph-level): callout boxes, quote bars, table
borders, thematic-break rules, code backgrounds. Fragments **tile vertically**,
so a multi-line run renders as one continuous box. A box's `bottomPad` grows
the **last** fragment's frame (TK2 omits trailing paragraph spacing from the
fragment, so padding done otherwise would be dead space).
- **`.fragmentOverlay`** (character-level): an image **or** a stroked vector path
drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes,
callout header icon, the custom-title callout icon (path). The anchor glyph is
**hidden** (≈0.01 pt font + clear color) and `.kern` reserves the drawing's
advance width.
**The image-on-wrapping-fragment wedge:** drawing an **image** overlay on a
**multi-line (wrapping)** fragment re-triggers a layout pass that collapses the
fragment to one line. Drawing a **shape/path** does not. So the wrapping
callout title's icon is a stroked `CGPath` (parsed by `SVGPath` from vendored
Lucide geometry), never an image. Full saga:
`docs/investigations/archives/callout-title-wrap-investigation.md`. This constraint holds for **any new
overlay** that could share a line with wrapping text.
**Hiding text** = `hiddenFont` (≈0.01 pt) + clear `foregroundColor`. This is how
delimiters (`**`, `` ` ``, `[!note]`) vanish without changing the string.
---
## 6. The queued selection fixup (the round-6 delete-drift mechanism)
When you mutate an `NSTextView`'s storage, AppKit queues a private step,
`-[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:]`, that repairs
the selection against the new character coordinates. Normally it fires promptly.
But if an edit **bypasses the normal close-out** (see §7), the fixup stays
**queued** and fires at the **next `endEditing`** — *even an attribute-only
restyle* — where it maps the now-**stale** selection against post-edit
coordinates and **leaps the caret blocks away**. It will move even a freshly set,
valid caret.
**Where it bites:** this is delete-drift round 6. The heal must set the caret
(from the pendingEdit hull) **before** the sync **and re-assert it after**
(`EditorTextView+EditFlow.swift`). Recognize a variant by: a suspicious
selection change arriving mid-recompose (`up=Y` in traces);
`traceSelectionOrigin` will log the call stack of whoever moved it.
**Critical for testing:** a headless test harness runs this deferred fixup
**synchronously**, so this bug class **cannot reproduce in a unit test** — the
round-6 regression test passes with *and* without the fix. Only the live
in-process repro discriminates (edmund-live-repro-and-diagnostics).
---
## 7. The AppKit edit pipeline contract (and where AppKit breaks it)
Normal edit: `shouldChangeText(in:replacementString:)` → the view calls
`replaceCharacters` → `didChangeText()`. Edmund's `didChangeText` syncs
`rawSource` from storage and restyles the edited block(s).
**AppKit does NOT always send `didChangeText`.** A drag-move of selected text
whose drop lands on **no valid target** (e.g. released past the end of the
document) deletes the dragged range via `shouldChangeText` → `replaceCharacters`
and **never calls `didChangeText`** — silently freezing `rawSource`/`blocks`,
after which every edit drifts and autosave writes stale content (delete-drift
round 4). Edmund heals this: `shouldChangeText` schedules a next-run-loop
**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`); an
unconsumed storage `pendingEdit` by then means the close-out never came, and the
editor runs the sync itself (breadcrumb: `healing storage edit that bypassed
didChangeText`).
**Never build a sync path on the assumption that `didChangeText` follows every
edit.**
**The authentic key route** *(background + repo)*: keyDown →
`interpretKeyEvents` → `insertText:` / `deleteBackward:`. This is why the repro
driver synthesizes real `NSEvent`s and pushes them through `window.sendEvent(_:)`
rather than calling `insertText` directly — shortcuts skip `deleteBackward`'s
selection machinery, which is exactly where round 6 lived.
---
## 8. IME / marked text lifecycle
While an input method is composing (e.g. CJK, accents), the view holds
**provisional "marked" text** in storage; `hasMarkedText()` is true. During this
window `storage == rawSource` is **transiently false**, and `didChangeText`
defers syncing until the composition commits.
**The cascade:** any styling path that runs
`beginEditing`/`setAttributes`/`invalidateLayout` **mid-composition** can strand
the marked text in the input context. After that, `didChangeText` keeps bailing
on its own guard and the invariant stays broken — so **every later edit drifts
the caret** (the original delete-drift bug). Therefore **every storage-touching
styling path must guard `!hasMarkedText()`** — including async ones scheduled
*before* composition began (the caret-move restyle in `+SelectionTracking`).
`becomeFirstResponder` resyncs from storage as a catch-all. Full write-up:
`docs/investigations/delete-drift-investigation.md`.
---
## 9. Responder chain & nil-target actions *(background + repo)*
The **responder chain** is AppKit's search order for who handles an action.
Menu items and toolbar buttons with a **nil target** send their action up the
chain until something responds. Edmund's Format menu (`FormatMenu.swift`) is a
declarative command table whose items use nil targets and route to the focused
`EditorTextView`'s `@objc format…` actions — the same wiring as undo/redo. The
first responder is normally the focused `EditorTextView`.
---
## 10. `NSWindow.sendEvent` — the pre-toolbar event funnel
Every event a window receives passes through `sendEvent(_:)` **before** the
toolbar acts. This matters because with `NSToolbar.allowsUserCustomization =
true`, the toolbar claims **any secondary (right/control) click over the
toolbar** — including a custom item view — for its own "Customize Toolbar…" menu,
downstream of view-level handlers (`menu`, `rightMouseDown`, gesture
recognizers all lose). Edmund's fix for the view-mode button: intercept in
`DocumentWindow.sendEvent(_:)`, pop the menu when the click is inside the
button's bounds, and swallow it (`return`); other clicks fall through to
`super`. (Caveat: true fullscreen moves the toolbar to a separate window, so
this main-window hook wouldn't cover it.)
---
## 11. Drag sessions *(background + repo)*
- **Text drag-move arming:** AppKit only starts a text drag after a **mouse-down
hold (~400 ms)**; a CGEvent driver must hold before moving or the drag never
arms.
- **Drag-select autoscroll:** dragging past the viewport edge autoscrolls.
- **Reveal at nearest end:** a selection taller than the viewport must be
revealed at its **nearest** end (Edmund's `scrollRangeToVisible` override) —
always revealing the top fought the drag-select autoscroll and oscillated the
viewport mid-drag.
---
## 12. swift-markdown walker model *(brief)*
Edmund parses with `apple/swift-markdown` (CommonMark/GFM) and walks the
resulting `Document` with **two back-ends**: a `SpanCollector`-style walk that
produces editor attributes, and `HTMLRenderer` that produces HTML for Read mode.
One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark)
syntax — callouts, `==highlight==`, wikilinks, comments, footnotes, math — is
handled by `SyntaxHighlighter+CustomParsers.swift`.
---
## When NOT to use this skill
- The project's **rules/invariants** (what you must not do) → **edmund-architecture-contract**.
- **Which** symptom means which mechanism → **edmund-debugging-playbook**.
- **Reproducing** a live bug / reading traces → **edmund-live-repro-and-diagnostics**.
- The **history** of how these mechanisms were discovered → **edmund-failure-archaeology**.
- Running the caret-integrity campaign → **edmund-caret-integrity-campaign**.
---
## Provenance and maintenance
Verified 2026-07-05. Re-verify the load-bearing identifiers:
```bash
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
```
Items marked *(background)* are AppKit/TextKit behavioral facts documented by
Apple and in `docs/*-investigation.md`, not directly observable by grep. If any
Edmund mitigation name above no longer greps, it was renamed — update this file
and `docs/ARCHITECTURE.md` §5/§8 together.
+27
View File
@@ -0,0 +1,27 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command // empty' | { read -r cmd; if echo \"$cmd\" | grep -Eq '(^|&&|;|\\|)[[:space:]]*git[[:space:]]+(checkout[[:space:]]+-b|switch[[:space:]]+-c)\\b' || echo \"$cmd\" | grep -Eq '(^|&&|;|\\|)[[:space:]]*git[[:space:]]+branch[[:space:]]+[^-[:space:]][^[:space:]]*([[:space:]]|$)'; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Edmund prefers git worktrees over branch-switching for concurrent local work: git worktree add .worktrees/<branch> <branch> (branch keeps its normal type/slug name). See CLAUDE.md Git practices / docs/ARCHITECTURE.md \\u00a712.\"}}'; else echo '{}'; fi; }"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "swift test 2>&1 | tail -5",
"timeout": 120,
"statusMessage": "Running swift test…"
}
]
}
]
}
}
+31
View File
@@ -0,0 +1,31 @@
---
name: Bug report
about: Something's literally wrong...
title: ''
labels: bug
assignees: ''
---
**Description**
What's the bug?
**Reproduction**
If description is not enough, how do you reproduce the bug?
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
If it's not obvious, what should've happened?
**Screenshots or recordings**
If applicable, add screenshots or screen recordings of the bug or the expected behavior.
**Device info:**
- Device: [e.g. MacBook Pro, M2 Pro, 16 GB]
- macOS version: [e.g. Sonoma 14.8.3]
**Additional context**
Questions, concerns, comments, context, etc.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: How can we do better?
title: ''
labels: enhancement
assignees: ''
---
**Problem**
What's wrong?
**Solution**
What would you like to happen instead?
**Alternative(s)**
What else would you like to happen instead? Why's the one above your favorite?
**Additional context**
Context, screenshots / recordings, comments, etc.
+38
View File
@@ -0,0 +1,38 @@
# Security Policy
## Supported Versions
Edmund is in beta (pre-1.0). Only the latest released version is supported
with security fixes.
| Version | Supported |
| ------- | --------- |
| latest | ✅ |
| older | ❌ |
## Reporting a Vulnerability
Please report security vulnerabilities privately using GitHub's
[private vulnerability reporting](https://github.com/I7T5/Edmund/security/advisories/new)
(Security tab → "Report a vulnerability"). Do not open a public issue for
suspected vulnerabilities.
Include as much detail as you can: affected version, reproduction steps,
impact, and any proof-of-concept. You should get an initial response within
7 days.
Since Edmund is a local, offline-first macOS app (no server, no accounts, no
telemetry), the main risk areas are:
- Malicious Markdown/HTML content leading to code execution, sandbox escape,
or unintended network access when opening a file
- Sparkle auto-update integrity (signature/verification bypass)
- Arbitrary file read/write outside the file the user opened
If confirmed, a fix will be released and credited in the release notes
(unless you prefer to stay anonymous).
## Disclosure
Please give a reasonable amount of time to fix an issue before any public
disclosure. There is no bug bounty program.
+45
View File
@@ -0,0 +1,45 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
# Only the latest run per branch/PR matters; cancel older ones so private-repo
# macOS minutes (billed at 10x) aren't spent on superseded commits.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: test
runs-on: macos-14
steps:
- uses: actions/checkout@v5
# Package.swift declares swift-tools-version 6.0, which needs Xcode 16+.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Swift version
run: swift --version
# Cache the SwiftPM build (deps + compiled objects) keyed on the resolved
# dependency graph, so green runs don't recompile SwiftMath etc. each time.
- name: Cache .build
uses: actions/cache@v5
with:
path: .build
# v2: the repo was renamed md -> Edmund, which changed the checkout
# path and invalidated absolute paths baked into the cached module
# cache. Bump this token to discard any pre-rename .build cache.
key: spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
restore-keys: spm-v2-${{ runner.os }}-
- name: Test
run: swift test
+133
View File
@@ -0,0 +1,133 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Build & publish
runs-on: macos-14
permissions:
contents: write # create releases and commit appcast
steps:
- uses: actions/checkout@v5
with:
# Fetch full history so we can push the appcast commit back.
fetch-depth: 0
# Admin PAT so the appcast commit can be pushed to the protected
# `main` branch. The default GITHUB_TOKEN/bot is not an admin and is
# blocked by the required `test` status check.
token: ${{ secrets.RELEASE_TOKEN }}
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Cache .build
uses: actions/cache@v5
with:
path: .build
key: spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
restore-keys: spm-v2-${{ runner.os }}-
- name: Build app bundle
run: ./scripts/build-app.sh
# create-dmg 8.x requires Node >= 20; pin it so the runner's default node
# version can't break the release.
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install create-dmg
# sindresorhus/create-dmg is the npm package (NOT the homebrew
# create-dmg/create-dmg tool, which has an incompatible CLI).
run: npm install --global create-dmg
- name: Create DMG
run: |
VERSION="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' Info.plist)"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
# create-dmg exits 2 when it can't Developer-ID-sign / notarize the
# image (we ship ad-hoc only) but still produces the .dmg — so don't
# let that non-zero status fail the job.
create-dmg build/Edmund.app build/ || true
# It names the output "Edmund <version>.dmg"; normalize to a
# hyphenated, URL-friendly name and fail loudly if none was produced.
DMG_SRC="$(ls build/Edmund*.dmg 2>/dev/null | head -1)"
if [ -z "$DMG_SRC" ]; then
echo "create-dmg produced no .dmg" >&2
exit 1
fi
mv "$DMG_SRC" "build/Edmund-${VERSION}.dmg"
- name: Sign archive (EdDSA)
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
SIGN_UPDATE="$(find .build -name sign_update -type f | head -1)"
# Pass the key on stdin via --ed-key-file -. The deprecated `-s <key>`
# is fatal for newly generated keys ("no longer supported").
SIG_OUTPUT="$(echo "$SPARKLE_ED_PRIVATE_KEY" | "$SIGN_UPDATE" --ed-key-file - "build/Edmund-${VERSION}.dmg")"
echo "ED_SIG=$(echo "$SIG_OUTPUT" | grep -o 'sparkle:edSignature="[^"]*"')" >> "$GITHUB_ENV"
echo "LENGTH=$(echo "$SIG_OUTPUT" | grep -o 'length="[^"]*"' | head -1)" >> "$GITHUB_ENV"
- name: Create GitHub Release
id: create_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" \
CHANGELOG.md > release-notes.txt
if [ ! -s release-notes.txt ]; then
echo "See CHANGELOG for details." > release-notes.txt
fi
gh release create "v${VERSION}" "build/Edmund-${VERSION}.dmg" \
--title "Edmund ${VERSION}" \
--notes-file release-notes.txt \
--latest
- name: Update appcast.xml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO="${{ github.repository }}"
ASSET_URL="https://github.com/${REPO}/releases/download/v${VERSION}/Edmund-${VERSION}.dmg"
PUB_DATE="$(date -u '+%a, %d %b %Y %H:%M:%S +0000')"
BUILD="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' Info.plist)"
# Release notes for Sparkle's update dialog (scrollable pane), built
# from this version's CHANGELOG section. Omitted if the section is missing.
# Built with printf (not a literal multi-line string) so this step's
# `run: |` block scalar doesn't contain an unindented line, which
# breaks YAML parsing of the whole workflow file.
# $(...) strips trailing newlines, so DESC_BLOCK can't carry its own
# trailing separator — NEW_ITEM's printf adds it explicitly instead.
DESC_HTML="$(python3 scripts/changelog-to-html.py "$VERSION")"
DESC_BLOCK=""
if [ -n "$DESC_HTML" ]; then
DESC_BLOCK="$(printf ' <description><![CDATA[\n%s\n]]></description>' "$DESC_HTML")"
fi
# Built with printf (one logical YAML line) rather than a literal
# multi-line string: an unindented line inside this value would
# break this run block's YAML indentation (as DESC_BLOCK did above).
NEW_ITEM="$(printf ' <item>\n <title>Edmund %s</title>\n <pubDate>%s</pubDate>\n%s\n <enclosure url="%s"\n sparkle:version="%s"\n sparkle:shortVersionString="%s"\n %s\n %s\n type="application/x-apple-diskimage"/>\n </item>' "$VERSION" "$PUB_DATE" "$DESC_BLOCK" "$ASSET_URL" "$BUILD" "$VERSION" "$ED_SIG" "$LENGTH")"
python3 - "$NEW_ITEM" <<'PYEOF'
import sys
new_item = sys.argv[1]
with open("appcast.xml", "r") as f:
content = f.read()
content = content.replace(" </channel>", new_item + "\n </channel>")
with open("appcast.xml", "w") as f:
f.write(content)
PYEOF
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add appcast.xml
git commit -m "appcast: add Edmund ${VERSION}"
git push origin HEAD:main
+31
View File
@@ -0,0 +1,31 @@
# AI
.claude/worktrees/
.claude/settings.json
.claude/settings.local.json
.codex/config.toml
# local tooling
.worktrees/
Tests/EdmundTests/_RenderDump.swift
Tests/EdmundTests/_RenderEdit.swift
# local files
/test-files/
/misc/
/tmp/
# dev stuff
.build/
build/
Packages/
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
# Sparkle private key
sparkle_private_key.txt
# system
.DS_Store
.netrc
+30
View File
@@ -0,0 +1,30 @@
# AGENTS.md — Edmund
Native macOS Markdown editor, live preview (AppKit + TextKit 2, SPM, macOS 14+).
**Before non-trivial work, read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)**
it covers the build/test commands, the two hard invariants (storage == rawSource;
TextKit 2 only), the render pipeline, the feature map, and the gotchas. Keep that
doc updated as you learn things.
## Environment
- Do **not** request Computer Access — Screen Recording and Accessibility are already granted.
- For frontend changes, verify with `screencapture` in the CLI (capture the window by id; see ARCHITECTURE §8). If it keeps failing, render the editor offscreen to a PNG.
## Git practices
- Commit automatically and frequently.
- Branch off `main` for each fix (don't commit straight to `main`); one feature/fix per branch, small logical commits.
- For concurrent local work, use `git worktree add .worktrees/<branch> <branch>` instead of stashing or switching branches. Branch names keep the normal `type/slug` convention (e.g. `fix/foo`) — never rename a branch to `worktree-*`. `.worktrees/` is gitignored. This is separate from `.Codex/worktrees/`, which Codex's own EnterWorktree tool manages automatically; don't hand-edit that one.
- **Never auto-push, PR, or merge — only when I explicitly ask.**
- Never delete uncommitted changes.
## Before committing (the checklist that works)
1. `swift test` — all green; add tests for new behavior / bug repros. **`swift test` runs automatically as a Stop hook** at the end of every turn that touches code, so failures surface before you commit.
2. Visual changes: build the app and `screencapture`-verify (or render offscreen to PNG). Don't trust headless layout alone for anything that draws.
3. Touch only what the task needs; match surrounding style; don't refactor unrelated code.
See ARCHITECTURE §12 for the fuller rationale.
## Comment quirks where they live
Document non-obvious behavior (edge cases, workarounds, the *why*) as a short
comment at the code itself — not in commits or this file.
+115
View File
@@ -0,0 +1,115 @@
# Changelog
All notable changes will be documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
GFM pass: closing the gaps between Edmund and the GFM spec in both edit and read mode.
### Added
- Setext headings (`Title` underlined by `===`/`---`) render in edit mode
- Indented code blocks (4 spaces or a tab, after a blank line) render in edit mode
- HTML `<!-- comments -->`: dimmed in edit mode, hidden in read mode (previously showed as literal text in read mode)
- `<small>` added to the rendered HTML whitelist (both modes)
- `<img src alt width height>` renders the image in both modes, at its declared size (one dimension alone scales proportionally); remote/local image policy applies as for markdown images
- Autolinks ([GFM extension](https://github.github.com/gfm/#autolinks-extension-)): bare `www.…`, `http(s)://…`, and email addresses become links in both modes, with CMD+click to follow
- Inline styling (bold, code, links, ==marks==, …) now renders inside table cells in edit mode; column widths align on the *styled* text, not the raw source
- Inline styling inside headings keeps the heading's font size (`# **bold** and `code``), for ATX and setext headings
- Raw HTML renders in read mode per GFM ([§4.6](https://github.github.com/gfm/#html-blocks)/[§6.10](https://github.github.com/gfm/#raw-html)) with the tagfilter extension ([§6.11](https://github.github.com/gfm/#disallowed-raw-html-extension-)) plus hardening: `on*` event-handler attributes and `javascript:`/`vbscript:` URLs stripped, and a `script-src 'none'` CSP on the page (JS was already disabled)
- HTML blocks (all seven GFM §4.6 start conditions) parse as blocks in edit mode and show as colored source
- Full GFM §6.10 inline tag grammar in edit mode: hyphenated tag names, single-quoted/unquoted attribute values, `>` inside quoted values, and PI/declaration/CDATA tokens
- Multi-backtick code spans in edit mode (`` ``a`b`` ``) style with their real delimiter length
- Loose vs tight lists in read mode: tight lists drop the `<p>` wrapper inside items per GFM §5.3
- Link `title` attributes carry into read-mode/exported HTML
### Changed
- Read mode no longer escapes unknown HTML — GFM passthrough (with tagfilter + hardening) replaces the escape-by-default whitelist
- A `---` line directly under a paragraph is now a setext h2 underline per GFM, no longer a thematic break — put a blank line between the paragraph and `---` to keep the rule
- `==highlight==` now follows GFM-style flanking: content can't begin or end with whitespace (`== spaced ==` stays literal)
- Setext heading content spans the whole preceding paragraph run (`Foo\nbar\n---` is one h2), matching GFM Example 51
- Interior blank lines stay inside an indented code block (GFM Examples 82/87)
### Fixed
- Tables whose delimiter row cell count differs from the header are no longer parsed as tables in edit mode (GFM Example 203)
- Backslash-escaped pipes (`\|`) are cell content, not column separators (GFM Example 200)
- The ATX heading closing sequence (`# foo ###`) hides like other delimiters instead of showing in the heading (GFM 4.2)
- A newline inserted at a display-math block boundary no longer leaves a stray centered line (separator newlines now reset when adjacent blocks restyle)
## [0.1.4] - 2026-07-09
Various small fixes and improvement and new round of grind at the [delete caret drift](https://github.com/I7T5/Edmund/issues/156). I think it actually worked this time, but don't quote me on it.
### Added
- `CMD+=`, `CMD+-`, and `CMD+0` to zoom in/out/reset. Also in View menu
- External images rendering in editor
- Block external images setting in Settings > Advanced
### Changed
- Rename "Source Mode" to "Show Source in Editor" in app and button menu. Removed icon from button menu.
- Opening an existing file closes the last opened Untitled window with no edit history
- Move Automatic updates to Settings > General
- Apply Settings > Appearance > Max content width to read mode
### Fixed
- Images have extra bottom padding when editor is not in full screen
- Images do not resize with max content width if the user changes the setting when the app is open
- Tables overflow handled by horizontal scroll
- Callouts have an extra line at the bottom when they are the last element of a file
- Footnotes rendering in edit mode and linking between inline marker and content in read mode
- Math environments `\begin{}...\end{}` padding offset in edit mode
- Math environments `\begin{}...\end{}` rendering in read mode
- Delete caret drift, round 7 ([docs](docs/delete-drift-investigation.md)) [#156](https://github.com/I7T5/Edmund/issues/156)
---
## [0.1.3] — 2026-07-04
### Fixed
- Delete caret drift *with reproduction* ([docs](docs/investigations/delete-drift-investigation.md)) [#156](https://github.com/I7T5/Edmund/issues/156)
---
## [0.1.2] — 2026-07-03
Polishing the editor and trying to have Fable 5 fix all the big bugs while I still have it with me.
### Changed
- Redo now jumps to where changed text was instead of caret
- Removed old code for identity mapping, etc., using [ponytail](https://github.com/DietrichGebert/ponytail)-review
### Fixed
- Updater [#158](https://github.com/I7T5/Edmund/issues/158)
- Icon display for callouts with custom titles ([docs](docs/investigations/archives/callout-title-wrap-investigation.md))
- Undo/redo viewport glitches from TextKit 2 ([docs](docs/investigations/viewport-glitch-investigation.md))
- Delete caret drift ([docs](docs/investigations/delete-drift-investigation.md)) [#156](https://github.com/I7T5/Edmund/issues/156)
---
## [0.1.1] — 2026-06-29
### Added
- Thematic Break `---`/`***` in the Format menu
- Remember window size: new document windows reopen at the size of the last one.
### Changed
- Max content width is now an absolute physical width (cm / in) with a max-width cap and a cm/in unit toggle.
- Typewriter Mode renamed to Typewriter Scroll
### Fixed
- Typewriter Scroll no longer jumps the viewport when you click to reposition the caret — it re-centers only while typing.
---
## [0.1.0] — 2026-06-27
First public release.
- **Live WYSIWYG preview** — Typora/Obsidian style
- **GFM support** — bold, italic, strikethrough, tables, task lists, fenced code with syntax highlighting, blockquotes, alerts
- **Extended syntax** — ==highlights==, [[WikiLinks]], `[^footnotes]`, Obsidian-flavored callouts and comments
- **Math** — inline (`$…$`) and display (`$$…$$`) rendering via SwiftMath
- **Native macOS UI** — AppKit editor, SwiftUI settings panel, full Dark Mode support
- **Keyboard-first** — configurable shortcuts, no required mouse interaction
- **Auto-update** — Sparkle 2.x with EdDSA-signed appcast; checks on launch
- **Open source** — Apache 2.0
+30
View File
@@ -0,0 +1,30 @@
# CLAUDE.md — Edmund
Native macOS Markdown editor, live preview (AppKit + TextKit 2, SPM, macOS 14+).
**Before non-trivial work, read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)**
it covers the build/test commands, the two hard invariants (storage == rawSource;
TextKit 2 only), the render pipeline, the feature map, and the gotchas. Keep that
doc updated as you learn things.
## Environment
- Do **not** request Computer Access — Screen Recording and Accessibility are already granted.
- For frontend changes, verify with `screencapture` in the CLI (capture the window by id; see ARCHITECTURE §8). If it keeps failing, render the editor offscreen to a PNG.
## Git practices
- Commit automatically and frequently.
- Branch off `main` for each fix (don't commit straight to `main`); one feature/fix per branch, small logical commits.
- For concurrent local work, use `git worktree add .worktrees/<branch> <branch>` instead of stashing or switching branches. Branch names keep the normal `type/slug` convention (e.g. `fix/foo`) — never rename a branch to `worktree-*`. `.worktrees/` is gitignored. This is separate from `.claude/worktrees/`, which Claude Code's own EnterWorktree tool manages automatically; don't hand-edit that one.
- **Never auto-push, PR, or merge — only when I explicitly ask.**
- Never delete uncommitted changes.
## Before committing (the checklist that works)
1. `swift test` — all green; add tests for new behavior / bug repros. **`swift test` runs automatically as a Stop hook** at the end of every turn that touches code, so failures surface before you commit.
2. Visual changes: build the app and `screencapture`-verify (or render offscreen to PNG). Don't trust headless layout alone for anything that draws.
3. Touch only what the task needs; match surrounding style; don't refactor unrelated code.
See ARCHITECTURE §12 for the fuller rationale.
## Comment quirks where they live
Document non-obvious behavior (edge cases, workarounds, the *why*) as a short
comment at the code itself — not in commits or this file.
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Edmund</string>
<key>CFBundleDisplayName</key>
<string>Edmund</string>
<key>CFBundleIdentifier</key>
<string>com.i7t5.edmund</string>
<key>CFBundleExecutable</key>
<string>edmd</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>NSAccentColorName</key>
<string>AccentColor</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>5</string>
<key>CFBundleShortVersionString</key>
<string>0.1.4</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>0XdLbbuOm0NonqWeDJkQuFF0CGnhKmpAfq+RNKVwEDs=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Markdown Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>net.daringfireball.markdown</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>md</string>
<string>markdown</string>
<string>mdown</string>
<string>mkd</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Plain Text</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
</array>
</dict>
</array>
</dict>
</plist>
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Yina Tang
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+43
View File
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
{
"originHash" : "a31b21636c19c1fb2661a51b226a2aef490af95e85b9325c822a9c5af1d81190",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark.git",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-markdown.git",
"state" : {
"revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94",
"version" : "0.7.3"
}
},
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mgriebling/SwiftMath.git",
"state" : {
"revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7",
"version" : "1.7.3"
}
}
],
"version" : 3
}
+30
View File
@@ -0,0 +1,30 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Edmund",
platforms: [.macOS(.v14)],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.5.0"),
.package(url: "https://github.com/mgriebling/SwiftMath.git", from: "1.7.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
],
targets: [
.target(
name: "EdmundCore",
dependencies: [
.product(name: "Markdown", package: "swift-markdown"),
.product(name: "SwiftMath", package: "SwiftMath"),
]),
// The user-facing app is "Edmund" (CFBundleName); the executable target
// and so the Mach-O binary at Edmund.app/Contents/MacOS/edmd is "edmd",
// an expansion of "Editor for Markdown". A quiet backronym for anyone who
// peeks inside the bundle or runs `swift run edmd`.
.executableTarget(
name: "edmd",
dependencies: ["EdmundCore", .product(name: "Sparkle", package: "Sparkle")]),
.testTarget(
name: "EdmundTests",
dependencies: ["EdmundCore"]),
]
)
+105
View File
@@ -0,0 +1,105 @@
# Edmund
![macOS Version Compatibility](https://img.shields.io/badge/platform-macOS%2014.0%2B-0064e1?style=flat-square&color=0064e1)
![GitHub License](https://img.shields.io/github/license/i7t5/edmund?style=flat-square&color=772678)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/i7t5/edmund/total?style=flat-square&color=ff6916)
![Tiny App Icon](docs/assets/AppIcon/AppIcon_16x16.png)
Edmund is a minimal, file-based, native Markdown editor for macOS with inline live preview.
<!-- Replace "minimal" with "customizable" or "lightweight" once more features are implemented -->
https://github.com/user-attachments/assets/5c9097c7-68d2-4423-b0f5-495979775f6d
Whether as a companion alongside your Markdown knowledge base or as a standalone editor,
Edmund blends in with macOS and works seamlessly with your files wherever they are.
Our goal is to be the [CotEditor](https://coteditor.com) of Markdown editors,
i.e. elegant, powerful, configurable, and native inside out.
> ⚠️ Edmund is currently in beta. See the [roadmap](docs/ROADMAP.md) for what's coming next :D
## Differentiators
- Live preview: Typora/Obsidian-style WYSIWYG.
- File-based: Open `.md` files from anywhere. No vaults or dedicated folders required.
- Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies.
- Fast: Handles ~1-2MB files with ease. No launch lag.
- Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!
- Private: Offline by default. Optional blocking of external links and HTML sanitization.
<!-- Move "Fast" and "Extensible"? Add "integrations" section to Native after implementation -->
See [my blog post](https://i7t5.com/posts/2026-06-26-edmund/) for more of the motivation and design philosophy.
## Screenshots
![Basic usage screenshot in light and dark mode](docs/assets/v0.1.0_basic.png)
![GFM syntax screenshot in edit mode](docs/assets/v0.1.0_gfm-syntax.png)
![Non-GFM and math syntax screenshot in edit mode](docs/assets/v0.1.0_more-syntax.png)
![Basic usage in read mode and inspector](docs/assets/v0.1.0_read-mode.png)
## Installation
Get `Edmund.dmg` from the [latest release](https://github.com/I7T5/Edmund/releases/latest), open it, and drag `Edmund.app` to `Applications`:
<img src="./docs/assets/installation.png" width="540" alt="Window for drag and drop to install">
> [!WARNING]
> If macOS reports that the app is `🚧DAMAGED🚧` when you're trying to open it for the first time, fear not.
> The app is not damaged. It's just not signed properly because I am not a $99/yr-certified Apple Developer.
> Good thing is there's an easy way to bypass the barrier.
>
> To open Edmund (or any other "damaged" app) for the first time, choose *one* of the following:
> - System Settings → Privacy & Security → scroll down → Open Anyway. Or,
> - Run the following line in Terminal: `xattr -dr com.apple.quarantine /Applications/Edmund.app`
> - You might also need to prepend the command with `sudo`.
Edmund checks for updates automatically; you can also browse version history [here](https://github.com/I7T5/Edmund/releases).
## Dependencies
- [swift-markdown](https://github.com/swiftlang/swift-markdown)
- [SwiftMath](https://github.com/mgriebling/SwiftMath)
- [Sparkle](https://github.com/sparkle-project/Sparkle)
## Alternatives
If Edmund's not your thing, some of the following might be:
- Closed source
- Obsidian, cyberWriter, Notion
- Typora, Lettera (beta), LitSquare Ink MD
- Open source
- WYSIWYG: [MarkText](https://marktext.me), [Nodes](https://nodes-web.com), [Scratch](https://github.com/erictli/scratch)
- Split-screen: [MacDown](https://macdown.uranusjr.com), [MiaoYan](https://miaoyan.app)
- [MarkEdit](https://github.com/MarkEdit-app/MarkEdit) - TextEdit for Markdown
- I *love* this. If only I wasn't so dependent on rendered math...
- [editxr](https://github.com/pixdeo/editxr) - TUI
- More feature-rich: [FSNotes](https://fsnot.es), [Zettlr](https://www.zettlr.com), [Joplin](https://joplinapp.org), [Tangent](https://www.tangentnotes.com)
The list is by no means exhaustive, and neither was it meant to be. I just wanted to give credit to the makers of these apps, esp. IMO the aesthetic open sourced ones. A comprehensive list may be found [here](https://github.com/mundimark/awesome-markdown-editors).
## Acknowledgements
The following have greatly influenced the architecture and/or helped with design. I owe them many thanks:
- [Swift Markdown Engine](https://github.com/nodes-app/swift-markdown-engine) / [Nodes](https://nodes-web.com) for the parser/token architecture and the TextKit 2 integration
- [Typora](https://typora.io) and Apple Notes for app menu organization
- [Tomorrow Light](https://github.com/chriskempson/tomorrow-theme) and [One Dark](https://github.com/atom/atom/tree/master/packages/one-dark-syntax) for code syntax highlighting
- [create-dmg](https://github.com/sindresorhus/create-dmg) for a Apple-looking `.dmg`
- [MarkEdit](https://github.com/MarkEdit-app/MarkEdit) for the readme organization
- [screenshot-studio](screenshot-studio.com) for the amazing screenshots editing experience
- [shields](shields.io) for the beautiful badges in this readme
- Claude, [caveman](https://github.com/JuliusBrussee/caveman), and [ponytail](https://github.com/DietrichGebert/ponytail) for the engineering.
## License
[Apache License 2.0](LICENSE)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`I7T5/Edmund`
- 原始仓库:https://github.com/I7T5/Edmund
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
Binary file not shown.
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "22",
"green" : "105",
"red" : "255"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,82 @@
import Foundation
// MARK: - Crash report uploading
//
// Opt-in (default off), best-effort uploading of the crash reports macOS writes
// for Edmund. On launch when the user has enabled it we read the per-user
// `.ips` reports the OS dropped in ~/Library/Logs/DiagnosticReports/ and POST any
// we haven't sent before.
//
// Why read `.ips` files rather than MetricKit's `MXCrashDiagnostic`? It's the
// simplest path that yields the *full* report (not just a call-stack payload),
// is available immediately on the next launch, and needs no framework wiring.
// The tradeoff: it relies on direct filesystem access, which only works because
// Edmund is **not sandboxed** (no entitlements file). If App Sandbox is ever
// adopted, this directory becomes unreadable and we'd switch to MetricKit.
//
// PII note: `.ips` reports embed the user's home path (and so their account
// name), the device model, and the OS version. We send them as-is acceptable
// for crash-fix use, which the Settings note states plainly. Revisit if scope
// changes.
public enum CrashReporter {
/// Placeholder ingestion endpoint. Nothing is ever sent against this in the
/// shipped build (the feature toggle is off and its UI is commented out);
/// replace this with the real server before exposing the toggle.
static let reportingEndpoint = URL(string: "https://REPLACE-ME.invalid/crash")! // TODO: real server
/// macOS crash reports are named `<executable>-<timestamp>.ips`. Our Mach-O
/// executable is `edmd` (see `main.swift`), so that's the filename prefix.
public static let processPrefix = "edmd"
/// Where macOS writes this user's crash reports.
public static var diagnosticReportsDirectory: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/DiagnosticReports", isDirectory: true)
}
/// Pure and testable: the `.ips` crash reports in `directory` that belong to
/// our process and haven't been sent yet, sorted by name (oldest-ish first)
/// for deterministic order.
public static func pendingReports(in directory: URL,
processPrefix: String = processPrefix,
alreadySent: Set<String>) -> [URL] {
let fm = FileManager.default
guard let urls = try? fm.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil) else { return [] }
return urls.filter { url in
url.pathExtension == "ips"
&& url.lastPathComponent.hasPrefix(processPrefix)
&& !alreadySent.contains(url.lastPathComponent)
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
/// Scan the real DiagnosticReports directory and POST any crash reports not in
/// `alreadySent`. Fire-and-forget returns immediately and never blocks the
/// caller. `onSent` is invoked on the main actor with each filename that
/// uploaded successfully, so the caller can record it and avoid resending.
public static func uploadPendingReports(alreadySent: Set<String>,
onSent: @escaping @MainActor (String) -> Void) {
let pending = pendingReports(in: diagnosticReportsDirectory, alreadySent: alreadySent)
guard !pending.isEmpty else { return }
for url in pending { upload(url, onSent: onSent) }
}
private static func upload(_ url: URL,
onSent: @escaping @MainActor (String) -> Void) {
guard let data = try? Data(contentsOf: url) else { return }
let name = url.lastPathComponent
var request = URLRequest(url: reportingEndpoint)
request.httpMethod = "POST"
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.setValue(name, forHTTPHeaderField: "X-Crash-Report-Name")
let task = URLSession.shared.uploadTask(with: request, from: data) { _, response, error in
guard error == nil,
let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else { return }
Task { @MainActor in onSent(name) }
}
task.resume()
}
}
+261
View File
@@ -0,0 +1,261 @@
import Foundation
// MARK: - Diagnostic logging
//
// A small always-on (opt-out) file logger that writes human-readable lines to
// `~/.edmund/logs/edmund-YYYY-MM-DD.log` (one file per day) so problems can be
// diagnosed after the fact. Logs stay on the user's Mac and may contain document
// text that's fine because they never leave the device.
//
// Design:
// - Three semantic levels (`debug`/`info`/`error`). A single compile-time
// threshold decides what ships: release writes `info` and up; DEBUG builds also
// write `debug`. The user never picks a level only on/off (see Settings).
// - Writes happen on a private serial queue, so logging never blocks the caller;
// timestamps are captured at the call site, so async writes stay in order.
// - `measure` times a closure and emits a single duration line for operations
// (load, full recompose) whose cost can't be read off a pair of events.
public enum Log {
public enum Level: Int, Comparable, Sendable {
case debug = 0, info = 1, error = 2
public static func < (lhs: Level, rhs: Level) -> Bool { lhs.rawValue < rhs.rawValue }
var tag: String {
switch self {
case .debug: return "DEBUG"
case .info: return "INFO"
case .error: return "ERROR"
}
}
}
/// Subsystem tag on each line mirrors the architecture's areas so logs can
/// be grepped by concern.
public enum Category: String, Sendable {
case app, document, io, render, compose, selection, lazy, callout, edit
}
/// What gets written in this build. The user opts the whole facility out;
/// they do not choose a level.
#if DEBUG
static let minLevel: Level = .debug
#else
static let minLevel: Level = .info
#endif
// MARK: Configuration (driven by the app from Settings)
/// Point the logger at a directory, enable/disable it, and set a retention
/// window (`nil` = keep forever). Enabling prunes anything past `retention`.
public static func configure(enabled: Bool, directory: URL, retention: TimeInterval?) {
LogStore.shared.configure(enabled: enabled, directory: directory, retention: retention)
}
/// Verbose editor tracing: a separate opt-in (off by default) gating the
/// high-volume per-edit / per-caret-move `trace` lines. Kept distinct from the
/// on/off of the whole logger so a normal user's logs aren't flooded with
/// keystroke-level detail; turned on only when reproducing an editor bug.
public static func setVerbose(_ verbose: Bool) {
LogStore.shared.setVerbose(verbose)
}
// MARK: Emit
public static func debug(_ message: @autoclosure () -> String, category: Category = .app) {
guard shouldLog(.debug) else { return }
LogStore.shared.write(level: .debug, category: category, message: message(), date: Date())
}
public static func info(_ message: @autoclosure () -> String, category: Category = .app) {
guard shouldLog(.info) else { return }
LogStore.shared.write(level: .info, category: category, message: message(), date: Date())
}
public static func error(_ message: @autoclosure () -> String, category: Category = .app) {
guard shouldLog(.error) else { return }
LogStore.shared.write(level: .error, category: category, message: message(), date: Date())
}
/// High-volume editor trace (edit pipeline, caret moves). Written at `info`
/// level but ONLY when verbose editor tracing is enabled so it's free and
/// silent in normal use, and complete when a bug is being reproduced. Use for
/// the intricate live-NSTextView / TextKit 2 paths that can't be inspected
/// headlessly. The message is an autoclosure: zero cost when verbose is off.
public static func trace(_ message: @autoclosure () -> String, category: Category = .edit) {
guard shouldTrace else { return }
LogStore.shared.write(level: .info, category: category, message: message(), date: Date())
}
/// True when verbose editor tracing should be written (logging on AND verbose
/// on). Lets callers skip building expensive trace context.
public static var shouldTrace: Bool {
LogStore.shared.isEnabled && LogStore.shared.isVerbose
}
/// Runs `body`, and if logging is active emits one line with how long it took.
/// Zero overhead (just runs `body`) when the level is filtered out or logging
/// is off.
@discardableResult
public static func measure<T>(_ label: @autoclosure () -> String,
category: Category = .app,
level: Level = .info,
_ body: () throws -> T) rethrows -> T {
guard shouldLog(level) else { return try body() }
let start = DispatchTime.now()
let result = try body()
let ms = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
LogStore.shared.write(level: level, category: category,
message: "\(label())\(String(format: "%.1f", ms)) ms", date: Date())
return result
}
/// Logs the structure of a block array at `debug` level: each block's kind
/// and character count, with no document text. Example output:
/// Structure (4): heading(2)·18c, paragraph·234c, codeBlock(swift)·456c, callout·120c
public static func blockStructure(_ blocks: [Block], category: Category = .compose) {
guard shouldLog(.debug) else { return }
let parts = blocks.map { b -> String in
let c = b.range.length
switch b.kind {
case .paragraph: return "paragraph·\(c)c"
case .heading(let level): return "heading(\(level)\(c)c"
case .quoteRun(let isCallout): return "\(isCallout ? "callout" : "quote")·\(c)c"
case .fence: return "fence·\(c)c"
case .indentedCode: return "indentedCode·\(c)c"
case .mathDisplay: return "math·\(c)c"
case .table: return "table·\(c)c"
case .listItem: return "listItem·\(c)c"
case .thematicBreak: return "hr·\(c)c"
case .htmlBlock: return "htmlBlock·\(c)c"
case .blank: return "blank·\(c)c"
}
}
LogStore.shared.write(level: .debug, category: category,
message: "Structure (\(blocks.count)): \(parts.joined(separator: ", "))",
date: Date())
}
/// Blocks until queued writes have hit disk. For tests.
public static func flush() { LogStore.shared.flush() }
private static func shouldLog(_ level: Level) -> Bool {
level >= minLevel && LogStore.shared.isEnabled
}
}
// MARK: - Backing store
/// Holds the logger's mutable state. Configuration is guarded by a lock; all file
/// I/O (and the non-`Sendable` `DateFormatter`s) is confined to one serial queue.
private final class LogStore: @unchecked Sendable {
static let shared = LogStore()
private let lock = NSLock()
private let queue = DispatchQueue(label: "com.i7t5.edmund.log")
// Lock-guarded configuration.
private var _enabled = false
private var _verbose = false
private var directory = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".edmund/logs", isDirectory: true)
// Queue-confined state.
private var handle: FileHandle?
private var handleDay: String?
private let dayFormatter = LogStore.makeFormatter("yyyy-MM-dd")
private let timeFormatter = LogStore.makeFormatter("yyyy-MM-dd HH:mm:ss.SSS")
var isEnabled: Bool { lock.withLock { _enabled } }
var isVerbose: Bool { lock.withLock { _verbose } }
func configure(enabled: Bool, directory: URL, retention: TimeInterval?) {
lock.withLock {
_enabled = enabled
self.directory = directory
}
queue.async { [weak self] in
self?.closeHandle() // directory may have changed
if enabled, let retention { self?.prune(retention: retention) }
}
}
func setVerbose(_ verbose: Bool) {
lock.withLock { _verbose = verbose }
}
func write(level: Log.Level, category: Log.Category, message: String, date: Date) {
let dir = lock.withLock { directory }
queue.async { [weak self] in
guard let self else { return }
let line = "\(self.timeFormatter.string(from: date)) [\(level.tag)] [\(category.rawValue)] \(message)"
self.append(line, in: dir, date: date)
}
}
func flush() { queue.sync {} }
// MARK: File I/O (queue only)
private func append(_ line: String, in dir: URL, date: Date) {
let day = dayFormatter.string(from: date)
if handleDay != day { closeHandle() }
if handle == nil {
guard let h = openHandle(dir: dir, day: day) else { return }
handle = h
handleDay = day
}
guard let data = (line + "\n").data(using: .utf8) else { return }
do {
try handle?.write(contentsOf: data)
} catch {
// The file may have been moved or deleted out from under us; reopen once.
closeHandle()
if let h = openHandle(dir: dir, day: day) {
handle = h
handleDay = day
try? h.write(contentsOf: data)
}
}
}
private func openHandle(dir: URL, day: String) -> FileHandle? {
let fm = FileManager.default
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("edmund-\(day).log")
if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil)
}
guard let h = try? FileHandle(forWritingTo: url) else { return nil }
try? h.seekToEnd()
return h
}
private func closeHandle() {
try? handle?.close()
handle = nil
handleDay = nil
}
private func prune(retention: TimeInterval) {
let fm = FileManager.default
let dir = lock.withLock { directory }
guard let urls = try? fm.contentsOfDirectory(
at: dir, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-retention)
for url in urls where url.lastPathComponent.hasPrefix("edmund-") && url.pathExtension == "log" {
let modified = try? url.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
if let modified, modified < cutoff {
try? fm.removeItem(at: url)
}
}
}
private static func makeFormatter(_ format: String) -> DateFormatter {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = format
return f
}
}
@@ -0,0 +1,59 @@
import AppKit
// MARK: - Block-quote / Callout Continuation on Enter
//
// Pressing Return inside a block quote (including a callout) repeats the quote
// prefix on the next line `> ` so the quote/callout keeps going, the same
// way list items auto-continue. Pressing Return on an *empty* quote line removes
// the prefix and breaks out of the quote.
extension EditorTextView {
/// Leading indent + one or more `>` levels (each with an optional single
/// space), at the start of a line. Captures the *full* nesting depth so a
/// nested callout/quote line `> > ` continues as `> > `, not `> `.
private static let blockquotePrefixRegex = try! NSRegularExpression(
pattern: #"^[ \t]*(?:>[ \t]?)+"#
)
/// Continues a block quote / callout on Return. Returns true if it handled
/// the newline.
func handleBlockquoteNewline(at location: Int) -> Bool {
let ns = rawSource as NSString
guard location <= ns.length else { return false }
// The line containing the cursor (without its trailing newline).
let lineRange = ns.lineRange(for: NSRange(location: location, length: 0))
var lineEnd = lineRange.upperBound
if lineEnd > lineRange.location, ns.character(at: lineEnd - 1) == 0x0A { lineEnd -= 1 }
let lineStart = lineRange.location
let line = ns.substring(with: NSRange(location: lineStart, length: lineEnd - lineStart))
let lineNS = line as NSString
guard let m = Self.blockquotePrefixRegex.firstMatch(
in: line, range: NSRange(location: 0, length: lineNS.length)) else { return false }
let prefix = lineNS.substring(with: m.range)
let hasContent = m.range.length < lineNS.length
if hasContent {
// Continue the quote/callout at the same depth: newline + same prefix.
insertText("\n" + prefix, replacementRange: NSRange(location: location, length: 0))
} else {
// Empty quote line step out one nesting level (drop the last `>`).
// At the top level this empties the line, breaking out of the quote.
insertText(Self.reduceQuotePrefix(prefix),
replacementRange: NSRange(location: lineStart, length: lineEnd - lineStart))
}
return true
}
/// Drops the deepest `>` level from a quote prefix: `> > ` `> `,
/// ` > ` ` ` (indent kept), `> ` `` (broken out).
static func reduceQuotePrefix(_ prefix: String) -> String {
let ns = prefix as NSString
let lastGT = ns.range(of: ">", options: .backwards)
guard lastGT.location != NSNotFound else { return "" }
return ns.substring(to: lastGT.location)
}
}
@@ -0,0 +1,503 @@
import AppKit
// MARK: - Format-menu actions
//
// Public @objc action methods targeted by the Format menu (nil-target items
// route through the responder chain to the focused editor the same wiring as
// undo/redo). Each delegates to a primitive/helper in +FormattingCore.
//
// ## Invertibility
//
// Most format commands are toggles: applying twice returns to the original text
// and cursor position. Exceptions:
// Checklist (L) NOT invertible. Re-applying cycles [ ] [x] instead of
// removing the checklist marker.
// Footnote NOT invertible. Inserts a new [^n] each time.
// Table NOT a toggle. Always inserts a fresh placeholder table.
//
// ## Caret repositioning
//
// After wrap-on (no selection or wrap-off leaves content selected):
// Symmetric inline styles (Bold, Italic, ): caret placed on the first char
// of the wrapped content so the next keystroke edits inside the delimiters.
// Link/Image (no selection): caret lands inside the `()` so the URL can be
// typed immediately.
// Link/Wikilink/Image (selection or word expansion): caret inside `()` or
// after content.
// Footnote: caret at the end-of-file definition line, ready to type the note.
// Math Block / Code Block: caret on the opening-fence line (language / math
// content).
// Table: caret on the first header cell.
//
// ## List replacement
//
// Applying any list format (Bulleted / Numbered / Checklist) to a line that
// already carries a different list marker strips the old marker first (via
// stripListPrefix), so lists replace rather than nest:
// `- [ ] task` B `- task` (not `- - [ ] task`)
// `1. item` L `- [ ] item`
//
// ## Whitespace stripping
//
// Inline wraps (Bold, Italic, Code, etc.) exclude leading/trailing spaces from
// the delimiters: selecting " word " and pressing Cmd+B gives " **word** ", not
// "** word **". Toggle-off detection also operates on the trimmed range, so
// selecting " **word** " and pressing Cmd+B correctly unwraps.
extension EditorTextView {
// MARK: - Inline font styles
// All toggle (applying twice restores original).
//
// Caret-with-no-selection behaviour: a caret INSIDE a word acts on the whole
// word (`anyth|ing` + Cmd+B `**anything**`); pressing again unwraps it. When
// the caret is not in a word (blank line, between punctuation), empty delimiters
// are inserted with the caret centred (`**|**`).
//
// Bold and Italic additionally use markdown's `*`-nesting semantics at a caret,
// so the two compose: `**w**` + Cmd+I `***w***`, and `***w***` + Cmd+B
// `*w*`. See toggleStarEmphasis. The other styles use the generic word wrap
// (expandToWord), since they don't nest with each other.
@objc public func formatBold(_ sender: Any?) { toggleStarEmphasis(stars: 2) }
@objc public func formatItalic(_ sender: Any?) { toggleStarEmphasis(stars: 1) }
@objc public func formatUnderline(_ sender: Any?) { toggleInlineWrap(open: "<u>", close: "</u>", expandToWord: true) }
@objc public func formatStrikethrough(_ sender: Any?) { toggleInlineWrap(open: "~~", close: "~~", expandToWord: true) }
@objc public func formatHighlight(_ sender: Any?) { toggleInlineWrap(open: "==", close: "==", expandToWord: true) }
@objc public func formatCode(_ sender: Any?) { toggleInlineWrap(open: "`", close: "`", expandToWord: true) }
@objc public func formatInlineMath(_ sender: Any?) { toggleInlineWrap(open: "$", close: "$", expandToWord: true) }
@objc public func formatKeyboard(_ sender: Any?) { toggleInlineWrap(open: "<kbd>", close: "</kbd>", expandToWord: true) }
@objc public func formatComment(_ sender: Any?) { toggleInlineWrap(open: "%%", close: "%%", expandToWord: true) }
// MARK: - Inline links
// Link / Image: caret in `()` so URL can be typed next.
// Wikilink: expands to the current word at caret (expandToWord: true).
// Footnote: NOT invertible inserts [^n] marker and EOF definition.
@objc public func formatWikilink(_ sender: Any?) { toggleInlineWrap(open: "[[", close: "]]", expandToWord: true) }
@objc public func formatLink(_ sender: Any?) { insertLink() }
@objc public func formatImage(_ sender: Any?) { insertImage() }
@objc public func formatFootnote(_ sender: Any?) { insertFootnote() }
// MARK: - Block-level commands
@objc public func formatBulletedList(_ sender: Any?) { toggleLinePrefix("- ") }
@objc public func formatNumberedList(_ sender: Any?) { toggleNumberedList() }
@objc public func formatChecklist(_ sender: Any?) { toggleChecklist() }
@objc public func formatBlockQuote(_ sender: Any?) { toggleLinePrefix("> ") }
@objc public func formatThematicBreak(_ sender: Any?) { insertThematicBreak() }
@objc public func formatCodeBlock(_ sender: Any?) { insertCodeBlock() }
@objc public func formatMathBlock(_ sender: Any?) { insertMathBlock() }
@objc public func formatTable(_ sender: Any?) { insertTable() }
/// Heading level read from the menu item's `tag` (16).
/// Heading H1H6: strips any existing `#` prefix and applies the new level.
/// Re-applying the same level clears the heading. Applies per selected line.
@objc public func formatHeading(_ sender: Any?) {
applyHeadingLevel((sender as? NSMenuItem)?.tag ?? 1)
}
/// Callout type read from the menu item's `representedObject` (pre-cased:
/// uppercase for GitHub alerts, lowercase for Obsidian callouts).
@objc public func formatCallout(_ sender: Any?) {
guard let type = (sender as? NSMenuItem)?.representedObject as? String else { return }
applyCalloutType(type)
}
// MARK: - Menu validation
// Formatting actions are disabled in Reading mode (the editor is read-only).
public override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
if let action = menuItem.action, Self.formattingActions.contains(action) {
return viewMode != .reading
}
return super.validateMenuItem(menuItem)
}
static let formattingActions: Set<Selector> = [
#selector(formatBold(_:)), #selector(formatItalic(_:)), #selector(formatUnderline(_:)),
#selector(formatStrikethrough(_:)), #selector(formatHighlight(_:)), #selector(formatCode(_:)),
#selector(formatInlineMath(_:)), #selector(formatKeyboard(_:)), #selector(formatComment(_:)),
#selector(formatWikilink(_:)), #selector(formatLink(_:)), #selector(formatImage(_:)),
#selector(formatFootnote(_:)), #selector(formatBulletedList(_:)), #selector(formatNumberedList(_:)),
#selector(formatChecklist(_:)), #selector(formatBlockQuote(_:)), #selector(formatThematicBreak(_:)),
#selector(formatCodeBlock(_:)), #selector(formatMathBlock(_:)), #selector(formatTable(_:)),
#selector(formatHeading(_:)), #selector(formatCallout(_:)),
]
// MARK: - Heading
func applyHeadingLevel(_ level: Int) {
// All selected lines get the same heading level applied or cleared.
// "Same level" is determined by majority: if every non-empty line already
// has exactly `level` hashes, they are all cleared (toggle-off).
transformSelectedLines { lines in
let nonEmpty = lines.filter { !$0.isEmpty }
let allAtLevel = !nonEmpty.isEmpty && nonEmpty.allSatisfy { self.leadingHashCount($0) == level }
return lines.map { line in
guard !line.isEmpty else { return line }
let stripped = self.stripLeadingHashes(line)
return allAtLevel ? stripped : String(repeating: "#", count: level) + " " + stripped
}
}
}
// MARK: - Lists / quote
/// Prepend `prefix` to every line, or strip it when every non-empty line is
/// already that exact type (toggle-off).
///
/// List replacement: for bullet prefixes (`"- "` etc.), any existing list
/// marker (checklist, numbered, other bullet) is stripped before the new one
/// is applied, so list types replace each other rather than stacking. Block
/// quotes (`"> "`) are not list markers; they stack normally.
///
/// Toggle-off check: only fires when every non-empty line is EXACTLY this
/// bullet type not checklists (which also start with `"- "` but are a
/// different type) or numbered lists.
func toggleLinePrefix(_ prefix: String) {
let isList = (prefix == "- " || prefix == "* " || prefix == "+ ")
transformSelectedLines { lines in
let nonEmpty = lines.filter { !$0.isEmpty }
let stripAll: Bool
if isList {
// Toggle-off only for exact bullets, not checklists or numbered.
stripAll = !nonEmpty.isEmpty && nonEmpty.allSatisfy { self.isBulletLine($0) && $0.hasPrefix(prefix) }
} else {
stripAll = !nonEmpty.isEmpty && nonEmpty.allSatisfy { $0.hasPrefix(prefix) }
}
if stripAll {
return lines.map { $0.hasPrefix(prefix) ? String($0.dropFirst(prefix.count)) : $0 }
}
if lines == [""] { return [prefix] }
return lines.map { line -> String in
guard !line.isEmpty else { return line }
// Strip existing list marker before adding new one (replacement, not nesting).
return isList ? prefix + self.stripListPrefix(line) : prefix + line
}
}
}
/// Numbered list: prepend `1.`, `2.`, per line (toggle-off strips the
/// prefix). If the line immediately before the selection ends with `N. `,
/// numbering continues from N+1 rather than restarting at 1.
/// Replacement: strips any existing list marker before numbering.
func toggleNumberedList() {
let ns = rawSource as NSString
let ctx = selectedLineContext()
var start = 1
if ctx.range.location > 0 {
let prev = ns.lineRange(for: NSRange(location: ctx.range.location - 1, length: 0))
var prevLine = ns.substring(with: prev)
if prevLine.hasSuffix("\n") { prevLine.removeLast() }
if let n = leadingListNumber(prevLine) { start = n + 1 }
}
transformSelectedLines { lines in
let nonEmpty = lines.filter { !$0.isEmpty }
let allNumbered = !nonEmpty.isEmpty && nonEmpty.allSatisfy { self.leadingListNumber($0) != nil }
if allNumbered {
return lines.map { self.stripListPrefix($0) }
}
if lines == [""] { return ["\(start). "] }
var n = start
return lines.map { line -> String in
guard !line.isEmpty else { return line }
defer { n += 1 }
return "\(n). " + self.stripListPrefix(line)
}
}
}
/// Checklist (NOT invertible):
/// Checklist line: toggles the mark `[ ]` `[x]`.
/// Any other line (plain, bullet, numbered): strips the existing list
/// marker and prepends `- [ ] ` (replacement, not nesting).
func toggleChecklist() {
transformSelectedLines { lines in
lines.map { line in
if self.isChecklistLine(line) {
let ns = line as NSString
let mark = ns.character(at: 3)
let newMark = (mark == 0x20) ? "x" : " " // ' ' 'x'
return "- [" + newMark + "] " + ns.substring(from: 6)
}
// Strip existing list marker before adding checklist prefix.
return "- [ ] " + self.stripListPrefix(line)
}
}
}
// MARK: - Link / Image / Footnote
/// Link (K): toggle.
/// With selection: wraps as `[selection]()`, caret in `()`. If the selection
/// is already `[text](dest)`, unwraps to the text.
/// No selection: if caret is inside an existing `[text](url)`, unwraps it.
/// Otherwise expands to the current word, producing `[word]()`, caret in `()`.
/// Fallback (no word): inserts `[]()`, caret in `()`.
private func insertLink() {
let ns = rawSource as NSString
let sel = selectedRange()
if sel.length > 0 {
let text = ns.substring(with: sel)
if let inner = unwrapLink(text) {
applyFormattingEdit(rawRange: sel, replacement: inner,
select: NSRange(location: sel.location, length: (inner as NSString).length))
return
}
let replacement = "[" + text + "]()"
let caret = sel.location + 1 + (text as NSString).length + 2 // inside ()
applyFormattingEdit(rawRange: sel, replacement: replacement,
select: NSRange(location: caret, length: 0))
return
}
// Caret: check if inside an existing link unwrap.
if let linkRange = linkRangeAroundCaret() {
let linkText = ns.substring(with: linkRange)
if let inner = unwrapLink(linkText) {
applyFormattingEdit(rawRange: linkRange, replacement: inner,
select: NSRange(location: linkRange.location, length: (inner as NSString).length))
return
}
}
// Expand to current word.
if let word = currentWordRange() {
let wordText = ns.substring(with: word)
let replacement = "[" + wordText + "]()"
let caret = word.location + 1 + (wordText as NSString).length + 2 // inside ()
applyFormattingEdit(rawRange: word, replacement: replacement,
select: NSRange(location: caret, length: 0))
return
}
// No word: insert empty link, caret inside ().
applyFormattingEdit(rawRange: NSRange(location: sel.location, length: 0),
replacement: "[]()",
select: NSRange(location: sel.location + 3, length: 0))
}
/// Image: same shape as Link, prefixed with `!`.
/// Caret ends up inside the `()` so the URL/path can be typed.
private func insertImage() {
let ns = rawSource as NSString
let sel = selectedRange()
if sel.length > 0 {
let text = ns.substring(with: sel)
if let inner = unwrapImage(text) {
applyFormattingEdit(rawRange: sel, replacement: inner,
select: NSRange(location: sel.location, length: (inner as NSString).length))
return
}
let replacement = "![" + text + "]()"
let caret = sel.location + 2 + (text as NSString).length + 2 // inside ()
applyFormattingEdit(rawRange: sel, replacement: replacement,
select: NSRange(location: caret, length: 0))
return
}
if let word = currentWordRange() {
let wordText = ns.substring(with: word)
let replacement = "![" + wordText + "]()"
let caret = word.location + 2 + (wordText as NSString).length + 2 // inside ()
applyFormattingEdit(rawRange: word, replacement: replacement,
select: NSRange(location: caret, length: 0))
return
}
applyFormattingEdit(rawRange: NSRange(location: sel.location, length: 0),
replacement: "![]()",
select: NSRange(location: sel.location + 4, length: 0))
}
/// Footnote (NOT invertible): inserts `[^n]` after the selection / end of
/// current word / caret, then appends `[^n]: ` at the end of the document.
/// Caret lands at the EOF definition so the note body can be typed immediately.
/// `n` is the next unused number (max existing [^k] + 1, starting at 1).
private func insertFootnote() {
let ns = rawSource as NSString
let sel = selectedRange()
let n = nextFootnoteNumber()
// Insertion point: after selection, or after the current word, or at caret.
let markerPos: Int
if sel.length > 0 {
markerPos = sel.upperBound
} else if let word = currentWordRange() {
markerPos = word.upperBound
} else {
markerPos = sel.location
}
var newRaw = ns.replacingCharacters(in: NSRange(location: markerPos, length: 0),
with: "[^\(n)]")
let body = newRaw as NSString
// A blank line (not just a single \n) before the definition so it parses
// as its own paragraph rather than a lazy-continuation line of the
// reference's paragraph CommonMark (and Read mode's HTMLRenderer, which
// parses the whole document) needs that separation to recognize it as a
// footnote definition rather than fused body text.
var trailingNewlines = 0
var i = body.length - 1
while i >= 0 && body.character(at: i) == 0x0A { trailingNewlines += 1; i -= 1 }
let separator = body.length == 0 ? "" : String(repeating: "\n", count: max(0, 2 - trailingNewlines))
newRaw += separator + "[^\(n)]: "
let caret = (newRaw as NSString).length
applyWholeDocumentEdit(newRawSource: newRaw, select: NSRange(location: caret, length: 0))
}
// MARK: - Block insert
/// Code block: wraps selected lines in ` ``` `` ``` ` fences (toggle-off removes
/// them). Caret lands on the opening-fence line so a language tag can be typed.
private func insertCodeBlock() {
let ctx = selectedLineContext()
if ctx.lines.count >= 2, ctx.lines.first!.hasPrefix("```"), ctx.lines.last! == "```" {
// Toggle off: unwrap the fenced content.
let inner = ctx.lines.dropFirst().dropLast().joined(separator: "\n")
var replacement = inner
if ctx.trailingNewline { replacement += "\n" }
applyFormattingEdit(rawRange: ctx.range, replacement: replacement,
select: NSRange(location: ctx.range.location, length: 0))
return
}
let content = ctx.lines.joined(separator: "\n")
var replacement = "```\n" + content + "\n```"
if ctx.trailingNewline { replacement += "\n" }
// Caret after the opening "```" so a language tag can be typed.
applyFormattingEdit(rawRange: ctx.range, replacement: replacement,
select: NSRange(location: ctx.range.location + 3, length: 0))
}
/// Math block: wraps selected lines in `$$``$$` fences (toggle-off removes
/// them). Each `$$` occupies its own line (block math format).
/// Caret lands on the first content line between the fences.
private func insertMathBlock() {
let ctx = selectedLineContext()
if ctx.lines.count >= 2, ctx.lines.first! == "$$", ctx.lines.last! == "$$" {
let inner = ctx.lines.dropFirst().dropLast().joined(separator: "\n")
var replacement = inner
if ctx.trailingNewline { replacement += "\n" }
applyFormattingEdit(rawRange: ctx.range, replacement: replacement,
select: NSRange(location: ctx.range.location, length: 0))
return
}
let content = ctx.lines.joined(separator: "\n")
var replacement = "$$\n" + content + "\n$$"
if ctx.trailingNewline { replacement += "\n" }
// Caret on the first content line (after the opening "$$\n").
applyFormattingEdit(rawRange: ctx.range, replacement: replacement,
select: NSRange(location: ctx.range.location + 3, length: 0))
}
/// Table: inserts a 3×2 placeholder table (3 columns, 2 data rows) after the
/// current line. Not a toggle. Dividers are padded to match header widths (8 chars).
/// Caret lands on the first header cell.
private func insertTable() {
let ns = rawSource as NSString
let sel = selectedRange()
let line = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0))
let lineEndsWithNewline = line.upperBound > line.location && ns.character(at: line.upperBound - 1) == 0x0A
let lineIsBlank = ns.substring(with: line).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
// 3 columns; dividers padded to "Header N" length (8 dashes).
let table = """
| Header 1 | Header 2 | Header 3 |
| -------- | -------- | -------- |
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
"""
let insertPos: Int
let replacement: String
let lead: Int
if lineIsBlank {
insertPos = line.location
replacement = table
lead = 0
} else if lineEndsWithNewline {
insertPos = line.upperBound
replacement = table
lead = 0
} else {
insertPos = ns.length
replacement = "\n" + table
lead = 1
}
let caret = insertPos + lead + 2 // past leading newline (if any) + "| "
applyFormattingEdit(rawRange: NSRange(location: insertPos, length: 0),
replacement: replacement,
select: NSRange(location: caret, length: 0))
}
// MARK: - Thematic break
/// Thematic break (horizontal rule): inserts `---` on its own line.
/// Toggle-off: if the selected line is exactly `---`, removes it.
///
/// Insertion rules:
/// Empty line replace the line with `---\n` (the caret is already on
/// an isolated line, so no separator is needed).
/// Non-empty line insert `\n---\n` after the line end; prepend an
/// extra `\n` when the line has no trailing newline (last line, no EOF
/// newline) so the `---` isn't parsed as a setext heading underline.
///
/// Caret lands after `---\n` so the next paragraph can be typed immediately.
private func insertThematicBreak() {
let ns = rawSource as NSString
let ctx = selectedLineContext()
if ctx.lines == ["---"] {
applyFormattingEdit(rawRange: ctx.range, replacement: "",
select: NSRange(location: ctx.range.location, length: 0))
return
}
let sel = selectedRange()
let line = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0))
// Empty line: replace just the line content with "---\n".
if ctx.lines == [""] {
let replacement = "---\n"
applyFormattingEdit(rawRange: ctx.range, replacement: replacement,
select: NSRange(location: ctx.range.location + 3, length: 0))
return
}
// Non-empty line: insert after the line end.
let hasTrailingNewline = line.upperBound > line.location
&& ns.character(at: line.upperBound - 1) == 0x0A
let text = hasTrailingNewline ? "\n---\n" : "\n\n---\n"
let insertAt = line.upperBound
let caret = insertAt + (text as NSString).length
applyFormattingEdit(rawRange: NSRange(location: insertAt, length: 0),
replacement: text,
select: NSRange(location: min(caret, (rawSource as NSString).length), length: 0))
}
// MARK: - Callout / alert
/// Wrap the selected lines in a callout (`> [!TYPE]` header + `> ` body), or
/// strip it when the lines are already that callout. `type` is pre-cased
/// (uppercase for GitHub, lowercase for Obsidian).
func applyCalloutType(_ type: String) {
transformSelectedLines { lines in
let header = "> [!\(type)]"
if let first = lines.first,
first.trimmingCharacters(in: .whitespaces).lowercased() == "> [!\(type.lowercased())]" {
return lines.dropFirst().map {
if $0.hasPrefix("> ") { return String($0.dropFirst(2)) }
if $0.hasPrefix(">") { return String($0.dropFirst(1)) }
return $0
}
}
let body = lines.map { $0.isEmpty ? ">" : "> " + $0 }
return [header] + body
}
}
}
@@ -0,0 +1,527 @@
import AppKit
// MARK: - Formatting primitives & helpers
//
// The Format-menu commands (EditorTextView+FormattingCommands) all funnel
// through the two edit primitives here. They follow the same template as the
// Tab-indent path (EditorTextView+Indentation): push a single undo snapshot,
// rebuild `rawSource`, re-parse blocks, and restyle only the affected span via
// `recomposeReplacing` preserving the hard invariant that the text storage
// always equals `rawSource` (rendering is attribute-only).
extension EditorTextView {
// MARK: - Edit primitives
/// Replace one contiguous `rawRange` (in current rawSource coordinates) with
/// `replacement` as a single undoable step, restyle the affected block span
/// in place, and set `select` (a caret when `length == 0`).
func applyFormattingEdit(rawRange: NSRange, replacement: String, select: NSRange) {
guard !blocks.isEmpty else { return }
let ns = rawSource as NSString
let loc = min(max(0, rawRange.location), ns.length)
let clamped = NSRange(location: loc, length: min(rawRange.length, ns.length - loc))
guard let startBlock = blockIndexForRawOffset(clamped.location),
let endBlock = blockIndexForRawOffset(clamped.upperBound) else { return }
// Pre-edit storage span covering exactly the affected blocks, so layout
// (and the viewport) above/below the edit stays put.
let oldSpan = NSRange(
location: blocks[startBlock].range.location,
length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location)
undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: selectedRange().location))
redoStack.removeAll()
lastEditType = .other
lastEditBlockIndex = nil
rawSource = ns.replacingCharacters(in: clamped, with: replacement)
rebuildListIndentState()
rebuildLinkDefState()
blocks = BlockParser.parse(rawSource, previous: blocks)
// The replaced region grew/shrank by `delta`; the new block-aligned span
// is the old span plus that delta. Its text is the storage replacement.
let delta = (replacement as NSString).length - clamped.length
let newSpan = NSRange(location: oldSpan.location, length: max(0, oldSpan.length + delta))
let newRaw = rawSource as NSString
let safeSpan = NSRange(location: min(newSpan.location, newRaw.length),
length: min(newSpan.length, newRaw.length - min(newSpan.location, newRaw.length)))
let newText = newRaw.substring(with: safeSpan)
let lastPos = safeSpan.length > 0 ? safeSpan.upperBound - 1 : safeSpan.location
let newStart = blockIndexForRawOffset(safeSpan.location) ?? 0
let newEnd = blockIndexForRawOffset(lastPos) ?? newStart
let dirty = IndexSet(integersIn: newStart...min(newEnd, blocks.count - 1))
let sel = NSRange(location: min(select.location, newRaw.length),
length: min(select.length, newRaw.length - min(select.location, newRaw.length)))
stabilizingViewport {
recomposeReplacing(oldRange: oldSpan, with: newText, dirty: dirty,
cursorInRaw: sel.location,
selectionInRaw: sel.length > 0 ? sel : nil)
}
document?.updateChangeCount(.changeDone)
}
/// Replace the whole document as one undoable step (for non-contiguous edits
/// like footnotes: an inline marker plus an end-of-file definition).
func applyWholeDocumentEdit(newRawSource: String, select: NSRange) {
undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: selectedRange().location))
redoStack.removeAll()
lastEditType = .other
lastEditBlockIndex = nil
rawSource = newRawSource
rebuildListIndentState()
rebuildLinkDefState()
blocks = BlockParser.parse(rawSource, previous: blocks)
let len = (rawSource as NSString).length
let loc = min(select.location, len)
let sel = NSRange(location: loc, length: min(select.length, len - loc))
recompose(cursorInRaw: sel.location, selectionInRaw: sel.length > 0 ? sel : nil)
document?.updateChangeCount(.changeDone)
}
// MARK: - Line-range helpers
/// The full lines covered by the current selection (or caret), their
/// contents split on `\n`, and whether the range ends in a trailing newline.
func selectedLineContext() -> (range: NSRange, lines: [String], trailingNewline: Bool) {
let ns = rawSource as NSString
let sel = selectedRange()
let startLine = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0))
let lastChar = sel.length > 0 ? max(sel.location, sel.upperBound - 1) : sel.location
let endLine = ns.lineRange(for: NSRange(location: min(lastChar, ns.length), length: 0))
let range = NSRange(location: startLine.location,
length: endLine.upperBound - startLine.location)
let text = ns.substring(with: range)
let trailing = text.hasSuffix("\n")
var lines = text.components(separatedBy: "\n")
if trailing { lines.removeLast() }
return (range, lines, trailing)
}
/// Apply a per-line `transform` over the selected line range.
///
/// Caret repositioning: the caret tracks the first line, shifted by however
/// many characters that line's prefix changed by (e.g. adding "- " moves the
/// caret two positions right). With a multi-line selection the whole new
/// content is re-selected (excluding any trailing newline).
func transformSelectedLines(_ transform: ([String]) -> [String]) {
let sel = selectedRange()
let ctx = selectedLineContext()
let newLines = transform(ctx.lines)
var replacement = newLines.joined(separator: "\n")
if ctx.trailingNewline { replacement += "\n" }
let select: NSRange
if sel.length > 0 {
let len = (replacement as NSString).length - (ctx.trailingNewline ? 1 : 0)
select = NSRange(location: ctx.range.location, length: max(0, len))
} else {
let oldFirst = (ctx.lines.first.map { ($0 as NSString).length }) ?? 0
let newFirst = (newLines.first.map { ($0 as NSString).length }) ?? 0
let caretInLine = sel.location - ctx.range.location
let newCaretInLine = min(max(0, caretInLine + (newFirst - oldFirst)), newFirst)
select = NSRange(location: ctx.range.location + newCaretInLine, length: 0)
}
applyFormattingEdit(rawRange: ctx.range, replacement: replacement, select: select)
}
// MARK: - Inline wrap (toggle)
/// Wrap the selection (or caret) in `open``close`, or unwrap when already wrapped.
///
/// ## Whitespace stripping
/// Leading and trailing spaces are excluded from the delimiters, so selecting
/// `" word "` and pressing Cmd+B yields `" **word** "`, not `"** word **"`.
/// Toggle-off detection also uses the trimmed range.
///
/// ## Toggle-off detection (selection path)
/// Three checks, tried in order:
/// 1. Delimiter pair sits immediately around the trimmed selection. An isolation
/// guard prevents a false match when the selection content (`"word"` after Cmd+B)
/// is inside a LONGER delimiter run: e.g. `*` at position 1 of `**word**` is not
/// an italic delimiter it is the inner character of the bold `**`. Without the
/// guard, Cmd+B Cmd+I would toggle italic OFF instead of adding it, producing
/// `*word*` rather than `***word***`.
/// 2. The trimmed selection itself starts and ends with the delimiter strings
/// (user selected `**word**` and pressed Cmd+B).
///
/// ## Toggle-off detection (caret path)
/// Three checks, tried in order:
/// 1. Empty delimiters straddle the caret remove them.
/// 2. The current word is wrapped by `open`/`close` (nearest-neighbour search)
/// unwrap. No isolation guard here, so peeling one layer from `***word***` works:
/// Cmd+B finds `**` at word.location-2 and correctly removes it.
/// 3. Fallback: insert empty `open+close` with the caret centred. When
/// `expandToWord` is true, the current word is wrapped instead.
func toggleInlineWrap(open: String, close: String, expandToWord: Bool = false) {
let ns = rawSource as NSString
let sel = selectedRange()
let openLen = (open as NSString).length
let closeLen = (close as NSString).length
if sel.length > 0 {
let selText = ns.substring(with: sel)
// Whitespace stripping: exclude leading/trailing spaces from the wrap
// so " word " " **word** " instead of "** word **".
let leading = selText.prefix(while: { $0 == " " || $0 == "\t" }).count
let trailing = selText.reversed().prefix(while: { $0 == " " || $0 == "\t" }).count
let hasContent = leading + trailing < sel.length
let effLead = hasContent ? leading : 0
let effTrail = hasContent ? trailing : 0
let trimmedSel = NSRange(location: sel.location + effLead,
length: sel.length - effLead - effTrail)
let trimmedText = ns.substring(with: trimmedSel)
// Check 1: delimiters sit immediately around the trimmed selection.
// The isolation guard rejects matches where the found delimiter is part of
// a longer run: e.g. the `*` at offset 1 of `**word**` is the inner char
// of `**`, not a standalone `*`. Without the guard, selecting the bare
// content of a bold word and pressing Cmd+I would fire here and unwrap
// instead of compounding to `***word***`.
let before = trimmedSel.location - openLen
if before >= 0, trimmedSel.upperBound + closeLen <= ns.length,
ns.substring(with: NSRange(location: before, length: openLen)) == open,
ns.substring(with: NSRange(location: trimmedSel.upperBound, length: closeLen)) == close,
delimiterIsIsolated(open: open, close: close,
openAt: before, closeAt: trimmedSel.upperBound, in: ns) {
let full = NSRange(location: before, length: openLen + trimmedSel.length + closeLen)
applyFormattingEdit(rawRange: full, replacement: trimmedText,
select: NSRange(location: before, length: trimmedSel.length))
return
}
// Check 2: the trimmed selection itself IS the wrapped text.
let trimmedLen = (trimmedText as NSString).length
if trimmedLen >= openLen + closeLen,
trimmedText.hasPrefix(open), trimmedText.hasSuffix(close) {
let innerLen = trimmedLen - openLen - closeLen
let inner = (trimmedText as NSString).substring(with: NSRange(location: openLen, length: innerLen))
applyFormattingEdit(rawRange: trimmedSel, replacement: inner,
select: NSRange(location: trimmedSel.location, length: innerLen))
return
}
// Wrap on apply only to the non-whitespace content, leaving leading/
// trailing spaces outside the delimiters.
let leadStr = String(selText.prefix(effLead))
let trailStr = String(selText.suffix(effTrail))
let replacement = leadStr + open + trimmedText + close + trailStr
// Caret: inside the new delimiters, on the first char of the content.
applyFormattingEdit(rawRange: sel, replacement: replacement,
select: NSRange(location: sel.location + effLead + openLen,
length: trimmedSel.length))
return
}
let caret = sel.location
// Caret check 1: empty delimiters straddle the caret remove.
// E.g. `**|**` pressing Cmd+B again removes the pair.
if caret - openLen >= 0, caret + closeLen <= ns.length,
ns.substring(with: NSRange(location: caret - openLen, length: openLen)) == open,
ns.substring(with: NSRange(location: caret, length: closeLen)) == close {
applyFormattingEdit(rawRange: NSRange(location: caret - openLen, length: openLen + closeLen),
replacement: "",
select: NSRange(location: caret - openLen, length: 0))
return
}
// Caret check 2: nearest word is wrapped unwrap (nearest-neighbour, no
// isolation guard so peeling one layer from `***word***` works correctly).
// E.g. caret in `***word***` + Cmd+B: finds `**` at word.location-2 and peels
// it, giving `*word*`. The same caret + Cmd+I finds `*` at word.location-1
// and peels that, giving `**word**`.
if let word = currentWordRange() {
let before = word.location - openLen
if before >= 0, word.upperBound + closeLen <= ns.length,
ns.substring(with: NSRange(location: before, length: openLen)) == open,
ns.substring(with: NSRange(location: word.upperBound, length: closeLen)) == close {
let inner = ns.substring(with: word)
let full = NSRange(location: before, length: openLen + word.length + closeLen)
// Caret lands where it was but shifted left by openLen (delimiters removed).
applyFormattingEdit(rawRange: full, replacement: inner,
select: NSRange(location: caret - openLen, length: 0))
return
}
// Caret check 2b: no wrapping found wrap the whole word under the caret.
// Used by the symmetric inline styles (Bold, Italic, ) and Wikilink so
// that `anyth|ing` + Cmd+B `**anyth|ing**`. The caret keeps its position
// within the word (shifted right by the opening delimiter), so the word
// text isn't disturbed and a second press re-detects + unwraps it.
if expandToWord {
let wordText = ns.substring(with: word)
let replacement = open + wordText + close
applyFormattingEdit(rawRange: word, replacement: replacement,
select: NSRange(location: caret + openLen, length: 0))
return
}
}
// Fallback: insert empty delimiters; caret centred between them.
applyFormattingEdit(rawRange: NSRange(location: caret, length: 0),
replacement: open + close,
select: NSRange(location: caret + openLen, length: 0))
}
/// Toggle `*`-based emphasis using markdown's nesting semantics, where the run
/// of `*` around a span encodes both styles (1 = italic, 2 = bold, 3 = both).
/// `stars` is 2 for Bold, 1 for Italic. This lets the two compose at a caret:
///
/// plain Cmd+B `**w**` (bold on)
/// `**w**` Cmd+I `***w***` (italic added compound)
/// `***w***` Cmd+B `*w*` (bold removed)
/// `***w***` Cmd+I `**w**` (italic removed)
///
/// With a selection it defers to `toggleInlineWrap`, which already compounds
/// (selecting the inner text adds a layer) and peels (selecting a `****` span
/// strips it). With a bare caret it reads the symmetric run of `*` surrounding
/// the current word and adds/removes exactly `stars` of them; when the caret is
/// not in a word it inserts/removes empty delimiters like `toggleInlineWrap`.
func toggleStarEmphasis(stars: Int) {
let delim = String(repeating: "*", count: stars)
let sel = selectedRange()
if sel.length > 0 {
toggleInlineWrap(open: delim, close: delim)
return
}
let ns = rawSource as NSString
let caret = sel.location
// Empty delimiters straddle the caret remove them.
if caret - stars >= 0, caret + stars <= ns.length,
ns.substring(with: NSRange(location: caret - stars, length: stars)) == delim,
ns.substring(with: NSRange(location: caret, length: stars)) == delim {
applyFormattingEdit(rawRange: NSRange(location: caret - stars, length: stars * 2),
replacement: "", select: NSRange(location: caret - stars, length: 0))
return
}
guard let word = currentWordRange() else {
// No word under the caret: insert empty delimiters, caret centred.
applyFormattingEdit(rawRange: NSRange(location: caret, length: 0),
replacement: delim + delim,
select: NSRange(location: caret + stars, length: 0))
return
}
// The symmetric run of `*` immediately surrounding the word.
var leftRun = 0
while word.location - leftRun - 1 >= 0,
ns.character(at: word.location - leftRun - 1) == 0x2A { leftRun += 1 }
var rightRun = 0
while word.upperBound + rightRun < ns.length,
ns.character(at: word.upperBound + rightRun) == 0x2A { rightRun += 1 }
let run = min(leftRun, rightRun)
// Bold present iff 2 stars; italic present iff an odd star count (1 or 3).
let present = (stars == 2) ? (run >= 2) : (run % 2 == 1)
let newRun = present ? run - stars : run + stars
let wordText = ns.substring(with: word)
let starsStr = String(repeating: "*", count: newRun)
let full = NSRange(location: word.location - run, length: run + word.length + run)
// Caret keeps its position within the word, shifted by the star-count change.
let newCaret = caret + (newRun - run)
applyFormattingEdit(rawRange: full, replacement: starsStr + wordText + starsStr,
select: NSRange(location: newCaret, length: 0))
}
/// Returns false when the delimiter at `openAt`/`closeAt` is part of a longer
/// run of the same character indicating it is an inner char of a wider delimiter,
/// not a standalone one of the type we matched.
///
/// Example: `*` at position 1 of `**word**` has `*` at position 0 to its left,
/// so it is NOT an isolated italic `*`; it is the inner character of the bold `**`.
///
/// This guard is applied ONLY to the selection-path Check 1. The caret word-check
/// does NOT use it, so that pressing Cmd+B with the caret inside `***word***`
/// correctly finds and peels the `**` at word.location-2.
private func delimiterIsIsolated(open: String, close: String,
openAt: Int, closeAt: Int, in ns: NSString) -> Bool {
let openFirst = (open as NSString).character(at: 0)
let closeLen = (close as NSString).length
let closeLast = (close as NSString).character(at: closeLen - 1)
if openAt > 0, ns.character(at: openAt - 1) == openFirst { return false }
let afterClose = closeAt + closeLen
if afterClose < ns.length, ns.character(at: afterClose) == closeLast { return false }
return true
}
/// The maximal run of alphanumerics around the caret, or nil when the caret
/// is not adjacent to a word character.
func currentWordRange() -> NSRange? {
let ns = rawSource as NSString
let caret = selectedRange().location
func isWord(_ at: Int) -> Bool {
guard let scalar = ns.substring(with: NSRange(location: at, length: 1)).unicodeScalars.first
else { return false }
return CharacterSet.alphanumerics.contains(scalar)
}
var start = caret
while start > 0, isWord(start - 1) { start -= 1 }
var end = caret
while end < ns.length, isWord(end) { end += 1 }
return end > start ? NSRange(location: start, length: end - start) : nil
}
// MARK: - Markdown line helpers
/// Number of leading `#` (16) when the line is an ATX heading (`#`s then a
/// space); 0 otherwise.
func leadingHashCount(_ line: String) -> Int {
let ns = line as NSString
var i = 0
while i < ns.length, i < 6, ns.character(at: i) == 0x23 { i += 1 } // '#'
if i > 0, i < ns.length, ns.character(at: i) == 0x20 { return i }
return 0
}
func stripLeadingHashes(_ line: String) -> String {
let n = leadingHashCount(line)
guard n > 0 else { return line }
let ns = line as NSString
var j = n
while j < ns.length, ns.character(at: j) == 0x20 { j += 1 }
return ns.substring(from: j)
}
/// The leading list number when the line is `N. `; nil otherwise.
func leadingListNumber(_ line: String) -> Int? {
let ns = line as NSString
var i = 0
while i < ns.length, ns.character(at: i) >= 0x30, ns.character(at: i) <= 0x39 { i += 1 }
guard i > 0, i + 1 < ns.length,
ns.character(at: i) == 0x2E, ns.character(at: i + 1) == 0x20 else { return nil }
return Int(ns.substring(to: i))
}
func stripLeadingNumber(_ line: String) -> String {
guard leadingListNumber(line) != nil else { return line }
let ns = line as NSString
var i = 0
while ns.character(at: i) != 0x2E { i += 1 }
return ns.substring(from: i + 2) // skip ". "
}
/// The next unused footnote number (max existing `[^n]` + 1, starting at 1).
func nextFootnoteNumber() -> Int {
guard let re = try? NSRegularExpression(pattern: #"\[\^(\d+)\]"#) else { return 1 }
let ns = rawSource as NSString
var maxN = 0
re.enumerateMatches(in: rawSource, range: NSRange(location: 0, length: ns.length)) { m, _, _ in
guard let m, m.numberOfRanges > 1, let n = Int(ns.substring(with: m.range(at: 1))) else { return }
maxN = max(maxN, n)
}
return maxN + 1
}
// MARK: - List-type helpers
/// True when `line` is a checklist item (`- [ ] ` or `- [x] `).
func isChecklistLine(_ line: String) -> Bool {
let ns = line as NSString
return ns.length >= 6
&& ns.substring(to: 3) == "- ["
&& ns.substring(with: NSRange(location: 4, length: 2)) == "] "
}
/// True when `line` is a plain bullet (`- `, `* `, `+ `) but NOT a checklist.
func isBulletLine(_ line: String) -> Bool {
guard line.count >= 2 else { return false }
let start = String(line.prefix(2))
return (start == "- " || start == "* " || start == "+ ") && !isChecklistLine(line)
}
/// Strips any leading list marker (checklist, bullet, numbered) from `line`,
/// leaving just the content. Returns `line` unchanged if none is detected.
///
/// Used by all three list commands to implement "replace" semantics: applying
/// any list type first strips the current marker so lists replace each other
/// instead of nesting (e.g. `- [ ] task` Cmd+B `- task`, not `- - [ ] task`).
func stripListPrefix(_ line: String) -> String {
if isChecklistLine(line) { return String(line.dropFirst(6)) }
if isBulletLine(line) { return String(line.dropFirst(2)) }
if leadingListNumber(line) != nil { return stripLeadingNumber(line) }
return line
}
// MARK: - Link detection
/// The range of the `[text](url)` link that contains the caret, or nil.
/// Handles carets in both the `[text]` and `(url)` parts.
func linkRangeAroundCaret() -> NSRange? {
let ns = rawSource as NSString
let caret = selectedRange().location
guard ns.length > 0, caret <= ns.length else { return nil }
// Path A: caret is in [text]. Scan backward for '[', bail on ']'/newline.
var i = caret
while i > 0 {
i -= 1
let c = ns.character(at: i)
if c == 0x5B { break }
if c == 0x5D || c == 0x0A { i = -1; break }
}
if i >= 0, i < ns.length, ns.character(at: i) == 0x5B {
if let r = linkRange(ns: ns, from: i, mustContain: caret) { return r }
}
// Path B: caret is in (url). Scan backward for '(', then locate '[' before ']'.
var p = caret
while p > 0 {
p -= 1
let c = ns.character(at: p)
if c == 0x28 { break } // '('
if c == 0x0A { p = -1; break }
}
if p >= 0, ns.character(at: p) == 0x28,
p > 0, ns.character(at: p - 1) == 0x5D { // '(' preceded by ']'
var q = p - 2
while q >= 0 {
let c = ns.character(at: q)
if c == 0x5B { break }
if c == 0x5D || c == 0x0A { q = -1; break }
q -= 1
}
if q >= 0, ns.character(at: q) == 0x5B {
if let r = linkRange(ns: ns, from: q, mustContain: caret) { return r }
}
}
return nil
}
private func linkRange(ns: NSString, from openBracket: Int, mustContain caret: Int) -> NSRange? {
var j = openBracket + 1
while j < ns.length, ns.character(at: j) != 0x5D, ns.character(at: j) != 0x0A { j += 1 }
guard j < ns.length, ns.character(at: j) == 0x5D else { return nil }
guard j + 1 < ns.length, ns.character(at: j + 1) == 0x28 else { return nil }
var k = j + 2
while k < ns.length, ns.character(at: k) != 0x29, ns.character(at: k) != 0x0A { k += 1 }
guard k < ns.length, ns.character(at: k) == 0x29 else { return nil }
let r = NSRange(location: openBracket, length: k - openBracket + 1)
return (caret >= r.location && caret <= r.upperBound) ? r : nil
}
/// Returns the link text when `s` is exactly `[text](dest)`, else nil.
func unwrapLink(_ s: String) -> String? {
captureFirst(s, pattern: #"^\[([^\]]*)\]\([^)]*\)$"#)
}
/// Returns the alt text when `s` is exactly `![alt](dest)`, else nil.
func unwrapImage(_ s: String) -> String? {
captureFirst(s, pattern: #"^!\[([^\]]*)\]\([^)]*\)$"#)
}
private func captureFirst(_ s: String, pattern: String) -> String? {
guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
let ns = s as NSString
guard let m = re.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)),
m.numberOfRanges > 1 else { return nil }
return ns.substring(with: m.range(at: 1))
}
}
@@ -0,0 +1,224 @@
import AppKit
// MARK: - Tab / Shift-Tab List Indentation
//
// Tab / Shift-Tab change the nesting of list items by adding or removing one
// indent unit of leading whitespace. They apply to every list block the
// selection touches (so a whole sub-list can be indented at once) and only kick
// in on list lines elsewhere Tab inserts a literal tab as usual.
extension EditorTextView {
private static let listLineRegex = try! NSRegularExpression(pattern: #"^\s*(?:[-*+]|\d+\.)\s"#)
static let indentUnit = " " // 2 spaces
/// Returns true if the line looks like a markdown list item
/// (optionally indented): `- `, `* `, `+ `, `1. `, etc.
func isListLine(_ line: String) -> Bool {
let range = NSRange(location: 0, length: (line as NSString).length)
return Self.listLineRegex.firstMatch(in: line, range: range) != nil
}
// MARK: - Key Overrides
public override func insertTab(_ sender: Any?) {
guard let (startBlock, endBlock) = affectedListBlockRange() else {
super.insertTab(sender)
return
}
indentListBlocks(from: startBlock, to: endBlock)
}
public override func insertBacktab(_ sender: Any?) {
guard let (startBlock, endBlock) = affectedListBlockRange() else {
return
}
dedentListBlocks(from: startBlock, to: endBlock)
}
// MARK: - Block Range Detection
/// Returns the inclusive range of block indices covered by the current
/// selection, but only if every covered block is a list line.
private func affectedListBlockRange() -> (Int, Int)? {
let sel = selectedRange()
let rawStart = sel.location
let rawEnd = sel.location + sel.length
guard let startIdx = blockIndexForRawOffset(rawStart),
var endIdx = blockIndexForRawOffset(rawEnd) else {
return nil
}
// If the selection end lands exactly on the first character of a
// block, that block isn't meaningfully selected exclude it.
if sel.length > 0 && endIdx > startIdx && endIdx < blocks.count
&& rawEnd == blocks[endIdx].range.location {
endIdx -= 1
}
for i in startIdx...endIdx {
guard i < blocks.count, isListLine(blocks[i].content) else {
return nil
}
}
return (startIdx, endIdx)
}
// MARK: - Indent (Tab)
private func indentListBlocks(from startBlock: Int, to endBlock: Int) {
let sel = selectedRange()
let rawStart = sel.location
let rawEnd = sel.location + sel.length
let indentLen = (Self.indentUnit as NSString).length
// The pre-edit storage span covering exactly the affected blocks; only
// this is replaced so layout above/below and the viewport is kept.
let oldRange = NSRange(
location: blocks[startBlock].range.location,
length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location)
// Record undo
undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: rawStart))
redoStack.removeAll()
lastEditType = .other
lastEditBlockIndex = nil
// Build new rawSource
var parts: [String] = []
for (i, block) in blocks.enumerated() {
if i >= startBlock && i <= endBlock {
parts.append(Self.indentUnit + block.content)
} else {
parts.append(block.content)
}
}
let newText = parts[startBlock...endBlock].joined(separator: blockSeparator)
let oldIndentUnit = listIndentUnit
rawSource = parts.joined(separator: blockSeparator)
rebuildListIndentState()
rebuildLinkDefState()
// Cursor in startBlock shifts by 1 indent; rawEnd in endBlock
// shifts by (endBlock - startBlock + 1) indents (one per block).
let newRawStart = rawStart + indentLen
let newRawEnd = rawEnd + indentLen * (endBlock - startBlock + 1)
blocks = BlockParser.parse(rawSource, previous: blocks)
let selInRaw = sel.length > 0
? NSRange(location: newRawStart, length: newRawEnd - newRawStart) : nil
stabilizingViewport {
recomposeReplacing(oldRange: oldRange, with: newText,
dirty: indentDirtySet(startBlock, endBlock,
unitChanged: listIndentUnit != oldIndentUnit),
cursorInRaw: newRawStart, selectionInRaw: selInRaw)
}
// The indented blocks changed depth: they may now belong to a
// different ordered run (or start a new one), and the old depth's
// remaining siblings lost a member both need renumbering.
renumberOrderedListRunsIfNeeded(touching: startBlock..<(endBlock + 1),
depthChanged: Set(startBlock...endBlock))
document?.updateChangeCount(.changeDone)
}
/// Blocks to restyle for an indent/dedent: the directly-edited span, plus
/// when the document-global list indent unit moved every list block,
/// whose rendered indentation is derived from that unit.
private func indentDirtySet(_ startBlock: Int, _ endBlock: Int,
unitChanged: Bool) -> IndexSet {
var dirty = IndexSet(integersIn: startBlock...min(endBlock, blocks.count - 1))
if unitChanged {
for (i, block) in blocks.enumerated() where block.kind == .listItem {
dirty.insert(i)
}
}
return dirty
}
// MARK: - Dedent (Shift-Tab)
private func dedentListBlocks(from startBlock: Int, to endBlock: Int) {
let sel = selectedRange()
let rawStart = sel.location
let rawEnd = sel.location + sel.length
let maxRemove = Self.indentUnit.count
// Compute how many leading whitespace characters to strip from each block.
var removed: [Int] = Array(repeating: 0, count: blocks.count)
for i in startBlock...endBlock {
let content = blocks[i].content
if content.hasPrefix("\t") {
removed[i] = 1
} else {
let leading = content.prefix(while: { $0 == " " }).count
removed[i] = min(leading, maxRemove)
}
}
let totalRemoved = removed[startBlock...endBlock].reduce(0, +)
guard totalRemoved > 0 else { return }
// The pre-edit storage span covering exactly the affected blocks; only
// this is replaced so layout above/below and the viewport is kept.
let oldRange = NSRange(
location: blocks[startBlock].range.location,
length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location)
// Record undo
undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: rawStart))
redoStack.removeAll()
lastEditType = .other
lastEditBlockIndex = nil
// Build new rawSource
var parts: [String] = []
for (i, block) in blocks.enumerated() {
if i >= startBlock && i <= endBlock {
parts.append(String(block.content.dropFirst(removed[i])))
} else {
parts.append(block.content)
}
}
let newText = parts[startBlock...endBlock].joined(separator: blockSeparator)
let oldIndentUnit = listIndentUnit
rawSource = parts.joined(separator: blockSeparator)
rebuildListIndentState()
rebuildLinkDefState()
// Adjust rawStart (in startBlock). No blocks before startBlock
// were modified, so its start position is unchanged.
let startOff = rawStart - blocks[startBlock].range.location
let newRawStart = blocks[startBlock].range.location
+ max(0, startOff - removed[startBlock])
// Adjust rawEnd (in endBlock). Every indented block before
// endBlock shifted its start position left.
var cumulativeBefore = 0
for i in startBlock..<endBlock {
cumulativeBefore += removed[i]
}
let endBlockNewStart = blocks[endBlock].range.location - cumulativeBefore
let endOff = rawEnd - blocks[endBlock].range.location
let newRawEnd = endBlockNewStart + max(0, endOff - removed[endBlock])
blocks = BlockParser.parse(rawSource, previous: blocks)
let selInRaw = sel.length > 0
? NSRange(location: newRawStart, length: max(0, newRawEnd - newRawStart)) : nil
stabilizingViewport {
recomposeReplacing(oldRange: oldRange, with: newText,
dirty: indentDirtySet(startBlock, endBlock,
unitChanged: listIndentUnit != oldIndentUnit),
cursorInRaw: newRawStart, selectionInRaw: selInRaw)
}
// The dedented blocks changed depth: they may now belong to a
// different ordered run (or merge into an existing one), and the
// old depth's remaining siblings lost a member both need renumbering.
renumberOrderedListRunsIfNeeded(touching: startBlock..<(endBlock + 1),
depthChanged: Set(startBlock...endBlock))
document?.updateChangeCount(.changeDone)
}
}
@@ -0,0 +1,102 @@
import AppKit
// MARK: - List Continuation on Enter
//
// Pressing Return inside a list item starts the next item automatically: it
// repeats the same indent and a fresh marker (the next number for ordered
// lists, an empty checkbox for task lists, the same bullet otherwise). Pressing
// Return on an *empty* item instead removes the marker and breaks out of the
// list, matching the behavior of most note editors.
extension EditorTextView {
/// Regex that captures a list marker prefix:
/// Group 1 = leading whitespace, Group 2 = marker (e.g. "- ", "* ", "1. ", "- [ ] ", "- [x] ")
private static let listMarkerRegex = try! NSRegularExpression(
pattern: #"^(\s*)([-*+]\s+(?:\[[ xX]\]\s+)?|\d+\.\s+)"#
)
/// If the cursor is on a list line, returns (leadingWhitespace, marker, hasContent).
/// `marker` is the bullet/number portion (e.g. "- ", "1. ", "- [ ] ").
private func parseListMarker(_ line: String) -> (indent: String, marker: String, hasContent: Bool)? {
let ns = line as NSString
let range = NSRange(location: 0, length: ns.length)
guard let match = Self.listMarkerRegex.firstMatch(in: line, range: range) else {
return nil
}
let indent = ns.substring(with: match.range(at: 1))
let marker = ns.substring(with: match.range(at: 2))
let prefixLen = match.range.length
let hasContent = prefixLen < ns.length
return (indent, marker, hasContent)
}
/// Builds the next marker for list continuation.
/// - Ordered lists: increments the number (e.g. "1. " "2. ")
/// - Checkbox items: resets to unchecked (e.g. "- [x] " "- [ ] ")
/// - Plain bullets: returns the same marker
private func nextMarker(for marker: String) -> String {
// Ordered: "1. " "2. "
if let dotRange = marker.range(of: #"^\d+\."#, options: .regularExpression) {
let numStr = String(marker[dotRange].dropLast()) // drop the "."
if let num = Int(numStr) {
return "\(num + 1)." + String(marker[dotRange.upperBound...])
}
}
// Checkbox: replace [x] with [ ]
if let cbRange = marker.range(of: "[x]", options: .caseInsensitive) {
var next = marker
next.replaceSubrange(cbRange, with: "[ ]")
return next
}
return marker
}
// MARK: - Override
public override func insertNewline(_ sender: Any?) {
let sel = selectedRange()
guard sel.length == 0 else {
super.insertNewline(sender)
return
}
if handleListNewline(sel) { return }
if handleBlockquoteNewline(at: sel.location) { return }
super.insertNewline(sender)
}
/// List continuation. Returns true if it handled the newline.
private func handleListNewline(_ sel: NSRange) -> Bool {
guard let blockIdx = blockIndexForRawOffset(sel.location),
blockIdx < blocks.count else { return false }
let block = blocks[blockIdx]
guard let (indent, marker, hasContent) = parseListMarker(block.content) else {
return false
}
if hasContent {
// Content present insert newline + next marker.
// If splitting mid-line and the next char is a space, consume it
// so we don't get a double space after the marker.
let next = indent + nextMarker(for: marker)
var replaceRange = sel
let nsRaw = rawSource as NSString
if sel.location < nsRaw.length && nsRaw.character(at: sel.location) == 0x20 {
replaceRange.length += 1
}
insertText("\n" + next, replacementRange: replaceRange)
} else if !indent.isEmpty {
// Indented empty list line un-indent one level
let maxRemove = Self.indentUnit.count
let leading = indent.prefix(while: { $0 == " " }).count
let remove = indent.hasPrefix("\t") ? 1 : min(leading, maxRemove)
let dedented = String(block.content.dropFirst(remove))
insertText(dedented, replacementRange: block.range)
} else {
// Root-level empty list line remove the marker entirely
insertText("", replacementRange: block.range)
}
return true
}
}
@@ -0,0 +1,177 @@
import AppKit
// MARK: - Ordered List Renumbering
//
// Keeps a contiguous run of ordered ("1. ", "1) ") list items sequential
// after an edit changes its item count (insert mid-list, delete an item,
// paste). Scoped to the nesting depth the edit touched sibling depths and
// unrelated lists elsewhere in the document are never rewritten. Called once
// per edit from `syncRawSourceFromDisplay` (EditorTextView+EditFlow.swift),
// after `blocks` has been reparsed to the post-edit state.
extension EditorTextView {
private static let orderedMarkerRegex = try! NSRegularExpression(
pattern: #"^([ \t]*)(\d+)([.)])[ ]"#
)
/// Leading indent, number, delimiter, and the digits' block-local NSRange
/// when `content` is an ordered list item line; nil otherwise.
private func orderedMarker(_ content: String) -> (indent: String, number: Int, digits: NSRange, delim: Character)? {
let ns = content as NSString
guard let m = Self.orderedMarkerRegex.firstMatch(in: content, range: NSRange(location: 0, length: ns.length)) else {
return nil
}
let indent = ns.substring(with: m.range(at: 1))
guard let number = Int(ns.substring(with: m.range(at: 2))) else { return nil }
let delim = ns.substring(with: m.range(at: 3)).first ?? "."
return (indent, number, m.range(at: 2), delim)
}
/// Reuses the render-time indentdepth mapping so renumbering agrees with
/// what's actually drawn.
private func depthOf(_ block: Block) -> Int {
let indent = block.content.prefix(while: { $0 == " " || $0 == "\t" })
return listDepth(leadingWhitespace: String(indent))
}
/// Entry point: renumbers every distinct contiguous ordered run touched
/// by the edit including two disjoint runs at the same depth (e.g. an
/// edit that splits one list into two, or an indent/dedent that moves
/// items between an old list and a new/existing one at a different
/// depth). `touchedBlocks`' block count is assumed unchanged by any
/// rewrite this makes (only digit widths inside existing lines change),
/// so indices computed up front stay valid across the whole call.
///
/// `depthChanged` block indices whose nesting depth this specific edit
/// just altered (Tab/Shift-Tab; empty for every other caller, which never
/// change depth) lets a run tell "merging into an existing list" apart
/// from "forming a brand-new one": a run made up entirely of just-moved
/// items starts at 1 instead of inheriting whatever number its first
/// member happened to have at its old depth.
func renumberOrderedListRunsIfNeeded(touching touchedBlocks: Range<Int>, depthChanged: Set<Int> = []) {
guard !blocks.isEmpty else { return }
let lo = max(0, touchedBlocks.lowerBound - 1)
let hi = min(blocks.count, touchedBlocks.upperBound + 1)
guard lo < hi else { return }
var processed = IndexSet()
for idx in lo..<hi {
guard !processed.contains(idx),
blocks[idx].kind == .listItem,
orderedMarker(blocks[idx].content) != nil else { continue }
let depth = depthOf(blocks[idx])
let bounds = orderedRunBounds(seedIndex: idx, depth: depth)
// Only the same-depth ordered members actually belong to this
// run's numbering `bounds` also spans deeper nested children
// and tolerated blank-line separators purely as pass-through
// text, and marking those processed here would wrongly suppress
// their own, separate depth's renumbering pass later in this loop.
let sequence = bounds.filter {
depthOf(blocks[$0]) == depth && orderedMarker(blocks[$0].content) != nil
}
for seqIdx in sequence { processed.insert(seqIdx) }
renumberOrderedListRun(bounds: bounds, sequence: sequence, depthChanged: depthChanged)
}
}
/// Walks outward from `seedIndex` to the bounds of the contiguous
/// same-depth ordered run: stops (exclusive) at a non-listItem block, a
/// shallower list item, a same-depth list item that isn't ordered (a
/// marker-type change starts a new list), or two consecutive blanks. A
/// single blank line is tolerated (CommonMark's "loose list" one blank
/// line doesn't end a list) as long as the run continues past it; a
/// deeper list item is included in the span but not the numbering.
private func orderedRunBounds(seedIndex: Int, depth: Int) -> ClosedRange<Int> {
func sameOrDeeper(_ idx: Int) -> Bool {
guard blocks[idx].kind == .listItem else { return false }
let d = depthOf(blocks[idx])
if d < depth { return false }
if d == depth { return orderedMarker(blocks[idx].content) != nil }
return true // deeper: nested child, part of the span
}
var lo = seedIndex
while lo > 0 {
if sameOrDeeper(lo - 1) { lo -= 1; continue }
if blocks[lo - 1].kind == .blank, lo >= 2, sameOrDeeper(lo - 2) { lo -= 2; continue }
break
}
var hi = seedIndex
while hi < blocks.count - 1 {
if sameOrDeeper(hi + 1) { hi += 1; continue }
if blocks[hi + 1].kind == .blank, hi + 2 < blocks.count, sameOrDeeper(hi + 2) { hi += 2; continue }
break
}
return lo...hi
}
/// Renumbers the contiguous ordered run spanning `bounds`; `sequence`
/// precomputed by the caller is the same-depth ordered subset of
/// `bounds` that actually gets numbered. No-op (no storage mutation)
/// when the run is already sequential.
///
/// Start number: when at least one sequence member predates this edit
/// (isn't in `depthChanged`), this run is continuing/merging into
/// something that already existed, so it preserves whatever number its
/// first member already had same as the plain-edit case, where
/// `depthChanged` is always empty. Only when EVERY member just arrived
/// together (a brand-new run, nothing pre-existing to continue) does it
/// start at 1 instead of inheriting a number from wherever its first
/// member used to live.
private func renumberOrderedListRun(bounds: ClosedRange<Int>, sequence: [Int], depthChanged: Set<Int>) {
guard let first = sequence.first else { return }
let isBrandNew = sequence.allSatisfy { depthChanged.contains($0) }
let start: Int
if isBrandNew {
start = 1
} else if let firstNumber = orderedMarker(blocks[first].content)?.number {
start = firstNumber
} else {
return
}
var rewrites: [(idx: Int, digits: NSRange, newNumber: String)] = []
for (i, idx) in sequence.enumerated() {
guard let marker = orderedMarker(blocks[idx].content) else { continue }
let expected = start + i
if marker.number != expected {
rewrites.append((idx, marker.digits, String(expected)))
}
}
guard !rewrites.isEmpty else { return }
let oldSpan = NSRange(
location: blocks[bounds.lowerBound].range.location,
length: blocks[bounds.upperBound].range.upperBound - blocks[bounds.lowerBound].range.location)
let rewriteByIndex = Dictionary(uniqueKeysWithValues: rewrites.map { ($0.idx, $0) })
var netDelta = 0
let newText = bounds.map { idx -> String in
guard let r = rewriteByIndex[idx] else { return blocks[idx].content }
let content = blocks[idx].content as NSString
let newLine = content.replacingCharacters(in: r.digits, with: r.newNumber)
return newLine
}.joined(separator: "\n")
let caretBefore = selectedRange().location
for idx in bounds {
guard let r = rewriteByIndex[idx] else { continue }
let digitsStart = blocks[idx].range.location + r.digits.location
if digitsStart < caretBefore {
netDelta += (r.newNumber as NSString).length - r.digits.length
}
}
let caretAfter = max(0, caretBefore + netDelta)
rawSource = (rawSource as NSString).replacingCharacters(in: oldSpan, with: newText)
blocks = BlockParser.parse(rawSource, previous: blocks)
// `recomposeReplacing` wipes the whole replaced span to base
// attributes before restyling only the dirty blocks every block in
// `bounds` sits inside that span, not just the ones whose digits
// changed, so all of them must be marked dirty or the untouched ones
// are left showing unstyled (undimmed) markers.
let dirty = IndexSet(bounds)
recomposeReplacing(oldRange: oldSpan, with: newText, dirty: dirty, cursorInRaw: caretAfter)
}
}
@@ -0,0 +1,252 @@
import AppKit
import SwiftMath
// MARK: - DocumentHTML
//
// Assembles the full, self-contained HTML document for Read mode and PDF export:
// the `HTMLRenderer` body, the `HTMLTheme` stylesheet, and a second pass that
// fills the renderer's placeholder elements with inlined assets (SwiftMath
// glyphs and local images) as data URIs. Callout/checkbox icons are inline
// Lucide SVGs emitted by `HTMLRenderer` (no asset pass needed). Inlining keeps
// the document self-contained the webview needs no file/network access.
// Raw HTML in the markdown passes through per GFM, filtered by
// `HTMLRenderer.filterRawHTML` (tagfilter + hardening); the page also carries a
// `script-src 'none'` CSP meta as defense-in-depth (§G, ARCHITECTURE §10).
@MainActor
enum DocumentHTML {
/// Builds a complete `<!DOCTYPE html>` document for `markdown`. `baseURL` is
/// the document's directory, used to resolve relative image paths for inlining.
static func full(markdown: String,
theme: EditorTheme,
callouts: [String: CalloutStyle],
dark: Bool,
baseURL: URL? = nil,
options: ReadRenderOptions = .default) -> String {
var body = HTMLRenderer.render(markdown: markdown, options: options)
body = fillMath(body, theme: theme, dark: dark)
body = fillImages(body, baseURL: baseURL, options: options)
let css = HTMLTheme.css(theme, callouts: callouts, dark: dark,
maxContentWidthPoints: options.maxContentWidthPoints)
return """
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="script-src 'none'">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
\(css)
</style></head>
<body><div class="page">\(body)</div></body></html>
"""
}
// MARK: Math (SwiftMath PNG data URI)
private static let inlineMathPattern = "<span class=\"math-inline\" data-tex=\"([^\"]*)\"></span>"
private static let displayMathPattern = "<div class=\"math-display\" data-tex=\"([^\"]*)\"></div>"
private static func fillMath(_ html: String, theme: EditorTheme, dark: Bool) -> String {
let color = NSColor(hex: dark ? "#e6e6e6" : "#1a1a1a") ?? .textColor
var out = replaceMatches(html, pattern: displayMathPattern) { groups in
let tex = unescapeAttr(groups[1])
guard let r = mathImage(latex: tex, display: true,
fontSize: theme.fontSize, color: color),
let data = pngData(r.image, scale: 2) else {
return "<div class=\"math-display\"><code>\(HTMLRenderer.escape(tex))</code></div>"
}
let uri = "data:image/png;base64,\(data.base64EncodedString())"
return "<div class=\"math-display\"><img class=\"math\" style=\"height:\(fmt(r.image.size.height))px\" src=\"\(uri)\" alt=\"\(HTMLRenderer.attr(tex))\"></div>"
}
out = replaceMatches(out, pattern: inlineMathPattern) { groups in
let tex = unescapeAttr(groups[1])
guard let r = mathImage(latex: tex, display: false,
fontSize: theme.fontSize, color: color),
let data = pngData(r.image, scale: 2) else {
return "<code>\(HTMLRenderer.escape(tex))</code>"
}
let uri = "data:image/png;base64,\(data.base64EncodedString())"
// Drop the image so its baseline (descent above its bottom) lands on
// the text baseline same alignment the editor computes.
return "<img class=\"math math-inline\" style=\"height:\(fmt(r.image.size.height))px; vertical-align:\(fmt(-r.descent))px\" src=\"\(uri)\" alt=\"\(HTMLRenderer.attr(tex))\">"
}
return out
}
/// Renders LaTeX with SwiftMath to an image + baseline descent. Standalone
/// (no `EditorTextView`) mirror of `EditorTextView.mathOverlay`'s core.
private static func mathImage(latex: String, display: Bool,
fontSize: CGFloat, color: NSColor)
-> (image: NSImage, descent: CGFloat)? {
let mode: MTMathUILabelMode = display ? .display : .text
let math = MTMathImage(latex: latex, fontSize: fontSize, textColor: color, labelMode: mode)
let insetPad: CGFloat = 2
math.contentInsets = MTEdgeInsets(top: insetPad, left: 0, bottom: insetPad, right: 0)
let (error, image) = math.asImage()
guard error == nil, let image else { return nil }
let label = MTMathUILabel()
label.latex = latex
label.fontSize = fontSize
label.labelMode = mode
label.layout()
let asc = label.displayList?.ascent ?? 0
let desc = label.displayList?.descent ?? 0
let clamped = max(asc + desc, fontSize / 2)
let descent = (asc + desc - clamped) / 2 + desc + insetPad
return (image, descent)
}
// MARK: Images (local inlined data URI; remote off by default)
// Groups 3/4 are optional declared dimensions from an HTML `<img>` tag
// (captured with their leading space so they re-emit verbatim).
private static let imagePattern =
"<img class=\"md-image\" data-src=\"([^\"]*)\" alt=\"([^\"]*)\"( width=\"[0-9]+\")?( height=\"[0-9]+\")?>"
/// Resolves each `md-image` placeholder: local/relative paths are read and
/// inlined as a data URI (self-contained, no file access needed at render
/// time); a `data:` source passes through; remote `https` sources load only
/// when `options.allowRemoteImages` is set. Anything that can't be shown
/// gets a visible icon + reason (`ImageLoadFailure`, shared with Edit
/// mode's inline preview) instead of silently showing nothing.
private static func fillImages(_ html: String, baseURL: URL?,
options: ReadRenderOptions) -> String {
var cache: [String: String] = [:] // resolved path data URI
return replaceMatches(html, pattern: imagePattern) { groups in
let src = unescapeAttr(groups[1])
let alt = groups[2] // already attribute-escaped by the renderer
let dims = groups[3] + groups[4] // optional ` width="N" height="N"`
if src.isEmpty { return blockedImagePlaceholder(reason:.notFound) }
let lower = src.lowercased()
if lower.hasPrefix("data:") {
return "<img class=\"md-image\" src=\"\(HTMLRenderer.attr(src))\" alt=\"\(alt)\"\(dims)>"
}
if lower.hasPrefix("http://") {
return blockedImagePlaceholder(reason:.httpUnsupported)
}
if lower.hasPrefix("https://") {
guard options.allowRemoteImages else {
return blockedImagePlaceholder(reason:.blockedBySetting)
}
return "<img class=\"md-image\" src=\"\(HTMLRenderer.attr(src))\" alt=\"\(alt)\"\(dims)>"
}
// Local: resolve against the document directory, read, inline.
guard let fileURL = resolveLocalImage(src, baseURL: baseURL) else {
return blockedImagePlaceholder(reason:.notFound)
}
if let cached = cache[fileURL.path] {
return "<img class=\"md-image\" src=\"\(cached)\" alt=\"\(alt)\"\(dims)>"
}
// `resolveLocalImage`'s absolute/`~` branches don't check existence
// (only the relative-path branch does), so a missing file and an
// undecodable one would otherwise fail `imageDataURI` identically
// check existence first so the two get distinct, accurate messages.
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return blockedImagePlaceholder(reason:.notFound)
}
guard let uri = imageDataURI(fileURL) else {
return blockedImagePlaceholder(reason:.notAnImage)
}
cache[fileURL.path] = uri
return "<img class=\"md-image\" src=\"\(uri)\" alt=\"\(alt)\"\(dims)>"
}
}
/// A visible stand-in for an image that can't be shown: an icon plus a
/// short reason, instead of just empty space.
private static func blockedImagePlaceholder(reason: ImageLoadFailure) -> String {
let icon = LucideIcons.inlineSVG("image-off") ?? ""
return "<span class=\"md-image-blocked\">\(icon)<span>\(reason.label)</span></span>"
}
/// Resolves a local image `path` to a file URL: absolute / `~` / `file:`
/// load directly; a relative path resolves against the document's directory.
private static func resolveLocalImage(_ path: String, baseURL: URL?) -> URL? {
if let url = URL(string: path), url.scheme == "file" { return url }
// A markdown image destination may be percent-encoded (e.g. `%20`).
let decoded = path.removingPercentEncoding ?? path
if decoded.hasPrefix("/") { return URL(fileURLWithPath: decoded) }
if decoded.hasPrefix("~") { return URL(fileURLWithPath: (decoded as NSString).expandingTildeInPath) }
guard let baseURL else { return nil }
let resolved = baseURL.appendingPathComponent(decoded)
return FileManager.default.fileExists(atPath: resolved.path) ? resolved : nil
}
/// Reads an image file and returns a `data:` URI, with the MIME type guessed
/// from the file extension (covers the common web image formats). Decodes
/// the bytes first (discarding the result) so a file that merely has an
/// image extension but isn't actually image data is caught here as
/// "Not an image" rather than silently inlining garbage the browser
/// then fails to render with no explanation.
private static func imageDataURI(_ url: URL) -> String? {
guard let data = try? Data(contentsOf: url), NSImage(data: data) != nil else { return nil }
let mime: String
switch url.pathExtension.lowercased() {
case "png": mime = "image/png"
case "jpg", "jpeg": mime = "image/jpeg"
case "gif": mime = "image/gif"
case "svg": mime = "image/svg+xml"
case "webp": mime = "image/webp"
case "bmp": mime = "image/bmp"
case "tiff", "tif": mime = "image/tiff"
default: mime = "application/octet-stream"
}
return "data:\(mime);base64,\(data.base64EncodedString())"
}
// MARK: Bitmap / escaping helpers
/// Rasterizes an `NSImage` to PNG `Data` at `scale`× its point size.
private static func pngData(_ image: NSImage, scale: CGFloat) -> Data? {
let size = image.size
guard size.width > 0, size.height > 0,
let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int((size.width * scale).rounded()),
pixelsHigh: Int((size.height * scale).rounded()),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { return nil }
rep.size = size
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
image.draw(in: NSRect(origin: .zero, size: size))
NSGraphicsContext.restoreGraphicsState()
return rep.representation(using: .png, properties: [:])
}
/// Reverses the HTML-attribute escaping done by `HTMLRenderer.attr` so the
/// raw LaTeX/symbol can be recovered from a placeholder attribute.
private static func unescapeAttr(_ s: String) -> String {
s.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
.replacingOccurrences(of: "&amp;", with: "&") // last, by convention
}
/// Finds every match of `pattern` and replaces it with `transform(groups)`,
/// where `groups[0]` is the whole match. Replaces back-to-front so ranges
/// stay valid.
private static func replaceMatches(_ html: String, pattern: String,
_ transform: ([String]) -> String) -> String {
guard let regex = try? NSRegularExpression(
pattern: pattern, options: [.dotMatchesLineSeparators]) else { return html }
let ns = html as NSString
let result = NSMutableString(string: html)
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
for m in matches.reversed() {
var groups: [String] = []
for i in 0..<m.numberOfRanges {
let r = m.range(at: i)
groups.append(r.location == NSNotFound ? "" : ns.substring(with: r))
}
result.replaceCharacters(in: m.range(at: 0), with: transform(groups))
}
return result as String
}
private static func fmt(_ v: CGFloat) -> String {
v == v.rounded() ? String(Int(v)) : String(format: "%.1f", v)
}
}
@@ -0,0 +1,768 @@
import Foundation
import Markdown
// MARK: - HTMLRenderer
//
// Renders the *same* swift-markdown `Document` the editor parses into an HTML
// body string. This is the Read-mode / Print-export counterpart to
// `SpanCollector` (which produces editor attribute spans): one parser, one set
// of element semantics, two back-ends. It mirrors SpanCollector's element
// coverage so Read mode shows exactly what Edit mode highlights.
//
// The renderer is intentionally **pure** AST string, no AppKit. Assets that
// need AppKit (callout icons, math glyphs) are emitted as placeholder elements
// that `DocumentHTML` fills in a second pass, so this type stays unit-testable
// with plain string assertions.
//
// Non-GFM inline constructs (==highlight==, $math$, [[wikilink]], %%comment%%)
// are detected by reusing the exact regex passes in
// `SyntaxHighlighter+CustomParsers` no second source of truth.
struct HTMLRenderer: MarkupVisitor {
typealias Result = String
/// Private URL scheme for `[[wikilink]]` hrefs. The read view's navigation
/// policy intercepts this scheme and routes the (percent-decoded) target
/// through the app's document graph instead of navigating the webview.
static let wikiScheme = "x-edmund-wiki"
/// Private URL scheme for relative/internal regular markdown links
/// (`[text](other.md)`). Routed like wikilinks; external links (http/https/
/// mailto) and in-page `#fragment` anchors keep their real hrefs.
static let linkScheme = "x-edmund-link"
/// The markdown this instance is rendering. Held so block-level constructs
/// (callouts) can recover their *raw* source text by range, the way the
/// editor's styling layer does.
private let source: String
private let sourceLines: [String]
private let options: ReadRenderOptions
/// Footnote definitions collected while walking the document (see
/// `visitParagraph`), rendered as a section at the bottom of the page
/// instead of in place. Order is document order of the *definitions*.
private var footnotes: [(id: String, bodyHTML: String)] = []
/// Tightness of each list currently being walked (stack: nested lists).
/// See `isTight(_:)`.
private var listIsTight: [Bool] = []
private init(source: String, options: ReadRenderOptions) {
self.source = source
self.sourceLines = source.components(separatedBy: "\n")
self.options = options
}
/// Parses `markdown` and returns the rendered HTML body (no `<html>`/`<head>`
/// wrapper `DocumentHTML` adds that).
static func render(markdown: String, options: ReadRenderOptions = .default) -> String {
var r = HTMLRenderer(source: markdown, options: options)
let doc = Document(parsing: markdown, options: [.disableSmartOpts])
let body = r.visit(doc)
return body + r.renderFootnotesSection()
}
/// `[^id]: body` definitions render at the bottom of the page as a `<hr>` +
/// ordered list, each entry linking back to its in-text reference the
/// Obsidian-style footnote layout (see misc/backlog.md's Markdown Footnotes
/// entry). Not rendered at all if the document had no footnote definitions.
private func renderFootnotesSection() -> String {
guard !footnotes.isEmpty else { return "" }
var out = "<hr class=\"footnotes-sep\"><ol class=\"footnotes\">"
for (id, bodyHTML) in footnotes {
let safeID = Self.attr(id)
out += "<li id=\"fn-\(safeID)\">\(bodyHTML) " +
"<a href=\"#fnref-\(safeID)\" class=\"footnote-backref\">↩</a></li>"
}
out += "</ol>"
return out
}
/// Top-level block iteration. When `preserveBlankLines` is on, a *run* of
/// blank source lines between two blocks emits one `.blank-line` spacer for
/// every blank line beyond the first i.e. standard Markdown keeps a single
/// blank line as the normal block separator and only renders the 2nd, 3rd,
/// blank lines as extra vertical space.
///
/// REFERENCE (future "rigorous" Read mode): to mimic Edit mode's layout
/// exactly, emit a spacer for EVERY blank line (`spacers = blanks`, not
/// `blanks - 1`). That preserves the author's spacing literally but fights
/// the HTML/CSS box model (blocks already carry their own margins), so it's
/// parked until Read mode commits to a styled-source rather than a rendered-
/// document model. See the discussion in the handoff notes.
///
/// QUIRK: a block's `range.upperBound.line` is NOT reliably its last content
/// line cmark folds trailing blank lines into some block ranges (lists in
/// particular), so a list followed by a blank line then a paragraph reports
/// the list ending on the blank line. We therefore clamp each block's end
/// back to its last non-blank source line; the blank run between blocks A and
/// B is then `B.firstLine - clamp(A.end) - 1`.
mutating func visitDocument(_ document: Document) -> String {
guard options.preserveBlankLines else { return renderChildren(of: document) }
var out = ""
var prevEndLine: Int?
for child in document.children {
if let prevEndLine, let range = child.range {
let blanks = range.lowerBound.line - prevEndLine - 1
if blanks > 1 {
out += String(repeating: "<div class=\"blank-line\"></div>", count: blanks - 1)
}
}
out += visit(child)
if let range = child.range {
prevEndLine = lastContentLine(atOrBefore: range.upperBound.line)
}
}
return out
}
/// The last source line at or before `line` (1-indexed) that has non-blank
/// content. Used to undo cmark folding trailing blank lines into a block.
private func lastContentLine(atOrBefore line: Int) -> Int {
var l = min(line, sourceLines.count)
while l >= 1, sourceLines[l - 1].trimmingCharacters(in: .whitespaces).isEmpty {
l -= 1
}
return l
}
// MARK: Default / children
mutating func defaultVisit(_ markup: Markup) -> String {
renderChildren(of: markup)
}
private mutating func renderChildren(of markup: Markup) -> String {
var out = ""
for child in markup.children { out += visit(child) }
return out
}
// MARK: Block-level
mutating func visitParagraph(_ paragraph: Paragraph) -> String {
// A paragraph that is wholly `$$$$` is a display-math block. Reuse the
// editor's detector so Read mode and Edit mode agree on what's math.
// Detect on the *raw* source, not `plainText`: swift-markdown has already
// applied Markdown backslash-unescaping to a Text node's `.string`
// (`\\``\`, `\$``$`), which corrupts LaTeX row separators and commands
// so a `\begin{aligned}\\\end{aligned}` block would be mangled. The
// editor's styling reads from raw source by range for the same reason.
let raw = sourceText(paragraph) ?? Self.plainText(of: paragraph)
var dm: [SyntaxHighlighter.Span] = []
SyntaxHighlighter.parseDisplayMath(raw, into: &dm)
if let span = dm.first(where: { if case .math(true) = $0.kind { return true }; return false }) {
let tex = (raw as NSString).substring(with: span.contentRange)
return "<div class=\"math-display\" data-tex=\"\(Self.attr(tex))\"></div>"
}
// A `[^id]: body` paragraph (the marker starts the paragraph's first
// Text child) is a footnote definition, not visible content collect it
// for the bottom-of-page footnotes section instead of rendering in place.
let children = Array(paragraph.children)
if let first = children.first as? Text,
let (id, markerLength) = Self.footnoteDefinitionMarker(in: first.string) {
var bodyHTML = Self.renderInline(String(first.string.dropFirst(markerLength)))
for child in children.dropFirst() { bodyHTML += visit(child) }
footnotes.append((id: id, bodyHTML: bodyHTML))
return ""
}
return "<p>\(renderChildren(of: paragraph))</p>"
}
mutating func visitHeading(_ heading: Heading) -> String {
let level = min(max(heading.level, 1), 6)
return "<h\(level)>\(renderChildren(of: heading))</h\(level)>"
}
mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String {
// Per-token syntax coloring reuses the editor's `CodeHighlighter`, so
// Edit mode and Read mode color the same tokens identically (the actual
// colors live in CSS, from the shared `CodeSyntaxPalette` via HTMLTheme).
let lang = codeBlock.language.map { " class=\"language-\(Self.attr($0))\"" } ?? ""
// QUIRK: U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are valid
// Unicode line-ending characters that appear in macOS-pasted text (e.g.
// from Notes or Safari). In HTML they are NOT newline characters inside
// a <pre> block they render as spaces or nothing, concatenating lines that
// should appear on separate rows. Normalize to plain U+000A before escaping.
let raw = codeBlock.code
.replacingOccurrences(of: "\u{2028}", with: "\n")
.replacingOccurrences(of: "\u{2029}", with: "\n")
// swift-markdown includes a trailing newline on the block's code.
let code = raw.hasSuffix("\n") ? String(raw.dropLast()) : raw
return "<pre><code\(lang)>\(Self.highlightCode(code, language: codeBlock.language))</code></pre>"
}
/// CSS class for a code token kind (consumed by `HTMLTheme`'s `.tok-*` rules).
private static func tokenClass(_ type: CodeHighlighter.TokenType) -> String {
switch type {
case .keyword: return "tok-keyword"
case .type: return "tok-type"
case .string: return "tok-string"
case .number: return "tok-number"
case .comment: return "tok-comment"
case .function: return "tok-function"
}
}
/// Escapes `code` and wraps each `CodeHighlighter` token in a colored
/// `<span class="tok-">`. Gaps between tokens stay plain (escaped) text and
/// inherit the plain `pre code` color, mirroring the editor's "plain first,
/// tokens paint over" model.
static func highlightCode(_ code: String, language: String?) -> String {
let tokens = CodeHighlighter.tokenize(code, language: language)
guard !tokens.isEmpty else { return escape(code) }
let ns = code as NSString
var out = ""
var cursor = 0
for token in tokens {
let r = token.range
guard r.location >= cursor, r.upperBound <= ns.length else { continue }
if r.location > cursor {
out += escape(ns.substring(with: NSRange(location: cursor, length: r.location - cursor)))
}
out += "<span class=\"\(tokenClass(token.type))\">\(escape(ns.substring(with: r)))</span>"
cursor = r.upperBound
}
if cursor < ns.length {
out += escape(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
}
return out
}
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> String { "<hr>" }
mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String {
// Detect a GFM callout (`> [!type] `) on the first line, the same way
// the editor does (Callout.parseMarker over the de-quoted first line).
if let inner = deQuoted(blockQuote) {
let firstLine = String(inner.prefix(while: { $0 != "\n" }))
if let marker = Callout.parseMarker(firstLine),
let style = Callout.style(for: marker.type) {
return renderCallout(marker: marker, style: style,
firstLine: firstLine, blockQuote: blockQuote)
}
}
return "<blockquote>\(renderChildren(of: blockQuote))</blockquote>"
}
/// GFM §5.3: a list is LOOSE iff any two adjacent blocks inside it between
/// items, or between blocks within one item are separated by a blank source
/// line. swift-markdown doesn't expose cmark's tight flag; recover it from
/// source-line gaps, clamping each block's end past cmark's folded trailing
/// blanks (same trick as visitDocument).
private func isTight(_ list: Markup) -> Bool {
var prevEnd: Int? = nil
for item in list.children {
guard let r = item.range else { continue }
if let p = prevEnd, r.lowerBound.line - p > 1 { return false }
var innerPrev: Int? = nil
for block in item.children {
guard let br = block.range else { continue }
if let ip = innerPrev, br.lowerBound.line - ip > 1 { return false }
innerPrev = lastContentLine(atOrBefore: br.upperBound.line)
}
prevEnd = lastContentLine(atOrBefore: r.upperBound.line)
}
return true
}
mutating func visitUnorderedList(_ list: UnorderedList) -> String {
listIsTight.append(isTight(list))
defer { listIsTight.removeLast() }
return "<ul>\(renderChildren(of: list))</ul>"
}
mutating func visitOrderedList(_ list: OrderedList) -> String {
listIsTight.append(isTight(list))
defer { listIsTight.removeLast() }
let start = list.startIndex == 1 ? "" : " start=\"\(list.startIndex)\""
return "<ol\(start)>\(renderChildren(of: list))</ol>"
}
mutating func visitListItem(_ listItem: ListItem) -> String {
if let checkbox = listItem.checkbox {
let checked = checkbox == .checked
// Composed Lucide SVG (not an SF Symbol, which can't ship in exported
// PDFs) mirroring the editor's look; CSS supplies the accent/dim color.
let mark = "<span class=\"task-check task-check--\(checked ? "checked" : "unchecked")\">"
+ "\(LucideIcons.checkboxSVG(checked: checked))</span>"
let checkedClass = checked ? " task--checked" : ""
return "<li class=\"task\(checkedClass)\">\(mark)\(renderListItemContents(listItem))</li>"
}
return "<li>\(renderListItemContents(listItem))</li>"
}
/// Item contents; in a tight list, each direct Paragraph child loses its
/// <p></p> wrapper (visit-then-strip, so visitParagraph's math/footnote
/// special cases still run).
private mutating func renderListItemContents(_ item: ListItem) -> String {
guard listIsTight.last == true else { return renderChildren(of: item) }
var out = ""
for child in item.children {
var html = visit(child)
if child is Paragraph, html.hasPrefix("<p>"), html.hasSuffix("</p>") {
html = String(html.dropFirst(3).dropLast(4))
}
out += html
}
return out
}
mutating func visitTable(_ table: Table) -> String {
let aligns = table.columnAlignments
func cellStyle(_ col: Int) -> String {
guard col < aligns.count, let a = aligns[col] else { return "" }
switch a {
case .left: return " style=\"text-align:left\""
case .center: return " style=\"text-align:center\""
case .right: return " style=\"text-align:right\""
}
}
var html = "<div class=\"table-wrap\"><table><thead><tr>"
for (col, cell) in table.head.cells.enumerated() {
html += "<th\(cellStyle(col))>\(renderChildren(of: cell))</th>"
}
html += "</tr></thead><tbody>"
for row in table.body.rows {
html += "<tr>"
for (col, cell) in row.cells.enumerated() {
html += "<td\(cellStyle(col))>\(renderChildren(of: cell))</td>"
}
html += "</tr>"
}
html += "</tbody></table></div>"
return html
}
// MARK: Inline
mutating func visitText(_ text: Text) -> String {
Self.renderInline(text.string, rawSource: sourceText(text))
}
mutating func visitEmphasis(_ emphasis: Emphasis) -> String { "<em>\(renderChildren(of: emphasis))</em>" }
mutating func visitStrong(_ strong: Strong) -> String { "<strong>\(renderChildren(of: strong))</strong>" }
mutating func visitStrikethrough(_ s: Strikethrough) -> String { "<del>\(renderChildren(of: s))</del>" }
mutating func visitInlineCode(_ code: InlineCode) -> String { "<code>\(Self.escape(code.code))</code>" }
mutating func visitLineBreak(_ lineBreak: LineBreak) -> String { "<br>\n" }
mutating func visitSoftBreak(_ softBreak: SoftBreak) -> String { "\n" }
mutating func visitLink(_ link: Link) -> String {
let dest = link.destination ?? ""
let inner = renderChildren(of: link)
let title = link.title.map { " title=\"\(Self.attr($0))\"" } ?? ""
// In-page `#fragment` anchors and external links (http/https/mailto, or
// any explicit scheme) keep their real href the nav policy lets the
// anchor scroll and hands external schemes to the browser. A relative /
// internal destination is wrapped in the private link scheme so it routes
// through the app's document graph reliably.
if dest.hasPrefix("#") || Self.hasExternalScheme(dest) {
return "<a href=\"\(Self.attr(dest))\"\(title)>\(inner)</a>"
}
let encoded = dest.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? dest
return "<a href=\"\(Self.linkScheme):\(encoded)\"\(title)>\(inner)</a>"
}
/// Whether a link destination carries an explicit URL scheme (`http:`,
/// `mailto:`, `file:`, ) and so should be treated as external/absolute
/// rather than a relative path into the document's directory.
private static func hasExternalScheme(_ dest: String) -> Bool {
guard let colon = dest.firstIndex(of: ":") else { return false }
let scheme = dest[dest.startIndex..<colon]
// A scheme is letters/digits/+/-/. and can't contain a slash; a path like
// "a/b:c" has its first colon after a slash, so it's not a scheme.
guard !scheme.isEmpty, scheme.first!.isLetter else { return false }
return scheme.allSatisfy { $0.isLetter || $0.isNumber || $0 == "+" || $0 == "-" || $0 == "." }
&& !scheme.contains("/")
}
mutating func visitImage(_ image: Image) -> String {
// Emit a placeholder carrying the raw source; `DocumentHTML` resolves and
// inlines it in a second pass (it needs the document directory + the
// remote-image policy, which the pure renderer doesn't have). No `src`
// here if the asset pass can't resolve it, the alt text shows.
let alt = Self.attr(Self.plainText(of: image))
let src = Self.attr(image.source ?? "")
return "<img class=\"md-image\" data-src=\"\(src)\" alt=\"\(alt)\">"
}
// Inline HTML (§6.10): full GFM raw-HTML passthrough, filtered through
// tagfilter (§6.11) + hardening (§G see ARCHITECTURE §10). Block HTML
// gets the same filter (below).
mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String {
Self.sanitizeInlineHTML(inlineHTML.rawHTML)
}
mutating func visitHTMLBlock(_ html: HTMLBlock) -> String {
// A block-level `<!-- comment -->` is invisible, like in a browser.
let trimmed = html.rawHTML.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("<!--") && trimmed.hasSuffix("-->") { return "" }
if isSingleTag(trimmed, named: "img"), let img = Self.imgPlaceholder(trimmed) {
return "<p>\(img)</p>"
}
// GFM passthrough (§4.6): tagfilter + hardening, then rewrite interior
// `<img src=>` tags to asset-pass placeholders (same baseURL-nil reason
// as inline; also the remote-image-policy enforcement point). No <p>
// wrapper, no escaping. filterRawHTML runs FIRST: the placeholders carry
// only class/data-src/alt/width/height attrs, which the hardening
// regexes can't touch.
return Self.rewriteImgs(in: Self.filterRawHTML(html.rawHTML))
}
/// Full §6.10 open-tag grammar for `img` (quoted values may contain `>`).
private static let imgTagRegex = try! NSRegularExpression(
pattern: #"<img(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*/?>"#,
options: [.caseInsensitive])
/// Replaces every `<img >` that has a usable src with the md-image
/// placeholder; src-less imgs pass through untouched (they simply won't load).
static func rewriteImgs(in html: String) -> String {
let ns = html as NSString
let out = NSMutableString(string: html)
for m in imgTagRegex.matches(in: html, range: NSRange(location: 0, length: ns.length)).reversed() {
if let placeholder = imgPlaceholder(ns.substring(with: m.range)) {
out.replaceCharacters(in: m.range, with: placeholder)
}
}
return out as String
}
/// True when `raw` is exactly one `<name >` tag (no trailing content
/// the anchored tag regex must consume the whole string).
private func isSingleTag(_ raw: String, named name: String) -> Bool {
let ns = raw as NSString
guard let m = Self.inlineTagRegex.firstMatch(
in: raw, range: NSRange(location: 0, length: ns.length)) else { return false }
return ns.substring(with: m.range(at: 1)).isEmpty
&& ns.substring(with: m.range(at: 2)).lowercased() == name
}
private static let inlineTagRegex =
try! NSRegularExpression(pattern: #"^<(/?)([A-Za-z][A-Za-z0-9]*)[^>]*>$"#)
// MARK: - GFM tagfilter (§6.11) + hardening
/// Tagfilter (§6.11): the leading `<` of the nine disallowed tag names
/// (open or closing, case-insensitive) becomes `&lt;`. Lookahead only
/// nothing else is consumed.
private static let tagfilterRegex = try! NSRegularExpression(
pattern: #"<(?=/?(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)(?:[\s/>]|$))"#,
options: [.caseInsensitive])
/// Hardening (beyond spec): strip `on*` event-handler attributes.
/// ponytail: plain-text regex, not an HTML parser a literal ` onclick="x"`
/// in text between tags is also stripped; harmless for a hardening pass.
private static let eventAttrRegex = try! NSRegularExpression(
pattern: #"\son[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)"#,
options: [.caseInsensitive])
/// Hardening: neutralize javascript:/vbscript: schemes in URL-carrying
/// attributes (the scheme is deleted; the rest becomes a harmless relative URL).
private static let scriptURLRegex = try! NSRegularExpression(
pattern: #"(\s(?:href|src|action|formaction|xlink:href|data)\s*=\s*["']?\s*)(?:javascript|vbscript)\s*:"#,
options: [.caseInsensitive])
/// GFM raw-HTML output filter: tagfilter + Edmund's hardening. Defense-in-depth
/// on top of the read webview's JS-off + CSP script-src 'none' + baseURL nil.
static func filterRawHTML(_ raw: String) -> String {
func sub(_ s: String, _ rx: NSRegularExpression, _ template: String) -> String {
rx.stringByReplacingMatches(in: s, range: NSRange(location: 0, length: (s as NSString).length),
withTemplate: template)
}
var out = sub(raw, tagfilterRegex, "&lt;")
out = sub(out, eventAttrRegex, "")
out = sub(out, scriptURLRegex, "$1")
return out
}
/// GFM raw-HTML passthrough (§6.10) with tagfilter (§6.11) + hardening.
/// Comments stay invisible. A lone `<img src=…>` becomes the asset-pass
/// placeholder — REQUIRED, not just policy: the page loads with `baseURL: nil`,
/// so a raw relative `<img src>` could never resolve; the placeholder routes
/// it through DocumentHTML.fillImages (data-URI inlining + remote-image policy,
/// declared width/height carried through). Everything else passes through
/// filtered. Whitelisted formatting tags now keep their (hardened) attributes.
static func sanitizeInlineHTML(_ raw: String) -> String {
if raw.hasPrefix("<!--") { return "" }
let ns = raw as NSString
if let m = inlineTagRegex.firstMatch(in: raw, range: NSRange(location: 0, length: ns.length)),
ns.substring(with: m.range(at: 1)).isEmpty, // open tag
ns.substring(with: m.range(at: 2)).lowercased() == "img",
let img = imgPlaceholder(raw) {
return img
}
return filterRawHTML(raw)
}
/// A `md-image` placeholder for a raw `<img src="">` tag, or nil when it
/// has no `src`. Attribute extraction shares the Edit-mode regexes so the
/// two back-ends accept the same tags (double-, single-, and unquoted
/// values, §6.10); every value is re-escaped, so no raw attribute text
/// passes through.
static func imgPlaceholder(_ raw: String) -> String? {
let ns = raw as NSString
let whole = NSRange(location: 0, length: ns.length)
func attrValue(_ regex: NSRegularExpression) -> String? {
regex.firstMatch(in: raw, range: whole)
.map { ns.substring(with: SyntaxHighlighter.attrValueRange($0)) }
}
guard let src = attrValue(SyntaxHighlighter.imgSrcRegex) else { return nil }
var out = "<img class=\"md-image\" data-src=\"\(attr(src))\""
out += " alt=\"\(attr(attrValue(SyntaxHighlighter.imgAltRegex) ?? ""))\""
if let w = attrValue(SyntaxHighlighter.imgWidthRegex) { out += " width=\"\(w)\"" }
if let h = attrValue(SyntaxHighlighter.imgHeightRegex) { out += " height=\"\(h)\"" }
return out + ">"
}
// MARK: - Callouts
private mutating func renderCallout(marker: Callout.Marker, style: CalloutStyle,
firstLine: String, blockQuote: BlockQuote) -> String {
// Custom title = whatever follows `]` on the first line.
let ns = firstLine as NSString
let afterMarker = marker.closeBracket.upperBound <= ns.length
? ns.substring(from: marker.closeBracket.upperBound)
: ""
let title = Callout.title(type: marker.type, customTitle: afterMarker)
// Callouts are strict: only the leading run of `>`-prefixed lines is the
// callout body. swift-markdown's CommonMark parse lazily continues a
// following bare line into the blockquote, but the editor keeps callouts
// strict (BlockParser splits the lazy line off) so a following
// `> [!type]` can't be pulled into a prior callout (GFM ex. 228). Split
// the raw source the same way and render the lazy tail as sibling
// content after the callout, matching edit-mode segmentation exactly.
let rawLines = (sourceText(blockQuote) ?? "").components(separatedBy: "\n")
let quotedCount = rawLines.prefix(while: Self.isQuotedLine).count
// Body = the de-quoted `>`-run after the first (marker) line, re-parsed.
let body = rawLines[min(1, quotedCount)..<quotedCount]
.map(Self.deQuoteLine).joined(separator: "\n")
let bodyHTML = body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? ""
: HTMLRenderer.render(markdown: body, options: options)
// Inline the Lucide icon directly (vector, sharp in PDF). It strokes in
// `currentColor`, so the `.callout-title` accent color tints it no
// per-appearance asset pass, and no SF Symbol shipped in the export.
let icon = "<span class=\"callout-icon\">\(LucideIcons.inlineSVG(style.iconName) ?? "")</span>"
let calloutHTML = "<div class=\"callout callout-\(Self.attr(marker.type))\">"
+ "<div class=\"callout-title\">\(icon)<span class=\"callout-title-text\">\(Self.escape(title))</span></div>"
+ "<div class=\"callout-body\">\(bodyHTML)</div></div>"
// Lazy tail (bare lines swift-markdown folded in) sibling markdown.
let tail = rawLines[quotedCount...].joined(separator: "\n")
let tailHTML = tail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? ""
: HTMLRenderer.render(markdown: tail, options: options)
return calloutHTML + tailHTML
}
/// The raw source text of a block quote with each line's `>` prefix removed.
private func deQuoted(_ blockQuote: BlockQuote) -> String? {
guard let quoted = sourceText(blockQuote) else { return nil }
return quoted.components(separatedBy: "\n")
.map(Self.deQuoteLine).joined(separator: "\n")
}
/// Strips one leading `>` marker (optional spaces, `>`, optional space).
private static func deQuoteLine(_ line: String) -> String {
var l = Substring(line)
while l.first == " " { l = l.dropFirst() }
if l.first == ">" {
l = l.dropFirst()
if l.first == " " { l = l.dropFirst() }
}
return String(l)
}
/// Whether a line carries a `>` marker (optional leading spaces then `>`)
/// the same predicate the editor's BlockParser uses for quote membership.
private static func isQuotedLine(_ line: String) -> Bool {
line.drop(while: { $0 == " " }).first == ">"
}
// MARK: - Inline non-GFM (highlight / math / wikilink / comment)
/// Renders a leaf text run, recognizing the non-GFM inline constructs the
/// editor supports by reusing the same custom-parser regexes. Everything not
/// matched is HTML-escaped.
///
/// `rawSource`, when given, is this run's *unescaped-by-swift-markdown* source
/// (`Text.string`) counterpart's raw markdown. Only inline math needs it: a
/// Text node's `.string` has already had Markdown backslash-escapes collapsed
/// (`\\``\`, `\$``$`), which mangles LaTeX (a `\begin{cases} \\ \end`
/// loses its row separators). The tex is therefore recovered from the raw
/// source instead. Everything else stays on the (correctly unescaped) `s`.
private static func renderInline(_ s: String, rawSource: String? = nil) -> String {
guard !s.isEmpty else { return "" }
var spans: [SyntaxHighlighter.Span] = []
SyntaxHighlighter.parseHighlight(s, into: &spans)
SyntaxHighlighter.parseMath(s, into: &spans) // inline $$ only
SyntaxHighlighter.parseWikiLinks(s, into: &spans)
SyntaxHighlighter.parseComments(s, into: &spans)
SyntaxHighlighter.parseFootnotes(s, into: &spans) // references only; a
// `.footnoteDefinition` match here is a false positive (mid-run text that
// happens to start with `[^id]:`) since real definitions are handled at
// the paragraph level in `visitParagraph` ignored by the switch below.
// Bare autolinks last, so the guards above are in place. Real `[x](url)`
// links never appear here (they're Link nodes, not leaf text).
SyntaxHighlighter.parseAutolinks(s, into: &spans)
// Keep only the kinds we emit, ordered, non-overlapping (earliest wins).
let relevant = spans.filter {
switch $0.kind {
case .highlight, .math(false), .wikilink, .comment, .footnoteReference,
.link: return true
default: return false
}
}.sorted { $0.fullRange.location < $1.fullRange.location }
// Recover each inline equation's tex from the raw source. The raw parse
// finds the same `$$` runs in the same order; pair the k-th emitted math
// span with the k-th raw one. Only when the counts agree (a `\$` escape
// can make the unescaped `s` see a spurious `$$` the raw source doesn't),
// else fall back to the unescaped tex no worse than before.
var rawTexByLoc: [Int: String] = [:]
if let rawSource {
var rawSpans: [SyntaxHighlighter.Span] = []
SyntaxHighlighter.parseMath(rawSource, into: &rawSpans)
let rns = rawSource as NSString
let rawTex = rawSpans
.filter { if case .math(false) = $0.kind { return true }; return false }
.sorted { $0.fullRange.location < $1.fullRange.location }
.map { rns.substring(with: $0.contentRange) }
let mathSpans = relevant.filter { if case .math(false) = $0.kind { return true }; return false }
if mathSpans.count == rawTex.count {
for (i, sp) in mathSpans.enumerated() { rawTexByLoc[sp.fullRange.location] = rawTex[i] }
}
}
let ns = s as NSString
var out = ""
var cursor = 0
for span in relevant {
let r = span.fullRange
if r.location < cursor { continue } // overlaps a prior span
if r.location > cursor {
out += escape(ns.substring(with: NSRange(location: cursor, length: r.location - cursor)))
}
switch span.kind {
case .highlight:
out += "<mark>\(escape(ns.substring(with: span.contentRange)))</mark>"
case .math(false):
let tex = rawTexByLoc[r.location] ?? ns.substring(with: span.contentRange)
out += "<span class=\"math-inline\" data-tex=\"\(attr(tex))\"></span>"
case .wikilink(let target):
// Emit a link in a private scheme so the read view's nav policy
// can intercept it and route through the app's document graph
// (rather than navigating the webview). The target is fully
// percent-encoded so a `#heading` isn't parsed as a URL fragment.
let encoded = target.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? target
let display = escape(ns.substring(with: span.contentRange))
out += "<a class=\"wikilink\" href=\"\(wikiScheme):\(encoded)\">\(display)</a>"
case .footnoteReference(let id):
let safeID = attr(id)
out += "<sup id=\"fnref-\(safeID)\" class=\"footnote-ref\">" +
"<a href=\"#fn-\(safeID)\">\(escape(id))</a></sup>"
case .comment:
break // hidden in reading, like the editor
case .link(let destination):
// A bare autolink: a real external href (http/mailto).
out += "<a href=\"\(attr(destination))\">\(escape(ns.substring(with: span.contentRange)))</a>"
default:
break
}
cursor = r.upperBound
}
if cursor < ns.length {
out += escape(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
}
return out
}
// MARK: - Source-offset helpers (UTF-8 SourceLocation UTF-16 NSRange)
private func sourceText(_ markup: Markup) -> String? {
guard let range = markup.range else { return nil }
let lo = utf16Offset(for: range.lowerBound)
let hi = utf16Offset(for: range.upperBound)
let ns = source as NSString
guard lo <= hi, hi <= ns.length else { return nil }
return ns.substring(with: NSRange(location: lo, length: hi - lo))
}
private func utf16Offset(for loc: SourceLocation) -> Int {
var utf8Offset = 0
for i in 0..<(loc.line - 1) where i < sourceLines.count {
utf8Offset += sourceLines[i].utf8.count + 1
}
utf8Offset += loc.column - 1
let utf8View = source.utf8
let targetIdx = utf8View.index(utf8View.startIndex,
offsetBy: min(utf8Offset, utf8View.count))
return source.utf16.distance(
from: source.utf16.startIndex,
to: String.Index(targetIdx, within: source.utf16) ?? source.utf16.endIndex)
}
// MARK: - Escaping
/// Escapes text content for HTML.
static func escape(_ s: String) -> String {
var out = ""
out.reserveCapacity(s.count)
for ch in s {
switch ch {
case "&": out += "&amp;"
case "<": out += "&lt;"
case ">": out += "&gt;"
default: out.append(ch)
}
}
return out
}
/// Escapes a string for use inside a double-quoted HTML attribute.
static func attr(_ s: String) -> String {
var out = ""
out.reserveCapacity(s.count)
for ch in s {
switch ch {
case "&": out += "&amp;"
case "<": out += "&lt;"
case ">": out += "&gt;"
case "\"": out += "&quot;"
case "'": out += "&#39;"
default: out.append(ch)
}
}
return out
}
/// Concatenates the literal text of a subtree (Text/InlineCode joined,
/// soft/line breaks as newlines). Used for display-math detection and image
/// alt text not for general rendering.
static func plainText(of markup: Markup) -> String {
if let t = markup as? Text { return t.string }
if let c = markup as? InlineCode { return c.code }
if markup is SoftBreak || markup is LineBreak { return "\n" }
return markup.children.map { plainText(of: $0) }.joined()
}
/// If `text` starts with a footnote definition marker `[^id]:` (optionally
/// followed by one space), returns the id and the marker's length so the
/// caller can split it off from the body. Mirrors
/// `SyntaxHighlighter.parseFootnotes`'s definition rule (which only matches
/// at the start of the string passed to it) without needing that file's
/// file-private regex.
private static func footnoteDefinitionMarker(in text: String) -> (id: String, markerLength: Int)? {
guard text.hasPrefix("[^"), let closeBracket = text[text.index(text.startIndex, offsetBy: 2)...].firstIndex(of: "]") else { return nil }
let id = text[text.index(text.startIndex, offsetBy: 2)..<closeBracket]
guard !id.isEmpty, !id.contains(where: { $0.isWhitespace }) else { return nil }
let afterBracket = text.index(after: closeBracket)
guard afterBracket < text.endIndex, text[afterBracket] == ":" else { return nil }
var markerEnd = text.index(after: afterBracket)
if markerEnd < text.endIndex, text[markerEnd] == " " { markerEnd = text.index(after: markerEnd) }
return (String(id), text.distance(from: text.startIndex, to: markerEnd))
}
}
+363
View File
@@ -0,0 +1,363 @@
import AppKit
// MARK: - HTMLTheme
//
// Emits the CSS for Read mode / PDF export from the *same* `EditorTheme` and
// `CalloutStyle` models the editor renders from, so the two can't drift. The
// theme is the single source of truth for the values it carries (body font/size,
// accent, code color, line/paragraph spacing, callout colors); spacing for
// elements the theme doesn't model (headings, list indent) uses tasteful
// document defaults.
//
// Colors are resolved for one appearance (`dark`); the Read view re-renders when
// the system appearance flips.
enum HTMLTheme {
@MainActor
static func css(_ theme: EditorTheme,
callouts: [String: CalloutStyle],
dark: Bool,
maxContentWidthPoints: Double = .greatestFiniteMagnitude) -> String {
let bg = dark ? "#1e1e1e" : "#ffffff"
let fg = dark ? "#e6e6e6" : "#1a1a1a"
let faint = dark ? "#9a9a9a" : "#6a6a6a"
let rule = dark ? "#3a3a3a" : "#e0e0e0"
let codeBg = dark ? "#2a2a2a" : "#f4f4f4"
// line-height: editor `NSParagraphStyle.lineSpacing` adds extra points
// *between* lines on top of the font's natural leading (~1.2×). The CSS
// equivalent is 1.2 + (lineSpacing / fontSize).
let lineHeight = 1.2 + theme.lineSpacing / theme.fontSize
// CSS px and AppKit points are both device-independent, so the editor's
// physical cap (EditorTextView.maxContentWidthPoints) carries over as-is.
// A huge/infinite value means "uncapped" in the editor too; `none` skips
// the constraint instead of emitting an unusable giant number.
let pageMaxWidth = maxContentWidthPoints < 100_000
? "\(trim(CGFloat(maxContentWidthPoints)))px" : "none"
return """
:root {
--body-font: \(cssFontStack(theme.fontName, generic: "serif"));
--body-size: \(trim(theme.fontSize))px;
--mono-font: \(cssFontStack(theme.monospaceFontName.isEmpty ? "ui-monospace" : theme.monospaceFontName, generic: "monospace"));
--mono-size: \(trim(theme.monospaceFontSize))px;
--accent: \(theme.linkBlueHex);
--code: \(theme.codeHex);
--bg: \(bg);
--fg: \(fg);
--faint: \(faint);
--rule: \(rule);
--code-bg: \(codeBg);
--marker: \(resolvedRGBA(.tertiaryLabelColor, dark: dark));
--check-fill: \(resolvedRGBA(.controlAccentColor, dark: dark));
--line-height: \(trim(lineHeight));
--para-space: \(trim(max(theme.paragraphSpacingBefore, 0)))px;
--page-max-width: \(pageMaxWidth);
}
\(calloutVars(callouts, dark: dark))
\(staticRules)
\(codeTokenRules(dark: dark))
"""
}
// MARK: Code syntax colors
/// `.tok-*` color rules for fenced code blocks, from the shared
/// `CodeSyntaxPalette` so Read mode matches the editor token-for-token. The
/// `pre code` rule overrides the static `var(--fg)` so plain (un-tokenized)
/// code uses the palette's plain color too, like the editor.
private static func codeTokenRules(dark: Bool) -> String {
func rule(_ selector: String, _ type: CodeHighlighter.TokenType?) -> String {
"\(selector) { color: \(CodeSyntaxPalette.hex(type, dark: dark)); }"
}
return [
rule("pre code", nil),
rule("pre code .tok-keyword", .keyword),
rule("pre code .tok-type", .type),
rule("pre code .tok-string", .string),
rule("pre code .tok-number", .number),
rule("pre code .tok-comment", .comment),
rule("pre code .tok-function", .function),
].joined(separator: "\n")
}
// MARK: Callout custom properties
@MainActor
private static func calloutVars(_ callouts: [String: CalloutStyle], dark: Bool) -> String {
// De-dup styles shared by aliases: emit one rule block per type key.
var out = ""
for type in callouts.keys.sorted() {
let style = callouts[type]!
let accent = style.accentHex(dark: dark)
let border = style.resolvedBorderHex(dark: dark)
let bg = style.explicitBackgroundHex(dark: dark)
?? rgba(accent, alpha: style.backgroundAlpha)
out += """
.callout-\(type) {
--c-accent: \(accent);
--c-border: \(border);
--c-bg: \(bg);
--c-border-width: \(trim(style.borderWidth))px;
\(borderEdgeRules(style.borderEdges))
}
"""
}
return out
}
private static func borderEdgeRules(_ edges: CalloutStyle.Edges) -> String {
var parts: [String] = []
if edges.contains(.left) { parts.append("border-left: var(--c-border-width) solid var(--c-border);") }
if edges.contains(.top) { parts.append("border-top: var(--c-border-width) solid var(--c-border);") }
if edges.contains(.right) { parts.append("border-right: var(--c-border-width) solid var(--c-border);") }
if edges.contains(.bottom) { parts.append("border-bottom: var(--c-border-width) solid var(--c-border);") }
return parts.joined(separator: " ")
}
// MARK: Static element rules
private static let staticRules = """
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: var(--body-font);
font-size: var(--body-size);
line-height: var(--line-height);
color: var(--fg);
background: var(--bg);
margin: 0;
padding: 48px 24px;
}
.page { max-width: var(--page-max-width); margin: 0 auto; }
/* Styled-source spacing: paragraphs and blocks get a full line's breathing
room, so the cadence feels like a clean, readable version of Edit mode
rather than a collapsed publication layout. */
p { margin: 0 0 1em; }
h1, h2, h3, h4, h5, h6 { line-height: 1.25; font-weight: 600; margin: 1.4em 0 0.5em; }
h1 { font-size: 1.9em; } h2 { font-size: 1.55em; } h3 { font-size: 1.3em; }
h4 { font-size: 1.1em; } h5 { font-size: 1em; } h6 { font-size: 0.9em; color: var(--faint); }
:is(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0; }
a { color: var(--accent); text-decoration: underline; }
code { font-family: var(--mono-font); font-size: 0.92em; color: var(--code);
background: var(--code-bg); padding: 0.1em 0.35em; border-radius: 4px; }
pre { background: var(--code-bg); padding: 12px 14px; border-radius: 8px; overflow-x: auto;
/* tab-size: browsers default to 8; match the common editor convention of 4. */
tab-size: 4; -moz-tab-size: 4; }
pre code { color: var(--fg); background: none; padding: 0; font-size: var(--mono-size); }
blockquote { margin: 1em 0; padding: 0.5em 1em; border-left: 3px solid var(--rule); color: var(--faint); }
/* Without this, the 1em bottom margin on the last <p> inside a blockquote
creates asymmetric vertical padding — the blockquote looks heavier at the
bottom than at the top. Reset it so padding alone controls the spacing. */
blockquote > p:last-child { margin-bottom: 0; }
/* A nested blockquote that is the last child of its parent blockquote (or
callout body) would otherwise leave 1em of extra space below itself inside
the parent's padding. Collapse it. */
blockquote > blockquote:last-child,
.callout-body > blockquote:last-child { margin-bottom: 0; }
hr { border: none; border-top: 1px solid var(--rule); margin: 1.6em 0; }
mark { background: rgba(255, 200, 0, 0.3); color: inherit; padding: 0 0.1em; }
/* Whitelisted inline HTML rendered in Read mode (see HTMLRenderer
sanitizeInlineHTML). <u>/<mark> use the UA underline / the rule above;
<kbd> matches the editor's inline-key chrome, <sub>/<sup> get the standard
line-height-safe reset. */
kbd { font-family: var(--mono-font); font-size: 0.92em; background: var(--code-bg);
border: 1px solid var(--rule); border-radius: 4px; padding: 0.05em 0.4em; }
sub, sup { font-size: 0.75em; line-height: 0; position: relative; vertical-align: baseline; }
sup { top: -0.5em; }
sub { bottom: -0.25em; }
/* Footnotes (see HTMLRenderer.renderFootnotesSection): in-text `[^id]` refs
are plain (undecorated) superscript links; the bottom-of-page list is a
smaller, dimmer <ol> below its own <hr>, each entry ending in a backref
arrow to the in-text marker. */
sup.footnote-ref a { text-decoration: none; }
hr.footnotes-sep { margin-bottom: 0.8em; }
ol.footnotes { font-size: 0.85em; color: var(--faint); }
ol.footnotes li { margin: 0.4em 0; }
a.footnote-backref { text-decoration: none; margin-left: 0.2em; font-size: 0.9em; line-height: 1; }
/* Match the editor's list indentation: level-1 text begins at one marker
slot past the marker (~2.25em), and each nesting level steps in by one
slot (~1.25em). Same dot at every level, like Edit mode. */
/* Only direct children of .page and .callout-body get block margin (1em top
+ bottom) and the wider level-1 indent (2.25em). Nested lists inside list
items stay at 0 margin — otherwise each level compounds to large gaps. */
ul, ol { margin: 0; padding-left: 1.25em; }
.page > ul, .page > ol,
.callout-body > ul, .callout-body > ol { margin: 1em 0; padding-left: 2.25em; }
li > ul, li > ol { margin: 0; }
ul { list-style-type: disc; }
li { margin: 0.2em 0; }
li::marker { color: var(--marker); font-size: 0.85em; }
li > p { margin: 0; }
/* Task items: float the checkbox into the marker slot so the label and
wrapped lines sit at the same content edge as bullet/number text. The
negative margin-left pulls the checkbox into the list's padding area; the
nested <ul>/<ol> clears the float so it falls below.
Lucide checkbox (a tinted <svg>, see HTMLRenderer/LucideIcons): unchecked =
dim outlined circle (--marker, the editor's tertiaryLabelColor); checked =
disc filled in the system accent (--check-fill, matching the editor's
controlAccentColor) with a white check baked into the SVG. `currentColor`
in the SVG inherits from `color` below. */
li.task { list-style: none; }
li.task > .task-check {
/* Sized a bit larger than 1em so the Lucide circle (r=10 in a 24-box, so it
underfills) reads as big as the editor's checkbox. margin-left is roughly
-(width + margin-right) so the task TEXT starts at the content edge,
lining up with sibling bullet/number text; hand-tuned to -1.45em (a hair
less negative than the -1.5em that formula gives) so the marker centers
over the bullet/number column at every nesting level.
margin-top (0.1em) centers the box on the first text line's cap-height
center — tuned visually for Iowan Old Style at 16pt / line-height 1.45. A
font-agnostic fix would measure NSFont.ascender at render time and emit an
inline margin-top. */
float: left; width: 1.2em; height: 1.2em; line-height: 0;
margin-top: 0.1em;
margin-right: 0.3em;
margin-left: -1.45em;
}
li.task > .task-check svg { display: block; width: 1.2em; height: 1.2em; }
.task-check--unchecked { color: var(--marker); }
.task-check--checked { color: var(--check-fill); }
li.task--checked > p { opacity: 0.45; text-decoration: line-through; }
li.task > p { display: inline; margin: 0; }
li.task > ul, li.task > ol { clear: left; }
/* Contain the checkbox float within its own item. Without this, a task item
that has no nested list (the float is never cleared by a child ul/ol)
leaks its float onto the FOLLOWING sibling, shoving that item's bullet/
number marker to the right — so sibling markers stop lining up. */
li.task::after { content: ""; display: block; clear: both; }
.blank-line { height: calc(var(--body-size) * var(--line-height)); }
/* Tables keep their natural (content-driven) width and scroll horizontally
inside .table-wrap instead of squeezing columns or forcing cell text to
wrap — same idiom as `pre`'s overflow-x below. */
.table-wrap { overflow-x: auto; margin: 1em 0; }
table { border-collapse: collapse; }
th, td { border: 1px solid var(--rule); padding: 6px 10px; }
thead th { background: var(--code-bg); }
img { max-width: 100%; }
img.math { vertical-align: middle; }
.math-display { text-align: center; margin: 1em 0; }
/* Stand-in for a plain-http image, which never loads under ATS. */
.md-image-blocked { display: inline-flex; align-items: center; gap: 0.4em;
color: var(--faint); background: var(--code-bg);
border: 1px dashed var(--rule); border-radius: 6px;
padding: 0.3em 0.6em; font-size: 0.9em; }
.md-image-blocked svg { width: 1.1em; height: 1.1em; flex: 0 0 auto; }
/* Callouts: tinted box + colored title; the icon sits as a non-shrinking
flex child so a long custom title wraps under the title text, never under
the icon — the layout the TextKit editor can't achieve. */
/* Outer margin matches the gap between two consecutive <pre> blocks (UA
stylesheet gives pre { margin: 1em 0 }; collapsing → 1em gap). Using
the same value here means neighboring callouts look equally spaced. */
.callout { background: var(--c-bg); border-radius: 8px; padding: 10px 14px; margin: 1em 0; }
/* Icon sits at the top so it stays on the first line of a wrapped title; its
box is exactly one line tall and centers the glyph, so it lines up with the
first line's text rather than floating above it. */
.callout-title { display: flex; align-items: flex-start; gap: 0.3em;
font-weight: 600; color: var(--c-accent); }
.callout-icon { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
height: calc(var(--body-size) * var(--line-height)); }
/* Lucide glyphs sit a touch low against the title's optical (cap-height)
center; nudge the icon up so it reads as centered with the title text. */
.callout-icon svg { width: 1em; height: 1em; transform: translateY(-0.06em); }
/* Per-glyph optical nudge: a few Lucide icons sit high in their 24-box, so
push them down a hair to read as centered against the title cap-height.
Aliases share an icon, so they get the same value. */
.callout-info .callout-icon, .callout-todo .callout-icon,
.callout-question .callout-icon, .callout-help .callout-icon, .callout-faq .callout-icon,
.callout-quote .callout-icon, .callout-cite .callout-icon { padding-top: 0.05em; }
.callout-warning .callout-icon, .callout-attention .callout-icon,
.callout-bug .callout-icon { padding-top: 0.06em; }
.callout-example .callout-icon { padding-top: 0.1em; }
.callout-success .callout-icon, .callout-check .callout-icon, .callout-done .callout-icon,
.callout-failure .callout-icon, .callout-fail .callout-icon,
.callout-missing .callout-icon { padding-top: 0.15em; }
.callout-title-text { flex: 1 1 auto; }
.callout-body { margin-top: 0.4em; }
/* A title-only callout still emits an empty body div; collapse its top margin
so the box doesn't carry the 0.4em title gap as dead space at the bottom. */
.callout-body:empty { margin-top: 0; }
/* Reduce paragraph spacing inside callout bodies so nested callouts and
body text don't sit too far apart. The full 1em bottom margin (from the
global <p> rule) + the nested callout's 0.5em top margin would give
1.5em gap — halving the paragraph bottom margin brings it to ~1em. */
.callout-body > p { margin-bottom: 0.5em; }
.callout-body > :first-child { margin-top: 0; }
.callout-body > :last-child { margin-bottom: 0; }
/* A callout that is the last child of a callout body (e.g. the nested TIP
inside the NOTE) has its top margin removed so the space above it is
governed only by the preceding element's bottom margin (0.5em for a <p>
from .callout-body > p), not the combined margin collapse of 1em. */
.callout-body > .callout:last-child { margin-top: 0; }
@media print {
body { padding: 0; }
/* QUIRK: WebKit strips background colors when printing by default (it
follows the user's browser setting), even though WKWebView.createPDF
keeps them. `print-color-adjust: exact` forces faithful color output
so callout backgrounds, code blocks, and highlights survive printing. */
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.callout, pre, blockquote, .table-wrap, .math-display { break-inside: avoid; }
h1, h2, h3, h4, h5, h6 { break-after: avoid; }
thead { display: table-header-group; }
}
"""
// MARK: Helpers
/// A CSS font stack: the (possibly multi-word) macOS family name quoted, then
/// a system fallback and a generic. WKWebView resolves installed families
/// (e.g. "Iowan Old Style") by name; the generic guards the rest.
private static func cssFontStack(_ family: String, generic: String) -> String {
let trimmed = family.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed == "ui-monospace" {
return "ui-monospace, \(generic)"
}
return "\"\(trimmed)\", -apple-system, \(generic)"
}
/// Resolves a (possibly dynamic/catalog) `NSColor` for the given appearance
/// to a CSS `rgba(...)`, preserving alpha. Used so list markers use the exact
/// same dim as the editor (`NSColor.tertiaryLabelColor`) and can't drift.
///
/// QUIRK: dynamic system colors like `tertiaryLabelColor` store a catalog
/// reference, not actual RGBA components calling `usingColorSpace(.sRGB)`
/// on one outside a drawing context resolves to nil or returns the wrong
/// variant. `performAsCurrentDrawingAppearance` sets the appearance context
/// so the catalog resolves to the correct light or dark concrete color.
@MainActor
private static func resolvedRGBA(_ color: NSColor, dark: Bool) -> String {
var resolved = color
NSAppearance(named: dark ? .darkAqua : .aqua)?.performAsCurrentDrawingAppearance {
resolved = color.usingColorSpace(.sRGB) ?? color
}
guard let c = resolved.usingColorSpace(.sRGB) else {
return dark ? "rgba(235,235,245,0.25)" : "rgba(60,60,67,0.3)"
}
let r = Int((c.redComponent * 255).rounded())
let g = Int((c.greenComponent * 255).rounded())
let b = Int((c.blueComponent * 255).rounded())
return "rgba(\(r), \(g), \(b), \(trim(c.alphaComponent)))"
}
/// rgba(...) from a "#RRGGBB" hex and an alpha.
private static func rgba(_ hex: String, alpha: CGFloat) -> String {
guard let (r, g, b) = rgbComponents(hex) else { return hex }
return "rgba(\(r), \(g), \(b), \(trim(alpha)))"
}
private static func rgbComponents(_ hex: String) -> (Int, Int, Int)? {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h.removeFirst() }
guard h.count == 6, let rgb = UInt64(h, radix: 16) else { return nil }
return (Int((rgb >> 16) & 0xFF), Int((rgb >> 8) & 0xFF), Int(rgb & 0xFF))
}
/// Formats a CGFloat without a trailing ".0" so CSS reads cleanly.
private static func trim(_ v: CGFloat) -> String {
v == v.rounded() ? String(Int(v)) : String(format: "%g", v)
}
}
@@ -0,0 +1,153 @@
import AppKit
import WebKit
import UniformTypeIdentifiers
// MARK: - MarkdownPrinter
//
// Export-to-PDF and Print over the same themed HTML the Read mode renders, using
// the same PDF *creator* `WKWebView.printOperation` so both are real vector
// text with native pagination. Export sets `jobDisposition = .save` to write the
// file headlessly; Print shows the system dialog. They remain separate entry
// points so Export can grow its own settings (margins, page size, ) later.
//
// `printOperation` is run via `runModal(for:)` (a nested event loop) rather than
// `op.run()` the latter blocks the main thread inside the WKWebView load
// callback and deadlocks, since WebKit rendering also needs the main thread.
@MainActor
public enum MarkdownPrinter {
/// Prompts for a PDF destination and writes the document as a paginated
/// vector PDF.
public static func exportPDF(markdown: String,
theme: EditorTheme,
callouts: [String: CalloutStyle],
baseURL: URL? = nil,
options: ReadRenderOptions = .default,
suggestedName: String,
window: NSWindow?) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = suggestedName + ".pdf"
let html = DocumentHTML.full(markdown: markdown, theme: theme,
callouts: callouts, dark: false,
baseURL: baseURL, options: options)
let begin: (URL) -> Void = { url in
let info = makePrintInfo()
info.jobDisposition = .save
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = url
PrintJob.start(html: html, parentWindow: window, printInfo: info, showsPanel: false)
}
if let window {
panel.beginSheetModal(for: window) { if $0 == .OK, let url = panel.url { begin(url) } }
} else if panel.runModal() == .OK, let url = panel.url {
begin(url)
}
}
/// Shows the native Print dialog for `markdown`.
public static func print(markdown: String,
theme: EditorTheme,
callouts: [String: CalloutStyle],
baseURL: URL? = nil,
options: ReadRenderOptions = .default,
window: NSWindow?) {
let html = DocumentHTML.full(markdown: markdown, theme: theme,
callouts: callouts, dark: false,
baseURL: baseURL, options: options)
PrintJob.start(html: html, parentWindow: window, printInfo: makePrintInfo(), showsPanel: true)
}
/// US-Letter with 0.75" margins; WKWebView reflows content to the imageable
/// width and paginates.
static func makePrintInfo() -> NSPrintInfo {
let info = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo.shared
let margin: CGFloat = 54
info.topMargin = margin; info.bottomMargin = margin
info.leftMargin = margin; info.rightMargin = margin
info.horizontalPagination = .automatic
info.verticalPagination = .automatic
info.isHorizontallyCentered = false
info.isVerticallyCentered = false
return info
}
}
// MARK: - PrintJob
//
// Loads HTML into a WKWebView placed in a real (off-screen, non-activating)
// window so AppKit can draw it, then runs printOperation via runModal(for:).
// Retains itself until the operation completes.
@MainActor
private final class PrintJob: NSObject, WKNavigationDelegate {
private static var live: Set<PrintJob> = []
private let webView: WKWebView
private let offscreenWindow: NSWindow
private let parentWindow: NSWindow?
private let printInfo: NSPrintInfo
private let showsPanel: Bool
static func start(html: String, parentWindow: NSWindow?,
printInfo: NSPrintInfo, showsPanel: Bool) {
let job = PrintJob(html: html, parentWindow: parentWindow,
printInfo: printInfo, showsPanel: showsPanel)
live.insert(job)
}
private init(html: String, parentWindow: NSWindow?,
printInfo: NSPrintInfo, showsPanel: Bool) {
let config = WKWebViewConfiguration()
config.defaultWebpagePreferences.allowsContentJavaScript = false
webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 1000),
configuration: config)
offscreenWindow = NSWindow(
contentRect: NSRect(x: -20000, y: -20000, width: 800, height: 1000),
styleMask: [.borderless], backing: .buffered, defer: false)
offscreenWindow.isReleasedWhenClosed = false
offscreenWindow.contentView = webView
offscreenWindow.orderBack(nil)
self.parentWindow = parentWindow
self.printInfo = printInfo
self.showsPanel = showsPanel
super.init()
webView.navigationDelegate = self
webView.loadHTMLString(html, baseURL: ReadModeNavigationPolicy.trustedBaseURL)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let op = webView.printOperation(with: printInfo)
op.showsPrintPanel = showsPanel
op.showsProgressPanel = showsPanel
if let parentWindow {
op.runModal(for: parentWindow, delegate: self,
didRun: #selector(printDidRun(_:success:contextInfo:)), contextInfo: nil)
} else {
op.run()
cleanup()
}
}
// QUIRK: for a `.save` (headless export) job, NSPrintOperation runs
// `_continueModalOperationToTheEnd` on a *spawned* thread and invokes this
// didRun callback off the main thread. `cleanup()` closes an NSWindow, which
// must happen on main so this callback is `nonisolated` (otherwise the
// Swift-6 main-actor check traps when AppKit calls it off-main) and hops back
// to the main actor before touching any AppKit state.
@objc nonisolated func printDidRun(_ op: NSPrintOperation, success: Bool,
contextInfo: UnsafeMutableRawPointer?) {
Task { @MainActor in self.cleanup() }
}
private func cleanup() {
offscreenWindow.close()
PrintJob.live.remove(self)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { cleanup() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { cleanup() }
}
@@ -0,0 +1,184 @@
import AppKit
import WebKit
// MARK: - ReadModeWebView
//
// The WKWebView that backs Read mode. It is a pure renderer of the user's own
// document: JavaScript is disabled (plus a `script-src 'none'` CSP meta in the
// page itself), all assets are inlined (so no file/network reach), raw HTML
// passes through per GFM but filtered by `HTMLRenderer.filterRawHTML`
// (tagfilter + hardening), and navigation is intercepted internal scrolling
// stays, external links open in the default browser, and the view never
// navigates away from the rendered document (§G, ARCHITECTURE §10).
//
// The navigation delegate is a *separate* object (not the webview itself). A
// WKWebView that is its own `navigationDelegate` does not reliably receive the
// policy callbacks, so link clicks would navigate in-view instead of opening
// externally; a dedicated, retained coordinator fixes that.
@MainActor
public final class ReadModeWebView: WKWebView {
private let coordinator = ReadModeNavigationCoordinator()
public init() {
let config = WKWebViewConfiguration()
config.defaultWebpagePreferences.allowsContentJavaScript = false
// QUIRK: `isInspectable` (macOS 13.3+) marks the webview as inspectable
// but does NOT add the "Inspect Element" context menu on its own. The
// legacy `developerExtrasEnabled` preference key is what actually shows
// the menu item. Both must be set for right-click Inspect Element to
// work; the developer tools must also be enabled in Safari's settings.
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
super.init(frame: .zero, configuration: config)
coordinator.owner = self
navigationDelegate = coordinator
if #available(macOS 13.3, *) { isInspectable = true }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// Called when the user activates a `[[wikilink]]` the (decoded) target is
/// routed through the app's document graph rather than navigating the webview.
public var onOpenWikiLink: ((String) -> Void)?
/// Called when the user activates a relative/internal markdown link
/// destination (e.g. `[text](other.md)`), routed the same way.
public var onOpenInternalLink: ((String) -> Void)?
/// The most recent render inputs, so the view can re-render itself when the
/// system appearance flips (light dark) without the document re-driving it.
private var pending: (markdown: String, theme: EditorTheme,
callouts: [String: CalloutStyle], baseURL: URL?,
options: ReadRenderOptions)?
/// Renders `markdown` with the given theme; appearance is resolved from the
/// view itself. `baseURL` is the document's directory (for resolving relative
/// image paths to inline).
public func render(markdown: String,
theme: EditorTheme,
callouts: [String: CalloutStyle],
baseURL: URL? = nil,
options: ReadRenderOptions = .default) {
pending = (markdown, theme, callouts, baseURL, options)
reloadHTML()
}
func reloadHTML() {
guard let p = pending else { return }
let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
let html = DocumentHTML.full(markdown: p.markdown, theme: p.theme,
callouts: p.callouts, dark: dark,
baseURL: p.baseURL, options: p.options)
loadHTMLString(html, baseURL: ReadModeNavigationPolicy.trustedBaseURL)
}
public override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
reloadHTML()
}
}
// MARK: - Navigation policy
/// Intercepts navigation for Read mode: the initial load and in-page anchor
/// scrolls proceed; any link the user activates opens in the default browser and
/// the read view stays put.
@MainActor
private final class ReadModeNavigationCoordinator: NSObject, WKNavigationDelegate {
/// Weak back-reference so the coordinator can re-inject HTML on reload
/// without needing the webview to be its own delegate.
weak var owner: ReadModeWebView?
// QUIRK: use the *async* form of this delegate method, not the
// completion-handler form. Under Swift 6 the SDK annotates the
// completion-handler's closure (`@MainActor @Sendable`); a plain
// `@escaping (WKNavigationActionPolicy) -> Void` does NOT match the
// requirement, so the compiler exposes it under the naïve selector
// `webView:decidePolicyFor:decisionHandler:` instead of the real
// `webView:decidePolicyForNavigationAction:decisionHandler:`. WebKit's
// `respondsToSelector:` check then fails and the method is never called
// every link navigates in-view. The async form matches the requirement
// (`webView(_:decidePolicyFor:)`) exactly and registers the correct selector.
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
// QUIRK: the page is loaded with an explicit `about:blank` base URL.
// A user-triggered or WebKit-triggered
// reload navigates back to `about:blank` and clears the content. Intercept
// it and re-inject the HTML ourselves instead of allowing the blank reload.
switch ReadModeNavigationPolicy.decision(for: navigationAction.request.url,
navigationType: navigationAction.navigationType) {
case .reload:
owner?.reloadHTML()
return .cancel
case .openWiki(let target):
owner?.onOpenWikiLink?(target)
return .cancel
case .openInternal(let target):
owner?.onOpenInternalLink?(target)
return .cancel
case .openExternal(let url):
NSWorkspace.shared.open(url)
return .cancel
case .allow:
return .allow
case .cancel:
return .cancel
}
}
}
// MARK: - Navigation classifier
enum ReadModeNavigationPolicy {
static let trustedBaseURL = URL(string: "about:blank")!
enum Decision: Equatable {
case allow
case reload
case openWiki(String)
case openInternal(String)
case openExternal(URL)
case cancel
}
/// Classifies read-mode navigation without touching WebKit/AppKit state. The
/// generated document is self-contained and loaded against `about:blank`, so
/// only in-document anchors, Edmund's private schemes, and browser handoffs are
/// expected. `file:` and other explicit schemes stay out of the webview.
static func decision(for url: URL?, navigationType: WKNavigationType) -> Decision {
if navigationType == .reload { return .reload }
guard let url else { return .allow }
let scheme = url.scheme?.lowercased()
// `[[wikilink]]`s and relative/internal markdown links carry their target
// in a private scheme (the renderer classifies them). Decode the target
// and route it through the app's document graph.
if scheme == HTMLRenderer.wikiScheme {
return .openWiki(decodeTarget(url, scheme: HTMLRenderer.wikiScheme))
}
if scheme == HTMLRenderer.linkScheme {
return .openInternal(decodeTarget(url, scheme: HTMLRenderer.linkScheme))
}
// Decide by URL scheme, not navigation type: WebKit does not reliably
// report `.linkActivated` for every click. Real web schemes are handed to
// the user's browser; `about:` covers the initial document and in-page
// `#fragment` scrolls; anything else is not fetched in the webview.
if scheme == "http" || scheme == "https" || scheme == "mailto" {
return .openExternal(url)
}
if scheme == nil || scheme == "about" {
return .allow
}
return .cancel
}
/// Recovers the percent-decoded target from a private-scheme URL
/// (`scheme:encoded`), which has no `//` authority.
private static func decodeTarget(_ url: URL, scheme: String) -> String {
let raw = String(url.absoluteString.dropFirst(scheme.count + 1))
return raw.removingPercentEncoding ?? raw
}
}
@@ -0,0 +1,34 @@
import Foundation
/// User-configurable options for the Read-mode / export HTML rendering. Kept in
/// EdmundCore (no AppKit/UserDefaults dependency) so the renderer stays pure;
/// the app layer reads the values from `AppSettings` and passes them in.
public struct ReadRenderOptions: Sendable, Equatable {
/// When true, runs of blank lines in the source add proportional vertical
/// space in the output (one extra blank line one extra line of space),
/// preserving the author's intentional spacing instead of collapsing it the
/// way Markdown normally does.
public var preserveBlankLines: Bool
/// When true, remote (`http`/`https`) image URLs are loaded in the rendered
/// document. Off by default so Read mode makes no surprise network requests;
/// local images are always inlined regardless of this flag.
public var allowRemoteImages: Bool
/// The centered reading column's max width in points, matching the editor's
/// `EditorTextView.maxContentWidthPoints` (§EditorTextView+ContentWidth). CSS
/// px and AppKit points are both device-independent, so the same number caps
/// the column to the same physical width in Read mode as in Edit mode.
/// `.greatestFiniteMagnitude` uncapped (fills the page).
public var maxContentWidthPoints: Double
public init(preserveBlankLines: Bool = true, allowRemoteImages: Bool = false,
maxContentWidthPoints: Double = .greatestFiniteMagnitude) {
self.preserveBlankLines = preserveBlankLines
self.allowRemoteImages = allowRemoteImages
self.maxContentWidthPoints = maxContentWidthPoints
}
public static let `default` = ReadRenderOptions()
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
/// What a block *is* at the line/merge level. Advisory metadata captured
/// during parsing used by the recompose engine (e.g. restyling every list
/// block when the document's indent unit changes) and available to future
/// outline/folding features. Styling itself still derives from the block's
/// content, not from this tag.
public enum BlockKind: Equatable, Sendable {
case paragraph
case heading(level: Int)
case quoteRun(isCallout: Bool)
case fence
case indentedCode
case mathDisplay
case table
case listItem
case thematicBreak
case htmlBlock
case blank
}
/// A Block is one paragraph of markdown the unit of rendering.
///
/// Blocks are separated by newlines (`\n`). Each block carries:
/// - `id`: stable UUID so we can track which block the cursor is in
/// - `content`: the raw markdown text of this paragraph
/// - `range`: the character range within the full document string
/// - `kind`: advisory classification (see `BlockKind`)
///
/// The editor renders every block as rich text *except* the one containing
/// the cursor, which stays as raw markdown so the user can edit it.
public struct Block: Identifiable, Sendable {
public var id: UUID
public var content: String
public var range: NSRange
public var kind: BlockKind
/// Whether the text storage currently holds this block's full styling.
/// False = the block is pending lazy styling (base attributes, or stale
/// styling scheduled for the drain). Maintained by the recompose engine.
public var isStyled: Bool
public init(id: UUID = UUID(), content: String, range: NSRange,
kind: BlockKind = .paragraph, isStyled: Bool = false) {
self.id = id
self.content = content
self.range = range
self.kind = kind
self.isStyled = isStyled
}
}
+170
View File
@@ -0,0 +1,170 @@
import Foundation
/// Visual style for a callout type. Fields are plain and serializable (hex
/// strings, enums) so the mapping can be overridden from user settings custom
/// color, icon, border, background. Colors are resolved to `NSColor` at render
/// time, picking the dark variant under a dark appearance when one is provided.
public struct CalloutStyle: Sendable, Equatable {
/// Which edges of the callout draw a border.
public struct Edges: OptionSet, Sendable, Equatable {
public let rawValue: Int
public init(rawValue: Int) { self.rawValue = rawValue }
public static let left = Edges(rawValue: 1 << 0)
public static let top = Edges(rawValue: 1 << 1)
public static let right = Edges(rawValue: 1 << 2)
public static let bottom = Edges(rawValue: 1 << 3)
public static let all: Edges = [.left, .top, .right, .bottom]
}
/// Lucide icon id for the header icon (see `LucideIcons`).
public let iconName: String
/// Accent color (icon + title); border/background default to it.
public let colorHex: String
/// Accent under a dark appearance (defaults to `colorHex`).
public let darkColorHex: String?
/// Explicit border color (defaults to the accent).
public let borderColorHex: String?
public let darkBorderColorHex: String?
/// Explicit background color (defaults to the accent at `backgroundAlpha`).
public let backgroundColorHex: String?
public let darkBackgroundColorHex: String?
/// Alpha used for the derived background when no explicit background is set.
public let backgroundAlpha: CGFloat
/// Which edges draw a border, and how thick.
public let borderEdges: Edges
public let borderWidth: CGFloat
/// Per-icon vertical nudge (points) for optical centering. Lucide icons
/// share a 24×24 box so this is 0 by default. Negative lowers the icon.
public let iconBaselineNudge: CGFloat
public init(iconName: String,
colorHex: String,
darkColorHex: String? = nil,
borderColorHex: String? = nil,
darkBorderColorHex: String? = nil,
backgroundColorHex: String? = nil,
darkBackgroundColorHex: String? = nil,
backgroundAlpha: CGFloat = 0.08,
borderEdges: Edges = [],
borderWidth: CGFloat = 3,
iconBaselineNudge: CGFloat = 0) {
self.iconName = iconName
self.colorHex = colorHex
self.darkColorHex = darkColorHex
self.borderColorHex = borderColorHex
self.darkBorderColorHex = darkBorderColorHex
self.backgroundColorHex = backgroundColorHex
self.darkBackgroundColorHex = darkBackgroundColorHex
self.backgroundAlpha = backgroundAlpha
self.borderEdges = borderEdges
self.borderWidth = borderWidth
self.iconBaselineNudge = iconBaselineNudge
}
/// The accent hex for the given appearance.
public func accentHex(dark: Bool) -> String { (dark ? darkColorHex : nil) ?? colorHex }
/// The border hex for the given appearance (falls back to the accent).
public func resolvedBorderHex(dark: Bool) -> String {
(dark ? darkBorderColorHex : nil) ?? borderColorHex ?? accentHex(dark: dark)
}
/// An explicit background hex for the given appearance, if any (else the
/// renderer derives one from the accent at `backgroundAlpha`).
public func explicitBackgroundHex(dark: Bool) -> String? {
(dark ? darkBackgroundColorHex : nil) ?? backgroundColorHex
}
}
/// GitHub-flavored callouts (a.k.a. admonitions): a block quote whose first line
/// is `[!type]` (case-insensitive), e.g.
///
/// > [!note]
/// > Body text.
///
/// swift-markdown has no native support for this syntax it parses the quote as
/// a plain `BlockQuote`, and its `BlockDirective` feature is the unrelated DocC
/// `@name { }` form so we detect the `[!type]` marker ourselves on top of the
/// existing block-quote span.
public enum Callout {
/// Default type style map (lowercased keys). GitHub's five built-in types
/// keep their accents; the rest are Obsidian's default callouts mapped to the
/// closest color + SF Symbol (with their aliases). Designed to be merged with
/// user overrides.
public static let defaultStyles: [String: CalloutStyle] = {
var m: [String: CalloutStyle] = [:]
func add(_ style: CalloutStyle, _ names: String...) { for n in names { m[n] = style } }
// note same blue as info; tip same teal as abstract (Obsidian-style).
add(CalloutStyle(iconName: "pencil", colorHex: "#086DDD"), "note")
add(CalloutStyle(iconName: "flame", colorHex: "#00BFBC"), "tip", "hint")
add(CalloutStyle(iconName: "message-square-warning", colorHex: "#8250DF", darkColorHex: "#A371F7"), "important")
add(CalloutStyle(iconName: "triangle-alert", colorHex: "#EC7500"), "warning", "attention")
add(CalloutStyle(iconName: "octagon-alert", colorHex: "#CF222E", darkColorHex: "#F85149"), "caution")
// Obsidian's other defaults (closest color + Lucide icon), with aliases.
add(CalloutStyle(iconName: "clipboard-list", colorHex: "#00BFBC"), "abstract", "summary", "tldr")
add(CalloutStyle(iconName: "info", colorHex: "#086DDD"), "info")
add(CalloutStyle(iconName: "circle-dashed", colorHex: "#086DDD"), "todo")
add(CalloutStyle(iconName: "check", colorHex: "#08B94E"), "success", "check", "done")
add(CalloutStyle(iconName: "circle-question-mark", colorHex: "#EC7500"), "question", "help", "faq")
add(CalloutStyle(iconName: "x", colorHex: "#E93147"), "failure", "fail", "missing")
add(CalloutStyle(iconName: "zap", colorHex: "#E93147"), "danger", "error")
add(CalloutStyle(iconName: "bug", colorHex: "#E93147"), "bug")
add(CalloutStyle(iconName: "list", colorHex: "#7852EE"), "example")
add(CalloutStyle(iconName: "quote", colorHex: "#9E9E9E"), "quote", "cite")
return m
}()
/// The style for `type` (case-insensitive), or `nil` if it isn't a known
/// callout type in which case the block stays a plain block quote, matching
/// GitHub. `overrides` lets a future settings layer supply custom types/styles.
public static func style(for type: String,
overrides: [String: CalloutStyle] = [:]) -> CalloutStyle? {
let key = type.lowercased()
return overrides[key] ?? defaultStyles[key]
}
/// The displayed title for a callout: the custom title if the header line has
/// one after `[!type]`, otherwise the capitalized type name so `[!note]`
/// and `[!NOTE]` both render as "Note".
public static func title(type: String, customTitle: String) -> String {
let trimmed = customTitle.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? type.capitalized : trimmed
}
/// A matched `[!type]` marker, with UTF-16 ranges relative to the scanned
/// first-line string.
public struct Marker: Equatable {
public let type: String // lowercased
public let openBracket: NSRange // "[!"
public let typeRange: NSRange // the type word
public let closeBracket: NSRange // "]"
}
/// Matches a callout marker `[!type]` at the start of `firstLine` (a block
/// quote's first line, after its `> `). Returns the lowercased type and the
/// component ranges, or `nil` if there's no marker.
public static func parseMarker(_ firstLine: String) -> Marker? {
let ns = firstLine as NSString
guard let m = markerRegex.firstMatch(
in: firstLine, options: [],
range: NSRange(location: 0, length: ns.length)) else { return nil }
let typeRange = m.range(at: 1)
let type = ns.substring(with: typeRange).lowercased()
return Marker(
type: type,
openBracket: NSRange(location: typeRange.location - 2, length: 2),
typeRange: typeRange,
closeBracket: NSRange(location: typeRange.upperBound, length: 1)
)
}
/// `[!type]` at the very start of the line (optional leading spaces). The
/// type is one or more letters/digits/`-`/`_` beginning with a letter.
private static let markerRegex = try! NSRegularExpression(
pattern: #"^[ \t]*\[!([A-Za-z][A-Za-z0-9_-]*)\]"#)
}
+239
View File
@@ -0,0 +1,239 @@
import AppKit
/// All user-configurable visual settings for the editor.
///
/// Stored as simple types (String, CGFloat) so it serializes cleanly to
/// UserDefaults. Computed properties provide the `NSFont` / `NSColor`
/// equivalents for rendering.
public struct EditorTheme: Equatable, Sendable {
// MARK: - Font
public var fontName: String
public var fontSize: CGFloat
/// Monospaced font for code (inline, blocks, tables). An empty name means the
/// system monospaced font.
public var monospaceFontName: String
public var monospaceFontSize: CGFloat
/// Whether ligatures are enabled for the standard (body) and monospaced fonts.
public var standardLigatures: Bool
public var monospaceLigatures: Bool
/// Whether editor text is antialiased (a single editor-wide setting).
public var antialias: Bool
// MARK: - Colors (hex strings, e.g. "#3366E6")
public var linkBlueHex: String
public var codeHex: String
/// Color for LaTeX operators/commands (`_`, `^`, `\sum`, ) in raw math.
public var mathOperatorHex: String
/// Color for numbers in raw math.
public var mathNumberHex: String
// MARK: - Spacing
public var lineSpacing: CGFloat
public var paragraphSpacingBefore: CGFloat
public init(fontName: String, fontSize: CGFloat, linkBlueHex: String, codeHex: String,
lineSpacing: CGFloat, paragraphSpacingBefore: CGFloat,
mathOperatorHex: String = "#D70015", mathNumberHex: String = "#C77800",
monospaceFontName: String = "", monospaceFontSize: CGFloat = 14,
standardLigatures: Bool = true, monospaceLigatures: Bool = false,
antialias: Bool = true) {
self.fontName = fontName
self.fontSize = fontSize
self.linkBlueHex = linkBlueHex
self.codeHex = codeHex
self.lineSpacing = lineSpacing
self.paragraphSpacingBefore = paragraphSpacingBefore
self.mathOperatorHex = mathOperatorHex
self.mathNumberHex = mathNumberHex
self.monospaceFontName = monospaceFontName
self.monospaceFontSize = monospaceFontSize
self.standardLigatures = standardLigatures
self.monospaceLigatures = monospaceLigatures
self.antialias = antialias
}
// MARK: - Defaults
public static let `default` = EditorTheme(
fontName: "Iowan Old Style",
fontSize: 16,
linkBlueHex: "#3366E6",
codeHex: "#8A2425",
lineSpacing: 4,
paragraphSpacingBefore: 2
)
// MARK: - Derived Properties
@MainActor public var bodyFont: NSFont {
let base = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize)
return Self.applyingLigatures(standardLigatures, to: base)
}
/// The monospaced font, at `size` (default: the theme's monospace size).
/// Falls back to the system monospaced font when no family is set or it can't
/// be loaded.
@MainActor public func monospaceFont(ofSize size: CGFloat? = nil) -> NSFont {
let resolved = size ?? monospaceFontSize
let base: NSFont = {
if !monospaceFontName.isEmpty, let font = NSFont(name: monospaceFontName, size: resolved) {
return font
}
// Default when no family is chosen: Input Mono Narrow, then Input Mono,
// then the system monospaced font all Regular.
for name in ["InputMonoNarrow-Regular", "InputMono-Regular"] {
if let font = NSFont(name: name, size: resolved) { return font }
}
return .monospacedSystemFont(ofSize: resolved, weight: .regular)
}()
return Self.applyingLigatures(monospaceLigatures, to: base)
}
/// Returns `font` with ligatures disabled (when `on` is false) by turning off
/// both common ligatures and contextual alternates in its descriptor the
/// latter is what drives programming ligatures like Fira Code's `=>`/`==`.
/// Baking it into the font (rather than the `.ligature` attribute) is what the
/// editor's TextKit 2 pipeline reliably honors.
private static func applyingLigatures(_ on: Bool, to font: NSFont) -> NSFont {
guard !on else { return font }
let kContextualAlternatesType = 36
let kContextualAlternatesOffSelector = 1
let settings: [[NSFontDescriptor.FeatureKey: Int]] = [
[.typeIdentifier: kLigaturesType, .selectorIdentifier: kCommonLigaturesOffSelector],
[.typeIdentifier: kContextualAlternatesType, .selectorIdentifier: kContextualAlternatesOffSelector],
]
let descriptor = font.fontDescriptor.addingAttributes([.featureSettings: settings])
return NSFont(descriptor: descriptor, size: font.pointSize) ?? font
}
@MainActor public var linkBlueColor: NSColor {
NSColor(hex: linkBlueHex) ?? .systemBlue
}
@MainActor public var codeColor: NSColor {
NSColor(hex: codeHex) ?? .systemRed
}
@MainActor public var mathOperatorColor: NSColor {
NSColor(hex: mathOperatorHex) ?? .systemRed
}
@MainActor public var mathNumberColor: NSColor {
NSColor(hex: mathNumberHex) ?? .systemOrange
}
// MARK: - UserDefaults Persistence
private enum Keys {
static let fontName = "EditorFontName"
static let fontSize = "EditorFontSize"
static let monospaceFontName = "EditorMonospaceFontName"
static let monospaceFontSize = "EditorMonospaceFontSize"
static let standardLigatures = "EditorStandardLigatures"
static let monospaceLigatures = "EditorMonospaceLigatures"
static let antialias = "EditorAntialias"
static let linkBlueHex = "EditorLinkBlueHex"
static let codeHex = "EditorCodeHex"
static let mathOperatorHex = "EditorMathOperatorHex"
static let mathNumberHex = "EditorMathNumberHex"
static let lineSpacing = "EditorLineSpacing"
static let paragraphSpacingBefore = "EditorParagraphSpacingBefore"
}
public static func load(from defaults: UserDefaults = .standard) -> EditorTheme {
let d = defaults
let def = EditorTheme.default
let fontName = d.string(forKey: Keys.fontName) ?? def.fontName
let fontSize: CGFloat = {
let v = CGFloat(d.float(forKey: Keys.fontSize))
return v > 0 ? v : def.fontSize
}()
// The accent color is not user-customizable; always use the default so a
// stale persisted value (e.g. left over from the removed in-app accent
// picker) can't leak in and recolor links.
let linkBlueHex = def.linkBlueHex
let monospaceFontName = d.string(forKey: Keys.monospaceFontName) ?? def.monospaceFontName
let monospaceFontSize: CGFloat = {
let v = CGFloat(d.float(forKey: Keys.monospaceFontSize))
return v > 0 ? v : def.monospaceFontSize
}()
let standardLigatures = d.object(forKey: Keys.standardLigatures) as? Bool ?? def.standardLigatures
let monospaceLigatures = d.object(forKey: Keys.monospaceLigatures) as? Bool ?? def.monospaceLigatures
let antialias = d.object(forKey: Keys.antialias) as? Bool ?? def.antialias
let codeHex = d.string(forKey: Keys.codeHex) ?? def.codeHex
let mathOperatorHex = d.string(forKey: Keys.mathOperatorHex) ?? def.mathOperatorHex
let mathNumberHex = d.string(forKey: Keys.mathNumberHex) ?? def.mathNumberHex
let lineSpacing: CGFloat = d.object(forKey: Keys.lineSpacing) != nil
? CGFloat(d.float(forKey: Keys.lineSpacing))
: def.lineSpacing
let paragraphSpacingBefore: CGFloat = d.object(forKey: Keys.paragraphSpacingBefore) != nil
? CGFloat(d.float(forKey: Keys.paragraphSpacingBefore))
: def.paragraphSpacingBefore
return EditorTheme(
fontName: fontName,
fontSize: fontSize,
linkBlueHex: linkBlueHex,
codeHex: codeHex,
lineSpacing: lineSpacing,
paragraphSpacingBefore: paragraphSpacingBefore,
mathOperatorHex: mathOperatorHex,
mathNumberHex: mathNumberHex,
monospaceFontName: monospaceFontName,
monospaceFontSize: monospaceFontSize,
standardLigatures: standardLigatures,
monospaceLigatures: monospaceLigatures,
antialias: antialias
)
}
public func save(to defaults: UserDefaults = .standard) {
let d = defaults
d.set(fontName, forKey: Keys.fontName)
d.set(Float(fontSize), forKey: Keys.fontSize)
d.set(monospaceFontName, forKey: Keys.monospaceFontName)
d.set(Float(monospaceFontSize), forKey: Keys.monospaceFontSize)
d.set(standardLigatures, forKey: Keys.standardLigatures)
d.set(monospaceLigatures, forKey: Keys.monospaceLigatures)
d.set(antialias, forKey: Keys.antialias)
d.set(linkBlueHex, forKey: Keys.linkBlueHex)
d.set(codeHex, forKey: Keys.codeHex)
d.set(mathOperatorHex, forKey: Keys.mathOperatorHex)
d.set(mathNumberHex, forKey: Keys.mathNumberHex)
d.set(Float(lineSpacing), forKey: Keys.lineSpacing)
d.set(Float(paragraphSpacingBefore), forKey: Keys.paragraphSpacingBefore)
}
}
// MARK: - NSColor Hex Helpers
extension NSColor {
/// Create a color from a hex string like "#3366E6" or "3366E6".
public convenience init?(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h.removeFirst() }
guard h.count == 6, let rgb = UInt64(h, radix: 16) else { return nil }
let r = CGFloat((rgb >> 16) & 0xFF) / 255.0
let g = CGFloat((rgb >> 8) & 0xFF) / 255.0
let b = CGFloat(rgb & 0xFF) / 255.0
self.init(calibratedRed: r, green: g, blue: b, alpha: 1.0)
}
/// Returns the hex string representation (e.g. "#3366E6").
public var hexString: String {
guard let rgb = usingColorSpace(.deviceRGB) else { return "#000000" }
let r = Int(round(rgb.redComponent * 255))
let g = Int(round(rgb.greenComponent * 255))
let b = Int(round(rgb.blueComponent * 255))
return String(format: "#%02X%02X%02X", r, g, b)
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
/// A file's line-ending style. The editor always keeps its buffer in LF
/// internally (so `BlockParser`'s `\n` split is clean and no stray `\r`
/// characters leak into block content); the original style is remembered so
/// it can be written back on save without silently changing the user's file.
public enum LineEnding: String, Sendable {
case lf // "\n"
case crlf // "\r\n"
case cr // "\r"
/// The literal character sequence for this line ending.
public var string: String {
switch self {
case .lf: return "\n"
case .crlf: return "\r\n"
case .cr: return "\r"
}
}
/// Short label for display in the status bar.
public var displayName: String {
switch self {
case .lf: return "LF"
case .crlf: return "CRLF"
case .cr: return "CR"
}
}
/// Detects the line ending used in `text`. CRLF is checked before CR/LF
/// because it contains both. Defaults to `.lf` when there are no breaks.
public static func detect(in text: String) -> LineEnding {
if text.contains("\r\n") { return .crlf }
if text.contains("\r") { return .cr }
return .lf
}
/// Whether `text` mixes more than one line-ending style (e.g. some CRLF and
/// some LF) the case the "inconsistent line endings" warning flags.
public static func isInconsistent(in text: String) -> Bool {
let hasCRLF = text.contains("\r\n")
let withoutCRLF = text.replacingOccurrences(of: "\r\n", with: "")
let hasCR = withoutCRLF.contains("\r")
let hasLF = withoutCRLF.contains("\n")
return [hasCRLF, hasCR, hasLF].filter { $0 }.count > 1
}
/// Converts every line ending in `text` to LF (`\n`).
public static func normalize(_ text: String) -> String {
text.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
}
}
@@ -0,0 +1,95 @@
import Foundation
/// Incremental backing for the document's link reference definitions.
///
/// GFM reference links (`[text][label]`, `[label][]`, `[label]`) resolve
/// against `[label]: destination` definitions that may live in *other* blocks.
/// Edmund styles one block at a time, so the editor collects every definition
/// line here built whole-document on load and maintained per changed block on
/// the edit path (mirroring `ListIndentState`) and appends `defsText` to each
/// block's parse so swift-markdown's CommonMark parser resolves the references
/// (see `SyntaxHighlighter.parse(_:linkDefinitions:)`).
///
/// A multiset of the raw definition *lines* is enough: swift-markdown applies
/// CommonMark's "first definition wins" itself, and `defsText` is sorted so the
/// incremental state and a from-scratch rebuild always produce the identical
/// string (the full-recompose oracle depends on that determinism).
struct LinkDefinitionState: Equatable {
/// Unique `[label]: url` source lines occurrence count, so per-block
/// add/remove stays exact when the same line appears more than once.
private var lines: [String: Int] = [:]
/// The collected definition lines, sorted and newline-joined. Empty when the
/// document defines no references (then parsing skips the append entirely).
var defsText: String { lines.keys.sorted().joined(separator: "\n") }
mutating func add(_ content: String) { scan(content, sign: 1) }
mutating func remove(_ content: String) { scan(content, sign: -1) }
static func build(from source: String) -> LinkDefinitionState {
var state = LinkDefinitionState()
state.add(source)
return state
}
private mutating func scan(_ content: String, sign: Int) {
for line in content.split(separator: "\n", omittingEmptySubsequences: false) {
guard let key = Self.canonicalDefinition(from: String(line)) else { continue }
let count = (lines[key] ?? 0) + sign
lines[key] = count <= 0 ? nil : count
}
}
/// A CommonMark link reference definition line: up to 3 leading spaces, a
/// non-empty `[label]`, `:`, then a destination. (Rare multi-line / titled
/// forms aren't recognized; ponytail: single-line covers the common case.)
private static let defRegex = try! NSRegularExpression(
pattern: #"^ {0,3}\[[^\]\n]+\]:\s*\S.*$"#)
/// One leading list marker (`-`/`*`/`+` or `1.`/`1)`) plus its trailing run.
private static let listMarkerRegex = try! NSRegularExpression(
pattern: #"^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+"#)
static func isDefinitionLine(_ line: String) -> Bool {
canonicalDefinition(from: line) != nil
}
/// The canonical `[label]: destination` line if `line` is a definition,
/// else nil. Definitions may sit inside a block quote or list item and still
/// define references for the whole document (GFM ex. 187), so leading `>`
/// quote markers and one list marker are stripped first; the stripped form is
/// what gets appended and re-parsed, so it must be container-free. The strip
/// only consumes whitespace that belongs to a marker a bare ` [x]: u`
/// (4-space indent) is still rejected as code by `defRegex`.
static func canonicalDefinition(from line: String) -> String? {
var s = Substring(line)
var stripped = false
// Block-quote markers: `>` optionally preceded by 3 spaces and followed
// by one space, repeated for nesting.
while true {
let t = s.drop { $0 == " " || $0 == "\t" }
guard t.first == ">" else { break }
var rest = t.dropFirst()
if rest.first == " " { rest = rest.dropFirst() }
s = rest
stripped = true
}
// One list marker on the same line as the definition.
let ns = String(s) as NSString
if let m = listMarkerRegex.firstMatch(in: String(s), range: NSRange(location: 0, length: ns.length)) {
s = Substring(ns.substring(from: m.range.length))
stripped = true
}
if stripped {
// Drop residual container indentation before the label.
s = s.drop { $0 == " " || $0 == "\t" }
}
let candidate = stripped ? String(s) : line
let cn = candidate as NSString
guard defRegex.firstMatch(in: candidate, range: NSRange(location: 0, length: cn.length)) != nil
else { return nil }
return candidate
}
}
@@ -0,0 +1,49 @@
import Foundation
/// Incremental backing for the document-global list indent unit.
///
/// Replicates `EditorTextView.detectListIndentUnit` exactly any
/// tab-indented list line forces 4; otherwise the smallest space indent of
/// any list line; 4 when none but as a histogram that can be updated per
/// block on the edit path instead of rescanning the whole document per
/// keystroke. Block contents tile the document's lines exactly (merged
/// constructs keep their inner newlines), so adding/removing block contents
/// is equivalent to rescanning those lines.
struct ListIndentState {
private(set) var tabLines = 0
private(set) var histogram: [Int: Int] = [:]
var unit: Int {
if tabLines > 0 { return 4 }
return histogram.keys.min() ?? 4
}
mutating func add(_ content: String) { scan(content, sign: 1) }
mutating func remove(_ content: String) { scan(content, sign: -1) }
static func build(from source: String) -> ListIndentState {
var state = ListIndentState()
state.add(source)
return state
}
private mutating func scan(_ content: String, sign: Int) {
for line in content.split(separator: "\n", omittingEmptySubsequences: false) {
var spaces = 0
var sawTab = false
for ch in line {
if ch == " " { spaces += 1 }
else if ch == "\t" { sawTab = true; break }
else { break }
}
let rest = line.drop(while: { $0 == " " || $0 == "\t" })
guard EditorTextView.startsWithListMarker(rest) else { continue }
if sawTab {
tabLines += sign
} else if spaces > 0 {
let count = (histogram[spaces] ?? 0) + sign
histogram[spaces] = count <= 0 ? nil : count
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import AppKit
// MARK: - LucideIcons
//
// Vendored [Lucide](https://lucide.dev) icons, used for callout headers and
// Read-mode checkboxes. We vendor the SVG markup (rather than SF Symbols)
// because Read mode / PDF export *redistributes* the rendered icons, and the SF
// Symbols license forbids distributing those symbols in print form. Lucide is
// ISC-licensed (a few icons MIT, via Feather) both permit redistribution; the
// notices live in `LICENSES/lucide.txt`.
//
// Only each icon's inner geometry is stored; `inlineSVG`/`image` wrap it in a
// 24×24, stroke-based `<svg>` matching Lucide's canonical form. One source feeds
// both back-ends: Read mode inlines the SVG (vector, CSS-tinted via
// `currentColor`); Edit mode rasterizes it to a tinted `NSImage` overlay.
enum LucideIcons {
/// Lucide icon id inner SVG geometry, verbatim from lucide.dev (v ISC).
/// Keys match `CalloutStyle.iconName` plus the checkbox primitives.
static let geometry: [String: String] = [
"pencil": #"<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>"#,
"flame": #"<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>"#,
"message-square-warning": #"<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/><path d="M12 15h.01"/><path d="M12 7v4"/>"#,
"triangle-alert": #"<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>"#,
"octagon-alert": #"<path d="M12 16h.01"/><path d="M12 8v4"/><path d="M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z"/>"#,
"clipboard-list": #"<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>"#,
"info": #"<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>"#,
"circle-dashed": #"<path d="M10.1 2.182a10 10 0 0 1 3.8 0"/><path d="M13.9 21.818a10 10 0 0 1-3.8 0"/><path d="M17.609 3.721a10 10 0 0 1 2.69 2.7"/><path d="M2.182 13.9a10 10 0 0 1 0-3.8"/><path d="M20.279 17.609a10 10 0 0 1-2.7 2.69"/><path d="M21.818 10.1a10 10 0 0 1 0 3.8"/><path d="M3.721 6.391a10 10 0 0 1 2.7-2.69"/><path d="M6.391 20.279a10 10 0 0 1-2.69-2.7"/>"#,
"check": #"<path d="M20 6 9 17l-5-5"/>"#,
"circle-question-mark": #"<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>"#,
"x": #"<path d="M18 6 6 18"/><path d="m6 6 12 12"/>"#,
"zap": #"<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>"#,
"bug": #"<path d="M12 20v-9"/><path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/><path d="M14.12 3.88 16 2"/><path d="M21 21a4 4 0 0 0-3.81-4"/><path d="M21 5a4 4 0 0 1-3.55 3.97"/><path d="M22 13h-4"/><path d="M3 21a4 4 0 0 1 3.81-4"/><path d="M3 5a4 4 0 0 0 3.55 3.97"/><path d="M6 13H2"/><path d="m8 2 1.88 1.88"/><path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>"#,
"list": #"<path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/>"#,
"quote": #"<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>"#,
"circle": #"<circle cx="12" cy="12" r="10"/>"#,
"image-off": #"<line x1="2" x2="22" y1="2" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" x2="6" y1="13.5" y2="21"/><line x1="18" x2="21" y1="12" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/>"#,
]
/// Raw `<svg></svg>` with `stroke="currentColor"` for inlining into HTML;
/// the host CSS supplies the color. Returns `nil` for an unknown id.
static func inlineSVG(_ name: String) -> String? {
guard let g = geometry[name] else { return nil }
return strokeSVG(geometry: g, stroke: "currentColor")
}
/// An `NSImage` of the icon stroked in `color`, sized to a `pointSize`
/// square. Renders the SVG (in black) then tints with `.sourceIn` so the
/// glyph matches `color` exactly regardless of the SVG decoder's color space
/// the same technique the PDF icon path used. `sourceIn` (not
/// `sourceAtop`) matters when `color` is itself translucent (e.g. a dynamic
/// system color like `.secondaryLabelColor`): `sourceIn`'s result alpha is
/// `color.alpha * baseGlyphAlpha`, so the tint's own translucency survives;
/// `sourceAtop` keeps only the base glyph's alpha, silently discarding the
/// tint's alpha invisible with the opaque theme colors this was first
/// used with, but it flattens a translucent tint to solid opaque. `nil` for
/// an unknown id or if the platform SVG decoder can't build the image.
static func image(_ name: String, color: NSColor, pointSize: CGFloat) -> NSImage? {
guard let g = geometry[name],
let data = strokeSVG(geometry: g, stroke: "#000000").data(using: .utf8),
let base = NSImage(data: data) else { return nil }
base.cacheMode = .never // re-rasterize the SVG at each draw scale (crisp on Retina)
let box = NSSize(width: pointSize, height: pointSize)
let image = NSImage(size: box, flipped: false) { rect in
base.draw(in: rect)
color.setFill()
NSGraphicsContext.current?.cgContext.setBlendMode(.sourceIn)
rect.fill()
return true
}
image.cacheMode = .never
return image
}
/// The icon's stroke geometry as a CGPath in Lucide's canonical 24×24,
/// y-down viewBox space (stroke it with width 2, round caps/joins, to
/// match the rendered SVG). Used where the icon must be drawn as a
/// *shape*, not an image an image on a wrapping TextKit 2 fragment
/// wedges its layout to one line (see FragmentOverlay). `nil` for an
/// unknown id.
static func path(_ name: String) -> CGPath? {
guard let g = geometry[name] else { return nil }
return SVGPath.path(fromGeometry: g)
}
/// Read-mode checkbox markup mirroring the editor's look. Unchecked: a
/// stroked `circle`. Checked: a disc filled in `currentColor` (CSS supplies
/// the accent) with a white check on top. The themeable part uses
/// `currentColor`; the check is a literal white so it reads on the disc.
static func checkboxSVG(checked: Bool) -> String {
if checked {
return ##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="currentColor"/><path d="m7.5 12.5 3 3 6-7" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>"##
}
return #"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/></svg>"#
}
/// Wraps inner `geometry` in Lucide's canonical stroke-based `<svg>`.
private static func strokeSVG(geometry: String, stroke: String) -> String {
#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke=""#
+ stroke
+ #"" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">"#
+ geometry
+ "</svg>"
}
}
+278
View File
@@ -0,0 +1,278 @@
import CoreGraphics
import Foundation
// MARK: - SVGPath
//
// Minimal SVG CGPath converter for the vendored Lucide icon geometry
// (`LucideIcons.geometry`). Exists so the editor can draw a callout icon as a
// *stroked vector path* instead of an NSImage: drawing an image on a wrapping,
// multi-line TextKit 2 layout fragment wedges that fragment's layout to a
// single line, while shape drawing does not (see
// docs/investigations/archives/callout-title-wrap-investigation.md). Supports exactly what the
// vendored geometry uses: `<path>`, `<circle>`, and `<rect>` elements, and the
// full SVG path-data command set. Coordinates stay in the icons' 24×24,
// y-down viewBox space; callers scale to the target size.
enum SVGPath {
/// Parses a fragment of SVG markup (one or more `<path>`/`<circle>`/
/// `<rect>` elements) into a single CGPath in viewBox coordinates.
/// Returns `nil` if nothing parseable is found.
static func path(fromGeometry svg: String) -> CGPath? {
let result = CGMutablePath()
let elementRegex = try! NSRegularExpression(pattern: #"<(path|circle|rect)\b([^>]*?)/?>"#)
let attrRegex = try! NSRegularExpression(pattern: #"([\w-]+)="([^"]*)""#)
let ns = svg as NSString
for m in elementRegex.matches(in: svg, range: NSRange(location: 0, length: ns.length)) {
let tag = ns.substring(with: m.range(at: 1))
let attrString = ns.substring(with: m.range(at: 2))
var attrs: [String: String] = [:]
let ans = attrString as NSString
for am in attrRegex.matches(in: attrString,
range: NSRange(location: 0, length: ans.length)) {
attrs[ans.substring(with: am.range(at: 1))] = ans.substring(with: am.range(at: 2))
}
func num(_ key: String) -> CGFloat? { attrs[key].flatMap { Double($0) }.map { CGFloat($0) } }
switch tag {
case "path":
if let d = attrs["d"], let p = path(fromData: d) { result.addPath(p) }
case "circle":
if let cx = num("cx"), let cy = num("cy"), let r = num("r") {
result.addEllipse(in: CGRect(x: cx - r, y: cy - r, width: 2 * r, height: 2 * r))
}
case "rect":
if let w = num("width"), let h = num("height") {
let rect = CGRect(x: num("x") ?? 0, y: num("y") ?? 0, width: w, height: h)
let rx = num("rx") ?? num("ry") ?? 0
let ry = num("ry") ?? rx
if rx > 0 || ry > 0 {
result.addRoundedRect(in: rect, cornerWidth: rx, cornerHeight: ry)
} else {
result.addRect(rect)
}
}
default:
break
}
}
return result.isEmpty ? nil : result
}
/// Parses an SVG path-data string (the `d` attribute) into a CGPath.
static func path(fromData d: String) -> CGPath? {
var scanner = NumberScanner(d)
let path = CGMutablePath()
var current = CGPoint.zero
var subpathStart = CGPoint.zero
// Reflection anchors for S/T smooth curves.
var lastCubicControl: CGPoint?
var lastQuadControl: CGPoint?
var lastCommand: Character = " "
while let command = scanner.nextCommand() {
let relative = command.isLowercase
let cmd = Character(command.uppercased())
// Each iteration of the repeat loop consumes one parameter set;
// SVG allows implicit command repetition until a new letter.
repeat {
func point() -> CGPoint? {
guard let x = scanner.nextNumber(), let y = scanner.nextNumber() else { return nil }
return relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
}
switch cmd {
case "M":
guard let p = point() else { return nil }
path.move(to: p); current = p; subpathStart = p
// Subsequent implicit pairs are LineTos.
while scanner.peekNumber() {
guard let q = point() else { return nil }
path.addLine(to: q); current = q
}
case "L":
guard let p = point() else { return nil }
path.addLine(to: p); current = p
case "H":
guard let x = scanner.nextNumber() else { return nil }
current.x = relative ? current.x + x : x
path.addLine(to: current)
case "V":
guard let y = scanner.nextNumber() else { return nil }
current.y = relative ? current.y + y : y
path.addLine(to: current)
case "C":
guard let c1 = point(), let c2 = point(), let p = point() else { return nil }
path.addCurve(to: p, control1: c1, control2: c2)
current = p; lastCubicControl = c2
case "S":
// First control point reflects the previous cubic's second
// control about the current point (or is the current point).
let c1: CGPoint
if "CS".contains(lastCommand), let prev = lastCubicControl {
c1 = CGPoint(x: 2 * current.x - prev.x, y: 2 * current.y - prev.y)
} else {
c1 = current
}
guard let c2 = point(), let p = point() else { return nil }
path.addCurve(to: p, control1: c1, control2: c2)
current = p; lastCubicControl = c2
case "Q":
guard let c = point(), let p = point() else { return nil }
path.addQuadCurve(to: p, control: c)
current = p; lastQuadControl = c
case "T":
let c: CGPoint
if "QT".contains(lastCommand), let prev = lastQuadControl {
c = CGPoint(x: 2 * current.x - prev.x, y: 2 * current.y - prev.y)
} else {
c = current
}
guard let p = point() else { return nil }
path.addQuadCurve(to: p, control: c)
current = p; lastQuadControl = c
case "A":
guard let rx = scanner.nextNumber(), let ry = scanner.nextNumber(),
let rot = scanner.nextNumber(),
let largeArc = scanner.nextNumber(), let sweep = scanner.nextNumber(),
let end = point() else { return nil }
addArc(to: path, from: current, rx: rx, ry: ry,
xAxisRotationDegrees: rot,
largeArc: largeArc != 0, sweep: sweep != 0, end: end)
current = end
case "Z":
path.closeSubpath()
current = subpathStart
default:
return nil
}
if !"CS".contains(cmd) { lastCubicControl = nil }
if !"QT".contains(cmd) { lastQuadControl = nil }
lastCommand = cmd
} while cmd != "Z" && scanner.peekNumber()
}
return path.isEmpty ? nil : path
}
/// SVG elliptical arc → cubic Béziers, via the endpoint-to-center
/// conversion in SVG spec appendix B.2.4, splitting into ≤90° segments.
private static func addArc(to path: CGMutablePath, from start: CGPoint,
rx: CGFloat, ry: CGFloat, xAxisRotationDegrees: CGFloat,
largeArc: Bool, sweep: Bool, end: CGPoint) {
if start == end { return }
var rx = abs(rx), ry = abs(ry)
if rx == 0 || ry == 0 { path.addLine(to: end); return }
let phi = xAxisRotationDegrees * .pi / 180
let cosPhi = cos(phi), sinPhi = sin(phi)
// (x1', y1'): midpoint vector rotated into the ellipse frame.
let dx = (start.x - end.x) / 2, dy = (start.y - end.y) / 2
let x1p = cosPhi * dx + sinPhi * dy
let y1p = -sinPhi * dx + cosPhi * dy
// Scale radii up if the endpoints can't be spanned (spec F.6.6).
let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
if lambda > 1 {
let s = sqrt(lambda)
rx *= s; ry *= s
}
// Center in the ellipse frame (spec F.6.5.2).
let rx2 = rx * rx, ry2 = ry * ry, x1p2 = x1p * x1p, y1p2 = y1p * y1p
var radicand = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2)
radicand = max(0, radicand)
let coef = (largeArc != sweep ? 1 : -1) * sqrt(radicand)
let cxp = coef * (rx * y1p / ry)
let cyp = coef * -(ry * x1p / rx)
// Center in user space.
let cx = cosPhi * cxp - sinPhi * cyp + (start.x + end.x) / 2
let cy = sinPhi * cxp + cosPhi * cyp + (start.y + end.y) / 2
func angle(_ ux: CGFloat, _ uy: CGFloat, _ vx: CGFloat, _ vy: CGFloat) -> CGFloat {
let dot = ux * vx + uy * vy
let len = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
var a = acos(min(1, max(-1, dot / len)))
if ux * vy - uy * vx < 0 { a = -a }
return a
}
let theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
var delta = angle((x1p - cxp) / rx, (y1p - cyp) / ry,
(-x1p - cxp) / rx, (-y1p - cyp) / ry)
if !sweep && delta > 0 { delta -= 2 * .pi }
if sweep && delta < 0 { delta += 2 * .pi }
// Approximate each ≤90° slice with one cubic.
let segments = max(1, Int(ceil(abs(delta) / (.pi / 2))))
let segmentDelta = delta / CGFloat(segments)
// Control-point distance for a cubic approximating a unit arc.
let t = 4 / 3 * tan(segmentDelta / 4)
var theta = theta1
for _ in 0..<segments {
let thetaNext = theta + segmentDelta
func onEllipse(_ a: CGFloat) -> CGPoint {
CGPoint(x: cx + rx * cos(a) * cosPhi - ry * sin(a) * sinPhi,
y: cy + rx * cos(a) * sinPhi + ry * sin(a) * cosPhi)
}
// Derivative (tangent) at the segment endpoints.
func tangent(_ a: CGFloat) -> CGPoint {
CGPoint(x: -rx * sin(a) * cosPhi - ry * cos(a) * sinPhi,
y: -rx * sin(a) * sinPhi + ry * cos(a) * cosPhi)
}
let p0 = onEllipse(theta), p1 = onEllipse(thetaNext)
let t0 = tangent(theta), t1 = tangent(thetaNext)
path.addCurve(to: p1,
control1: CGPoint(x: p0.x + t * t0.x, y: p0.y + t * t0.y),
control2: CGPoint(x: p1.x - t * t1.x, y: p1.y - t * t1.y))
theta = thetaNext
}
}
/// Lexer for SVG path data: commands are single letters; numbers may be
/// packed together (`-.5.83` is 0.5 then 0.83 — a second `.` starts a new
/// number), separated by whitespace or commas.
private struct NumberScanner {
private let chars: [Character]
private var index = 0
init(_ s: String) { chars = Array(s) }
private mutating func skipSeparators() {
while index < chars.count, chars[index] == " " || chars[index] == "," ||
chars[index] == "\n" || chars[index] == "\t" || chars[index] == "\r" {
index += 1
}
}
mutating func nextCommand() -> Character? {
skipSeparators()
guard index < chars.count, chars[index].isLetter else { return nil }
defer { index += 1 }
return chars[index]
}
/// True if a number (not a command letter) comes next.
mutating func peekNumber() -> Bool {
skipSeparators()
guard index < chars.count else { return false }
let c = chars[index]
return c.isNumber || c == "-" || c == "+" || c == "."
}
mutating func nextNumber() -> CGFloat? {
skipSeparators()
var s = ""
guard index < chars.count else { return nil }
if chars[index] == "-" || chars[index] == "+" { s.append(chars[index]); index += 1 }
var seenDot = false
while index < chars.count {
let c = chars[index]
if c.isNumber { s.append(c); index += 1 }
else if c == ".", !seenDot { seenDot = true; s.append(c); index += 1 }
else { break }
}
return Double(s).map { CGFloat($0) }
}
}
}
@@ -0,0 +1,62 @@
import Foundation
/// Persisted user preferences for the status bar: whether it auto-hides (the
/// default reveal on hover) or stays visible, and which fields are shown.
/// Backed by `UserDefaults` so the choice survives relaunches.
public struct StatusBarPrefs: Equatable, Sendable {
/// When true (the default), the bar is hidden and revealed on hover.
/// When false, it stays visible.
public var autoHide: Bool
public var showWords: Bool
public var showCharacters: Bool
public var showLocation: Bool
public var showLine: Bool
public var showLineEnding: Bool
public init(autoHide: Bool = true,
showWords: Bool = true,
showCharacters: Bool = true,
showLocation: Bool = true,
showLine: Bool = true,
showLineEnding: Bool = true) {
self.autoHide = autoHide
self.showWords = showWords
self.showCharacters = showCharacters
self.showLocation = showLocation
self.showLine = showLine
self.showLineEnding = showLineEnding
}
private enum Key {
static let configured = "statusBar.configured"
static let autoHide = "statusBar.autoHide"
static let showWords = "statusBar.showWords"
static let showCharacters = "statusBar.showCharacters"
static let showLocation = "statusBar.showLocation"
static let showLine = "statusBar.showLine"
static let showLineEnding = "statusBar.showLineEnding"
}
/// Loads saved preferences, or the defaults if nothing was ever saved.
public static func load(from defaults: UserDefaults = .standard) -> StatusBarPrefs {
guard defaults.bool(forKey: Key.configured) else { return StatusBarPrefs() }
return StatusBarPrefs(
autoHide: defaults.bool(forKey: Key.autoHide),
showWords: defaults.bool(forKey: Key.showWords),
showCharacters: defaults.bool(forKey: Key.showCharacters),
showLocation: defaults.bool(forKey: Key.showLocation),
showLine: defaults.bool(forKey: Key.showLine),
showLineEnding: defaults.bool(forKey: Key.showLineEnding)
)
}
public func save(to defaults: UserDefaults = .standard) {
defaults.set(true, forKey: Key.configured)
defaults.set(autoHide, forKey: Key.autoHide)
defaults.set(showWords, forKey: Key.showWords)
defaults.set(showCharacters, forKey: Key.showCharacters)
defaults.set(showLocation, forKey: Key.showLocation)
defaults.set(showLine, forKey: Key.showLine)
defaults.set(showLineEnding, forKey: Key.showLineEnding)
}
}
@@ -0,0 +1,714 @@
import Foundation
import Markdown
/// Splits a document string into `Block`s and preserves block identity
/// across re-parses so the "active block" doesn't jump around.
///
/// Strategy:
/// 1. Split the raw string on single newlines (`\n`) to get paragraphs,
/// tagging each with its `BlockKind`.
/// 2. Compute each paragraph's `NSRange` within the full string.
/// 3. Preserve UUIDs positionally: blocks in the unchanged prefix and
/// suffix (by content equality from both ends) keep their previous IDs;
/// the changed window in between gets fresh ones. The window is also the
/// exact set of blocks whose styling may have changed the dirty set
/// the recompose engine restyles.
public enum BlockParser {
public static func parse(_ text: String, previous: [Block] = []) -> [Block] {
parseWithDiff(text, previous: previous).blocks
}
/// Parses `text` and returns the blocks plus the changed window: the range
/// of indices (in the new list) outside the unchanged prefix/suffix.
public static func parseWithDiff(
_ text: String, previous: [Block] = []
) -> (blocks: [Block], changed: Range<Int>) {
let nsText = text as NSString
let paragraphs = splitParagraphs(text)
var blocks: [Block] = []
blocks.reserveCapacity(paragraphs.count)
var cursor = 0
for (para, kind) in paragraphs {
let length = (para as NSString).length
let range = NSRange(location: cursor, length: length)
blocks.append(Block(content: para, range: range, kind: kind))
// Advance past this paragraph.
cursor = range.upperBound
// Skip the single \n separator (if present).
if cursor < nsText.length && nsText.character(at: cursor) == UInt16(0x0A) {
cursor += 1
}
}
let changed = assignIdentity(old: previous, new: &blocks)
return (blocks, changed)
}
/// Re-parses only the lines affected by an edit, splicing the untouched
/// prefix and (shifted) suffix of the previous parse around the re-split
/// window O(edit), not O(document).
///
/// The window starts one block before the first affected block: every
/// merge rule needs at most that much left context (quote-run adjacency,
/// the indented-code prevLine check, and the table-separator /
/// setext-underline lookaheads are single-step; an unclosed
/// fence/math opener further up would already contain the edit inside its
/// merged block). Downstream, the re-split continues until a produced
/// block boundary lands on an old block start at/after the edit's end
/// from there the old parse is provably identical, because a block's
/// parse depends only on its own and following lines.
///
/// Returns nil when the inputs don't allow it (caller falls back to the
/// full parse).
public static func incrementalParse(
text: String,
old: [Block],
editedOldRange: NSRange,
delta: Int
) -> (blocks: [Block], changed: Range<Int>)? {
guard !old.isEmpty else { return nil }
let newLength = (text as NSString).length
let oldLength = newLength - delta
guard editedOldRange.location >= 0,
editedOldRange.upperBound <= oldLength else { return nil }
guard let firstAffected = blockIndex(in: old, forOffset: editedOldRange.location)
else { return nil }
var windowStartIndex = max(0, firstAffected - 1)
// A setext underline merges the whole run of single-line paragraph
// blocks above it into one heading (consumeBlock's multi-line setext
// scan), so the window must start before that entire run, not just
// one block back keep walking back while the block at the window
// start is itself a `.paragraph` block. This is a superset of the
// one-block-back rule above (a non-paragraph block one back leaves
// the loop immediately), so every other merge rule stays covered.
while windowStartIndex > 0, case .paragraph = old[windowStartIndex].kind {
windowStartIndex -= 1
}
let windowStartOffset = old[windowStartIndex].range.location
let editEndNew = editedOldRange.upperBound + delta
var buf = LineBuffer(text, from: windowStartOffset)
var window: [Block] = []
var cursor = windowStartOffset // new-coords offset of the next block
var lineIndex = 0
var suffixStart: Int? = nil // old block index to splice from
// Backward context for the indented-code rule. The block before the
// window is untouched by the edit, so its old content is current.
var prevLine: String? = windowStartIndex > 0
? lastLine(of: old[windowStartIndex - 1].content) : nil
while true {
// Resync probe at this block boundary (not at the initial one).
if cursor > windowStartOffset && cursor >= editEndNew {
let oldOffset = cursor - delta
if let j = blockIndex(in: old, forOffset: oldOffset),
old[j].range.location == oldOffset,
oldOffset >= editedOldRange.upperBound {
// An indented-code-ish start depends on the line above it
// (blank vs not), which the edit may have changed the
// old parse from here isn't provably identical. Bail to
// the full parse (rare and cheap).
if isIndentedCodeLine(firstLine(of: old[j].content)) { return nil }
// Same bail for HTML-block-ish starts: type 7 depends on the
// line above (which the edit may have changed), so an old
// block whose first line looks like ANY html-block opener
// isn't provably re-derivable full parse (rare and cheap).
// prevLine: nil = most permissive check.
if htmlBlockStart(firstLine(of: old[j].content), prevLine: nil) != nil { return nil }
suffixStart = j
break
}
}
guard let (content, kind, next) = consumeBlock(&buf, at: lineIndex,
prevLine: prevLine) else {
break // end of document: the window runs to the end
}
let length = (content as NSString).length
window.append(Block(content: content,
range: NSRange(location: cursor, length: length),
kind: kind))
lineIndex = next
prevLine = lastLine(of: content)
cursor += length
// Skip the `\n` separator if another line follows; otherwise this
// was the document's final block stop before re-probing (the
// boundary we'd probe is the block we just consumed).
if buf.line(at: next) != nil { cursor += 1 } else { break }
}
// Trim unchanged leading window blocks (the lookback block usually
// re-parses identically): preserve their identity and styling, and
// keep the changed window tight.
let spliceLimit = suffixStart ?? old.count
var keep = 0
while keep < window.count, windowStartIndex + keep < spliceLimit,
old[windowStartIndex + keep].content == window[keep].content {
window[keep].id = old[windowStartIndex + keep].id
window[keep].isStyled = old[windowStartIndex + keep].isStyled
keep += 1
}
var blocks = Array(old[0..<windowStartIndex])
blocks.append(contentsOf: window)
if let s = suffixStart {
for j in s..<old.count {
var b = old[j]
b.range.location += delta
blocks.append(b)
}
}
return (blocks, (windowStartIndex + keep) ..< (windowStartIndex + window.count))
}
/// Binary search over sorted, adjacent block ranges the same
/// inclusive-upper-bound semantics as the editor's lookup (an offset at a
/// block's trailing separator maps to that block; past-end clamps to last).
private static func blockIndex(in blocks: [Block], forOffset offset: Int) -> Int? {
guard !blocks.isEmpty else { return nil }
var lo = 0
var hi = blocks.count - 1
while lo < hi {
let mid = (lo + hi) / 2
if blocks[mid].range.upperBound < offset { lo = mid + 1 } else { hi = mid }
}
return lo
}
/// Positional prefix/suffix diff: scans content equality from the front
/// and the back, copies old IDs onto the matches, and returns the changed
/// window in new-list indices. O(unchanged + changed); never matches a
/// block across the edit (no cross-document ID stealing).
static func assignIdentity(old: [Block], new: inout [Block]) -> Range<Int> {
var prefix = 0
while prefix < old.count && prefix < new.count
&& old[prefix].content == new[prefix].content {
new[prefix].id = old[prefix].id
new[prefix].isStyled = old[prefix].isStyled
prefix += 1
}
var suffix = 0
let maxSuffix = min(old.count, new.count) - prefix // overlap clamp
while suffix < maxSuffix
&& old[old.count - 1 - suffix].content == new[new.count - 1 - suffix].content {
new[new.count - 1 - suffix].id = old[old.count - 1 - suffix].id
new[new.count - 1 - suffix].isStyled = old[old.count - 1 - suffix].isStyled
suffix += 1
}
return prefix ..< (new.count - suffix)
}
// MARK: - Helpers
/// Lazily materializes the `\n`-separated line segments of a text starting
/// at a given UTF-16 offset. `components(separatedBy: "\n")` semantics:
/// number of segments = number of newlines + 1, so a trailing `\n` yields
/// a final empty segment. Both the full and incremental parses consume
/// lines through this buffer, so they cannot diverge.
struct LineBuffer {
private let ns: NSString
private(set) var lines: [String] = []
private var nextOffset: Int
private var exhausted = false
/// Setext-scan memo: a failed underline scan that terminated at line
/// `k` proves no scan starting before `k` can find an underline (the
/// intervening lines are all plain paragraphs and `k` isn't an
/// underline), so `consumeBlock` skips the scan for start lines below
/// this bound. Without it, a long blank-line-free paragraph run makes
/// the parse quadratic (each line re-scans to the run's end).
var noSetextUnderlineBefore = 0
init(_ text: String, from offset: Int = 0) {
self.ns = text as NSString
self.nextOffset = offset
}
/// The line at index `i` (buffer-relative), fetching as needed.
/// Returns nil past the end of the text.
mutating func line(at i: Int) -> String? {
while lines.count <= i && !exhausted {
fetchNext()
}
return i < lines.count ? lines[i] : nil
}
private mutating func fetchNext() {
guard !exhausted else { return }
let remaining = NSRange(location: nextOffset, length: ns.length - nextOffset)
let nl = ns.range(of: "\n", options: [], range: remaining)
if nl.location == NSNotFound {
lines.append(ns.substring(with: remaining))
exhausted = true
} else {
lines.append(ns.substring(
with: NSRange(location: nextOffset, length: nl.location - nextOffset)))
nextOffset = nl.upperBound
if nextOffset == ns.length {
// Trailing newline: one final empty segment.
lines.append("")
exhausted = true
}
}
}
}
/// Consumes one block starting at line `i`, merging multi-line constructs
/// (fences, display math, quote runs, tables, indented code runs, setext
/// headings). `prevLine` is the last line before `i` (nil at document
/// start) the only backward context any rule uses: an indented code
/// block may start only after a blank line. Returns the block's
/// content/kind and the index of the line after it.
static func consumeBlock(_ buf: inout LineBuffer, at i: Int, prevLine: String?)
-> (content: String, kind: BlockKind, next: Int)? {
guard let first = buf.line(at: i) else { return nil }
// Detect opening code fence
if let fence = codeFenceInfo(first) {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j) {
merged.append(line)
j += 1
if isClosingFence(line, char: fence.char, count: fence.count) {
break
}
}
return (merged.joined(separator: "\n"), .fence, j)
}
// Detect display-math fence: a line starting with `$$`.
if let closedOnSameLine = displayMathClosedOnSameLine(first) {
if closedOnSameLine {
return (first, .mathDisplay, i + 1)
}
var merged = [first]
var j = i + 1
while let line = buf.line(at: j) {
merged.append(line)
j += 1
if line.contains("$$") { break }
}
return (merged.joined(separator: "\n"), .mathDisplay, j)
}
// Merge block-quote lines into one block (the editor's styling /
// activation unit per quote).
if isBlockquoteLine(first) {
// Callouts stay strict: only consecutive `>` lines. Lazy
// continuation is deliberately suppressed so a following `> [!type]`
// can't be pulled into a prior callout's paragraph (GFM ex. 228).
// Read mode matches this (HTMLRenderer.renderCallout splits at the
// first non-`>` line).
if quoteRunOpensCallout(first) {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j), isBlockquoteLine(line) {
merged.append(line)
j += 1
}
return (merged.joined(separator: "\n"), .quoteRun(isCallout: true), j)
}
// Plain block quote: honor CommonMark lazy continuation (a bare
// non-blank line after a quote paragraph joins the quote). The
// extent depends only on this line and following lines up to the
// next blank (a blank always ends a quote), so the parse stays
// forward-only and the incremental invariant holds.
if let (content, next) = mergePlainQuote(&buf, at: i) {
return (content, .quoteRun(isCallout: false), next)
}
// Fallback (candidate's first child wasn't a BlockQuote shouldn't
// happen): strict `>`-run.
var merged = [first]
var j = i + 1
while let line = buf.line(at: j), isBlockquoteLine(line) {
merged.append(line)
j += 1
}
return (merged.joined(separator: "\n"), .quoteRun(isCallout: false), j)
}
// Detect table: header row followed by separator row with the same
// cell count (GFM: a mismatched delimiter row isn't a table at all).
if isTableRow(first), let second = buf.line(at: i + 1), isTableSeparator(second),
splitTableRow(first).count == splitTableRow(second).count {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j), isTableRow(line) || isTableSeparator(line) {
merged.append(line)
j += 1
}
return (merged.joined(separator: "\n"), .table, j)
}
// Indented code block (GFM): a run of lines indented 4+ spaces (or a
// tab), starting only after a blank line / document start so list
// continuation text isn't swallowed. Deeply indented list items keep
// priority (the indentedListRegex rescue deliberate divergence).
// Interior blank lines belong to the block (GFM Examples 82/87); a
// run of blanks only ends the block if code doesn't resume after
// them trailing blanks stay separate `.blank` blocks.
if isIndentedCodeLine(first), prevLine == nil || isBlankLine(prevLine!) {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j) {
if isIndentedCodeLine(line) {
merged.append(line)
j += 1
continue
}
guard isBlankLine(line) else { break }
var k = j
while let blank = buf.line(at: k), isBlankLine(blank) { k += 1 }
guard let resumed = buf.line(at: k), isIndentedCodeLine(resumed) else { break }
for m in j..<k { merged.append(buf.line(at: m)!) }
j = k
}
return (merged.joined(separator: "\n"), .indentedCode, j)
}
// GFM §4.6 HTML block. Types 15 scan forward for the end-condition line
// (included; the end may already be on the start line; unterminated runs
// to EOF spec: end of document closes it). Types 6/7 end BEFORE the
// first blank line (the blank stays its own `.blank` block). Type 7
// can't interrupt a paragraph htmlBlockStart gates it on prevLine.
// Placement: after indented code (the 3-space guard keeps a 4-space-
// indented `<div>` as indented code); a `<`-line forming a valid table
// header+separator still becomes a table (deliberate divergence, tables
// win this branch sits below the table branch); must precede the
// setext scan so an HTML start isn't swallowed as heading content.
// Edit mode shows the block as colored SOURCE (read mode renders it)
// same split as GitHub's editor; rendered HTML in edit mode is
// impossible under the storage==rawSource invariant.
if let type = htmlBlockStart(first, prevLine: prevLine) {
var merged = [first]
var j = i + 1
switch type {
case .scriptPreStyle, .comment, .processing, .declaration, .cdata:
if !htmlBlockEnds(first, type: type) {
while let line = buf.line(at: j) {
merged.append(line)
j += 1
if htmlBlockEnds(line, type: type) { break }
}
}
case .blockTag, .completeTag:
while let line = buf.line(at: j), !isBlankLine(line) {
merged.append(line)
j += 1
}
}
return (merged.joined(separator: "\n"), .htmlBlock, j)
}
// Setext heading: a paragraph line underlined by `===` (h1) or `---`
// (h2). Consuming the underline here means a `---` after a paragraph
// is a heading underline (GFM setext wins over thematic break); only
// a `---` after a blank line / non-paragraph stays a rule.
//
// The underline can follow any number of plain paragraph lines, not
// just the first (GFM Example 51: "Foo\nbar\n---" is one heading
// whose content is "Foo\nbar") so scan forward through a run of
// paragraph lines looking for the underline, checking each line for
// a setext underline *before* classifying it (an underline reads as
// `.paragraph`/`.thematicBreak` under `classifyLine`, and must
// terminate-and-merge the run rather than continue or break it). A
// table start also breaks the run, mirroring the table branch above
// so a table isn't swallowed as heading content. If no underline is
// found, fall through and return just `first` as a single-line
// paragraph block Edmund deliberately keeps one block per
// paragraph line when there's no setext underline beneath it.
if case .paragraph = classifyLine(first), i >= buf.noSetextUnderlineBefore {
var j = i + 1
while let line = buf.line(at: j) {
if let level = setextUnderlineLevel(line) {
let merged = (i...j).map { buf.line(at: $0)! }
return (merged.joined(separator: "\n"), .heading(level: level), j + 1)
}
guard case .paragraph = classifyLine(line) else { break }
if isTableRow(line), let next = buf.line(at: j + 1), isTableSeparator(next),
splitTableRow(line).count == splitTableRow(next).count {
break
}
// An HTML block start (types 16 interrupt paragraphs; type 7 is
// gated on the previous line) terminates the run, mirroring the
// table break above otherwise "Foo\n<div>\n---" would merge
// into a setext heading instead of paragraph + HTML block
// (GFM: the `---` belongs to the HTML block).
if htmlBlockStart(line, prevLine: buf.line(at: j - 1)) != nil { break }
j += 1
}
// No underline: everything up to the terminator at `j` is plain
// paragraph lines, so no scan starting before `j` can succeed.
buf.noSetextUnderlineBefore = j
}
return (first, classifyLine(first), i + 1)
}
/// Splits text into paragraphs on single newlines, merging fenced code blocks
/// and table rows into single multi-line blocks. Each paragraph is tagged
/// with its `BlockKind`.
private static func splitParagraphs(_ text: String) -> [(content: String, kind: BlockKind)] {
if text.isEmpty { return [("", .blank)] }
var buf = LineBuffer(text)
var result: [(content: String, kind: BlockKind)] = []
var i = 0
var prevLine: String? = nil
while let (content, kind, next) = consumeBlock(&buf, at: i, prevLine: prevLine) {
result.append((content, kind))
i = next
prevLine = lastLine(of: content)
}
return result
}
/// The text after the last `\n` (the whole string when single-line)
/// the `prevLine` context for the block that follows.
private static func lastLine(of content: String) -> String {
if let nl = content.range(of: "\n", options: .backwards) {
return String(content[nl.upperBound...])
}
return content
}
/// The text before the first `\n` (the whole string when single-line).
private static func firstLine(of content: String) -> String {
if let nl = content.range(of: "\n") {
return String(content[..<nl.lowerBound])
}
return content
}
// MARK: - Line Classification
/// Classifies a single (non-merged) line. Advisory: see `BlockKind`.
private static func classifyLine(_ line: String) -> BlockKind {
if line.allSatisfy({ $0 == " " || $0 == "\t" }) { return .blank }
let trimmed = line.drop(while: { $0 == " " })
let hashes = trimmed.prefix(while: { $0 == "#" }).count
if (1...6).contains(hashes),
trimmed.count == hashes || trimmed.dropFirst(hashes).first == " " {
return .heading(level: hashes)
}
if isThematicBreakLine(line) { return .thematicBreak }
if isListLine(line) { return .listItem }
return .paragraph
}
/// Returns true if the line is a bullet (`- `, `* `, `+ `) or ordered
/// (`1. `, `1) `) list item, with any leading-space indent.
static func isListLine(_ line: String) -> Bool {
let trimmed = line.drop(while: { $0 == " " })
if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
return true
}
let digits = trimmed.prefix(while: { $0.isNumber })
guard !digits.isEmpty else { return false }
let rest = trimmed.dropFirst(digits.count)
return rest.hasPrefix(". ") || rest.hasPrefix(") ")
}
/// Returns the heading level if the line is a setext underline: 3 leading
/// spaces, then 1+ of the same character (`=` level 1, `-` level 2),
/// then only trailing spaces/tabs. Internal spaces (`- - -`) disqualify it,
/// so a spaced thematic break after a paragraph stays a rule.
private static func setextUnderlineLevel(_ line: String) -> Int? {
let trimmed = line.drop(while: { $0 == " " })
guard line.count - trimmed.count <= 3,
let first = trimmed.first, first == "=" || first == "-" else { return nil }
let run = trimmed.prefix(while: { $0 == first })
guard trimmed.dropFirst(run.count).allSatisfy({ $0 == " " || $0 == "\t" }) else { return nil }
return first == "=" ? 1 : 2
}
/// Returns true if the line opens/continues an indented code block: some
/// content indented by 4 spaces or a tab, that isn't a deeply indented
/// list item (the indentedListRegex rescue keeps priority).
static func isIndentedCodeLine(_ line: String) -> Bool {
guard !isBlankLine(line) else { return false }
let indent = line.prefix(while: { $0 == " " || $0 == "\t" })
guard indent.contains("\t") || indent.count >= 4 else { return false }
let range = NSRange(location: 0, length: (line as NSString).length)
return SyntaxHighlighter.indentedListRegex.firstMatch(in: line, range: range) == nil
}
private static func isBlankLine(_ line: String) -> Bool {
line.allSatisfy { $0 == " " || $0 == "\t" }
}
/// GFM §4.6 HTML block start conditions.
enum HTMLBlockType {
case scriptPreStyle // 1: <script|<pre|<style ends ON the line containing </script>|</pre>|</style>
case comment // 2: <!-- ends ON the line containing -->
case processing // 3: <? ends ON the line containing ?>
case declaration // 4: <! + ASCII uppercase ends ON the line containing >
case cdata // 5: <![CDATA[ ends ON the line containing ]]>
case blockTag // 6: one of the 62 block tag names ends BEFORE a blank line
case completeTag // 7: a complete lone tag ends BEFORE a blank line; can't interrupt a paragraph
}
// Tag set pinned to CommonMark 0.29 / GFM (script|pre|style no textarea,
// which later CommonMark added); deliberate, documented in ARCHITECTURE §10.
private static let htmlType1Regex = try! NSRegularExpression(
pattern: #"^ {0,3}<(?:script|pre|style)(?:[ \t>]|$)"#, options: [.caseInsensitive])
private static let htmlType6Regex = try! NSRegularExpression(
pattern: #"^ {0,3}</?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:[ \t]|/?>|$)"#,
options: [.caseInsensitive])
/// One COMPLETE open tag (full §6.10 attribute grammar quoted values may
/// contain `>`) or closing tag, alone on the line. Check order 17 means a
/// normal `<script >` is always claimed by type 1 first; the one leak is a
/// self-closing `<script/>` lone tag, which spec calls a paragraph but we
/// call type 7 deliberate, harmless divergence (ARCHITECTURE §10).
private static let htmlType7Regex = try! NSRegularExpression(
pattern: #"^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*/?>|</[A-Za-z][A-Za-z0-9-]*\s*>)[ \t]*$"#)
/// GFM §4.6: the HTML-block type `line` opens, or nil. `prevLine` gates type 7
/// (it cannot interrupt a paragraph same backward context as indented code).
/// O(line), and only `<`-prefixed lines get past the cheap guard.
static func htmlBlockStart(_ line: String, prevLine: String?) -> HTMLBlockType? {
let trimmed = line.drop(while: { $0 == " " })
guard line.count - trimmed.count <= 3, trimmed.first == "<" else { return nil }
let range = NSRange(location: 0, length: (line as NSString).length)
if htmlType1Regex.firstMatch(in: line, range: range) != nil { return .scriptPreStyle }
if trimmed.hasPrefix("<!--") { return .comment }
if trimmed.hasPrefix("<?") { return .processing }
if trimmed.hasPrefix("<![CDATA[") { return .cdata }
if trimmed.hasPrefix("<!"), let c = trimmed.dropFirst(2).first,
c.isASCII, c.isUppercase { return .declaration }
if htmlType6Regex.firstMatch(in: line, range: range) != nil { return .blockTag }
if prevLine == nil || isBlankLine(prevLine!),
htmlType7Regex.firstMatch(in: line, range: range) != nil { return .completeTag }
return nil
}
/// End condition for types 15 (the matching line is INCLUDED in the block).
private static func htmlBlockEnds(_ line: String, type: HTMLBlockType) -> Bool {
switch type {
case .scriptPreStyle:
let l = line.lowercased()
return l.contains("</script>") || l.contains("</pre>") || l.contains("</style>")
case .comment: return line.contains("-->")
case .processing: return line.contains("?>")
case .declaration: return line.contains(">")
case .cdata: return line.contains("]]>")
case .blockTag, .completeTag: return false
}
}
/// Returns true for a thematic break: 3+ of the same `-`/`*`/`_` character
/// and nothing else but spaces.
private static func isThematicBreakLine(_ line: String) -> Bool {
let stripped = line.filter { $0 != " " && $0 != "\t" }
guard stripped.count >= 3, let first = stripped.first,
first == "-" || first == "*" || first == "_" else { return false }
return stripped.allSatisfy { $0 == first }
}
/// Returns true if the first line of a quote run opens a callout
/// (`> [!type]`, known or unknown type).
private static func quoteRunOpensCallout(_ firstLine: String) -> Bool {
let trimmed = firstLine.drop(while: { $0 == " " })
guard trimmed.first == ">" else { return false }
return Callout.parseMarker(String(trimmed.dropFirst())) != nil
}
/// If the line (after optional leading whitespace) starts with `$$`, returns
/// whether a second `$$` also appears on the same line (a one-line `$$$$`
/// block). Returns nil when the line is not a display-math opener.
private static func displayMathClosedOnSameLine(_ line: String) -> Bool? {
let trimmed = line.drop(while: { $0 == " " })
guard trimmed.hasPrefix("$$") else { return nil }
return trimmed.dropFirst(2).contains("$$")
}
/// Returns true if the line is a block-quote line (optional leading spaces
/// then `>`).
private static func isBlockquoteLine(_ line: String) -> Bool {
return line.drop(while: { $0 == " " }).first == ">"
}
/// Extent of a plain block quote starting at line `i`, honoring CommonMark
/// lazy continuation. Gathers the run of non-blank candidate lines (a blank
/// always ends a quote), parses them with swift-markdown, and truncates the
/// quote to the first `BlockQuote` node's line span so the exact rules
/// (heading/list/fence/thematic-break interrupt; an empty `>` closes the
/// paragraph; a bare paragraph line continues it) come straight from the
/// CommonMark parser, matching read mode. Returns the quote content and the
/// index of the line after it, or nil when the candidate's first child
/// isn't a BlockQuote (caller falls back to the strict `>`-run).
private static func mergePlainQuote(_ buf: inout LineBuffer, at i: Int)
-> (content: String, next: Int)? {
var candidate: [String] = []
var j = i
while let line = buf.line(at: j), !isBlankLine(line) {
candidate.append(line)
j += 1
}
let doc = Document(parsing: candidate.joined(separator: "\n"),
options: [.disableSmartOpts])
guard doc.child(at: 0) is BlockQuote else { return nil }
// The candidate has no blank lines, so its top-level blocks are
// contiguous: the quote spans lines 1 ..< (second child's start line).
// swift-markdown line numbers are 1-based within the candidate, and
// candidate line 1 == buffer line `i`.
let quoteLineCount: Int
if doc.childCount > 1, let nextStart = doc.child(at: 1)?.range?.lowerBound.line {
quoteLineCount = min(max(nextStart - 1, 1), candidate.count)
} else {
quoteLineCount = candidate.count
}
let content = candidate[0..<quoteLineCount].joined(separator: "\n")
return (content, i + quoteLineCount)
}
/// Returns true if the line contains a pipe character (potential table row).
private static func isTableRow(_ line: String) -> Bool {
return line.contains("|")
}
/// Returns true if the line is a table separator (e.g., "| --- | --- |").
private static func isTableSeparator(_ line: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.contains("|") && trimmed.contains("---") else { return false }
return trimmed.allSatisfy { "|:- \t".contains($0) }
}
/// Returns fence info (character and count) if the line is an opening code fence.
private static func codeFenceInfo(_ line: String) -> (char: Character, count: Int)? {
let trimmed = line.drop(while: { $0 == " " })
let leadingSpaces = line.count - trimmed.count
guard leadingSpaces <= 3 else { return nil }
guard let first = trimmed.first, (first == "`" || first == "~") else { return nil }
let count = trimmed.prefix(while: { $0 == first }).count
guard count >= 3 else { return nil }
if first == "`" {
let afterFence = trimmed.dropFirst(count)
if afterFence.contains("`") { return nil }
}
return (first, count)
}
/// Returns true if the line is a valid closing fence for the given char/count.
private static func isClosingFence(_ line: String, char: Character, count: Int) -> Bool {
let trimmed = line.drop(while: { $0 == " " })
let leadingSpaces = line.count - trimmed.count
guard leadingSpaces <= 3 else { return false }
guard let first = trimmed.first, first == char else { return false }
let fenceCount = trimmed.prefix(while: { $0 == char }).count
guard fenceCount >= count else { return false }
let after = trimmed.dropFirst(fenceCount)
return after.allSatisfy { $0 == " " || $0 == "\t" }
}
}
@@ -0,0 +1,135 @@
import Foundation
// MARK: - Code Highlighter
//
// A small, language-agnostic tokenizer for fenced code blocks. It is not a full
// parser it recognizes the lexical tokens that carry most of the visual
// signal (comments, strings, numbers, keywords, types, function calls) across
// the common C-family / script languages. The block's info string picks the
// comment style (`#` vs `//`), everything else is shared.
enum CodeHighlighter {
enum TokenType: Equatable {
case keyword, type, string, number, comment, function
}
struct Token: Equatable {
let range: NSRange
let type: TokenType
}
/// Languages whose line comments start with `#` rather than `//`.
private static let hashCommentLanguages: Set<String> = [
"python", "py", "ruby", "rb", "sh", "bash", "shell", "zsh", "fish",
"yaml", "yml", "toml", "ini", "perl", "pl", "r", "makefile", "make",
"dockerfile", "docker", "elixir", "ex", "nim", "julia", "jl", "tcl",
]
/// A union of frequent keywords across popular languages. Over-inclusive is
/// fine: a keyword that doesn't apply to the current language simply won't
/// appear in its code.
private static let keywords: Set<String> = [
// declarations / control flow (shared across many languages)
"func", "function", "fn", "def", "let", "var", "val", "const", "static",
"final", "class", "struct", "enum", "interface", "protocol", "trait",
"impl", "extends", "implements", "namespace", "package", "module", "mod",
"import", "export", "from", "use", "using", "include", "require",
"public", "private", "protected", "internal", "fileprivate", "open",
"if", "else", "elif", "for", "while", "do", "switch", "case", "default",
"break", "continue", "return", "yield", "goto", "match", "when", "where",
"try", "catch", "except", "finally", "throw", "throws", "raise", "rescue",
"guard", "defer", "async", "await", "go", "chan", "select", "with", "as",
"is", "in", "of", "new", "delete", "typeof", "instanceof", "sizeof",
"void", "int", "long", "short", "char", "float", "double", "bool",
"boolean", "string", "unsigned", "signed", "auto", "typedef", "template",
"virtual", "override", "abstract", "extension", "init", "self", "this",
"super", "nil", "null", "none", "undefined", "true", "false", "and",
"or", "not", "lambda", "pass", "global", "nonlocal", "mut", "pub", "dyn",
"type", "object", "end", "begin", "then", "elsif", "unless", "until",
]
static func tokenize(_ code: String, language: String?) -> [Token] {
let ns = code as NSString
let n = ns.length
guard n > 0 else { return [] }
let lang = language?
.trimmingCharacters(in: .whitespaces).lowercased() ?? ""
let hashComments = hashCommentLanguages.contains(lang)
func isIdentStart(_ c: unichar) -> Bool {
(c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 95
}
func isIdentChar(_ c: unichar) -> Bool { isIdentStart(c) || (c >= 48 && c <= 57) }
func isDigit(_ c: unichar) -> Bool { c >= 48 && c <= 57 }
var tokens: [Token] = []
var i = 0
while i < n {
let c = ns.character(at: i)
// Line comment: `//` always, `#` for hash-comment languages.
if (c == 0x2F && i + 1 < n && ns.character(at: i + 1) == 0x2F)
|| (c == 0x23 && hashComments) {
let start = i
while i < n && ns.character(at: i) != 0x0A { i += 1 }
tokens.append(Token(range: NSRange(location: start, length: i - start), type: .comment))
continue
}
// Block comment `/* */`.
if c == 0x2F && i + 1 < n && ns.character(at: i + 1) == 0x2A {
let start = i; i += 2
while i + 1 < n && !(ns.character(at: i) == 0x2A && ns.character(at: i + 1) == 0x2F) { i += 1 }
i = min(n, i + 2)
tokens.append(Token(range: NSRange(location: start, length: i - start), type: .comment))
continue
}
// String: "", '', `` with backslash escapes; stops at end of line.
if c == 0x22 || c == 0x27 || c == 0x60 {
let quote = c, start = i; i += 1
while i < n {
let d = ns.character(at: i)
if d == 0x5C { i += 2; continue } // escape
if d == quote { i += 1; break }
if d == 0x0A { break }
i += 1
}
tokens.append(Token(range: NSRange(location: start, length: min(i, n) - start), type: .string))
continue
}
// Number (incl. trailing hex/exponent/dot chars).
if isDigit(c) {
let start = i; i += 1
while i < n {
let d = ns.character(at: i)
if isIdentChar(d) || d == 0x2E { i += 1 } else { break }
}
tokens.append(Token(range: NSRange(location: start, length: i - start), type: .number))
continue
}
// Identifier keyword / type / function call / plain.
if isIdentStart(c) {
let start = i; i += 1
while i < n && isIdentChar(ns.character(at: i)) { i += 1 }
let word = ns.substring(with: NSRange(location: start, length: i - start))
let range = NSRange(location: start, length: i - start)
if keywords.contains(word) {
tokens.append(Token(range: range, type: .keyword))
} else if let first = word.unicodeScalars.first, first.properties.isUppercase {
tokens.append(Token(range: range, type: .type))
} else {
// Function call: identifier immediately followed by `(`.
var j = i
while j < n && ns.character(at: j) == 0x20 { j += 1 }
if j < n && ns.character(at: j) == 0x28 {
tokens.append(Token(range: range, type: .function))
}
}
continue
}
i += 1
}
return tokens
}
}
@@ -0,0 +1,47 @@
import Foundation
// MARK: - Code Syntax Palette
//
// The single source of truth for fenced-code-block colors: the Tomorrow palette
// (light) and One Dark (dark), as plain hex strings with no AppKit dependency.
// Both consumers derive from these:
// - the editor (`EditorTextView+CodeHighlighting`) builds `NSColor`s for the
// TextKit attribute run,
// - Read mode / PDF export (`HTMLTheme`) emits CSS `color` rules,
// so Edit mode and Read mode color identical tokens identically.
enum CodeSyntaxPalette {
/// The hex color for a token kind (`nil` = plain, un-tokenized code text) in
/// the given appearance. Only foregrounds are themed; the block keeps its
/// background, so each palette is paired with the appearance it's legible on.
static func hex(_ type: CodeHighlighter.TokenType?, dark: Bool) -> String {
dark ? oneDark(type) : tomorrow(type)
}
/// Tomorrow (light).
private static func tomorrow(_ type: CodeHighlighter.TokenType?) -> String {
switch type {
case nil: return "#4d4d4c"
case .keyword: return "#8959a8"
case .type: return "#c18401"
case .string: return "#718c00"
case .number: return "#f5871f"
case .comment: return "#8e908c"
case .function: return "#4271ae"
}
}
/// One Dark.
private static func oneDark(_ type: CodeHighlighter.TokenType?) -> String {
switch type {
case nil: return "#abb2bf"
case .keyword: return "#c678dd"
case .type: return "#e5c07b"
case .string: return "#98c379"
case .number: return "#d19a66"
case .comment: return "#5c6370"
case .function: return "#61afef"
}
}
}
@@ -0,0 +1,727 @@
import Foundation
import Markdown
// MARK: - Custom Parsers
//
// Regex / scan-based passes for inline constructs that swift-markdown does not
// model. Each appends to the span list built by the AST walker (see parse()):
//
// - parseHighlight ==text==
// - parseDisplayMath $$\u{2026}$$ (block pre-merged by BlockParser)
// - parseMath $\u{2026}$ (Pandoc-style disambiguation)
// - parseLineBreak trailing backslash hard break
// - parseIndentedListItem 4+ space list items swift-markdown treats as code
extension SyntaxHighlighter {
private static let footnoteDefRegex =
try! NSRegularExpression(pattern: #"^\[\^([^\]\s]+)\]:"#)
private static let footnoteRefRegex =
try! NSRegularExpression(pattern: #"\[\^([^\]\s]+)\]"#)
/// Parses footnotes (not supported by swift-markdown):
/// - `[^id]:` at the start of a block a `.footnoteDefinition` marker.
/// - `[^id]` elsewhere a `.footnoteReference`.
static func parseFootnotes(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
let whole = NSRange(location: 0, length: ns.length)
// Definition marker at the very start of the block: `[^id]:`.
if let m = footnoteDefRegex.firstMatch(in: text, range: whole) {
let marker = m.range(at: 0) // includes the trailing ":"
spans.append(Span(
kind: .footnoteDefinition(id: ns.substring(with: m.range(at: 1))),
fullRange: marker,
contentRange: m.range(at: 1),
delimiterRanges: [marker]))
}
// References `[^id]` anywhere except the definition marker (followed by
// ":") and anything overlapping a code span or the definition above.
for m in footnoteRefRegex.matches(in: text, range: whole) {
let full = m.range(at: 0)
if full.upperBound < ns.length && ns.character(at: full.upperBound) == 0x3A { continue }
let overlaps = spans.contains { existing in
switch existing.kind {
case .code, .codeBlock, .footnoteDefinition: break
default: return false
}
return existing.fullRange.location <= full.location
&& existing.fullRange.upperBound >= full.upperBound
}
guard !overlaps else { continue }
spans.append(Span(
kind: .footnoteReference(id: ns.substring(with: m.range(at: 1))),
fullRange: full,
contentRange: m.range(at: 1), // the id
delimiterRanges: [NSRange(location: full.location, length: 2), // "[^"
NSRange(location: full.upperBound - 1, length: 1)])) // "]"
}
}
private static let commentRegex =
try! NSRegularExpression(pattern: "%%([\\s\\S]*?)%%", options: [])
/// Parses Obsidian-style `%%comment%%` spans (not supported by
/// swift-markdown). Matches across newlines within a block; skips `%%`
/// inside code spans / code blocks.
static func parseComments(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
for m in commentRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
let full = m.range(at: 0)
let overlaps = spans.contains { existing in
switch existing.kind {
case .code, .codeBlock: break
default: return false
}
return existing.fullRange.location <= full.location
&& existing.fullRange.upperBound >= full.upperBound
}
guard !overlaps else { continue }
spans.append(Span(
kind: .comment,
fullRange: full,
contentRange: m.range(at: 1),
delimiterRanges: [NSRange(location: full.location, length: 2),
NSRange(location: full.upperBound - 2, length: 2)]))
}
}
private static let htmlCommentRegex =
try! NSRegularExpression(pattern: "<!--[\\s\\S]*?-->")
/// Parses HTML `<!-- comment -->` spans into the same `.comment` kind as
/// `%%%%` (dimmed in edit mode, hidden in reading view; inner spans are
/// dropped by the opaque-range pass). Skips comments inside code / math.
static func parseHTMLComments(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
for m in htmlCommentRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
let full = m.range(at: 0)
let overlaps = spans.contains { existing in
switch existing.kind {
case .code, .codeBlock, .math: break
default: return false
}
return existing.fullRange.location <= full.location
&& existing.fullRange.upperBound >= full.upperBound
}
guard !overlaps else { continue }
spans.append(Span(
kind: .comment,
fullRange: full,
contentRange: NSRange(location: full.location + 4, length: full.length - 7),
delimiterRanges: [NSRange(location: full.location, length: 4),
NSRange(location: full.upperBound - 3, length: 3)]))
}
}
private static let wikiLinkRegex =
try! NSRegularExpression(pattern: #"\[\[([^\[\]\n]+?)\]\]"#)
/// Parses Obsidian-style `[[target]]`, `[[target#heading]]`, and
/// `[[target|alias]]` internal links. The span's `contentRange` is the
/// visible display text (the alias when present, else the target); the
/// `[[`, an optional `target|`, and the `]]` are delimiter ranges hidden
/// when rendered. Skips `[[` inside code spans / code blocks.
static func parseWikiLinks(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
for m in wikiLinkRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
let full = m.range(at: 0)
let inner = m.range(at: 1)
let innerNS = ns.substring(with: inner) as NSString
guard innerNS.length > 0 else { continue }
let overlaps = spans.contains { existing in
switch existing.kind {
case .code, .codeBlock: break
default: return false
}
return existing.fullRange.location <= full.location
&& existing.fullRange.upperBound >= full.upperBound
}
guard !overlaps else { continue }
// Split target | alias on the first "|".
let pipe = innerNS.range(of: "|")
let targetRel = pipe.location == NSNotFound
? NSRange(location: 0, length: innerNS.length)
: NSRange(location: 0, length: pipe.location)
var displayRel = pipe.location == NSNotFound
? targetRel
: NSRange(location: pipe.upperBound, length: innerNS.length - pipe.upperBound)
if displayRel.length == 0 { displayRel = targetRel } // "[[Note|]]" show target
let target = innerNS.substring(with: targetRel).trimmingCharacters(in: .whitespaces)
guard !target.isEmpty || pipe.location != NSNotFound else { continue }
let content = NSRange(location: inner.location + displayRel.location, length: displayRel.length)
let leading = NSRange(location: full.location, length: content.location - full.location)
let trailing = NSRange(location: content.upperBound, length: full.upperBound - content.upperBound)
spans.append(Span(
kind: .wikilink(target: target),
fullRange: full,
contentRange: content,
delimiterRanges: [leading, trailing]))
}
}
/// Parses ==highlight== spans using regex (not supported by swift-markdown).
/// GFM-style flanking: the content must not begin or end with whitespace
/// (`== spaced ==` stays literal), matching how cmark treats `**`/`~~`.
static func parseHighlight(_ text: String, into spans: inout [Span]) {
let nsText = text as NSString
guard let regex = try? NSRegularExpression(pattern: "==(?!\\s)(.+?)(?<!\\s)==", options: []) else { return }
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
for match in matches {
let full = match.range(at: 0)
let content = match.range(at: 1)
// Skip if overlapping with a code span
let overlaps = spans.contains { existing in
existing.kind == .code &&
existing.fullRange.location <= full.location &&
existing.fullRange.upperBound >= full.upperBound
}
guard !overlaps else { continue }
let openDelim = NSRange(location: full.location, length: 2)
let closeDelim = NSRange(location: full.upperBound - 2, length: 2)
spans.append(Span(
kind: .highlight,
fullRange: full,
contentRange: content,
delimiterRanges: [openDelim, closeDelim]
))
}
}
/// Scans for `$$$$` display math runs. A run can own its whole block
/// (`BlockParser` merges a multi-line `$$ $$` into one block, so content
/// may span newlines) or sit inline within a prose line (`text $$x$$ more`).
///
/// Tightness (space/tab, NOT newline) guards against prose false positives
/// like "pay $$5 and $$6": a `$$` delimiter must abut non-space on the inner
/// side mirrors the Pandoc rule in `parseMath`. Newlines are allowed so a
/// block-merged `$$\n \n$$` still matches. Runs before `parseMath`, which
/// skips ranges inside a `.math(display: true)` span.
static func parseDisplayMath(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
let n = ns.length
let dollar: unichar = 0x24, backslash: unichar = 0x5C
// Same-line whitespace only; newlines are legal inside a display block.
func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 }
var i = 0
while i < n {
let c = ns.character(at: i)
if c == backslash { i += 2; continue } // skip escaped char
// Opening `$$`, abutting a non-space on its inner side.
guard c == dollar, i + 1 < n, ns.character(at: i + 1) == dollar else { i += 1; continue }
let afterOpen = i + 2
guard afterOpen < n, !isSpace(ns.character(at: afterOpen)) else { i += 1; continue }
// Find the closing `$$`, abutting a non-space on its inner side.
var j = afterOpen
var closeLoc = -1
while j + 1 < n {
let cj = ns.character(at: j)
if cj == backslash { j += 2; continue }
if cj == dollar && ns.character(at: j + 1) == dollar {
if !isSpace(ns.character(at: j - 1)) { closeLoc = j; break }
j += 2; continue // `$$` preceded by space isn't a valid close
}
j += 1
}
guard closeLoc > afterOpen else { i += 2; continue } // no close / empty content
spans.append(Span(
kind: .math(display: true),
fullRange: NSRange(location: i, length: closeLoc + 2 - i),
contentRange: NSRange(location: afterOpen, length: closeLoc - afterOpen),
delimiterRanges: [NSRange(location: i, length: 2),
NSRange(location: closeLoc, length: 2)]
))
i = closeLoc + 2
}
}
/// Scans for inline `$$` math. Uses Pandoc-style disambiguation so prose
/// like "it cost $5 to $10" is left alone:
/// - the opening `$` is immediately followed by a non-space, non-`$` char,
/// - the closing `$` is immediately preceded by a non-space char and is
/// not followed by a digit,
/// - `\$` is a literal escape, `$$` is skipped (display math, later phase),
/// - inline math never spans a newline.
static func parseMath(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
let n = ns.length
let dollar: unichar = 0x24, backslash: unichar = 0x5C, newline: unichar = 0x0A
func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 }
func isDigit(_ c: unichar) -> Bool { c >= 0x30 && c <= 0x39 }
var i = 0
while i < n {
let c = ns.character(at: i)
if c == backslash { i += 2; continue } // skip escaped char
if c != dollar { i += 1; continue }
// Skip display `$$` (handled per-block in a later phase).
if i + 1 < n && ns.character(at: i + 1) == dollar { i += 2; continue }
// Opening `$`: must be followed by a non-space, non-`$` character.
guard i + 1 < n else { break }
let next = ns.character(at: i + 1)
if isSpace(next) || next == dollar || next == newline { i += 1; continue }
// Find the closing `$`.
var j = i + 1
var close = -1
while j < n {
let cj = ns.character(at: j)
if cj == backslash { j += 2; continue }
if cj == newline { break } // inline math stays on one line
if cj == dollar {
let prev = ns.character(at: j - 1)
let isDouble = j + 1 < n && ns.character(at: j + 1) == dollar
let nextIsDigit = j + 1 < n && isDigit(ns.character(at: j + 1))
if !isDouble && !isSpace(prev) && !nextIsDigit { close = j; break }
}
j += 1
}
guard close > i + 1 else { i += 1; continue }
let full = NSRange(location: i, length: close - i + 1)
// Don't match inside code spans or a display-math block.
let overlaps = spans.contains { existing in
switch existing.kind {
case .code, .codeBlock, .math(display: true):
return existing.fullRange.location <= full.location
&& existing.fullRange.upperBound >= full.upperBound
default:
return false
}
}
if !overlaps {
spans.append(Span(
kind: .math(display: false),
fullRange: full,
contentRange: NSRange(location: i + 1, length: close - i - 1),
delimiterRanges: [NSRange(location: i, length: 1),
NSRange(location: close, length: 1)]
))
}
i = close + 1
}
}
/// The set of ASCII-punctuation characters CommonMark allows a backslash to
/// escape (§2.4). A `\` before any other character is a literal backslash.
private static let escapableChars: Set<unichar> = {
let punct = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
return Set((punct as NSString).description.utf16)
}()
/// Parses CommonMark backslash escapes: a `\` followed by an escapable
/// punctuation char. The backslash becomes the span's hidden/dimmed
/// delimiter; the escaped char renders literally (swift-markdown already
/// strips the escape from the AST text, so no inline span double-styles it).
/// Skips escapes inside code / math / a trailing-`\` line break so those keep
/// their raw source (e.g. `\,` inside `$$` stays a LaTeX command).
static func parseEscapes(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
let n = ns.length
let backslash: unichar = 0x5C
var i = 0
while i < n - 1 {
guard ns.character(at: i) == backslash,
escapableChars.contains(ns.character(at: i + 1)) else { i += 1; continue }
let full = NSRange(location: i, length: 2)
let overlaps = spans.contains { existing in
switch existing.kind {
case .code, .codeBlock, .math, .lineBreak:
return existing.fullRange.location <= full.location
&& existing.fullRange.upperBound >= full.upperBound
default:
return false
}
}
if !overlaps {
spans.append(Span(
kind: .escape,
fullRange: full,
contentRange: NSRange(location: i + 1, length: 1),
delimiterRanges: [NSRange(location: i, length: 1)]))
}
// Consume both chars so `\\` is one escape (and the 2nd `\` can't
// start another escape or be read as a trailing line break).
i += 2
}
}
/// Whitelisted HTML formatting tags rendered (not just colored). The inner
/// content keeps its own markdown styling. Built from `htmlFormatTags` so the
/// Edit and Read whitelists share one source of truth.
/// Known ceiling: the open tag's attr swallow `(?:\s[^>]*)?` breaks on a `>`
/// inside a quoted attribute of a whitelist pair open tag the pair then
/// falls back to two colored `.htmlTag` tokens (acceptable).
private static let htmlPairRegex: NSRegularExpression = {
let names = htmlFormatTags.sorted().joined(separator: "|")
return try! NSRegularExpression(
pattern: "<(\(names))(?:\\s[^>]*)?>(.*?)</\\1\\s*>",
options: [.caseInsensitive, .dotMatchesLineSeparators])
}()
/// Any single inline HTML tag per GFM §6.10: an open tag (group 1 = name,
/// full attribute grammar names may contain hyphens, attribute values may
/// be double-quoted, single-quoted, or unquoted, and quoted values may
/// contain `>`), or a closing tag (group 2 = name; no attributes allowed).
private static let htmlTagRegex = try! NSRegularExpression(pattern:
#"<(?:([A-Za-z][A-Za-z0-9-]*)(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*/?|/([A-Za-z][A-Za-z0-9-]*)\s*)>"#)
/// §6.10 processing instructions `<??>`, declarations `<!NAME >`, and
/// CDATA `<![CDATA[]]>` shown as dimmed source. HTML comments are handled
/// (more laxly than spec interior `--` allowed, deliberate divergence) by
/// parseHTMLComments, which runs first.
private static let htmlOtherRegex = try! NSRegularExpression(
pattern: #"<\?[\s\S]*?\?>|<![A-Z]+\s+[^>]*>|<!\[CDATA\[[\s\S]*?\]\]>"#)
// `<img>` attribute extractors double-, single-, and unquoted values
// (§6.10). Exactly one of groups 13 participates per match. Shared with the
// read-mode renderer so both back-ends accept the same tags.
static let imgSrcRegex = try! NSRegularExpression(
pattern: #"\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, options: [.caseInsensitive])
static let imgAltRegex = try! NSRegularExpression(
pattern: #"\salt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, options: [.caseInsensitive])
static let imgWidthRegex = try! NSRegularExpression(
pattern: #"\swidth\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))"#, options: [.caseInsensitive])
static let imgHeightRegex = try! NSRegularExpression(
pattern: #"\sheight\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))"#, options: [.caseInsensitive])
/// The matched value range: whichever of groups 13 participated.
static func attrValueRange(_ m: NSTextCheckingResult) -> NSRange {
for i in 1...3 where m.range(at: i).location != NSNotFound { return m.range(at: i) }
return m.range(at: 0)
}
/// Parses inline HTML tags. Two tiers:
/// - a whitelisted pair (`<u></u>`, `<kbd>`, `<mark>`, `<sub>`, `<sup>`)
/// becomes a `.htmlFormat` span whose tags hide and whose content takes a
/// rendered attribute;
/// - any other recognized tag becomes a `.htmlTag` span shown as colored
/// source (the open/close tags of a pair are not re-emitted).
/// Skips tags inside code / math, and a `\<`-escaped `<` (escapes run first).
static func parseHTMLTags(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
let whole = NSRange(location: 0, length: ns.length)
// True if `r` sits inside a code/math span, or its `<` is an escaped `\<`.
func guarded(_ r: NSRange) -> Bool {
for span in spans {
switch span.kind {
case .code, .codeBlock, .math:
if span.fullRange.location <= r.location
&& span.fullRange.upperBound >= r.upperBound { return true }
case .escape:
// The escape covers `\` + the escaped char; reject if it
// covers this tag's opening `<`.
if span.fullRange.location <= r.location
&& span.fullRange.upperBound > r.location { return true }
default:
break
}
}
return false
}
// Pass 1: whitelist pairs render. Remember each pair's tag ranges so the
// generic pass doesn't re-emit them (inner tags are still colored).
var pairTagRanges: [NSRange] = []
for m in htmlPairRegex.matches(in: text, range: whole) {
let full = m.range(at: 0)
guard !guarded(full) else { continue }
let name = ns.substring(with: m.range(at: 1)).lowercased()
let content = m.range(at: 2)
let openTag = NSRange(location: full.location, length: content.location - full.location)
let closeTag = NSRange(location: content.upperBound, length: full.upperBound - content.upperBound)
spans.append(Span(kind: .htmlFormat(tag: name), fullRange: full,
contentRange: content, delimiterRanges: [openTag, closeTag]))
pairTagRanges.append(openTag)
pairTagRanges.append(closeTag)
}
// Pass 2: any other recognized tag colored source.
for m in htmlTagRegex.matches(in: text, range: whole) {
let full = m.range(at: 0)
guard !guarded(full) else { continue }
if pairTagRanges.contains(where: {
$0.location <= full.location && $0.upperBound >= full.upperBound
}) { continue }
// Group 1 = open-tag name, group 2 = closing-tag name.
let nameR = m.range(at: 1).location != NSNotFound ? m.range(at: 1) : m.range(at: 2)
// `<img src="">` renders as an inline image (like `![]()`),
// optionally at declared pixel dimensions. Without a src the tag
// stays colored source.
if ns.substring(with: nameR).lowercased() == "img",
let srcM = imgSrcRegex.firstMatch(in: text, range: full) {
func intAttr(_ regex: NSRegularExpression) -> Int? {
regex.firstMatch(in: text, range: full)
.map { ns.substring(with: attrValueRange($0)) }.flatMap(Int.init)
}
spans.append(Span(
kind: .image(destination: ns.substring(with: attrValueRange(srcM)),
width: intAttr(imgWidthRegex),
height: intAttr(imgHeightRegex)),
fullRange: full,
contentRange: attrValueRange(srcM),
delimiterRanges: []))
continue
}
let pre = NSRange(location: full.location, length: nameR.location - full.location)
let post = NSRange(location: nameR.upperBound, length: full.upperBound - nameR.upperBound)
spans.append(Span(kind: .htmlTag, fullRange: full, contentRange: nameR,
delimiterRanges: [pre, post]))
}
// Pass 3: PI / declaration / CDATA dimmed source. Zero-length content +
// full-range delimiter the whole token dims (like a comment); tokens
// inside a real <!-- comment --> are dropped by the opaque-range pass.
for m in htmlOtherRegex.matches(in: text, range: whole) {
let full = m.range(at: 0)
guard !guarded(full) else { continue }
spans.append(Span(kind: .htmlTag, fullRange: full,
contentRange: NSRange(location: full.location, length: 0),
delimiterRanges: [full]))
}
}
// GFM autolinks extension. Group 1 is the allowed preceding character
// (start of text, whitespace, or `*`/`_`/`~`/`(`); group 2 the candidate:
// a scheme/www URL run or an email. Trailing punctuation, unbalanced `)`,
// and `&entity;` suffixes are trimmed in code afterwards, then the domain
// is validated (1 dot, no `_` in the last two labels).
private static let autolinkRegex = try! NSRegularExpression(
pattern: #"(^|[\s*_~(])((?:https?://|www\.)[^\s<]+|[A-Za-z0-9._+-]+@[A-Za-z0-9._-]+)"#,
options: [.caseInsensitive])
/// Parses bare `www.`/`http(s)://`/`user@host` autolinks per the GFM
/// autolinks extension (swift-markdown doesn't attach cmark's). Emits
/// `.link` spans with no delimiters (the whole match is content). Skips
/// candidates inside code, math, comments, wikilinks, real links/images,
/// and HTML tags. Must run after every other pass.
static func parseAutolinks(_ text: String, into spans: inout [Span]) {
let ns = text as NSString
func isTrimPunct(_ c: unichar) -> Bool {
// ? ! . , : * _ ~ ' "
switch c {
case 0x3F, 0x21, 0x2E, 0x2C, 0x3A, 0x2A, 0x5F, 0x7E, 0x27, 0x22: return true
default: return false
}
}
func isAlnum(_ c: unichar) -> Bool {
(c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) || (c >= 0x30 && c <= 0x39)
}
// GFM trailing trim: strip punctuation, a `)` only while the match's
// parens are unbalanced, and a trailing `&entity;`.
func trimmedEnd(from start: Int, to initialEnd: Int) -> Int {
var end = initialEnd
loop: while end > start {
let c = ns.character(at: end - 1)
if isTrimPunct(c) { end -= 1; continue }
if c == 0x29 { // ")"
var opens = 0, closes = 0
for i in start..<end {
let ch = ns.character(at: i)
if ch == 0x28 { opens += 1 } else if ch == 0x29 { closes += 1 }
}
if closes > opens { end -= 1; continue }
break
}
if c == 0x3B { // ";" strip a `&word;` entity-like suffix
var i = end - 2
while i >= start, isAlnum(ns.character(at: i)) { i -= 1 }
if i >= start, ns.character(at: i) == 0x26, i < end - 2 { // "&" + 1+ alnum + ";"
end = i
continue loop
}
break
}
break
}
return end
}
/// GFM valid domain: `.`-separated labels of alphanumerics/`-`/`_`,
/// at least two labels, no `_` in the last two.
func isValidDomain(_ domain: Substring) -> Bool {
let labels = domain.split(separator: ".", omittingEmptySubsequences: false)
guard labels.count >= 2 else { return false }
for label in labels {
guard !label.isEmpty,
label.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" })
else { return false }
}
return !labels.suffix(2).contains { $0.contains("_") }
}
for m in autolinkRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
let candidate = m.range(at: 2)
let end = trimmedEnd(from: candidate.location, to: candidate.upperBound)
guard end > candidate.location else { continue }
let full = NSRange(location: candidate.location, length: end - candidate.location)
let match = ns.substring(with: full)
let destination: String
if match.range(of: "^https?://", options: [.regularExpression, .caseInsensitive]) != nil {
let afterScheme = match[match.range(of: "://")!.upperBound...]
guard isValidDomain(afterScheme.prefix {
$0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "."
}) else { continue }
destination = match
} else if match.lowercased().hasPrefix("www.") {
guard isValidDomain(match.prefix(while: {
$0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "."
})) else { continue }
destination = "http://" + match
} else {
// Email: needs text before the `@`, a valid domain after it,
// and the last character can't be `-` or `_`.
guard let at = match.firstIndex(of: "@"), at != match.startIndex,
isValidDomain(match[match.index(after: at)...]),
match.last != "-", match.last != "_"
else { continue }
destination = "mailto:" + match
}
let overlapsExisting = spans.contains { existing in
switch existing.kind {
case .code, .codeBlock, .math, .comment, .wikilink,
.link, .image, .htmlTag, .htmlFormat:
return existing.fullRange.location < full.upperBound
&& existing.fullRange.upperBound > full.location
default:
return false
}
}
guard !overlapsExisting else { continue }
spans.append(Span(
kind: .link(destination: destination),
fullRange: full,
contentRange: full,
delimiterRanges: []))
}
}
/// Parses trailing `\` as a line break indicator.
static func parseLineBreak(_ text: String, into spans: inout [Span]) {
let nsText = text as NSString
let len = nsText.length
guard len > 0 else { return }
// Must not contain \n (only applies to single-line blocks)
guard !text.contains("\n") else { return }
let lastChar = nsText.character(at: len - 1)
guard lastChar == 0x5C else { return } // backslash
// Not an escaped backslash (\\)
if len >= 2 && nsText.character(at: len - 2) == 0x5C { return }
let range = NSRange(location: len - 1, length: 1)
spans.append(Span(
kind: .lineBreak,
fullRange: range,
contentRange: NSRange(location: len - 1, length: 0),
delimiterRanges: [range]
))
}
/// Detects list items with deep indentation (4+ spaces or tabs) that
/// swift-markdown parses as indented code instead of list items. Group 2 is
/// the marker an unordered bullet (`-`/`*`/`+`) or an ordered number
/// (`1.`/`1)`), so nested ordered lists are rescued too.
static let indentedListRegex = try! NSRegularExpression(
pattern: #"^([\t ]*\t[\t ]*|[ ]{4,})([-*+]|\d{1,9}[.)])\s"#
)
/// Matches a GFM task-list checkbox at the start of list-item content:
/// "[ ] ", "[x] ", or "[X] ". Capture group 1 is the state character.
static let checkboxRegex = try! NSRegularExpression(
pattern: #"^\[([ xX])\]\s"#
)
static func parseIndentedListItem(_ text: String, into spans: inout [Span]) {
// Only single-line blocks (no \n)
guard !text.contains("\n") else { return }
let nsText = text as NSString
let match = indentedListRegex.firstMatch(
in: text, range: NSRange(location: 0, length: nsText.length)
)
guard let match = match else { return }
// Don't duplicate if swift-markdown already found a listItem
let alreadyHasListItem = spans.contains {
if case .listItem = $0.kind { return true }
return false
}
guard !alreadyHasListItem else { return }
let full = NSRange(location: 0, length: nsText.length)
let markerEnd = match.range(at: 0).upperBound // end of " - "
// An ordered marker (1./1)) starts with a digit; a bullet (-/*/+) doesn't.
let marker = nsText.substring(with: match.range(at: 2))
let ordered = marker.first?.isNumber ?? false
// Detect a GFM task-list checkbox following the marker ("[ ] "/"[x] ").
// swift-markdown skips these on deeply-indented lines (it treats the
// whole line as code), so we parse the checkbox ourselves otherwise
// task items nested beyond level 2 render without a circle. Only the
// unordered `- [ ]` form is supported.
var checkbox: Span.Kind.CheckboxState? = nil
var delimEnd = markerEnd
if !ordered {
let afterMarker = nsText.substring(from: markerEnd) as NSString
if let cb = checkboxRegex.firstMatch(
in: afterMarker as String,
range: NSRange(location: 0, length: afterMarker.length)
) {
let stateChar = afterMarker.substring(with: cb.range(at: 1))
checkbox = (stateChar == "x" || stateChar == "X") ? .checked : .unchecked
delimEnd = markerEnd + cb.range(at: 0).length
}
}
let delim = NSRange(location: 0, length: delimEnd)
let content = NSRange(location: delimEnd, length: nsText.length - delimEnd)
// Remove any codeBlock span swift-markdown created for this indented line
spans.removeAll { span in
if case .codeBlock = span.kind { return true }
return false
}
spans.append(Span(
kind: .listItem(ordered: ordered, checkbox: checkbox),
fullRange: full,
contentRange: content,
delimiterRanges: [delim]
))
// Re-parse the content for inline formatting (bold, italic, code, etc.)
// since swift-markdown treated the whole line as code and skipped them.
let contentStr = nsText.substring(with: content)
let inlineSpans = parse(contentStr)
for s in inlineSpans {
// Skip any listItem spans from the recursive parse
if case .listItem = s.kind { continue }
// Offset ranges by the content start position
let offsetFull = NSRange(location: s.fullRange.location + content.location,
length: s.fullRange.length)
let offsetContent = NSRange(location: s.contentRange.location + content.location,
length: s.contentRange.length)
let offsetDelims = s.delimiterRanges.map {
NSRange(location: $0.location + content.location, length: $0.length)
}
spans.append(Span(kind: s.kind, fullRange: offsetFull,
contentRange: offsetContent,
delimiterRanges: offsetDelims))
}
}
}
@@ -0,0 +1,549 @@
import Foundation
import Markdown
// MARK: - AST Walker
//
// SpanCollector walks the swift-markdown AST and records a Span for each inline
// construct it recognizes (headings, emphasis, code, links, tables, lists, ),
// translating swift-markdown SourceRanges into NSRanges over the original text.
// Constructs swift-markdown does not model (==highlight==, $math$, indented
// list items) are handled by the regex passes in +CustomParsers.
extension SyntaxHighlighter {
struct SpanCollector: MarkupWalker {
let source: String
private let lines: [String]
var spans: [Span] = []
/// Track nesting depth so we can detect bold-inside-italic (= boldItalic)
/// and avoid emitting duplicate spans. `insideEmphasis`/`insideStrong`
/// are internal (not private) so the inline visitors in
/// SyntaxHighlighter+WalkerInline can read and set them.
var insideEmphasis = false
var insideStrong = false
private var insideOrderedList = false
/// >0 while walking inside a *plain* block quote. Nested block-level
/// constructs (code blocks, headings, lists, tables, rules, nested
/// quotes) are left literal there only inline emphasis renders.
/// Callouts are not walked at all (their bodies are rendered
/// recursively by the styling layer), so this never counts them.
private var plainQuoteDepth = 0
init(source: String) {
self.source = source
self.lines = source.components(separatedBy: "\n")
}
// MARK: - Source offset conversion
/// Converts a SourceLocation (1-indexed line, 1-indexed UTF-8 column)
/// to a UTF-16 offset suitable for NSRange.
func utf16Offset(for loc: SourceLocation) -> Int {
var utf8Offset = 0
for i in 0..<(loc.line - 1) {
if i < lines.count {
utf8Offset += lines[i].utf8.count + 1
}
}
utf8Offset += loc.column - 1
let utf8View = source.utf8
let targetIdx = utf8View.index(utf8View.startIndex,
offsetBy: min(utf8Offset, utf8View.count))
return source.utf16.distance(
from: source.utf16.startIndex,
to: String.Index(targetIdx, within: source.utf16) ?? source.utf16.endIndex
)
}
func nsRange(for range: SourceRange) -> NSRange {
let start = utf16Offset(for: range.lowerBound)
let end = utf16Offset(for: range.upperBound)
return NSRange(location: start, length: max(0, end - start))
}
/// Computes delimiter ranges by subtracting direct child ranges from parent.
func delimiterRanges(parent: NSRange, children: some Sequence<Markup>) -> [NSRange] {
// Collect child ranges (only direct children with source ranges)
var childRanges: [NSRange] = []
for child in children {
if let cr = child.range {
childRanges.append(nsRange(for: cr))
}
}
guard !childRanges.isEmpty else { return [] }
var delims: [NSRange] = []
let firstChild = childRanges[0]
if firstChild.location > parent.location {
delims.append(NSRange(location: parent.location,
length: firstChild.location - parent.location))
}
let lastChild = childRanges[childRanges.count - 1]
if lastChild.upperBound < parent.upperBound {
delims.append(NSRange(location: lastChild.upperBound,
length: parent.upperBound - lastChild.upperBound))
}
return delims
}
/// Trims delimiter ranges to the expected width for emphasis types.
/// When cmark includes unmatched delimiter characters in the emphasis
/// node's source range (e.g. `**here*` italic with opening `**`),
/// this trims them so only the real delimiter chars are styled.
/// Returns adjusted (fullRange, delimiterRanges).
func trimEmphasisDelimiters(
expectedWidth: Int, full: NSRange, delims: [NSRange]
) -> (NSRange, [NSRange]) {
guard delims.count == 2 else { return (full, delims) }
var trimmedDelims = delims
var trimmedFull = full
// Opening delimiter: keep only the last `expectedWidth` chars
if delims[0].length > expectedWidth {
let excess = delims[0].length - expectedWidth
trimmedDelims[0] = NSRange(location: delims[0].location + excess,
length: expectedWidth)
trimmedFull = NSRange(location: trimmedFull.location + excess,
length: trimmedFull.length - excess)
}
// Closing delimiter: keep only the first `expectedWidth` chars
if delims[1].length > expectedWidth {
let excess = delims[1].length - expectedWidth
trimmedDelims[1] = NSRange(location: delims[1].location,
length: expectedWidth)
trimmedFull = NSRange(location: trimmedFull.location,
length: trimmedFull.length - excess)
}
return (trimmedFull, trimmedDelims)
}
/// Compute content range from full range and delimiter ranges.
func contentRange(full: NSRange, delims: [NSRange]) -> NSRange {
var start = full.location
var end = full.upperBound
if let first = delims.first, first.location == full.location {
start = first.upperBound
}
if let last = delims.last, last.upperBound == full.upperBound {
end = last.location
}
return NSRange(location: start, length: max(0, end - start))
}
// MARK: - Visitors
mutating func visitHeading(_ heading: Heading) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = heading.range else { return }
let full = nsRange(for: range)
let text = (source as NSString).substring(with: full)
// Setext heading (`Title\n===`, or `Foo\nbar\n===` per GFM Example
// 51 content can span multiple lines): no `#` prefix. Everything
// up to the *last* line is the content; that last line is the
// underline delimiter (hidden when rendered, dimmed when active).
// The `\n`s stay untouched so the line structure survives.
if !text.drop(while: { $0 == " " }).hasPrefix("#") {
let nl = (text as NSString).range(of: "\n", options: .backwards)
guard nl.location != NSNotFound else { return } // setext is 2+ lines
spans.append(Span(
kind: .heading(heading.level),
fullRange: full,
contentRange: NSRange(location: full.location, length: nl.location),
delimiterRanges: [NSRange(location: full.location + nl.upperBound,
length: full.length - nl.upperBound)]
))
descendInto(heading) // inline children style at heading size
return
}
let delimLen = heading.level + 1
// cmark already recognizes and trims a valid optional closing
// sequence (GFM 4.2, e.g. `# foo ###`) out of `heading.range`
// it can even trim `full` down to just the opening `#` run when
// the heading is otherwise empty (`## ##`), shorter than
// `delimLen`. Clamp so an empty-content heading doesn't push
// `cStart` past what `full` actually covers.
let openDelimLen = min(delimLen, full.length)
let cStart = full.location + openDelimLen
let cLen = max(0, full.length - openDelimLen)
var delimiterRanges = [NSRange(location: full.location, length: openDelimLen)]
// Whatever raw text follows `full` to the end of this
// single-line block is exactly what cmark trimmed as the
// closing sequence (its required separating whitespace, the
// `#` run, and any trailing whitespace) hide it too.
let nsSource = source as NSString
let lineEnd = nsSource.length
if full.upperBound < lineEnd {
delimiterRanges.append(NSRange(location: full.upperBound, length: lineEnd - full.upperBound))
}
spans.append(Span(
kind: .heading(heading.level),
fullRange: full,
contentRange: NSRange(location: cStart, length: cLen),
delimiterRanges: delimiterRanges
))
// The heading span is appended first, so inner spans read the
// heading font as their context and keep its size.
descendInto(heading)
}
// MARK: - Code Blocks
mutating func visitCodeBlock(_ codeBlock: CodeBlock) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = codeBlock.range else { return }
let full = nsRange(for: range)
guard full.length > 0 else { return }
let nsSource = source as NSString
let blockText = nsSource.substring(with: full) as NSString
// Indented code block: no fence, so no delimiters every
// character (indentation included) is content. swift-markdown's
// node range starts *after* the first line's 4-space indent, so
// expand back over the leading whitespace or those characters
// would keep the body font and misalign the first line.
let opener = (blockText as String).drop(while: { $0 == " " })
if !(opener.hasPrefix("```") || opener.hasPrefix("~~~")) {
var start = full.location
while start > 0 {
let c = nsSource.character(at: start - 1)
guard c == 0x20 || c == 0x09 else { break }
start -= 1
}
let expanded = NSRange(location: start, length: full.upperBound - start)
spans.append(Span(
kind: .codeBlock(language: nil),
fullRange: expanded,
contentRange: expanded,
delimiterRanges: []
))
return
}
var delims: [NSRange] = []
var cStart = full.location
var cEnd = full.upperBound
let firstNL = blockText.range(of: "\n")
if firstNL.location != NSNotFound {
// Opening fence line (including newline)
let openLen = firstNL.location + 1
delims.append(NSRange(location: full.location, length: openLen))
cStart = full.location + openLen
// Look for closing fence line
let lastNL = blockText.range(of: "\n", options: .backwards)
if lastNL.location != NSNotFound && lastNL.location != firstNL.location {
let lastLineStart = lastNL.location + 1
if lastLineStart < blockText.length {
let lastLine = blockText.substring(from: lastLineStart)
.trimmingCharacters(in: .whitespaces)
if lastLine.hasPrefix("```") || lastLine.hasPrefix("~~~") {
let closeStart = full.location + lastNL.location
delims.append(NSRange(location: closeStart,
length: full.upperBound - closeStart))
cEnd = closeStart
}
}
}
} else {
// Single line (shouldn't normally happen with fenced code blocks)
delims.append(full)
cStart = full.upperBound
}
let content = NSRange(location: cStart, length: max(0, cEnd - cStart))
spans.append(Span(
kind: .codeBlock(language: codeBlock.language),
fullRange: full,
contentRange: content,
delimiterRanges: delims
))
}
// MARK: - Block Quotes
mutating func visitBlockQuote(_ blockQuote: BlockQuote) {
guard let range = blockQuote.range else {
descendInto(blockQuote)
return
}
let full = nsRange(for: range)
let nsSource = source as NSString
let opensCallout = Self.quoteOpensCallout(firstLineOf: full, in: nsSource)
// A callout nested inside a plain quote is fully literal: emit no
// span and don't descend (matches showing `> [!note]` raw inside an
// outer quote callouts render via their own recursive splice,
// which isn't set up to stack with an enclosing quote's bar). A
// nested *plain* quote is the one exception to "nested blocks stay
// literal": it gets its own span (below) so its marker hides and it
// draws its own bar, stacked with its ancestors'.
if plainQuoteDepth > 0 && opensCallout { return }
// `plainQuoteDepth` doubles as this quote's nesting depth (0 =
// outermost) captured before incrementing for our own descent.
let depth = plainQuoteDepth
// Scan each line within the blockquote for its OWN "> " marker.
// swift-markdown's nested-BlockQuote `range` only skips ancestor
// markers on the *first* line (its start position lands right on
// this quote's own `>`) every subsequent line is the raw source
// verbatim, ancestor markers and all. So each later line must peel
// exactly `depth` ancestor markers before this quote's own can be
// at hand.
//
// A no-marker line is CommonMark "lazy continuation". Which quote
// it belongs to depends on depth: at the OUTERMOST quote (depth 0)
// it continues *this* quote's paragraph BlockParser already
// merged it into the block, so keep it in the span (the bar extends
// over it) with no marker to hide. Deeper in (depth > 0), a line
// that runs out of ancestor markers, or lacks this quote's own,
// belongs to a shallower ancestor's span clip `fullRange` before
// it.
var delims: [NSRange] = []
var cursor = full.location
var clippedEnd = full.upperBound
var isFirstLine = true
while cursor < clippedEnd {
let remaining = NSRange(location: cursor, length: clippedEnd - cursor)
let nlRange = nsSource.range(of: "\n", options: [], range: remaining)
let lineEnd = nlRange.location != NSNotFound ? nlRange.location : clippedEnd
var p = cursor
var ranOut = false
for _ in 0..<(isFirstLine ? 0 : depth) {
guard let after = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) else {
ranOut = true
break
}
p = after
}
if ranOut {
// Ran out of ancestor markers: line belongs to a shallower
// ancestor (nested lazy continuation). Clip here.
clippedEnd = cursor
break
}
if let markerEnd = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) {
delims.append(NSRange(location: p, length: markerEnd - p))
} else if depth == 0 {
// Lazy continuation of the outermost quote: keep it in the
// span, no marker to hide, and keep scanning.
} else {
// Nested quote missing its own marker: belongs to a
// shallower ancestor. Clip here.
clippedEnd = cursor
break
}
cursor = nlRange.location != NSNotFound ? nlRange.location + 1 : clippedEnd
isFirstLine = false
}
let clippedFull = NSRange(location: full.location, length: clippedEnd - full.location)
let content = contentRange(full: clippedFull, delims: delims)
spans.append(Span(
kind: .blockquote(depth: depth),
fullRange: clippedFull,
contentRange: content,
delimiterRanges: delims
))
// A callout's body is rendered recursively by the styling layer
// (which strips the `>` prefixes and re-parses), so don't descend
// doing so would emit nested spans over `>`-prefixed source ranges.
// A plain quote descends, but with a depth guard so only inline
// content and further nested plain quotes render (other nested
// blocks stay literal).
if opensCallout { return }
plainQuoteDepth += 1
descendInto(blockQuote)
plainQuoteDepth -= 1
}
/// Peels one `>` marker (optional leading spaces, `>`, optional single
/// trailing space) starting at `from`, returning the position right
/// after it or `nil` if `[from, lineEnd)` doesn't start with `>`
/// (after skipping spaces). Used both to skip `depth` ancestor
/// markers and to locate this quote's own marker on a line (see
/// `visitBlockQuote`).
private static func peelOneMarker(_ source: NSString, from: Int, lineEnd: Int) -> Int? {
var q = from
while q < lineEnd, source.character(at: q) == 0x20 { q += 1 }
guard q < lineEnd, source.character(at: q) == 0x3E else { return nil }
q += 1
if q < lineEnd, source.character(at: q) == 0x20 { q += 1 }
return q
}
/// Whether the first line of a block quote (its source `range`) opens a
/// callout `> [!type]` (known or unknown type). Mirrors
/// `BlockParser.quoteRunOpensCallout` over an NSRange.
private static func quoteOpensCallout(firstLineOf range: NSRange, in source: NSString) -> Bool {
let bound = NSRange(location: range.location, length: range.length)
let nl = source.range(of: "\n", options: [], range: bound)
let lineEnd = nl.location == NSNotFound ? range.upperBound : nl.location
let line = source.substring(with: NSRange(location: range.location,
length: lineEnd - range.location))
let trimmed = line.drop(while: { $0 == " " })
guard trimmed.first == ">" else { return false }
return Callout.parseMarker(String(trimmed.dropFirst())) != nil
}
// MARK: - Tables
mutating func visitTable(_ table: Table) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = table.range else {
descendInto(table)
return
}
let full = nsRange(for: range)
// Compute gaps between child rows (head/body) as delimiters
// (this captures the separator row between head and body)
var childRanges: [NSRange] = []
for child in table.children {
if let cr = child.range {
childRanges.append(nsRange(for: cr))
}
}
var delims: [NSRange] = []
if let first = childRanges.first, first.location > full.location {
delims.append(NSRange(location: full.location,
length: first.location - full.location))
}
for i in 0..<(childRanges.count - 1) {
let gapStart = childRanges[i].upperBound
let gapEnd = childRanges[i + 1].location
if gapEnd > gapStart {
delims.append(NSRange(location: gapStart,
length: gapEnd - gapStart))
}
}
if let last = childRanges.last, last.upperBound < full.upperBound {
delims.append(NSRange(location: last.upperBound,
length: full.upperBound - last.upperBound))
}
spans.append(Span(
kind: .table,
fullRange: full,
contentRange: full,
delimiterRanges: delims
))
}
// MARK: - Lists
mutating func visitOrderedList(_ orderedList: OrderedList) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
insideOrderedList = true
descendInto(orderedList)
insideOrderedList = false
}
mutating func visitListItem(_ listItem: ListItem) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = listItem.range else {
descendInto(listItem)
return
}
let full = nsRange(for: range)
var delims = delimiterRanges(parent: full, children: listItem.children)
// An empty list item (just "- " / "1. " / "- [ ] " with no content,
// e.g. a marker freshly created by pressing Return) has no child
// nodes, so `delimiterRanges` finds no marker and the whole item is
// treated as content. That collapses the marker's width to zero and
// pushes the freshly-typed marker a full slot too deep. Synthesize
// the marker delimiter from the leading text so the content begins
// after it, matching a non-empty item.
if delims.isEmpty, let markerLen = Self.leadingListMarkerLength(in: source, range: full) {
delims = [NSRange(location: full.location, length: markerLen)]
}
let content = contentRange(full: full, delims: delims)
// swift-markdown flags an item as a task list item via `checkbox`,
// but it reports the STATE by scanning the whole line for `[x]` so
// an unchecked `- [ ]` whose body merely contains `[x]` (e.g. in a
// code span) is wrongly reported as checked. Take only the "is this a
// task item" signal from swift-markdown and read the actual state
// from the leading `[ ]`/`[x]` marker ourselves.
let checkbox: Span.Kind.CheckboxState?
if listItem.checkbox != nil {
let markerLen = max(0, content.location - full.location)
let marker = (source as NSString).substring(
with: NSRange(location: full.location, length: markerLen))
checkbox = Self.leadingCheckboxState(inMarker: marker)
?? (listItem.checkbox == .checked ? .checked : .unchecked)
} else {
checkbox = nil
}
spans.append(Span(
kind: .listItem(ordered: insideOrderedList, checkbox: checkbox),
fullRange: full,
contentRange: content,
delimiterRanges: delims
))
descendInto(listItem)
}
/// Matches a list item's leading marker (optional indentation +
/// `-`/`*`/`+` or `N.`, plus an optional `[ ]`/`[x]` checkbox), used to
/// recover the marker range for an empty item that has no child nodes.
private static let listMarkerRegex = try! NSRegularExpression(
pattern: #"^[ \t]*(?:[-*+][ \t]+(?:\[[ xX]\][ \t]*)?|\d+\.[ \t]+)"#)
/// Length (UTF-16) of the leading list marker within `range` of `source`,
/// or nil if the text there doesn't begin with a marker.
private static func leadingListMarkerLength(in source: String, range: NSRange) -> Int? {
let line = (source as NSString).substring(with: range)
let m = listMarkerRegex.firstMatch(
in: line, range: NSRange(location: 0, length: (line as NSString).length))
guard let m, m.range.location == 0, m.range.length > 0 else { return nil }
return m.range.length
}
/// Reads a task-list checkbox state from the item's leading marker text
/// (e.g. `"- [ ] "` unchecked, `"1. [x] "` checked) by inspecting the
/// character inside the first `[...]`. Returns nil if no bracket is found.
private static func leadingCheckboxState(inMarker marker: String)
-> Span.Kind.CheckboxState? {
let ns = marker as NSString
let open = ns.range(of: "[")
guard open.location != NSNotFound, open.upperBound < ns.length else { return nil }
switch ns.substring(with: NSRange(location: open.upperBound, length: 1)) {
case "x", "X": return .checked
case " ": return .unchecked
default: return nil
}
}
// MARK: - Thematic Break
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = thematicBreak.range else { return }
let full = nsRange(for: range)
spans.append(Span(
kind: .thematicBreak,
fullRange: full,
contentRange: full,
delimiterRanges: [full]
))
}
}
}
@@ -0,0 +1,202 @@
import Foundation
import Markdown
// Inline-construct visitors for SpanCollector: emphasis/strong (including the
// `***boldItalic***` nesting both cmark orderings produce), inline code,
// strikethrough, links, and images. The struct, its stored state, and the
// shared source-offset/delimiter helpers live in SyntaxHighlighter+Walker;
// block-level visitors (headings, code blocks, quotes, tables, lists) stay there
// too.
extension SyntaxHighlighter.SpanCollector {
mutating func visitEmphasis(_ emphasis: Emphasis) {
guard let range = emphasis.range else {
descendInto(emphasis)
return
}
let full = nsRange(for: range)
if insideStrong {
// Already inside Strong parent will have emitted boldItalic
// or we're a nested emphasis. Just descend.
descendInto(emphasis)
return
}
// Check for ***...***: Emphasis wrapping a single Strong child
// with the same source range.
if emphasis.childCount == 1,
let strong = emphasis.children.first(where: { $0 is Strong }) as? Strong,
let strongRange = strong.range {
let strongNS = nsRange(for: strongRange)
if strongNS == full {
// This is boldItalic. Compute delimiters from the Strong's children
// (the text nodes inside), not from the Emphasis's children (the Strong).
let rawDelims = delimiterRanges(parent: full, children: strong.children)
let (trimmedFull, delims) = trimEmphasisDelimiters(
expectedWidth: 3, full: full, delims: rawDelims)
let content = contentRange(full: trimmedFull, delims: delims)
spans.append(SyntaxHighlighter.Span(
kind: .boldItalic,
fullRange: trimmedFull,
contentRange: content,
delimiterRanges: delims
))
// Don't descend we've handled the whole subtree
return
}
}
// Regular italic
let rawDelims = delimiterRanges(parent: full, children: emphasis.children)
let (trimmedFull, delims) = trimEmphasisDelimiters(
expectedWidth: 1, full: full, delims: rawDelims)
let content = contentRange(full: trimmedFull, delims: delims)
spans.append(SyntaxHighlighter.Span(
kind: .italic,
fullRange: trimmedFull,
contentRange: content,
delimiterRanges: delims
))
insideEmphasis = true
descendInto(emphasis)
insideEmphasis = false
}
mutating func visitStrong(_ strong: Strong) {
guard let range = strong.range else {
descendInto(strong)
return
}
let full = nsRange(for: range)
if insideEmphasis {
// Already inside Emphasis parent will have emitted boldItalic
// or we're nested. Just descend.
descendInto(strong)
return
}
// Check for ***...***: Strong wrapping a single Emphasis child
// with the same source range. (cmark can produce either nesting order.)
if strong.childCount == 1,
let emph = strong.children.first(where: { $0 is Emphasis }) as? Emphasis,
let emphRange = emph.range {
let emphNS = nsRange(for: emphRange)
if emphNS == full {
let rawDelims = delimiterRanges(parent: full, children: emph.children)
let (trimmedFull, delims) = trimEmphasisDelimiters(
expectedWidth: 3, full: full, delims: rawDelims)
let content = contentRange(full: trimmedFull, delims: delims)
spans.append(SyntaxHighlighter.Span(
kind: .boldItalic,
fullRange: trimmedFull,
contentRange: content,
delimiterRanges: delims
))
return
}
}
// Regular bold
let rawDelims = delimiterRanges(parent: full, children: strong.children)
let (trimmedFull, delims) = trimEmphasisDelimiters(
expectedWidth: 2, full: full, delims: rawDelims)
let content = contentRange(full: trimmedFull, delims: delims)
spans.append(SyntaxHighlighter.Span(
kind: .bold,
fullRange: trimmedFull,
contentRange: content,
delimiterRanges: delims
))
insideStrong = true
descendInto(strong)
insideStrong = false
}
mutating func visitInlineCode(_ code: InlineCode) {
guard let range = code.range else { return }
let full = nsRange(for: range)
guard full.length >= 2 else { return }
// GFM §6.3: the delimiters are equal-length backtick runs of ANY length.
// Measure the actual runs in the raw source (the AST doesn't carry them).
let ns = source as NSString
let backtick: unichar = 0x60
var open = 0
while full.location + open < full.upperBound,
ns.character(at: full.location + open) == backtick { open += 1 }
var close = 0
while full.upperBound - 1 - close > full.location + open - 1,
ns.character(at: full.upperBound - 1 - close) == backtick { close += 1 }
// cmark guarantees matching runs; clamp defensively so a surprise can't
// produce inverted ranges.
let d = max(1, min(min(open, close), full.length / 2))
// NOTE: the §6.3 one-space strip (`` ` `` "`") is a render rule; in
// edit mode the padding spaces are source and stay in contentRange.
spans.append(SyntaxHighlighter.Span(
kind: .code,
fullRange: full,
contentRange: NSRange(location: full.location + d, length: max(0, full.length - 2 * d)),
delimiterRanges: [NSRange(location: full.location, length: d),
NSRange(location: full.upperBound - d, length: d)]
))
}
mutating func visitStrikethrough(_ strikethrough: Strikethrough) {
guard let range = strikethrough.range else {
descendInto(strikethrough)
return
}
let full = nsRange(for: range)
let delims = delimiterRanges(parent: full, children: strikethrough.children)
let content = contentRange(full: full, delims: delims)
spans.append(SyntaxHighlighter.Span(
kind: .strikethrough,
fullRange: full,
contentRange: content,
delimiterRanges: delims
))
descendInto(strikethrough)
}
mutating func visitLink(_ link: Link) {
guard let range = link.range else {
descendInto(link)
return
}
let full = nsRange(for: range)
let delims = delimiterRanges(parent: full, children: link.children)
let content = contentRange(full: full, delims: delims)
spans.append(SyntaxHighlighter.Span(
kind: .link(destination: link.destination ?? ""),
fullRange: full,
contentRange: content,
delimiterRanges: delims
))
descendInto(link)
}
mutating func visitImage(_ image: Image) {
guard let range = image.range else {
descendInto(image)
return
}
let full = nsRange(for: range)
let delims = delimiterRanges(parent: full, children: image.children)
let content = contentRange(full: full, delims: delims)
spans.append(SyntaxHighlighter.Span(
kind: .image(destination: image.source ?? "", width: nil, height: nil),
fullRange: full,
contentRange: content,
delimiterRanges: delims
))
descendInto(image)
}
}
@@ -0,0 +1,202 @@
import Foundation
import Markdown
/// Parses raw markdown using Apple's swift-markdown (cmark-gfm) and returns
/// spans identifying inline formatting with their delimiter and content ranges.
///
/// This ensures the active block's syntax highlighting is consistent with the
/// rendered (non-active) blocks, including mismatched-delimiter edge cases
/// like `**hi*` (treated as literal `*` + italic `hi`).
///
/// This file holds the public model (`Span`/`Kind`) and the `parse` entry
/// point. The heavy lifting lives in two siblings:
/// - SyntaxHighlighter+Walker.swift the swift-markdown AST walker
/// - SyntaxHighlighter+CustomParsers.swift regex passes for constructs the
/// AST doesn't model (==highlight==, $math$, indented list items)
public enum SyntaxHighlighter {
/// Inline HTML element names that *render* their formatting rather than show
/// as colored source. Single source of truth shared by the editor's
/// `parseHTMLTags` (Edit mode) and `HTMLRenderer.sanitizeInlineHTML` (Read
/// mode), so the two back-ends can't drift on which tags are allowed.
public static let htmlFormatTags: Set<String> = ["u", "kbd", "mark", "sub", "sup", "small"]
// MARK: - Model
public struct Span: Sendable {
public let kind: Kind
public let fullRange: NSRange
public let contentRange: NSRange
public let delimiterRanges: [NSRange]
public enum Kind: Equatable, Sendable {
case bold
case italic
case boldItalic
case code
case codeBlock(language: String?)
case strikethrough
case highlight
case heading(Int)
case link(destination: String)
/// A markdown `![alt](src)` image, or an HTML `<img src="">` tag
/// (which may carry declared pixel dimensions).
case image(destination: String, width: Int?, height: Int?)
/// `depth` is the nesting level (0 = outermost, not itself inside
/// another plain quote). A `> > text` emits two spans, depth 0 and
/// depth 1, so each level's own marker hides and draws its own bar.
case blockquote(depth: Int)
case listItem(ordered: Bool, checkbox: CheckboxState? = nil)
case table
case thematicBreak
case lineBreak
case math(display: Bool)
/// An inline `[^id]` footnote reference.
case footnoteReference(id: String)
/// A `[^id]:` footnote definition marker at the start of a block.
case footnoteDefinition(id: String)
/// An Obsidian-style `%%comment%%` (hidden in reading view).
case comment
/// An Obsidian-style `[[target]]` internal link. `target` is the raw
/// `path#heading` portion (before any `|alias`); the visible display
/// text is the span's contentRange.
case wikilink(target: String)
/// A CommonMark backslash escape `\X`. The backslash is the delimiter
/// (hidden when inactive, dimmed when active); the escaped character
/// `X` renders literally as its content.
case escape
/// A single inline HTML tag (`<tag >` or `</tag>`) shown only as
/// colored source: the `<`/`>`/`/` dim and the tag name colors red
/// (like math). Used for unknown / unpaired tags. `contentRange` is
/// the tag name.
case htmlTag
/// A whitelisted HTML formatting tag pair (`<u></u>`, etc.). When the
/// caret is outside, the open/close tags hide and the corresponding
/// attribute is applied to the inner `contentRange`; inside, the raw
/// tags show colored. `tag` is the lowercased element name; the two
/// delimiterRanges are the open and close tags.
case htmlFormat(tag: String)
public enum CheckboxState: Equatable, Sendable {
case checked, unchecked
}
}
}
// MARK: - Parsing
/// Returns all inline syntax spans found in `text`, ordered by position.
///
/// `linkDefinitions` (the document's collected `[label]: url` lines, from
/// `LinkDefinitionState.defsText`) is appended after the block so
/// swift-markdown can resolve GFM reference links whose definition lives in
/// another block; spans landing in the appended region are dropped. Empty
/// (the common case) means no append and no cost.
public static func parse(_ text: String, linkDefinitions: String = "") -> [Span] {
guard !text.isEmpty else { return [] }
// Walk the AST over the block plus any appended reference definitions,
// then keep only spans within the original block. Custom parsers below
// still run on `text`, so their offsets need no adjustment.
let textLen = (text as NSString).length
let parseText = linkDefinitions.isEmpty ? text : text + "\n\n" + linkDefinitions
let doc = Document(parsing: parseText, options: [.disableSmartOpts])
var walker = SpanCollector(source: parseText)
walker.visit(doc)
if !linkDefinitions.isEmpty {
walker.spans.removeAll { $0.fullRange.upperBound > textLen }
}
// ==highlight== is not supported by swift-markdown; parse with regex.
parseHighlight(text, into: &walker.spans)
// $$$$ display math (the block is pre-merged by BlockParser), then
// $$ inline math.
parseDisplayMath(text, into: &walker.spans)
parseMath(text, into: &walker.spans)
// Trailing backslash line break (single-line blocks only).
parseLineBreak(text, into: &walker.spans)
// Deeply indented list items (4+ spaces) that swift-markdown treats as code.
parseIndentedListItem(text, into: &walker.spans)
// [^id] footnote references and [^id]: definition markers.
parseFootnotes(text, into: &walker.spans)
// %%comments%% and [[wikilinks]]. Both are opaque: their inner text is
// a raw note / link target, not markdown drop any span fully inside
// one so the content isn't re-styled.
parseComments(text, into: &walker.spans)
parseWikiLinks(text, into: &walker.spans)
// CommonMark backslash escapes (`\*`, `\$`, ). Runs after math/line-break
// so it can defer to them; before HTML tags so `\<` defers to the escape.
parseEscapes(text, into: &walker.spans)
// HTML `<!-- comments -->` share the `.comment` kind (and its opaque
// treatment below). Before parseHTMLTags so a tag inside a comment
// belongs to the comment, not the tag pass.
parseHTMLComments(text, into: &walker.spans)
// Inline HTML tags: whitelist pairs render (`<u></u>`); any other tag is
// colored source. Runs after escapes so an escaped `\<` isn't seen as a tag.
parseHTMLTags(text, into: &walker.spans)
// Bare www./http(s)/email autolinks (GFM extension). Last, so every
// guard (code, math, real links, HTML tags, ) is already in place.
parseAutolinks(text, into: &walker.spans)
let opaqueRanges: [NSRange] = walker.spans.compactMap { span in
switch span.kind {
case .comment, .wikilink: return span.fullRange
default: return nil
}
}
if !opaqueRanges.isEmpty {
walker.spans.removeAll { span in
switch span.kind {
case .comment, .wikilink: return false
default: break
}
return opaqueRanges.contains {
$0.location <= span.fullRange.location && $0.upperBound >= span.fullRange.upperBound
}
}
}
// A callout's body is rendered recursively by the styling layer (it
// strips the `>` prefixes and re-parses the inner markdown), so drop any
// other span the custom parsers placed inside a callout keeping it
// would double-style the body. Plain block quotes are unaffected: their
// inline spans are intentionally kept.
let calloutRanges: [NSRange] = walker.spans.compactMap { span in
guard case .blockquote(_) = span.kind,
isCalloutFirstLine(of: span.fullRange, in: text) else { return nil }
return span.fullRange
}
if !calloutRanges.isEmpty {
walker.spans.removeAll { span in
if case .blockquote(_) = span.kind { return false }
return calloutRanges.contains {
$0.location <= span.fullRange.location && $0.upperBound >= span.fullRange.upperBound
}
}
}
return walker.spans.sorted { $0.fullRange.location < $1.fullRange.location }
}
/// Whether the first line of `range` in `text` opens a callout (`> [!type]`).
private static func isCalloutFirstLine(of range: NSRange, in text: String) -> Bool {
let ns = text as NSString
let nl = ns.range(of: "\n", options: [], range: range)
let lineEnd = nl.location == NSNotFound ? range.upperBound : nl.location
let line = ns.substring(with: NSRange(location: range.location,
length: lineEnd - range.location))
let trimmed = line.drop(while: { $0 == " " })
guard trimmed.first == ">" else { return false }
return Callout.parseMarker(String(trimmed.dropFirst())) != nil
}
}
@@ -0,0 +1,472 @@
import AppKit
// MARK: - Callout Rendering
//
// A callout is a block quote whose first line is `[!type]` (case-insensitive).
// swift-markdown gives us a plain `.blockquote` span; here we detect the marker
// and render the header line as an icon + title image (hiding the raw
// `[!type] ` source), with a customizable colored border + tinted background.
// Colors resolve per light/dark appearance. While the cursor is inside the
// callout the raw, editable marker is shown instead.
extension EditorTextView {
/// A detected callout on a block-quote span, with ranges mapped to absolute
/// offsets within the block string.
struct CalloutInfo {
let marker: Callout.Marker
let style: CalloutStyle
/// `[ '[' end-of-first-line )` the marker plus any custom title.
let headerRange: NSRange
/// Capitalized type name, or the custom title if the header has one.
let title: String
/// When the header carries a custom title, the source range of that
/// title text (leading whitespace trimmed). Rendered as real, wrapping
/// text so a long title wraps inside the box instead of clipping. `nil`
/// for a default callout, whose synthesized type name isn't in the
/// source and stays a compact icon+name overlay.
let customTitleRange: NSRange?
}
/// Returns callout info if `span` (a `.blockquote`) begins with a known
/// `[!type]` marker on its first line, else `nil` (a plain block quote).
func calloutInfo(forBlockquote span: SyntaxHighlighter.Span, markdown: String) -> CalloutInfo? {
guard let firstDelim = span.delimiterRanges.min(by: { $0.location < $1.location })
else { return nil }
let ns = markdown as NSString
let contentStart = firstDelim.upperBound
let blockEnd = min(span.fullRange.upperBound, ns.length)
guard contentStart < blockEnd else { return nil }
let searchRange = NSRange(location: contentStart, length: blockEnd - contentStart)
let nl = ns.range(of: "\n", options: [], range: searchRange)
let lineEnd = nl.location == NSNotFound ? blockEnd : nl.location
let firstLine = ns.substring(with: NSRange(location: contentStart, length: lineEnd - contentStart))
guard let rel = Callout.parseMarker(firstLine),
let style = Callout.style(for: rel.type, overrides: calloutStyleOverrides) else { return nil }
func abs(_ r: NSRange) -> NSRange { NSRange(location: r.location + contentStart, length: r.length) }
let marker = Callout.Marker(type: rel.type,
openBracket: abs(rel.openBracket),
typeRange: abs(rel.typeRange),
closeBracket: abs(rel.closeBracket))
let titleStart = marker.closeBracket.upperBound
let customRaw = titleStart < lineEnd
? ns.substring(with: NSRange(location: titleStart, length: lineEnd - titleStart)) : ""
let title = Callout.title(type: marker.type, customTitle: customRaw)
let headerRange = NSRange(location: marker.openBracket.location,
length: lineEnd - marker.openBracket.location)
// A custom title is the real source text after `]` (leading spaces
// skipped). Rendered as live wrapping text; absent default callout.
var customTitleRange: NSRange?
if !customRaw.trimmingCharacters(in: .whitespaces).isEmpty {
var s = titleStart
while s < lineEnd, ns.character(at: s) == 0x20 { s += 1 }
customTitleRange = NSRange(location: s, length: lineEnd - s)
}
return CalloutInfo(marker: marker, style: style, headerRange: headerRange,
title: title, customTitleRange: customTitleRange)
}
/// Applies callout styling: the box, the icon + title header image, and the
/// recursively-rendered body. Only called for an *inactive* callout when
/// the cursor is inside, the caller renders the raw `>` source instead so the
/// markers stay editable.
func styleCalloutContent(_ result: NSMutableAttributedString,
span: SyntaxHighlighter.Span,
info: CalloutInfo) {
guard span.fullRange.upperBound <= result.length else { return }
let c = resolvedCalloutColors(info.style)
// The box is drawn by DecoratedTextLayoutFragment behind every
// paragraph of the callout; the fragments tile into one continuous box.
func box(bottomPad: CGFloat) -> BlockDecoration {
BlockDecoration(.box(background: c.background,
borderColor: c.border,
borderEdges: info.style.borderEdges,
borderWidth: info.style.borderWidth,
bottomPad: bottomPad))
}
result.addAttribute(.blockDecoration, value: box(bottomPad: 0),
range: span.fullRange)
result.addAttribute(.paragraphStyle, value: calloutParagraphStyle(),
range: span.fullRange)
// Bottom breathing room: the last line's box carries a bottomPad, which
// grows that fragment's frame (see layoutFragmentFrame). The extra space
// is genuine clickable text space below the last line clicks there
// land on the callout, the next block tiles clear, and the box covers
// it no dead zone, no trailing paragraph spacing.
let ns = result.string as NSString
var lastLineStart = span.fullRange.location
let nl = ns.range(of: "\n", options: .backwards,
range: span.fullRange)
if nl.location != NSNotFound { lastLineStart = nl.upperBound }
let lastLine = NSRange(location: lastLineStart,
length: span.fullRange.upperBound - lastLineStart)
result.addAttribute(.blockDecoration, value: box(bottomPad: calloutBottomPad),
range: lastLine)
// End of the header (first) line, before any body lines.
let headerNL = ns.range(of: "\n", options: [], range: span.fullRange)
let headerLineEnd = headerNL.location == NSNotFound
? span.fullRange.upperBound : headerNL.location
let header = info.headerRange
let headerLine = NSRange(location: span.fullRange.location,
length: headerLineEnd - span.fullRange.location)
if header.length > 0, header.upperBound <= result.length {
if let titleRange = info.customTitleRange, titleRange.upperBound <= result.length {
// Custom title: hide the `[!type]` marker and render the title as
// real bold + tinted text so a long title WRAPS inside the box.
//
// NOTE: the type icon here is a stroked vector *path* overlay,
// never an image: drawing an image on this (potentially
// multi-line, wrapping) header line wedges TextKit 2's layout to
// a single line clipping the title by every image-drawing
// mechanism tried, while shape drawing is unaffected. See
// docs/investigations/archives/callout-title-wrap-investigation.md. Default callouts (the
// `else` below) keep their icon+name image: their synthesized
// type name is short and never wraps, so the single-line image
// overlay never hits the wedge.
let markerHide = NSRange(location: header.location,
length: titleRange.location - header.location)
if markerHide.length > 0 {
result.addAttribute(.font, value: hiddenFont, range: markerHide)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: markerHide)
}
let titleFont = NSFontManager.shared.convert(bodyFont, toHaveTrait: .boldFontMask)
result.addAttribute(.font, value: titleFont, range: titleRange)
result.addAttribute(.foregroundColor, value: c.accent, range: titleRange)
// Icon before the title: anchored on the hidden `[`, with the
// kern reserving the icon's advance plus a gap so the title
// starts clear of it (applyOverlay's own kern is only the icon
// width overwrite it).
var iconAdvance: CGFloat = 0
if let icon = calloutIconPathOverlay(iconName: info.style.iconName,
color: c.accent, titleFont: titleFont,
iconNudge: info.style.iconBaselineNudge) {
let anchor = NSRange(location: header.location, length: 1)
applyOverlay(icon, anchor: anchor, in: result)
iconAdvance = icon.bounds.width + bodyFont.pointSize * 0.3
result.addAttribute(.kern, value: iconAdvance, range: anchor)
}
// The title sits at the callout's left padding. The first line
// carries the width-preserved `> ` marker before the (hidden)
// `[!type]`, so its text starts `quoteMarkerWidth` past the
// inset, plus the icon's kerned advance; headIndent adds both so
// wrapped lines align under the title. Top breathing room is
// above the first line only (the box covers it).
let ps = NSMutableParagraphStyle()
ps.lineSpacing = bodyParagraphStyle.lineSpacing
ps.firstLineHeadIndent = 2
ps.headIndent = 2 + quoteMarkerWidth + iconAdvance
ps.tailIndent = -10
ps.paragraphSpacingBefore = calloutTopPad
result.addAttribute(.paragraphStyle, value: ps, range: headerLine)
} else {
// Default title (synthesized type name, not in the source, and
// short enough never to wrap): hide the whole header and draw the
// icon + name as one compact overlay image.
result.addAttribute(.font, value: hiddenFont, range: header)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: header)
if let overlay = calloutHeaderOverlay(iconName: info.style.iconName,
title: info.title, color: c.accent,
iconNudge: info.style.iconBaselineNudge) {
applyOverlay(overlay, anchor: NSRange(location: header.location, length: 1),
in: result)
result.addAttribute(
.paragraphStyle,
value: calloutParagraphStyle(minimumLineHeight: overlay.bounds.height + calloutTopPad),
range: headerLine)
}
}
}
// Render the body (the lines after the header) recursively: strip one
// `>` level, re-style the inner markdown, and splice it back so nested
// code/quotes/callouts/lists/etc. render inside the box.
renderCalloutBody(result, span: span, headerLineEnd: headerLineEnd)
}
/// Renders a callout's body every line after the header by stripping one
/// level of `>` prefix, running the full `styleBlock` over the stripped
/// inner markdown (which recurses into deeper callouts), and splicing the
/// resulting attributes back onto the real characters with the prefixes
/// hidden. Nested boxes/bars stack with the outer callout's box.
private func renderCalloutBody(_ result: NSMutableAttributedString,
span: SyntaxHighlighter.Span,
headerLineEnd: Int) {
let end = span.fullRange.upperBound
// Body starts after the header line's trailing newline.
guard headerLineEnd < end else { return }
let bodyStart = headerLineEnd + 1 // skip the `\n`
guard bodyStart < end else { return }
let ns = result.string as NSString
let space: unichar = 0x20, gt: unichar = 0x3E, newline: unichar = 0x0A
// Build the stripped inner markdown (UTF-16 units) with a parallel map
// back to real offsets, hide each line's `>` prefix, and remember each
// line's real and stripped ranges so we can splice paragraph styles and
// decorations per line.
var units: [unichar] = []
var realIndex: [Int] = [] // stripped UTF-16 offset real offset
var lineMap: [(real: NSRange, stripped: NSRange)] = []
var cursor = bodyStart
while cursor < end {
let lineNL = ns.range(of: "\n", options: [],
range: NSRange(location: cursor, length: end - cursor))
let lineEnd = lineNL.location == NSNotFound ? end : lineNL.location
// Leading `>` prefix: optional spaces, `>`, optional single space.
var p = cursor
while p < lineEnd, ns.character(at: p) == space { p += 1 }
if p < lineEnd, ns.character(at: p) == gt {
p += 1
if p < lineEnd, ns.character(at: p) == space { p += 1 }
}
let prefixLen = p - cursor
if prefixLen > 0 {
let pr = NSRange(location: cursor, length: prefixLen)
result.addAttribute(.font, value: hiddenFont, range: pr)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: pr)
}
let sStart = units.count
for i in p..<lineEnd { units.append(ns.character(at: i)); realIndex.append(i) }
lineMap.append((real: NSRange(location: cursor, length: lineEnd - cursor),
stripped: NSRange(location: sStart, length: units.count - sStart)))
if lineEnd < end {
units.append(newline); realIndex.append(lineEnd) // keep the `\n`
cursor = lineEnd + 1
} else {
cursor = end
}
}
guard !units.isEmpty else { return }
let stripped = String(utf16CodeUnits: units, count: units.count)
// Segment the stripped body with BlockParser and style each block on
// its own mirroring the top-level pipeline. This enforces strict
// `>`-prefix membership: a line without the deeper prefix is its own
// block, so swift-markdown's lazy continuation can't pull a `> ` line
// into an adjacent `> > ` callout/quote. The body is only rendered for an
// inactive callout (the cursor is elsewhere), so inner blocks render
// fully no cursor reveal needed.
let sub = NSMutableAttributedString(string: stripped, attributes: baseAttributes)
for b in BlockParser.parse(stripped) {
guard b.range.upperBound <= sub.length else { continue }
let styled = styleBlock(b.content, cursorPosition: nil)
styled.enumerateAttributes(in: NSRange(location: 0, length: styled.length),
options: []) { attrs, r, _ in
sub.setAttributes(attrs,
range: NSRange(location: r.location + b.range.location, length: r.length))
}
}
spliceStyledBody(sub, realIndex: realIndex, lineMap: lineMap, into: result)
}
/// Splices an inner-rendered body (`sub`, in stripped space) back onto the
/// real characters. Character attributes are mapped per run (skipping the
/// hidden prefixes); paragraph styles and block decorations are applied per
/// line, inset/stacked so inner content stays inside the outer box.
private func spliceStyledBody(_ sub: NSAttributedString,
realIndex: [Int],
lineMap: [(real: NSRange, stripped: NSRange)],
into result: NSMutableAttributedString) {
let step = 2 + quoteMarkerWidth // one nesting level of horizontal inset
let subLen = sub.length
// Character attributes (everything except paragraph style / decoration),
// mapped from stripped offsets to real offsets in coalesced runs.
sub.enumerateAttributes(in: NSRange(location: 0, length: subLen), options: []) { attrs, sr, _ in
var ca = attrs
ca[.paragraphStyle] = nil
ca[.blockDecoration] = nil
guard !ca.isEmpty else { return }
var k = sr.location
while k < sr.upperBound {
let runStart = realIndex[k]
var last = runStart
var j = k + 1
while j < sr.upperBound, realIndex[j] == last + 1 { last = realIndex[j]; j += 1 }
result.addAttributes(ca, range: NSRange(location: runStart, length: last - runStart + 1))
k = j
}
}
// Paragraph styles and decorations, per body line.
for lm in lineMap {
let ss = lm.stripped.location
guard ss < subLen else { continue }
// Paragraph style: inset by one level so inner content stays inside
// the box; preserve any inner style (list indent, centered math, ).
let innerPS = (sub.attribute(.paragraphStyle, at: ss, effectiveRange: nil)
as? NSParagraphStyle) ?? bodyParagraphStyle
let ps = innerPS.mutableCopy() as! NSMutableParagraphStyle
ps.firstLineHeadIndent += step
ps.headIndent += step
if ps.tailIndent == 0 { ps.tailIndent = -10 }
result.addAttribute(.paragraphStyle, value: ps, range: lm.real)
// Decoration: stack any inner box/bar (inset bumped) under the
// outer callout box already present on this line.
let innerDeco = sub.attribute(.blockDecoration, at: ss, effectiveRange: nil)
let bumped = bumpedDecorations(innerDeco, by: step)
if !bumped.isEmpty {
var stack: [BlockDecoration] = []
if let outer = result.attribute(.blockDecoration, at: lm.real.location,
effectiveRange: nil) as? BlockDecoration {
stack.append(outer)
}
stack.append(contentsOf: bumped)
result.addAttribute(.blockDecoration, value: BlockDecorationList(stack), range: lm.real)
}
}
}
/// Returns inner decorations with every `.box` inset increased by `step`
/// (so a nested box sits within its parent); other kinds are unchanged.
private func bumpedDecorations(_ value: Any?, by step: CGFloat) -> [BlockDecoration] {
func bump(_ d: BlockDecoration) -> BlockDecoration {
if case .box = d.kind { return BlockDecoration(d.kind, inset: d.inset + step) }
return d
}
if let list = value as? BlockDecorationList { return list.decorations.map(bump) }
if let single = value as? BlockDecoration { return [bump(single)] }
return []
}
// MARK: Colors (appearance-aware)
private var isDarkAppearance: Bool {
effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
private func resolvedCalloutColors(_ style: CalloutStyle)
-> (accent: NSColor, border: NSColor, background: NSColor) {
let dark = isDarkAppearance
let accent = NSColor(hex: style.accentHex(dark: dark)) ?? accentColor
let border = NSColor(hex: style.resolvedBorderHex(dark: dark)) ?? accent
let background: NSColor
if let bgHex = style.explicitBackgroundHex(dark: dark), let bg = NSColor(hex: bgHex) {
background = bg
} else {
background = accent.withAlphaComponent(style.backgroundAlpha)
}
return (accent, border, background)
}
// MARK: Padding constants (shared by the box and the header image)
/// Top breathing room raised on the header line's minimum line height
/// (clickable text space), not dead block padding.
private var calloutTopPad: CGFloat { bodyFont.pointSize * 0.8 }
/// Bottom breathing room. Delivered by growing the last line's layout
/// fragment frame (a box `bottomPad`), so it is genuine clickable text
/// space below the last line not trailing paragraph spacing, which
/// TextKit 2 leaves out of the fragment and which clicks would miss.
/// Tuned so the *rendered* bottom gap matches the rendered top gap: the
/// header overlay sits low in its line, so the top renders ~0.4·pointSize
/// larger than `calloutTopPad`, and this makes the bottom match it.
var calloutBottomPad: CGFloat { bodyFont.pointSize * 1.14 }
// MARK: Paragraph style (text insets; the box itself is a BlockDecoration)
/// Text insets the NSTextBlock padding used to provide. The left inset is
/// kept small so the callout's text lines up with a plain block quote's
/// the quote's 2pt bar inset matches this 2pt and the top breathing room
/// lives in the header image (clickable text space). The bottom breathing
/// room is the last line's box `bottomPad` (which grows that fragment's
/// frame), so the drawn box covers it and clicks there land on the
/// callout's last line no trailing paragraph spacing needed.
private func calloutParagraphStyle(minimumLineHeight: CGFloat = 0) -> NSParagraphStyle {
let ps = NSMutableParagraphStyle()
ps.lineSpacing = bodyParagraphStyle.lineSpacing
ps.firstLineHeadIndent = 2
// Hanging indent so wrapped body lines align after the `> ` marker,
// matching list items and plain blockquotes.
ps.headIndent = 2 + quoteMarkerWidth
ps.tailIndent = -10
ps.minimumLineHeight = minimumLineHeight
return ps
}
// MARK: Header icon (custom title stroked path, never an image)
/// The type icon for a custom-title header, as a stroked-path overlay
/// sized to a `pointSize` square and vertically centered on the bold
/// title's optical middle (same optics as the header image below). A path
/// not an image because the custom-title line wraps, and an image
/// drawn on a multi-line fragment wedges its layout to one line (see
/// docs/investigations/archives/callout-title-wrap-investigation.md). `nil` for an unknown icon.
private func calloutIconPathOverlay(iconName: String, color: NSColor,
titleFont: NSFont, iconNudge: CGFloat) -> FragmentOverlay? {
let pointSize = bodyFont.pointSize
guard let svgPath = LucideIcons.path(iconName) else { return nil }
let scale = pointSize / 24 // Lucide viewBox icon square
var transform = CGAffineTransform(scaleX: scale, y: scale)
guard let scaled = svgPath.copy(using: &transform) else { return nil }
// bounds.minY is the icon's *bottom* relative to the baseline: center
// the square on the title's optical middle (midpoint of x-height and
// cap-height centers matches the header image's icon placement).
let opticalCenter = (titleFont.xHeight + titleFont.capHeight) / 4
return FragmentOverlay(path: scaled, color: color, lineWidth: 2 * scale,
bounds: CGRect(x: 0,
y: opticalCenter - pointSize / 2 + iconNudge,
width: pointSize, height: pointSize))
}
// MARK: Header image (icon + title)
/// Draws "icon Title" into one image, tinted to the callout color, and
/// wraps it in a `FragmentOverlay`. Returns `nil` if the Lucide icon can't
/// be resolved. The top breathing room is NOT in the image the caller
/// raises the header line's minimum line height instead.
private func calloutHeaderOverlay(iconName: String, title: String, color: NSColor,
iconNudge: CGFloat) -> FragmentOverlay? {
let pointSize = bodyFont.pointSize
guard let symbol = LucideIcons.image(iconName, color: color, pointSize: pointSize)
else { return nil }
let titleFont = NSFontManager.shared.convert(bodyFont, toHaveTrait: .boldFontMask)
let titleAttrs: [NSAttributedString.Key: Any] = [.font: titleFont, .foregroundColor: color]
let titleStr = NSAttributedString(string: title, attributes: titleAttrs)
let titleSize = titleStr.size()
let gap = pointSize * 0.3
let symW = symbol.size.width, symH = symbol.size.height
let contentHeight = ceil(max(symH, titleSize.height))
let width = ceil(symW + gap + titleSize.width)
let image = NSImage(size: NSSize(width: width, height: contentHeight), flipped: false) { _ in
let titleY = (contentHeight - titleSize.height) / 2
titleStr.draw(at: NSPoint(x: symW + gap, y: titleY))
// Center the icon on the visual middle of the bold title: the midpoint
// between its x-height center (too low on its own) and cap-height center
// (~1.5px too high on its own). This reads as centered for the
// mostly-lowercase, capital-initial titles.
let baseline = titleY + abs(titleFont.descender)
let opticalCenter = baseline + (titleFont.xHeight + titleFont.capHeight) / 4
symbol.draw(in: NSRect(x: 0, y: opticalCenter - symH / 2 + iconNudge, width: symW, height: symH))
return true
}
// Re-rasterize at the screen's backing scale on every draw rather than
// caching a 1× bitmap (which would render the composited title soft).
image.cacheMode = .never
return FragmentOverlay(image: image,
bounds: CGRect(x: 0, y: -pointSize * 0.15,
width: width, height: contentHeight))
}
}
@@ -0,0 +1,39 @@
import AppKit
// MARK: - Code Block Syntax Highlighting
//
// Colors a fenced code block's content from `CodeHighlighter` tokens, using the
// Tomorrow palette in light appearance and One Dark in dark. Only foregrounds
// are themed the block keeps the editor's background so each palette is
// paired with the appearance whose background it's legible on.
extension EditorTextView {
private var prefersDarkCodeTheme: Bool {
effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
/// The `NSColor` for a token kind (`nil` = plain code) in the current
/// appearance, derived from the shared `CodeSyntaxPalette` hexes so the
/// editor and Read mode / PDF export color tokens identically.
private func codeColor(_ type: CodeHighlighter.TokenType?) -> NSColor {
NSColor(hex: CodeSyntaxPalette.hex(type, dark: prefersDarkCodeTheme)) ?? .textColor
}
/// Applies syntax colors to a code block's content range in place.
func highlightCodeBlock(_ result: NSMutableAttributedString,
contentRange: NSRange, language: String?) {
guard contentRange.length > 0, contentRange.upperBound <= result.length else { return }
// Plain code text first; token colors paint over it.
result.addAttribute(.foregroundColor, value: codeColor(nil), range: contentRange)
let code = (result.string as NSString).substring(with: contentRange)
for token in CodeHighlighter.tokenize(code, language: language) {
let abs = NSRange(location: contentRange.location + token.range.location,
length: token.range.length)
guard abs.upperBound <= result.length else { continue }
result.addAttribute(.foregroundColor, value: codeColor(token.type), range: abs)
}
}
}
@@ -0,0 +1,211 @@
import AppKit
// MARK: - Image Rendering
//
// `![alt](path)` renders the referenced image inline when the cursor is outside
// the token, and shows the raw, editable markdown when the cursor is inside it
// (the `.image` branch of `styleBlock`). A loaded image is drawn by a
// `FragmentOverlay` anchored on the leading `!` the same mechanism math and
// list markers use with the rest of the markdown hidden and the line height
// reserved for the picture. An image that can't be shown (outside the token)
// gets the same overlay treatment, but with a small icon + reason in place of
// the picture, so the user knows *why* not just that nothing rendered.
//
// Resolution: absolute paths, `~`-paths, and `file:` URLs load directly;
// relative paths resolve against the document's directory. A remote `https`
// image loads only when `allowRemoteImages` is on (mirrors Read mode's
// `allowRemoteImages`/`blockExternalImages`), and asynchronously loading it
// synchronously on the styling path would block the main thread. `http` never
// loads: App Transport Security refuses the insecure connection outright,
// regardless of the setting (same reasoning as Read mode's DocumentHTML).
// Loaded images are cached by resolved absolute path (local) or URL string
// (remote), so a recompose doesn't re-read/re-fetch them. NSCache is
// internally thread-safe.
nonisolated(unsafe) private let imageCache = NSCache<NSString, NSImage>()
// Remote URLs currently being fetched, so a burst of re-styles (scrolling,
// cursor moves near the image) doesn't kick off duplicate downloads. Mutated
// only on the main actor: inserted synchronously from `loadRemoteImage`
// (called while styling, always on the main thread) and removed inside the
// fetch completion's `@MainActor` hop.
nonisolated(unsafe) private var inFlightRemoteImages = Set<String>()
// Remote URLs that were fetched and turned out not to decode as an image, so
// repeated re-styles show "Not an image" instead of re-fetching forever.
nonisolated(unsafe) private var undecodableRemoteImages = Set<String>()
/// Why an `![alt](destination)` couldn't be shown the short label a
/// blocked-image placeholder draws next to its icon. Shared by Edit mode
/// (this file) and Read mode/export (`DocumentHTML`) so the two report the
/// same reason, in the same words, for the same failure.
enum ImageLoadFailure {
case httpUnsupported
case blockedBySetting
case notAnImage
case notFound
var label: String {
switch self {
case .httpUnsupported: return "HTTP connection not supported"
case .blockedBySetting: return "External images blocked"
case .notAnImage: return "Not an image"
case .notFound: return "Image not found"
}
}
}
extension EditorTextView {
/// What `styleBlock` should show for an image token when the cursor is
/// outside it.
enum ImageDisplay {
/// The image loaded; draw it.
case image(NSImage)
/// It can't be shown; draw an icon + `failure.label` in its place.
case blocked(ImageLoadFailure)
/// A remote fetch is in flight transient, not an error; the caller
/// falls back to plain alt text until a recompose picks up the result.
case pending
}
/// Resolves and (for local files) loads the image referenced by
/// `destination`, classifying why it can't be shown when it can't.
func imageDisplay(destination: String) -> ImageDisplay {
let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines)
guard !dest.isEmpty else { return .blocked(.notFound) }
if let scheme = URL(string: dest)?.scheme?.lowercased(), scheme == "http" || scheme == "https" {
guard scheme == "https" else { return .blocked(.httpUnsupported) }
guard allowRemoteImages else { return .blocked(.blockedBySetting) }
return loadRemoteImage(dest)
}
guard let url = resolveImageURL(dest) else { return .blocked(.notFound) }
let key = url.path as NSString
if let cached = imageCache.object(forKey: key) { return .image(cached) }
// `resolveImageURL` builds a URL from the path string alone (it doesn't
// check existence), so a missing file and an undecodable one both fail
// `NSImage(contentsOf:)` the same way check existence first so the two
// get distinct, accurate messages.
guard FileManager.default.fileExists(atPath: url.path) else { return .blocked(.notFound) }
guard let image = NSImage(contentsOf: url) else { return .blocked(.notAnImage) }
imageCache.setObject(image, forKey: key)
return .image(image)
}
/// Returns the cached/decoded outcome for a remote `urlString`; otherwise
/// starts an async fetch (once per URL, while one is already in flight)
/// and returns `.pending`. The completion caches the image (or remembers a
/// decode failure) and re-styles the document so the result appears
/// without blocking the main thread on network I/O.
private func loadRemoteImage(_ urlString: String) -> ImageDisplay {
let key = urlString as NSString
if let cached = imageCache.object(forKey: key) { return .image(cached) }
if undecodableRemoteImages.contains(urlString) { return .blocked(.notAnImage) }
guard !inFlightRemoteImages.contains(urlString), let url = URL(string: urlString) else { return .pending }
inFlightRemoteImages.insert(urlString)
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
let image = data.flatMap { NSImage(data: $0) }
Task { @MainActor in
inFlightRemoteImages.remove(urlString)
if let image {
imageCache.setObject(image, forKey: urlString as NSString)
} else {
undecodableRemoteImages.insert(urlString)
}
self?.recomposeAllDirty()
}
}.resume()
return .pending
}
/// Resolves a destination string to a local file URL. Returns nil for a
/// remote URL (handled separately, before this is reached) or when a
/// relative path can't be anchored.
private func resolveImageURL(_ destination: String) -> URL? {
let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines)
guard !dest.isEmpty else { return nil }
if let url = URL(string: dest), let scheme = url.scheme {
return scheme == "file" ? url : nil
}
if dest.hasPrefix("/") { return URL(fileURLWithPath: dest) }
if dest.hasPrefix("~") {
return URL(fileURLWithPath: (dest as NSString).expandingTildeInPath)
}
// Relative to the document's directory.
if let docDir = document?.fileURL?.deletingLastPathComponent() {
return docDir.appendingPathComponent(dest)
}
return nil
}
/// A `FragmentOverlay` for `destination`'s image or placeholder, or nil
/// while a remote fetch is pending (the caller then shows plain alt text).
/// `width`/`height` are declared pixel dimensions from an HTML `<img>` tag.
func imageOverlay(destination: String, width: Int? = nil, height: Int? = nil) -> FragmentOverlay? {
switch imageDisplay(destination: destination) {
case .image(let image):
return scaledOverlay(image: image, width: width, height: height)
case .blocked(let failure):
return placeholderOverlay(failure: failure)
case .pending:
return nil
}
}
/// Scales `image` down to fit the text width while keeping its aspect
/// ratio. `bounds.minY == 0` sits the image bottom on the text baseline
/// (the reserved line height makes room above it). Declared `width`/
/// `height` override the natural size first (one alone scales the other
/// proportionally); the max-width clamp still applies after.
private func scaledOverlay(image: NSImage, width: Int? = nil, height: Int? = nil) -> FragmentOverlay? {
var size = image.size
guard size.width > 0, size.height > 0 else { return nil }
switch (width, height) {
case let (w?, h?): size = NSSize(width: CGFloat(w), height: CGFloat(h))
case let (w?, nil): size = NSSize(width: CGFloat(w),
height: size.height * CGFloat(w) / size.width)
case let (nil, h?): size = NSSize(width: size.width * CGFloat(h) / size.height,
height: CGFloat(h))
case (nil, nil): break
}
let maxWidth = availableContentWidth
if maxWidth > 0, size.width > maxWidth {
size = NSSize(width: maxWidth, height: size.height * (maxWidth / size.width))
}
return FragmentOverlay(image: image,
bounds: CGRect(x: 0, y: 0, width: size.width, height: size.height))
}
/// Draws "icon reason" into one image (same technique as the callout
/// header: `LucideIcons.image` tinted to match the muted text), so a
/// blocked/missing/undecodable image reads at a glance instead of just
/// showing nothing.
private func placeholderOverlay(failure: ImageLoadFailure) -> FragmentOverlay? {
let pointSize = bodyFont.pointSize
guard let icon = LucideIcons.image("image-off", color: .secondaryLabelColor, pointSize: pointSize)
else { return nil }
let labelAttrs: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor]
let label = NSAttributedString(string: failure.label, attributes: labelAttrs)
let labelSize = label.size()
let gap = pointSize * 0.3
let iconW = icon.size.width, iconH = icon.size.height
let height = ceil(max(iconH, labelSize.height))
let width = ceil(iconW + gap + labelSize.width)
let image = NSImage(size: NSSize(width: width, height: height), flipped: false) { _ in
icon.draw(in: NSRect(x: 0, y: (height - iconH) / 2, width: iconW, height: iconH))
label.draw(at: NSPoint(x: iconW + gap, y: (height - labelSize.height) / 2))
return true
}
image.cacheMode = .never // re-rasterize at the screen's backing scale, like the callout header
return FragmentOverlay(image: image, bounds: CGRect(x: 0, y: 0, width: width, height: height))
}
}
@@ -0,0 +1,93 @@
import AppKit
/// List-item marker styling: maps a list item's leading whitespace to a nesting
/// depth, indents the content by one marker "slot" per level (Apple Notes
/// style), and positions the raw/rendered marker so the text column stays put
/// whether or not the caret is inside the item. Extracted from the `styleBlock`
/// switch in EditorTextView+Rendering.
extension EditorTextView {
/// Styles the `.listItem` content for one span. The caller has already
/// bounds-checked `span.fullRange` against `result`.
func styleListItemSpan(_ result: NSMutableAttributedString,
span: SyntaxHighlighter.Span,
markdown: String,
ordered: Bool,
checkbox: SyntaxHighlighter.Span.Kind.CheckboxState?,
cursorInToken: Bool) {
// Indentation model (Apple Notes style): each nesting level steps
// in by one marker "slot" (pointSize-wide icon + a space), so a
// child's marker lands under its parent's content. All list types
// share the same slot, so their text lines up. The leading
// whitespace is hidden (by the delimiter styling) and the indent
// comes entirely from the paragraph style.
let markerStr = (markdown as NSString).substring(to: span.contentRange.location)
let leadingWS = markerStr.prefix(while: { $0 == " " || $0 == "\t" })
let spaceWidth = (" " as NSString).size(withAttributes: [.font: bodyFont]).width
let slotWidth = bodyFont.pointSize + spaceWidth
let depth = listDepth(leadingWhitespace: String(leadingWS))
let markerStart = listPadding + CGFloat(depth) * slotWidth
let contentIndent = markerStart + slotWidth
// The visible marker text ("- ", "1. ", "- [ ] "), without the
// leading whitespace (which we hide below).
let markerText = String(markerStr.dropFirst(leadingWS.count))
let markerWidth = (markerText as NSString).size(withAttributes: [.font: bodyFont]).width
let firstLineIndent: CGFloat
// For an active bullet we left-shift the raw "-" onto the dot's
// column; this kern widens its trailing space so the content
// still begins at contentIndent (set after the paragraph style).
var activeBulletSpaceKern: CGFloat = 0
if ordered || (cursorInToken && checkbox != nil) {
// Ordered marker, or an active checkbox: right-align the marker
// into its slot so the content begins at `contentIndent` the
// same place as the rendered (inactive) form. This keeps the
// item aligned at every depth (and clicking in doesn't shift
// the text), while leaving the raw "1." / "- [ ]" editable.
// Wrapped lines hang at contentIndent via headIndent.
firstLineIndent = max(2, contentIndent - markerWidth)
} else if cursorInToken {
// Active bullet: sit the raw "-" on the dot's column instead of
// right-aligning it into the slot, so the marker doesn't jump
// sideways when you click into the item. The inactive dot is
// centered in a pointSize-wide box at markerStart, so center the
// dash there too; the kern below keeps the content at
// contentIndent.
let dashWidth = ("-" as NSString).size(withAttributes: [.font: bodyFont]).width
firstLineIndent = max(2, markerStart + (bodyFont.pointSize - dashWidth) / 2)
activeBulletSpaceKern = max(0, contentIndent - (firstLineIndent + markerWidth))
} else {
// Inactive bullet/checkbox: the marker icon sits at markerStart.
firstLineIndent = markerStart
}
// Hide the leading indentation the indent is provided entirely by
// the paragraph style. swift-markdown's list-item delimiter range
// starts at the marker and excludes this whitespace, so without
// hiding it here those spaces render visibly and push the first line
// right, breaking alignment with the hanging (wrapped-line) indent.
// (The deep-indent rescue parser already includes the whitespace in
// its delimiter; the delimiter styling below avoids re-showing it.)
let wsLen = leadingWS.count
if wsLen > 0 {
let lead = NSRange(location: 0, length: wsLen)
result.addAttribute(.font, value: hiddenFont, range: lead)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: lead)
}
// Apply paragraph style from position 0 NSTextView uses the paragraph
// style from the first character of a paragraph.
result.addAttribute(.paragraphStyle,
value: listParagraphStyle(firstLineIndent: firstLineIndent, contentIndent: contentIndent),
range: NSRange(location: 0, length: result.length))
// Active bullet: widen the marker's trailing space so the content
// lands at contentIndent even though the "-" sits on the dot column.
if activeBulletSpaceKern > 0, span.contentRange.location > 0,
span.contentRange.location <= result.length {
result.addAttribute(.kern, value: activeBulletSpaceKern,
range: NSRange(location: span.contentRange.location - 1, length: 1))
}
// Strikethrough checked items
if !ordered, checkbox == .checked {
result.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange)
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.contentRange)
}
}
}
@@ -0,0 +1,202 @@
import AppKit
// MARK: - List Rendering
//
// Everything that turns a list item's marker into its rendered form, used by
// the `.listItem` branch of `styleBlock` (in EditorTextView+Rendering.swift):
//
// - Indentation: `listPadding`, `listParagraphStyle`, `listDepth`. Each
// nesting level steps in by one marker "slot" so a child's marker lands
// under its parent's content (Apple Notes style).
// - Marker icons: `checkboxAttachment` (circle), `bulletAttachment` (dot).
// - `styleListDelimiter` applies the right treatment per list type when the
// item is inactive (cursor outside): bullet dot, checkbox circle,
// ordered dimmed "N." right-aligned into the slot. The leading
// whitespace is hidden so the indent comes purely from the paragraph style.
extension EditorTextView {
// MARK: Indentation
/// Fixed padding before the bullet/number marker for all list items.
var listPadding: CGFloat { 16 }
/// Paragraph style for a list item. `firstLineIndent` positions the marker
/// line; `contentIndent` (the hanging indent) aligns wrapped lines and the
/// text after the marker.
func listParagraphStyle(firstLineIndent: CGFloat, contentIndent: CGFloat) -> NSParagraphStyle {
let ps = NSMutableParagraphStyle()
ps.lineSpacing = bodyParagraphStyle.lineSpacing
ps.paragraphSpacing = bodyParagraphStyle.paragraphSpacing
ps.firstLineHeadIndent = firstLineIndent
ps.headIndent = contentIndent
return ps
}
/// Nesting depth of a list item from its leading whitespace, using the
/// document's detected indent unit (a tab counts as one unit/level).
func listDepth(leadingWhitespace ws: String) -> Int {
let unit = max(1, listIndentUnit)
var cols = 0
for ch in ws { cols += (ch == "\t") ? unit : 1 }
return cols / unit
}
// MARK: Marker Icons
/// Creates a fragment overlay with an SF Symbol for checkbox rendering.
/// Unchecked: dim outlined `circle`. Checked: filled `checkmark.circle.fill`.
private func checkboxOverlay(checked: Bool) -> FragmentOverlay {
let fontSize = bodyFont.pointSize
let symbolName = checked ? "checkmark.circle.fill" : "circle"
// Checked: white checkmark knocked out of an accent-tinted circle (two
// palette layers checkmark first, circle second). Unchecked: dim outline.
let palette: [NSColor] = checked ? [.white, accentColor] : [.tertiaryLabelColor]
let config = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular)
.applying(NSImage.SymbolConfiguration(paletteColors: palette))
// Render the symbol centered in a fontSize-square box so the box (and
// therefore list indentation) stays identical to the previous icon.
let symbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
.withSymbolConfiguration(config)
let box = NSSize(width: fontSize, height: fontSize)
let image = NSImage(size: box, flipped: false) { _ in
guard let symbol else { return true }
let s = symbol.size
symbol.draw(in: NSRect(x: (box.width - s.width) / 2,
y: (box.height - s.height) / 2,
width: s.width, height: s.height))
return true
}
// Vertically center the circle relative to the text baseline
return FragmentOverlay(image: image,
bounds: CGRect(x: 0, y: -fontSize * 0.15,
width: fontSize, height: fontSize))
}
/// Creates an overlay with a small filled dot for unordered bullets,
/// sized to the same box as the checkbox circle so bullet and todo lists
/// share one indentation (Apple Notes style).
private func bulletOverlay() -> FragmentOverlay {
let fontSize = bodyFont.pointSize
let image = NSImage(size: NSSize(width: fontSize, height: fontSize), flipped: true) { bounds in
let r = fontSize * 0.13 // small dot
let dot = NSRect(x: bounds.midX - r, y: bounds.midY - r, width: 2 * r, height: 2 * r)
// Match the dim used for numbered-list markers (syntaxDimColor).
NSColor.tertiaryLabelColor.setFill()
NSBezierPath(ovalIn: dot).fill()
return true
}
return FragmentOverlay(image: image,
bounds: CGRect(x: 0, y: -fontSize * 0.15,
width: fontSize, height: fontSize))
}
// MARK: Marker Styling
/// Applies custom non-active styling to a list item's delimiter range.
/// - Unordered bullet: small dot attachment.
/// - Unchecked / checked checkbox: circle icon (Apple Notes style).
/// - Ordered: dimmed "N." marker.
/// In all cases the leading whitespace is hidden so the indentation comes
/// from the paragraph style.
func styleListDelimiter(
_ result: NSMutableAttributedString,
markdown: String,
delimiterRange dr: NSRange,
ordered: Bool,
checkbox: SyntaxHighlighter.Span.Kind.CheckboxState?
) {
if ordered {
// Ordered lists: hide the leading whitespace (indent comes from the
// paragraph style) and dim the "N." marker.
let nsDelim = (markdown as NSString).substring(with: dr) as NSString
let digit = nsDelim.rangeOfCharacter(from: .decimalDigits)
let wsLen = digit.location == NSNotFound ? 0 : digit.location
if wsLen > 0 {
let before = NSRange(location: dr.location, length: wsLen)
result.addAttribute(.font, value: hiddenFont, range: before)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: before)
}
let numStart = dr.location + wsLen
result.addAttribute(.foregroundColor, value: syntaxDimColor,
range: NSRange(location: numStart, length: dr.upperBound - numStart))
return
}
if checkbox == nil {
// Plain bullet: render the dash as a small dot (Apple Notes style),
// sized to the checkbox box so all list types share one indent.
let nsDelim = (markdown as NSString).substring(with: dr) as NSString
let markerRel = nsDelim.rangeOfCharacter(from: CharacterSet(charactersIn: "-*+"))
guard markerRel.location != NSNotFound else {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
return
}
let markerAbs = dr.location + markerRel.location
// Hide any leading whitespace before the bullet (matches checkbox).
if markerRel.location > 0 {
let before = NSRange(location: dr.location, length: markerRel.location)
result.addAttribute(.font, value: hiddenFont, range: before)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: before)
}
// Dot overlay on the (hidden) bullet character.
applyOverlay(bulletOverlay(), anchor: NSRange(location: markerAbs, length: 1),
in: result)
// Dim the trailing space(s) after the bullet.
let afterStart = markerAbs + 1
if afterStart < dr.upperBound {
result.addAttribute(.foregroundColor, value: syntaxDimColor,
range: NSRange(location: afterStart, length: dr.upperBound - afterStart))
}
return
}
guard let checkbox = checkbox else { return }
let nsDelim = (markdown as NSString).substring(with: dr) as NSString
// --- Checkbox item: replace [ ]/[x] with circle icon ---
let bracketOpen = nsDelim.range(of: "[")
guard bracketOpen.location != NSNotFound else {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
return
}
let afterOpen = NSRange(location: bracketOpen.upperBound,
length: nsDelim.length - bracketOpen.upperBound)
let bracketClose = nsDelim.range(of: "]", options: [], range: afterOpen)
guard bracketClose.location != NSNotFound else {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
return
}
let cbStart = dr.location + bracketOpen.location
let cbEnd = dr.location + bracketClose.upperBound
// Hide everything before `[` (the "- " prefix) zero-width + clear
if bracketOpen.location > 0 {
let before = NSRange(location: dr.location, length: bracketOpen.location)
result.addAttribute(.font, value: hiddenFont, range: before)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: before)
}
// Circle overlay on the (hidden) `[` character
applyOverlay(checkboxOverlay(checked: checkbox == .checked),
anchor: NSRange(location: cbStart, length: 1), in: result)
// Hide remaining checkbox characters (` ]`/`x]`) with zero-width + clear
let hideStart = cbStart + 1
if hideStart < cbEnd {
let hideRange = NSRange(location: hideStart, length: cbEnd - hideStart)
result.addAttribute(.font, value: hiddenFont, range: hideRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange)
}
// Dim everything after `]` (trailing space)
if cbEnd < dr.upperBound {
let after = NSRange(location: cbEnd, length: dr.upperBound - cbEnd)
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: after)
}
}
}
@@ -0,0 +1,184 @@
import AppKit
import SwiftMath
/// A rendered equation: the image plus its typesetting descent, which we need to
/// sit the math on the surrounding text's baseline.
private final class MathRender {
let image: NSImage
let descent: CGFloat
init(image: NSImage, descent: CGFloat) {
self.image = image
self.descent = descent
}
}
/// Rendered math is cached so we don't re-typeset on every keystroke or
/// recompose. The key encodes everything that affects the pixels/metrics:
/// latex, display vs inline, font size, and the resolved text color.
// NSCache is internally thread-safe; `nonisolated(unsafe)` opts it out of the
// Swift 6 Sendable check (in practice it's only touched on the main actor).
nonisolated(unsafe) private let mathRenderCache = NSCache<NSString, MathRender>()
extension EditorTextView {
/// Renders a LaTeX string to a `FragmentOverlay` sized to `fontSize` and
/// aligned to the text baseline, or `nil` if SwiftMath can't parse it (the
/// caller then shows the raw source instead).
func mathOverlay(latex: String, display: Bool, fontSize: CGFloat) -> FragmentOverlay? {
// Resolve the (dynamic) text color against this view's appearance so the
// math renders in the right shade for light/dark and so the cache key
// differs between the two.
var color = foregroundColor
effectiveAppearance.performAsCurrentDrawingAppearance {
color = self.foregroundColor.usingColorSpace(.deviceRGB) ?? self.foregroundColor
}
let tag = String(format: "%.1f,%.3f,%.3f,%.3f,%.3f", fontSize,
color.redComponent, color.greenComponent,
color.blueComponent, color.alphaComponent)
let key = "\(display ? "D" : "I")|\(tag)|\(latex)" as NSString
let render: MathRender
if let cached = mathRenderCache.object(forKey: key) {
render = cached
} else {
let mode: MTMathUILabelMode = display ? .display : .text
let math = MTMathImage(latex: latex, fontSize: fontSize, textColor: color, labelMode: mode)
// SwiftMath sizes the image to the exact typographic ascent+descent,
// which crops a glyph's ink overshoot below the baseline the bottom
// of a lone `x`/`c` sits flush on the image edge and renders clipped.
// A small content inset gives the rasterizer room so the full glyph is
// drawn; it's folded into the descent below so alignment is unchanged.
let insetPad: CGFloat = 2
math.contentInsets = MTEdgeInsets(top: insetPad, left: 0, bottom: insetPad, right: 0)
let (error, image) = math.asImage()
guard error == nil, let image else { return nil }
// Typeset once more via a label to read ascent/descent, then compute
// the baseline's distance from the image bottom the way SwiftMath's
// asImage does including its `height < fontSize/2` clamp, which
// re-centers small glyphs (a lone x/c/n). Ignoring the clamp left
// those a pixel below the surrounding text baseline.
let label = MTMathUILabel()
label.latex = latex
label.fontSize = fontSize
label.labelMode = mode
label.layout()
let asc = label.displayList?.ascent ?? 0
let desc = label.displayList?.descent ?? 0
let clamped = max(asc + desc, fontSize / 2)
let descent = (asc + desc - clamped) / 2 + desc + insetPad
render = MathRender(image: image, descent: descent)
mathRenderCache.setObject(render, forKey: key)
}
var width = render.image.size.width
var height = render.image.size.height
var descent = render.descent
// Interim until SwiftMath line-wrapping ships: if the equation is wider
// than the text area, scale it down to fit (otherwise leave it natural
// size). The baseline descent scales with it.
let maxWidth = availableContentWidth
if maxWidth > 0, width > maxWidth {
let scale = maxWidth / width
width *= scale
height *= scale
descent *= scale
}
// The rendered image's baseline sits exactly one device pixel below the
// surrounding text baseline (measured constant across font sizes it's a
// fixed rasterization offset, not a size-dependent rounding). Lift the
// image by one device pixel so the math rests on the text baseline. Done
// here, not in the cached descent, so it tracks the window's scale if it
// moves between a Retina and a non-Retina display.
let backingScale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
descent -= 1 / backingScale
// Drop the image so its baseline (descent above the image bottom) lands
// on the text baseline.
return FragmentOverlay(image: render.image,
bounds: CGRect(x: 0, y: -descent, width: width, height: height))
}
/// The usable text width for one line the text container minus its line
/// fragment padding on both sides. Used to cap over-wide equations (and
/// over-wide images).
var availableContentWidth: CGFloat {
guard let container = textContainer else { return 0 }
return container.containerSize.width - 2 * container.lineFragmentPadding
}
// MARK: - Raw LaTeX Source (shown when the cursor is inside the math)
/// Colors raw LaTeX source: operators/commands (`_`, `^`, `\sum`, `\cdot`,
/// i.e. a backslash followed by letters) in the theme's math-operator color,
/// and numbers in the math-number color. Other characters keep their color.
func colorMathSource(_ result: NSMutableAttributedString, range: NSRange) {
guard range.length > 0, range.upperBound <= result.length else { return }
let ns = result.string as NSString
let opColor = theme.mathOperatorColor
let numColor = theme.mathNumberColor
let backslash: unichar = 0x5C, underscore: unichar = 0x5F, caret: unichar = 0x5E
func isAlpha(_ c: unichar) -> Bool { (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) }
func isDigit(_ c: unichar) -> Bool { c >= 0x30 && c <= 0x39 }
var i = range.location
let end = range.upperBound
while i < end {
let c = ns.character(at: i)
if c == backslash {
// Command: backslash + following letters (\sum, \cdot). A
// backslash before a non-letter (\,, \{) colors just the pair.
var j = i + 1
while j < end, isAlpha(ns.character(at: j)) { j += 1 }
let cmdEnd = j > i + 1 ? j : min(i + 2, end)
result.addAttribute(.foregroundColor, value: opColor,
range: NSRange(location: i, length: cmdEnd - i))
i = cmdEnd
} else if c == underscore || c == caret {
result.addAttribute(.foregroundColor, value: opColor,
range: NSRange(location: i, length: 1))
i += 1
} else if isDigit(c) {
var j = i + 1
while j < end, isDigit(ns.character(at: j)) { j += 1 }
result.addAttribute(.foregroundColor, value: numColor,
range: NSRange(location: i, length: j - i))
i = j
} else {
i += 1
}
}
}
/// Centered paragraph style for display math. The vertical padding is applied
/// only to the image's (first) line a multi-line `$$$$` block is
/// several paragraphs in the text storage (its hidden inner lines), so
/// padding every paragraph would multiply into a huge gap.
///
/// `imageAscent`/`imageDescent` reserve the equation's height on that line:
/// the line's own characters are hidden (near-zero), so without it the line
/// collapses. They're reserved separately, not as one combined height,
/// because of how TextKit 2 grows a line to meet `minimumLineHeight`: with
/// the hidden anchor's near-zero natural metrics, it adds ~all of the extra
/// height as ascent and pins the baseline at the box's bottom edge (measured
/// empirically a tall multi-row image split ~54/46 ascent/descent left a
/// matching-sized gap above the equation and an overlap with the next
/// paragraph below, since `minimumLineHeight = full height` reserved that
/// height entirely above the baseline). Reserving only `imageAscent` in
/// `minimumLineHeight` sits the image's top flush with the box's top instead
/// of leaving a surplus gap, and folding `imageDescent` into the *following*
/// spacing (rather than the line's own height) gives the part of the image
/// that hangs below the baseline somewhere to go before the next paragraph.
func displayMathParagraphStyle(padded: Bool, imageAscent: CGFloat = 0,
imageDescent: CGFloat = 0) -> NSParagraphStyle {
let ps = NSMutableParagraphStyle()
ps.alignment = .center
ps.lineSpacing = 0
let pad = padded ? bodyFont.pointSize * 0.9 : 0
ps.paragraphSpacingBefore = pad
ps.paragraphSpacing = pad + imageDescent
ps.minimumLineHeight = imageAscent
return ps
}
}
@@ -0,0 +1,750 @@
import AppKit
extension NSAttributedString.Key {
/// Stores a link's destination (URL string) on its visible text so a
/// cmd+click can follow it. Kept separate from the system `.link` attribute
/// to avoid NSTextView's built-in link styling/cursor behavior.
static let editorLinkURL = NSAttributedString.Key("EditorLinkURL")
/// Stores a wikilink's raw `path#heading` target on its visible text so a
/// cmd+click can resolve it to a file or in-document heading.
static let editorWikiTarget = NSAttributedString.Key("EditorWikiTarget")
}
// MARK: - Word-Level Styling
//
// This file is the heart of the inline live preview. `styleBlock` takes one
// block's raw markdown, parses it into spans (SyntaxHighlighter), and returns
// an NSAttributedString that decorates the *same* characters the text storage
// always holds the raw markdown, never a stripped version. Formatting is purely
// attribute-based:
//
// - Content gets rich styling (bold/italic, code color, heading size, ).
// - Inline delimiters (`**`, `*`, `` ` ``, `$`) are hidden when the cursor is
// outside the token (near-zero font + clear color) and dimmed when inside.
// - Block markers (`#`, `>`, list bullets) are decorated or dimmed, never
// stripped, so editing stays WYSIWYG-ish and round-trips losslessly.
//
// Larger, self-contained pieces live in sibling files to keep this one focused:
// - EditorTextView+ListMarkerRendering.swift the `.listItem` styling case
// - EditorTextView+TableRendering.swift the `.table` styling case
// - EditorTextView+ListRendering.swift list/checkbox/bullet markers + indent
// - EditorTextView+TableSupport.swift table border blocks + row parsing
// - EditorTextView+MathRendering.swift `$$` / `$$$$` rendering + raw coloring
//
// What remains here: the styling primitives (fonts/colors/paragraph styles),
// the `styleBlock` switch that dispatches per span kind, and the in-place
// `restyleBlock` / `applyBlockStyle` used to re-style a single block on edits.
extension EditorTextView {
/// Color for dimmed syntax delimiters (*, **, `, #, etc.)
var syntaxDimColor: NSColor { .tertiaryLabelColor }
/// Color for links and wikilinks always the theme's accent blue, independent of
/// the system accent so links stay consistently blue across user accent preferences.
var linkColor: NSColor { theme.linkBlueColor }
/// Monospaced font for tables.
var tableFont: NSFont { theme.monospaceFont() }
/// Monospaced font for code blocks.
var codeBlockFont: NSFont { theme.monospaceFont() }
/// Font used to visually hide delimiter characters.
/// Near-zero size makes them effectively invisible and zero-width.
var hiddenFont: NSFont { NSFont.systemFont(ofSize: 0.01) }
/// Monospaced font for inline code spans.
var inlineCodeFont: NSFont { theme.monospaceFont() }
/// Subtle background color for inline code spans.
var inlineCodeBackground: NSColor {
NSColor(calibratedWhite: 0.5, alpha: 0.1)
}
/// Paragraph style for thematic breaks. The raw dashes are hidden with a
/// near-zero font, which would collapse the line so we force the line to a
/// full body-line height and add symmetric breathing space above and below.
/// A `.horizontalRule` BlockDecoration draws the hairline centered in it.
private func thematicBreakParagraphStyle() -> NSParagraphStyle {
let lineHeight = bodyFont.pointSize + theme.lineSpacing
let ps = NSMutableParagraphStyle()
// Force a real line height despite the hidden (0.01pt) dashes.
ps.minimumLineHeight = lineHeight
ps.maximumLineHeight = lineHeight
// Symmetric breathing space. The rule is drawn centered in the
// fragment, so paragraphSpacingBefore sits above the line (and the
// rule) while paragraphSpacing sits below equal values keep the
// rule visually equidistant from the text on either side. Kept small so
// the break occupies roughly a body line plus a little air, not a full
// blank line above and below.
let pad = bodyFont.pointSize * 0.2
ps.paragraphSpacingBefore = pad
ps.paragraphSpacing = pad
return ps
}
/// How far below the rule fragment's geometric center to draw the hairline.
/// Adjacent text sits at its baseline (low in its line box), so a
/// center-drawn rule looks too close to the line above; this nudge brings
/// it down to the optical midpoint between the surrounding text. Tuned
/// against rendered output (see RenderingRegressionTests / screencapture).
var thematicBreakCenterOffset: CGFloat { bodyFont.pointSize * 0.3 }
/// Width of the `> ` quote marker in body text. Used as the hanging indent
/// for blockquotes and callouts so wrapped/continuation lines align after
/// the marker (like list items) rather than under the `>`. The marker is
/// rendered width-preserved (clear when inactive, dimmed when active) on
/// each line's first visual line, so subsequent lines hang by this width.
var quoteMarkerWidth: CGFloat {
("> " as NSString).size(withAttributes: [.font: bodyFont]).width
}
/// Paragraph style for blockquotes: a 2pt text inset matching the width of
/// the left bar that the `.leftBar` BlockDecoration draws, plus a hanging
/// indent so wrapped lines align after the `> ` marker.
private func blockquoteParagraphStyle() -> NSParagraphStyle {
let ps = NSMutableParagraphStyle()
ps.lineSpacing = bodyParagraphStyle.lineSpacing
ps.paragraphSpacing = bodyParagraphStyle.paragraphSpacing
ps.firstLineHeadIndent = 2
ps.headIndent = 2 + quoteMarkerWidth
return ps
}
// MARK: - Delimiter Hiding Classification
/// Returns true if this span kind's delimiters should be hidden (not just
/// dimmed) when the cursor is not inside the token.
private func isDelimiterHideable(_ kind: SyntaxHighlighter.Span.Kind) -> Bool {
switch kind {
case .bold, .italic, .boldItalic, .strikethrough, .highlight,
.code, .link, .image, .lineBreak,
.heading, .blockquote(_), .footnoteReference, .escape:
return true
case .listItem, .table, .codeBlock, .thematicBreak, .footnoteDefinition, .comment,
.htmlTag, .htmlFormat:
// htmlTag: always colored source (brackets dimmed by the generic
// pass). htmlFormat: handled explicitly in the delimiter loop.
return false
case .wikilink:
// The `[[`, optional `target|`, and `]]` are hidden when rendered,
// dimmed when the cursor is inside (like other inline delimiters).
return true
case .math(let display):
// Inline math hides its `$` like other inline tokens; display math
// is block-level and handled specially.
return !display
}
}
// MARK: - Unified Styling
/// Styles raw markdown text with rich attributes. Inline delimiters are hidden
/// unless the cursor is inside the token (in which case they're dimmed).
/// Block-level markers are always dimmed, never hidden.
///
/// - Parameters:
/// - markdown: Raw markdown text.
/// - cursorPosition: Cursor offset within the markdown (nil = hide all inline delimiters).
func styleBlock(_ markdown: String, cursorPosition: Int? = nil,
hideComments: Bool = false) -> NSAttributedString {
let result = NSMutableAttributedString(string: markdown, attributes: baseAttributes)
guard !markdown.isEmpty else { return result }
let spans = SyntaxHighlighter.parse(markdown, linkDefinitions: linkDefState.defsText)
// The font already applied at `loc` the enclosing heading's when
// inside one, else the base body font. Inline spans derive their font
// from it so `# **bold** and `code`` keeps the heading's size. Spans
// apply in location order, so a heading (at the block start) styles
// its fullRange before any inner span reads the context.
func contextFont(at loc: Int) -> NSFont {
guard loc >= 0, loc < result.length else { return bodyFont }
return result.attribute(.font, at: loc, effectiveRange: nil) as? NSFont ?? bodyFont
}
// The mono font matching `ctx`'s scale: the plain inline-code font in
// body text, scaled up inside a heading.
func monoFont(for ctx: NSFont) -> NSFont {
let scale = ctx.pointSize / bodyFont.pointSize
return scale == 1 ? inlineCodeFont
: theme.monospaceFont(ofSize: inlineCodeFont.pointSize * scale)
}
for span in spans {
let cursorInToken = cursorPosition.map {
$0 >= span.fullRange.location && $0 <= span.fullRange.upperBound
} ?? false
// --- Content styling (applied first) ---
switch span.kind {
case .bold:
guard span.contentRange.upperBound <= result.length else { continue }
let ctx = contextFont(at: span.contentRange.location)
let bold = NSFontManager.shared.convert(ctx, toHaveTrait: .boldFontMask)
result.addAttribute(.font, value: bold, range: span.contentRange)
case .italic:
guard span.contentRange.upperBound <= result.length else { continue }
let ctx = contextFont(at: span.contentRange.location)
let italic = NSFontManager.shared.convert(ctx, toHaveTrait: .italicFontMask)
result.addAttribute(.font, value: italic, range: span.contentRange)
case .boldItalic:
guard span.contentRange.upperBound <= result.length else { continue }
let ctx = contextFont(at: span.contentRange.location)
let bi = NSFontManager.shared.convert(ctx, toHaveTrait: [.boldFontMask, .italicFontMask])
result.addAttribute(.font, value: bi, range: span.contentRange)
case .code:
guard span.contentRange.upperBound <= result.length else { continue }
let ctx = contextFont(at: span.contentRange.location)
result.addAttribute(.font, value: monoFont(for: ctx), range: span.contentRange)
result.addAttribute(.foregroundColor, value: foregroundColor, range: span.contentRange)
result.addAttribute(.backgroundColor, value: inlineCodeBackground, range: span.contentRange)
case .codeBlock(let language):
guard span.contentRange.upperBound <= result.length else { continue }
result.addAttribute(.font, value: codeBlockFont, range: span.contentRange)
highlightCodeBlock(result, contentRange: span.contentRange, language: language)
case .strikethrough:
guard span.contentRange.upperBound <= result.length else { continue }
result.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange)
case .highlight:
guard span.contentRange.upperBound <= result.length else { continue }
result.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: span.contentRange)
case .heading(let level):
guard span.fullRange.upperBound <= result.length else { continue }
let scale: CGFloat = level == 1 ? 1.5 : level == 2 ? 1.3 : level == 3 ? 1.15 : 1.0
let sized = NSFont(descriptor: bodyFont.fontDescriptor,
size: bodyFont.pointSize * scale) ?? bodyFont
let heading = NSFontManager.shared.convert(sized, toHaveTrait: .boldFontMask)
result.addAttribute(.font, value: heading, range: span.fullRange)
case .link(let destination):
guard span.contentRange.upperBound <= result.length else { continue }
result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange)
result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange)
if !destination.isEmpty {
result.addAttribute(.editorLinkURL, value: destination, range: span.contentRange)
}
case .wikilink(let target):
guard span.contentRange.upperBound <= result.length else { continue }
// The display text reads as a link; the brackets (and a
// `target|` alias prefix) are hidden/dimmed by the delimiter pass.
result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange)
result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue,
range: span.contentRange)
if !target.isEmpty {
result.addAttribute(.editorWikiTarget, value: target, range: span.contentRange)
}
case .image(let destination, let width, let height):
guard span.fullRange.upperBound <= result.length else { continue }
if !cursorInToken, let overlay = imageOverlay(destination: destination,
width: width, height: height) {
// Rendered: draw the image at the leading character (`!` of
// `![alt](path)`, `<` of `<img >`) and hide the rest of the
// source, reserving the line height so the picture has room.
let hideStart = span.fullRange.location + 1
let hideLen = span.fullRange.upperBound - hideStart
if hideLen > 0 {
let hideRange = NSRange(location: hideStart, length: hideLen)
result.addAttribute(.font, value: hiddenFont, range: hideRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange)
}
applyOverlay(overlay,
anchor: NSRange(location: span.fullRange.location, length: 1),
in: result)
reserveLineHeight(overlay.bounds.height,
forOverlayAt: span.fullRange.location, in: result)
} else if (markdown as NSString).character(at: span.fullRange.location) == 0x3C {
// Active (or pending) `<img >`: show the raw tag as colored
// HTML source, like any other tag.
styleRawHTMLTag(result, range: span.fullRange)
} else {
// Active, or the image couldn't be loaded: show the alt text
// link-colored (same as a plain link); delimiters are dimmed/hidden below.
result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange)
let italic = NSFontManager.shared.convert(bodyFont, toHaveTrait: .italicFontMask)
result.addAttribute(.font, value: italic, range: span.contentRange)
}
case .blockquote(let depth):
guard span.fullRange.upperBound <= result.length else { continue }
// A block quote whose first line is `[!type]` is a callout
// (GitHub-flavored) render it with an icon, colored label, and
// colored bar instead of the plain quote styling. Only depth 0
// ever detects as a callout: a callout nested inside a plain
// quote stays literal (see SyntaxHighlighter+Walker's
// visitBlockQuote), so no deeper span is ever callout-shaped.
if let callout = calloutInfo(forBlockquote: span, markdown: markdown), !cursorInToken {
styleCalloutContent(result, span: span, info: callout)
} else {
// Plain block quote (any nesting depth). Indent and draw
// this level's own bar regardless of active/inactive the
// generic delimiter pass (elsewhere in this function)
// separately decides whether this level's own `>` marker
// is hidden (inactive) or shown dimmed (active/editing).
//
// Per-level indentation comes from the width-preserved
// hidden `> ` markers alone (one more per level), so the
// first-line indent stays constant adding a paragraph
// indent per depth too would double the step. Only the
// hanging indent grows, to keep wrapped lines clear of
// all this line's markers.
//
// A nested quote's span range is a *subset* of its
// ancestors' (processed earlier, in outer-to-inner order:
// the walker emits a parent before descending to its
// children), so stacking here only has to keep whatever
// decoration the ancestor already painted over this same
// range and append this level's own bar bar x positions
// are absolute per level, independent of the line.
// The fragment vendor reads paragraph-level attributes at
// paragraph offset 0, but a nested span's range starts at
// its *own* `>` past the ancestors' markers on its first
// line. Extend back to the line start so the line's
// paragraph carries this level's decoration/indent (else
// the line draws only the ancestor's single bar).
let lineStart = (markdown as NSString)
.lineRange(for: NSRange(location: span.fullRange.location, length: 0)).location
let paraRange = NSRange(location: lineStart,
length: span.fullRange.upperBound - lineStart)
let ps = blockquoteParagraphStyle().mutableCopy() as! NSMutableParagraphStyle
ps.headIndent += CGFloat(depth) * quoteMarkerWidth
result.addAttribute(.paragraphStyle, value: ps, range: paraRange)
// The quote's own bar hugs the text top on its *first* line
// only (see BlockDecoration.hugsTextTop) interior lines
// fill their whole fragment so the bar tiles gap-free. The
// ancestor stack is read per sub-range: an ancestor's own
// first line (hugging) can coincide with this span's first
// line, but its interior lines never hug.
let firstLineEnd = min((markdown as NSString)
.lineRange(for: NSRange(location: lineStart, length: 0)).upperBound,
paraRange.upperBound)
let firstRange = NSRange(location: lineStart, length: firstLineEnd - lineStart)
let restRange = NSRange(location: firstLineEnd,
length: paraRange.upperBound - firstLineEnd)
for (range, hugs) in [(firstRange, true), (restRange, false)] {
guard range.length > 0 else { continue }
let ownBar = BlockDecoration(.leftBar(color: .tertiaryLabelColor, width: 2),
inset: CGFloat(depth) * quoteMarkerWidth,
hugsTextTop: hugs)
if depth == 0 {
result.addAttribute(.blockDecoration, value: ownBar, range: range)
} else {
let ancestor = result.attribute(.blockDecoration, at: range.location,
effectiveRange: nil)
let kept: [BlockDecoration]
if let list = ancestor as? BlockDecorationList {
kept = list.decorations
} else if let single = ancestor as? BlockDecoration {
kept = [single]
} else {
kept = []
}
result.addAttribute(.blockDecoration,
value: BlockDecorationList(kept + [ownBar]),
range: range)
}
}
// Only the outermost span fills content color: `contentRange`
// only trims a span's very first/last delimiter, not ones in
// the middle (a nested quote's own markers on later lines)
// a nested span's fill would repaint an ancestor's marker
// right back to visible, undoing that ancestor's delimiter
// pass (which already ran, earlier in this same loop). The
// outermost span's fill already covers all nested text, so
// deeper spans don't need to (re-)apply it.
if depth == 0 {
result.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor,
range: span.contentRange)
}
}
case .listItem(let ordered, let checkbox):
guard span.fullRange.upperBound <= result.length else { continue }
styleListItemSpan(result, span: span, markdown: markdown,
ordered: ordered, checkbox: checkbox,
cursorInToken: cursorInToken)
case .table:
guard span.fullRange.upperBound <= result.length else { continue }
styleTableSpan(result, span: span, cursorInToken: cursorInToken)
case .thematicBreak:
guard span.fullRange.upperBound <= result.length else { continue }
if cursorInToken {
// Active: show raw dashes, dimmed but keep the rendered
// rule's vertical metrics (forced line height + breathing
// space) so clicking in doesn't collapse the block's height
// and shift content below.
result.addAttribute(.paragraphStyle, value: thematicBreakParagraphStyle(), range: span.fullRange)
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.fullRange)
} else {
// Non-active: horizontal hairline decoration, hide raw text
result.addAttribute(.paragraphStyle, value: thematicBreakParagraphStyle(), range: span.fullRange)
result.addAttribute(.blockDecoration,
value: BlockDecoration(.horizontalRule(color: .separatorColor,
centerOffset: thematicBreakCenterOffset)),
range: span.fullRange)
result.addAttribute(.font, value: hiddenFont, range: span.fullRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: span.fullRange)
}
case .math(let display):
guard span.fullRange.upperBound <= result.length else { continue }
if cursorInToken {
// Active: show the raw LaTeX in monospace (like inline code),
// with LaTeX syntax coloring; `$` delimiters dimmed below.
result.addAttribute(.font, value: inlineCodeFont, range: span.fullRange)
colorMathSource(result, range: span.contentRange)
} else {
let latex = (markdown as NSString).substring(with: span.contentRange)
// Size the math to the font already applied at this location, so
// inline math inside a heading matches the heading's size.
let contextFont = result.attribute(.font, at: span.fullRange.location,
effectiveRange: nil) as? NSFont ?? bodyFont
if let overlay = mathOverlay(latex: latex.trimmingCharacters(in: .whitespacesAndNewlines),
display: display,
fontSize: contextFont.pointSize) {
// Draw the rendered image at the first `$` (hidden, with
// kern reserving the image's width) and hide everything
// after it the rest of the opening delimiter, the
// source, and the close.
let hideStart = span.fullRange.location + 1
let hideLen = span.fullRange.upperBound - hideStart
let hideRange = NSRange(location: hideStart, length: hideLen)
result.addAttribute(.font, value: hiddenFont, range: hideRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange)
applyOverlay(overlay,
anchor: NSRange(location: span.fullRange.location, length: 1),
in: result)
// A `$$$$` run gets block layout (centered on its own
// line) only when it owns the whole block. A run sharing
// its line with prose flows inline like `$$` math.
let displayOwnsBlock: Bool = {
guard display else { return false }
let blockNS = markdown as NSString
let full = span.fullRange
let nonWS = CharacterSet.whitespacesAndNewlines.inverted
let before = NSRange(location: 0, length: full.location)
let after = NSRange(location: full.upperBound,
length: blockNS.length - full.upperBound)
return blockNS.rangeOfCharacter(from: nonWS, options: [], range: before).location == NSNotFound
&& blockNS.rangeOfCharacter(from: nonWS, options: [], range: after).location == NSNotFound
}()
if !displayOwnsBlock {
// Inline math and a display run sharing its line
// with prose flows within the text line; reserve
// the line height so a tall equation (e.g. scaled to
// a heading's font) doesn't overlap the line below.
reserveLineHeight(overlay.bounds.height,
forOverlayAt: span.fullRange.location,
in: result)
}
// Display math sits centered on its own line, with
// vertical padding and the image's ascent/descent
// reserved on the (first) line that carries it.
if displayOwnsBlock {
let fullStr = result.string as NSString
result.addAttribute(.paragraphStyle,
value: displayMathParagraphStyle(padded: false),
range: span.fullRange)
let nl = fullStr.range(of: "\n", options: [], range: span.fullRange)
let firstLine = nl.location == NSNotFound
? span.fullRange
: NSRange(location: span.fullRange.location,
length: nl.location - span.fullRange.location + 1)
let imageDescent = -overlay.bounds.minY
let imageAscent = overlay.bounds.height - imageDescent
result.addAttribute(.paragraphStyle,
value: displayMathParagraphStyle(padded: true,
imageAscent: imageAscent,
imageDescent: imageDescent),
range: firstLine)
}
} else {
// Invalid LaTeX: surface the raw source in monospace, tinted.
result.addAttribute(.font, value: inlineCodeFont, range: span.fullRange)
result.addAttribute(.foregroundColor, value: NSColor.systemRed, range: span.fullRange)
}
}
case .footnoteReference:
guard span.fullRange.upperBound <= result.length else { continue }
// Dim the id like other syntax markers (bullets, etc.) rather than
// coloring it like a link; when rendered (cursor outside), raise and
// shrink it into a superscript and hide the `[^`/`]` (below). When
// active, it stays full size and editable with dimmed delimiters.
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.contentRange)
if !cursorInToken {
let ctx = contextFont(at: span.contentRange.location)
let small = NSFont(descriptor: ctx.fontDescriptor,
size: ctx.pointSize * 0.75) ?? ctx
result.addAttribute(.font, value: small, range: span.contentRange)
result.addAttribute(.baselineOffset, value: ctx.pointSize * 0.35,
range: span.contentRange)
}
case .footnoteDefinition:
guard span.fullRange.upperBound <= result.length else { continue }
// The `[^id]:` marker is dimmed by the delimiter pass below; the
// definition text after it stays normal. Nothing to add here.
break
case .comment:
guard span.fullRange.upperBound <= result.length else { continue }
// Reading view hides comments entirely; Edit view dims the whole
// `%%%%` (delimiters dimmed again in the delimiter pass). The
// content is opaque (no inner markdown), so dimming fullRange is
// enough.
if hideComments {
result.addAttribute(.font, value: hiddenFont, range: span.fullRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: span.fullRange)
} else {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.fullRange)
}
case .lineBreak:
break // Delimiter handling done below
case .escape:
break // The escaped char keeps base attributes; the backslash
// is hidden/dimmed by the generic delimiter pass below.
case .htmlTag:
guard span.contentRange.upperBound <= result.length else { continue }
// Always literal: color the element name red like math; the
// `<`/`>`/`/` are dimmed by the generic (non-hideable) pass below.
result.addAttribute(.foregroundColor, value: theme.mathOperatorColor,
range: span.contentRange)
case .htmlFormat(let tag):
guard span.fullRange.upperBound <= result.length else { continue }
// Inactive: hide the tags (delimiter pass) and apply the rendered
// attribute to the inner content. Active: the raw tags show
// colored (handled in the delimiter pass).
if !cursorInToken {
applyHTMLFormatAttribute(result, tag: tag, range: span.contentRange)
}
}
// --- Delimiter treatment (applied after content styling so it takes precedence) ---
for dr in span.delimiterRanges {
guard dr.upperBound <= result.length else { continue }
if case .thematicBreak = span.kind {
// Thematic break: fully handled in content styling above
if cursorInToken {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
}
// Non-active: already hidden, don't override
} else if case .table = span.kind {
// Table delimiters (separator row): dimmed when active, hidden when not
if cursorInToken {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
}
// Non-active: already hidden by content styling, don't override
} else if case .listItem(let ordered, let checkbox) = span.kind {
// List markers: custom styling when non-active, dimmed when active
if cursorInToken {
// Dim the visible marker, but skip any leading whitespace in
// the delimiter range it was hidden during content styling
// and dimming it here would re-show it (the rescue parser's
// delimiter includes that whitespace).
let nsDelim = (markdown as NSString).substring(with: dr) as NSString
let firstNonWS = nsDelim.rangeOfCharacter(
from: CharacterSet(charactersIn: " \t").inverted)
let mStart = dr.location +
(firstNonWS.location == NSNotFound ? dr.length : firstNonWS.location)
if mStart < dr.upperBound {
result.addAttribute(.foregroundColor, value: syntaxDimColor,
range: NSRange(location: mStart, length: dr.upperBound - mStart))
}
} else {
styleListDelimiter(result, markdown: markdown,
delimiterRange: dr, ordered: ordered,
checkbox: checkbox)
}
} else if case .math = span.kind {
// Math: when active, dim the `$`; when not, the attachment and
// source-hiding are already applied in content styling leave them.
if cursorInToken {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
}
} else if case .htmlFormat = span.kind {
// Whitelisted tag pair: show the raw tags (dim brackets, red
// name) when active; hide them when the content is rendered.
if cursorInToken {
styleRawHTMLTag(result, range: dr)
} else {
result.addAttribute(.font, value: hiddenFont, range: dr)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr)
}
} else if case .comment = span.kind {
// Comment `%%`: hidden in reading view, dimmed otherwise
// matching the content styling above.
if hideComments {
result.addAttribute(.font, value: hiddenFont, range: dr)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr)
} else {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
}
} else if case .heading = span.kind, cursorPosition != nil {
// A heading is one logical line. Keep its marker visible
// while the caret is anywhere on that line so moving between
// inline tokens does not make the leading `#` flicker.
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
} else if cursorInToken || !isDelimiterHideable(span.kind) {
// Visible: dim the delimiters
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr)
} else if case .blockquote(_) = span.kind {
// Blockquote: invisible but preserve width for indentation
result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr)
} else {
// Hidden: make delimiters invisible and near-zero-width
result.addAttribute(.font, value: hiddenFont, range: dr)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr)
}
}
}
return result
}
/// Applies a whitelisted HTML tag's rendered formatting to `range` (the inner
/// content). Unknown tags are no-ops (handled as colored source elsewhere).
/// Fonts derive from the one already applied at the range (the enclosing
/// heading's, when inside one), so sizes nest like other inline spans.
private func applyHTMLFormatAttribute(_ result: NSMutableAttributedString,
tag: String, range: NSRange) {
let ctx = (range.location < result.length
? result.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont
: nil) ?? bodyFont
switch tag {
case "u":
result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
case "mark":
result.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: range)
case "kbd":
let scale = ctx.pointSize / bodyFont.pointSize
let mono = scale == 1 ? inlineCodeFont
: theme.monospaceFont(ofSize: inlineCodeFont.pointSize * scale)
result.addAttribute(.font, value: mono, range: range)
result.addAttribute(.backgroundColor, value: inlineCodeBackground, range: range)
case "sub", "sup":
let small = NSFont(descriptor: ctx.fontDescriptor, size: ctx.pointSize * 0.75) ?? ctx
result.addAttribute(.font, value: small, range: range)
let offset = tag == "sub" ? -ctx.pointSize * 0.25 : ctx.pointSize * 0.35
result.addAttribute(.baselineOffset, value: offset, range: range)
case "small":
let fine = NSFont(descriptor: ctx.fontDescriptor, size: ctx.pointSize * 0.85) ?? ctx
result.addAttribute(.font, value: fine, range: range)
default:
break
}
}
/// Dims an HTML tag's punctuation (`<`, `/`, attrs, `>`) and colors its
/// element name red the active-state look for a `.htmlFormat` pair, matching
/// how `.htmlTag` colored source reads.
private func styleRawHTMLTag(_ result: NSMutableAttributedString, range: NSRange) {
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: range)
let ns = result.string as NSString
var i = range.location
let end = range.upperBound
while i < end, ns.character(at: i) == 0x3C || ns.character(at: i) == 0x2F { i += 1 } // < /
var j = i
func isAlphaNum(_ c: unichar) -> Bool {
(c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) || (c >= 0x30 && c <= 0x39)
}
while j < end, isAlphaNum(ns.character(at: j)) { j += 1 }
if j > i {
result.addAttribute(.foregroundColor, value: theme.mathOperatorColor,
range: NSRange(location: i, length: j - i))
}
}
/// Plain monospaced styling for source mode: the raw markdown with no
/// markup interpretation (no hidden delimiters, overlays, or decorations).
func sourceStyled(_ markdown: String) -> NSAttributedString {
let mono = theme.monospaceFont(ofSize: bodyFont.pointSize)
let ps = NSMutableParagraphStyle()
ps.lineSpacing = theme.lineSpacing
return NSAttributedString(string: markdown, attributes: [
.font: mono,
.foregroundColor: foregroundColor,
.paragraphStyle: ps,
])
}
// MARK: - In-Place Block Restyling
/// Re-styles a single block in the text storage in place (no string mutation).
/// `cursorInBlock` is the cursor offset within the block, or nil to hide
/// all inline delimiters (non-active block).
func restyleBlock(_ blockIndex: Int, cursorInBlock: Int? = nil) {
guard let ts = textStorage,
blockIndex < blocks.count else { return }
let block = blocks[blockIndex]
guard block.range.upperBound <= ts.length else { return }
let styled: NSAttributedString
switch viewMode {
case .edit: styled = styleBlock(block.content, cursorPosition: cursorInBlock)
case .reading: styled = styleBlock(block.content, cursorPosition: nil, hideComments: true)
case .source: styled = sourceStyled(block.content)
}
let offset = block.range.location
styled.enumerateAttributes(in: NSRange(location: 0, length: styled.length), options: []) { attrs, range, _ in
let tsRange = NSRange(location: range.location + offset, length: range.length)
ts.setAttributes(attrs, range: tsRange)
}
// Reset the separator newlines adjacent to the block. No block's
// styled range covers them, and a character inserted at a block
// boundary inherits its neighbor's attributes (e.g. a display-math
// block's centered paragraph style), which would otherwise stick
// forever a full recompose leaves separators at base attributes,
// so the in-place path must too.
let nsStr = ts.string as NSString
if offset > 0, nsStr.character(at: offset - 1) == 0x0A {
ts.setAttributes(baseAttributes, range: NSRange(location: offset - 1, length: 1))
}
let after = block.range.upperBound
if after < nsStr.length, nsStr.character(at: after) == 0x0A {
ts.setAttributes(baseAttributes, range: NSRange(location: after, length: 1))
}
}
/// Re-applies styling to the active block. Called after each keystroke.
func applyBlockStyle() {
guard let ts = textStorage,
let activeIdx = activeBlockIndex,
activeIdx < blocks.count else { return }
let cursorInBlock = max(0, selectedRange().location - blocks[activeIdx].range.location)
isUpdating = true
ts.beginEditing()
restyleBlock(activeIdx, cursorInBlock: cursorInBlock)
ts.endEditing()
isUpdating = false
typingAttributes = baseAttributes
}
}
// MARK: - ThematicBreakTextBlock
@@ -0,0 +1,257 @@
import AppKit
/// Table styling: the largest single case of the `styleBlock` switch. When the
/// caret is inside, the table shows as dimmed monospace; otherwise it's laid out
/// with a bold header, hidden pipes, kern-padded columns, and drawn borders (via
/// a `.tableRow` BlockDecoration). Row parsing helpers live in
/// EditorTextView+TableSupport; extracted from EditorTextView+Rendering.
extension EditorTextView {
/// Styles the `.table` content for one span. The caller has already
/// bounds-checked `span.fullRange` against `result`.
func styleTableSpan(_ result: NSMutableAttributedString,
span: SyntaxHighlighter.Span,
cursorInToken: Bool) {
if cursorInToken {
// Active: monospace, all pipes dimmed
result.addAttribute(.font, value: tableFont, range: span.fullRange)
let nsStr = (result.string as NSString)
var sr = span.fullRange
while sr.length > 0 {
let pr = nsStr.range(of: "|", options: [], range: sr)
guard pr.location != NSNotFound else { break }
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: pr)
let ns = pr.upperBound
sr = NSRange(location: ns, length: max(0, span.fullRange.upperBound - ns))
}
} else {
// Non-active: bold header, hidden pipes, column-width alignment
// via kern, drawn vertical + horizontal borders via TableRowTextBlock,
// with cell padding for breathing room. A cell wider than its
// column instead hides its real characters and gets redrawn
// wrapped via `.tableCellWraps` (see EditorTextView+TextKit2.swift).
let tableNS = (result.string as NSString)
let tableStr = tableNS.substring(with: span.fullRange)
let lines = tableStr.components(separatedBy: "\n")
let cellHPad = bodyFont.pointSize * 0.3
let cellVPad = bodyFont.pointSize * 0.15
// --- Style each cell's inline markdown and measure the result ---
// Each cell runs through styleBlock so `**bold**`, `code`, links,
// ==marks== etc. render inside tables; hidden delimiters measure
// ~zero, so column widths reflect what's actually visible. Header
// cells are bolded before measuring.
// ponytail: block-level markdown in a cell (`# x`, `- x`) keeps its
// fonts but loses its block chrome (paragraph styles / decorations
// are row-owned, see the transplant below); tall math or image
// overlays get no extra line height in cells. A wrapped (overflowing)
// cell is drawn from a detached scratch text layout rather than the
// live glyph run, so click-to-caret placement inside it is
// approximate while the table is non-active clicking anywhere in
// the cell still enters the table and lands the caret at the raw
// source's nearest position once active.
let headerCells = splitTableRow(lines[0])
let numCols = headerCells.count
guard numCols > 0 else { return }
var natural = [CGFloat](repeating: 0, count: numCols)
// Per line: the cell's character range within the line + its
// styled form (empty for the separator row).
var rowCells: [[(start: Int, end: Int, styled: NSAttributedString)]] = []
for (li, line) in lines.enumerated() {
guard li != 1 else { rowCells.append([]); continue }
let lineNS = line as NSString
var cells: [(start: Int, end: Int, styled: NSAttributedString)] = []
for cr in cellRanges(in: lineNS) {
let text = lineNS.substring(with: NSRange(location: cr.start,
length: cr.end - cr.start))
let styled = NSMutableAttributedString(
attributedString: styleBlock(text, cursorPosition: nil))
if li == 0 {
styled.enumerateAttribute(
.font, in: NSRange(location: 0, length: styled.length)
) { value, r, _ in
guard let f = value as? NSFont else { return }
styled.addAttribute(
.font,
value: NSFontManager.shared.convert(f, toHaveTrait: .boldFontMask),
range: r)
}
}
cells.append((cr.start, cr.end, styled))
}
rowCells.append(cells)
for ci in 0..<min(cells.count, numCols) {
natural[ci] = max(natural[ci], cells[ci].styled.size().width)
}
}
// Clamp column content widths to the available line width so a
// pathologically wide cell doesn't stretch the whole table off
// screen the overflow gets wrapped (below) instead. Columns
// that already fit their fair share keep their natural width.
let minColWidth = bodyFont.pointSize * 3
let available = max(0, availableContentWidth - CGFloat(numCols) * 2 * cellHPad)
let clamped = distributeColumnWidths(natural: natural, available: available,
minWidth: minColWidth)
// Add horizontal padding to each column (space after cell text).
var colWidths = clamped
for ci in 0..<numCols {
colWidths[ci] += 2 * cellHPad
}
// Column-border X offsets (between columns) and total width.
// Each border is drawn cellHPad before the column boundary
// so the 2*cellHPad per column splits evenly: hPad of right
// padding for the current cell, hPad of left padding for the next.
var borderXOffsets: [CGFloat] = []
var colStartX: [CGFloat] = []
var cumX: CGFloat = 0
for ci in 0..<numCols {
colStartX.append(cumX + cellHPad)
cumX += colWidths[ci]
if ci < numCols - 1 { borderXOffsets.append(cumX - cellHPad) }
}
let totalWidth = cumX
// Per-column alignment from the separator row (`:--`/`:-:`/`--:`).
let aligns = tableColumnAlignments(separatorRow: lines.count > 1 ? lines[1] : "",
count: numCols)
// --- Style each row ---
var lineOffset = span.fullRange.location
for (i, line) in lines.enumerated() {
let lineLen = (line as NSString).length
let lineRange = NSRange(location: lineOffset, length: lineLen)
guard lineRange.upperBound <= result.length else { break }
// Row geometry via the paragraph style; the borders are
// drawn by a .tableRow BlockDecoration. Vertical padding
// becomes paragraph spacing (row gap = trailing + leading
// spacing = 2*cellVPad, same as the old block padding).
let ps = NSMutableParagraphStyle()
ps.lineSpacing = 0
ps.firstLineHeadIndent = cellHPad
ps.headIndent = cellHPad
if i == 1 {
// Separator row: its text is hidden; force a thin
// strip and draw the horizontal rule through it.
ps.minimumLineHeight = 4
ps.maximumLineHeight = 4
ps.paragraphSpacingBefore = 0
ps.paragraphSpacing = 0
} else {
ps.paragraphSpacingBefore = cellVPad + ((i == 0)
? bodyParagraphStyle.paragraphSpacingBefore : 0)
ps.paragraphSpacing = cellVPad
}
result.addAttribute(.paragraphStyle, value: ps, range: lineRange)
result.addAttribute(
.blockDecoration,
value: BlockDecoration(.tableRow(columnXOffsets: borderXOffsets,
width: totalWidth,
leftInset: cellHPad,
separator: i == 1,
bottomBorder: i > 1)),
range: lineRange)
// Cells whose styled width exceeds their column's (clamped)
// content width can't be kern-aligned in place they get
// hidden and redrawn wrapped by `.tableCellWraps` instead
// (see DecoratedTextLayoutFragment). Computed once per row so
// both the hide/transplant step and the kern step below agree.
var overflowsCol = [Bool](repeating: false, count: numCols)
if i != 1, i < rowCells.count {
for ci in 0..<min(rowCells[i].count, numCols) {
overflowsCol[ci] = rowCells[i][ci].styled.size().width
> colWidths[ci] - 2 * cellHPad + 0.5
}
}
if i == 1 {
// Separator row: hide all text
result.addAttribute(.font, value: hiddenFont, range: lineRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: lineRange)
} else if i < rowCells.count {
// Transplant each styled cell's attributes onto the table,
// skipping .paragraphStyle and .blockDecoration row
// geometry and borders stay owned by the table code.
// Overflowing cells instead hide their real characters and
// get redrawn wrapped, since kern alone can't wrap text.
var wraps: [TableCellWrap] = []
for (ci, cell) in rowCells[i].enumerated() where ci < numCols {
let offset = lineOffset + cell.start
if overflowsCol[ci] {
let hideRange = NSRange(location: offset, length: cell.end - cell.start)
guard hideRange.upperBound <= result.length else { continue }
result.addAttribute(.font, value: hiddenFont, range: hideRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange)
wraps.append(TableCellWrap(styled: cell.styled, x: colStartX[ci],
contentWidth: colWidths[ci] - 2 * cellHPad))
} else {
cell.styled.enumerateAttributes(
in: NSRange(location: 0, length: cell.styled.length)
) { attrs, r, _ in
let target = NSRange(location: offset + r.location, length: r.length)
guard target.upperBound <= result.length else { return }
for (key, value) in attrs {
guard key != .paragraphStyle && key != .blockDecoration else { continue }
result.addAttribute(key, value: value, range: target)
}
}
}
}
if !wraps.isEmpty {
result.addAttribute(.tableCellWraps, value: TableCellWrapList(wraps),
range: lineRange)
}
}
// Hide all structural pipes (zero-width + clear). A `\|` is
// escaped cell content, not a separator leave it visible
// (its `\` is already hidden by the cell's escape span).
let lineNS = line as NSString
for ci in 0..<lineNS.length {
if lineNS.character(at: ci) == 0x7C,
!(ci > 0 && lineNS.character(at: ci - 1) == 0x5C) {
let pipeRange = NSRange(location: lineOffset + ci, length: 1)
result.addAttribute(.font, value: hiddenFont, range: pipeRange)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: pipeRange)
}
}
// Kern-pad each cell to its column width, distributing the slack
// by column alignment (skip separator). Left pads after content;
// right pads before it (kern on the cell's leading hidden pipe,
// which still adds advance though it's near-zero-width); center
// splits the slack. Kern adds advance *after* a glyph, so the
// "before" kern goes on the char preceding the cell content.
if i != 1, i < rowCells.count {
for ci in 0..<min(rowCells[i].count, numCols) {
guard !overflowsCol[ci] else { continue }
let cr = rowCells[i][ci]
let cellWidth = cr.styled.size().width
let padding = colWidths[ci] - cellWidth
guard padding > 0.5 else { continue }
let leadingIdx = (cr.start - 1 >= 0 && lineNS.character(at: cr.start - 1) == 0x7C)
? cr.start - 1 : cr.start
let trailingIdx = cr.end - 1
func kern(_ amount: CGFloat, at idx: Int) {
result.addAttribute(.kern, value: amount,
range: NSRange(location: lineOffset + idx, length: 1))
}
switch aligns[ci] {
case .left: kern(padding, at: trailingIdx)
case .right: kern(padding, at: leadingIdx)
case .center:
let half = (padding / 2).rounded()
kern(half, at: leadingIdx)
kern(padding - half, at: trailingIdx)
}
}
}
lineOffset += lineLen + 1
}
}
}
}
@@ -0,0 +1,129 @@
import AppKit
// MARK: - Table Rendering Support
//
// Helpers used by the `.table` branch of `styleBlock` (in
// EditorTextView+Rendering.swift) to lay out GFM tables:
// `splitTableRow` / `cellRanges` parse a pipe-delimited row into its cells.
//
// A rendered table is a run of consecutive single-line paragraphs (one per
// table row) that the BlockParser merges into a single block. Each row
// carries a `.tableRow` BlockDecoration; because every row uses the same
// column X offsets, the per-row vertical strokes line up into continuous
// column borders. A cell too wide for its column renders across multiple
// *visual* sublines within that one paragraph, via `.tableCellWraps`
// (EditorTextView+TextKit2.swift) the paragraph/row count is unchanged.
// MARK: - Column Width Distribution
/// Clamps each column's natural (widest-cell) width to fit `available` total
/// width, so one very wide cell doesn't stretch the whole table off screen.
/// Columns already at or under their fair share (`available / count`) keep
/// their natural width; the slack they don't use is handed to the columns
/// that exceed fair share, split evenly among them and floored at `minWidth`.
/// ponytail: single-pass, not CSS's iterative auto-table-layout fixed point
/// revisit only if real documents show pathological many-column cases.
func distributeColumnWidths(natural: [CGFloat], available: CGFloat,
minWidth: CGFloat) -> [CGFloat] {
let numCols = natural.count
guard numCols > 0, available > 0 else { return natural }
let fairShare = available / CGFloat(numCols)
var overIdx: [Int] = []
var usedByUnderShare: CGFloat = 0
for (ci, width) in natural.enumerated() {
if width <= fairShare { usedByUnderShare += width } else { overIdx.append(ci) }
}
guard !overIdx.isEmpty else { return natural }
let remaining = max(0, available - usedByUnderShare)
let perOverShare = remaining / CGFloat(overIdx.count)
var result = natural
for ci in overIdx {
result[ci] = max(minWidth, min(natural[ci], perOverShare))
}
return result
}
// MARK: - Column Alignment
/// GFM table column alignment, parsed from the separator row's `:` markers.
enum ColumnAlign: Equatable { case left, center, right }
/// Parses per-column alignment from a table's separator row (`:--`/`:-:`/`--:`).
/// `:` on both ends = center, trailing only = right, otherwise left. Padded to
/// `count` with `.left`. Mirrors swift-markdown's `Table.columnAlignments`, so
/// the live editor and the HTML export agree.
func tableColumnAlignments(separatorRow: String, count: Int) -> [ColumnAlign] {
var aligns = [ColumnAlign](repeating: .left, count: count)
let cells = splitTableRow(separatorRow)
for ci in 0..<min(cells.count, count) {
let t = cells[ci].trimmingCharacters(in: .whitespaces)
let lead = t.hasPrefix(":")
let trail = t.hasSuffix(":")
aligns[ci] = (lead && trail) ? .center : (trail ? .right : .left)
}
return aligns
}
// MARK: - Table Row Parsing
/// Splits a markdown table row into cell strings (text between pipes).
/// Handles both `| A | B |` (outer pipes) and `A | B` (no outer pipes).
/// A `\|` is escaped content, not a cell separator (GFM Example 200).
func splitTableRow(_ line: String) -> [String] {
var parts: [String] = []
var current = ""
var prevWasBackslash = false
for ch in line {
if ch == "|" && !prevWasBackslash {
parts.append(current)
current = ""
} else {
current.append(ch)
}
prevWasBackslash = (ch == "\\") && !prevWasBackslash
}
parts.append(current)
// Remove empty/whitespace-only first/last from outer pipes.
if let first = parts.first, first.trimmingCharacters(in: .whitespaces).isEmpty {
parts.removeFirst()
}
if let last = parts.last, last.trimmingCharacters(in: .whitespaces).isEmpty {
parts.removeLast()
}
return parts
}
/// Returns `(start, end)` character ranges for each cell in a table line.
/// Works with or without outer pipes. `start` is the first content char,
/// `end` is one past the last content char (i.e., the next pipe or line end).
func cellRanges(in line: NSString) -> [(start: Int, end: Int)] {
var pipePos: [Int] = []
for ci in 0..<line.length {
guard line.character(at: ci) == 0x7C else { continue }
// A `\|` is escaped content, not a cell separator (GFM Example 200).
if ci > 0 && line.character(at: ci - 1) == 0x5C { continue }
pipePos.append(ci)
}
guard !pipePos.isEmpty else { return [] }
// Build edge list: either the pipe position or a virtual -1/length sentinel.
var edges: [Int] = []
if pipePos[0] == 0 {
edges.append(contentsOf: pipePos)
} else {
edges.append(-1)
edges.append(contentsOf: pipePos)
}
if pipePos.last != line.length - 1 {
edges.append(line.length)
}
var result: [(start: Int, end: Int)] = []
for ei in 0..<(edges.count - 1) {
let s = edges[ei] + 1
let e = edges[ei + 1]
if e > s { result.append((s, e)) }
}
return result
}
@@ -0,0 +1,149 @@
import AppKit
// MARK: - Internal Link Following
//
// `[[wikilinks]]` and regular `[](dest)` links share one navigation core:
// - `#heading` scroll to a heading in the current document,
// - `path` / `path#head` resolve a file under the opened file's directory
// (a direct child, else a recursive search), open
// it, and scroll to the heading if one was named,
// - external URL (scheme) open in the default app (regular links only).
//
// Resolution needs only the opened file (its directory), not a vault folder.
/// Implemented by the owning document so a freshly opened document can be
/// scrolled to a heading after a cross-file link is followed.
@MainActor
public protocol HeadingNavigable: AnyObject {
func navigateToHeading(_ heading: String)
}
extension EditorTextView {
// MARK: Hit testing
/// The wikilink target under a mouse event, or nil if the click doesn't land
/// on wikilink display text.
func wikiTarget(at event: NSEvent) -> String? {
guard let storage = textStorage, let i = clickCharIndex(at: event) else { return nil }
return storage.attribute(.editorWikiTarget, at: i, effectiveRange: nil) as? String
}
// MARK: Following
/// Follows a `[[wikilink]]` target (`path#heading`, no scheme, `.md` implied).
public func followWikiLink(_ target: String) {
let (path, heading) = Self.splitHeading(target)
if path.isEmpty {
if let heading { scrollToHeading(heading) } else { NSSound.beep() }
return
}
openLinkedFile(path: path, heading: heading)
}
/// Follows a regular markdown link destination: an external URL opens in the
/// default app; `#heading` scrolls in-document; a local `path#heading`
/// resolves a file and opens it (scrolling to the heading if named).
public func followLinkDestination(_ destination: String) {
let dest = destination.trimmingCharacters(in: .whitespaces)
if let url = URL(string: dest), let scheme = url.scheme, scheme != "file" {
NSWorkspace.shared.open(url)
return
}
// A markdown link destination is URL-encoded (e.g. `%20` for a space),
// so decode both the path and the heading anchor.
let (rawPath, rawHeading) = Self.splitHeading(dest)
let path = rawPath.removingPercentEncoding ?? rawPath
let heading = rawHeading.map { $0.removingPercentEncoding ?? $0 }
if path.isEmpty {
if let heading { scrollToHeading(heading) } else { NSSound.beep() }
return
}
openLinkedFile(path: path, heading: heading)
}
/// Resolves `path` to a file and opens it, scrolling the opened document to
/// `heading` when one is named (cross-file, via `HeadingNavigable`).
private func openLinkedFile(path: String, heading: String?) {
guard let fileURL = resolveLinkedFile(path) else { NSSound.beep(); return }
NSDocumentController.shared.openDocument(withContentsOf: fileURL, display: true) { _, _, _ in
guard let heading else { return }
// Re-find the document by URL on the main actor (the NSDocument
// isn't Sendable, so we don't capture it across the boundary). By
// now its content has loaded (showWindows loadContent).
Task { @MainActor in
let doc = NSDocumentController.shared.document(for: fileURL)
(doc as? HeadingNavigable)?.navigateToHeading(heading)
}
}
}
// MARK: Heading navigation
/// Scrolls to the first heading block whose text matches `heading`
/// (case-insensitive). Beeps if there is no such heading.
public func scrollToHeading(_ heading: String) {
let want = heading.lowercased()
for block in blocks {
guard case .heading = block.kind,
Self.headingText(block.content).lowercased() == want else { continue }
let loc = block.range.location
setSelectedRange(NSRange(location: loc, length: 0))
scrollRangeToVisible(NSRange(location: loc, length: 0))
return
}
NSSound.beep()
}
/// The text of a heading line, stripped of its leading `#`s and whitespace.
static func headingText(_ line: String) -> String {
var s = Substring(line)
while s.first == "#" { s = s.dropFirst() }
return s.trimmingCharacters(in: .whitespaces)
}
/// Splits `path#heading` (or `#heading`, or `path`) into a path and the
/// deepest heading component (so `Note#H1#H2` targets `H2`). A nil heading
/// means none was named.
static func splitHeading(_ s: String) -> (path: String, heading: String?) {
let ns = s as NSString
let hash = ns.range(of: "#")
guard hash.location != NSNotFound else {
return (s.trimmingCharacters(in: .whitespaces), nil)
}
let path = ns.substring(to: hash.location).trimmingCharacters(in: .whitespaces)
let rest = ns.substring(from: hash.upperBound)
let heading = rest.split(separator: "#").last
.map { $0.trimmingCharacters(in: .whitespaces) }
.flatMap { $0.isEmpty ? nil : $0 }
return (path, heading)
}
// MARK: File resolution
/// Resolves a link `path` to a file URL. Absolute / `~` / `file:` paths load
/// directly; otherwise it resolves under the opened file's directory a
/// direct child first, else a recursive search by filename appending `.md`
/// when the path has no extension (Obsidian-style wikilinks omit it).
func resolveLinkedFile(_ path: String) -> URL? {
if let url = URL(string: path), url.scheme == "file" { return url }
if path.hasPrefix("/") { return URL(fileURLWithPath: path) }
if path.hasPrefix("~") { return URL(fileURLWithPath: (path as NSString).expandingTildeInPath) }
guard let docDir = document?.fileURL?.deletingLastPathComponent() else { return nil }
let fm = FileManager.default
let rel = (path as NSString).pathExtension.isEmpty ? path + ".md" : path
let direct = docDir.appendingPathComponent(rel)
if fm.fileExists(atPath: direct.path) { return direct }
// Recursive search by the link's filename (Obsidian resolves by name).
let wantName = (rel as NSString).lastPathComponent.lowercased()
if let walker = fm.enumerator(at: docDir, includingPropertiesForKeys: nil) {
for case let url as URL in walker where url.lastPathComponent.lowercased() == wantName {
return url
}
}
return nil
}
}
@@ -0,0 +1,146 @@
import AppKit
import CoreText
/// A text storage subclass whose `fixAttributes` does font substitution only.
///
/// The editor explicitly manages every attribute on every character (custom
/// keys like `.blockDecoration` / `.fragmentOverlay` included), so the
/// framework's default attribute "fixing" has nothing useful to add and
/// historically it stripped attributes the renderer depended on.
public class EditorTextStorage: NSTextStorage {
private let backing = NSMutableAttributedString()
/// The accumulated string mutation since the last consume, expressed as
/// "this range of the OLD string was replaced, shifting lengths by
/// `delta`". Multiple mutations coalesce into the conservative hull.
/// This is the single funnel for all string edits (typing, paste, IME),
/// so the incremental block parser can re-split only the affected lines.
public struct PendingEdit {
public var oldRange: NSRange
public var delta: Int
}
public private(set) var pendingEdit: PendingEdit?
/// Returns and clears the accumulated edit.
public func consumePendingEdit() -> PendingEdit? {
defer { pendingEdit = nil }
return pendingEdit
}
/// Drops accumulated-edit tracking. Programmatic whole-document
/// replacements (recompose after load/undo/indent) call this they
/// re-parse from scratch themselves.
public func clearPendingEdit() {
pendingEdit = nil
}
/// Coalesces a new edit (given in CURRENT-string coordinates) into the
/// pending edit (kept in OLD-string coordinates).
private func accumulateEdit(currentRange r: NSRange, delta d: Int) {
guard var p = pendingEdit else {
pendingEdit = PendingEdit(oldRange: r, delta: d)
return
}
// Map the new edit's bounds back to old-string coordinates and take
// the hull. Positions at/after the previous edit's replacement shift
// back by the previous delta; the max() keeps positions inside or
// before it clamped to the previous old range.
let start = min(p.oldRange.location, r.location)
let end = max(p.oldRange.upperBound, r.upperBound - p.delta)
p.oldRange = NSRange(location: start, length: max(0, end - start))
p.delta += d
pendingEdit = p
}
override public var string: String { backing.string }
override public func attributes(
at location: Int, effectiveRange range: NSRangePointer?
) -> [NSAttributedString.Key: Any] {
backing.attributes(at: location, effectiveRange: range)
}
override public func replaceCharacters(in range: NSRange, with str: String) {
let delta = (str as NSString).length - range.length
accumulateEdit(currentRange: range, delta: delta)
backing.replaceCharacters(in: range, with: str)
edited(.editedCharacters, range: range, changeInLength: delta)
}
override public func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) {
let delta = attrString.length - range.length
accumulateEdit(currentRange: range, delta: delta)
backing.replaceCharacters(in: range, with: attrString)
edited([.editedCharacters, .editedAttributes], range: range,
changeInLength: delta)
}
override public func setAttributes(
_ attrs: [NSAttributedString.Key: Any]?, range: NSRange
) {
backing.setAttributes(attrs, range: range)
edited(.editedAttributes, range: range, changeInLength: 0)
}
override public func fixAttributes(in range: NSRange) {
// We deliberately skip the framework's default attribute fixing the
// editor manages all attributes itself, and the default pass has a
// history of stripping what the renderer depends on.
//
// But one part of fixing is still needed: font substitution. The body
// font (a serif/mono) has no glyphs for emoji, CJK, etc.; without
// substitution those render as missing-glyph boxes. So we do font
// fixing ourselves, leaving every other attribute (attachments included)
// untouched.
fixFontSubstitution(in: range)
}
/// Replaces the font on any character the run's font cannot render with a
/// fallback font that can (e.g. Apple Color Emoji), preserving the original
/// size. Substitutions are computed first, then applied, so we never mutate
/// the attribute we're enumerating mid-pass.
private func fixFontSubstitution(in range: NSRange) {
guard range.length > 0, range.upperBound <= backing.length else { return }
let ns = backing.string as NSString
var fixes: [(NSRange, NSFont)] = []
backing.enumerateAttribute(.font, in: range, options: []) { value, runRange, _ in
// Skip the tiny hidden-delimiter font those chars are invisible
// and are plain ASCII delimiters the base font already covers.
guard let font = value as? NSFont, font.pointSize > 1.0 else { return }
// Fast path: does the font cover the whole run?
let runChars = Array(ns.substring(with: runRange).utf16)
var runGlyphs = [CGGlyph](repeating: 0, count: runChars.count)
if CTFontGetGlyphsForCharacters(font as CTFont, runChars, &runGlyphs, runChars.count) {
return
}
// Substitute per composed-character sequence so we never split a
// grapheme (emoji ZWJ sequences, skin-tone modifiers, é, ).
var i = runRange.location
let end = runRange.upperBound
while i < end {
let seq = ns.rangeOfComposedCharacterSequence(at: i)
let seqRange = NSRange(location: seq.location,
length: min(seq.length, end - seq.location))
let seqStr = ns.substring(with: seqRange)
let seqChars = Array(seqStr.utf16)
var seqGlyphs = [CGGlyph](repeating: 0, count: seqChars.count)
let covered = CTFontGetGlyphsForCharacters(
font as CTFont, seqChars, &seqGlyphs, seqChars.count)
if !covered {
let substitute = CTFontCreateForString(
font as CTFont, seqStr as CFString,
CFRange(location: 0, length: seqChars.count)) as NSFont
fixes.append((seqRange, substitute))
}
i = seqRange.upperBound
}
}
for (r, f) in fixes {
backing.addAttribute(.font, value: f, range: r)
}
}
}

Some files were not shown because too many files have changed in this diff Show More