commit 5b99bf6bca3963e981ff7c4ce5d84d41783988fd Author: wehub-resource-sync Date: Mon Jul 13 12:34:54 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.agents/skills/edmund-architecture-contract/SKILL.md b/.agents/skills/edmund-architecture-contract/SKILL.md new file mode 100644 index 0000000..50a798a --- /dev/null +++ b/.agents/skills/edmund-architecture-contract/SKILL.md @@ -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 `
`, 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 `
` 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 +``` diff --git a/.agents/skills/edmund-build-and-env/SKILL.md b/.agents/skills/edmund-build-and-env/SKILL.md new file mode 100644 index 0000000..91421b3 --- /dev/null +++ b/.agents/skills/edmund-build-and-env/SKILL.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 +# 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 | grep ''` + 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. diff --git a/.agents/skills/edmund-caret-integrity-campaign/SKILL.md b/.agents/skills/edmund-caret-integrity-campaign/SKILL.md new file mode 100644 index 0000000..d549906 --- /dev/null +++ b/.agents/skills/edmund-caret-integrity-campaign/SKILL.md @@ -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-.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. diff --git a/.agents/skills/edmund-change-control/SKILL.md b/.agents/skills/edmund-change-control/SKILL.md new file mode 100644 index 0000000..6b41c7f --- /dev/null +++ b/.agents/skills/edmund-change-control/SKILL.md @@ -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. diff --git a/.agents/skills/edmund-config-and-flags/SKILL.md b/.agents/skills/edmund-config-and-flags/SKILL.md new file mode 100644 index 0000000..9e06f3f --- /dev/null +++ b/.agents/skills/edmund-config-and-flags/SKILL.md @@ -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 `- ` 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 `- ` 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 ` | **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 ` | wait before next command | +| `caret ` | place caret before first occurrence of `` | +| `type ` | one real key event per char (~80 ms apart) | +| `backspace ` | n real delete keystrokes (~300 ms apart) | +| `bypassdelete ` | simulate drag-move source deletion: `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `PASS/FAIL` iff caret sits exactly before `` | +| `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..`). +2. Bind it in the relevant SwiftUI pane with `@AppStorage(AppSettings.)`. +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. diff --git a/.agents/skills/edmund-debugging-playbook/SKILL.md b/.agents/skills/edmund-debugging-playbook/SKILL.md new file mode 100644 index 0000000..15a1c6d --- /dev/null +++ b/.agents/skills/edmund-debugging-playbook/SKILL.md @@ -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 -- ` + 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 `. 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 ""`; `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 + ` (`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 `, 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. diff --git a/.agents/skills/edmund-docs-and-writing/SKILL.md b/.agents/skills/edmund-docs-and-writing/SKILL.md new file mode 100644 index 0000000..ed96b21 --- /dev/null +++ b/.agents/skills/edmund-docs-and-writing/SKILL.md @@ -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/-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/.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/-investigation.md` | Deep multi-round investigation chronicles for active bug classes | Existing: `delete-drift-`, `viewport-glitch-investigation.md`. Template in §5. | +| `docs/investigations/archives/-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/-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: . See .` 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/.txt` + a feature-map note in §6 | Follow the Lucide precedent. | +| Deep explanation of an existing subsystem | `docs/architecture/.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/`, `docs/`, `chore/`. 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 `
  • ` — +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 +- ([docs](docs/-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 +# "" — investigation notes + +Context for anyone who sees this again. + +Fixed on branch `fix/`, commits: `` — , ... + +## Symptom + +`, `~/.edmund/logs/...`.> + +## How it was diagnosed + +1. + +## Root cause + + + +## The fix + + + +## Verification + + + +## If it ever recurs + + + +## Round 2: ← 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. ``) — leave them unless acting on + them. +- **At release**: CHANGELOG section header ↔ `Info.plist` version ↔ appcast + `` 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. diff --git a/.agents/skills/edmund-external-positioning/SKILL.md b/.agents/skills/edmund-external-positioning/SKILL.md new file mode 100644 index 0000000..f51efd4 --- /dev/null +++ b/.agents/skills/edmund-external-positioning/SKILL.md @@ -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: ("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 ``). 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). diff --git a/.agents/skills/edmund-failure-archaeology/SKILL.md b/.agents/skills/edmund-failure-archaeology/SKILL.md new file mode 100644 index 0000000..779bca0 --- /dev/null +++ b/.agents/skills/edmund-failure-archaeology/SKILL.md @@ -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 `) replays + keystrokes in-process through `window.sendEvent(_:)` — no TCC, works on an + invisible Space. `bypassdelete ` 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 `; 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 - `. + 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. diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md b/.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md new file mode 100644 index 0000000..89d960d --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md @@ -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 +/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 ` | wait before the next command | +| `caret ` | place caret before the first occurrence of `` | +| `type ` | one real key event per char, ~80 ms apart | +| `backspace ` | n real delete keystrokes, ~300 ms apart | +| `bypassdelete ` | simulate the drag-move source deletion: select range, `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `repro assertcaret PASS/FAIL sel=… want=…` iff caret sits exactly before `` | +| `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 ` (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 ".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 out.png` finds the window id +(JXA → `CGWindowListCopyWindowInfo`) and runs `screencapture -x -o -l`. +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 ` | Screenshot a window by id + report bounds | **verify on first use** (JXA CGWindowList lookup not executed this session) | +| `launch-debug.sh [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. diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh new file mode 100755 index 0000000..bc83000 --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# capture-window.sh +# 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 " ", then `screencapture -x -o +# -l` 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 }" +out="${2:?usage: capture-window.sh }" + +read -r wid x y w h < <(osascript -l JavaScript <&2 + exit 1 +fi + +screencapture -x -o -l"$wid" "$out" +echo "Captured window $wid ($needle) -> $out bounds=${x},${y} ${w}x${h}" diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh new file mode 100755 index 0000000..73e442a --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh @@ -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 diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh new file mode 100755 index 0000000..eb84477 --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh @@ -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)" diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh new file mode 100755 index 0000000..809761e --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# launch-debug.sh [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 [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" diff --git a/.agents/skills/edmund-release-and-operate/SKILL.md b/.agents/skills/edmund-release-and-operate/SKILL.md new file mode 100644 index 0000000..0191985 --- /dev/null +++ b/.agents/skills/edmund-release-and-operate/SKILL.md @@ -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 .dmg"` → `Edmund-.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 `` (HTML `` via `scripts/changelog-to-html.py`), inserts it before ``, 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 ' && 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 `, a deliberately tiny converter that +only understands Keep-a-Changelog shapes: + +- `### Added` / `### Changed` / `### Fixed` → `

    ` — **use `###`, not `##`**. + The 0.1.2 appcast item literally shows `

    ## Changed

    ` because the + section used `##` subheads at release time; the converter passed them + through as paragraphs. +- `- ` / `* ` bullets → `
    • `; 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 `

      `. +- Missing section → empty output → the `` 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 `; 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 - +``` + +Both `release.yml` and `release.sh` do exactly this. Never "simplify" it back +to `-s`. Output format: `sparkle:edSignature="" length=""` — 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 ` 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..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 .dmg"` — with a **space**. Both scripts + rename to `Edmund-.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-.dmg` asset (hyphenated name). +- [ ] `appcast.xml` on `main` got the new `` — with ``, + 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 `` 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-.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 `` (title/link/description/language) containing one `` per +release. Items are inserted **before ``**, so the file reads oldest +→ newest; Sparkle doesn't care about order — it picks by version. Per item: + +```xml + + Edmund 0.1.2 + Fri, 03 Jul 2026 19:03:59 +0000 + + + sparkle:shortVersionString="0.1.2" + sparkle:edSignature="…" + length="7608991" + type="application/x-apple-diskimage"/> + +``` + +`` 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). diff --git a/.agents/skills/edmund-research-frontier/SKILL.md b/.agents/skills/edmund-research-frontier/SKILL.md new file mode 100644 index 0000000..a34f8f8 --- /dev/null +++ b/.agents/skills/edmund-research-frontier/SKILL.md @@ -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. diff --git a/.agents/skills/edmund-research-methodology/SKILL.md b/.agents/skills/edmund-research-methodology/SKILL.md new file mode 100644 index 0000000..5384816 --- /dev/null +++ b/.agents/skills/edmund-research-methodology/SKILL.md @@ -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/-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. diff --git a/.agents/skills/edmund-validation-and-qa/SKILL.md b/.agents/skills/edmund-validation-and-qa/SKILL.md new file mode 100644 index 0000000..b888219 --- /dev/null +++ b/.agents/skills/edmund-validation-and-qa/SKILL.md @@ -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 `. **`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. diff --git a/.agents/skills/textkit2-appkit-reference/SKILL.md b/.agents/skills/textkit2-appkit-reference/SKILL.md new file mode 100644 index 0000000..8c6908a --- /dev/null +++ b/.agents/skills/textkit2-appkit-reference/SKILL.md @@ -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. diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md new file mode 100644 index 0000000..e354497 --- /dev/null +++ b/.claude/commands/ship.md @@ -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 + ``` + Keep it one logical change per commit. + +5. **Push.** `git push -u origin `. + +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. diff --git a/.claude/skills/edmund-architecture-contract/SKILL.md b/.claude/skills/edmund-architecture-contract/SKILL.md new file mode 100644 index 0000000..50a798a --- /dev/null +++ b/.claude/skills/edmund-architecture-contract/SKILL.md @@ -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 `
      `, 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 `
      ` 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 +``` diff --git a/.claude/skills/edmund-build-and-env/SKILL.md b/.claude/skills/edmund-build-and-env/SKILL.md new file mode 100644 index 0000000..2a8831f --- /dev/null +++ b/.claude/skills/edmund-build-and-env/SKILL.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 +# 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 | grep ''` + 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. diff --git a/.claude/skills/edmund-caret-integrity-campaign/SKILL.md b/.claude/skills/edmund-caret-integrity-campaign/SKILL.md new file mode 100644 index 0000000..d549906 --- /dev/null +++ b/.claude/skills/edmund-caret-integrity-campaign/SKILL.md @@ -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-.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. diff --git a/.claude/skills/edmund-change-control/SKILL.md b/.claude/skills/edmund-change-control/SKILL.md new file mode 100644 index 0000000..13f9151 --- /dev/null +++ b/.claude/skills/edmund-change-control/SKILL.md @@ -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. diff --git a/.claude/skills/edmund-config-and-flags/SKILL.md b/.claude/skills/edmund-config-and-flags/SKILL.md new file mode 100644 index 0000000..9e06f3f --- /dev/null +++ b/.claude/skills/edmund-config-and-flags/SKILL.md @@ -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 `- ` 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 `- ` 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 ` | **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 ` | wait before next command | +| `caret ` | place caret before first occurrence of `` | +| `type ` | one real key event per char (~80 ms apart) | +| `backspace ` | n real delete keystrokes (~300 ms apart) | +| `bypassdelete ` | simulate drag-move source deletion: `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `PASS/FAIL` iff caret sits exactly before `` | +| `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..`). +2. Bind it in the relevant SwiftUI pane with `@AppStorage(AppSettings.)`. +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. diff --git a/.claude/skills/edmund-debugging-playbook/SKILL.md b/.claude/skills/edmund-debugging-playbook/SKILL.md new file mode 100644 index 0000000..15a1c6d --- /dev/null +++ b/.claude/skills/edmund-debugging-playbook/SKILL.md @@ -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 -- ` + 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 `. 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 ""`; `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 + ` (`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 `, 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. diff --git a/.claude/skills/edmund-docs-and-writing/SKILL.md b/.claude/skills/edmund-docs-and-writing/SKILL.md new file mode 100644 index 0000000..7abec53 --- /dev/null +++ b/.claude/skills/edmund-docs-and-writing/SKILL.md @@ -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/-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/.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/-investigation.md` | Deep multi-round investigation chronicles for active bug classes | Existing: `delete-drift-`, `viewport-glitch-investigation.md`. Template in §5. | +| `docs/investigations/archives/-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/-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: . See .` 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/.txt` + a feature-map note in §6 | Follow the Lucide precedent. | +| Deep explanation of an existing subsystem | `docs/architecture/.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/`, `docs/`, `chore/`. 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 `

    • ` — +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 +- ([docs](docs/-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 +# "" — investigation notes + +Context for anyone who sees this again. + +Fixed on branch `fix/`, commits: `` — , ... + +## Symptom + +`, `~/.edmund/logs/...`.> + +## How it was diagnosed + +1. + +## Root cause + + + +## The fix + + + +## Verification + + + +## If it ever recurs + + + +## Round 2: ← 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. ``) — leave them unless acting on + them. +- **At release**: CHANGELOG section header ↔ `Info.plist` version ↔ appcast + `` 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. diff --git a/.claude/skills/edmund-external-positioning/SKILL.md b/.claude/skills/edmund-external-positioning/SKILL.md new file mode 100644 index 0000000..f51efd4 --- /dev/null +++ b/.claude/skills/edmund-external-positioning/SKILL.md @@ -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: ("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 ``). 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). diff --git a/.claude/skills/edmund-failure-archaeology/SKILL.md b/.claude/skills/edmund-failure-archaeology/SKILL.md new file mode 100644 index 0000000..779bca0 --- /dev/null +++ b/.claude/skills/edmund-failure-archaeology/SKILL.md @@ -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 `) replays + keystrokes in-process through `window.sendEvent(_:)` — no TCC, works on an + invisible Space. `bypassdelete ` 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 `; 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 - `. + 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. diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md b/.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md new file mode 100644 index 0000000..89d960d --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md @@ -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 +/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 ` | wait before the next command | +| `caret ` | place caret before the first occurrence of `` | +| `type ` | one real key event per char, ~80 ms apart | +| `backspace ` | n real delete keystrokes, ~300 ms apart | +| `bypassdelete ` | simulate the drag-move source deletion: select range, `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `repro assertcaret PASS/FAIL sel=… want=…` iff caret sits exactly before `` | +| `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 ` (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 ".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 out.png` finds the window id +(JXA → `CGWindowListCopyWindowInfo`) and runs `screencapture -x -o -l`. +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 ` | Screenshot a window by id + report bounds | **verify on first use** (JXA CGWindowList lookup not executed this session) | +| `launch-debug.sh [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. diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh new file mode 100755 index 0000000..bc83000 --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# capture-window.sh +# 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 " ", then `screencapture -x -o +# -l` 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 }" +out="${2:?usage: capture-window.sh }" + +read -r wid x y w h < <(osascript -l JavaScript <&2 + exit 1 +fi + +screencapture -x -o -l"$wid" "$out" +echo "Captured window $wid ($needle) -> $out bounds=${x},${y} ${w}x${h}" diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh new file mode 100755 index 0000000..73e442a --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh @@ -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 diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh new file mode 100755 index 0000000..eb84477 --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh @@ -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)" diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh new file mode 100755 index 0000000..809761e --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# launch-debug.sh [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 [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" diff --git a/.claude/skills/edmund-release-and-operate/SKILL.md b/.claude/skills/edmund-release-and-operate/SKILL.md new file mode 100644 index 0000000..0191985 --- /dev/null +++ b/.claude/skills/edmund-release-and-operate/SKILL.md @@ -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 .dmg"` → `Edmund-.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 `` (HTML `` via `scripts/changelog-to-html.py`), inserts it before ``, 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 ' && 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 `, a deliberately tiny converter that +only understands Keep-a-Changelog shapes: + +- `### Added` / `### Changed` / `### Fixed` → `

      ` — **use `###`, not `##`**. + The 0.1.2 appcast item literally shows `

      ## Changed

      ` because the + section used `##` subheads at release time; the converter passed them + through as paragraphs. +- `- ` / `* ` bullets → `
      • `; 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 `

        `. +- Missing section → empty output → the `` 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 `; 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 - +``` + +Both `release.yml` and `release.sh` do exactly this. Never "simplify" it back +to `-s`. Output format: `sparkle:edSignature="" length=""` — 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 ` 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..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 .dmg"` — with a **space**. Both scripts + rename to `Edmund-.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-.dmg` asset (hyphenated name). +- [ ] `appcast.xml` on `main` got the new `` — with ``, + 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 `` 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-.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 `` (title/link/description/language) containing one `` per +release. Items are inserted **before ``**, so the file reads oldest +→ newest; Sparkle doesn't care about order — it picks by version. Per item: + +```xml + + Edmund 0.1.2 + Fri, 03 Jul 2026 19:03:59 +0000 + + + sparkle:shortVersionString="0.1.2" + sparkle:edSignature="…" + length="7608991" + type="application/x-apple-diskimage"/> + +``` + +`` 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). diff --git a/.claude/skills/edmund-research-frontier/SKILL.md b/.claude/skills/edmund-research-frontier/SKILL.md new file mode 100644 index 0000000..a34f8f8 --- /dev/null +++ b/.claude/skills/edmund-research-frontier/SKILL.md @@ -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. diff --git a/.claude/skills/edmund-research-methodology/SKILL.md b/.claude/skills/edmund-research-methodology/SKILL.md new file mode 100644 index 0000000..5384816 --- /dev/null +++ b/.claude/skills/edmund-research-methodology/SKILL.md @@ -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/-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. diff --git a/.claude/skills/edmund-validation-and-qa/SKILL.md b/.claude/skills/edmund-validation-and-qa/SKILL.md new file mode 100644 index 0000000..e53d743 --- /dev/null +++ b/.claude/skills/edmund-validation-and-qa/SKILL.md @@ -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 `. **`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. diff --git a/.claude/skills/textkit2-appkit-reference/SKILL.md b/.claude/skills/textkit2-appkit-reference/SKILL.md new file mode 100644 index 0000000..8c6908a --- /dev/null +++ b/.claude/skills/textkit2-appkit-reference/SKILL.md @@ -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. diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..d3b118d --- /dev/null +++ b/.codex/hooks.json @@ -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 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…" + } + ] + } + ] + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0eb3dd8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..f2f0991 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..385e656 --- /dev/null +++ b/.github/SECURITY.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d001e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..89b5a76 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 .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 ` + # 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 ' ' "$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 ' \n Edmund %s\n %s\n%s\n \n ' "$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(" ", new_item + "\n ") + 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c6208b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6853440 --- /dev/null +++ b/AGENTS.md @@ -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/ ` 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9dd8ee --- /dev/null +++ b/CHANGELOG.md @@ -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 ``: dimmed in edit mode, hidden in read mode (previously showed as literal text in read mode) +- `` added to the rendered HTML whitelist (both modes) +- `` 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 `

        ` 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9df2931 --- /dev/null +++ b/CLAUDE.md @@ -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/ ` 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. diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..5c0686b --- /dev/null +++ b/Info.plist @@ -0,0 +1,68 @@ + + + + + CFBundleName + Edmund + CFBundleDisplayName + Edmund + CFBundleIdentifier + com.i7t5.edmund + CFBundleExecutable + edmd + CFBundleIconFile + AppIcon + NSAccentColorName + AccentColor + CFBundlePackageType + APPL + CFBundleVersion + 5 + CFBundleShortVersionString + 0.1.4 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + SUFeedURL + https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml + SUPublicEDKey + 0XdLbbuOm0NonqWeDJkQuFF0CGnhKmpAfq+RNKVwEDs= + SUEnableAutomaticChecks + + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown Document + CFBundleTypeRole + Editor + LSItemContentTypes + + net.daringfireball.markdown + + CFBundleTypeExtensions + + md + markdown + mdown + mkd + + + + CFBundleTypeName + Plain Text + CFBundleTypeRole + Viewer + LSItemContentTypes + + public.plain-text + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63baad1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LICENSES/lucide.txt b/LICENSES/lucide.txt new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/LICENSES/lucide.txt @@ -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. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..4ece815 --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6f6d8ec --- /dev/null +++ b/Package.swift @@ -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"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..47dcc2c --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Edmund +![macOS Version Compatibility](https://img.shields.io/badge/platform-macOS%2014.0%2B-0064e1?style=flat-square&color=0064e1) +![GitHub License](https://img.shields.io/github/license/i7t5/edmund?style=flat-square&color=772678) +![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/i7t5/edmund/total?style=flat-square&color=ff6916) +![Tiny App Icon](docs/assets/AppIcon/AppIcon_16x16.png) + +Edmund is a minimal, file-based, native Markdown editor for macOS with inline live preview. + + +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. + + + +See [my blog post](https://i7t5.com/posts/2026-06-26-edmund/) for more of the motivation and design philosophy. + + +## Screenshots + +![Basic usage screenshot in light and dark mode](docs/assets/v0.1.0_basic.png) + +![GFM syntax screenshot in edit mode](docs/assets/v0.1.0_gfm-syntax.png) + +![Non-GFM and math syntax screenshot in edit mode](docs/assets/v0.1.0_more-syntax.png) + +![Basic usage in read mode and inspector](docs/assets/v0.1.0_read-mode.png) + + +## Installation + +Get `Edmund.dmg` from the [latest release](https://github.com/I7T5/Edmund/releases/latest), open it, and drag `Edmund.app` to `Applications`: + +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) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..6903c6f --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`I7T5/Edmund` +- 原始仓库:https://github.com/I7T5/Edmund +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..265ae1d Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..63ffca7 --- /dev/null +++ b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 + } +} diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/EdmundCore/Diagnostics/CrashReporter.swift b/Sources/EdmundCore/Diagnostics/CrashReporter.swift new file mode 100644 index 0000000..b10d680 --- /dev/null +++ b/Sources/EdmundCore/Diagnostics/CrashReporter.swift @@ -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 `-.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) -> [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, + 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() + } +} diff --git a/Sources/EdmundCore/Diagnostics/Log.swift b/Sources/EdmundCore/Diagnostics/Log.swift new file mode 100644 index 0000000..c621e79 --- /dev/null +++ b/Sources/EdmundCore/Diagnostics/Log.swift @@ -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(_ 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 + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+BlockquoteContinuation.swift b/Sources/EdmundCore/Editing/EditorTextView+BlockquoteContinuation.swift new file mode 100644 index 0000000..4412ec1 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+BlockquoteContinuation.swift @@ -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) + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+FormattingCommands.swift b/Sources/EdmundCore/Editing/EditorTextView+FormattingCommands.swift new file mode 100644 index 0000000..1e9b613 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+FormattingCommands.swift @@ -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: "", close: "", 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: "", close: "", 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(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 + } + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+FormattingCore.swift b/Sources/EdmundCore/Editing/EditorTextView+FormattingCore.swift new file mode 100644 index 0000000..6eefb16 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+FormattingCore.swift @@ -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 `![alt](dest)`, else nil. + func unwrapImage(_ s: String) -> String? { + captureFirst(s, pattern: #"^!\[([^\]]*)\]\([^)]*\)$"#) + } + + private func captureFirst(_ s: String, pattern: String) -> String? { + guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } + let ns = s as NSString + guard let m = re.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)), + m.numberOfRanges > 1 else { return nil } + return ns.substring(with: m.range(at: 1)) + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+Indentation.swift b/Sources/EdmundCore/Editing/EditorTextView+Indentation.swift new file mode 100644 index 0000000..93bf192 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+Indentation.swift @@ -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.. 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) + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+ListContinuation.swift b/Sources/EdmundCore/Editing/EditorTextView+ListContinuation.swift new file mode 100644 index 0000000..dd22f81 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+ListContinuation.swift @@ -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 + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+ListRenumbering.swift b/Sources/EdmundCore/Editing/EditorTextView+ListRenumbering.swift new file mode 100644 index 0000000..6ee58c2 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+ListRenumbering.swift @@ -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, depthChanged: Set = []) { + 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.. ClosedRange { + 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, sequence: [Int], depthChanged: Set) { + 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) + } +} diff --git a/Sources/EdmundCore/Export/DocumentHTML.swift b/Sources/EdmundCore/Export/DocumentHTML.swift new file mode 100644 index 0000000..c946ef5 --- /dev/null +++ b/Sources/EdmundCore/Export/DocumentHTML.swift @@ -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 `…` 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 """ + + + + + +

        \(body)
        + """ + } + + // MARK: Math (SwiftMath → PNG data URI) + + private static let inlineMathPattern = "" + private static let displayMathPattern = "
        " + + 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 "
        \(HTMLRenderer.escape(tex))
        " + } + let uri = "data:image/png;base64,\(data.base64EncodedString())" + return "
        \"\(HTMLRenderer.attr(tex))\"
        " + } + 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 "\(HTMLRenderer.escape(tex))" + } + 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 "\"\(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 `` tag + // (captured with their leading space so they re-emit verbatim). + private static let imagePattern = + "\"([^\"]*)\"(" + + /// 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 "\"\(alt)\"\(dims)" + } + if lower.hasPrefix("http://") { + return blockedImagePlaceholder(reason:.httpUnsupported) + } + if lower.hasPrefix("https://") { + guard options.allowRemoteImages else { + return blockedImagePlaceholder(reason:.blockedBySetting) + } + return "\"\(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 "\"\(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 "\"\(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 "\(icon)\(reason.label)" + } + + /// 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.. String { + v == v.rounded() ? String(Int(v)) : String(format: "%.1f", v) + } +} diff --git a/Sources/EdmundCore/Export/HTMLRenderer.swift b/Sources/EdmundCore/Export/HTMLRenderer.swift new file mode 100644 index 0000000..c21e793 --- /dev/null +++ b/Sources/EdmundCore/Export/HTMLRenderer.swift @@ -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 ``/`` + /// 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 `
        ` + + /// 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 = "
          " + for (id, bodyHTML) in footnotes { + let safeID = Self.attr(id) + out += "
        1. \(bodyHTML) " + + "
        2. " + } + out += "
        " + 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: "
        ", 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 "
        " + } + + // 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 "

        \(renderChildren(of: paragraph))

        " + } + + mutating func visitHeading(_ heading: Heading) -> String { + let level = min(max(heading.level, 1), 6) + return "\(renderChildren(of: heading))" + } + + 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
         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 "
        \(Self.highlightCode(code, language: codeBlock.language))
        " + } + + /// 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 + /// ``. 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 += "\(escape(ns.substring(with: r)))" + 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 { "
        " } + + 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 "
        \(renderChildren(of: 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 "
          \(renderChildren(of: list))
        " + } + + mutating func visitOrderedList(_ list: OrderedList) -> String { + listIsTight.append(isTight(list)) + defer { listIsTight.removeLast() } + let start = list.startIndex == 1 ? "" : " start=\"\(list.startIndex)\"" + return "\(renderChildren(of: list))" + } + + 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 = "" + + "\(LucideIcons.checkboxSVG(checked: checked))" + let checkedClass = checked ? " task--checked" : "" + return "
      • \(mark)\(renderListItemContents(listItem))
      • " + } + return "
      • \(renderListItemContents(listItem))
      • " + } + + /// Item contents; in a tight list, each direct Paragraph child loses its + ///

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

        "), html.hasSuffix("

        ") { + 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 = "
        " + for (col, cell) in table.head.cells.enumerated() { + html += "\(renderChildren(of: cell))" + } + html += "" + for row in table.body.rows { + html += "" + for (col, cell) in row.cells.enumerated() { + html += "\(renderChildren(of: cell))" + } + html += "" + } + html += "
        " + return html + } + + // MARK: Inline + + mutating func visitText(_ text: Text) -> String { + Self.renderInline(text.string, rawSource: sourceText(text)) + } + mutating func visitEmphasis(_ emphasis: Emphasis) -> String { "\(renderChildren(of: emphasis))" } + mutating func visitStrong(_ strong: Strong) -> String { "\(renderChildren(of: strong))" } + mutating func visitStrikethrough(_ s: Strikethrough) -> String { "\(renderChildren(of: s))" } + mutating func visitInlineCode(_ code: InlineCode) -> String { "\(Self.escape(code.code))" } + mutating func visitLineBreak(_ lineBreak: LineBreak) -> String { "
        \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 "\(inner)" + } + let encoded = dest.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? dest + return "\(inner)" + } + + /// 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.. 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 "\"\(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 `` is invisible, like in a browser. + let trimmed = html.rawHTML.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("") { return "" } + if isSingleTag(trimmed, named: "img"), let img = Self.imgPlaceholder(trimmed) { + return "

        \(img)

        " + } + // GFM passthrough (§4.6): tagfilter + hardening, then rewrite interior + // `` tags to asset-pass placeholders (same baseURL-nil reason + // as inline; also the remote-image-policy enforcement point). No

        + // 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: #"`]+|'[^']*'|"[^"]*"))?)*\s*/?>"#, + options: [.caseInsensitive]) + + /// Replaces every `` 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 `` 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 `` becomes the asset-pass + /// placeholder — REQUIRED, not just policy: the page loads with `baseURL: nil`, + /// so a raw relative `` 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(" + case processing // 3: + case declaration // 4: + case cdata // 5: + 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}|$)"#, + 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 `") || l.contains("

        ") || l.contains("") + 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.. 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" } + } +} diff --git a/Sources/EdmundCore/Parsing/CodeHighlighter.swift b/Sources/EdmundCore/Parsing/CodeHighlighter.swift new file mode 100644 index 0000000..d965035 --- /dev/null +++ b/Sources/EdmundCore/Parsing/CodeHighlighter.swift @@ -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 = [ + "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 = [ + // 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 + } +} diff --git a/Sources/EdmundCore/Parsing/CodeSyntaxPalette.swift b/Sources/EdmundCore/Parsing/CodeSyntaxPalette.swift new file mode 100644 index 0000000..d5faeef --- /dev/null +++ b/Sources/EdmundCore/Parsing/CodeSyntaxPalette.swift @@ -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" + } + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift new file mode 100644 index 0000000..aafed5e --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift @@ -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: "") + + /// Parses HTML `` 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)(.+?)(?= 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 = { + 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[^>]*)?>(.*?)", + 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 ``, and + /// 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]*?\?>|]*>|"#) + + // `` 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 (``, ``, ``, ``, ``) + /// 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) + + // `` 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 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.. 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)) + } + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift new file mode 100644 index 0000000..c68642b --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift @@ -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) -> [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] + )) + } + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter+WalkerInline.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter+WalkerInline.swift new file mode 100644 index 0000000..9b01c1c --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter+WalkerInline.swift @@ -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) + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter.swift new file mode 100644 index 0000000..ff1e38b --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter.swift @@ -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 = ["u", "kbd", "mark", "sub", "sup", "small"] + + // MARK: - Model + + public struct Span: Sendable { + public let kind: Kind + public let fullRange: NSRange + public let contentRange: NSRange + public let delimiterRanges: [NSRange] + + public enum Kind: Equatable, Sendable { + case bold + case italic + case boldItalic + case code + case codeBlock(language: String?) + case strikethrough + case highlight + case heading(Int) + case link(destination: String) + /// A markdown `![alt](src)` image, or an HTML `` 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 (`` or ``) 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 (``, 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 `` 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 (``); 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 + } + +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+CalloutRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+CalloutRendering.swift new file mode 100644 index 0000000..f6d566a --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+CalloutRendering.swift @@ -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..`-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)) + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+CodeHighlighting.swift b/Sources/EdmundCore/Rendering/EditorTextView+CodeHighlighting.swift new file mode 100644 index 0000000..a76db38 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+CodeHighlighting.swift @@ -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) + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift new file mode 100644 index 0000000..9064a43 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift @@ -0,0 +1,211 @@ +import AppKit + +// MARK: - Image Rendering +// +// `![alt](path)` renders the referenced image inline when the cursor is outside +// the token, and shows the raw, editable markdown when the cursor is inside it +// (the `.image` branch of `styleBlock`). A loaded image is drawn by a +// `FragmentOverlay` anchored on the leading `!` — the same mechanism math and +// list markers use — with the rest of the markdown hidden and the line height +// reserved for the picture. An image that can't be shown (outside the token) +// gets the same overlay treatment, but with a small icon + reason in place of +// the picture, so the user knows *why* — not just that nothing rendered. +// +// Resolution: absolute paths, `~`-paths, and `file:` URLs load directly; +// relative paths resolve against the document's directory. A remote `https` +// image loads only when `allowRemoteImages` is on (mirrors Read mode's +// `allowRemoteImages`/`blockExternalImages`), and asynchronously — loading it +// synchronously on the styling path would block the main thread. `http` never +// loads: App Transport Security refuses the insecure connection outright, +// regardless of the setting (same reasoning as Read mode's DocumentHTML). + +// Loaded images are cached by resolved absolute path (local) or URL string +// (remote), so a recompose doesn't re-read/re-fetch them. NSCache is +// internally thread-safe. +nonisolated(unsafe) private let imageCache = NSCache() + +// 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() + +// 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() + +/// Why an `![alt](destination)` couldn't be shown — the short label a +/// blocked-image placeholder draws next to its icon. Shared by Edit mode +/// (this file) and Read mode/export (`DocumentHTML`) so the two report the +/// same reason, in the same words, for the same failure. +enum ImageLoadFailure { + case httpUnsupported + case blockedBySetting + case notAnImage + case notFound + + var label: String { + switch self { + case .httpUnsupported: return "HTTP connection not supported" + case .blockedBySetting: return "External images blocked" + case .notAnImage: return "Not an image" + case .notFound: return "Image not found" + } + } +} + +extension EditorTextView { + + /// What `styleBlock` should show for an image token when the cursor is + /// outside it. + enum ImageDisplay { + /// The image loaded; draw it. + case image(NSImage) + /// It can't be shown; draw an icon + `failure.label` in its place. + case blocked(ImageLoadFailure) + /// A remote fetch is in flight — transient, not an error; the caller + /// falls back to plain alt text until a recompose picks up the result. + case pending + } + + /// Resolves and (for local files) loads the image referenced by + /// `destination`, classifying why it can't be shown when it can't. + func imageDisplay(destination: String) -> ImageDisplay { + let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !dest.isEmpty else { return .blocked(.notFound) } + + if let scheme = URL(string: dest)?.scheme?.lowercased(), scheme == "http" || scheme == "https" { + guard scheme == "https" else { return .blocked(.httpUnsupported) } + guard allowRemoteImages else { return .blocked(.blockedBySetting) } + return loadRemoteImage(dest) + } + guard let url = resolveImageURL(dest) else { return .blocked(.notFound) } + let key = url.path as NSString + if let cached = imageCache.object(forKey: key) { return .image(cached) } + // `resolveImageURL` builds a URL from the path string alone (it doesn't + // check existence), so a missing file and an undecodable one both fail + // `NSImage(contentsOf:)` the same way — check existence first so the two + // get distinct, accurate messages. + guard FileManager.default.fileExists(atPath: url.path) else { return .blocked(.notFound) } + guard let image = NSImage(contentsOf: url) else { return .blocked(.notAnImage) } + imageCache.setObject(image, forKey: key) + return .image(image) + } + + /// Returns the cached/decoded outcome for a remote `urlString`; otherwise + /// starts an async fetch (once per URL, while one is already in flight) + /// and returns `.pending`. The completion caches the image (or remembers a + /// decode failure) and re-styles the document so the result appears — + /// without blocking the main thread on network I/O. + private func loadRemoteImage(_ urlString: String) -> ImageDisplay { + let key = urlString as NSString + if let cached = imageCache.object(forKey: key) { return .image(cached) } + if undecodableRemoteImages.contains(urlString) { return .blocked(.notAnImage) } + guard !inFlightRemoteImages.contains(urlString), let url = URL(string: urlString) else { return .pending } + inFlightRemoteImages.insert(urlString) + + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + let image = data.flatMap { NSImage(data: $0) } + Task { @MainActor in + inFlightRemoteImages.remove(urlString) + if let image { + imageCache.setObject(image, forKey: urlString as NSString) + } else { + undecodableRemoteImages.insert(urlString) + } + self?.recomposeAllDirty() + } + }.resume() + return .pending + } + + /// Resolves a destination string to a local file URL. Returns nil for a + /// remote URL (handled separately, before this is reached) or when a + /// relative path can't be anchored. + private func resolveImageURL(_ destination: String) -> URL? { + let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !dest.isEmpty else { return nil } + + if let url = URL(string: dest), let scheme = url.scheme { + return scheme == "file" ? url : nil + } + if dest.hasPrefix("/") { return URL(fileURLWithPath: dest) } + if dest.hasPrefix("~") { + return URL(fileURLWithPath: (dest as NSString).expandingTildeInPath) + } + // Relative to the document's directory. + if let docDir = document?.fileURL?.deletingLastPathComponent() { + return docDir.appendingPathComponent(dest) + } + return nil + } + + /// A `FragmentOverlay` for `destination`'s image or placeholder, or nil + /// while a remote fetch is pending (the caller then shows plain alt text). + /// `width`/`height` are declared pixel dimensions from an HTML `` 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)) + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+ListMarkerRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+ListMarkerRendering.swift new file mode 100644 index 0000000..47d2a82 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+ListMarkerRendering.swift @@ -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) + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+ListRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+ListRendering.swift new file mode 100644 index 0000000..3d035c8 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+ListRendering.swift @@ -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) + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift new file mode 100644 index 0000000..1918312 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift @@ -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() + +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 + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift new file mode 100644 index 0000000..9d57663 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift @@ -0,0 +1,750 @@ +import AppKit + +extension NSAttributedString.Key { + /// Stores a link's destination (URL string) on its visible text so a + /// cmd+click can follow it. Kept separate from the system `.link` attribute + /// to avoid NSTextView's built-in link styling/cursor behavior. + static let editorLinkURL = NSAttributedString.Key("EditorLinkURL") + /// Stores a wikilink's raw `path#heading` target on its visible text so a + /// cmd+click can resolve it to a file or in-document heading. + static let editorWikiTarget = NSAttributedString.Key("EditorWikiTarget") +} + +// MARK: - Word-Level Styling +// +// This file is the heart of the inline live preview. `styleBlock` takes one +// block's raw markdown, parses it into spans (SyntaxHighlighter), and returns +// an NSAttributedString that decorates the *same* characters — the text storage +// always holds the raw markdown, never a stripped version. Formatting is purely +// attribute-based: +// +// - Content gets rich styling (bold/italic, code color, heading size, …). +// - Inline delimiters (`**`, `*`, `` ` ``, `$`) are hidden when the cursor is +// outside the token (near-zero font + clear color) and dimmed when inside. +// - Block markers (`#`, `>`, list bullets) are decorated or dimmed, never +// stripped, so editing stays WYSIWYG-ish and round-trips losslessly. +// +// Larger, self-contained pieces live in sibling files to keep this one focused: +// - EditorTextView+ListMarkerRendering.swift — the `.listItem` styling case +// - EditorTextView+TableRendering.swift — the `.table` styling case +// - EditorTextView+ListRendering.swift — list/checkbox/bullet markers + indent +// - EditorTextView+TableSupport.swift — table border blocks + row parsing +// - EditorTextView+MathRendering.swift — `$…$` / `$$…$$` rendering + raw coloring +// +// What remains here: the styling primitives (fonts/colors/paragraph styles), +// the `styleBlock` switch that dispatches per span kind, and the in-place +// `restyleBlock` / `applyBlockStyle` used to re-style a single block on edits. + +extension EditorTextView { + + /// Color for dimmed syntax delimiters (*, **, `, #, etc.) + var syntaxDimColor: NSColor { .tertiaryLabelColor } + + /// Color for links and wikilinks — always the theme's accent blue, independent of + /// the system accent so links stay consistently blue across user accent preferences. + var linkColor: NSColor { theme.linkBlueColor } + + /// Monospaced font for tables. + var tableFont: NSFont { theme.monospaceFont() } + + /// Monospaced font for code blocks. + var codeBlockFont: NSFont { theme.monospaceFont() } + + /// Font used to visually hide delimiter characters. + /// Near-zero size makes them effectively invisible and zero-width. + var hiddenFont: NSFont { NSFont.systemFont(ofSize: 0.01) } + + /// Monospaced font for inline code spans. + var inlineCodeFont: NSFont { theme.monospaceFont() } + + /// Subtle background color for inline code spans. + var inlineCodeBackground: NSColor { + NSColor(calibratedWhite: 0.5, alpha: 0.1) + } + + /// Paragraph style for thematic breaks. The raw dashes are hidden with a + /// near-zero font, which would collapse the line — so we force the line to a + /// full body-line height and add symmetric breathing space above and below. + /// A `.horizontalRule` BlockDecoration draws the hairline centered in it. + private func thematicBreakParagraphStyle() -> NSParagraphStyle { + let lineHeight = bodyFont.pointSize + theme.lineSpacing + + let ps = NSMutableParagraphStyle() + // Force a real line height despite the hidden (0.01pt) dashes. + ps.minimumLineHeight = lineHeight + ps.maximumLineHeight = lineHeight + // Symmetric breathing space. The rule is drawn centered in the + // fragment, so paragraphSpacingBefore sits above the line (and the + // rule) while paragraphSpacing sits below — equal values keep the + // rule visually equidistant from the text on either side. Kept small so + // the break occupies roughly a body line plus a little air, not a full + // blank line above and below. + let pad = bodyFont.pointSize * 0.2 + ps.paragraphSpacingBefore = pad + ps.paragraphSpacing = pad + return ps + } + + /// How far below the rule fragment's geometric center to draw the hairline. + /// Adjacent text sits at its baseline (low in its line box), so a + /// center-drawn rule looks too close to the line above; this nudge brings + /// it down to the optical midpoint between the surrounding text. Tuned + /// against rendered output (see RenderingRegressionTests / screencapture). + var thematicBreakCenterOffset: CGFloat { bodyFont.pointSize * 0.3 } + + /// Width of the `> ` quote marker in body text. Used as the hanging indent + /// for blockquotes and callouts so wrapped/continuation lines align after + /// the marker (like list items) rather than under the `>`. The marker is + /// rendered width-preserved (clear when inactive, dimmed when active) on + /// each line's first visual line, so subsequent lines hang by this width. + var quoteMarkerWidth: CGFloat { + ("> " as NSString).size(withAttributes: [.font: bodyFont]).width + } + + /// Paragraph style for blockquotes: a 2pt text inset matching the width of + /// the left bar that the `.leftBar` BlockDecoration draws, plus a hanging + /// indent so wrapped lines align after the `> ` marker. + private func blockquoteParagraphStyle() -> NSParagraphStyle { + let ps = NSMutableParagraphStyle() + ps.lineSpacing = bodyParagraphStyle.lineSpacing + ps.paragraphSpacing = bodyParagraphStyle.paragraphSpacing + ps.firstLineHeadIndent = 2 + ps.headIndent = 2 + quoteMarkerWidth + return ps + } + + // MARK: - Delimiter Hiding Classification + + /// Returns true if this span kind's delimiters should be hidden (not just + /// dimmed) when the cursor is not inside the token. + private func isDelimiterHideable(_ kind: SyntaxHighlighter.Span.Kind) -> Bool { + switch kind { + case .bold, .italic, .boldItalic, .strikethrough, .highlight, + .code, .link, .image, .lineBreak, + .heading, .blockquote(_), .footnoteReference, .escape: + return true + case .listItem, .table, .codeBlock, .thematicBreak, .footnoteDefinition, .comment, + .htmlTag, .htmlFormat: + // htmlTag: always colored source (brackets dimmed by the generic + // pass). htmlFormat: handled explicitly in the delimiter loop. + return false + case .wikilink: + // The `[[`, optional `target|`, and `]]` are hidden when rendered, + // dimmed when the cursor is inside (like other inline delimiters). + return true + case .math(let display): + // Inline math hides its `$` like other inline tokens; display math + // is block-level and handled specially. + return !display + } + } + + // MARK: - Unified Styling + + /// Styles raw markdown text with rich attributes. Inline delimiters are hidden + /// unless the cursor is inside the token (in which case they're dimmed). + /// Block-level markers are always dimmed, never hidden. + /// + /// - Parameters: + /// - markdown: Raw markdown text. + /// - cursorPosition: Cursor offset within the markdown (nil = hide all inline delimiters). + func styleBlock(_ markdown: String, cursorPosition: Int? = nil, + hideComments: Bool = false) -> NSAttributedString { + let result = NSMutableAttributedString(string: markdown, attributes: baseAttributes) + guard !markdown.isEmpty else { return result } + + let spans = SyntaxHighlighter.parse(markdown, linkDefinitions: linkDefState.defsText) + + // The font already applied at `loc` — the enclosing heading's when + // inside one, else the base body font. Inline spans derive their font + // from it so `# **bold** and `code`` keeps the heading's size. Spans + // apply in location order, so a heading (at the block start) styles + // its fullRange before any inner span reads the context. + func contextFont(at loc: Int) -> NSFont { + guard loc >= 0, loc < result.length else { return bodyFont } + return result.attribute(.font, at: loc, effectiveRange: nil) as? NSFont ?? bodyFont + } + // The mono font matching `ctx`'s scale: the plain inline-code font in + // body text, scaled up inside a heading. + func monoFont(for ctx: NSFont) -> NSFont { + let scale = ctx.pointSize / bodyFont.pointSize + return scale == 1 ? inlineCodeFont + : theme.monospaceFont(ofSize: inlineCodeFont.pointSize * scale) + } + + for span in spans { + let cursorInToken = cursorPosition.map { + $0 >= span.fullRange.location && $0 <= span.fullRange.upperBound + } ?? false + + // --- Content styling (applied first) --- + switch span.kind { + case .bold: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + let bold = NSFontManager.shared.convert(ctx, toHaveTrait: .boldFontMask) + result.addAttribute(.font, value: bold, range: span.contentRange) + + case .italic: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + let italic = NSFontManager.shared.convert(ctx, toHaveTrait: .italicFontMask) + result.addAttribute(.font, value: italic, range: span.contentRange) + + case .boldItalic: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + let bi = NSFontManager.shared.convert(ctx, toHaveTrait: [.boldFontMask, .italicFontMask]) + result.addAttribute(.font, value: bi, range: span.contentRange) + + case .code: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + result.addAttribute(.font, value: monoFont(for: ctx), range: span.contentRange) + result.addAttribute(.foregroundColor, value: foregroundColor, range: span.contentRange) + result.addAttribute(.backgroundColor, value: inlineCodeBackground, range: span.contentRange) + + case .codeBlock(let language): + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.font, value: codeBlockFont, range: span.contentRange) + highlightCodeBlock(result, contentRange: span.contentRange, language: language) + + case .strikethrough: + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange) + + case .highlight: + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: span.contentRange) + + case .heading(let level): + guard span.fullRange.upperBound <= result.length else { continue } + let scale: CGFloat = level == 1 ? 1.5 : level == 2 ? 1.3 : level == 3 ? 1.15 : 1.0 + let sized = NSFont(descriptor: bodyFont.fontDescriptor, + size: bodyFont.pointSize * scale) ?? bodyFont + let heading = NSFontManager.shared.convert(sized, toHaveTrait: .boldFontMask) + result.addAttribute(.font, value: heading, range: span.fullRange) + + case .link(let destination): + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange) + result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange) + if !destination.isEmpty { + result.addAttribute(.editorLinkURL, value: destination, range: span.contentRange) + } + + case .wikilink(let target): + guard span.contentRange.upperBound <= result.length else { continue } + // The display text reads as a link; the brackets (and a + // `target|` alias prefix) are hidden/dimmed by the delimiter pass. + result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange) + result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, + range: span.contentRange) + if !target.isEmpty { + result.addAttribute(.editorWikiTarget, value: target, range: span.contentRange) + } + + case .image(let destination, let width, let height): + guard span.fullRange.upperBound <= result.length else { continue } + if !cursorInToken, let overlay = imageOverlay(destination: destination, + width: width, height: height) { + // Rendered: draw the image at the leading character (`!` of + // `![alt](path)`, `<` of ``) 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) ``: 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 + diff --git a/Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift new file mode 100644 index 0000000..5510cb9 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift @@ -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.. 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.. 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.. 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.. 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 + } + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+TableSupport.swift b/Sources/EdmundCore/Rendering/EditorTextView+TableSupport.swift new file mode 100644 index 0000000..1ccbde1 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+TableSupport.swift @@ -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.. [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.. 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 +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+WikiLinks.swift b/Sources/EdmundCore/Rendering/EditorTextView+WikiLinks.swift new file mode 100644 index 0000000..ad823ef --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+WikiLinks.swift @@ -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 + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextStorage.swift b/Sources/EdmundCore/TextView/EditorTextStorage.swift new file mode 100644 index 0000000..1522d8c --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextStorage.swift @@ -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) + } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+Composition.swift b/Sources/EdmundCore/TextView/EditorTextView+Composition.swift new file mode 100644 index 0000000..531bcd5 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+Composition.swift @@ -0,0 +1,254 @@ +import AppKit + +// MARK: - Display Composition & Coordinate Mapping +// +// `recompose` rebuilds the whole text storage by styling every block and +// joining them; `recomposeIncremental` re-styles only the block(s) the cursor +// moved between, which is what runs on most edits. Because the text storage +// always equals the raw source (word-level rendering, no string stripping), +// the display↔raw coordinate mapping is the identity — display offsets are +// used as raw offsets directly. + +extension EditorTextView { + + // MARK: - Display Composition + // + // Text storage content = rawSource, always. + // Styling is attribute-only; the string never changes during recompose. + + /// Full recompose: replaces the entire text storage with `rawSource` in + /// base attributes, then styles via the dirty flush — viewport-first when + /// the editor is in a scroll view, everything synchronously otherwise. + /// Used when rawSource was rebuilt (initial load, undo/redo, indent) and + /// for content changes that bypass the edit path. + func recompose(cursorInRaw: Int, selectionInRaw: NSRange? = nil) { + guard let ts = textStorage else { return } + + Log.measure("Full recompose (\(blocks.count) blocks)", category: .compose, level: .debug) { + isUpdating = true + let fullRange = NSRange(location: 0, length: ts.length) + ts.beginEditing() + ts.replaceCharacters(in: fullRange, + with: NSAttributedString(string: rawSource, + attributes: baseAttributes)) + ts.endEditing() + // Whole-document replacement; blocks are re-parsed by our callers. + (ts as? EditorTextStorage)?.clearPendingEdit() + isUpdating = false + + for i in blocks.indices { blocks[i].isStyled = false } + recomposeDirty(IndexSet(blocks.indices), cursorInRaw: cursorInRaw, + selectionInRaw: selectionInRaw, settingSelection: true) + } + } + + /// Range-bounded recompose: replaces only `oldRange` (pre-edit storage + /// coordinates) with `newText` in base attributes, then restyles the given + /// dirty blocks. Unlike `recompose`, the storage — and its TextKit 2 layout + /// — outside `oldRange` is untouched, so content above the edit keeps its + /// laid-out positions and the viewport can't lurch. For edits that rebuild + /// `rawSource` but change only a contiguous span (Tab / Shift-Tab indent): + /// callers update `rawSource` and re-parse `blocks` first, then pass the old + /// span's range and its replacement text. + func recomposeReplacing(oldRange: NSRange, with newText: String, + dirty: IndexSet, cursorInRaw: Int, + selectionInRaw: NSRange? = nil) { + guard let ts = textStorage else { return } + + isUpdating = true + ts.beginEditing() + ts.replaceCharacters(in: oldRange, + with: NSAttributedString(string: newText, + attributes: baseAttributes)) + ts.endEditing() + // Programmatic replacement; the incremental parser must not see it. + (ts as? EditorTextStorage)?.clearPendingEdit() + isUpdating = false + + for idx in dirty where idx < blocks.count { blocks[idx].isStyled = false } + recomposeDirty(dirty, cursorInRaw: cursorInRaw, + selectionInRaw: selectionInRaw, settingSelection: true) + } + + /// Dirty-set recompose: restyles exactly the given block indexes in place. + /// Attribute-only — the storage string is never touched. This is the + /// single styling path for edits, activation changes, and theme / + /// appearance refreshes; `recompose` (string-replacing) remains only for + /// paths that rebuild `rawSource` (load, undo, indent). + /// + /// `settingSelection` is true for selection-driven and whole-document + /// callers (preserving the old recompose behavior); the edit path leaves + /// the caret where NSTextView already placed it to avoid re-entrant + /// selection notifications. + func recomposeDirty( + _ dirty: IndexSet, + cursorInRaw: Int, + selectionInRaw: NSRange? = nil, + settingSelection: Bool = false + ) { + guard let ts = textStorage else { return } + + isUpdating = true + + let newActiveIndex = blockIndexForRawOffset(cursorInRaw) + activeBlockIndex = newActiveIndex + + // Lazy rendering: a LARGE dirty set (load, theme change, a fence + // absorbing half the document) is restyled synchronously only near + // the viewport; the rest goes to the idle drain / scroll promotion. + // Small sets — every normal interaction — are styled in full, so + // user-visible state transitions are never deferred. Without a + // scroll view (headless), everything is synchronous. + var syncSet = dirty + if dirty.count > 8, let bounds = syncStylingBlockRange() { + syncSet = dirty.filteredIndexSet { bounds.contains($0) } + if let active = newActiveIndex, dirty.contains(active) { + syncSet.insert(active) + } + } + let deferred = dirty.subtracting(syncSet) + + let nsString = ts.string as NSString + ts.beginEditing() + for idx in syncSet where idx < blocks.count { + let cursorInBlock: Int? = (idx == newActiveIndex) + ? max(0, cursorInRaw - blocks[idx].range.location) : nil + restyleBlock(idx, cursorInBlock: cursorInBlock) + blocks[idx].isStyled = true + + // Full recompose resets separator newlines to base attributes as + // a side effect of rebuilding the whole string; do the same for + // dirty blocks so stale paragraph styles can't linger on the `\n` + // after e.g. a former callout. + let sep = blocks[idx].range.upperBound + if sep < nsString.length && nsString.character(at: sep) == 0x0A { + ts.setAttributes(baseAttributes, range: NSRange(location: sep, length: 1)) + } + } + ts.endEditing() + + // A block whose paragraph style we just changed (e.g. a freshly created + // list item's indent) can keep a stale first-line layout: on the edit + // path `insertText` already laid the line out with the base typing + // attributes, and TextKit 2 doesn't re-measure the first-line indent for + // an attribute-only change. Force the restyled blocks to re-lay-out so + // the new indent shows immediately instead of after the next cursor move. + if let tlm = textLayoutManager { + for idx in syncSet where idx < blocks.count { + if let range = blockTextRange(blocks[idx].range, tlm) { + tlm.invalidateLayout(for: range) + } + } + } + + for idx in deferred where idx < blocks.count { + blocks[idx].isStyled = false + } + + if settingSelection { + if let rawSel = selectionInRaw, rawSel.length > 0 { + let len = ts.length + let displaySel = NSRange( + location: min(rawSel.location, len), + length: max(0, min(rawSel.upperBound, len) - min(rawSel.location, len)) + ) + setSelectedRange(displaySel) + } else { + let clamped = min(cursorInRaw, ts.length) + setSelectedRange(NSRange(location: clamped, length: 0)) + } + } + + typingAttributes = baseAttributes + + isUpdating = false + + if !deferred.isEmpty { + scheduleProgressiveStyling() + } else { + // Small documents stay fully laid out (no TextKit 2 height + // estimates): re-lay the blocks this flush invalidated once this + // interaction settles. Cheap on the per-keystroke path — only the + // invalidated fragments are re-laid. + scheduleFullLayoutSettle() + } + } + + /// Maps a block's raw NSRange to an `NSTextRange` for layout invalidation. + func blockTextRange(_ nsRange: NSRange, _ tlm: NSTextLayoutManager) -> NSTextRange? { + guard let start = tlm.location(tlm.documentRange.location, offsetBy: nsRange.location), + let end = tlm.location(start, offsetBy: nsRange.length) else { return nil } + return NSTextRange(location: start, end: end) + } + + /// Incremental recompose: only re-styles the old and new active blocks. + /// Used when the cursor moves between blocks without changing content. + /// `settingSelection` is false when the caller already owns the caret (a + /// user-driven cursor move) — re-setting it would trigger AppKit's + /// scroll-the-selection-into-view, fighting typewriter centering. + func recomposeIncremental(cursorInRaw: Int, selectionInRaw: NSRange? = nil, + settingSelection: Bool = true) { + var dirty = IndexSet() + if let oldIdx = activeBlockIndex, oldIdx < blocks.count { dirty.insert(oldIdx) } + if let newIdx = blockIndexForRawOffset(cursorInRaw) { dirty.insert(newIdx) } + recomposeDirty(dirty, cursorInRaw: cursorInRaw, + selectionInRaw: selectionInRaw, settingSelection: settingSelection) + } + + /// Restyles every block in place (attribute-only). For theme and + /// appearance changes: the string is unchanged but every attribute + /// derives from the new theme/appearance. + func recomposeAllDirty() { + for i in blocks.indices { blocks[i].isStyled = false } + recomposeDirty(IndexSet(blocks.indices), + cursorInRaw: currentCursorInRaw(), + settingSelection: true) + } + + /// The block-index window to style synchronously: the TextKit 2 viewport + /// plus a margin, or — before any layout exists (fresh load) — a window + /// around the active block. Returns nil without a scroll view (headless): + /// callers then style everything synchronously. + func syncStylingBlockRange() -> ClosedRange? { + guard enclosingScrollView != nil, !blocks.isEmpty, + let tlm = textLayoutManager else { return nil } + + if let viewport = tlm.textViewportLayoutController.viewportRange { + let docStart = tlm.documentRange.location + let start = tlm.offset(from: docStart, to: viewport.location) + let end = tlm.offset(from: docStart, to: viewport.endLocation) + if let s = blockIndexForRawOffset(start), + let e = blockIndexForRawOffset(max(start, end)) { + let margin = max(16, e - s + 1) + return max(0, s - margin) ... min(blocks.count - 1, e + margin) + } + } + // No viewport yet (first layout hasn't happened): style a generous + // window around the cursor; the drain and scroll promotion cover the rest. + let anchor = activeBlockIndex ?? 0 + return max(0, anchor - 200) ... min(blocks.count - 1, anchor + 200) + } + + // MARK: - Coordinate Mapping + // + // With text storage = rawSource, display offset = raw offset (identity). + + /// Binary search over the (sorted, adjacent) block ranges. An offset at a + /// block's `upperBound` — the trailing `\n` separator — belongs to that + /// block; offsets past the last block clamp to it. + func blockIndexForRawOffset(_ rawOffset: Int) -> Int? { + guard !blocks.isEmpty else { return nil } + var lo = 0 + var hi = blocks.count - 1 + // First block whose inclusive upper bound reaches the offset. + while lo < hi { + let mid = (lo + hi) / 2 + if blocks[mid].range.upperBound < rawOffset { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+ContentWidth.swift b/Sources/EdmundCore/TextView/EditorTextView+ContentWidth.swift new file mode 100644 index 0000000..2e82960 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+ContentWidth.swift @@ -0,0 +1,50 @@ +import AppKit + +// MARK: - Content width (centered reading column) +// +// The text column has a physical maximum width (set in cm in Settings and +// converted to points using the display's real PPI). Windows wider than the +// cap get symmetric side margins that center the column; narrower windows +// fill edge-to-edge as usual. This is CSS `max-width` semantics: the cap is +// an absolute physical size, not a fraction of the window or the screen, so +// the column doesn't widen when you make the window bigger. + +extension EditorTextView { + + /// Padding applied on each side of the text column at all window sizes. + static let contentBaseInset: CGFloat = 24 + + /// The symmetric horizontal inset for a given view width and max-column width. + /// `maxContentWidth == .greatestFiniteMagnitude` → base inset only (fills the window). + /// When the window is too narrow to fit `maxContentWidth`, the column also fills. + public static func horizontalInset(viewWidth: CGFloat, maxContentWidth: CGFloat) -> CGFloat { + let available = viewWidth - 2 * contentBaseInset + guard available > maxContentWidth else { return contentBaseInset } + return contentBaseInset + (available - maxContentWidth) / 2 + } + + /// Recomputes the horizontal text inset from the current bounds + max-column cap, + /// preserving the vertical inset. Usually no recompose — only the inset + /// changes and TextKit 2 reflows wrapped text on its own. The exception is + /// image overlays: their scaled-to-fit size is baked into the styled + /// attribute at render time (§4 fragmentOverlay), not recomputed at draw + /// time, so a column narrower than an already-rendered image needs those + /// blocks restyled to shrink it. + public func updateContentInset() { + let target = Self.horizontalInset(viewWidth: bounds.width, + maxContentWidth: maxContentWidthPoints) + guard abs(textContainerInset.width - target) > 0.5 else { return } + textContainerInset = NSSize(width: target, height: textContainerInset.height) + + let imageBlocks = IndexSet(blocks.indices.filter { blocks[$0].content.contains("![") }) + guard !imageBlocks.isEmpty else { return } + for idx in imageBlocks { blocks[idx].isStyled = false } + recomposeDirty(imageBlocks, cursorInRaw: currentCursorInRaw(), settingSelection: true) + } + + /// Recompute the centered inset as the view width changes (window resize). + public override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + updateContentInset() + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift b/Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift new file mode 100644 index 0000000..19dc74d --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift @@ -0,0 +1,96 @@ +import AppKit + +// MARK: - Editor diagnostics +// +// The edit pipeline lives in the live NSTextView / TextKit 2 / input-context +// layer, which can't be reproduced or inspected headlessly — so bugs there +// (caret drift, sync desyncs) are hard to pin down from a recording alone. These +// helpers capture the decisive live state: +// +// - `traceEdit` — a one-line snapshot of caret + flags + lengths, emitted only +// when verbose editor tracing is on (Settings ▸ Advanced). Sprinkled at the +// key pipeline points so a reproduction yields a readable keystroke-level +// trail in `~/.edmund/logs`. +// - `verifyEditorInvariants` — checks the two model invariants after a sync. A +// cheap length check is effectively always on (logs an error if the +// storage==rawSource invariant ever breaks); the full structural check runs +// under verbose tracing (and asserts in DEBUG). + +extension EditorTextView { + + /// Compact live-state prefix: caret, active block, marked-text, the sync + /// flags, and storage-vs-rawSource lengths — everything that explains a caret + /// drift or a stranded sync. + var diagnosticState: String { + let sel = selectedRange() + let marked = markedRange() + let markedDesc = marked.location == NSNotFound + ? "-" : "{\(marked.location),\(marked.length)}" + let storLen = textStorage?.length ?? -1 + let rawLen = (rawSource as NSString).length + return "sel={\(sel.location),\(sel.length)} active=\(activeBlockIndex.map(String.init) ?? "nil") " + + "marked=\(markedDesc) up=\(isUpdating ? "Y" : "N") undo=\(isUndoRedoing ? "Y" : "N") " + + "blocks=\(blocks.count) storLen=\(storLen) rawLen=\(rawLen)" + + (storLen == rawLen ? "" : " ⚠︎LEN-MISMATCH") + } + + /// One verbose trace line: ` | `. No-op (and the message + /// closure isn't built) unless verbose editor tracing is on. + func traceEdit(_ event: @autoclosure () -> String) { + Log.trace("\(event()) | \(diagnosticState)", category: .edit) + } + + /// Under verbose tracing, logs a condensed call stack for a suspicious + /// selection change — one arriving mid-recompose (up=Y) or while a + /// pendingEdit is unconsumed. Those are exactly the changes behind the + /// issue-#156 caret drifts, and the stack names the AppKit path that + /// moved the caret. + func traceSelectionOrigin() { + guard Log.shouldTrace else { return } + let frames = Thread.callStackSymbols.dropFirst(2).prefix(14).map { frame in + // "3 AppKit 0x00: -[NSTextView foo] + 12" → drop index/module/addr. + let parts = frame.split(separator: " ", omittingEmptySubsequences: true) + return parts.count > 3 ? parts[3...].joined(separator: " ") : frame + } + Log.trace("selection origin:\n " + frames.joined(separator: "\n "), + category: .edit) + } + + /// A short, newline-escaped, length-capped rendering of edit text for traces. + func logSnippet(_ s: String?) -> String { + guard let s else { return "nil" } + let flat = s.replacingOccurrences(of: "\n", with: "⏎") + return flat.count <= 24 ? "\"\(flat)\"" : "\"\(flat.prefix(24))…\"(\(s.count))" + } + + /// Validates the editor's two model invariants and reports violations. The + /// length check is O(1) and the error is written whenever logging is on (the + /// always-on tripwire for a desync); the structural checks are O(n) and run + /// only under verbose tracing. In DEBUG any violation also trips an assertion. + func verifyEditorInvariants(_ context: String) { + guard let ts = textStorage else { return } + let storLen = ts.length + let rawLen = (rawSource as NSString).length + if storLen != rawLen { + Log.error("invariant: storage.length \(storLen) != rawSource.length \(rawLen) [\(context)]", + category: .edit) + assertionFailure("storage != rawSource length after \(context)") + return + } + guard Log.shouldTrace else { return } + if ts.string != rawSource { + Log.error("invariant: storage string != rawSource (same length) [\(context)]", category: .edit) + assertionFailure("storage string != rawSource after \(context)") + } + let reconstructed = blocks.map(\.content).joined(separator: blockSeparator) + if reconstructed != rawSource { + Log.error("invariant: blocks do not reconstruct rawSource [\(context)]", category: .edit) + assertionFailure("blocks != rawSource after \(context)") + } + if let bad = blocks.first(where: { $0.range.upperBound > rawLen }) { + Log.error("invariant: block range \(bad.range) exceeds rawSource \(rawLen) [\(context)]", + category: .edit) + assertionFailure("block range out of bounds after \(context)") + } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift b/Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift new file mode 100644 index 0000000..d704e29 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift @@ -0,0 +1,284 @@ +import AppKit + +/// The edit pipeline: how keystrokes flow from NSTextView into `rawSource`, +/// reparse, and an attribute-only restyle of exactly the affected blocks. +extension EditorTextView { + + /// NSTextView copies the attributes next to the caret into `typingAttributes`. + /// When the caret sits beside a hidden delimiter (our near-zero-size + /// `hiddenFont` + clear color), newly inserted text inherits that invisible + /// font. Regular typing self-heals via `applyBlockStyle` on each keystroke, + /// but IME composition (e.g. Pinyin) is deferred while marked — so the + /// composing text would render invisibly and input appears broken. Refuse the + /// invisible font here so composition is always visible; the block restyle + /// still applies the correct final styling on commit. + public override var typingAttributes: [NSAttributedString.Key: Any] { + get { super.typingAttributes } + set { + var attrs = newValue + if let font = attrs[.font] as? NSFont, font.pointSize < 1.0 { + attrs[.font] = bodyFont + if (attrs[.foregroundColor] as? NSColor) == .clear { + attrs[.foregroundColor] = foregroundColor + } + } + super.typingAttributes = attrs + } + } + + public override func shouldChangeText(in affectedCharRange: NSRange, replacementString: String?) -> Bool { + if isUpdating { + traceEdit("shouldChangeText REJECTED (isUpdating) range=\(affectedCharRange) repl=\(logSnippet(replacementString))") + return false + } + if let replacement = replacementString { + if !isUndoRedoing { + recordUndoIfNeeded(editRange: affectedCharRange, replacement: replacement) + } + } + traceEdit("shouldChangeText OK range=\(affectedCharRange) repl=\(logSnippet(replacementString))") + scheduleBypassedEditSyncCheck() + return true + } + + /// AppKit does not pair every storage mutation with `didChangeText()`. + /// Known case: after a drag of the selected text whose drop falls through + /// to no valid target (e.g. released past the end of the document), the + /// drag-move's source deletion runs shouldChangeText → replaceCharacters + /// and never calls didChangeText. rawSource/blocks then silently freeze: + /// every later edit does its offset math against the stale model (the + /// issue-#156 caret leap) and autosave writes the stale rawSource. + /// didChangeText consumes the storage's pendingEdit synchronously within + /// the same event turn, so a pendingEdit still unconsumed on the next + /// run-loop pass is exactly the "didChangeText was bypassed" signal — + /// heal by running the sync it would have run. + private func scheduleBypassedEditSyncCheck() { + guard !bypassedEditCheckScheduled else { return } + bypassedEditCheckScheduled = true + // RunLoop.perform, not DispatchQueue.main.async: identical "next + // run-loop pass" timing in the app, but also drainable by + // `RunLoop.main.run(until:)` in tests. + RunLoop.main.perform { [weak self] in + // The main run loop only ever performs on the main thread; the + // closure just isn't statically annotated as such. + MainActor.assumeIsolated { + guard let self else { return } + self.bypassedEditCheckScheduled = false + guard let storage = self.textStorage as? EditorTextStorage, + storage.pendingEdit != nil, + !self.isUpdating, !self.isUndoRedoing, + // During IME composition the unconsumed pendingEdit is + // legitimate — didChangeText defers the sync until commit. + !self.hasMarkedText() else { return } + // Permanent breadcrumb (release builds too): if a desync recurs, + // grep ~/.edmund/logs for this line to see which path bypassed + // didChangeText. + Log.info("healing storage edit that bypassed didChangeText: " + + "storLen=\(storage.length) rawLen=\((self.rawSource as NSString).length)", + category: .edit) + // The bypassing path also skips TextKit 2's selection fixup — + // it stays queued and fires at the NEXT endEditing (our restyle), + // via _fixSelectionAfterChangeInCharacterRange, mapping the + // stale selection against post-edit coordinates. That's the + // issue-#156 caret leap: the fixer lands the caret a couple of + // lines away. Collapse the selection to the edit's end point + // ourselves before syncing; the late fixer then has a valid + // caret to map and leaves it in place. + var caretAfterEdit: Int? + if let pending = storage.pendingEdit { + let newLength = max(0, pending.oldRange.length + pending.delta) + caretAfterEdit = min(pending.oldRange.location + newLength, storage.length) + self.setSelectedRange(NSRange(location: caretAfterEdit!, length: 0)) + } + self.syncRawSourceFromDisplay() + // The queued fixer fires during the sync's endEditing and moves + // the caret even when it was just set to a valid spot — so + // re-assert after the sync; by the next edit the fixer state is + // clean (verified: follow-up deletes behave). + if let caret = caretAfterEdit { + self.setSelectedRange(NSRange(location: min(caret, storage.length), + length: 0)) + } + self.document?.updateChangeCount(.changeDone) + } + } + } + + public override func didChangeText() { + super.didChangeText() + guard !isUpdating, !isUndoRedoing else { + traceEdit("didChangeText SKIPPED sync (isUpdating=\(isUpdating) isUndoRedoing=\(isUndoRedoing))") + return + } + // While an input method is composing (marked text — e.g. emoji search, + // accent/IME entry), the storage holds provisional text. Re-styling it + // (setAttributes over the marked range) interferes with the composition + // and can abort it, so defer all syncing/restyling until the IME commits + // — which fires didChangeText again with no marked text. + guard !hasMarkedText() else { + traceEdit("didChangeText DEFERRED sync (marked text active)") + return + } + syncRawSourceFromDisplay() + document?.updateChangeCount(.changeDone) + scrollCursorToCenter() + } + + /// Syncs rawSource from the text storage, re-parses blocks, and restyles + /// exactly the blocks the edit affected: the parser's changed window, the + /// old and new active blocks, and — when the document-global list indent + /// unit moved — every list block. One flush, attribute-only; the storage + /// string is never replaced on the edit path. + private func syncRawSourceFromDisplay() { + guard let ts = textStorage else { return } + + let oldIndentUnit = listIndentUnit + rawSource = ts.string + let sel = selectedRange() + let cursorRaw = min(sel.location, (rawSource as NSString).length) + + // Where this edit should leave the caret, derived from the storage's + // pending edit (same hull formula as the bypassed-edit heal). TextKit 2's + // queued selection fixup (`_fixSelectionAfterChangeInCharacterRange`) can + // fire during `recomposeDirty`'s `endEditing` below and remap a stale + // selection, leaping the caret to a block boundary — issue #156 round 7, + // the round-6 mechanism on the *normal* edit path (armed by a cross-block + // caret move rather than a drag bypass). This path styles with + // `settingSelection` false and otherwise trusts NSTextView's caret, so a + // leap here sticks and every later edit re-triggers it. Capture the + // intended caret now, before `consumePendingEdit` clears it, and re-assert + // it after the restyle if the fixup moved it. + let expectedCaret: Int? = (ts as? EditorTextStorage)?.pendingEdit.map { + min($0.oldRange.location + max(0, $0.oldRange.length + $0.delta), ts.length) + } + + let oldCount = blocks.count + let oldActive = activeBlockIndex + // Incremental parse from the storage's accumulated edit — O(edit); + // full positional-diff parse as the fallback. + let newBlocks: [Block] + let changed: Range + // Whether the document's link reference definitions changed — a `[label]: + // url` line added/removed/edited can affect a reference link in *any* + // block, so on change every bracket-bearing block is restyled below. + var defsChanged = false + if let pending = (ts as? EditorTextStorage)?.consumePendingEdit(), + let incremental = BlockParser.incrementalParse(text: rawSource, + old: blocks, + editedOldRange: pending.oldRange, + delta: pending.delta) { + (newBlocks, changed) = incremental + #if DEBUG + verifyIncrementalParse(newBlocks) + #endif + // Update the indent histogram and link definitions from exactly the + // replaced blocks (old) and their replacements (new) — O(edit), same + // effect as a whole-document rescan. + let oldDefState = linkDefState + let suffixCount = newBlocks.count - changed.upperBound + for i in changed.lowerBound ..< (oldCount - suffixCount) { + listIndentState.remove(blocks[i].content) + linkDefState.remove(blocks[i].content) + } + for i in changed { + listIndentState.add(newBlocks[i].content) + linkDefState.add(newBlocks[i].content) + } + listIndentUnit = listIndentState.unit + defsChanged = linkDefState != oldDefState + } else { + (newBlocks, changed) = BlockParser.parseWithDiff(rawSource, previous: blocks) + let oldDefState = linkDefState + rebuildListIndentState() + rebuildLinkDefState() + defsChanged = linkDefState != oldDefState + } + blocks = newBlocks + + var dirty = IndexSet(integersIn: changed) + + // Map the old active block through the diff so its deactivation + // restyle lands on the right index: prefix indices are unchanged, + // suffix indices shift by the count delta, and anything inside the + // window is already dirty. + if let old = oldActive { + let suffixCount = newBlocks.count - changed.upperBound + if old < changed.lowerBound { + dirty.insert(old) + } else if old >= oldCount - suffixCount { + dirty.insert(old + (newBlocks.count - oldCount)) + } + } + + // The block under the cursor gets cursor-aware delimiter styling + // (this also subsumes the old per-keystroke applyBlockStyle pass). + if let newActive = blockIndexForRawOffset(cursorRaw) { + dirty.insert(newActive) + } + + // listIndentUnit is document-global: when it changes, the rendered + // indentation of every list block changes with it. + if listIndentUnit != oldIndentUnit { + for (i, block) in blocks.enumerated() where block.kind == .listItem { + dirty.insert(i) + } + } + + // A changed link definition can flip any reference link (even a bare + // `[label]` shortcut) elsewhere in the document, so restyle every block + // that could hold one. Bracket-free blocks can't, so skip them. + if defsChanged { + for (i, block) in blocks.enumerated() where block.content.contains("[") { + dirty.insert(i) + } + } + + recomposeDirty(dirty, cursorInRaw: cursorRaw) + + // If the queued selection fixup leaped the caret off the edit point + // during the restyle's `endEditing`, put it back. Only fires on a real + // mismatch — normal edits already sit at `expectedCaret`, so this is a + // no-op then (no spurious selection notification). Permanent breadcrumb + // (release builds too): a recurrence prints which edit leaped. + if let want = expectedCaret { + let now = selectedRange() + if now.location != want || now.length != 0 { + Log.info("re-asserting caret after fixup leap (normal path): " + + "\(now.location)→\(want)", category: .edit) + setSelectedRange(NSRange(location: want, length: 0)) + } + } + + renumberOrderedListRunsIfNeeded(touching: changed) + + traceEdit("synced cursorRaw=\(cursorRaw) changed=\(changed.lowerBound)..<\(changed.upperBound) oldActive=\(oldActive.map(String.init) ?? "nil")") + verifyEditorInvariants("syncRawSourceFromDisplay") + } + + #if DEBUG + /// Debug oracle for the incremental parser: every incremental result must + /// equal a from-scratch parse (content, ranges, kinds — IDs are allowed + /// to differ). Skipped under MD_PERF so measurements stay representative. + private func verifyIncrementalParse(_ incremental: [Block]) { + guard ProcessInfo.processInfo.environment["MD_PERF"] == nil else { return } + let reference = BlockParser.parse(rawSource) + guard incremental.count == reference.count else { + assertionFailure(""" + incremental parse diverged: \(incremental.count) blocks \ + vs \(reference.count) reference + """) + return + } + for (a, b) in zip(incremental, reference) { + if a.content != b.content || a.range != b.range || a.kind != b.kind { + assertionFailure(""" + incremental parse diverged at \(a.range): \ + \(String(reflecting: a.content)) [\(a.kind)] vs \ + \(String(reflecting: b.content)) [\(b.kind)] at \(b.range) + """) + return + } + } + } + #endif +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift b/Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift new file mode 100644 index 0000000..6036f48 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift @@ -0,0 +1,220 @@ +import AppKit + +// MARK: - Lazy Styling: idle drain + scroll promotion +// +// The dirty flush styles only blocks near the viewport synchronously and +// leaves the rest marked `isStyled == false` (base attributes after a load, +// or briefly-stale styling after an offscreen structural change). Two +// mechanisms converge the document: +// +// - The idle drain: time-budgeted main-thread slices restyling unstyled +// blocks until none remain, so document height settles and offscreen +// content is ready before the user gets there. +// - Scroll promotion: when the clip view scrolls, unstyled blocks entering +// the viewport window are styled synchronously so the user never sees raw +// base-attributed text. + +extension EditorTextView { + + /// Schedules the idle drain (coalesced; safe to call repeatedly). + func scheduleProgressiveStyling() { + guard !progressiveStylingScheduled else { return } + progressiveStylingScheduled = true + DispatchQueue.main.async { [weak self] in + self?.progressiveStylingScheduled = false + self?.drainStylingSlice() + } + } + + /// Restyles unstyled blocks for ~6 ms, then reschedules itself if any + /// remain. Reads current state each slice, so edits/undo/load between + /// slices are naturally accommodated. Internal so tests can drive the + /// drain synchronously. + func drainStylingSlice() { + guard let ts = textStorage else { return } + guard !isUpdating else { scheduleProgressiveStyling(); return } + // Restyling marked text aborts IME composition — wait it out. + guard !hasMarkedText() else { scheduleProgressiveStyling(); return } + + let start = ContinuousClock.now + let budget = Duration.milliseconds(6) + + isUpdating = true + let nsString = ts.string as NSString + let cursor = selectedRange().location + var remaining = false + // Blocks restyled this slice need their TextKit 2 layout invalidated + // afterward: restyling is attribute-only, and TextKit 2 doesn't + // re-measure a fragment's geometry (height, first-line indent) for an + // attribute-only change — so a deferred block whose styled height differs + // from its base/estimated height would otherwise keep a stale fragment, + // leaving an empty band on screen. `recomposeDirty` invalidates its + // synchronously-styled blocks for the same reason. + var restyled = IndexSet() + // Explicit pool: styling churns through transient images/attributed + // strings, and a caller may run many slices without a run-loop turn. + autoreleasepool { + ts.beginEditing() + // Resume the scan where the last slice stopped (`drainCursor` is a + // hint — edits shift indices, the wrap-around pass self-corrects). + // Rescanning from 0 each slice made the drain quadratic: deep + // slices burned their whole budget skipping styled blocks. + let count = blocks.count + var scanned = 0 + var idx = min(drainCursor, max(0, count - 1)) + while scanned < count { + if idx >= count { idx = 0 } + if !blocks[idx].isStyled { + let cursorInBlock: Int? = (idx == activeBlockIndex) + ? max(0, cursor - blocks[idx].range.location) : nil + restyleBlock(idx, cursorInBlock: cursorInBlock) + blocks[idx].isStyled = true + restyled.insert(idx) + let sep = blocks[idx].range.upperBound + if sep < nsString.length && nsString.character(at: sep) == 0x0A { + ts.setAttributes(baseAttributes, range: NSRange(location: sep, length: 1)) + } + if ContinuousClock.now - start > budget { + remaining = true + idx += 1 + break + } + } + idx += 1 + scanned += 1 + } + drainCursor = idx + ts.endEditing() + } + + if let tlm = textLayoutManager { + for idx in restyled where idx < blocks.count { + if let range = blockTextRange(blocks[idx].range, tlm) { + tlm.invalidateLayout(for: range) + } + } + } + isUpdating = false + + if remaining { + scheduleProgressiveStyling() + } else { + scheduleFullLayoutSettle() + } + } + + /// TextKit 2 only gives a fragment a real frame once it's laid out; + /// everything else is a height *estimate*, and estimate corrections are + /// what make the scroller jump, drag-selection autoscroll oscillate, and + /// scroll targets land wrong. For small documents we can afford to lay + /// everything out once styling has converged, so no estimates remain. + /// `ensureLayout` is incremental — already-laid-out fragments are skipped + /// — so repeated settles after edits only re-lay the invalidated blocks. + /// (Large documents keep viewport-based layout: a full layout there is + /// the process-killing path that motivated `scrollRangeToVisible`'s + /// override.) + /// + /// Runs on the next run-loop pass, wrapped in `preservingViewportAnchor`: + /// correcting estimates *above* the viewport shifts every laid-out + /// position below them, so doing it synchronously inside a caller's own + /// anchored restyle would poison that caller's before/after measurement. + func scheduleFullLayoutSettle() { + guard !fullLayoutSettleScheduled else { return } + fullLayoutSettleScheduled = true + // RunLoop.perform, not DispatchQueue.main.async, so tests can drain it + // with `RunLoop.main.run(until:)`. + RunLoop.main.perform { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.fullLayoutSettleScheduled = false + guard !self.isUpdating, !self.hasMarkedText(), + let tlm = self.textLayoutManager else { return } + self.repairContentAboveOrigin() + guard (self.textStorage?.length ?? 0) <= Self.fullLayoutMaxLength, + self.blocks.allSatisfy({ $0.isStyled }) else { return } + self.preservingViewportAnchor { + tlm.ensureLayout(for: tlm.documentRange) + } + } + } + } + + /// TextKit 2 can leave the document's first fragment at a *negative* y + /// after edits near the top: layout proceeding upward from a viewport + /// anchor with a wrong height estimate assigns origins above 0, and + /// nothing renormalizes them. The symptom is the first line sitting above + /// the visible area with the scroller already at the top — unreachable. + /// Repair: re-lay from the document start (anchoring the first fragment + /// back at y 0) inside `preservingViewportAnchor`, which compensates the + /// clip origin so what the user is looking at doesn't move — and the + /// content above becomes scrollable again. + func repairContentAboveOrigin() { + guard let tlm = textLayoutManager else { return } + var firstMinY: CGFloat? + tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: []) { + firstMinY = $0.layoutFragmentFrame.minY + return false + } + guard let firstMinY, firstMinY < -0.5 else { return } + + // Bound the re-lay to start→viewport-end (the bug only manifests with + // the viewport near the top, so this is small); bail on huge spans + // rather than risk the full-document layout cost on a large file. + var end = tlm.documentRange.endLocation + if let vp = tlm.textViewportLayoutController.viewportRange { + end = vp.endLocation + } + guard tlm.offset(from: tlm.documentRange.location, to: end) <= 60_000, + let range = NSTextRange(location: tlm.documentRange.location, end: end) + else { return } + Log.info("repairing content above origin: firstMinY=\(firstMinY)", + category: .compose) + preservingViewportAnchor { + tlm.invalidateLayout(for: range) + tlm.ensureLayout(for: range) + } + } + + /// Styles any unstyled blocks inside the current viewport window. Forces a + /// viewport layout first because callers may run before the next layout + /// pass (the viewport range would otherwise be stale). + func promoteVisibleUnstyledBlocks() { + textLayoutManager?.textViewportLayoutController.layoutViewport() + guard let bounds = syncStylingBlockRange() else { return } + let unstyled = IndexSet(bounds.filter { !blocks[$0].isStyled }) + guard !unstyled.isEmpty else { return } + recomposeDirty(unstyled, cursorInRaw: selectedRange().location) + } + + /// Observes clip-view scrolling for promotion. Called from + /// `viewDidMoveToWindow`. + func installScrollPromotionObserver() { + guard let clipView = enclosingScrollView?.contentView else { return } + clipView.postsBoundsChangedNotifications = true + // viewDidMoveToWindow can fire more than once; keep one observation. + NotificationCenter.default.removeObserver( + self, name: NSView.boundsDidChangeNotification, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(clipViewBoundsDidChange(_:)), + name: NSView.boundsDidChangeNotification, + object: clipView + ) + } + + @objc private func clipViewBoundsDidChange(_ note: Notification) { + // Promotion forces a viewport layout and may restyle blocks (changing + // their heights). Running that synchronously inside the scroll + // notification fights the momentum scroll and makes the viewport + // bounce. Defer to the next run-loop turn (coalesced), so each scroll + // tick just scrolls and styling catches up between ticks. + guard !isUpdating, !pendingPromotion else { return } + pendingPromotion = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pendingPromotion = false + guard !self.isUpdating else { return } + self.promoteVisibleUnstyledBlocks() + } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+SelectionTracking.swift b/Sources/EdmundCore/TextView/EditorTextView+SelectionTracking.swift new file mode 100644 index 0000000..1d4dc57 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+SelectionTracking.swift @@ -0,0 +1,96 @@ +import AppKit + +/// Caret-movement handling: when the caret crosses into a different block we +/// restyle the old and new active blocks (revealing/hiding their raw markdown); +/// within a block we just update which token's delimiters are shown. +extension EditorTextView { + + @objc func selectionDidChange(_ notification: Notification) { + traceEdit("selectionDidChange") + // A selection change landing mid-recompose is the drift signature + // (issue #156); the stack names the AppKit path that moved the caret. + if isUpdating { traceSelectionOrigin() } + guard !isUpdating else { return } + // NSTextView moves the selection DURING an edit, before didChangeText + // runs the sync — at that moment `blocks` still has pre-edit ranges. + // Styling here would apply stale ranges/content against the new text, + // spilling wrong attributes across block boundaries. The pending edit + // is exactly the "storage ahead of blocks" signal; didChangeText's + // flush styles the active block anyway. + if let storage = textStorage as? EditorTextStorage, + storage.pendingEdit != nil { return } + // Don't restyle while an input method is composing (see didChangeText). + guard !hasMarkedText() else { return } + + let sel = selectedRange() + let rawOffset = sel.location + let newActiveIndex = blockIndexForRawOffset(rawOffset) + + if newActiveIndex != activeBlockIndex && !pendingRecompose { + pendingRecompose = true + // Capture the flag now — it's reset synchronously after mouseDown + // returns, before this async block runs. + let fromMouse = suppressTypewriterCentering + DispatchQueue.main.async { [weak self, fromMouse] in + guard let self = self else { return } + // Always clear the flag first. If we bail out below (a recompose is + // mid-flight and will set the active block itself), leaving it set + // would permanently wedge active-block switching — the cursor could + // never re-activate a block, so e.g. a callout would stay rendered + // with un-editable zero-width marker characters. + self.pendingRecompose = false + guard !self.isUpdating else { return } + // Never restyle (mutate storage / invalidate layout) while an + // input method is composing. This async block was scheduled + // before composition began, so — unlike the synchronous guard + // above — `hasMarkedText()` can have flipped true in between. + // Running `recomposeDirty` over storage that holds a live + // composition can strand the marked text in the input context, + // after which `didChangeText` keeps bailing on its own + // marked-text guard and the storage/`rawSource` invariant breaks + // — the "delete drift" bug. The active-block restyle is applied + // anyway when composition commits (didChangeText → recomposeDirty). + guard !self.hasMarkedText() else { return } + + // Restyle the new active block now. DEFER the old active block + // if it's off screen: deactivating it (rendered ↔ raw — callout + // box, checklist marker, …) changes its height, and doing that + // synchronously while the user is looking elsewhere shifts the + // whole viewport. Marking it unstyled hands it to the async + // drain, which TextKit 2 lays out without disturbing the + // viewport. Don't re-set the selection (that triggers AppKit's + // autoscroll-to-selection on stale layout). + let loc = self.selectedRange().location + let newIdx = self.blockIndexForRawOffset(loc) + var dirty = IndexSet() + if let n = newIdx { dirty.insert(n) } + var deferred = false + if let old = self.activeBlockIndex, old != newIdx, old < self.blocks.count { + if let vis = self.syncStylingBlockRange(), vis.contains(old) { + dirty.insert(old) // visible — restyle in place + } else { + self.blocks[old].isStyled = false // off screen — defer + deferred = true + } + } + // Only the new (visible) active block changes height now, so the + // caret anchor's delta is small and reliable. Typewriter mode + // centers on the post-restyle layout instead. + if self.typewriterModeEnabled && !fromMouse { + self.recomposeDirty(dirty, cursorInRaw: loc) + self.scrollCursorToCenter() + } else { + self.preservingViewportAnchor { + self.recomposeDirty(dirty, cursorInRaw: loc) + } + } + if deferred { self.scheduleProgressiveStyling() } + } + return + } else if newActiveIndex == activeBlockIndex { + // Same block — update active token (re-style to show/hide delimiters) + applyBlockStyle() + } + if !suppressTypewriterCentering { scrollCursorToCenter() } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift b/Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift new file mode 100644 index 0000000..8538ade --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift @@ -0,0 +1,666 @@ +import AppKit + +// MARK: - TextKit 2 Support +// +// The editor runs on TextKit 2 (NSTextLayoutManager): layout is viewport-based +// — the system only lays out what's on screen, which is what makes large +// documents tractable. The hard rule that follows: never touch +// `NSTextView.layoutManager` or store NSTextBlock/NSTextTable attributes — +// either silently switches the view back to TextKit 1 for good. +// +// Two custom attributes drive a custom layout fragment: +// +// - `.blockDecoration` (paragraph-level): callout boxes, quote bars, table +// borders, thematic-break rules. Fragment frames tile vertically, so +// per-paragraph drawing renders a multi-line quote run as one continuous +// box/bar. +// - `.fragmentOverlay` (character-level): images drawn at a character's +// position — callout header (icon + title), rendered math, list bullets and +// checkboxes. TextKit 1 rendered `.attachment` over any character; TextKit 2 +// only honors attachments on U+FFFC, which the storage==rawSource invariant +// forbids. Instead the anchor character is hidden, `.kern` reserves the +// image's advance width (the same trick the table renderer uses for column +// alignment), and the fragment draws the image at the anchor's position. +// - `.tableCellWraps` (paragraph-level): a table cell too wide for its column +// can't wrap in place — TextKit 2 only wraps a whole paragraph at the +// container's edge, it has no notion of an independent per-cell flow region +// (that's what NSTextTable/NSTextBlock exist for, and they're banned). So an +// overflowing cell's real characters are hidden, and its styled text is laid +// out separately in a small detached text stack sized to the column's +// width; the fragment draws the resulting lines stacked at the cell's x. + +public extension NSAttributedString.Key { + /// Paragraph-level decoration drawn behind the text by + /// `DecoratedTextLayoutFragment`. Value: `BlockDecoration`. + static let blockDecoration = NSAttributedString.Key("MarkdownEditor.blockDecoration") + /// Character-level image drawn at the character's position by + /// `DecoratedTextLayoutFragment`. Value: `FragmentOverlay`. The styling + /// code pairs it with a hidden anchor glyph plus `.kern` for layout space. + static let fragmentOverlay = NSAttributedString.Key("MarkdownEditor.fragmentOverlay") + /// A table row's overflowing cells, wrapped and drawn by + /// `DecoratedTextLayoutFragment`. Value: `TableCellWrapList`. + static let tableCellWraps = NSAttributedString.Key("MarkdownEditor.tableCellWraps") +} + +/// Value object describing what to draw behind a decorated paragraph. +/// Reference type (NSObject) so it lives in attributed strings; value +/// equality so attribute-run merging and the test oracle behave. +public final class BlockDecoration: NSObject, @unchecked Sendable { + + public enum Kind: Equatable { + /// Filled box across the text column (callouts), with optional borders. + /// `bottomPad` extends the fill/border below the fragment's text frame — + /// TextKit 2 does not include trailing `paragraphSpacing` in the + /// fragment height, so a callout's last line carries the bottom padding + /// here (and a matching paragraphSpacing pushes the next block clear). + case box(background: NSColor, borderColor: NSColor?, + borderEdges: CalloutStyle.Edges, borderWidth: CGFloat, + bottomPad: CGFloat) + /// Vertical bar just left of the paragraph's text (plain block quotes). + case leftBar(color: NSColor, width: CGFloat) + /// Table-row chrome: vertical column borders at text-relative x + /// offsets, and a horizontal rule through the separator row. `width` + /// is the table's full width; `leftInset` the text's inset from the + /// table's left edge. `bottomBorder` draws a full-width line at this + /// row's bottom edge — the grid line between data rows (the header/ + /// separator boundary already gets its line from `separator`). + case tableRow(columnXOffsets: [CGFloat], width: CGFloat, + leftInset: CGFloat, separator: Bool, bottomBorder: Bool) + /// Horizontal hairline across the text column, drawn `centerOffset` + /// points below the fragment's vertical center. The offset compensates + /// for adjacent text sitting at its baseline (low in its line box), so + /// the rule looks equidistant from the text above and below rather + /// than hugging the line above it. + case horizontalRule(color: NSColor, centerOffset: CGFloat) + } + + public let kind: Kind + /// For `.box`: horizontal inset (points) from the text column's left and + /// right edges, non-zero for a box nested inside another box (e.g. a + /// callout inside a callout), so the inner box sits within the outer one. + /// For `.leftBar`: rightward shift (points) from the outermost bar + /// position — one `quoteMarkerWidth` per nesting level, mirroring the + /// hidden `> ` marker that indents the text, so each nested quote's bar + /// (e.g. `> > text`) sits just left of its own level's text. Absolute per + /// level: the same level's bar lands at the same x on every line, which + /// keeps stacked bars tiling into continuous columns. Ignored by other + /// kinds. + public let inset: CGFloat + /// For `.leftBar`: start the bar at the first line's glyph top (baseline + /// minus ascender) instead of the fragment top. The line box carries its + /// extra spacing (lineSpacing) *above* the glyphs, so a bar over the full + /// fragment pokes past the text. Set only on a quote run's first line — + /// interior lines must fill the whole fragment so consecutive lines' bars + /// tile without gaps. Ignored by other kinds. + public let hugsTextTop: Bool + + public init(_ kind: Kind, inset: CGFloat = 0, hugsTextTop: Bool = false) { + self.kind = kind + self.inset = inset + self.hugsTextTop = hugsTextTop + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? BlockDecoration else { return false } + return kind == other.kind && inset == other.inset + && hugsTextTop == other.hugsTextTop + } + + public override var hash: Int { + switch kind { + case .box: return 1 + case .leftBar: return 2 + case .tableRow: return 3 + case .horizontalRule: return 4 + } + } +} + +/// An ordered stack of decorations drawn behind one paragraph, outermost +/// first. Used when nesting puts more than one box/bar on the same line — e.g. +/// a callout's outer box plus an inner nested callout's box. A single +/// decoration still uses a bare `BlockDecoration`; the fragment reads either. +public final class BlockDecorationList: NSObject, @unchecked Sendable { + public let decorations: [BlockDecoration] + + public init(_ decorations: [BlockDecoration]) { + self.decorations = decorations + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? BlockDecorationList else { return false } + return decorations == other.decorations + } + + public override var hash: Int { decorations.count } +} + +/// An image or stroked vector path drawn at a character's laid-out position, +/// with attachment-style bounds: `bounds.origin.y` is the drawing's bottom +/// relative to the text baseline (negative descends below it). +/// +/// The path form exists because of a TextKit 2 wedge: drawing an *image* on a +/// wrapping, multi-line layout fragment collapses that fragment's layout to a +/// single line, while drawing a *shape* does not (see +/// docs/investigations/archives/callout-title-wrap-investigation.md). Overlays that can share a line +/// with wrapping text (the custom-callout-title icon) must use the path form. +public final class FragmentOverlay: NSObject, @unchecked Sendable { + public let image: NSImage? + /// Stroked path in bounds-local coordinates (y-down, origin at the + /// bounds' top-left), pre-scaled to the bounds size. + public let path: CGPath? + public let pathColor: NSColor? + public let pathLineWidth: CGFloat + public let bounds: CGRect + + public init(image: NSImage, bounds: CGRect) { + self.image = image + self.path = nil + self.pathColor = nil + self.pathLineWidth = 0 + self.bounds = bounds + super.init() + } + + public init(path: CGPath, color: NSColor, lineWidth: CGFloat, bounds: CGRect) { + self.image = nil + self.path = path + self.pathColor = color + self.pathLineWidth = lineWidth + self.bounds = bounds + super.init() + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? FragmentOverlay else { return false } + return other.image === image && other.path == path + && other.pathColor == pathColor && other.pathLineWidth == pathLineWidth + && other.bounds == bounds + } + + public override var hash: Int { Int(bounds.width) ^ Int(bounds.height) } +} + +/// A table cell too wide for its column: its real characters are hidden, and +/// this holds what to draw instead. `x` is text-relative (same coordinate +/// space as `BlockDecoration.tableRow`'s `columnXOffsets`) — the cell's +/// content start. `contentWidth` is the column's clamped content width (the +/// width the cell's text must wrap within). +public final class TableCellWrap: NSObject, @unchecked Sendable { + public let styled: NSAttributedString + public let x: CGFloat + public let contentWidth: CGFloat + + public init(styled: NSAttributedString, x: CGFloat, contentWidth: CGFloat) { + self.styled = styled + self.x = x + self.contentWidth = contentWidth + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? TableCellWrap else { return false } + return other.styled.string == styled.string + && abs(other.x - x) < 0.5 && abs(other.contentWidth - contentWidth) < 0.5 + } + + public override var hash: Int { styled.string.hashValue } +} + +/// A table row's overflowing cells, one `TableCellWrap` per overflowing cell. +public final class TableCellWrapList: NSObject, @unchecked Sendable { + public let wraps: [TableCellWrap] + + public init(_ wraps: [TableCellWrap]) { + self.wraps = wraps + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? TableCellWrapList else { return false } + return wraps == other.wraps + } + + public override var hash: Int { wraps.count } +} + +/// Layout fragment that draws its paragraph's `BlockDecoration` behind the +/// text and any `FragmentOverlay` images at their characters' positions. +final class DecoratedTextLayoutFragment: NSTextLayoutFragment { + + /// Decorations drawn behind the paragraph, outermost first. + let decorations: [BlockDecoration] + /// Paragraph-relative anchor offsets and their overlays. + let overlays: [(offset: Int, overlay: FragmentOverlay)] + /// Whether the text is antialiased (editor-wide setting). + let antialias: Bool + /// Each overflowing cell's x and pre-laid-out lines, from a detached + /// scratch text stack sized to the column's content width. The stack + /// itself is retained (`scratchStacks`) so the line fragments stay valid. + private let resolvedCellWraps: [(x: CGFloat, lines: [NSTextLineFragment])] + private let scratchStacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)] + + init(textElement: NSTextElement, range: NSTextRange?, + decorations: [BlockDecoration], + overlays: [(offset: Int, overlay: FragmentOverlay)], + cellWraps: [TableCellWrap], + antialias: Bool) { + self.decorations = decorations + self.overlays = overlays + self.antialias = antialias + var resolved: [(x: CGFloat, lines: [NSTextLineFragment])] = [] + var stacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)] = [] + for wrap in cellWraps { + let contentStorage = NSTextContentStorage() + contentStorage.textStorage = NSTextStorage(attributedString: wrap.styled) + let layoutManager = NSTextLayoutManager() + contentStorage.addTextLayoutManager(layoutManager) + let container = NSTextContainer( + size: NSSize(width: max(1, wrap.contentWidth), height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + layoutManager.textContainer = container + var lines: [NSTextLineFragment] = [] + layoutManager.enumerateTextLayoutFragments( + from: layoutManager.documentRange.location, options: [.ensuresLayout] + ) { frag in + lines.append(contentsOf: frag.textLineFragments) + return true + } + resolved.append((wrap.x, lines)) + stacks.append((contentStorage, layoutManager, container)) + } + self.resolvedCellWraps = resolved + self.scratchStacks = stacks + super.init(textElement: textElement, range: range) + } + + /// Extra row height needed to fit the tallest wrapped cell, beyond the + /// row's natural (single-line) height. + private var tableRowExtraHeight: CGFloat { + guard !resolvedCellWraps.isEmpty else { return 0 } + let tallest = resolvedCellWraps + .map { $0.lines.reduce(0) { $0 + $1.typographicBounds.height } } + .max() ?? 0 + return max(0, tallest - super.layoutFragmentFrame.height) + } + + required init?(coder: NSCoder) { + fatalError("DecoratedTextLayoutFragment does not support coding") + } + + /// Fragment-local x of the text container's left edge. The fragment's + /// frame hugs the laid-out text, so container x = 0 sits at -frame.minX. + private var containerLeft: CGFloat { -layoutFragmentFrame.minX } + + private var containerWidth: CGFloat { + textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width + } + + /// A box decoration's `bottomPad` grows the fragment's own frame (not just + /// its drawing): TextKit 2 leaves trailing `paragraphSpacing` out of the + /// fragment, so padding added that way is dead space — clicks there miss the + /// text. Making the fragment frame taller means the line fragments stay + /// anchored at the top, the extra height is genuine clickable space below + /// the last line, the next block tiles clear of it, and the box (drawn over + /// the full frame height) covers it. Mirrors how the header's raised + /// minimumLineHeight makes the top padding clickable text space. + /// + /// Padding is *summed* across stacked boxes: when a nested callout is the + /// last line of its parent, the line needs the nested box's bottom padding + /// *and* the parent's below it (see `draw`), so both fit. + private var boxBottomPad: CGFloat { + decorations.reduce(0) { acc, deco in + if case .box(_, _, _, _, let bottomPad) = deco.kind { return acc + bottomPad } + return acc + } + } + + /// Height to actually paint a filled decoration (box / left bar) over, + /// which is *not* always the full frame height. When a callout or quote is + /// the last block AND the document ends with a newline, TextKit 2 folds the + /// document's final empty line into this (the preceding) layout fragment + /// instead of giving it its own fragment — it shows up as a trailing + /// zero-length line fragment. Painting the decoration over the full frame + /// then floods the callout color onto that trailing empty line (the + /// "extra colored line at the bottom" bug). Detect the absorbed empty line + /// and stop the fill at the last real content line plus the box's bottom + /// padding. + var decorationDrawHeight: CGFloat { + let full = layoutFragmentFrame.height + let lines = textLineFragments + guard lines.count > 1, let last = lines.last, + last.characterRange.length == 0 else { return full } + // Bottom of the last line that actually holds text (fragment-local). + let contentBottom = lines.dropLast().map { $0.typographicBounds.maxY }.max() ?? 0 + // `super` frame excludes our bottomPad; its extent past the content is + // exactly the absorbed empty line. Remove that, keep the bottomPad. + let emptyLineHeight = max(0, super.layoutFragmentFrame.height - contentBottom) + return max(0, full - emptyLineHeight) + } + + override var layoutFragmentFrame: CGRect { + var frame = super.layoutFragmentFrame + // A row is never both a box and a table row, so at most one of these + // two is ever nonzero. + frame.size.height += boxBottomPad + tableRowExtraHeight + return frame + } + + override var renderingSurfaceBounds: CGRect { + var bounds = super.renderingSurfaceBounds + let frame = layoutFragmentFrame + if !decorations.isEmpty { + bounds = bounds.union(CGRect(x: containerLeft - 4, y: 0, + width: containerWidth + 8, height: frame.height)) + } + for (offset, overlay) in overlays { + if let rect = overlayRect(anchorOffset: offset, overlay: overlay) { + bounds = bounds.union(rect.insetBy(dx: -2, dy: -2)) + } + } + return bounds + } + + override func draw(at point: CGPoint, in context: CGContext) { + context.saveGState() + // Decorations are stacked outermost-first. Each box stops short of the + // fragment bottom by the padding of the boxes drawn before it, so an + // outer box's bottom padding stays visible *below* an inner nested box + // (e.g. the parent callout's padding under a nested callout) instead of + // being covered by it. + var precedingBottomPad: CGFloat = 0 + for decoration in decorations { + drawDecoration(decoration, at: point, in: context, bottomInset: precedingBottomPad) + if case .box(_, _, _, _, let bottomPad) = decoration.kind { + precedingBottomPad += bottomPad + } + } + context.restoreGState() + context.saveGState() + context.setShouldAntialias(antialias) + super.draw(at: point, in: context) + context.restoreGState() + for (offset, overlay) in overlays { + guard let rect = overlayRect(anchorOffset: offset, overlay: overlay) else { continue } + let drawRect = rect.offsetBy(dx: point.x, dy: point.y) + if let image = overlay.image { + // Draw the (resolution-independent) NSImage into the flipped context, + // so it rasterizes at the screen's backing scale — crisp on Retina, + // and positioned precisely. (Converting to a CGImage first would bake + // it at 1×, then upscale: soft, and quantized a pixel low.) The math + // image carries a small transparent inset, so the flipped draw can't + // clip a descender at the image edge. + let nsContext = NSGraphicsContext(cgContext: context, flipped: true) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = nsContext + image.draw(in: drawRect, from: .zero, operation: .sourceOver, + fraction: 1, respectFlipped: true, hints: nil) + NSGraphicsContext.restoreGraphicsState() + } else if let path = overlay.path, let color = overlay.pathColor { + // Stroke the vector path directly in CG — never rasterize it to + // an image first: an image drawn on a multi-line fragment wedges + // its layout to one line (see the FragmentOverlay note). Path + // coords are bounds-local and y-down, matching this flipped + // context, so a translate places them. + context.saveGState() + context.translateBy(x: drawRect.minX, y: drawRect.minY) + context.addPath(path) + context.setStrokeColor(color.cgColor) + context.setLineWidth(overlay.pathLineWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + context.strokePath() + context.restoreGState() + } + } + // Overflowing table cells: the real characters are hidden, so draw + // each cell's pre-wrapped lines here instead, stacked top-down at the + // cell's column x. Left-aligned regardless of the column's declared + // alignment (ponytail: not requested; upgrade path is the same + // per-line x-shift math the kern-based alignment above already uses). + for cellWrap in resolvedCellWraps { + var y = point.y + for line in cellWrap.lines { + line.draw(at: CGPoint(x: point.x + cellWrap.x, y: y), in: context) + y += line.typographicBounds.height + } + } + } + + /// Fragment-local rect for an overlay image, anchored to the character at + /// the given paragraph-relative offset. + private func overlayRect(anchorOffset: Int, overlay: FragmentOverlay) -> CGRect? { + guard let line = textLineFragments.first(where: { + NSLocationInRange(anchorOffset, $0.characterRange) + }) ?? textLineFragments.last else { return nil } + let anchorX = line.typographicBounds.minX + + line.locationForCharacter(at: anchorOffset).x + // Baseline (flipped coords): the line's glyph origin sits at its + // typographic origin plus the ascent-derived glyph origin. + let baselineY = line.typographicBounds.minY + line.glyphOrigin.y + return CGRect(x: anchorX + overlay.bounds.minX, + y: baselineY - overlay.bounds.height - overlay.bounds.minY, + width: overlay.bounds.width, + height: overlay.bounds.height) + } + + /// Fragment-local y of the first line's glyph top (baseline minus the + /// line's font ascender). The line box can hold extra space above the + /// glyphs (lineSpacing lands there), which a text-hugging bar skips. + private var firstLineGlyphTop: CGFloat? { + guard let line = textLineFragments.first, + line.characterRange.length > 0, + let font = line.attributedString.attribute( + .font, at: line.characterRange.location, effectiveRange: nil) as? NSFont + else { return nil } + return line.typographicBounds.minY + line.glyphOrigin.y - font.ascender + } + + private func drawDecoration(_ decoration: BlockDecoration, at point: CGPoint, + in context: CGContext, bottomInset: CGFloat = 0) { + let frame = layoutFragmentFrame + // Filled decorations (box, bar) stop above an absorbed trailing empty + // line; center-line decorations (rule, table) still use the full frame. + let fillHeight = decorationDrawHeight + // Fragment-local rect spanning the full text column for this fragment. + let columnRect = CGRect(x: point.x + containerLeft, y: point.y, + width: containerWidth, height: fillHeight) + + switch decoration.kind { + case .box(let background, let borderColor, let edges, let borderWidth, _): + // The fragment frame already includes any box bottomPad (see + // layoutFragmentFrame), so columnRect covers the padded area. A + // nested box insets symmetrically so it sits within its parent box, + // and stops `bottomInset` short of the frame bottom so the enclosing + // box's padding shows below it. + var columnRect = decoration.inset > 0 + ? columnRect.insetBy(dx: decoration.inset, dy: 0) + : columnRect + columnRect.size.height -= bottomInset + context.setFillColor(background.cgColor) + context.fill(columnRect) + if let borderColor, !edges.isEmpty { + context.setFillColor(borderColor.cgColor) + if edges.contains(.left) { + context.fill(CGRect(x: columnRect.minX, y: columnRect.minY, + width: borderWidth, height: columnRect.height)) + } + if edges.contains(.right) { + context.fill(CGRect(x: columnRect.maxX - borderWidth, y: columnRect.minY, + width: borderWidth, height: columnRect.height)) + } + if edges.contains(.top) { + context.fill(CGRect(x: columnRect.minX, y: columnRect.minY, + width: columnRect.width, height: borderWidth)) + } + if edges.contains(.bottom) { + context.fill(CGRect(x: columnRect.minX, y: columnRect.maxY - borderWidth, + width: columnRect.width, height: borderWidth)) + } + } + + case .leftBar(let color, let width): + // The bar sits immediately left of the text (the paragraph style + // insets the text by the bar's width) — or `inset` further right, + // for a nested quote's bar next to its own level's text. + var barTop = point.y + var barHeight = fillHeight + if decoration.hugsTextTop, let glyphTop = firstLineGlyphTop { + barTop += glyphTop + barHeight -= glyphTop + } + context.setFillColor(color.cgColor) + context.fill(CGRect(x: point.x - width + decoration.inset, y: barTop, + width: width, height: barHeight)) + + case .tableRow(let xOffsets, let width, let leftInset, let separator, let bottomBorder): + // Offsets are text-relative; the fragment's origin is the text start. + context.setStrokeColor(NSColor.separatorColor.cgColor) + context.setLineWidth(1) + for x in xOffsets { + let lineX = round(point.x + x) + 0.5 + context.move(to: CGPoint(x: lineX, y: point.y)) + context.addLine(to: CGPoint(x: lineX, y: point.y + frame.height)) + } + if separator { + let y = round(point.y + frame.height / 2) + 0.5 + context.move(to: CGPoint(x: point.x - leftInset, y: y)) + context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y)) + } + if bottomBorder { + let y = round(point.y + frame.height) + 0.5 + context.move(to: CGPoint(x: point.x - leftInset, y: y)) + context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y)) + } + context.strokePath() + + case .horizontalRule(let color, let centerOffset): + context.setStrokeColor(color.cgColor) + context.setLineWidth(1) + let y = round(point.y + frame.height / 2 + centerOffset) + 0.5 + context.move(to: CGPoint(x: columnRect.minX, y: y)) + context.addLine(to: CGPoint(x: columnRect.maxX, y: y)) + context.strokePath() + } + } +} + +// MARK: - Fragment Vending + +extension EditorTextView: NSTextLayoutManagerDelegate { + public nonisolated func textLayoutManager( + _ textLayoutManager: NSTextLayoutManager, + textLayoutFragmentFor location: NSTextLocation, + in textElement: NSTextElement + ) -> NSTextLayoutFragment { + guard let paragraph = textElement as? NSTextParagraph, + paragraph.attributedString.length > 0 else { + return NSTextLayoutFragment(textElement: textElement, + range: textElement.elementRange) + } + let str = paragraph.attributedString + let decoValue = str.attribute(.blockDecoration, at: 0, effectiveRange: nil) + let decorations: [BlockDecoration] + if let list = decoValue as? BlockDecorationList { + decorations = list.decorations + } else if let single = decoValue as? BlockDecoration { + decorations = [single] + } else { + decorations = [] + } + var overlays: [(offset: Int, overlay: FragmentOverlay)] = [] + str.enumerateAttribute(.fragmentOverlay, + in: NSRange(location: 0, length: str.length), + options: []) { value, range, _ in + if let overlay = value as? FragmentOverlay { + overlays.append((range.location, overlay)) + } + } + let cellWrapsValue = str.attribute(.tableCellWraps, at: 0, effectiveRange: nil) + let cellWraps = (cellWrapsValue as? TableCellWrapList)?.wraps ?? [] + // A plain fragment suffices only when there's nothing to draw over the + // text and antialiasing is on (the default); otherwise vend the custom + // fragment so its draw can disable antialiasing. + guard !decorations.isEmpty || !overlays.isEmpty || !cellWraps.isEmpty || !textAntialias + else { + return NSTextLayoutFragment(textElement: textElement, + range: textElement.elementRange) + } + return DecoratedTextLayoutFragment(textElement: textElement, + range: textElement.elementRange, + decorations: decorations, + overlays: overlays, + cellWraps: cellWraps, + antialias: textAntialias) + } +} + +// MARK: - Overlay Application + +extension EditorTextView { + /// Renders `overlay` at `anchor` (a single character): hides the anchor + /// glyph, reserves the image's advance width with kern so following text + /// flows around it, and stores the overlay for the layout fragment to draw. + /// + /// The kern is capped just short of the full line width: a full-width + /// image/equation (the common case — anything wider than the column gets + /// scaled to exactly fill it) would otherwise reserve 100% of the line, + /// leaving zero room for the hidden markdown text that follows the anchor + /// on the same line. TextKit then force-wraps that hidden run onto a new + /// line fragment — and since `minimumLineHeight` (reserveLineHeight) is a + /// paragraph-wide property applying to every line fragment, that phantom + /// wrapped line also inflates to the overlay's full height, doubling the + /// reserved space below the image. The slack is comfortably larger than + /// any realistic hidden-text width (near-zero at `hiddenFont`'s size). + func applyOverlay(_ overlay: FragmentOverlay, anchor: NSRange, + in result: NSMutableAttributedString) { + guard anchor.upperBound <= result.length else { return } + let kernSlack: CGFloat = 8 + let kernWidth = min(overlay.bounds.width, max(0, availableContentWidth - kernSlack)) + result.addAttribute(.font, value: hiddenFont, range: anchor) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: anchor) + result.addAttribute(.kern, value: kernWidth, range: anchor) + result.addAttribute(.fragmentOverlay, value: overlay, range: anchor) + } + + /// Reserves vertical room for an overlay taller than the text line that + /// carries it. A `FragmentOverlay` only reserves horizontal advance (kern), + /// so — unlike the old `NSTextAttachment`, which grew its line fragment — + /// a tall image (e.g. inline math scaled to a heading's size) would + /// otherwise overlap the line below. Raises the enclosing paragraph's + /// `minimumLineHeight` to fit, preserving any other paragraph attributes. + func reserveLineHeight(_ height: CGFloat, forOverlayAt location: Int, + in result: NSMutableAttributedString) { + guard location < result.length else { return } + let ns = result.string as NSString + // The enclosing paragraph (between newlines): minimumLineHeight is a + // paragraph attribute, and for the heading/inline cases the math sits + // on a single line, so this grows exactly the line that needs it. + let para = ns.paragraphRange(for: NSRange(location: location, length: 0)) + let base = (result.attribute(.paragraphStyle, at: location, effectiveRange: nil) + as? NSParagraphStyle) ?? bodyParagraphStyle + guard height > base.minimumLineHeight else { return } + let ps = (base.mutableCopy() as! NSMutableParagraphStyle) + ps.minimumLineHeight = height + result.addAttribute(.paragraphStyle, value: ps, range: para) + } +} + +// MARK: - Stack Construction + +public extension EditorTextView { + /// Builds the TextKit 2 text system chain and returns the wired editor: + /// EditorTextStorage → NSTextContentStorage → NSTextLayoutManager + /// → NSTextContainer → EditorTextView + static func makeTextKit2(frame: NSRect, containerSize: NSSize) -> EditorTextView { + let contentStorage = NSTextContentStorage() + contentStorage.textStorage = EditorTextStorage() + + let layoutManager = NSTextLayoutManager() + contentStorage.addTextLayoutManager(layoutManager) + + let container = NSTextContainer(size: containerSize) + container.widthTracksTextView = true + layoutManager.textContainer = container + + return EditorTextView(frame: frame, textContainer: container) + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+TypewriterScroll.swift b/Sources/EdmundCore/TextView/EditorTextView+TypewriterScroll.swift new file mode 100644 index 0000000..edf9b24 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+TypewriterScroll.swift @@ -0,0 +1,238 @@ +import AppKit + +/// Viewport stability: typewriter centering, viewport-top anchoring across +/// height-changing restyles, and a fragment-based `scrollRangeToVisible` that +/// avoids AppKit's TextKit 2 scroll-to-range (which kills the process on large +/// documents). The `typewriterModeEnabled` stored flag lives on the main class. +extension EditorTextView { + + /// Runs `body` (which restyles the active block, changing its height) while + /// pinning the line at the TOP of the viewport to the same screen position + /// — so the part of the document the user is looking at doesn't move, even + /// when the height change (or a lazy-layout height estimate) is above the + /// caret. Anchoring the viewport top rather than the caret is what removes + /// the residual lurch: a caret anchor holds the caret but lets the content + /// above it slide. + /// + /// Reliability: `layoutViewport()` first guarantees the on-screen fragments + /// are laid out, so the top fragment's character offset is correct; both + /// samples then go through `lineRect` (which forces layout) for a + /// consistent measurement. A mis-measure degrades to no scroll — never a + /// yank or a jump to the document start. + func preservingViewportAnchor(_ body: () -> Void) { + guard let scrollView = enclosingScrollView, let tlm = textLayoutManager else { + body(); return + } + tlm.textViewportLayoutController.layoutViewport() + let visible = scrollView.contentView.bounds + let topPoint = CGPoint(x: 0, y: visible.minY - textContainerOrigin.y) + guard let frag = tlm.textLayoutFragment(for: topPoint) else { body(); return } + let anchorOffset = tlm.offset(from: tlm.documentRange.location, + to: frag.rangeInElement.location) + let beforeY = lineRect(forCharacterAt: anchorOffset)?.minY + + body() + + guard let beforeY, let afterY = lineRect(forCharacterAt: anchorOffset)?.minY else { return } + let delta = afterY - beforeY + guard abs(delta) > 0.5 else { return } + let newY = max(0, visible.origin.y + delta) + scrollView.contentView.scroll(to: NSPoint(x: visible.origin.x, y: newY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + /// Runs a restyle (`body`) while keeping the viewport visually stable: in + /// typewriter mode it re-centers on the caret afterward; otherwise it pins + /// the viewport top (`preservingViewportAnchor`) so content above the edit + /// doesn't shift. The selection-driven cursor-move path uses the same + /// split inline; this wraps it for the indent path's two call sites. + func stabilizingViewport(_ body: () -> Void) { + if typewriterModeEnabled { + body() + scrollCursorToCenter() + } else { + preservingViewportAnchor(body) + } + } + + /// Scrolls the view so the cursor's line fragment is vertically centered + /// in the visible area — but only in typewriter mode. + func scrollCursorToCenter() { + guard typewriterModeEnabled else { return } + centerViewportOnCaret() + } + + /// Scrolls the view so the caret's line fragment is vertically centered in + /// the visible area, regardless of typewriter mode. Used by typewriter + /// centering and by undo/redo when the restored edit is off-screen. + /// + /// The caret's geometry must be laid out for real first — a TextKit 2 + /// estimate for an off-screen caret would center on the wrong place (or not + /// move at all). When the caret is too far from the viewport to lay out + /// cheaply, fall back to a plain reveal rather than risk a huge layout. + func centerViewportOnCaret() { + guard let scrollView = enclosingScrollView else { return } + guard ensureCaretRegionLaidOut() else { + scrollRangeToVisible(selectedRange()); return + } + guard let lineRect = caretLineRect() else { return } + let cursorY = lineRect.midY + textContainerOrigin.y + + let visibleHeight = scrollView.contentView.bounds.height + let targetY = cursorY - visibleHeight / 2 + let maxY = max(0, frame.height - visibleHeight) + let clampedY = min(max(0, targetY), maxY) + + scrollView.contentView.scroll(to: NSPoint(x: 0, y: clampedY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + // Settle passes: the target Y was computed from geometry that can + // still contain TextKit 2 height *estimates* above the caret. After + // the scroll, the viewport's own layout is real — re-measure and + // correct any residual error (typically converges in one pass; + // bounded for safety). + guard let tlm = textLayoutManager else { return } + for _ in 0..<3 { + tlm.textViewportLayoutController.layoutViewport() + guard let settled = caretLineRect() else { return } + let settledTarget = settled.midY + textContainerOrigin.y - visibleHeight / 2 + let settledMaxY = max(0, frame.height - visibleHeight) + let settledY = min(max(0, settledTarget), settledMaxY) + guard abs(settledY - scrollView.contentView.bounds.origin.y) > 1 else { return } + scrollView.contentView.scroll(to: NSPoint(x: 0, y: settledY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } + + /// Lays out the span between the current viewport and the caret so the + /// caret's line geometry is real rather than a TextKit 2 estimate. Returns + /// false when that span is too large to lay out cheaply (the caller should + /// fall back to a plain reveal) — this is the guard that keeps a deep caret + /// in a 1–2 MB document from triggering the process-killing full-document + /// layout that motivated the rest of this file. + @discardableResult + func ensureCaretRegionLaidOut() -> Bool { + guard let tlm = textLayoutManager else { return false } + let caretOffset = selectedRange().location + tlm.textViewportLayoutController.layoutViewport() + + let lo: Int, hi: Int + if let vp = tlm.textViewportLayoutController.viewportRange { + let vpStart = tlm.offset(from: tlm.documentRange.location, to: vp.location) + let vpEnd = tlm.offset(from: tlm.documentRange.location, to: vp.endLocation) + lo = min(vpStart, caretOffset); hi = max(vpEnd, caretOffset) + } else { + // No viewport yet (first layout): lay out from the document start. + lo = 0; hi = caretOffset + } + + let cap = 60_000 // ~a few screenfuls; bounds the layout cost + guard hi - lo <= cap, + let a = tlm.location(tlm.documentRange.location, offsetBy: max(0, lo)), + let b = tlm.location(tlm.documentRange.location, offsetBy: hi), + let range = NSTextRange(location: a, end: b) else { return false } + tlm.ensureLayout(for: range) + return true + } + + /// Whether the caret's line fragment lies within the viewport defined by + /// `origin` (a clip-view bounds origin), in view coordinates. + func caretIsVisible(forViewportOrigin origin: CGPoint) -> Bool { + guard let scrollView = enclosingScrollView, + let lineRect = caretLineRect() else { return false } + let visible = CGRect(origin: origin, size: scrollView.contentView.bounds.size) + let caretInView = CGRect(x: visible.minX, + y: lineRect.minY + textContainerOrigin.y, + width: 1, height: lineRect.height) + return visible.intersects(caretInView) + } + + /// Whether any part of `range`'s vertical span (start line through end + /// line) lies within the viewport defined by `origin`. Used by undo/redo + /// to decide hold-vs-center for the restored change. + func rangeIsVisible(_ range: NSRange, forViewportOrigin origin: CGPoint) -> Bool { + guard let scrollView = enclosingScrollView, + let startRect = lineRect(forCharacterAt: range.location) else { return false } + let endRect = range.length > 0 + ? (lineRect(forCharacterAt: range.upperBound) ?? startRect) : startRect + let visible = CGRect(origin: origin, size: scrollView.contentView.bounds.size) + let span = CGRect(x: visible.minX, + y: min(startRect.minY, endRect.minY) + textContainerOrigin.y, + width: 1, + height: max(startRect.maxY, endRect.maxY) - min(startRect.minY, endRect.minY)) + return visible.intersects(span) + } + + /// The caret line's rect in text-container coordinates (TextKit 2: lays + /// out only the caret's fragment, positions above are estimated). + private func caretLineRect() -> CGRect? { + lineRect(forCharacterAt: selectedRange().location) + } + + /// The line rect for the character at `offset`, in text-container + /// coordinates. Lays out only the offset's own fragment — forcing layout + /// from the document start would lay out (and could OOM on) the whole 1–2 + /// MB document for a deep caret. For carets in or near the viewport the + /// position is exact; for far jumps it may be a TextKit 2 estimate that + /// the scroll anchoring + promotion settle once the region is reached. + func lineRect(forCharacterAt offset: Int) -> CGRect? { + guard let tlm = textLayoutManager else { return nil } + guard let loc = tlm.location(tlm.documentRange.location, offsetBy: offset) + else { return nil } + tlm.ensureLayout(for: NSTextRange(location: loc)) + guard let fragment = tlm.textLayoutFragment(for: loc) else { return nil } + let frame = fragment.layoutFragmentFrame + + guard let paraStart = fragment.textElement?.elementRange?.location else { return frame } + let offsetInPara = tlm.offset(from: paraStart, to: loc) + let line = fragment.textLineFragments.first { + NSLocationInRange(offsetInPara, $0.characterRange) + } ?? fragment.textLineFragments.last + guard let line else { return frame } + return line.typographicBounds.offsetBy(dx: frame.minX, dy: frame.minY) + } + + /// AppKit's TextKit 2 implementation of scroll-to-range kills the process + /// on large documents (observed reproducibly at ~1.5 MB; silent kill, no + /// crash report). NSTextView calls it internally after every insertion + /// (caret autoscroll), so replace it with the minimal fragment-based + /// scroll: lay out just the target's fragment and move the clip view. + public override func scrollRangeToVisible(_ range: NSRange) { + guard let scrollView = enclosingScrollView else { return } + // Bound the range by its two ends so an extended selection follows the + // end being modified rather than always its start. + let visible = scrollView.contentView.bounds + guard let startRect = lineRect(forCharacterAt: range.location) else { return } + let endRect = range.length > 0 + ? (lineRect(forCharacterAt: range.upperBound) ?? startRect) : startRect + let top = min(startRect.minY, endRect.minY) + textContainerOrigin.y + let bottom = max(startRect.maxY, endRect.maxY) + textContainerOrigin.y + let margin: CGFloat = 8 + + var targetY = visible.origin.y + if top < visible.minY && bottom > visible.maxY { + // The range overflows the viewport on both sides (e.g. a drag + // selection grown taller than the screen). Follow the end + // *nearest* the viewport — that's the end being extended. Always + // scrolling to the top here fought the drag's downward autoscroll + // and made the viewport oscillate up and down mid-drag. + let overflowTop = visible.minY - top + let overflowBottom = bottom - visible.maxY + targetY = overflowBottom <= overflowTop + ? bottom + margin - visible.height + : top - margin + } else if top < visible.minY { + targetY = top - margin + } else if bottom > visible.maxY { + // Prefer keeping the bottom edge visible; fall back to the top if + // the range is taller than the viewport. + targetY = min(bottom + margin - visible.height, top - margin) + } else { + return // already visible + } + let maxY = max(0, frame.height - visible.height) + let clampedY = min(max(0, targetY), maxY) + scrollView.contentView.scroll(to: NSPoint(x: visible.origin.x, y: clampedY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+Undo.swift b/Sources/EdmundCore/TextView/EditorTextView+Undo.swift new file mode 100644 index 0000000..bae991b --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+Undo.swift @@ -0,0 +1,174 @@ +import AppKit + +// MARK: - Custom Undo/Redo +// +// Custom undo stack operating on rawSource snapshots. Completely bypasses +// NSTextView's built-in undo (allowsUndo = false) because recompose +// replaces the entire text storage, invalidating position-based undo. + +extension EditorTextView { + + @objc public func undo(_ sender: Any?) { + performUndo() + } + + @objc public func redo(_ sender: Any?) { + performRedo() + } + + func classifyEdit(range: NSRange, replacement: String) -> EditType { + if replacement == "\n" { return .other } // Enter always starts a new group + if replacement.count == 1 && range.length == 0 { return .insert } + if replacement.isEmpty && range.length == 1 { return .delete } + return .other + } + + /// Push an undo snapshot if this edit starts a new coalescing group. + func recordUndoIfNeeded(editRange: NSRange, replacement: String) { + let editType = classifyEdit(range: editRange, replacement: replacement) + + let shouldPush = undoStack.isEmpty + || editType == .other + || editType != lastEditType + || activeBlockIndex != lastEditBlockIndex + + if shouldPush { + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: currentCursorInRaw())) + redoStack.removeAll() + } + + lastEditType = editType + lastEditBlockIndex = activeBlockIndex + } + + func performUndo() { + guard let snapshot = undoStack.popLast() else { return } + redoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: currentCursorInRaw())) + restoreSnapshot(snapshot) + } + + func performRedo() { + guard let snapshot = redoStack.popLast() else { return } + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: currentCursorInRaw())) + restoreSnapshot(snapshot) + } + + /// The single contiguous span that differs between two strings, as the + /// replaced range in `old` (UTF-16) plus its replacement text from `new`. + /// nil when the strings are equal. Boundaries never split a surrogate + /// pair, so the result is always safe to select or restyle. + nonisolated static func textDiff(old: String, new: String) -> (oldRange: NSRange, replacement: String)? { + let o = old as NSString + let n = new as NSString + guard !o.isEqual(to: new) else { return nil } + + var prefix = 0 + let maxPrefix = min(o.length, n.length) + while prefix < maxPrefix && o.character(at: prefix) == n.character(at: prefix) { + prefix += 1 + } + var suffix = 0 + let maxSuffix = min(o.length, n.length) - prefix + while suffix < maxSuffix + && o.character(at: o.length - 1 - suffix) == n.character(at: n.length - 1 - suffix) { + suffix += 1 + } + // Widen rather than split a surrogate pair at either boundary. + while prefix > 0 && UTF16.isLeadSurrogate(o.character(at: prefix - 1)) { + prefix -= 1 + } + while suffix > 0 && UTF16.isTrailSurrogate(o.character(at: o.length - suffix)) { + suffix -= 1 + } + + let oldRange = NSRange(location: prefix, length: o.length - suffix - prefix) + let replacement = n.substring(with: NSRange(location: prefix, + length: n.length - suffix - prefix)) + return (oldRange, replacement) + } + + private func restoreSnapshot(_ snapshot: UndoSnapshot) { + // Diff the current text against the snapshot: the changed span is what + // this undo/redo actually touches, so it drives the selection and the + // viewport — not the caret stored at snapshot time (which, for redo, + // is wherever the caret happened to sit when undo was invoked). + guard let diff = Self.textDiff(old: rawSource, new: snapshot.rawSource) else { + // Nothing changed textually — just restore the caret. + let clamped = min(snapshot.cursorInRaw, (rawSource as NSString).length) + setSelectedRange(NSRange(location: clamped, length: 0)) + return + } + + isUndoRedoing = true + let oldIndentUnit = listIndentUnit + let oldActive = activeBlockIndex + let oldCount = blocks.count + + rawSource = snapshot.rawSource + rebuildListIndentState() + rebuildLinkDefState() + let (newBlocks, changed) = BlockParser.parseWithDiff(rawSource, previous: blocks) + blocks = newBlocks + + var dirty = IndexSet(integersIn: changed) + // Map the old active block through the diff (same scheme as the edit + // path): prefix indices are unchanged, suffix indices shift by the + // count delta, anything inside the window is already dirty. + if let old = oldActive { + let suffixCount = newBlocks.count - changed.upperBound + if old < changed.lowerBound { + dirty.insert(old) + } else if old >= oldCount - suffixCount { + dirty.insert(old + (newBlocks.count - oldCount)) + } + } + // listIndentUnit is document-global: when it changes, every list + // block's rendered indentation changes with it. + if listIndentUnit != oldIndentUnit { + for (i, block) in blocks.enumerated() where block.kind == .listItem { + dirty.insert(i) + } + } + + // The changed text in restored coordinates: select it so the user sees + // exactly what this undo/redo did. A pure deletion has no new text to + // select — the caret goes to the deletion point instead. + let changedInNew = NSRange(location: diff.oldRange.location, + length: (diff.replacement as NSString).length) + let selection: NSRange? = changedInNew.length > 0 ? changedInNew : nil + + // Range-bounded storage replacement: layout outside the changed span + // stays real. (The old full `recompose` reset the whole document to + // TextKit 2 height estimates, and centering math done on estimates is + // what made the post-undo scroll land too far down.) + let apply = { + self.recomposeReplacing(oldRange: diff.oldRange, with: diff.replacement, + dirty: dirty, cursorInRaw: changedInNew.location, + selectionInRaw: selection) + } + + if typewriterModeEnabled { + // Typewriter: always center on the changed text. + apply() + centerViewportOnCaret() + } else if let scrollView = enclosingScrollView { + // If any of the changed text is already on screen, hold the + // viewport perfectly still; otherwise center the change. + let savedOrigin = scrollView.contentView.bounds.origin + apply() + ensureCaretRegionLaidOut() + if rangeIsVisible(changedInNew, forViewportOrigin: savedOrigin) { + scrollView.contentView.scroll(to: savedOrigin) + scrollView.reflectScrolledClipView(scrollView.contentView) + } else { + centerViewportOnCaret() + } + } else { + apply() + } + + isUndoRedoing = false + lastEditType = .other + lastEditBlockIndex = nil + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView.swift b/Sources/EdmundCore/TextView/EditorTextView.swift new file mode 100644 index 0000000..1d578be --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView.swift @@ -0,0 +1,525 @@ +import AppKit + +/// A single NSTextView with word-level inline preview. +/// +/// ## Architecture +/// +/// `rawSource` is the **sole source of truth** for document content. +/// The text storage always contains rawSource — no delimiter stripping. +/// All formatting is achieved through NSAttributedString attributes: +/// - Inline delimiters (`**`, `*`, `` ` ``, etc.) are hidden via near-zero +/// font size when the cursor is not inside the token. +/// - Block-level markers (`#`, `>`, `-`, etc.) are always visible and dimmed. +/// - Content gets rich text styling (bold, italic, colors, etc.). +/// +/// **Edits** flow through NSTextView's normal path: +/// 1. `shouldChangeText` records an undo snapshot (coalesced), returns `true` +/// 2. NSTextView applies the edit to the text storage +/// 3. `didChangeText` fires — we sync `rawSource` and re-style the block +/// +/// **Cursor movement** is detected via `didChangeSelectionNotification`. +/// When the cursor moves to a different block, we restyle both blocks. +/// When it moves within a block, we update which token's delimiters +/// are visible (the "active token"). +/// +/// **Undo/Redo** uses custom stacks of `rawSource` snapshots, completely +/// bypassing NSTextView's built-in undo. +public class EditorTextView: NSTextView { + + // MARK: - Document Link + + /// Weak reference to the owning NSDocument, used for dirty-state tracking. + /// Set by Document.makeWindowControllers(). Not available in unit tests. + public weak var document: NSDocument? + + // MARK: - State (internal for @testable import) + + public var rawSource: String = "" + /// Columns of leading whitespace that make up one list-nesting level, + /// detected from the document (the smallest indent used, or one tab). + /// Defaults to 4. Used to map a list item's indentation to a nesting depth. + /// Maintained incrementally from `listIndentState` on the edit path; + /// rebuilt by the whole-document paths (load, undo, indent). + public var listIndentUnit: Int = 4 + /// Histogram of indented-list-line indents (see ListIndentState) backing + /// the incremental `listIndentUnit`. + var listIndentState = ListIndentState() + + /// Rebuilds the indent histogram from the whole document. O(n) — for the + /// paths that rebuilt rawSource anyway; the edit path updates per block. + func rebuildListIndentState() { + listIndentState = ListIndentState.build(from: rawSource) + listIndentUnit = listIndentState.unit + } + + /// Document-wide link reference definitions (`[label]: url`), fed into each + /// block's parse so GFM reference links resolve across blocks. Maintained + /// incrementally on the edit path; rebuilt by the whole-document paths. + var linkDefState = LinkDefinitionState() + + func rebuildLinkDefState() { + linkDefState = LinkDefinitionState.build(from: rawSource) + } + /// Line ending of the most recently loaded content. The buffer itself is + /// always LF; this is remembered so saves preserve the file's style. + public var originalLineEnding: LineEnding = .lf + var blocks: [Block] = [] + var activeBlockIndex: Int? = nil + var isUpdating = false + /// Coalesces the async active-block restyle scheduled from a caret move + /// (internal so EditorTextView+SelectionTracking can clear it). + var pendingRecompose = false + /// Coalesces idle-drain scheduling (see EditorTextView+LazyStyling). + var progressiveStylingScheduled = false + /// Coalesces scroll-driven promotion onto the next run-loop turn, off the + /// scroll notification (see EditorTextView+LazyStyling). + var pendingPromotion = false + /// Coalesces the didChangeText-bypass check scheduled from + /// shouldChangeText (see EditorTextView+EditFlow). + var bypassedEditCheckScheduled = false + /// Where the idle drain resumes scanning for unstyled blocks (a hint; + /// it wraps around and self-corrects after edits shift indices). + var drainCursor = 0 + /// Coalesces the deferred full-document layout settle for small documents + /// (see EditorTextView+LazyStyling `scheduleFullLayoutSettle`). + var fullLayoutSettleScheduled = false + /// Documents at or below this UTF-16 length are laid out in full once + /// styling converges, eliminating TextKit 2 height estimates (and the + /// scroll jumps they cause). See `scheduleFullLayoutSettle`. + static let fullLayoutMaxLength = 100_000 + + // MARK: - Custom Undo/Redo State + + struct UndoSnapshot { + let rawSource: String + let cursorInRaw: Int + } + + enum EditType { case insert, delete, other } + + var undoStack: [UndoSnapshot] = [] + var redoStack: [UndoSnapshot] = [] + var lastEditBlockIndex: Int? = nil + var lastEditType: EditType = .other + var isUndoRedoing = false + + /// The separator between blocks in the display. + /// Must match what BlockParser splits on. + let blockSeparator = "\n" + + // MARK: - Theme (user-configurable visual settings) + + /// The UserDefaults domain backing theme persistence. Defaults to the + /// shared `.standard` store; tests override it to isolate from the real + /// domain (and from each other under parallel execution). + public var themeDefaults: UserDefaults = .standard + + public var theme: EditorTheme = .load() { + didSet { textAntialias = theme.antialias } + } + + /// Mirror of `theme.antialias`, readable from the `nonisolated` + /// layout-fragment vendor. + nonisolated(unsafe) var textAntialias = true + + /// How the document is presented: + /// - `edit` — live preview; the block under the caret reveals its raw + /// markdown (the default editing experience). + /// - `reading` — everything rendered, no raw ever revealed; read-only. + /// - `source` — plain monospaced raw markdown, no styling. + public enum ViewMode: Sendable { case edit, reading, source } + + public var viewMode: ViewMode = .edit { + didSet { + guard oldValue != viewMode else { return } + isEditable = (viewMode != .reading) + // Re-style every block under the new mode (viewport-first for big docs). + guard !blocks.isEmpty else { return } + recomposeDirty(IndexSet(integersIn: 0.. Int? { + guard let tlm = textLayoutManager, + let storage = textStorage, storage.length > 0 else { return nil } + + var point = convert(event.locationInWindow, from: nil) + point.x -= textContainerOrigin.x + point.y -= textContainerOrigin.y + + guard let fragment = tlm.textLayoutFragment(for: point) else { return nil } + let frame = fragment.layoutFragmentFrame + let pointInFragment = CGPoint(x: point.x - frame.minX, y: point.y - frame.minY) + // Reject clicks past the end of a line: typographic bounds cover only + // the line's used extent. + guard let line = fragment.textLineFragments.first(where: { + $0.typographicBounds.contains(pointInFragment) + }) else { return nil } + + let indexInParagraph = line.characterIndex(for: pointInFragment) + guard indexInParagraph >= 0, + let paraStart = fragment.textElement?.elementRange?.location else { return nil } + let charIndex = tlm.offset(from: tlm.documentRange.location, to: paraStart) + indexInParagraph + return charIndex < storage.length ? charIndex : nil + } + + /// The raw destination string of the regular link under a mouse event, or + /// nil if the click doesn't land directly on link text. The destination is + /// resolved by `followLinkDestination` (external URL, `#heading`, or file). + private func linkDestination(at event: NSEvent) -> String? { + guard let storage = textStorage, let charIndex = clickCharIndex(at: event) else { return nil } + return storage.attribute(.editorLinkURL, at: charIndex, effectiveRange: nil) as? String + } + + // MARK: - Stranded-Composition Recovery + + /// Regaining first-responder status: recover from any stranded input-method + /// composition. If a marked-text (IME / accent / emoji) composition is ever + /// left uncommitted, `didChangeText` keeps bailing on its `hasMarkedText()` + /// guard, so the text storage drifts away from `rawSource` and every edit + /// then does offset math against a frozen block model — the "delete drift" + /// bug. Returning focus is a reliable "composition is over" signal (the view + /// can't become first responder while it already holds an active + /// composition), so when the invariant is broken here we commit any stranded + /// marked text and resync the model from the storage the user actually sees. + /// This formalizes the focus-switch recovery users already stumble into. + public override func becomeFirstResponder() -> Bool { + let became = super.becomeFirstResponder() + if became { recoverFromStrandedCompositionIfNeeded() } + return became + } + + func recoverFromStrandedCompositionIfNeeded() { + guard let ts = textStorage, ts.string != rawSource else { return } + // Rare and high-signal: this only fires when focus returns to a desynced + // editor. The flag snapshot tells us *why* the sync was stranded the next + // time the bug appears (marked text vs a leaked isUpdating/isUndoRedoing). + Log.info(""" + recovered stranded desync on focus regain: hasMarked=\(hasMarkedText()) \ + isUpdating=\(isUpdating) isUndoRedoing=\(isUndoRedoing) \ + storageΔ=\((ts.string as NSString).length - (rawSource as NSString).length) + """, category: .compose) + if hasMarkedText() { unmarkText() } + rawSource = ts.string + rebuildListIndentState() + rebuildLinkDefState() + blocks = BlockParser.parse(rawSource, previous: blocks) + recompose(cursorInRaw: min(selectedRange().location, (rawSource as NSString).length)) + } + + // MARK: - Helpers + + func currentCursorInRaw() -> Int { + return selectedRange().location + } + + /// Detects the indentation unit (columns per nesting level) used by list + /// items in `source`: the smallest positive leading-space count, or 4 when + /// tabs are used (a tab counts as one level) or nothing is found. + static func detectListIndentUnit(_ source: String) -> Int { + var minSpaces = Int.max + for line in source.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 startsWithListMarker(rest) else { continue } + if sawTab { return 4 } + if spaces > 0 { minSpaces = min(minSpaces, spaces) } + } + return minSpaces == Int.max ? 4 : minSpaces + } + + nonisolated static func startsWithListMarker(_ s: Substring) -> Bool { + guard let first = s.first else { return false } + if first == "-" || first == "*" || first == "+" { + return s.dropFirst().first == " " + } + if first.isNumber { + let afterDigits = s.drop(while: { $0.isNumber }) + if let d = afterDigits.first, d == "." || d == ")" { + return afterDigits.dropFirst().first == " " + } + } + return false + } + + // MARK: - Content Loading (called by Document) + + /// Replace the editor's content. Used by NSDocument on file open. + public func loadContent(_ content: String) { + Log.measure("Loaded document (\(content.count) chars)", category: .document) { + // Remember the file's line ending, then normalize the buffer to LF so + // block parsing and rendering never see a stray `\r`. A file that mixes + // styles is normalized to LF on save too (rather than its dominant style), + // so its endings become consistent. + originalLineEnding = LineEnding.isInconsistent(in: content) ? .lf : LineEnding.detect(in: content) + rawSource = LineEnding.normalize(content) + rebuildListIndentState() + rebuildLinkDefState() + blocks = BlockParser.parse(rawSource) + Log.blockStructure(blocks) + undoStack.removeAll() + redoStack.removeAll() + recompose(cursorInRaw: 0) + } + } +} + +// MARK: - String UTF-16 Index Helper + +extension String { + func utf16Index(at offset: Int) -> String.Index { + return String.Index(utf16Offset: offset, in: self) + } +} diff --git a/Sources/edmd/About/AboutView.swift b/Sources/edmd/About/AboutView.swift new file mode 100644 index 0000000..63323f2 --- /dev/null +++ b/Sources/edmd/About/AboutView.swift @@ -0,0 +1,40 @@ +// Note: `swift run` does not bundle resources, so the app icon and version +// string will not render correctly. Build and launch via ./scripts/build-app.sh. +import SwiftUI +import AppKit + +struct AboutView: View { + var body: some View { + let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" + + VStack(spacing: 6) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 96, height: 96) + .padding(.bottom, 4) + + Text("Edmund") + .font(.title2.weight(.semibold)) + + Text("Version \(short) (\(build))") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text("Copyright \u{00A9} 2026 Yina Tang") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Link("GitHub", destination: URL(string: "https://github.com/I7T5/Edmund")!) + .focusEffectDisabled() + Link("License", destination: URL(string: "https://github.com/I7T5/Edmund/blob/main/LICENSE")!) + .focusEffectDisabled() + } + .font(.caption) + .padding(.top, 4) + } + .padding(20) + .frame(width: 280) + } +} diff --git a/Sources/edmd/About/AboutWindowController.swift b/Sources/edmd/About/AboutWindowController.swift new file mode 100644 index 0000000..7139ac7 --- /dev/null +++ b/Sources/edmd/About/AboutWindowController.swift @@ -0,0 +1,15 @@ +import AppKit +import SwiftUI + +final class AboutWindowController: NSWindowController { + convenience init() { + let hosting = NSHostingController(rootView: AboutView()) + hosting.sizingOptions = [.preferredContentSize] + let window = NSWindow(contentViewController: hosting) + window.styleMask = [.titled, .closable] + window.title = "" + window.center() + window.isReleasedWhenClosed = false + self.init(window: window) + } +} diff --git a/Sources/edmd/App/Document.swift b/Sources/edmd/App/Document.swift new file mode 100644 index 0000000..470174d --- /dev/null +++ b/Sources/edmd/App/Document.swift @@ -0,0 +1,632 @@ +import AppKit +import UniformTypeIdentifiers +import EdmundCore + +/// NSDocument subclass that provides standard macOS document lifecycle: +/// file open/save, dirty-dot indicator, click-to-rename in the titlebar, +/// recent documents, and more — all for free. +/// +/// The actual editing is delegated entirely to `EditorTextView`. +class Document: NSDocument, HeadingNavigable { + + var editor: EditorTextView! + private var statusBar: StatusBarView! + private var viewModeButton: NSButton? + private static let viewModeItemID = NSToolbarItem.Identifier("viewMode") + + /// Session-only zoom scale (View ▸ Actual Size/Zoom In/Zoom Out), applied on + /// top of the persisted font size and content width. Not saved — each new + /// window starts back at 100%. + private var zoomFactor: CGFloat = 1.0 + private static let zoomStep: CGFloat = 0.1 + private static let zoomRange: ClosedRange = 0.5...3.0 + + /// Editor scroll view and its container, held so Read mode can swap the + /// editor out for a `ReadModeWebView` (created lazily on first read). + private var scrollView: NSScrollView! + private var containerView: NSView! + private var readView: ReadModeWebView? + + /// Content loaded from disk before the editor window exists. + /// `nonisolated(unsafe)` because `read(from:ofType:)` may be called + /// off the main actor, but the value is only consumed on main via `showWindows`. + nonisolated(unsafe) var pendingContent: String? + + // MARK: - Type Registration + // + // Without an Info.plist (SPM executable), NSDocument's default readableTypes + // and writableTypes are empty, which causes NSDocumentController to disable + // Open/Save entirely. We override them here. + + override class var readableTypes: [String] { + ["public.plain-text", "net.daringfireball.markdown"] + } + + override class var writableTypes: [String] { + ["net.daringfireball.markdown", "public.plain-text"] + } + + override class var autosavesInPlace: Bool { + AppSettings.autoSaveWithVersions + } + + override class func isNativeType(_ name: String) -> Bool { + return readableTypes.contains(name) + } + + // A single writable type keeps the save panel from showing a file-format + // popup. Everything we write is markdown, so there's nothing to choose. + override func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] { + ["net.daringfireball.markdown"] + } + + // The `net.daringfireball.markdown` UTI prefers the ".markdown" extension; + // force ".md" instead, which is what people actually expect. + override func fileNameExtension(forType typeName: String, + saveOperation: NSDocument.SaveOperationType) -> String? { + "md" + } + + // `fileNameExtension(forType:…)` alone isn't enough: for an untitled save + // the panel still seeds its name field from the markdown UTI's preferred + // extension (".markdown"). Force the default name to end in ".md" and let + // the user type any other extension if they really want one. + override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool { + savePanel.allowedContentTypes = [] + savePanel.allowsOtherFileTypes = true + let base = (savePanel.nameFieldStringValue as NSString).deletingPathExtension + savePanel.nameFieldStringValue = (base.isEmpty ? "Untitled" : base) + ".md" + return true + } + + // MARK: - Window Setup + + override func makeWindowControllers() { + // Default content size for first launch. Any saved size is applied as a + // full window frame at the end of setup (below), once the toolbar is in + // place — so the frame round-trips exactly and doesn't drift by the + // title bar + toolbar height each time. + let windowWidth: CGFloat = 800 + let windowHeight: CGFloat = 560 + + let window = DocumentWindow( + contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + // Don't persist/restore document windows: macOS state restoration + // otherwise reopens the last-edited file on the next launch, so a fresh + // start (or File ▸ New) shows that document instead of a blank Untitled. + window.isRestorable = AppSettings.reopenWindows + window.minSize = NSSize(width: 320, height: 400) + window.backgroundColor = NSColor.textBackgroundColor + + // Build the TextKit 2 text system chain (viewport-based layout). + editor = EditorTextView.makeTextKit2( + frame: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), + containerSize: NSSize(width: windowWidth, height: CGFloat.greatestFiniteMagnitude) + ) + editor.minSize = NSSize(width: 0, height: 0) + editor.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude) + editor.isVerticallyResizable = true + editor.isHorizontallyResizable = false + editor.autoresizingMask = [.width] + editor.textContainerInset = NSSize(width: 24, height: 18) + // Centered reading column (see EditorTextView+ContentWidth). Convert the + // persisted cm value to points using the main screen PPI at window-creation + // time; recomputed on resize (setFrameSize) and when the window moves to a + // different display (windowDidChangeScreen). + let initScreen = NSScreen.main + editor.maxContentWidthPoints = initScreen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000 + editor.updateContentInset() + editor.allowRemoteImages = !AppSettings.blockExternalImages + editor.typewriterModeEnabled = AppDelegate.typewriterModeEnabled() + editor.document = self + + // Toolbar holds the right-aligned view-mode toggle (and gives the + // titlebar extra height for roomy traffic lights). Set it only after + // `editor` exists — assigning the toolbar synchronously vends its items. + let toolbar = NSToolbar(identifier: "MainToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = true + toolbar.autosavesConfiguration = true // persists layout per "MainToolbar" + window.toolbar = toolbar + window.toolbarStyle = .unified + window.titlebarSeparatorStyle = .line + + // Wire the window's secondary-click interception now that the toolbar has + // synchronously vended the view-mode button (see DocumentWindow). + window.viewModeButton = viewModeButton + window.makeViewModeMenu = { [weak self] in self?.viewModeMenu() ?? NSMenu() } + + let statusBarHeight: CGFloat = 22 + let contentBounds = window.contentView!.bounds + + // The text view fills the whole window; the status bar floats over its + // bottom edge, revealed on hover. + scrollView = NSScrollView(frame: contentBounds) + scrollView.autoresizingMask = [.width, .height] + scrollView.hasVerticalScroller = true + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.documentView = editor + + // Floating status bar: hidden by default, fades in when the pointer + // enters its strip. Counts on the left, line ending on the right. + statusBar = StatusBarView(frame: NSRect( + x: 0, y: 0, width: contentBounds.width, height: statusBarHeight + )) + statusBar.autoresizingMask = [.width] + + containerView = NSView(frame: contentBounds) + containerView.autoresizesSubviews = true + containerView.addSubview(scrollView) + containerView.addSubview(statusBar) // overlay, on top of the text + + window.contentView = containerView + + NotificationCenter.default.addObserver( + self, selector: #selector(editorDidChange(_:)), + name: NSText.didChangeNotification, object: editor + ) + NotificationCenter.default.addObserver( + self, selector: #selector(editorSelectionDidChange(_:)), + name: NSTextView.didChangeSelectionNotification, object: editor + ) + NotificationCenter.default.addObserver( + self, selector: #selector(windowDidResize(_:)), + name: NSWindow.didResizeNotification, object: window + ) + NotificationCenter.default.addObserver( + self, selector: #selector(windowDidChangeScreen(_:)), + name: NSWindow.didChangeScreenNotification, object: window + ) + + // Restore the last window's frame size (the toolbar is now installed, so + // the frame is final). Applied as a frame, not a contentRect, so it + // round-trips exactly with what windowDidResize saves. Then center. + if let savedSize = AppSettings.lastWindowSize { + window.setFrame(NSRect(origin: window.frame.origin, size: savedSize), display: false) + } + window.center() + + let wc = NSWindowController(window: window) + addWindowController(wc) + window.makeFirstResponder(editor) + // Honor the persisted source-mode preference for the editing view. + if AppSettings.sourceMode { setViewMode(.source) } + updateStatusBar() + } + + @objc private func windowDidResize(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + // Save the full frame size; it's restored verbatim via setFrame on the + // next window, so the size round-trips exactly (no title-bar/toolbar drift). + AppSettings.lastWindowSize = window.frame.size + } + + /// Reapply the content-width cap in points when the window moves to a + /// display with a different physical PPI (e.g. external monitor). + @objc private func windowDidChangeScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let screen = window.screen else { return } + editor?.maxContentWidthPoints = screen.cmToPoints(AppSettings.maxContentWidthCm) * zoomFactor + } + + // MARK: - Zoom (View ▸ Actual Size / Zoom In / Zoom Out) + + @objc func zoomIn(_ sender: Any?) { setZoom(zoomFactor + Self.zoomStep) } + @objc func zoomOut(_ sender: Any?) { setZoom(zoomFactor - Self.zoomStep) } + @objc func actualSize(_ sender: Any?) { setZoom(1.0) } + + /// Scales font size (standard + code) and max content width together by + /// `factor`, off the persisted base values — never off the currently + /// applied (possibly already-zoomed) theme, so repeated zooming doesn't + /// compound rounding error and Actual Size always returns to the true base. + private func setZoom(_ factor: CGFloat) { + guard let editor else { return } + zoomFactor = min(Self.zoomRange.upperBound, max(Self.zoomRange.lowerBound, factor)) + + let base = EditorTheme.load(from: editor.themeDefaults) + var zoomed = base + zoomed.fontSize = base.fontSize * zoomFactor + zoomed.monospaceFontSize = base.monospaceFontSize * zoomFactor + editor.applyTheme(zoomed, persist: false) + + let screen = editor.window?.screen ?? NSScreen.main + editor.maxContentWidthPoints = (screen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000) * zoomFactor + + refreshReadView() + } + + @objc private func editorDidChange(_ notification: Notification) { + updateStatusBar() + // Keep an open Read view in sync with edits (it renders a snapshot). + refreshReadView() + } + + @objc private func editorSelectionDidChange(_ notification: Notification) { + updateStatusBar() + } + + private func updateStatusBar() { + guard let editor = editor, let statusBar = statusBar else { return } + let text = editor.rawSource + let nsText = text as NSString + let wordCount = text.split { $0.isWhitespace || $0.isNewline }.count + let charCount = text.count + + // Cursor position: 0-based character location and 1-based line number. + let cursorOffset = editor.selectedRange().location + let location = min(cursorOffset, nsText.length) + let upToCursor = nsText.substring(to: location) + let line = upToCursor.isEmpty ? 1 : upToCursor.components(separatedBy: "\n").count + + // The buffer is always LF; show the file's remembered original ending. + statusBar.setMetrics(words: wordCount, characters: charCount, + location: location, line: line, + lineEnding: editor.originalLineEnding.displayName) + } + + // MARK: - Reading + + override nonisolated func read(from data: Data, ofType typeName: String) throws { + guard let contents = String(data: data, encoding: .utf8) else { + Log.error("Read failed: \(data.count) bytes not valid UTF-8", category: .io) + throw NSError(domain: NSOSStatusErrorDomain, code: -1, + userInfo: [NSLocalizedDescriptionKey: "Could not read file as UTF-8"]) + } + Log.info("Read \(data.count) bytes from disk", category: .io) + pendingContent = contents + } + + /// Called after makeWindowControllers when opening an existing file. + override func showWindows() { + super.showWindows() + if let content = pendingContent { + editor?.loadContent(content) + pendingContent = nil + warnIfInconsistentLineEndings(in: content) + } + updateStatusBar() + } + + /// Warn (once, suppressibly) when an opened file mixed line-ending styles. + /// The buffer has already been normalized to a single style for editing. + private func warnIfInconsistentLineEndings(in content: String) { + guard LineEnding.isInconsistent(in: content), + !AppSettings.suppressInconsistentLineEndingWarning, + let window = windowControllers.first?.window else { return } + + let alert = NSAlert() + alert.messageText = "Inconsistent Line Endings" + alert.informativeText = "This document mixes different line endings. " + + "It will be saved using \(editor?.originalLineEnding.displayName ?? "LF") throughout." + alert.addButton(withTitle: "OK") + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Do not warn about inconsistent line endings" + alert.beginSheetModal(for: window) { _ in + if alert.suppressionButton?.state == .on { + AppSettings.suppressInconsistentLineEndingWarning = true + } + } + } + + /// Cross-file link following: scroll this document's editor to a heading + /// once it's on screen (the content has already loaded in showWindows). + func navigateToHeading(_ heading: String) { + editor?.scrollToHeading(heading) + } + + // MARK: - Rename & Move (manual — NSDocument's built-in versions + // are disabled without Info.plist / .app bundle) + + override func rename(_ sender: Any?) { + guard let url = fileURL, let window = windowControllers.first?.window else { return } + let panel = NSSavePanel() + panel.directoryURL = url.deletingLastPathComponent() + panel.nameFieldStringValue = url.lastPathComponent + panel.prompt = "Rename" + panel.beginSheetModal(for: window) { response in + guard response == .OK, let newURL = panel.url else { return } + do { + try FileManager.default.moveItem(at: url, to: newURL) + self.fileURL = newURL + } catch { + NSAlert(error: error).runModal() + } + } + } + + override func move(_ sender: Any?) { + guard let url = fileURL, let window = windowControllers.first?.window else { return } + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.prompt = "Move" + panel.message = "Choose a new location for \"\(url.lastPathComponent)\"" + panel.beginSheetModal(for: window) { response in + guard response == .OK, let destDir = panel.url else { return } + let newURL = destDir.appendingPathComponent(url.lastPathComponent) + do { + try FileManager.default.moveItem(at: url, to: newURL) + self.fileURL = newURL + } catch { + NSAlert(error: error).runModal() + } + } + } + + // MARK: - View Mode (edit / reading / source) + + private func icon(for mode: EditorTextView.ViewMode) -> NSImage? { + let name: String + switch mode { + // Source is a raw-text view of the same editing mode as Edit, so it + // shares the pencil icon rather than getting a distinct glyph. + case .edit, .source: name = "pencil" + case .reading: name = "book" + } + return NSImage(systemSymbolName: name, accessibilityDescription: label(for: mode)) + } + + private func label(for mode: EditorTextView.ViewMode) -> String { + switch mode { + case .edit: return "Edit" + case .reading: return "Read" + case .source: return "Source" + } + } + + /// Shows the active mode's icon on the button and keeps the tooltip in sync. + private func refreshViewModeButton() { + guard let editor else { return } + viewModeButton?.image = icon(for: editor.viewMode)? + .withSymbolConfiguration(.init(pointSize: 13, weight: .regular)) + viewModeButton?.toolTip = "View mode: \(label(for: editor.viewMode))" + } + + private func setViewMode(_ mode: EditorTextView.ViewMode) { + editor.viewMode = mode + applyViewMode(mode) + refreshViewModeButton() + } + + /// Swaps the on-screen view for the mode: Read mode shows the rendered-HTML + /// `ReadModeWebView`; Edit and Source stay on the editor's scroll view. + private func applyViewMode(_ mode: EditorTextView.ViewMode) { + guard let containerView else { return } + if mode == .reading { + let read = readView ?? { + let v = ReadModeWebView() + v.frame = scrollView.frame + v.autoresizingMask = [.width, .height] + // Below the floating status bar so counts stay visible. + containerView.addSubview(v, positioned: .below, relativeTo: statusBar) + // Route internal navigation through the editor's link resolver + // (which resolves against this document's directory and opens via + // NSDocumentController) instead of navigating the webview. + v.onOpenWikiLink = { [weak self] in self?.editor.followWikiLink($0) } + v.onOpenInternalLink = { [weak self] in self?.editor.followLinkDestination($0) } + readView = v + return v + }() + read.render(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions) + read.isHidden = false + scrollView.isHidden = true + editor.window?.makeFirstResponder(read) + } else { + readView?.isHidden = true + scrollView.isHidden = false + editor.window?.makeFirstResponder(editor) + } + } + + /// Re-renders an open Read view from the editor's current source + theme. + /// No-op unless Read mode is the active, visible view — so settings/edit + /// broadcasts stay cheap when the user is in Edit or Source mode. + func refreshReadView() { + guard let read = readView, !read.isHidden, editor?.viewMode == .reading else { return } + read.render(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions) + } + + /// The opened file's directory, used to resolve relative image paths for + /// inlining (nil for an unsaved document). + private var documentDirectory: URL? { + fileURL?.deletingLastPathComponent() + } + + /// Built-in callout styles merged with the editor's user overrides, so Read + /// mode and the PDF match exactly what the editor draws. + private var mergedCallouts: [String: CalloutStyle] { + var m = Callout.defaultStyles + for (k, v) in editor.calloutStyleOverrides { m[k] = v } + return m + } + + /// Read-mode/export render options derived from user settings. Reuses the + /// editor's own `maxContentWidthPoints` (already the cm setting converted via + /// the window's screen PPI) so Read mode's column matches Edit mode's. + private var renderOptions: ReadRenderOptions { + ReadRenderOptions(preserveBlankLines: AppSettings.renderBlankLinesAsBreaks, + allowRemoteImages: !AppSettings.blockExternalImages, + maxContentWidthPoints: Double(editor.maxContentWidthPoints)) + } + + // MARK: - Export / Print + + @objc func exportToPDF(_ sender: Any?) { + let name = (displayName as NSString).deletingPathExtension + MarkdownPrinter.exportPDF(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions, + suggestedName: name.isEmpty ? "Untitled" : name, + window: windowControllers.first?.window) + } + + @objc override func printDocument(_ sender: Any?) { + MarkdownPrinter.print(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions, + window: windowControllers.first?.window) + } + + /// The editing-side view: Source when source mode is on, otherwise Edit. + /// Read is the other half of the toggle. + private var editingMode: EditorTextView.ViewMode { + AppSettings.sourceMode ? .source : .edit + } + + @objc private func selectEditMode(_ sender: Any?) { setViewMode(editingMode) } + @objc private func selectReadingMode(_ sender: Any?) { setViewMode(.reading) } + + /// The "Show source in editor" checkbox (button menu and View menu). + /// Persists the setting and, if we're in the editing view, swaps it to + /// the new editing mode right away. + @objc func toggleSourceMode(_ sender: Any?) { + AppSettings.sourceMode.toggle() + if editor.viewMode != .reading { setViewMode(editingMode) } + } + + /// Keeps the View-menu "Show Source in Editor" checkmark in sync with the setting. + override func validateMenuItem(_ item: NSMenuItem) -> Bool { + if item.action == #selector(toggleSourceMode(_:)) { + item.state = AppSettings.sourceMode ? .on : .off + } + return super.validateMenuItem(item) + } + + /// Toggle the editing view ↔ Read (the View-menu ⌘E item and the toolbar + /// button). With source mode on the editing view is Source, so this flips + /// Source ↔ Read; otherwise Edit ↔ Read. + @objc func toggleViewMode(_ sender: Any?) { + setViewMode(editor.viewMode == .reading ? editingMode : .reading) + } + + /// One mode menu item: icon + title, checked when `on`. + private func menuItem(_ title: String, _ image: NSImage?, + _ action: Selector, on: Bool) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = self + item.image = image + item.state = on ? .on : .off + return item + } + + /// The right-click menu: Edit / Read selection, a divider, then the + /// "Show source in editor" checkbox. Built fresh each time so state stays current. + fileprivate func viewModeMenu() -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false // actions always fire on selection + let inEditing = editor?.viewMode != .reading + menu.addItem(menuItem("Edit", icon(for: .edit), + #selector(selectEditMode(_:)), on: inEditing)) + menu.addItem(menuItem("Read", icon(for: .reading), + #selector(selectReadingMode(_:)), on: !inEditing)) + menu.addItem(.separator()) + menu.addItem(menuItem("Show source in editor", nil, + #selector(toggleSourceMode(_:)), on: AppSettings.sourceMode)) + return menu + } + + // MARK: - Writing + + override func data(ofType typeName: String) throws -> Data { + // The buffer is always LF; restore the file's original line ending on + // write so opening, then saving, doesn't silently rewrite every line. + let normalized = editor?.rawSource ?? "" + let ending = editor?.originalLineEnding ?? .lf + let text = ending == .lf + ? normalized + : normalized.replacingOccurrences(of: "\n", with: ending.string) + guard let data = text.data(using: .utf8) else { + Log.error("Save failed: could not encode \(text.count) chars as UTF-8", category: .io) + throw NSError(domain: NSOSStatusErrorDomain, code: -1, + userInfo: [NSLocalizedDescriptionKey: "Could not encode text as UTF-8"]) + } + Log.info("Saving \(data.count) bytes (\(ending.displayName))", category: .io) + return data + } +} + +// MARK: - Toolbar (view-mode toggle) + +extension Document: NSToolbarDelegate { + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, Self.viewModeItemID] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, .space, Self.viewModeItemID] + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + guard itemIdentifier == Self.viewModeItemID else { return nil } + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = "View Mode" + item.visibilityPriority = .high + + // Left-click toggles the editing view ↔ Read. The right-click mode menu + // is handled upstream in DocumentWindow.sendEvent — every view-level + // approach (the view's `menu`, rightMouseDown, a gesture recognizer) + // loses the secondary click to the toolbar's "Customize Toolbar…" menu. + let button = NSButton(image: NSImage(), target: self, + action: #selector(toggleViewMode(_:))) + button.bezelStyle = .texturedRounded + button.imagePosition = .imageOnly + viewModeButton = button + item.view = button + refreshViewModeButton() + return item + } +} + +/// Document window that intercepts a secondary (right / control) click on the +/// view-mode toolbar button and shows the mode menu itself. `sendEvent` is the +/// single funnel all window events pass through *before* the toolbar/titlebar +/// can turn the click into its own "Customize Toolbar…" context menu, so this is +/// the one place the interception reliably wins. +final class DocumentWindow: NSWindow { + weak var viewModeButton: NSView? + var makeViewModeMenu: (() -> NSMenu)? + + override func sendEvent(_ event: NSEvent) { + if isSecondaryClick(event), let button = viewModeButton, + button.bounds.contains(button.convert(event.locationInWindow, from: nil)), + let menu = makeViewModeMenu?() { + menu.popUp(positioning: nil, + at: NSPoint(x: 0, y: button.bounds.maxY + 4), in: button) + return + } + super.sendEvent(event) + } + + private func isSecondaryClick(_ event: NSEvent) -> Bool { + switch event.type { + case .rightMouseDown: return true + case .leftMouseDown: return event.modifierFlags.contains(.control) + default: return false + } + } +} diff --git a/Sources/edmd/App/DocumentController.swift b/Sources/edmd/App/DocumentController.swift new file mode 100644 index 0000000..557915e --- /dev/null +++ b/Sources/edmd/App/DocumentController.swift @@ -0,0 +1,88 @@ +import AppKit +import UniformTypeIdentifiers + +/// Custom document controller that registers our Document class for markdown files. +/// +/// Without an Info.plist (SPM executable), NSDocumentController's default +/// `openDocument:` is broken because it relies on CFBundleDocumentTypes for +/// type validation. We override it to show the Open panel ourselves and +/// create Document instances directly. +class DocumentController: NSDocumentController { + + // MARK: - Type Registration + + override var documentClassNames: [String] { + ["Document"] + } + + override var defaultType: String? { + "net.daringfireball.markdown" + } + + override func documentClass(forType typeName: String) -> AnyClass? { + Document.self + } + + override func typeForContents(of url: URL) throws -> String { + let ext = url.pathExtension.lowercased() + if ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd" { + return "net.daringfireball.markdown" + } + return "public.plain-text" + } + + // MARK: - Open Document (manual implementation) + + /// Completely replaces NSDocumentController's openDocument: because the + /// default implementation refuses to show the panel without Info.plist + /// type registrations. + @MainActor + override func openDocument(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + // Allow all files — our read(from:ofType:) handles UTF-8 decoding. + panel.allowedContentTypes = [] + panel.allowsOtherFileTypes = true + + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + self.openDocument(withContentsOf: url, display: true) { _, _, error in + if let error = error { + NSAlert(error: error).runModal() + } + } + } + } + + // MARK: - Untitled Window Cleanup + // + // Apple's documented single funnel for opening an existing file — the Open + // panel, Recent Items, and drag-and-drop all call this — so hooking it + // here catches every "open another file" path in one place. + override func openDocument(withContentsOf url: URL, display displayDocument: Bool, + completionHandler: @escaping (NSDocument?, Bool, Error?) -> Void) { + super.openDocument(withContentsOf: url, display: displayDocument) { document, alreadyOpen, error in + if let document, error == nil { + self.closeLastUntouchedUntitledWindow(keeping: document) + } + completionHandler(document, alreadyOpen, error) + } + } + + /// Closes the most-recently-opened blank Untitled window the user never + /// typed into — e.g. the automatic blank document from launch — once a + /// real file opens. Only the last one, so opening several Untitled + /// windows on purpose still leaves the earlier ones alone. + /// `isDocumentEdited` already tracks "untyped": edits call + /// `updateChangeCount` (`EditorTextView+EditFlow.swift`), so an untouched + /// Untitled document is never marked edited. + private func closeLastUntouchedUntitledWindow(keeping opened: NSDocument) { + let stale = documents.last { doc in + guard let doc = doc as? Document, doc !== opened else { return false } + return doc.fileURL == nil && !doc.isDocumentEdited + } + stale?.close() + } +} diff --git a/Sources/edmd/App/FormatMenu.swift b/Sources/edmd/App/FormatMenu.swift new file mode 100644 index 0000000..1be01c3 --- /dev/null +++ b/Sources/edmd/App/FormatMenu.swift @@ -0,0 +1,189 @@ +import AppKit +import EdmundCore + +// MARK: - Format menu (declarative command registry) +// +// The Format menu and its shortcuts are described by a single table of +// `MenuCommand`s and built from it. Action methods live on `EditorTextView` +// (and `Document` for the view-mode cycle); items use a nil target so they +// route through the responder chain to the focused editor — exactly like the +// undo/redo items in `setupMenuBar`. +// +// Groundwork for user-configurable shortcuts: every command carries a stable +// `id` and a default `Shortcut`. A later pass can resolve a per-`id` override +// from UserDefaults before building the item; nothing is persisted yet. + +/// A key equivalent: the (lowercased) key plus its modifier flags. +struct Shortcut { + let key: String + let modifiers: NSEvent.ModifierFlags + + static func cmd(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command]) } + static func cmdShift(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command, .shift]) } + static func cmdOpt(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command, .option]) } +} + +/// One actionable menu command. +struct MenuCommand { + let id: String + let title: String + let action: Selector + var shortcut: Shortcut? = nil + var tag: Int = 0 + var representedObject: Any? = nil + + func makeItem() -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: shortcut?.key ?? "") + item.keyEquivalentModifierMask = shortcut?.modifiers ?? [] + item.tag = tag + item.representedObject = representedObject + // nil target → responder chain (focused EditorTextView / Document). + return item + } +} + +@MainActor +enum FormatMenu { + + /// The top-level "Format" menu item (with its submenu). + static func build() -> NSMenuItem { + let formatItem = NSMenuItem() + let menu = NSMenu(title: "Format") + + menu.addItem(headingSubmenuItem()) + menu.addItem(thematicBreakCommand.makeItem()) + menu.addItem(.separator()) + + for cmd in listCommands { menu.addItem(cmd.makeItem()) } + menu.addItem(.separator()) + + for cmd in linkCommands { menu.addItem(cmd.makeItem()) } + menu.addItem(.separator()) + + for cmd in blockCommands { menu.addItem(cmd.makeItem()) } + menu.addItem(calloutSubmenuItem()) + menu.addItem(footnoteCommand.makeItem()) + menu.addItem(.separator()) + + menu.addItem(fontSubmenuItem()) + + formatItem.submenu = menu + return formatItem + } + + /// The "Toggle View Mode" item (⌘E) for the View menu — bracketed by + /// dividers by the caller. + static func viewModeToggleItem() -> NSMenuItem { + MenuCommand(id: "view.toggleMode", title: "Toggle View Mode", + action: #selector(Document.toggleViewMode(_:)), + shortcut: .cmd("e")).makeItem() + } + + // MARK: - Groups + + private static let listCommands: [MenuCommand] = [ + MenuCommand(id: "format.bulletedList", title: "Bulleted List", + action: #selector(EditorTextView.formatBulletedList(_:)), shortcut: .cmdOpt("b")), + MenuCommand(id: "format.numberedList", title: "Numbered List", + action: #selector(EditorTextView.formatNumberedList(_:))), + MenuCommand(id: "format.checklist", title: "Checklist", + action: #selector(EditorTextView.formatChecklist(_:)), shortcut: .cmd("l")), + ] + + private static let linkCommands: [MenuCommand] = [ + MenuCommand(id: "format.link", title: "Link", + action: #selector(EditorTextView.formatLink(_:)), shortcut: .cmd("k")), + MenuCommand(id: "format.wikilink", title: "Wikilink", + action: #selector(EditorTextView.formatWikilink(_:))), + MenuCommand(id: "format.image", title: "Image", + action: #selector(EditorTextView.formatImage(_:))), + ] + + private static let thematicBreakCommand = MenuCommand(id: "format.thematicBreak", title: "Thematic Break", + action: #selector(EditorTextView.formatThematicBreak(_:))) + + private static let footnoteCommand = MenuCommand(id: "format.footnote", title: "Footnote", + action: #selector(EditorTextView.formatFootnote(_:))) + + private static let blockCommands: [MenuCommand] = [ + MenuCommand(id: "format.table", title: "Table", + action: #selector(EditorTextView.formatTable(_:))), + MenuCommand(id: "format.codeBlock", title: "Code Block", + action: #selector(EditorTextView.formatCodeBlock(_:))), + MenuCommand(id: "format.mathBlock", title: "Math Block", + action: #selector(EditorTextView.formatMathBlock(_:))), + MenuCommand(id: "format.blockQuote", title: "Block Quote", + action: #selector(EditorTextView.formatBlockQuote(_:)), shortcut: .cmdShift("b")), + ] + + private static let fontCommands: [MenuCommand] = [ + MenuCommand(id: "format.bold", title: "Bold", + action: #selector(EditorTextView.formatBold(_:)), shortcut: .cmd("b")), + MenuCommand(id: "format.italic", title: "Italic", + action: #selector(EditorTextView.formatItalic(_:)), shortcut: .cmd("i")), + MenuCommand(id: "format.underline", title: "Underline", + action: #selector(EditorTextView.formatUnderline(_:)), shortcut: .cmd("u")), + MenuCommand(id: "format.strikethrough", title: "Strikethrough", + action: #selector(EditorTextView.formatStrikethrough(_:))), + MenuCommand(id: "format.highlight", title: "Highlight", + action: #selector(EditorTextView.formatHighlight(_:))), + MenuCommand(id: "format.code", title: "Code", + action: #selector(EditorTextView.formatCode(_:))), + MenuCommand(id: "format.math", title: "Math", + action: #selector(EditorTextView.formatInlineMath(_:))), + MenuCommand(id: "format.keyboard", title: "Keyboard", + action: #selector(EditorTextView.formatKeyboard(_:))), + MenuCommand(id: "format.comment", title: "Comments", + action: #selector(EditorTextView.formatComment(_:))), + ] + + /// GitHub alert types (uppercase in source: `> [!NOTE]`). + private static let githubCalloutTypes = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"] + + /// Obsidian-only callout types (lowercase). note/tip/warning are omitted + /// since they duplicate NOTE/TIP/WARNING already in the GitHub group. + private static let obsidianCalloutTypes = [ + "abstract", "info", "todo", "success", "question", + "failure", "danger", "bug", "example", "quote", + ] + + // MARK: - Submenus + + private static func headingSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: "Heading", action: nil, keyEquivalent: "") + let menu = NSMenu(title: "Heading") + for level in 1...6 { + menu.addItem(MenuCommand(id: "format.heading\(level)", title: "Heading \(level)", + action: #selector(EditorTextView.formatHeading(_:)), + tag: level).makeItem()) + } + item.submenu = menu + return item + } + + private static func calloutSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: "Alert / Callout", action: nil, keyEquivalent: "") + let menu = NSMenu(title: "Alert / Callout") + for type in githubCalloutTypes { + menu.addItem(MenuCommand(id: "format.callout.\(type)", title: type.capitalized, + action: #selector(EditorTextView.formatCallout(_:)), + representedObject: type).makeItem()) + } + menu.addItem(.separator()) + for type in obsidianCalloutTypes { + menu.addItem(MenuCommand(id: "format.callout.\(type)", title: type.capitalized, + action: #selector(EditorTextView.formatCallout(_:)), + representedObject: type).makeItem()) + } + item.submenu = menu + return item + } + + private static func fontSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: "Font", action: nil, keyEquivalent: "") + let menu = NSMenu(title: "Font") + for cmd in fontCommands { menu.addItem(cmd.makeItem()) } + item.submenu = menu + return item + } +} diff --git a/Sources/edmd/App/ReproScript.swift b/Sources/edmd/App/ReproScript.swift new file mode 100644 index 0000000..4278113 --- /dev/null +++ b/Sources/edmd/App/ReproScript.swift @@ -0,0 +1,207 @@ +#if DEBUG +import AppKit +import EdmundCore + +/// In-process repro driver: `-debug.reproScript ` replays a keystroke +/// script against the front document through the real AppKit key-event path +/// (window.sendEvent → keyDown → interpretKeyEvents → insertText / +/// deleteBackward). Exists because TCC-denied automation sessions cannot post +/// CGEvents at the app; this keeps live-app bug repros scriptable without +/// Accessibility permission. Commands, one per line: +/// sleep wait before the next command +/// caret place the caret before the first occurrence of +/// type type text, one key event per character +/// backspace press delete n times (300ms apart) +/// tab / backtab indent / dedent the selected list line(s) +/// scroll scroll the clip view to y (bypasses the caret/typewriter +/// recentering, so a block can be driven off-screen) +/// logsel log the current selection +@MainActor +enum ReproScript { + + static func runIfRequested() { + guard let path = UserDefaults.standard.string(forKey: "debug.reproScript"), + let script = try? String(contentsOfFile: path, encoding: .utf8) else { return } + Log.info("repro script: \(path)", category: .app) + var delay: TimeInterval = 1.5 // let the document finish opening + for line in script.split(separator: "\n") { + let parts = line.split(separator: " ", maxSplits: 1).map(String.init) + guard let cmd = parts.first, !cmd.hasPrefix("#") else { continue } + let arg = parts.count > 1 ? parts[1] : "" + switch cmd { + case "sleep": + delay += (Double(arg) ?? 0) / 1000 + case "caret": + schedule(after: delay) { editor in + let r = (editor.rawSource as NSString).range(of: arg) + guard r.location != NSNotFound else { + Log.info("repro caret: needle not found: \(arg)", category: .app) + return + } + editor.setSelectedRange(NSRange(location: r.location, length: 0)) + } + case "caretoff": + // Absolute-offset caret move (arrow-key-like: fromMouse=false). + schedule(after: delay) { editor in + let n = min(Int(arg) ?? 0, (editor.rawSource as NSString).length) + editor.setSelectedRange(NSRange(location: n, length: 0)) + } + case "clickoff": + // Absolute-offset caret move on the MOUSE path: sets + // suppressTypewriterCentering for the selection change so the + // +SelectionTracking restyle captures fromMouse=true and takes + // the preservingViewportAnchor branch (what a real click does). + schedule(after: delay) { editor in + editor.reproClickSelect(Int(arg) ?? 0) + } + case "realclickoff": + // Absolute-offset caret move via a REAL synthesized mouse click + // at the glyph's on-screen position: goes through hit-testing and + // NSTextView.mouseDown, the genuine mouse path (fromMouse=true), + // which programmatic setSelectedRange does not replicate. Needed + // because faithful keystroke replay alone does not arm the + // round-7 drift — the arming caret moves were real clicks. + schedule(after: delay) { editor in + let n = min(Int(arg) ?? 0, (editor.rawSource as NSString).length) + var actual = NSRange() + let scr = editor.firstRect(forCharacterRange: NSRange(location: n, length: 0), + actualRange: &actual) + guard let screen = editor.window?.screen else { return } + // firstRect: Cocoa screen coords (origin bottom-left). CGEvent + // wants top-left origin. + let cocoaPt = CGPoint(x: scr.midX, y: scr.midY) + let p = CGPoint(x: cocoaPt.x, y: screen.frame.maxY - cocoaPt.y) + func post(_ t: CGEventType) { + CGEvent(mouseEventSource: nil, mouseType: t, mouseCursorPosition: p, + mouseButton: .left)?.post(tap: .cghidEventTap) + } + post(.mouseMoved); post(.leftMouseDown); post(.leftMouseUp) + } + case "selrange": + // "selrange N M" — select M chars at offset N. + schedule(after: delay) { editor in + let f = arg.split(separator: " ") + guard f.count == 2, let n = Int(f[0]), let m = Int(f[1]) else { return } + editor.setSelectedRange(NSRange(location: n, length: m)) + } + case "caretend": + // Place the caret at the very end of the document (the phantom + // empty final line when rawSource ends in "\n"). Needles can't + // target an empty line, so this is the only way to sit there. + schedule(after: delay) { editor in + let end = (editor.rawSource as NSString).length + editor.setSelectedRange(NSRange(location: end, length: 0)) + } + case "type": + for ch in arg { + let s = String(ch) + // Direct action call (not a synthesized NSEvent through the + // input context): the storage mutation + queued-fixup path is + // identical, but avoids the input-context fragility that a + // long scripted replay hits when programmatic selections and + // synthetic key events interleave. + schedule(after: delay) { $0.insertText(s, replacementRange: NSRange(location: NSNotFound, length: 0)) } + delay += 0.08 + } + case "backspace": + for _ in 0 ..< (Int(arg) ?? 1) { + schedule(after: delay) { $0.deleteBackward(nil) } + delay += 0.3 + } + case "return": + schedule(after: delay) { $0.insertText("\n", replacementRange: NSRange(location: NSNotFound, length: 0)) } + delay += 0.05 + case "tab": + schedule(after: delay) { $0.insertTab(nil) } + delay += 0.05 + case "backtab": + schedule(after: delay) { $0.insertBacktab(nil) } + delay += 0.05 + case "bypassdelete": + // Mimics AppKit's drag-move source deletion (the issue-#156 + // trigger): select the range, run shouldChangeText and the + // storage mutation, and never call didChangeText — the + // bypassed-edit heal then fires on the next run-loop pass. + schedule(after: delay) { editor in + let r = (editor.rawSource as NSString).range(of: arg) + guard r.location != NSNotFound else { + Log.info("repro bypassdelete: needle not found: \(arg)", category: .app) + return + } + editor.setSelectedRange(r) + guard editor.shouldChangeText(in: r, replacementString: "") else { return } + editor.textStorage?.replaceCharacters(in: r, with: "") + } + case "bypassoff": + // "bypassoff N M" — offset form of bypassdelete: delete M chars + // at N via shouldChangeText + storage mutation, no didChangeText. + schedule(after: delay) { editor in + let f = arg.split(separator: " ") + guard f.count == 2, let n = Int(f[0]), let m = Int(f[1]) else { return } + let r = NSRange(location: n, length: m) + editor.setSelectedRange(r) + guard editor.shouldChangeText(in: r, replacementString: "") else { return } + editor.textStorage?.replaceCharacters(in: r, with: "") + } + case "assertcaret": + // PASS iff the caret sits exactly before the first occurrence + // of — position-independent drift check for soaks. + schedule(after: delay) { editor in + let want = (editor.rawSource as NSString).range(of: arg).location + let sel = editor.selectedRange() + let ok = sel.location == want && sel.length == 0 + Log.info("repro assertcaret \(ok ? "PASS" : "FAIL") " + + "sel=\(sel) want=\(want) needle=\(arg)", category: .app) + } + case "logsel": + schedule(after: delay) { editor in + Log.info("repro logsel sel=\(editor.selectedRange()) " + + "rawLen=\((editor.rawSource as NSString).length) " + + "docs=\(NSDocumentController.shared.documents.count)", + category: .app) + } + case "scroll": + // Scrolls the clip view directly (bypassing the caret, so the + // active block can be driven off-screen independent of where + // typewriter-mode recentering would otherwise put it). + // `scroll(to:)` posts boundsDidChange, same as a real drag/wheel + // scroll, so promotion/idle-drain react exactly as they would live. + schedule(after: delay) { editor in + guard let clipView = editor.enclosingScrollView?.contentView else { return } + let y = CGFloat(Double(arg) ?? 0) + let proposed = NSRect(origin: NSPoint(x: 0, y: y), size: clipView.bounds.size) + let clamped = clipView.constrainBoundsRect(proposed) + clipView.scroll(to: clamped.origin) + editor.enclosingScrollView?.reflectScrolledClipView(clipView) + Log.info("repro scroll y=\(y) clamped=\(clamped.origin.y)", category: .app) + } + default: + break + } + delay += 0.02 + } + } + + private static func schedule(after: TimeInterval, + _ body: @escaping @MainActor (EditorTextView) -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + after) { + guard let doc = NSDocumentController.shared.documents.first as? Document, + let editor = doc.editor else { return } + body(editor) + } + } + + /// Sends a key event through the window so it takes the full AppKit + /// keyDown route, exactly like a physical keystroke. + private static func press(_ chars: String, keyCode: UInt16, in editor: EditorTextView) { + guard let window = editor.window, + let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, + characters: chars, charactersIgnoringModifiers: chars, + isARepeat: false, keyCode: keyCode) else { return } + window.makeFirstResponder(editor) + window.sendEvent(event) + } +} +#endif diff --git a/Sources/edmd/App/ViewMenu.swift b/Sources/edmd/App/ViewMenu.swift new file mode 100644 index 0000000..b041d03 --- /dev/null +++ b/Sources/edmd/App/ViewMenu.swift @@ -0,0 +1,55 @@ +import AppKit +import EdmundCore + +// MARK: - View menu + +@MainActor +enum ViewMenu { + + /// The top-level "View" menu item (with its submenu). + static func build() -> NSMenuItem { + let viewMenuItem = NSMenuItem() + let viewMenu = NSMenu(title: "View") + + // Routes through the responder chain to the key window's toolbar. + // AppKit auto-inserts "Show Tab Bar"/"Show All Tabs" above this at + // runtime (window tabbing is on by default) — that position isn't + // ours to control short of disabling tabbing outright. + viewMenu.addItem(withTitle: "Customize Toolbar…", + action: #selector(NSWindow.runToolbarCustomizationPalette(_:)), + keyEquivalent: "") + viewMenu.addItem(.separator()) + + let typewriterItem = viewMenu.addItem( + withTitle: "Typewriter Scroll", + action: #selector(AppDelegate.toggleTypewriterMode(_:)), + keyEquivalent: "") + typewriterItem.state = AppDelegate.typewriterModeEnabled() ? .on : .off + + // View-mode toggle (Edit ↔ Read) + the Source-mode checkbox. + viewMenu.addItem(.separator()) + viewMenu.addItem(FormatMenu.viewModeToggleItem()) + viewMenu.addItem(withTitle: "Show Source in Editor", + action: #selector(Document.toggleSourceMode(_:)), + keyEquivalent: "") + viewMenu.addItem(.separator()) + + // Zoom (font size + max content width, scaled together). Target nil + // routes through the responder chain to the key window's Document. + // Kept last, directly above the separator AppKit inserts before its + // automatic "Enter/Exit Full Screen" item at the menu's end. + viewMenu.addItem(withTitle: "Actual Size", + action: #selector(Document.actualSize(_:)), + keyEquivalent: "0") + viewMenu.addItem(withTitle: "Zoom In", + action: #selector(Document.zoomIn(_:)), + keyEquivalent: "=") + viewMenu.addItem(withTitle: "Zoom Out", + action: #selector(Document.zoomOut(_:)), + keyEquivalent: "-") + viewMenu.addItem(.separator()) + + viewMenuItem.submenu = viewMenu + return viewMenuItem + } +} diff --git a/Sources/edmd/App/main.swift b/Sources/edmd/App/main.swift new file mode 100644 index 0000000..fcb27b2 --- /dev/null +++ b/Sources/edmd/App/main.swift @@ -0,0 +1,289 @@ +import AppKit +import EdmundCore +import Sparkle + +// Entry point for the app the user knows as "Edmund" (CFBundleName). The +// executable target — and so this binary at Edmund.app/Contents/MacOS/edmd — is +// named `edmd`, an expansion of "Editor for Markdown". The backronym is the +// app's original working name; it survives only here and in Package.swift, never +// in anything user-facing. + +// --- App Delegate ----------------------------------------------------------- + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { + + var aboutWindowController: AboutWindowController? + var settingsWindowController: SettingsWindowController? + // startingUpdater: true kicks off the scheduled background check immediately; + // the "Check for Updates…" menu item targets this controller directly. + let updaterController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + + // MARK: - Typewriter Mode (persisted) + + static let typewriterModeKey = "EditorTypewriterMode" + + /// Whether typewriter scrolling is enabled. Defaults to on (the historical + /// behavior) when nothing has been saved yet. + static func typewriterModeEnabled(_ defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: typewriterModeKey) as? Bool ?? true + } + + func applicationDidFinishLaunching(_ notification: Notification) { + AppSettings.applyLogging() + Log.info("Edmund launched", category: .app) + AppSettings.applyAppearance() + setupMenuBar() + + // Opt-in (default off): upload any crash reports macOS wrote for us since + // last launch. Fire-and-forget; never blocks startup. + if AppSettings.sendCrashLogs { + CrashReporter.uploadPendingReports( + alreadySent: AppSettings.sentCrashReports, + onSent: { AppSettings.sentCrashReports.insert($0) }) + } + + // Open file from command-line argument. When a file is given, + // `applicationShouldOpenUntitledFile` suppresses the otherwise-automatic + // blank document, so we don't end up with two windows. + let args = CommandLine.arguments + if args.count > 1 { + let url = URL(fileURLWithPath: args[1]) + Log.info("Opening file from launch argument: \(url.path)", category: .document) + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { _, _, _ in } + } + + #if DEBUG + ReproScript.runIfRequested() + #endif + } + + // Auto-open a blank document on launch only when no file was passed on the + // command line (otherwise the file arg + the blank doc make two windows). + func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { + CommandLine.arguments.count <= 1 && AppSettings.startupAction == .createNewDocument + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + AppSettings.reopenWindows + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + // Reopen a new untitled document when the app is activated with no windows. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + if AppSettings.startupAction == .createNewDocument { + NSDocumentController.shared.newDocument(nil) + } + } + return true + } + + // MARK: - Settings + + @MainActor @objc func showAbout(_ sender: Any?) { + if aboutWindowController == nil { + aboutWindowController = AboutWindowController() + } + aboutWindowController?.showWindow(nil) + aboutWindowController?.window?.makeKeyAndOrderFront(nil) + } + + @MainActor @objc func showSettings(_ sender: Any?) { + if settingsWindowController == nil { + settingsWindowController = SettingsWindowController() + } + settingsWindowController?.showWindow(nil) + settingsWindowController?.window?.makeKeyAndOrderFront(nil) + } + + // MARK: - View + + /// Toggles typewriter scrolling, persists the choice, and applies it to every + /// open document immediately. + @MainActor @objc func toggleTypewriterMode(_ sender: Any?) { + let newValue = !AppDelegate.typewriterModeEnabled() + UserDefaults.standard.set(newValue, forKey: AppDelegate.typewriterModeKey) + for case let doc as Document in NSDocumentController.shared.documents { + doc.editor?.typewriterModeEnabled = newValue + } + } + + @MainActor func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if menuItem.action == #selector(toggleTypewriterMode(_:)) { + menuItem.state = AppDelegate.typewriterModeEnabled() ? .on : .off + } + return true + } + + // MARK: - Open Document + + /// Manual Open panel — bypasses NSDocumentController's type validation + /// which is broken without Info.plist. + @MainActor @objc func openDocumentManually(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + + let complete: (NSApplication.ModalResponse) -> Void = { response in + guard response == .OK, let url = panel.url else { return } + NSDocumentController.shared.openDocument( + withContentsOf: url, display: true + ) { _, _, error in + if let error = error { + NSAlert(error: error).runModal() + } + } + } + + // Attach the panel to the front window as a sheet so it's always visible + // — a free-floating panel can open off-screen or behind the window + // (the app's windows can launch off-screen), which looks like a hang. + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + panel.beginSheetModal(for: window, completionHandler: complete) + } else { + panel.begin(completionHandler: complete) + } + } + + // MARK: - Menu Bar + + @MainActor private func setupMenuBar() { + let mainMenu = NSMenu() + + // App menu (required for Cmd+Q) + let appMenuItem = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(withTitle: "About Edmund", + action: #selector(AppDelegate.showAbout(_:)), + keyEquivalent: "") + appMenu.addItem(NSMenuItem.separator()) + appMenu.addItem(withTitle: "Settings\u{2026}", + action: #selector(AppDelegate.showSettings(_:)), + keyEquivalent: ",") + + let updatesItem = appMenu.addItem( + withTitle: "Check for Updates\u{2026}", + action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), + keyEquivalent: "") + updatesItem.target = updaterController + + appMenu.addItem(NSMenuItem.separator()) + + appMenu.addItem(withTitle: "Quit Edmund", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) + + // File menu — NSDocument provides the standard actions + let fileMenuItem = NSMenuItem() + let fileMenu = NSMenu(title: "File") + + fileMenu.addItem(withTitle: "New", + action: #selector(NSDocumentController.newDocument(_:)), + keyEquivalent: "n") + + fileMenu.addItem(withTitle: "Open\u{2026}", + action: #selector(AppDelegate.openDocumentManually(_:)), + keyEquivalent: "o") + + // Recent documents submenu + let recentMenuItem = NSMenuItem(title: "Open Recent", action: nil, keyEquivalent: "") + let recentMenu = NSMenu(title: "Open Recent") + recentMenu.addItem(withTitle: "Clear Menu", + action: #selector(NSDocumentController.clearRecentDocuments(_:)), + keyEquivalent: "") + recentMenuItem.submenu = recentMenu + fileMenu.addItem(recentMenuItem) + + fileMenu.addItem(NSMenuItem.separator()) + + fileMenu.addItem(withTitle: "Save", + action: #selector(NSDocument.save(_:)), + keyEquivalent: "s") + + fileMenu.addItem(NSMenuItem.separator()) + + fileMenu.addItem(withTitle: "Rename\u{2026}", + action: #selector(Document.rename(_:)), + keyEquivalent: "") + + fileMenu.addItem(withTitle: "Move To\u{2026}", + action: #selector(Document.move(_:)), + keyEquivalent: "") + + fileMenu.addItem(NSMenuItem.separator()) + + fileMenu.addItem(withTitle: "Export as PDF\u{2026}", + action: #selector(Document.exportToPDF(_:)), + keyEquivalent: "") + + fileMenu.addItem(withTitle: "Print\u{2026}", + action: #selector(Document.printDocument(_:)), + keyEquivalent: "p") + + fileMenuItem.submenu = fileMenu + mainMenu.addItem(fileMenuItem) + + // Edit menu (required for Cmd+C/V/X/A/Z) + let editMenuItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + + editMenu.addItem(withTitle: "Undo", + action: #selector(EditorTextView.undo(_:)), + keyEquivalent: "z") + + let redoItem = editMenu.addItem(withTitle: "Redo", + action: #selector(EditorTextView.redo(_:)), + keyEquivalent: "z") + redoItem.keyEquivalentModifierMask = [.command, .shift] + + editMenu.addItem(NSMenuItem.separator()) + + editMenu.addItem(withTitle: "Cut", + action: #selector(NSText.cut(_:)), + keyEquivalent: "x") + + editMenu.addItem(withTitle: "Copy", + action: #selector(NSText.copy(_:)), + keyEquivalent: "c") + + editMenu.addItem(withTitle: "Paste", + action: #selector(NSText.paste(_:)), + keyEquivalent: "v") + + editMenu.addItem(withTitle: "Select All", + action: #selector(NSText.selectAll(_:)), + keyEquivalent: "a") + + editMenuItem.submenu = editMenu + mainMenu.addItem(editMenuItem) + + // Format menu — built from the declarative command registry. + mainMenu.addItem(FormatMenu.build()) + + // View menu — built in its own file (ViewMenu.swift). + mainMenu.addItem(ViewMenu.build()) + + NSApplication.shared.mainMenu = mainMenu + } +} + +// --- Launch ----------------------------------------------------------------- +let app = NSApplication.shared + +// Must be created before NSDocumentController.shared is first accessed. +let _ = DocumentController() + +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.activate(ignoringOtherApps: true) +app.run() diff --git a/Sources/edmd/Settings/AdvancedSettingsView.swift b/Sources/edmd/Settings/AdvancedSettingsView.swift new file mode 100644 index 0000000..eca1821 --- /dev/null +++ b/Sources/edmd/Settings/AdvancedSettingsView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import AppKit + +struct AdvancedSettingsView: View { + @AppStorage(AppSettings.Key.blockExternalImages) private var blockExternalImages = true + @AppStorage(AppSettings.Key.diagnosticLogging) private var diagnosticLogging = false + @AppStorage(AppSettings.Key.verboseEditorDiagnostics) private var verboseEditorDiagnostics = false + @AppStorage(AppSettings.Key.logRetention) private var logRetention = AppSettings.LogRetention.twoWeeks + // Crash-log sending is dormant until the receiving server exists — the toggle + // is hidden (commented out below) so it isn't offered with nowhere to send to. + // Uncomment this and the "Crash reports:" GridRow once the server is live. + // @AppStorage(AppSettings.Key.sendCrashLogs) private var sendCrashLogs = false + @State private var showingWarnings = false + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, verticalSpacing: 18) { + GridRow { + Text("Privacy & Security:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Block external images", isOn: $blockExternalImages) + .onChange(of: blockExternalImages) { refreshOpenReadViews() } + Text("For more information, refer to this [proposal](https://github.com/opencloud-eu/opencloud/issues/1145).") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 380, alignment: .leading) + .padding(.leading, 20) + + // TODO: Add a "Enable HTTP whitelist" toggle here + // with a short scrollable view of the whitelist that allows user addition + // with +/- signs at the bottom-right corner + // Implement later + } + } + + GridRow { + Divider().gridCellColumns(2) + } + + GridRow { + Text("Diagnostics:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Save diagnostic logs", isOn: $diagnosticLogging) + .onChange(of: diagnosticLogging) { AppSettings.applyLogging() } + HStack(spacing: 6) { + Text("Clear logs after:") + Picker("", selection: $logRetention) { + ForEach(AppSettings.LogRetention.allCases) { Text($0.label).tag($0) } + } + .labelsHidden() + .fixedSize() + .onChange(of: logRetention) { AppSettings.applyLogging() } + } + .disabled(!diagnosticLogging) + .padding(.leading, 20) + Text("Logs are kept locally at ~/.edmund/logs and will never leave that folder unless you move them. They are only useful if you want to improve your bug reports / GitHub issues.") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 380, alignment: .leading) + .padding(.leading, 20) + Toggle("Verbose editor tracing", isOn: $verboseEditorDiagnostics) + .onChange(of: verboseEditorDiagnostics) { AppSettings.applyLogging() } + .disabled(!diagnosticLogging) + .padding(.leading, 20) + Text("Records every keystroke, caret move, and sync — for reproducing tricky editor bugs (caret drift). Noisy; leave off unless asked.") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 360, alignment: .leading) + .padding(.leading, 40) +} + } + + // Dormant until the crash-report server exists (see note above and + // CrashReporter). The launch-time upload path and the `sendCrashLogs` + // setting stay in place but inert (default off); only this UI is hidden. + // GridRow { + // Text("Crash reports:") + // .gridColumnAlignment(.trailing) + // VStack(alignment: .leading, spacing: 6) { + // Toggle("Automatically send crash logs", isOn: $sendCrashLogs) + // Text("Crash logs are sent only to us and will be used and stored for crash fix purposes only.") + // .foregroundStyle(.secondary) + // .controlSize(.small) + // .fixedSize(horizontal: false, vertical: true) + // .frame(width: 380, alignment: .leading) + // .padding(.leading, 20) + // } + // } + + GridRow { + Text("Dialog warnings:") + .gridColumnAlignment(.trailing) + Button("Manage Warnings…") { showingWarnings = true } + } + } + .settingsPanePadding() + .sheet(isPresented: $showingWarnings) { + ManageWarningsView() + } + } + + /// Pushes the toggle to every open document's editor (Edit mode's inline + /// image overlay) and Read view, so the change takes effect immediately. + private func refreshOpenReadViews() { + for case let document as Document in NSDocumentController.shared.documents { + document.editor?.allowRemoteImages = !blockExternalImages + document.refreshReadView() + } + } +} + +/// The Manage Warnings sheet: per-warning suppression toggles. +private struct ManageWarningsView: View { + @AppStorage(AppSettings.Key.suppressInconsistentLineEndingWarning) + private var suppressLineEnding = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Suppress the following warnings:") + Toggle("Inconsistent line endings", isOn: $suppressLineEnding) + HStack { + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + } + .scenePadding() + .frame(width: 360) + } +} diff --git a/Sources/edmd/Settings/AntialiasingText.swift b/Sources/edmd/Settings/AntialiasingText.swift new file mode 100644 index 0000000..1c67e23 --- /dev/null +++ b/Sources/edmd/Settings/AntialiasingText.swift @@ -0,0 +1,88 @@ +import SwiftUI +import AppKit + +/// A label that previews a font by drawing its name in that font, with optional +/// antialiasing control — the bezeled font-display field used in the Appearance +/// settings (mirrors CotEditor's `AntialiasingText`). +struct AntialiasingText: NSViewRepresentable { + private var text: String + private var antialiasDisabled = false + private var font: NSFont? + + init(_ text: String) { + self.text = text + } + + func makeNSView(context: Context) -> NSTextField { + let nsView = AntialiasingTextField(string: text) + nsView.isEditable = false + nsView.isSelectable = false + nsView.alignment = .center + nsView.lineBreakMode = .byTruncatingMiddle + nsView.allowsExpansionToolTips = true + nsView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + // Pin a fixed, stable height so a 16pt preview fits with a little + // breathing room. (Deriving it from `frame.height` collapses the field — + // the frame is zero-height before Auto Layout has sized it.) + nsView.heightAnchor.constraint(equalToConstant: 24).isActive = true + + return nsView + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + nsView.stringValue = text + nsView.font = font + (nsView as? AntialiasingTextField)?.antialiasDisabled = antialiasDisabled + } + + /// Sets whether antialiasing is disabled when drawing the text. + func antialiasDisabled(_ disabled: Bool = true) -> Self { + var view = self + view.antialiasDisabled = disabled + return view + } + + /// Sets the font to preview the text in. + func font(nsFont font: NSFont?) -> Self { + var view = self + view.font = font + return view + } +} + +private final class AntialiasingTextField: NSTextField { + var antialiasDisabled = false { + didSet { needsDisplay = true } + } + + override static var cellClass: AnyClass? { + get { CenteringTextFieldCell.self } + set { _ = newValue } + } + + override func draw(_ dirtyRect: NSRect) { + if antialiasDisabled { + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.shouldAntialias = false + } + super.draw(dirtyRect) + if antialiasDisabled { + NSGraphicsContext.restoreGraphicsState() + } + } +} + +private final class CenteringTextFieldCell: NSTextFieldCell { + override func titleRect(forBounds rect: NSRect) -> NSRect { + var titleRect = super.titleRect(forBounds: rect) + let titleSize = attributedStringValue.size() + titleRect.origin.y = (rect.minY + (rect.height - titleSize.height) / 2).rounded(.up) + titleRect.size.height = rect.height - titleRect.origin.y + return titleRect + } + + override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { + attributedStringValue.draw(in: titleRect(forBounds: cellFrame)) + } +} diff --git a/Sources/edmd/Settings/AppSettings.swift b/Sources/edmd/Settings/AppSettings.swift new file mode 100644 index 0000000..df473ba --- /dev/null +++ b/Sources/edmd/Settings/AppSettings.swift @@ -0,0 +1,324 @@ +// AppSettings — UserDefaults-backed model for every Settings value. +// The rest of the app reads these accessors; the SwiftUI panes bind to the +// same keys via @AppStorage. + +import AppKit +import EdmundCore + +enum AppSettings { + enum StartupAction: String, CaseIterable, Identifiable { + case createNewDocument + case doNothing + var id: Self { self } + var label: String { + switch self { + case .createNewDocument: return "Create New Document" + case .doNothing: return "Do Nothing" + } + } + } + + enum ConflictResolution: String, CaseIterable, Identifiable { + case keepCurrent + case ask + case updateToModified + var id: Self { self } + var label: String { + switch self { + case .keepCurrent: return "Keep Edmund’s edition" + case .ask: return "Ask how to resolve" + case .updateToModified: return "Update to modified edition" + } + } + } + + enum AppearanceMode: String, CaseIterable, Identifiable { + case matchSystem + case light + case dark + var id: Self { self } + var label: String { + switch self { + case .matchSystem: return "Match System" + case .light: return "Light" + case .dark: return "Dark" + } + } + /// Display order in the Appearance pane (left to right). + static let displayOrder: [AppearanceMode] = [.light, .dark, .matchSystem] + } + + /// How long diagnostic logs are kept before being pruned on launch. + enum LogRetention: String, CaseIterable, Identifiable { + case oneDay, twoDays, oneWeek, twoWeeks, thirtyDays, never + var id: Self { self } + var label: String { + switch self { + case .oneDay: return "1 day" + case .twoDays: return "2 days" + case .oneWeek: return "1 week" + case .twoWeeks: return "2 weeks" + case .thirtyDays: return "30 days" + case .never: return "Never" + } + } + /// The retention window in seconds; `nil` means keep forever. + var timeInterval: TimeInterval? { + let day: TimeInterval = 24 * 60 * 60 + switch self { + case .oneDay: return day + case .twoDays: return 2 * day + case .oneWeek: return 7 * day + case .twoWeeks: return 14 * day + case .thirtyDays: return 30 * day + case .never: return nil + } + } + } + + enum Key { + static let reopenWindows = "settings.general.reopenWindows" + // Must match Sparkle's own default key exactly — Sparkle reads/writes this string. + static let automaticallyChecksForUpdates = "SUAutomaticallyChecksForUpdates" + static let startupAction = "settings.general.startupAction" + static let autoSaveWithVersions = "settings.general.autoSaveWithVersions" + static let conflictResolution = "settings.general.conflictResolution" + static let appearanceMode = "settings.appearance.mode" + static let maxContentWidthCm = "settings.appearance.maxContentWidthCm" + // "cm" / "in" override the locale default for the content-width control. + static let contentWidthUnit = "settings.appearance.contentWidthUnit" + static let suppressInconsistentLineEndingWarning = "settings.general.suppressInconsistentLineEndingWarning" + static let diagnosticLogging = "settings.general.diagnosticLogging" + static let verboseEditorDiagnostics = "settings.advanced.verboseEditorDiagnostics" + static let blockExternalImages = "settings.advanced.blockExternalImages" + static let logRetention = "settings.general.logRetention" + static let renderBlankLinesAsBreaks = "settings.reading.renderBlankLinesAsBreaks" + static let sourceMode = "settings.view.sourceMode" + static let sendCrashLogs = "settings.advanced.sendCrashLogs" + static let sentCrashReports = "settings.advanced.sentCrashReports" + static let lastWindowWidth = "settings.window.lastWidth" + static let lastWindowHeight = "settings.window.lastHeight" + } + + /// Maximum text-column width in centimetres. Wider windows center the + /// column at this physical width; narrower windows fill edge-to-edge. + /// Default: a comfortable 12 cm / 5 in reading column. + static var maxContentWidthCm: Double { + get { + guard UserDefaults.standard.object(forKey: Key.maxContentWidthCm) != nil else { + return defaultMaxContentWidthCm + } + return UserDefaults.standard.double(forKey: Key.maxContentWidthCm) + } + set { UserDefaults.standard.set(newValue, forKey: Key.maxContentWidthCm) } + } + + /// Out-of-the-box reading column: 5 in for US locales, 12 cm elsewhere. + /// This is also the slider's magnetic snap point. + static var defaultMaxContentWidthCm: Double { + Locale.current.measurementSystem == .us ? 5.0 * 2.54 : 12.0 + } + + /// Full frame size of the last document window, to reopen new windows at the + /// same dimensions (applied via setFrame). Returns nil when nothing is saved. + /// The floor only rejects garbage/zero values — every real window size, + /// including ones smaller than the default, is remembered. + static var lastWindowSize: NSSize? { + get { + let w = UserDefaults.standard.double(forKey: Key.lastWindowWidth) + let h = UserDefaults.standard.double(forKey: Key.lastWindowHeight) + guard w >= 100, h >= 100 else { return nil } + return NSSize(width: w, height: h) + } + set { + guard let s = newValue else { return } + UserDefaults.standard.set(Double(s.width), forKey: Key.lastWindowWidth) + UserDefaults.standard.set(Double(s.height), forKey: Key.lastWindowHeight) + } + } + + static var reopenWindows: Bool { + get { UserDefaults.standard.bool(forKey: Key.reopenWindows) } + set { UserDefaults.standard.set(newValue, forKey: Key.reopenWindows) } + } + + /// Source mode: an alternate form of Edit mode that shows the raw markdown. + /// When on, the editing half of the view-mode toggle is Source instead of + /// Edit (so the toggle flips Source ↔ Read). Defaults off. + static var sourceMode: Bool { + get { UserDefaults.standard.bool(forKey: Key.sourceMode) } + set { UserDefaults.standard.set(newValue, forKey: Key.sourceMode) } + } + + /// Read mode: render runs of blank lines as proportional vertical space + /// (preserving the author's spacing). Defaults on. The toggle UI lives in a + /// future Reading-settings tab; the value is already honored here. + static var renderBlankLinesAsBreaks: Bool { + get { + guard UserDefaults.standard.object(forKey: Key.renderBlankLinesAsBreaks) != nil else { + return true + } + return UserDefaults.standard.bool(forKey: Key.renderBlankLinesAsBreaks) + } + set { UserDefaults.standard.set(newValue, forKey: Key.renderBlankLinesAsBreaks) } + } + + static var startupAction: StartupAction { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.startupAction), + let action = StartupAction(rawValue: raw) else { + return .createNewDocument + } + return action + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.startupAction) } + } + + static var autoSaveWithVersions: Bool { + get { + guard UserDefaults.standard.object(forKey: Key.autoSaveWithVersions) != nil else { + return true + } + return UserDefaults.standard.bool(forKey: Key.autoSaveWithVersions) + } + set { UserDefaults.standard.set(newValue, forKey: Key.autoSaveWithVersions) } + } + + static var conflictResolution: ConflictResolution { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.conflictResolution), + let resolution = ConflictResolution(rawValue: raw) else { + return .ask + } + return resolution + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.conflictResolution) } + } + + static var appearanceMode: AppearanceMode { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.appearanceMode), + let mode = AppearanceMode(rawValue: raw) else { + return .matchSystem + } + return mode + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.appearanceMode) } + } + + static var suppressInconsistentLineEndingWarning: Bool { + get { UserDefaults.standard.bool(forKey: Key.suppressInconsistentLineEndingWarning) } + set { UserDefaults.standard.set(newValue, forKey: Key.suppressInconsistentLineEndingWarning) } + } + + /// Whether diagnostic logging is on. Defaults to off; the user can opt in. + static var diagnosticLogging: Bool { + get { UserDefaults.standard.bool(forKey: Key.diagnosticLogging) } + set { UserDefaults.standard.set(newValue, forKey: Key.diagnosticLogging) } + } + + /// Verbose editor tracing: high-volume per-edit / per-caret-move trace lines + /// for diagnosing live-NSTextView / TextKit 2 editor bugs (caret drift, sync + /// desyncs) that can't be reproduced headlessly. Off by default — turned on + /// only when capturing a reproduction. Requires diagnostic logging to be on. + static var verboseEditorDiagnostics: Bool { + get { UserDefaults.standard.bool(forKey: Key.verboseEditorDiagnostics) } + set { UserDefaults.standard.set(newValue, forKey: Key.verboseEditorDiagnostics) } + } + + /// Whether Read mode / export blocks remote (`http`/`https`) image loads. + /// Defaults on: no surprise network requests until the user opts out. + static var blockExternalImages: Bool { + get { + guard UserDefaults.standard.object(forKey: Key.blockExternalImages) != nil else { + return true + } + return UserDefaults.standard.bool(forKey: Key.blockExternalImages) + } + set { UserDefaults.standard.set(newValue, forKey: Key.blockExternalImages) } + } + + /// Whether to auto-send crash reports on launch. Opt-in: defaults off, since + /// it sends data off-device. (UI currently commented out — see + /// AdvancedSettingsView — until the receiving server exists.) + static var sendCrashLogs: Bool { + get { UserDefaults.standard.bool(forKey: Key.sendCrashLogs) } + set { UserDefaults.standard.set(newValue, forKey: Key.sendCrashLogs) } + } + + /// Filenames of crash reports already uploaded, so we don't resend them. + /// Bounded on write by dropping entries whose `.ips` file no longer exists. + static var sentCrashReports: Set { + get { Set(UserDefaults.standard.stringArray(forKey: Key.sentCrashReports) ?? []) } + set { + let onDisk = (try? FileManager.default.contentsOfDirectory( + atPath: CrashReporter.diagnosticReportsDirectory.path)).map(Set.init) ?? [] + let pruned = onDisk.isEmpty ? newValue : newValue.intersection(onDisk) + UserDefaults.standard.set(Array(pruned), forKey: Key.sentCrashReports) + } + } + + static var logRetention: LogRetention { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.logRetention), + let value = LogRetention(rawValue: raw) else { + return .twoWeeks + } + return value + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.logRetention) } + } + + /// Where diagnostic logs live: `~/.edmund/logs`. + static var logDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".edmund/logs", isDirectory: true) + } + + /// Pushes the current logging settings into the `Log` facility. Called at + /// launch and whenever the toggle or retention changes. + static func applyLogging() { + Log.configure(enabled: diagnosticLogging, + directory: logDirectory, + retention: logRetention.timeInterval) + Log.setVerbose(verboseEditorDiagnostics) + } + + @MainActor static func applyAppearance() { + switch appearanceMode { + case .matchSystem: + NSApp.appearance = nil + case .light: + NSApp.appearance = NSAppearance(named: .aqua) + case .dark: + NSApp.appearance = NSAppearance(named: .darkAqua) + } + } +} + +// MARK: - Screen physical-unit helpers + +extension NSScreen { + /// Physical pixels-per-inch from the display's actual diagonal/width size + /// (via Core Graphics — not the nominal 72 pt/in). Falls back to 109 PPI + /// (the typical value for a 27-inch 5K iMac) when the display ID can't be read. + var physicalPPI: CGFloat { + guard let n = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return 109 + } + let mm = CGDisplayScreenSize(CGDirectDisplayID(n.uint32Value)) + guard mm.width > 0 else { return 109 } + // frame.width is in points (not pixels); mm.width is physical mm. + return frame.width / (mm.width / 25.4) + } + + /// Convert a physical centimetre value to AppKit points on this display. + func cmToPoints(_ cm: Double) -> CGFloat { + CGFloat(cm) / 2.54 * physicalPPI + } + + /// The display's full physical width in centimetres. + var physicalWidthCm: Double { + Double(frame.width / physicalPPI) * 2.54 + } +} diff --git a/Sources/edmd/Settings/AppearanceSettingsView.swift b/Sources/edmd/Settings/AppearanceSettingsView.swift new file mode 100644 index 0000000..42ec377 --- /dev/null +++ b/Sources/edmd/Settings/AppearanceSettingsView.swift @@ -0,0 +1,237 @@ +// The Appearance settings pane: appearance mode, fonts, and line height. +// The app accent comes from the AccentColor asset (see Resources/Assets.xcassets), +// so there is no in-app accent picker — native controls follow the asset / system. + +import SwiftUI +import AppKit +import EdmundCore + +struct AppearanceSettingsView: View { + @ObservedObject var fonts: FontSettings + @AppStorage(AppSettings.Key.appearanceMode) private var appearanceMode = AppSettings.AppearanceMode.matchSystem + @AppStorage(AppSettings.Key.maxContentWidthCm) private var maxContentWidthCm = AppSettings.defaultMaxContentWidthCm + /// "" follows the locale; "cm"/"in" override it (toggled via the unit button). + @AppStorage(AppSettings.Key.contentWidthUnit) private var unitOverride = "" + + // MARK: - Unit helpers + + /// Imperial when the user picked "in", metric when "cm", else the locale default. + private var usesImperial: Bool { + switch unitOverride { + case "in": return true + case "cm": return false + default: return Locale.current.measurementSystem == .us + } + } + private func toggleUnit() { unitOverride = usesImperial ? "cm" : "in" } + + private var unitLabel: String { usesImperial ? "in" : "cm" } + /// Stepper increment in display units (0.5 cm ≈ 0.25 in). + private var stepSize: Double { usesImperial ? 0.25 : 0.5 } + + /// Lower bound ≈ 3 inches (7.62 cm); upper bound is the full physical width + /// of the main display, so the column can be capped anywhere up to the + /// screen edge. + private var minCm: Double { 7.62 } + private var maxCm: Double { NSScreen.main?.physicalWidthCm ?? 50 } + private var displayRange: ClosedRange { + usesImperial ? (minCm / 2.54)...(maxCm / 2.54) : minCm...maxCm + } + + /// Magnetic snap target in display units: 5 in / 12 cm (the default width). + private var snapDisplayValue: Double { usesImperial ? 5.0 : 12.0 } + + /// Two-way binding between stored cm and the display unit. + private var displayValueBinding: Binding { + Binding( + get: { usesImperial ? maxContentWidthCm / 2.54 : maxContentWidthCm }, + set: { maxContentWidthCm = usesImperial ? $0 * 2.54 : $0 } + ) + } + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, verticalSpacing: 12) { + GridRow { + Text("Appearance:") + .gridColumnAlignment(.trailing) + Picker("", selection: $appearanceMode) { + ForEach(AppSettings.AppearanceMode.displayOrder) { Text($0.label).tag($0) } + } + .pickerStyle(.radioGroup) + .horizontalRadioGroupLayout() + .labelsHidden() + .onChange(of: appearanceMode) { AppSettings.applyAppearance() } + } + + GridRow { + Text("Max content width:") + .gridColumnAlignment(.trailing) + HStack(spacing: 8) { + ContentWidthSlider( + cmValue: $maxContentWidthCm, + usesImperial: usesImperial, + displayRange: displayRange, + snapDisplayValue: snapDisplayValue + ) + .frame(width: 200, height: 20) + + // Field width is sized so its stepper's chevrons line up + // vertically with the font rows' steppers (slider 200 + two + // 8-pt gaps + field == 240, the font rows' label width). + TextField("", value: displayValueBinding, + format: .number.precision(.fractionLength(1))) + .multilineTextAlignment(.trailing) + .frame(width: 32) + Stepper("", value: displayValueBinding, + in: displayRange, step: stepSize) + .labelsHidden() + // Clickable unit toggle styled exactly like a plain label — + // .plain strips all button chrome so only the text shows. + Button(action: toggleUnit) { Text(unitLabel) } + .buttonStyle(.plain) + .help("Switch between centimetres and inches") + } + .onChange(of: maxContentWidthCm) { applyContentWidthToOpenDocuments() } + } + + GridRow { + Divider().gridCellColumns(2) + } + + GridRow { + Text("Standard font:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + fontRow(summary: fonts.standardSummary, + font: fonts.standardFont, + antialias: fonts.antialias, + size: Binding(get: { Double(fonts.standardFont.pointSize) }, + set: { fonts.setStandardSize(CGFloat($0)) }), + select: fonts.selectStandardFont) + HStack(spacing: 16) { + Toggle("Antialias", isOn: $fonts.antialias) + Toggle("Ligatures", isOn: $fonts.standardLigatures) + } + } + } + + GridRow { + Text("Monospaced font:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + fontRow(summary: fonts.monospaceSummary, + font: fonts.monospaceFont, + antialias: fonts.antialias, + size: Binding(get: { Double(fonts.monospaceFont.pointSize) }, + set: { fonts.setMonospaceSize(CGFloat($0)) }), + select: fonts.selectMonospaceFont) + HStack(spacing: 16) { + Toggle("Antialias", isOn: $fonts.antialias) + Toggle("Ligatures", isOn: $fonts.monospaceLigatures) + } + } + } + + GridRow { + Text("Line height:") + .gridColumnAlignment(.trailing) + HStack(spacing: 6) { + let lineHeight = Binding(get: { Double(fonts.lineHeight) }, + set: { fonts.setLineHeight(CGFloat($0)) }) + TextField("", value: lineHeight, format: .number.precision(.fractionLength(1))) + .multilineTextAlignment(.trailing) + .frame(width: 56) + Stepper("", value: lineHeight, in: 1...3, step: 0.1) + .labelsHidden() + Text("times") + } + } + } + .settingsPanePadding() + } + + /// Pushes a content-width change to every open editor live, converting cm + /// to points using each editor's window screen PPI (or main screen as fallback). + private func applyContentWidthToOpenDocuments() { + for case let document as Document in NSDocumentController.shared.documents { + let screen = document.editor?.window?.screen ?? NSScreen.main + guard let screen else { continue } + document.editor?.maxContentWidthPoints = screen.cmToPoints(maxContentWidthCm) + document.refreshReadView() + } + } + + @ViewBuilder + private func fontRow(summary: String, font: NSFont, antialias: Bool, + size: Binding, select: @escaping () -> Void) -> some View { + HStack(spacing: 8) { + AntialiasingText(summary) + .antialiasDisabled(!antialias) + .font(nsFont: font) + .frame(width: 240) + Stepper("", value: size, in: 8...72, step: 1) + .labelsHidden() + Button("Select…", action: select) + .fixedSize() + } + } +} + +// MARK: - Continuous NSSlider + +/// Wraps NSSlider so the content-width control can use cm/in units and a +/// magnetic snap onto the default value. +private struct ContentWidthSlider: NSViewRepresentable { + @Binding var cmValue: Double + let usesImperial: Bool + let displayRange: ClosedRange + /// Magnetic snap target in display units (the default width); dragging + /// within `snapTolerance` of it locks onto it exactly. + let snapDisplayValue: Double + private var snapTolerance: Double { usesImperial ? 0.15 : 0.4 } + + func cmToDisplay(_ cm: Double) -> Double { usesImperial ? cm / 2.54 : cm } + func displayToCm(_ d: Double) -> Double { usesImperial ? d * 2.54 : d } + + /// Snap `display` onto the default value when it lands close enough. + func snapped(_ display: Double) -> Double { + abs(display - snapDisplayValue) < snapTolerance ? snapDisplayValue : display + } + + private func clamp(_ v: Double) -> Double { + max(displayRange.lowerBound, min(displayRange.upperBound, v)) + } + + func makeNSView(context: Context) -> NSSlider { + NSSlider(value: clamp(cmToDisplay(cmValue)), + minValue: displayRange.lowerBound, + maxValue: displayRange.upperBound, + target: context.coordinator, + action: #selector(Coordinator.sliderChanged(_:))) + } + + func updateNSView(_ slider: NSSlider, context: Context) { + context.coordinator.parent = self + // Range changes when the unit is toggled (cm ↔ in). + slider.minValue = displayRange.lowerBound + slider.maxValue = displayRange.upperBound + let display = clamp(cmToDisplay(cmValue)) + if abs(slider.doubleValue - display) > 0.001 { + slider.doubleValue = display + } + } + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + @MainActor + final class Coordinator: NSObject { + var parent: ContentWidthSlider + init(parent: ContentWidthSlider) { self.parent = parent } + + @objc func sliderChanged(_ sender: NSSlider) { + let snapped = parent.snapped(sender.doubleValue) + if snapped != sender.doubleValue { sender.doubleValue = snapped } + parent.cmValue = parent.displayToCm(snapped) + } + } +} diff --git a/Sources/edmd/Settings/FontSettings.swift b/Sources/edmd/Settings/FontSettings.swift new file mode 100644 index 0000000..917ee9c --- /dev/null +++ b/Sources/edmd/Settings/FontSettings.swift @@ -0,0 +1,129 @@ +// FontSettings — owns the editor fonts, line height, and accent hex, bridges the +// AppKit font panel, and applies changes to every open document. + +import SwiftUI +import AppKit +import EdmundCore + +// MARK: - Font / theme state + +/// Owns the editor's standard/monospace fonts and line height, bridges the +/// AppKit font panel, and applies font/line-height changes to open documents +/// (the genuinely AppKit-bound part of the Appearance pane). +@MainActor +final class FontSettings: NSObject, ObservableObject { + @Published var standardFont: NSFont + @Published var monospaceFont: NSFont + @Published var lineHeight: CGFloat + @Published var standardLigatures: Bool { didSet { applyLigatures() } } + @Published var monospaceLigatures: Bool { didSet { applyLigatures() } } + /// A single editor-wide antialias setting (both font toggles share it). + @Published var antialias: Bool { didSet { applyAntialias() } } + + private var theme: EditorTheme + private enum Target { case standard, monospace } + private var target: Target = .standard + + override init() { + let theme = EditorTheme.load() + self.theme = theme + standardFont = theme.bodyFont + monospaceFont = theme.monospaceFont() + standardLigatures = theme.standardLigatures + monospaceLigatures = theme.monospaceLigatures + antialias = theme.antialias + let size = theme.bodyFont.pointSize + lineHeight = size > 0 ? max(1, min(3, (size + theme.lineSpacing) / size)) : 1 + super.init() + } + + var standardSummary: String { Self.summary(standardFont) } + var monospaceSummary: String { Self.summary(monospaceFont) } + + func selectStandardFont() { beginFontPanel(.standard, current: standardFont) } + func selectMonospaceFont() { beginFontPanel(.monospace, current: monospaceFont) } + + func setStandardSize(_ size: CGFloat) { + standardFont = NSFont(descriptor: standardFont.fontDescriptor, size: size) ?? standardFont + applyTheme() + } + + func setMonospaceSize(_ size: CGFloat) { + monospaceFont = NSFont(descriptor: monospaceFont.fontDescriptor, size: size) ?? monospaceFont + applyMonospace() + } + + func setLineHeight(_ value: CGFloat) { + lineHeight = max(1, min(3, value)) + applyTheme() + } + + @objc func changeFont(_ sender: NSFontManager) { + switch target { + case .standard: + standardFont = sender.convert(standardFont) + applyTheme() + case .monospace: + monospaceFont = sender.convert(monospaceFont) + applyMonospace() + } + } + + private func beginFontPanel(_ target: Target, current: NSFont) { + self.target = target + let manager = NSFontManager.shared + manager.target = self + manager.action = #selector(changeFont(_:)) + manager.setSelectedFont(current, isMultiple: false) + manager.orderFrontFontPanel(nil) + } + + private func applyMonospace() { + var updated = theme + updated.monospaceFontName = monospaceFont.fontName + updated.monospaceFontSize = monospaceFont.pointSize + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyLigatures() { + var updated = theme + updated.standardLigatures = standardLigatures + updated.monospaceLigatures = monospaceLigatures + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyAntialias() { + var updated = theme + updated.antialias = antialias + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyTheme() { + var updated = theme + updated.fontName = standardFont.fontName + updated.fontSize = standardFont.pointSize + updated.lineSpacing = max(0, (lineHeight - 1) * standardFont.pointSize) + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyToDocuments(_ theme: EditorTheme) { + for case let document as Document in NSDocumentController.shared.documents { + document.editor?.applyTheme(theme) + // Reflect the theme change live in an open Read view too. + document.refreshReadView() + } + } + + private static func summary(_ font: NSFont) -> String { + let name = font.displayName ?? font.familyName ?? font.fontName + return "\(name) \(Int(round(font.pointSize)))" + } +} diff --git a/Sources/edmd/Settings/GeneralSettingsView.swift b/Sources/edmd/Settings/GeneralSettingsView.swift new file mode 100644 index 0000000..fad33dd --- /dev/null +++ b/Sources/edmd/Settings/GeneralSettingsView.swift @@ -0,0 +1,77 @@ +// The General settings pane (startup, document saving, conflict resolution). + +import SwiftUI +import AppKit + +// MARK: - General + +struct GeneralSettingsView: View { + @AppStorage(AppSettings.Key.automaticallyChecksForUpdates) + private var autoCheckUpdates = true + @AppStorage(AppSettings.Key.reopenWindows) private var reopenWindows = false + @AppStorage(AppSettings.Key.startupAction) private var startupAction = AppSettings.StartupAction.createNewDocument + @AppStorage(AppSettings.Key.autoSaveWithVersions) private var autoSave = true + @AppStorage(AppSettings.Key.conflictResolution) private var conflict = AppSettings.ConflictResolution.ask + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, verticalSpacing: 18) { + GridRow { + Text("Software updates:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Automatically check for updates", isOn: $autoCheckUpdates) + } + } + + GridRow { + Divider().gridCellColumns(2) + } + + GridRow { + Text("On startup:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Reopen windows from last session", isOn: $reopenWindows) + Text("When nothing else is open:") + Picker("", selection: $startupAction) { + ForEach(AppSettings.StartupAction.allCases) { Text($0.label).tag($0) } + } + .labelsHidden() + .fixedSize() + .padding(.leading, 20) + } + } + + GridRow { + Text("Document save:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 4) { + Toggle("Enable Auto Save with Versions", isOn: $autoSave) + Text("A system feature that automatically overwrites your files while editing. Even if turned off, Edmund creates a backup in case it unexpectedly quits.") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 380, alignment: .leading) + .padding(.leading, 20) + } + } + + GridRow { + Text("When document is changed by another application:") + .gridCellColumns(2) + } + .padding(.bottom, -8) + + GridRow { + Color.clear.frame(width: 1, height: 1) + Picker("", selection: $conflict) { + ForEach(AppSettings.ConflictResolution.allCases) { Text($0.label).tag($0) } + } + .pickerStyle(.radioGroup) + .labelsHidden() + } + + } + .settingsPanePadding() + } +} diff --git a/Sources/edmd/Settings/SettingsControls.swift b/Sources/edmd/Settings/SettingsControls.swift new file mode 100644 index 0000000..5896f28 --- /dev/null +++ b/Sources/edmd/Settings/SettingsControls.swift @@ -0,0 +1,15 @@ +// Shared Settings view helpers. + +import SwiftUI + +extension View { + /// Consistent pane padding: CotEditor-style breathing room (scene padding at + /// the top, a little more on the sides and bottom). + func settingsPanePadding() -> some View { + self.padding(EdgeInsets(top: 20, leading: 28, bottom: 28, trailing: 28)) + .frame(width: 600, alignment: .leading) + // Don't auto-focus (and draw a focus ring around) the first control + // when a pane opens — Settings has no use for keyboard-focus rings. + .focusEffectDisabled() + } +} diff --git a/Sources/edmd/Settings/SettingsWindowController.swift b/Sources/edmd/Settings/SettingsWindowController.swift new file mode 100644 index 0000000..9c5b68f --- /dev/null +++ b/Sources/edmd/Settings/SettingsWindowController.swift @@ -0,0 +1,92 @@ +// SettingsWindowController — the Settings window. +// +// Built on NSTabViewController (`.toolbar` style), which provides the native +// preference toolbar, pane selection, and per-pane window sizing. Each pane is a +// SwiftUI view hosted in an NSHostingController. Pane switching mirrors +// CotEditor: hide the content, animate the window resize, then reveal it. + +import AppKit +import SwiftUI + +final class SettingsWindowController: NSWindowController { + convenience init() { + let tabController = SettingsTabViewController() + let window = NSWindow(contentViewController: tabController) + window.styleMask = [.titled, .closable] + window.title = "Settings" + window.toolbarStyle = .preference + window.center() + window.isReleasedWhenClosed = false + self.init(window: window) + } +} + +/// Hosts the Settings panes as toolbar tabs and animates the window resize on +/// each switch. +final class SettingsTabViewController: NSTabViewController { + /// Owns the editor font / line-height state and the font-panel plumbing. + private let fonts = FontSettings() + + override func viewDidLoad() { + super.viewDidLoad() + tabStyle = .toolbar + // The window title follows this controller's title; keep it "Settings" + // (NSTabViewController otherwise blanks it to the selected pane's nil title + // on each switch, showing "Untitled"). + title = "Settings" + + addPane(GeneralSettingsView(), label: "General", symbol: "gearshape") + addPane(AppearanceSettingsView(fonts: fonts), label: "Appearance", symbol: "eyeglasses") + addPane(AdvancedSettingsView(), label: "Advanced", symbol: "gearshape.2") + } + + private func addPane(_ view: some View, label: String, symbol: String) { + let hosting = NSHostingController(rootView: view) + // Report a definite size so the tab controller can size the window to it. + hosting.sizingOptions = [.preferredContentSize] + let item = NSTabViewItem(viewController: hosting) + item.label = label + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + addTabViewItem(item) + } + + override func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) { + super.tabView(tabView, didSelect: tabViewItem) + title = "Settings" // re-assert after super resets it to the pane's nil title + guard let tabViewItem else { return } + switchPane(to: tabViewItem) + } + + /// Resize the window to fit the newly selected pane, keeping the top-left + /// fixed. The content is hidden during the resize so nothing stretches + /// mid-animation, then revealed once the window is at its final size. + private func switchPane(to tabViewItem: NSTabViewItem) { + guard let window = view.window, + let contentSize = tabViewItem.view?.frame.size else { return } + + let frame = window.frameRect(forContentSize: contentSize) + + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + window.setFrame(frame, display: true) + return + } + + view.isHidden = true + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = true + context.duration = window.animationResizeTime(frame) + window.setFrame(frame, display: true) + } completionHandler: { [weak self] in + self?.view.isHidden = false + } + } +} + +private extension NSWindow { + /// The window frame for the given content size, keeping the top-left fixed. + func frameRect(forContentSize contentSize: NSSize) -> NSRect { + let frameSize = frameRect(forContentRect: NSRect(origin: .zero, size: contentSize)).size + return NSRect(origin: frame.origin, size: frameSize) + .offsetBy(dx: 0, dy: frame.height - frameSize.height) + } +} diff --git a/Sources/edmd/Views/StatusBarView.swift b/Sources/edmd/Views/StatusBarView.swift new file mode 100644 index 0000000..878aaff --- /dev/null +++ b/Sources/edmd/Views/StatusBarView.swift @@ -0,0 +1,215 @@ +import AppKit +import EdmundCore + +// MARK: - Status Bar View + +/// Floating status bar. Hidden by default and revealed when the pointer enters +/// its strip (or pinned visible via the context menu). It draws everything +/// itself — a vertical gradient from the editor background fading to transparent, +/// the enabled document-count fields on the left, and the line ending on the +/// right — so there are no subviews to truncate the text. +final class StatusBarView: NSView { + + static let labelFont = NSFont.systemFont(ofSize: 11) + + private var prefs = StatusBarPrefs.load() + + // Latest metrics pushed from the document. + private var words = 0 + private var characters = 0 + private var location = 0 + private var lineNumber = 1 + private var lineEnding = "LF" + + private var trackingArea: NSTrackingArea? + private var isHovering = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + alphaValue = prefs.autoHide ? 0 : 1 + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - Data + + func setMetrics(words: Int, characters: Int, location: Int, line: Int, lineEnding: String) { + self.words = words + self.characters = characters + self.location = location + self.lineNumber = line + self.lineEnding = lineEnding + needsDisplay = true + } + + // MARK: - Visibility + + private var shouldBeVisible: Bool { !prefs.autoHide || isHovering } + + private func refreshVisibility(animated: Bool) { + let target: CGFloat = shouldBeVisible ? 1 : 0 + guard abs(alphaValue - target) > 0.001 else { return } + if animated { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.3 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animator().alphaValue = target + } + } else { + alphaValue = target + } + } + + // MARK: - Hover Tracking + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let ta = trackingArea { removeTrackingArea(ta) } + let ta = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self, userInfo: nil) + addTrackingArea(ta) + trackingArea = ta + } + + override func mouseEntered(with event: NSEvent) { + isHovering = true + refreshVisibility(animated: true) + } + + override func mouseExited(with event: NSEvent) { + isHovering = false + refreshVisibility(animated: true) + } + + /// When the bar is hidden, let clicks fall through to the text view beneath. + override func hitTest(_ point: NSPoint) -> NSView? { + if !shouldBeVisible && alphaValue < 0.01 { return nil } + return super.hitTest(point) + } + + // MARK: - Context Menu (double- or right-click) + + override func rightMouseDown(with event: NSEvent) { + NSMenu.popUpContextMenu(buildMenu(), with: event, for: self) + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + NSMenu.popUpContextMenu(buildMenu(), with: event, for: self) + } else { + super.mouseDown(with: event) + } + } + + private func buildMenu() -> NSMenu { + let menu = NSMenu() + + let autoHide = NSMenuItem(title: "Auto-hide", + action: #selector(toggleAutoHide), keyEquivalent: "") + autoHide.target = self + autoHide.state = prefs.autoHide ? .on : .off + menu.addItem(autoHide) + + menu.addItem(.separator()) + let header = NSMenuItem(title: "Show Fields", action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + + let fields: [(title: String, key: String, on: Bool)] = [ + ("Words", "words", prefs.showWords), + ("Characters", "characters", prefs.showCharacters), + ("Location", "location", prefs.showLocation), + ("Line", "line", prefs.showLine), + ("Line Ending", "lineEnding", prefs.showLineEnding), + ] + for field in fields { + let item = NSMenuItem(title: field.title, + action: #selector(toggleField(_:)), keyEquivalent: "") + item.target = self + item.representedObject = field.key + item.state = field.on ? .on : .off + item.indentationLevel = 1 + menu.addItem(item) + } + return menu + } + + @objc private func toggleAutoHide() { + prefs.autoHide.toggle() + prefs.save() + refreshVisibility(animated: true) + } + + @objc private func toggleField(_ sender: NSMenuItem) { + switch sender.representedObject as? String { + case "words": prefs.showWords.toggle() + case "characters": prefs.showCharacters.toggle() + case "location": prefs.showLocation.toggle() + case "line": prefs.showLine.toggle() + case "lineEnding": prefs.showLineEnding.toggle() + default: return + } + prefs.save() + needsDisplay = true + } + + // MARK: - Drawing + + private var labelAttrs: [NSAttributedString.Key: Any] { + [.font: Self.labelFont, .foregroundColor: NSColor.secondaryLabelColor] + } + private var valueAttrs: [NSAttributedString.Key: Any] { + [.font: Self.labelFont, .foregroundColor: NSColor.labelColor] + } + + override func draw(_ dirtyRect: NSRect) { + // Vertical gradient: the editor background, fully opaque at the bottom and + // softening only slightly toward the top so the bar reads clearly even + // when overlaid on text. textBackgroundColor is semantic (light/dark). + let base = NSColor.textBackgroundColor + if let gradient = NSGradient(starting: base, ending: base.withAlphaComponent(0.85)) { + gradient.draw(in: bounds, angle: 90) // 90° = bottom → top + } + + let hMargin: CGFloat = 12 + + // Left: enabled count fields ("Label: value", label dimmed, value bold-ish). + let info = NSMutableAttributedString() + func field(_ name: String, _ value: String) { + if info.length > 0 { + info.append(NSAttributedString(string: " ", attributes: labelAttrs)) + } + info.append(NSAttributedString(string: "\(name): ", attributes: labelAttrs)) + info.append(NSAttributedString(string: value, attributes: valueAttrs)) + } + if prefs.showWords { field("Words", "\(words)") } + if prefs.showCharacters { field("Characters", "\(characters)") } + if prefs.showLocation { field("Location", "\(location)") } + if prefs.showLine { field("Line", "\(lineNumber)") } + + if info.length > 0 { + let size = info.size() + info.draw(at: NSPoint(x: hMargin, y: (bounds.height - size.height) / 2)) + } + + // Right: line ending, preceded by a short vertical divider. + if prefs.showLineEnding { + let value = NSAttributedString(string: lineEnding, attributes: valueAttrs) + let size = value.size() + let x = bounds.maxX - hMargin - size.width + value.draw(at: NSPoint(x: x, y: (bounds.height - size.height) / 2)) + + if info.length > 0 { + NSColor.separatorColor.setStroke() + let dx = round(x - 12) + 0.5 + let divider = NSBezierPath() + divider.move(to: NSPoint(x: dx, y: 5)) + divider.line(to: NSPoint(x: dx, y: bounds.height - 5)) + divider.lineWidth = 1 + divider.stroke() + } + } + } +} diff --git a/Tests/EdmundTests/ActiveBulletMarkerTests.swift b/Tests/EdmundTests/ActiveBulletMarkerTests.swift new file mode 100644 index 0000000..9c8431a --- /dev/null +++ b/Tests/EdmundTests/ActiveBulletMarkerTests.swift @@ -0,0 +1,50 @@ +import Testing +import AppKit +@testable import EdmundCore + +// When you click into a bullet item, the raw "-" should stay on the dot's +// column (where the rendered • sits) rather than jumping a slot to the right. +// Content must still hang at the same indent so the text doesn't move. +@Suite("Active bullet marker column") +@MainActor +struct ActiveBulletMarkerTests { + + private func ps(_ editor: EditorTextView, _ s: String, cursor: Int?) -> NSParagraphStyle? { + let st = editor.styleBlock(s, cursorPosition: cursor) + return st.attribute(.paragraphStyle, at: st.length - 1, effectiveRange: nil) as? NSParagraphStyle + } + + @Test("Active bullet marker stays on the dot column (not right-aligned into the slot)") + func bulletStaysOnDotColumn() { + let editor = makeEditor() + let active = ps(editor, "- item", cursor: 3)! + let inactive = ps(editor, "- item", cursor: nil)! + let slot = editor.bodyFont.pointSize + + (" " as NSString).size(withAttributes: [.font: editor.bodyFont]).width + // The active dash sits on the inactive dot's column (within a fraction of + // a slot), not a full slot to the right of it. + #expect(abs(active.firstLineHeadIndent - inactive.firstLineHeadIndent) < slot * 0.5) + // Content is unchanged so the text doesn't shift when clicking in. + #expect(abs(active.headIndent - inactive.headIndent) < 0.5) + } + + @Test("Active bullet kerns its trailing space so content keeps the hanging indent") + func bulletKernsTrailingSpace() { + let editor = makeEditor() + let st = editor.styleBlock("- item", cursorPosition: 3) + // "- item": the space is index 1; it carries positive kern to push the + // content out to the content indent even though the dash sits left. + let kern = st.attribute(.kern, at: 1, effectiveRange: nil) as? CGFloat + #expect((kern ?? 0) > 0) + } + + @Test("Active ordered marker still right-aligns into its slot") + func orderedStillRightAligns() { + let editor = makeEditor() + let active = ps(editor, "1. item", cursor: 4)! + // Ordered numbers keep right-alignment (periods line up), so the first + // line indent is well right of the bullet column. + #expect(active.firstLineHeadIndent < active.headIndent) + #expect(active.firstLineHeadIndent > editor.listPadding + 1) + } +} diff --git a/Tests/EdmundTests/BlockParserTests.swift b/Tests/EdmundTests/BlockParserTests.swift new file mode 100644 index 0000000..1d0eae0 --- /dev/null +++ b/Tests/EdmundTests/BlockParserTests.swift @@ -0,0 +1,780 @@ +import Testing +import Foundation +@testable import EdmundCore + +@Suite("BlockParser") +struct BlockParserTests { + + // MARK: - Basic Splitting + + @Test("Empty string produces one empty block") + func emptyString() { + let blocks = BlockParser.parse("") + #expect(blocks.count == 1) + #expect(blocks[0].content == "") + #expect(blocks[0].range == NSRange(location: 0, length: 0)) + } + + @Test("A callout merges its block-quote lines into one block") + func calloutMerges() { + let blocks = BlockParser.parse("> [!note]\n> line one\n> line two\n\nafter") + // The three `>` lines form one callout block; blank + "after" follow. + #expect(blocks.count == 3) + #expect(blocks[0].content == "> [!note]\n> line one\n> line two") + #expect(blocks[2].content == "after") + } + + @Test("Consecutive block-quote lines merge into one block") + func blockquoteLinesMerge() { + // One NSTextBlock per quote (vs one per line) avoids the NSTextView + // table-cell deletion restriction at per-line boundaries. + let blocks = BlockParser.parse("> a\n> b\n\nafter") + #expect(blocks.count == 3) + #expect(blocks[0].content == "> a\n> b") + #expect(blocks[2].content == "after") + } + + @Test("An unknown [!type] still merges as a plain block quote") + func unknownCalloutMergesAsQuote() { + let blocks = BlockParser.parse("> [!bogus]\n> b") + #expect(blocks.count == 1) + } + + @Test("Single line produces one block") + func singleLine() { + let blocks = BlockParser.parse("hello") + #expect(blocks.count == 1) + #expect(blocks[0].content == "hello") + #expect(blocks[0].range == NSRange(location: 0, length: 5)) + } + + @Test("Two lines produce two blocks") + func twoLines() { + let blocks = BlockParser.parse("hello\nworld") + #expect(blocks.count == 2) + #expect(blocks[0].content == "hello") + #expect(blocks[0].range == NSRange(location: 0, length: 5)) + #expect(blocks[1].content == "world") + #expect(blocks[1].range == NSRange(location: 6, length: 5)) + } + + @Test("Three lines produce three blocks") + func threeLines() { + let blocks = BlockParser.parse("a\nb\nc") + #expect(blocks.count == 3) + #expect(blocks[0].content == "a") + #expect(blocks[1].content == "b") + #expect(blocks[2].content == "c") + #expect(blocks[0].range == NSRange(location: 0, length: 1)) + #expect(blocks[1].range == NSRange(location: 2, length: 1)) + #expect(blocks[2].range == NSRange(location: 4, length: 1)) + } + + @Test("Trailing newline creates empty block (Enter at end)") + func trailingNewline() { + let blocks = BlockParser.parse("hello\n") + #expect(blocks.count == 2) + #expect(blocks[0].content == "hello") + #expect(blocks[1].content == "") + #expect(blocks[1].range == NSRange(location: 6, length: 0)) + } + + @Test("Multiple trailing newlines create multiple empty blocks") + func multipleTrailingNewlines() { + let blocks = BlockParser.parse("hello\n\n") + #expect(blocks.count == 3) + #expect(blocks[0].content == "hello") + #expect(blocks[1].content == "") + #expect(blocks[2].content == "") + } + + @Test("Only newlines produce empty blocks") + func onlyNewlines() { + let blocks = BlockParser.parse("\n\n") + #expect(blocks.count == 3) + for block in blocks { + #expect(block.content == "") + } + } + + // MARK: - Ranges Are Contiguous + + @Test("Block ranges cover the full string with separators between them") + func rangesContiguous() { + let text = "alpha\nbeta\ngamma" + let blocks = BlockParser.parse(text) + let nsText = text as NSString + + // First block starts at 0 + #expect(blocks[0].range.location == 0) + + // Each block's end + 1 (separator) == next block's start + for i in 0..<(blocks.count - 1) { + #expect(blocks[i].range.upperBound + 1 == blocks[i + 1].range.location) + } + + // Last block ends at or before string length + #expect(blocks.last!.range.upperBound <= nsText.length) + } + + // MARK: - ID Preservation + + @Test("Re-parsing unchanged text preserves block IDs") + func idPreservation() { + let blocks1 = BlockParser.parse("hello\nworld") + let blocks2 = BlockParser.parse("hello\nworld", previous: blocks1) + + #expect(blocks1[0].id == blocks2[0].id) + #expect(blocks1[1].id == blocks2[1].id) + } + + @Test("Changed block gets a new ID") + func idChangedBlock() { + let blocks1 = BlockParser.parse("hello\nworld") + let blocks2 = BlockParser.parse("hello\nearth", previous: blocks1) + + #expect(blocks1[0].id == blocks2[0].id) // "hello" unchanged + #expect(blocks1[1].id != blocks2[1].id) // "world" → "earth" + } + + @Test("Added block gets a new ID, existing blocks keep theirs") + func idAddedBlock() { + let blocks1 = BlockParser.parse("hello\nworld") + let blocks2 = BlockParser.parse("hello\nworld\nnew", previous: blocks1) + + #expect(blocks2.count == 3) + #expect(blocks1[0].id == blocks2[0].id) + #expect(blocks1[1].id == blocks2[1].id) + // blocks2[2] is new — just verify it exists with the right content + #expect(blocks2[2].content == "new") + } + + @Test("Removed block: remaining blocks keep their IDs") + func idRemovedBlock() { + let blocks1 = BlockParser.parse("hello\nworld\nfoo") + let blocks2 = BlockParser.parse("hello\nfoo", previous: blocks1) + + #expect(blocks2.count == 2) + #expect(blocks1[0].id == blocks2[0].id) // "hello" + #expect(blocks1[2].id == blocks2[1].id) // "foo" + } + + @Test("Each previous block ID is used at most once") + func idUniqueness() { + let blocks1 = BlockParser.parse("a\na\na") // three identical blocks + let blocks2 = BlockParser.parse("a\na\na", previous: blocks1) + + // Each reused at most once: all three IDs should still be unique + let ids = blocks2.map(\.id) + #expect(Set(ids).count == 3) + } + + // MARK: - Markdown Content (parser doesn't interpret, just preserves) + + @Test("Markdown syntax is preserved as-is in block content") + func markdownPreserved() { + let text = "**bold**\n*italic*\n`code`" + let blocks = BlockParser.parse(text) + #expect(blocks[0].content == "**bold**") + #expect(blocks[1].content == "*italic*") + #expect(blocks[2].content == "`code`") + } + + // MARK: - Edge Cases + + @Test("Single character") + func singleChar() { + let blocks = BlockParser.parse("x") + #expect(blocks.count == 1) + #expect(blocks[0].content == "x") + #expect(blocks[0].range == NSRange(location: 0, length: 1)) + } + + @Test("Single newline produces two empty blocks") + func singleNewline() { + let blocks = BlockParser.parse("\n") + #expect(blocks.count == 2) + #expect(blocks[0].content == "") + #expect(blocks[1].content == "") + } + + @Test("Ranges are correct for multi-byte characters") + func multiByte() { + // NSRange uses UTF-16 offsets. "café" is 4 UTF-16 code units, + // "é" is 1 code unit (U+00E9). + let text = "café\nnext" + let blocks = BlockParser.parse(text) + #expect(blocks[0].content == "café") + #expect(blocks[0].range == NSRange(location: 0, length: 4)) + #expect(blocks[1].content == "next") + #expect(blocks[1].range == NSRange(location: 5, length: 4)) + } + + @Test("Emoji ranges use UTF-16 length") + func emoji() { + // "👋" is 2 UTF-16 code units (surrogate pair) + let text = "👋\nhi" + let blocks = BlockParser.parse(text) + #expect(blocks[0].content == "👋") + #expect(blocks[0].range.length == ("👋" as NSString).length) + #expect(blocks[1].content == "hi") + } + + // MARK: - Table Merging + + @Test("Table with header and separator merges into single block") + func tableMerge() { + let text = "| A | B |\n| --- | --- |\n| 1 | 2 |" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == text) + } + + @Test("Table between paragraphs") + func tableBetweenParagraphs() { + let text = "above\n| A | B |\n| --- | --- |\n| 1 | 2 |\nbelow" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 3) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "| A | B |\n| --- | --- |\n| 1 | 2 |") + #expect(blocks[2].content == "below") + } + + @Test("Table with multiple data rows") + func tableMultipleRows() { + let text = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == text) + } + + @Test("Single pipe line without separator is not a table") + func singlePipeNotTable() { + let text = "| not a table |" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "| not a table |") + } + + @Test("Table range covers full text") + func tableRange() { + let text = "| A |\n| --- |\n| 1 |" + let blocks = BlockParser.parse(text) + #expect(blocks[0].range == NSRange(location: 0, length: (text as NSString).length)) + } + + @Test("Delimiter row with fewer cells than the header is not a table") + func tableRejectsMismatchedDelimiterCount() { + let blocks = BlockParser.parse("| a | b |\n|---|") + #expect(blocks.map(\.kind) != [.table]) + } + + @Test("Delimiter row with matching cell count is still a table") + func tableAcceptsMatchingDelimiterCount() { + let blocks = BlockParser.parse("| a | b |\n|---|---|") + #expect(blocks.map(\.kind) == [.table]) + } + + // MARK: - Code Fence Merging + + @Test("Fenced code block merges into single block") + func codeFenceMerge() { + let text = "```\nhello\n```" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "```\nhello\n```") + } + + @Test("Code fence with language merges into single block") + func codeFenceWithLanguage() { + let text = "```swift\nlet x = 1\n```" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "```swift\nlet x = 1\n```") + } + + @Test("Tilde code fence merges into single block") + func tildeFenceMerge() { + let text = "~~~\ncode\n~~~" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "~~~\ncode\n~~~") + } + + @Test("Code fence between paragraphs") + func codeFenceBetweenParagraphs() { + let text = "above\n```\ncode\n```\nbelow" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 3) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "```\ncode\n```") + #expect(blocks[2].content == "below") + } + + @Test("Unclosed code fence merges to end of document") + func unclosedFence() { + let text = "```\nline1\nline2" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "```\nline1\nline2") + } + + @Test("Code fence with multiple content lines") + func multiLineFence() { + let text = "```\na\nb\nc\n```" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content.contains("a\nb\nc")) + } + + @Test("Multi-line $$ display math merges into a single block") + func displayMathMerge() { + let text = "$$\nx = y\n$$" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "$$\nx = y\n$$") + } + + @Test("One-line $$…$$ stays a single block") + func displayMathOneLine() { + let blocks = BlockParser.parse("$$ x = y $$") + #expect(blocks.count == 1) + #expect(blocks[0].content == "$$ x = y $$") + } + + @Test("Display math between paragraphs splits correctly") + func displayMathBetweenParagraphs() { + let text = "above\n$$\nx\n$$\nbelow" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 3) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "$$\nx\n$$") + #expect(blocks[2].content == "below") + } + + @Test("Display math opener with content on the same line") + func displayMathOpenerWithContent() { + let text = "$$ x +\ny $$" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "$$ x +\ny $$") + } + + @Test("Code fence range covers full text") + func codeFenceRange() { + let text = "```\nhello\n```" + let blocks = BlockParser.parse(text) + #expect(blocks[0].range == NSRange(location: 0, length: (text as NSString).length)) + } + + // MARK: - Blockquotes (consecutive lines merge into one block) + + @Test("Consecutive blockquote lines merge into one block") + func blockquoteMergedBlock() { + let text = "> line1\n> line2\n> line3" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "> line1\n> line2\n> line3") + } + + @Test("A blockquote lazily continues a following bare line") + func blockquoteBetweenParagraphs() { + let text = "above\n> line1\n> line2\nbelow" + let blocks = BlockParser.parse(text) + // `below` lacks `>` but lazily continues the quote's paragraph (CommonMark). + #expect(blocks.count == 2) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "> line1\n> line2\nbelow") + } + + @Test("Single blockquote line is one block") + func singleBlockquoteLine() { + let text = "> just one" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "> just one") + } + + @Test("A merged blockquote's range spans all its lines") + func blockquoteRange() { + let text = "> a\n> b" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].range == NSRange(location: 0, length: 7)) + } + + @Test("A bare line then `> second` all lazily continue one quote (GFM ex. 228)") + func nonConsecutiveBlockquotes() { + let text = "> first\nparagraph\n> second" + let blocks = BlockParser.parse(text) + // No blank line separates them, so `paragraph` lazily continues the + // quote and `> second` continues the same still-open paragraph. + #expect(blocks.count == 1) + #expect(blocks[0].content == "> first\nparagraph\n> second") + } + + // MARK: - Changed Window (parseWithDiff) + + @Test("Unchanged re-parse yields an empty window") + func diffUnchanged() { + let (b1, _) = BlockParser.parseWithDiff("a\nb\nc") + let (b2, changed) = BlockParser.parseWithDiff("a\nb\nc", previous: b1) + #expect(changed.isEmpty) + #expect(b1.map(\.id) == b2.map(\.id)) + } + + @Test("Editing one block yields a one-block window") + func diffSingleEdit() { + let (b1, _) = BlockParser.parseWithDiff("a\nb\nc") + let (b2, changed) = BlockParser.parseWithDiff("a\nbX\nc", previous: b1) + #expect(changed == 1..<2) + #expect(b2[0].id == b1[0].id) + #expect(b2[2].id == b1[2].id) + #expect(b2[1].id != b1[1].id) + } + + @Test("Splitting a block yields a two-block window (count change)") + func diffSplit() { + let (b1, _) = BlockParser.parseWithDiff("hello world\ntail") + let (b2, changed) = BlockParser.parseWithDiff("hello\nworld\ntail", previous: b1) + #expect(b2.count == 3) + #expect(changed == 0..<2) + #expect(b2[2].id == b1[1].id) // suffix keeps its ID + } + + @Test("Deleting a blank merges a paragraph into the quote (lazy continuation)") + func diffMerge() { + let (b1, _) = BlockParser.parseWithDiff("> a\n\ntail") // quote, blank, paragraph + #expect(b1.count == 3) + // Removing the blank lets `tail` lazily continue the quote's paragraph. + let (b2, _) = BlockParser.parseWithDiff("> a\ntail", previous: b1) + #expect(b2.count == 1) + #expect(b2[0].content == "> a\ntail") + #expect(b2[0].kind == .quoteRun(isCallout: false)) + } + + @Test("Identical-content documents: window covers only the edit") + func diffIdenticalContent() { + let (b1, _) = BlockParser.parseWithDiff("a\na\na\na") + let (b2, changed) = BlockParser.parseWithDiff("a\nX\na\na", previous: b1) + #expect(changed == 1..<2) + // Prefix and suffix matches keep their IDs positionally. + #expect(b2[0].id == b1[0].id) + #expect(b2[2].id == b1[2].id) + #expect(b2[3].id == b1[3].id) + } + + @Test("Overlap clamp: duplicating a block doesn't double-match") + func diffOverlapClamp() { + let (b1, _) = BlockParser.parseWithDiff("a") + let (b2, changed) = BlockParser.parseWithDiff("a\na", previous: b1) + #expect(b2.count == 2) + #expect(changed.count == 1) + #expect(Set(b2.map(\.id)).count == 2) + } + + // MARK: - Block Kinds + + @Test("Kinds are classified per block") + func kinds() { + let text = """ + # Title + plain + - item + 3. ordered + --- + > quote + + > [!note] + > body + | a | b | + | --- | --- | + ``` + code + ``` + $$ + x + $$ + """ + let blocks = BlockParser.parse(text) + let kinds = blocks.map(\.kind) + #expect(kinds == [ + .heading(level: 1), + .paragraph, + .listItem, + .listItem, + .thematicBreak, + .quoteRun(isCallout: false), + .blank, + .quoteRun(isCallout: true), + .table, + .fence, + .mathDisplay, + ]) + } + + @Test("Blank and whitespace-only lines are .blank") + func blankKinds() { + let blocks = BlockParser.parse("a\n\n \nb") + #expect(blocks.map(\.kind) == [.paragraph, .blank, .blank, .paragraph]) + } + + @Test("Thematic break beats list classification for '- - -'") + func hrBeatsList() { + let blocks = BlockParser.parse("- - -") + #expect(blocks[0].kind == .thematicBreak) + } + + // MARK: - Setext headings + + @Test("Paragraph + === underline merges into an h1 block") + func setextH1() { + let blocks = BlockParser.parse("Title\n===\nbody") + #expect(blocks.count == 2) + #expect(blocks[0].content == "Title\n===") + #expect(blocks[0].kind == .heading(level: 1)) + #expect(blocks[1].content == "body") + } + + @Test("Paragraph + --- underline merges into an h2 block (setext beats rule)") + func setextH2() { + let blocks = BlockParser.parse("Title\n---") + #expect(blocks.count == 1) + #expect(blocks[0].kind == .heading(level: 2)) + } + + @Test("A setext underline merges the whole preceding paragraph run (GFM Example 51)") + func setextMultiLineContent() { + let blocks = BlockParser.parse("Foo\nbar\n---") + #expect(blocks.count == 1) + #expect(blocks[0].kind == .heading(level: 2)) + #expect(blocks[0].content == "Foo\nbar\n---") + } + + @Test("Without an underline, each paragraph line stays its own block") + func noUnderlineKeepsPerLineBlocks() { + let blocks = BlockParser.parse("Foo\nbar") + #expect(blocks.map(\.kind) == [.paragraph, .paragraph]) + #expect(blocks[0].content == "Foo") + #expect(blocks[1].content == "bar") + } + + @Test("A setext run doesn't swallow a preceding non-paragraph block") + func setextRunStopsAtHeading() { + let blocks = BlockParser.parse("# h\nFoo\n===") + #expect(blocks.map(\.kind) == [.heading(level: 1), .heading(level: 1)]) + #expect(blocks[1].content == "Foo\n===") + } + + @Test("--- after a blank line stays a thematic break") + func ruleAfterBlank() { + let blocks = BlockParser.parse("para\n\n---") + #expect(blocks.map(\.kind) == [.paragraph, .blank, .thematicBreak]) + } + + @Test("--- after a list item stays a thematic break") + func ruleAfterList() { + let blocks = BlockParser.parse("- item\n---") + #expect(blocks.map(\.kind) == [.listItem, .thematicBreak]) + } + + @Test("Spaced '- - -' after a paragraph is not a setext underline") + func spacedRuleNotUnderline() { + let blocks = BlockParser.parse("para\n- - -") + #expect(blocks.map(\.kind) == [.paragraph, .thematicBreak]) + } + + @Test("Underline with trailing non-space text is not a setext underline") + func underlineWithTrailingText() { + let blocks = BlockParser.parse("para\n=== x") + #expect(blocks.map(\.kind) == [.paragraph, .paragraph]) + } + + @Test("Setext underline can't follow a heading or blank line") + func underlineNeedsParagraph() { + let blocks = BlockParser.parse("# h\n===") + #expect(blocks.map(\.kind) == [.heading(level: 1), .paragraph]) + } + + // MARK: - Indented code blocks + + @Test("A run of 4-space lines at document start merges into one code block") + func indentedCodeRun() { + let blocks = BlockParser.parse(" a\n b") + #expect(blocks.count == 1) + #expect(blocks[0].content == " a\n b") + #expect(blocks[0].kind == .indentedCode) + } + + @Test("Tab indentation opens a code block") + func tabIndentedCode() { + let blocks = BlockParser.parse("\tcode") + #expect(blocks.map(\.kind) == [.indentedCode]) + } + + @Test("Indented code needs a preceding blank line") + func indentedCodeNeedsBlank() { + let blocks = BlockParser.parse("foo\n bar") + #expect(blocks.map(\.kind) == [.paragraph, .paragraph]) + } + + @Test("Indented run after a blank line is a code block") + func indentedCodeAfterBlank() { + let blocks = BlockParser.parse("foo\n\n bar") + #expect(blocks.map(\.kind) == [.paragraph, .blank, .indentedCode]) + } + + @Test("An interior blank line stays inside the code block (GFM Example 82)") + func indentedCodeBlankSplits() { + let blocks = BlockParser.parse(" a\n\n b") + #expect(blocks.map(\.kind) == [.indentedCode]) + #expect(blocks[0].content == " a\n\n b") + } + + @Test("A blank line before non-code content is not swallowed into the code block") + func indentedCodeTrailingBlankNotSwallowed() { + let blocks = BlockParser.parse(" a\n\nx") + #expect(blocks.map(\.kind) == [.indentedCode, .blank, .paragraph]) + #expect(blocks[0].content == " a") + } + + @Test("Multiple interior blank lines and chunks all stay in one code block") + func indentedCodeMultipleInteriorBlanks() { + let blocks = BlockParser.parse(" a\n\n\n b\n\npara") + #expect(blocks.map(\.kind) == [.indentedCode, .blank, .paragraph]) + #expect(blocks[0].content == " a\n\n\n b") + } + + @Test("A deeply indented list item is rescued as a list, not code") + func indentedListBeatsCode() { + let blocks = BlockParser.parse(" - item") + #expect(blocks.map(\.kind) == [.listItem]) + } + + @Test("An indented line followed by a setext-ish underline stays code + rule") + func indentedCodeNotSetext() { + let blocks = BlockParser.parse(" code\n---") + #expect(blocks.map(\.kind) == [.indentedCode, .thematicBreak]) + } +} + +// GFM §4.6: the seven HTML-block start conditions and their end conditions. +@Suite("BlockParser — HTML blocks") +struct BlockParserHTMLBlockTests { + + @Test("Type 6 block tag runs to the blank line") + func type6() { + let blocks = BlockParser.parse("
        \n*foo*\n
        \n\npara") + #expect(blocks.map(\.kind) == [.htmlBlock, .blank, .paragraph]) + #expect(blocks[0].content == "
        \n*foo*\n
        ") + } + + @Test("Type 6 unterminated runs to EOF") + func type6EOF() { + let blocks = BlockParser.parse("
        \n*foo*") + #expect(blocks.map(\.kind) == [.htmlBlock]) + } + + @Test("Type 6 interrupts a paragraph") + func type6Interrupts() { + let blocks = BlockParser.parse("para\n
        \nx") + #expect(blocks.map(\.kind) == [.paragraph, .htmlBlock]) + } + + @Test("Type 1 line") + func type1() { + let blocks = BlockParser.parse("\nafter") + #expect(blocks.map(\.kind) == [.htmlBlock, .paragraph]) + #expect(blocks[0].content == "") + } + + @Test("Type 1 end condition can be on the start line") + func type1OneLine() { + let blocks = BlockParser.parse("
        y
        ") + #expect(blocks.map(\.kind) == [.htmlBlock]) + } + + @Test("Types 2–5: comment, PI, declaration, CDATA") + func types2to5() { + let comment = BlockParser.parse("\nok") + #expect(comment.map(\.kind) == [.htmlBlock, .paragraph]) + #expect(comment[0].content == "") + #expect(BlockParser.parse("").map(\.kind) == [.htmlBlock]) + #expect(BlockParser.parse("").map(\.kind) == [.htmlBlock]) + #expect(BlockParser.parse("").map(\.kind) == [.htmlBlock]) + } + + @Test("Type 2 unterminated comment runs to EOF") + func type2EOF() { + #expect(BlockParser.parse(" after") + #expect(!out.contains("secret")) + #expect(out.contains("before")) + #expect(out.contains("after")) + } + + @Test("Block-level HTML comment is hidden") + func blockHTMLComment() { + #expect(!html("para\n\n\n\nend").contains("secret")) + } + + @Test("Inline emits the asset-pass placeholder with declared dimensions") + func inlineImgTag() { + let out = html("pic \"a here") + #expect(out.contains("\"a")) + } + + @Test("Block-level line emits the placeholder too") + func blockImgTag() { + let out = html("") + #expect(out.contains("\"\"")) + } + + @Test("An without a src passes through raw (no placeholder)") + func imgWithoutSrc() { + let out = html("x y") + #expect(!out.contains("md-image")) + #expect(out.contains("")) + } + + @Test("Bare www autolink renders as a real anchor") + func wwwAutolink() { + let out = html("visit www.example.com now") + #expect(out.contains("www.example.com")) + } + + @Test("Email autolink renders as a mailto anchor") + func emailAutolink() { + let out = html("mail foo@bar.example.com please") + #expect(out.contains("foo@bar.example.com")) + } + + @Test("Autolink trailing punctuation stays outside the anchor") + func autolinkTrim() { + let out = html("see www.example.com.") + #expect(out.contains("www.example.com.")) + } + + @Test("No autolink inside inline code") + func autolinkNotInCode() { + let out = html("`www.example.com`") + #expect(!out.contains("x")) + #expect(out.contains("http://b.example.com")) + } +} + +@Suite("HTMLRenderer — footnotes") +struct HTMLRendererFootnoteTests { + + private func html(_ md: String) -> String { HTMLRenderer.render(markdown: md) } + + @Test("Reference becomes a superscript link; raw [^id] doesn't leak into the page") + func reference() { + let out = html("Hello world.[^1]\n\n[^1]: a note") + #expect(out.contains("1")) + #expect(!out.contains("[^1]")) + } + + @Test("Definition moves to a bottom section with a backlink, not rendered in place") + func definitionMovesToBottom() { + let out = html("See.[^1]\n\n[^1]: the note text\n\nMore content.") + #expect(!out.contains("

        [^1]")) + #expect(out.contains("


          ")) + #expect(out.contains("
        1. the note text
        2. ")) + // The unrelated paragraph after the definition still renders normally. + #expect(out.contains("

          More content.

          ")) + } + + @Test("Definition body keeps its inline markdown formatting") + func definitionBodyKeepsFormatting() { + let out = html("See.[^1]\n\n[^1]: has **bold** text") + #expect(out.contains("
        3. has bold text")) + } + + @Test("No footnotes: no bottom section emitted") + func noFootnotesNoSection() { + #expect(!html("plain paragraph, no notes").contains("footnotes-sep")) + } + + @Test("Document produced by the Format-menu Insert Footnote command renders correctly in Read mode") + @MainActor func viaFormatMenuCommand() { + let editor = makeEditor() + editor.loadContent("Hello world.") + editor.formatFootnote(nil) + editor.insertText("This is the note.", replacementRange: editor.selectedRange()) + let out = html(editor.rawSource) + #expect(out.contains("1")) + #expect(out.contains("
        4. This is the note.")) + #expect(!out.contains("[^1]")) + } +} + +@Suite("HTMLRenderer — callouts") +struct HTMLRendererCalloutTests { + + private func html(_ md: String) -> String { HTMLRenderer.render(markdown: md) } + + @Test("Known callout type → callout div with title and body") + func basicCallout() { + let out = html("> [!note]\n> Body text.") + #expect(out.contains("
          ")) + #expect(out.contains("
          ")) + // Inline Lucide SVG (note → pencil), tinted by CSS via currentColor. + #expect(out.contains("Note")) + #expect(out.contains("

          Body text.

          ")) + } + + @Test("Custom title is used verbatim") + func customTitle() { + let out = html("> [!warning] Watch out here\n> Careful.") + #expect(out.contains("callout callout-warning")) + #expect(out.contains("Watch out here")) + } + + @Test("Unknown type stays a plain block quote") + func unknownType() { + let out = html("> [!bogus]\n> hi") + #expect(out.hasPrefix("
          ")) + #expect(!out.contains("callout")) + } + + // Callouts are strict in BOTH modes: a bare (lazy) line after a callout body + // does NOT join the callout — it renders as a sibling, matching edit-mode + // block segmentation (BlockParser keeps callouts strict; see + // BlockquoteLazyContinuationTests.calloutStaysStrict for the edit side). + @Test("A lazy line after a callout renders as a sibling, not in the body") + func calloutBodyStrict() { + let out = html("> [!note]\n> body\nlazy") + #expect(out.contains("

          body

          ")) + // `lazy` sits after the callout div closes, not inside the body. + #expect(out.contains("

          lazy

          ")) + #expect(!out.contains("body\nlazy")) + } + + @Test("GFM ex.228 tail: `> y` after a lazy line is a sibling quote, not the callout") + func calloutLazyTailWithQuote() { + let out = html("> [!note]\n> body\nlazy\n> y") + #expect(out.contains("

          body

          ")) + #expect(out.contains("

          lazy

          ")) + #expect(out.contains("

          y

          ")) + // Exactly one callout — the second `> y` was not pulled into it. + #expect(out.components(separatedBy: "class=\"callout ").count == 2) + } + + @Test("A fully `>`-prefixed callout body is unchanged (no lazy split)") + func calloutFullyQuotedUnchanged() { + let out = html("> [!note]\n> a\n> b") + #expect(out.contains("

          a\nb

          ")) + #expect(!out.contains("

        ")) + } +} diff --git a/Tests/EdmundTests/HTMLTagRenderingTests.swift b/Tests/EdmundTests/HTMLTagRenderingTests.swift new file mode 100644 index 0000000..cb53564 --- /dev/null +++ b/Tests/EdmundTests/HTMLTagRenderingTests.swift @@ -0,0 +1,282 @@ +import Testing +import Foundation +import AppKit +@testable import EdmundCore + +// HTML tags in edit mode: every recognized tag is colored source (name red, +// brackets dimmed); a whitelist (u/kbd/mark/sub/sup) additionally renders its +// formatting when the caret is outside the token. Read mode passes raw HTML +// through per GFM, filtered by tagfilter (§6.11) + hardening. + +@Suite("SyntaxHighlighter — HTML tags") +struct HTMLTagParseTests { + + private func kinds(_ text: String) -> [SyntaxHighlighter.Span.Kind] { + SyntaxHighlighter.parse(text).map(\.kind) + } + + @Test("Whitelisted pair → htmlFormat") + func pair() { + let spans = SyntaxHighlighter.parse("hi") + let fmt = spans.first { if case .htmlFormat = $0.kind { return true }; return false } + #expect(fmt != nil) + #expect(fmt?.contentRange == NSRange(location: 3, length: 2)) // "hi" + #expect(fmt?.delimiterRanges == [NSRange(location: 0, length: 3), // + NSRange(location: 5, length: 4)]) // + } + + @Test("Unknown tag → htmlTag (colored only)") + func unknown() { + let spans = SyntaxHighlighter.parse("a b") + let tag = spans.first { if case .htmlTag = $0.kind { return true }; return false } + #expect(tag?.contentRange == NSRange(location: 3, length: 4)) // "span" + #expect(!spans.contains { if case .htmlFormat = $0.kind { return true }; return false }) + } + + @Test("Unpaired whitelist tag → htmlTag, not htmlFormat") + func unpaired() { + let k = kinds(" alone") + #expect(k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test("Escaped `\\` is not an HTML tag") + func escaped() { + let k = kinds("\\") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test("No HTML tag inside inline code") + func insideCode() { + let k = kinds("`x`") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test(" → .comment with delimiters") + func htmlComment() { + let spans = SyntaxHighlighter.parse("a b") + let comment = spans.first { if case .comment = $0.kind { return true }; return false } + #expect(comment != nil) + #expect(comment?.fullRange == NSRange(location: 2, length: 13)) + #expect(comment?.delimiterRanges == [NSRange(location: 2, length: 4), // + } + + @Test("A tag inside an HTML comment belongs to the comment, not the tag pass") + func commentSwallowsTags() { + let k = kinds("") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test(" → image span carrying src and declared dimensions") + func imgTag() { + let text = "\"a" + let spans = SyntaxHighlighter.parse(text) + let images = spans.filter { if case .image = $0.kind { return true }; return false } + #expect(images.count == 1) + guard let img = images.first else { return } + if case .image(let dest, let w, let h) = img.kind { + #expect(dest == "cat.png") + #expect(w == 120) + #expect(h == 80) + } + #expect(img.fullRange == NSRange(location: 0, length: (text as NSString).length)) + #expect((text as NSString).substring(with: img.contentRange) == "cat.png") + #expect(!spans.contains { if case .htmlTag = $0.kind { return true }; return false }) + } + + @Test(" without dimensions parses with nil width/height") + func imgNoDims() { + let spans = SyntaxHighlighter.parse("") + guard case .image(let dest, let w, let h)? = + spans.first(where: { if case .image = $0.kind { return true }; return false })?.kind + else { Issue.record("no image span"); return } + #expect(dest == "cat.png") + #expect(w == nil && h == nil) + } + + @Test(" without a quoted src stays colored source (htmlTag)") + func imgWithoutSrc() { + let k = kinds("") + #expect(!k.contains { if case .image = $0 { return true }; return false }) + #expect(k.contains { if case .htmlTag = $0 { return true }; return false }) + } + + @Test("Hyphenated element name is a tag (§6.10)") + func hyphenName() { + let spans = SyntaxHighlighter.parse("a b") + let tag = spans.first { if case .htmlTag = $0.kind { return true }; return false } + #expect(tag?.contentRange == NSRange(location: 3, length: 10)) // "my-element" + } + + @Test("A quoted attribute value may contain `>`") + func quotedGT() { + let text = "b\">" + let spans = SyntaxHighlighter.parse(text) + let tags = spans.filter { if case .htmlTag = $0.kind { return true }; return false } + #expect(tags.count == 1) + #expect(tags.first?.fullRange == NSRange(location: 0, length: (text as NSString).length)) + } + + @Test(" accepts single-quoted and unquoted attribute values") + func imgAltQuoting() { + guard case .image(let dest1, _, _)? = SyntaxHighlighter.parse("") + .first(where: { if case .image = $0.kind { return true }; return false })?.kind + else { Issue.record("no image span for single-quoted src"); return } + #expect(dest1 == "cat.png") + + guard case .image(let dest2, let w, _)? = SyntaxHighlighter.parse("") + .first(where: { if case .image = $0.kind { return true }; return false })?.kind + else { Issue.record("no image span for unquoted src"); return } + #expect(dest2 == "cat.png") + #expect(w == 120) + } + + @Test("A closing tag with attributes is not a tag (§6.10)") + func badCloseTag() { + let k = kinds("") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + } + + @Test("PI, declaration, and CDATA tokens become dimmed source") + func otherRawHTML() { + for text in ["x y", "x y", "x &<]]> y"] { + #expect(kinds(text).contains { if case .htmlTag = $0 { return true }; return false }, + "no htmlTag span in \(text)") + } + } +} + +@Suite("Rendering — HTML tags") +@MainActor +struct HTMLTagRenderingTests { + + private func attr(_ key: NSAttributedString.Key, at i: Int, in s: NSAttributedString) -> Any? { + guard i < s.length else { return nil } + return s.attribute(key, at: i, effectiveRange: nil) + } + + @Test("Unknown tag: name red, brackets dimmed") + func coloredSource() { + let editor = makeEditor() + let styled = editor.styleBlock("a b") + #expect(attr(.foregroundColor, at: 3, in: styled) as? NSColor == editor.theme.mathOperatorColor) + #expect(isDimmed(at: 2, in: styled)) // < + #expect(isDimmed(at: 7, in: styled)) // > + } + + @Test("Inactive pair: tags hidden, content rendered") + func pairInactive() { + let editor = makeEditor() + let styled = editor.styleBlock("hi", cursorPosition: nil) + #expect(isHidden(at: 0, in: styled)) // + #expect(isHidden(at: 5, in: styled)) // + #expect(attr(.underlineStyle, at: 3, in: styled) as? Int == NSUnderlineStyle.single.rawValue) + } + + @Test("Active pair: tags shown (not hidden), content not underlined") + func pairActive() { + let editor = makeEditor() + let styled = editor.styleBlock("hi", cursorPosition: 4) + #expect(!isHidden(at: 0, in: styled)) + #expect(attr(.underlineStyle, at: 3, in: styled) == nil) + } + + @Test("kbd, mark, sub, sup map to their attributes") + func attributeMap() { + let editor = makeEditor() + + let kbd = editor.styleBlock("K", cursorPosition: nil) + #expect((attr(.font, at: 5, in: kbd) as? NSFont) == editor.inlineCodeFont) + #expect(attr(.backgroundColor, at: 5, in: kbd) as? NSColor == editor.inlineCodeBackground) + + let mark = editor.styleBlock("M", cursorPosition: nil) + #expect(attr(.backgroundColor, at: 6, in: mark) != nil) + + let sub = editor.styleBlock("2", cursorPosition: nil) + #expect((attr(.baselineOffset, at: 5, in: sub) as? CGFloat ?? 0) < 0) + + let sup = editor.styleBlock("2", cursorPosition: nil) + #expect((attr(.baselineOffset, at: 5, in: sup) as? CGFloat ?? 0) > 0) + + let small = editor.styleBlock("fine", cursorPosition: nil) + let f = attr(.font, at: 8, in: small) as? NSFont + #expect(f != nil && f!.pointSize < editor.bodyFont.pointSize) + } + + @Test("HTML comment: dimmed in edit view, hidden in reading view") + func htmlComment() { + let editor = makeEditor() + let edit = editor.styleBlock("a b", cursorPosition: nil) + #expect(!isHidden(at: 5, in: edit)) // visible (dimmed) in edit mode + + let read = editor.styleBlock("a b", cursorPosition: nil, hideComments: true) + #expect(isHidden(at: 5, in: read)) // gone in reading view + } + + @Test("Inner markdown still styles: **b**") + func innerMarkdown() { + let editor = makeEditor() + let styled = editor.styleBlock("**b**", cursorPosition: nil) + // 'b' is at offset 5 (after and **). + #expect(attr(.underlineStyle, at: 5, in: styled) as? Int == NSUnderlineStyle.single.rawValue) + let f = attr(.font, at: 5, in: styled) as? NSFont + #expect(f != nil && NSFontManager.shared.traits(of: f!).contains(.boldFontMask)) + } + + @Test("An .htmlBlock renders as colored source: tags colored, markdown literal") + func htmlBlockSource() { + let editor = makeEditor() + let styled = editor.styleBlock("

        \n**text**\n
        ", cursorPosition: nil) + // "div" name colored like any recognized tag (offset 1). + #expect(attr(.foregroundColor, at: 1, in: styled) as? NSColor == editor.theme.mathOperatorColor) + // The `**` asterisks stay visible — raw HTML source, no markdown spans. + #expect(!isHidden(at: 6, in: styled)) + #expect(!isHidden(at: 7, in: styled)) + } +} + +@Suite("HTMLRenderer — whitelisted HTML passes through") +struct HTMLTagExportTests { + + private func html(_ md: String) -> String { HTMLRenderer.render(markdown: md) } + + @Test("Whitelisted tags render as real tags") + func passesThrough() { + #expect(html("x").contains("x")) + #expect(html("K").contains("K")) + #expect(html("m").contains("m")) + #expect(html("H2O").contains("2")) + #expect(html("x2").contains("2")) + #expect(html("fine").contains("fine")) + } + + @Test("Benign attributes kept, event handlers stripped") + func hardensAttributes() { + let out = html("hi") + #expect(out.contains("hi")) + #expect(!out.contains("onclick")) + } + + @Test("Inner markdown still renders inside a passed tag") + func innerMarkdown() { + #expect(html("**b**").contains("b")) + } + + @Test("Non-whitelisted inline tag passes through raw (GFM)") + func unknownPassesThrough() { + let out = html("a x b") + #expect(out.contains("x")) + } + + @Test("A nested script inside a passed tag is tagfiltered") + func nestedScriptTagfiltered() { + let out = html("") + #expect(out.contains("")) + #expect(!out.contains(" String { + let theme = EditorTheme(fontName: "Iowan Old Style", fontSize: 16, + linkBlueHex: "#3366E6", codeHex: "#8A2425", + lineSpacing: 4, paragraphSpacingBefore: 2) + return HTMLTheme.css(theme, callouts: Callout.defaultStyles, dark: dark) + } + + @Test("Derives custom properties from the theme") + func vars() { + let out = css(dark: false) + #expect(out.contains("--accent: #3366E6;")) + #expect(out.contains("--code: #8A2425;")) + #expect(out.contains("--body-size: 16px;")) + // 1.2 + 4/16 = 1.45 + #expect(out.contains("--line-height: 1.45;")) + // Multi-word family is quoted with a fallback stack. + #expect(out.contains("\"Iowan Old Style\"")) + } + + @Test("Reading column max-width matches the editor's physical cap; uncapped by default") + func pageMaxWidth() { + let theme = EditorTheme(fontName: "Iowan Old Style", fontSize: 16, + linkBlueHex: "#3366E6", codeHex: "#8A2425", + lineSpacing: 4, paragraphSpacingBefore: 2) + let capped = HTMLTheme.css(theme, callouts: Callout.defaultStyles, dark: false, + maxContentWidthPoints: 340) + #expect(capped.contains("--page-max-width: 340px;")) + + let uncapped = HTMLTheme.css(theme, callouts: Callout.defaultStyles, dark: false) + #expect(uncapped.contains("--page-max-width: none;")) + } + + @Test("Emits per-callout-type colors with derived rgba background") + func calloutVars() { + let out = css(dark: false) + #expect(out.contains(".callout-note {")) + #expect(out.contains("--c-accent: #086DDD;")) + // note's background is derived from the accent at backgroundAlpha 0.08. + #expect(out.contains("rgba(8, 109, 221, 0.08)")) + } + + @Test("Dark appearance picks dark color variants") + func darkVariant() { + // 'caution' has darkColorHex #F85149. + #expect(css(dark: true).contains("--c-accent: #F85149;")) + #expect(css(dark: false).contains("--c-accent: #CF222E;")) + #expect(css(dark: true).contains("--bg: #1e1e1e;")) + } + + @Test("Wide tables scroll horizontally instead of wrapping cell text") + func tableScrolls() { + let out = css(dark: false) + #expect(out.contains(".table-wrap { overflow-x: auto; margin: 1em 0; }")) + #expect(!out.contains("overflow-wrap")) + } + + @Test("Emits code token colors from the shared palette, per appearance") + func codeTokenColors() { + let light = css(dark: false) + #expect(light.contains("pre code .tok-keyword { color: \(CodeSyntaxPalette.hex(.keyword, dark: false)); }")) + #expect(light.contains("pre code { color: \(CodeSyntaxPalette.hex(nil, dark: false)); }")) + let dark = css(dark: true) + #expect(dark.contains("pre code .tok-string { color: \(CodeSyntaxPalette.hex(.string, dark: true)); }")) + // The palettes differ between appearances. + #expect(CodeSyntaxPalette.hex(.keyword, dark: false) != CodeSyntaxPalette.hex(.keyword, dark: true)) + } +} diff --git a/Tests/EdmundTests/HeightStabilityTests.swift b/Tests/EdmundTests/HeightStabilityTests.swift new file mode 100644 index 0000000..ac5e120 --- /dev/null +++ b/Tests/EdmundTests/HeightStabilityTests.swift @@ -0,0 +1,181 @@ +import Testing +import AppKit +@testable import EdmundCore + +/// Activating a block (moving the caret into it) reveals its raw markdown +/// markers. For most block kinds the rendered and active forms must occupy the +/// same vertical space — otherwise content below shifts the moment you click in, +/// which is the scroll "lurch" the editor has been chasing. This suite pins that +/// invariant per block kind by measuring a reference line BELOW the block: if the +/// block's height changes between rendered and active, the reference line moves. +/// +/// Known/accepted exceptions (tracked in todo.md): thematic break and heading +/// legitimately change height (different font size when the `#`s appear). +@Suite("Height stability: rendered vs active") +@MainActor +struct HeightStabilityTests { + + /// Loads `block` followed by a reference paragraph, measures the reference + /// line's Y rendered (caret elsewhere) vs active (caret in the block), and + /// returns how far it moved. `< ~1pt` means no height change. + private func referenceShift(forBlock block: String, + caretMarker: String) -> CGFloat { + let editor = makeEditor() + // A leading paragraph keeps the measured block off the document origin, + // and the reference paragraph sits below it. + let doc = "lead paragraph\n\n\(block)\n\nREFERENCE LINE\n\n" + editor.loadContent(doc) + ensureFullLayout(editor) + + let ns = editor.rawSource as NSString + let refOffset = ns.range(of: "REFERENCE LINE").location + #expect(refOffset != NSNotFound) + + // Rendered: caret in the lead paragraph, block inactive. + let leadOffset = ns.range(of: "lead paragraph").location + editor.setSelectedRange(NSRange(location: leadOffset, length: 0)) + editor.recomposeIncremental(cursorInRaw: leadOffset) + ensureFullLayout(editor) + guard let yRendered = editor.lineRect(forCharacterAt: refOffset)?.minY else { + Issue.record("no rendered line rect"); return .greatestFiniteMagnitude + } + + // Active: caret inside the block. + let caretOffset = ns.range(of: caretMarker).location + #expect(caretOffset != NSNotFound) + editor.setSelectedRange(NSRange(location: caretOffset, length: 0)) + editor.recomposeIncremental(cursorInRaw: caretOffset) + ensureFullLayout(editor) + guard let yActive = editor.lineRect(forCharacterAt: refOffset)?.minY else { + Issue.record("no active line rect"); return .greatestFiniteMagnitude + } + + return abs(yActive - yRendered) + } + + private let tolerance: CGFloat = 1.0 + + @Test("Inline bold has no height change") + func inlineBold() { + #expect(referenceShift(forBlock: "A line with **bold** word.", + caretMarker: "bold") < tolerance) + } + + @Test("Inline italic has no height change") + func inlineItalic() { + #expect(referenceShift(forBlock: "A line with *italic* word.", + caretMarker: "italic") < tolerance) + } + + @Test("Inline strikethrough has no height change") + func inlineStrikethrough() { + #expect(referenceShift(forBlock: "A line with ~~struck~~ word.", + caretMarker: "struck") < tolerance) + } + + @Test("Inline highlight has no height change") + func inlineHighlight() { + #expect(referenceShift(forBlock: "A line with ==marked== word.", + caretMarker: "marked") < tolerance) + } + + @Test("Inline code has no height change") + func inlineCode() { + #expect(referenceShift(forBlock: "A line with `code` word.", + caretMarker: "code") < tolerance) + } + + @Test("Inline link has no height change") + func inlineLink() { + #expect(referenceShift(forBlock: "A line with [text](https://e.com) word.", + caretMarker: "text") < tolerance) + } + + @Test("Bulleted list item has no height change") + func bulletedList() { + #expect(referenceShift(forBlock: "- bullet item", + caretMarker: "bullet item") < tolerance) + } + + @Test("Numbered list item has no height change") + func numberedList() { + #expect(referenceShift(forBlock: "1. numbered item", + caretMarker: "numbered item") < tolerance) + } + + @Test("Checklist item has no height change") + func checklist() { + #expect(referenceShift(forBlock: "- [ ] task item", + caretMarker: "task item") < tolerance) + } + + @Test("Blockquote has no height change") + func blockquote() { + #expect(referenceShift(forBlock: "> quoted text", + caretMarker: "quoted text") < tolerance) + } + + // MARK: Wrapping cases + // + // The todo's real concern: revealing markers widens the active form, which + // can tip a long line onto an extra wrapped line. These use content long + // enough to wrap several times in the ~500pt container, with slack so the + // marker width doesn't sit on a wrap boundary — the wrapped height must + // still match exactly. + + @Test("Wrapping bold paragraph has no height change") + func wrappingBold() { + let long = "Alpha bravo charlie delta echo foxtrot golf hotel india " + + "**juliett** kilo lima mike november oscar papa quebec romeo sierra " + + "tango uniform victor whiskey xray yankee zulu." + #expect(referenceShift(forBlock: long, caretMarker: "juliett") < tolerance) + } + + @Test("Wrapping list item has no height change") + func wrappingListItem() { + let long = "- Alpha bravo charlie delta echo foxtrot golf hotel india " + + "juliett kilo lima mike november oscar papa quebec romeo sierra " + + "tango uniform victor whiskey xray yankee zulu wraps here." + #expect(referenceShift(forBlock: long, caretMarker: "juliett") < tolerance) + } + + @Test("Wrapping checklist item has no height change") + func wrappingChecklist() { + let long = "- [ ] Alpha bravo charlie delta echo foxtrot golf hotel india " + + "juliett kilo lima mike november oscar papa quebec romeo sierra " + + "tango uniform victor whiskey xray yankee zulu wraps here." + #expect(referenceShift(forBlock: long, caretMarker: "juliett") < tolerance) + } + + @Test("Heading has no height change") + func heading() { + // An existing heading keeps its size when activated — the `#` markers + // appear without changing line height. (The todo's "different font size" + // note is about the paragraph↔heading content transition of typing `#`, + // a different scenario than this rendered↔active toggle.) + #expect(referenceShift(forBlock: "# Big Heading", + caretMarker: "Big Heading") < tolerance) + } + + @Test("Thematic break has no height change") + func thematicBreak() { + // The active form keeps the rendered rule's forced line height and + // breathing space, so clicking into the `---` no longer shifts content. + #expect(referenceShift(forBlock: "---", caretMarker: "---") < tolerance) + } + + // MARK: Discriminator — keeps the all-green suite honest + // + // todo.md excludes math from the zero-change requirement ("Inline items + // (except math)"): display math renders as an image when inactive and as + // raw multi-line LaTeX when active, so it legitimately changes height. + // Asserting it DOES shift proves the harness can still detect a real height + // change — otherwise a `referenceShift` that always returned 0 would pass + // every test above vacuously. + + @Test("Display math changes height (excluded from zero-change)") + func displayMathChangesHeight() { + let shift = referenceShift(forBlock: "$$\ne = mc^2\n$$", caretMarker: "mc^2") + #expect(shift > tolerance, "expected a height change, got \(shift)") + } +} diff --git a/Tests/EdmundTests/ImageRenderingTests.swift b/Tests/EdmundTests/ImageRenderingTests.swift new file mode 100644 index 0000000..1e0f036 --- /dev/null +++ b/Tests/EdmundTests/ImageRenderingTests.swift @@ -0,0 +1,173 @@ +import Testing +import AppKit +@testable import EdmundCore + +@Suite("Image rendering") +@MainActor +struct ImageRenderingTests { + + /// Writes a tiny solid PNG to a temp file and returns its absolute path + /// (absolute so resolution doesn't need a document directory). + private func tempPNGPath() -> String { + let size = NSSize(width: 24, height: 16) + let img = NSImage(size: size) + img.lockFocus() + NSColor.systemBlue.setFill() + NSRect(origin: .zero, size: size).fill() + img.unlockFocus() + let rep = NSBitmapImageRep(data: img.tiffRepresentation!)! + let data = rep.representation(using: .png, properties: [:])! + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("md-img-test-\(UUID().uuidString).png") + try! data.write(to: url) + return url.path + } + + @Test("Image syntax parses to an image span carrying the destination") + func parsesImage() { + let dests = SyntaxHighlighter.parse("![alt](pic.png)").compactMap { s -> String? in + if case .image(let d, _, _) = s.kind { return d }; return nil + } + #expect(dests == ["pic.png"]) + } + + @Test("Rendered image draws an overlay and hides the raw markdown") + func rendersOverlay() { + let editor = makeEditor() + let styled = editor.styleBlock("![alt](\(tempPNGPath()))", cursorPosition: nil) + // Overlay anchored on the leading `!`. + let overlay = styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) as? FragmentOverlay + #expect(overlay != nil) + // The rest of the markdown is hidden (near-zero font). + let f = styled.attribute(.font, at: 5, effectiveRange: nil) as? NSFont + #expect((f?.pointSize ?? 99) < 1.0) + } + + @Test("Active image shows the raw markdown (no overlay)") + func activeShowsRaw() { + let editor = makeEditor() + let styled = editor.styleBlock("![alt](\(tempPNGPath()))", cursorPosition: 3) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) == nil) + } + + @Test("HTML renders an overlay and hides the raw tag") + func htmlImgRendersOverlay() { + let editor = makeEditor() + let styled = editor.styleBlock("", cursorPosition: nil) + // Overlay anchored on the leading `<`. + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + let f = styled.attribute(.font, at: 5, effectiveRange: nil) as? NSFont + #expect((f?.pointSize ?? 99) < 1.0) + } + + @Test("Active HTML shows the raw tag (no overlay)") + func htmlImgActiveShowsRaw() { + let editor = makeEditor() + let styled = editor.styleBlock("", cursorPosition: 3) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) == nil) + } + + @Test("Declared width/height size the overlay exactly; one alone scales proportionally") + func declaredDimensions() { + let editor = makeEditor() + let path = tempPNGPath() // natural 24×16 + + let exact = editor.imageOverlay(destination: path, width: 12, height: 10) + #expect(exact?.bounds.size == CGSize(width: 12, height: 10)) + + let widthOnly = editor.imageOverlay(destination: path, width: 12) + #expect(widthOnly?.bounds.size == CGSize(width: 12, height: 8)) + + let heightOnly = editor.imageOverlay(destination: path, height: 8) + #expect(heightOnly?.bounds.size == CGSize(width: 12, height: 8)) + + let natural = editor.imageOverlay(destination: path) + #expect(natural?.bounds.size == CGSize(width: 24, height: 16)) + } + + @Test("Missing local image shows a blocked-image placeholder overlay, not just alt text") + func fallbackShowsPlaceholder() { + let editor = makeEditor() + let styled = editor.styleBlock("![alt](/no/such/file.png)", cursorPosition: nil) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + guard case .blocked(.notFound) = editor.imageDisplay(destination: "/no/such/file.png") else { + Issue.record("expected .blocked(.notFound)"); return + } + } + + @Test("A path that resolves but isn't image data is classified as 'not an image'") + func notAnImage() { + let editor = makeEditor() + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("md-not-image-\(UUID().uuidString).png") + try! Data("not a png".utf8).write(to: url) + guard case .blocked(.notAnImage) = editor.imageDisplay(destination: url.path) else { + Issue.record("expected .blocked(.notAnImage)"); return + } + } + + @Test("Plain-http image always shows the placeholder, even with remote images allowed") + func httpImageAlwaysBlocked() { + let editor = makeEditor() + editor.allowRemoteImages = true + let styled = editor.styleBlock("![alt](http://example.com/x.png)", cursorPosition: nil) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + guard case .blocked(.httpUnsupported) = editor.imageDisplay(destination: "http://example.com/x.png") else { + Issue.record("expected .blocked(.httpUnsupported)"); return + } + } + + @Test("Https image shows the placeholder while remote images are disallowed") + func httpsImageBlockedByDefault() { + let editor = makeEditor() + editor.allowRemoteImages = false + let styled = editor.styleBlock("![alt](https://example.com/x.png)", cursorPosition: nil) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + guard case .blocked(.blockedBySetting) = editor.imageDisplay(destination: "https://example.com/x.png") else { + Issue.record("expected .blocked(.blockedBySetting)"); return + } + } + + @Test("Https image is pending (no placeholder yet) while the fetch is in flight") + func httpsImagePendingWhileFetching() { + let editor = makeEditor() + editor.allowRemoteImages = true + // `.invalid` is RFC 2606-reserved (never resolves) — the request + // fails async, off the assertion below; only the synchronous + // first-call return value (.pending, before any response) matters. + let dest = "https://example.invalid/pending-\(UUID().uuidString).png" + guard case .pending = editor.imageDisplay(destination: dest) else { + Issue.record("expected .pending"); return + } + } + + @Test("Narrowing the max-content-width column shrinks an already-rendered image") + func shrinksOnColumnNarrow() { + let editor = EditorTextView.makeTextKit2( + frame: NSRect(x: 0, y: 0, width: 800, height: 300), + containerSize: NSSize(width: 800, height: CGFloat.greatestFiniteMagnitude)) + let suite = "EdmundTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + editor.themeDefaults = defaults + editor.theme = .load(from: defaults) + + let content = "![alt](\(tempPNGPath()))\n\nsecond paragraph" // image is 24x16, well under 800pt + editor.loadContent(content) + // `loadContent` puts the cursor at offset 0, inside the image block, + // which renders raw markdown (no overlay) while active. Move the + // cursor to the second block so the image renders as an overlay. + editor.recomposeIncremental(cursorInRaw: content.count) + let before = editor.textStorage?.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) as? FragmentOverlay + #expect(before?.bounds.width == 24) + + // Cap the column narrower than the image's natural width. + editor.maxContentWidthPoints = 22 + + let after = editor.textStorage?.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) as? FragmentOverlay + #expect((after?.bounds.width ?? 9999) <= editor.availableContentWidth + 0.01) + #expect(after!.bounds.width < before!.bounds.width) + // Aspect ratio preserved (24x16 -> half width -> half height). + #expect(abs(after!.bounds.height / after!.bounds.width - 16.0 / 24.0) < 0.01) + } +} diff --git a/Tests/EdmundTests/IncrementalParseFuzzTests.swift b/Tests/EdmundTests/IncrementalParseFuzzTests.swift new file mode 100644 index 0000000..de2aef3 --- /dev/null +++ b/Tests/EdmundTests/IncrementalParseFuzzTests.swift @@ -0,0 +1,68 @@ +import Testing +import AppKit +@testable import EdmundCore + +/// Seeded random edits through the full editor pipeline. In DEBUG builds +/// every edit also runs the incremental-vs-full parse assertion inside +/// syncRawSourceFromDisplay, so this doubles as the incremental parser's +/// fuzz net; the oracle assertion here checks the styled storage too. +@Suite("Incremental parse — fuzz") +struct IncrementalParseFuzzTests { + + @Test("Random edits keep blocks and styling equivalent to a full parse", + arguments: [UInt64(1), 7, 1234, 0xBEEF]) + @MainActor func randomEdits(seed: UInt64) { + var rng = SeededGenerator(seed: seed) + let editor = makeEditor() + editor.loadContent(makeLargeMarkdown(approximateBytes: 6_000, seed: seed)) + + // Fragments biased toward structure-changing characters. + let fragments = ["\n", "\n\n", "```", "```\n", "$$", "|", ">", "> ", + "# ", "- ", "---\n", "x", "hello **world**", + "| a | b |", "[!note]", " - indented\n", "\t- tabbed\n", + "===\n", "===", " code\n", "\tcode\n", + "
        ", "
        \n", "", "\n", "
        ", "", "\n",
        +                         // Blockquote lazy continuation: bare lines after `>`.
        +                         "> q\nlazy", "> a\nb\n> c", "> a\n\nb", "lazy\n"]
        +
        +        for _ in 0..<120 {
        +            let ns = editor.rawSource as NSString
        +            let len = ns.length
        +            switch Int.random(in: 0..<10, using: &rng) {
        +            case 0..<5:   // insert a fragment somewhere (edges included)
        +                let loc = Int.random(in: 0...len, using: &rng)
        +                let frag = fragments.randomElement(using: &rng)!
        +                editor.setSelectedRange(NSRange(location: loc, length: 0))
        +                editor.insertText(frag, replacementRange: NSRange(location: loc, length: 0))
        +            case 5..<8 where len > 0:   // delete a span (possibly multi-block)
        +                let loc = Int.random(in: 0.. 0:       // replace a span with a fragment
        +                let loc = Int.random(in: 0..