This commit is contained in:
@@ -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 4–5 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 |
|
||||
|---|---|---|
|
||||
| 1–3 | 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 1–5 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 1–3).
|
||||
- **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, 4–5
|
||||
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 1–5 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 1–2 (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 5–6 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 1–5 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 1–6),
|
||||
`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 1–5 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 1–2 |
|
||||
| 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 1–5 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 3–4.
|
||||
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 1–6 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 1–6),
|
||||
`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.0–0.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.0–0.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 1–6) — 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 4–6).
|
||||
|
||||
### 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 1–2. 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 4–6. 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 5–6 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 3–4.
|
||||
- **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 4–5 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}"
|
||||
+22
@@ -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 "~1–2 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 1–5
|
||||
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 1–5 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 4–5 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 ~1–2MB 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.
|
||||
@@ -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 4–5 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 |
|
||||
|---|---|---|
|
||||
| 1–3 | 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 1–5 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 1–3).
|
||||
- **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, 4–5
|
||||
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 1–5 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 1–2 (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 5–6 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 1–5 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 1–6),
|
||||
`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 1–5 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 1–2 |
|
||||
| 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 1–5 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 3–4.
|
||||
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 1–6 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 1–6),
|
||||
`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.0–0.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.0–0.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 1–6) — 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 4–6).
|
||||
|
||||
### 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 1–2. 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 4–6. 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 5–6 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 3–4.
|
||||
- **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 4–5 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}"
|
||||
+22
@@ -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 "~1–2 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 1–5
|
||||
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 1–5 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 4–5 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 ~1–2MB 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.
|
||||
@@ -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…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
# Edmund
|
||||

|
||||

|
||||

|
||||

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

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
## 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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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` (1–6).
|
||||
/// Heading H1–H6: 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 `#` (1–6) 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 ``, 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 indent→depth 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: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: "&", 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 `<`. 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, "<")
|
||||
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 += "&"
|
||||
case "<": out += "<"
|
||||
case ">": out += ">"
|
||||
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 += "&"
|
||||
case "<": out += "<"
|
||||
case ">": out += ">"
|
||||
case "\"": out += """
|
||||
case "'": out += "'"
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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_-]*)\]"#)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
@@ -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 1–5 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 1–6 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 1→7 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 1–5 (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 1–3 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 1–3 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 `` 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
|
||||
//
|
||||
// `` 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 `` 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
|
||||
// ``, `<` 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
Reference in New Issue
Block a user