600 lines
38 KiB
Markdown
600 lines
38 KiB
Markdown
# Edmund — architecture & agent onboarding
|
|
|
|
A native macOS Markdown editor with live preview (AppKit + TextKit 2, SPM,
|
|
macOS 14+). This doc gets an AI agent productive fast: read it before any
|
|
non-trivial change. **Keep it updated** — when you learn something non-obvious
|
|
or change an invariant, edit this file in the same PR.
|
|
|
|
This is the agent-oriented canonical summary — dense, terse, optimized for
|
|
fast onboarding before a change. Humans wanting the fuller narrative write-up
|
|
(rationale, worked examples, prose) start at
|
|
[`docs/architecture/README.md`](architecture/README.md); the same-PR update
|
|
rule above still applies to both.
|
|
|
|
---
|
|
|
|
## 1. Build / run / test
|
|
|
|
```bash
|
|
swift build # debug build of both targets
|
|
swift test # full suite (≈750+ tests, ~10s)
|
|
swift test --filter Callout # one suite
|
|
./scripts/build-app.sh # builds build/Edmund.app (release + bundles + icon + codesign)
|
|
```
|
|
|
|
Run the app for visual checks (see §8 for the gotchas):
|
|
```bash
|
|
pkill -x edmd; build/Edmund.app/Contents/MacOS/edmd /path/to/file.md &
|
|
```
|
|
|
|
Two SPM targets (see `Package.swift`):
|
|
- **`EdmundCore`** — the library: all editor logic, parsing, rendering, the
|
|
`EditorTextView`. Has the tests. **Most work happens here.**
|
|
- **`edmd`** — the executable: `NSDocument` app shell, Settings (SwiftUI),
|
|
menus, window setup. Depends on EdmundCore.
|
|
|
|
Dependencies: `swift-markdown` (CommonMark/GFM parsing), `SwiftMath` (LaTeX), and `Sparkle` (auto-update).
|
|
|
|
---
|
|
|
|
## 2. The two non-negotiable invariants
|
|
|
|
Break either and the whole editor misbehaves in subtle ways.
|
|
|
|
1. **Text storage always equals `rawSource`.** Rendering is *attribute-only* —
|
|
we never insert/delete display characters. Delimiters (`**`, `` ` ``,
|
|
`[!note]`, etc.) are *hidden* (near-zero font + clear color), never stripped.
|
|
Consequences: no `NSTextAttachment` (TextKit 2 only honors them on U+FFFC,
|
|
which `rawSource` never contains — so images/icons are drawn as overlays
|
|
instead, see §5); display offset == raw offset (identity mapping).
|
|
|
|
2. **TextKit 2 only.** Never touch `NSTextView.layoutManager` and never store
|
|
`NSTextBlock`/`NSTextTable` attributes — either silently reverts the view to
|
|
TextKit 1 for good. A DEBUG tripwire asserts if TK1 is engaged. Layout is
|
|
viewport-based (only on-screen content is laid out), which is what makes big
|
|
documents fast.
|
|
|
|
---
|
|
|
|
## 3. Rendering pipeline (the core loop)
|
|
|
|
```
|
|
rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs
|
|
│
|
|
├─ SyntaxHighlighter (swift-markdown
|
|
│ Walker + custom parsers for
|
|
│ callouts, ==highlight==, wikilinks,
|
|
│ %% and <!-- --> comments, footnotes,
|
|
│ math, backslash escapes, inline HTML
|
|
│ tags incl. <img>, GFM autolinks)
|
|
└─ writes attributes into textStorage
|
|
```
|
|
|
|
- **`BlockParser`** (`Parsing/`) splits `rawSource` into `Block`s (one per
|
|
logical block: paragraph, heading — ATX or setext, list run, quote/callout
|
|
run, code fence, indented code run, table…). `Block.kind` is a `BlockKind`
|
|
enum (e.g. `.quoteRun(isCallout:)`). The indented-code rule is the parser's
|
|
only backward-looking one (a run starts only after a blank line), so
|
|
`consumeBlock` carries a `prevLine` context and the incremental parse won't
|
|
resync onto an indented-code-ish line.
|
|
- **`SyntaxHighlighter`** + `+Walker`/`+WalkerInline`/`+CustomParsers` produce
|
|
styling spans. The custom parsers handle the non-CommonMark syntax.
|
|
- **`styleBlock(_:cursorPosition:)`** (`Rendering/EditorTextView+Rendering.swift`)
|
|
renders ONE block to an attributed string. Each feature has a `Rendering/`
|
|
extension: Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks.
|
|
- **Recompose** (`TextView/EditorTextView+Composition.swift`) drives styling:
|
|
- `recompose(cursorInRaw:)` — full: replaces storage with rawSource, restyles
|
|
all blocks (load, undo, indent).
|
|
- `recomposeDirty(_:cursorInRaw:)` — restyles a specific set of block indices
|
|
in place (the workhorse; attribute-only).
|
|
- `recomposeIncremental(...)` — restyles just the block(s) the cursor moved
|
|
between (most cursor moves).
|
|
- **Active block**: the block under the caret renders its *raw* markdown
|
|
(delimiters visible/editable); all others render styled. This is the "live
|
|
preview reveals the active line" behavior.
|
|
|
|
- **Lazy styling** (`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). Keeps large docs responsive.
|
|
|
|
---
|
|
|
|
## 4. Edit flow & undo
|
|
|
|
- Edits go through NSTextView's normal path: `shouldChangeText` records a
|
|
coalesced undo snapshot → NSTextView mutates storage → `didChangeText` syncs
|
|
`rawSource` and restyles the edited block(s) (`+EditFlow`, `+Composition`).
|
|
- **Incremental parsing**: edits capture a pending edit on `EditorTextStorage`
|
|
and reparse a window, not the whole doc.
|
|
- **Undo/redo** is custom: stacks of `rawSource` snapshots (`+Undo.swift`),
|
|
bypassing NSTextView's built-in undo. Restoring a snapshot *diffs* the
|
|
snapshot against the current text (`textDiff`, single contiguous span) and
|
|
applies it with the range-bounded `recomposeReplacing` — never a full
|
|
`recompose`, which would reset every fragment to a TextKit 2 height
|
|
estimate and make the follow-up scroll land wrong. The changed text is
|
|
selected (caret at the deletion point for pure deletions) and drives the
|
|
viewport: hold if any of it is on-screen, else center it. The snapshot's
|
|
stored caret is only a fallback for a no-op diff.
|
|
- **Cursor tracking** (`+SelectionTracking`): moving between blocks restyles
|
|
both; moving within a block updates which token's delimiters are revealed.
|
|
|
|
---
|
|
|
|
## 5. TextKit 2 specifics (how visuals are drawn)
|
|
|
|
- **`DecoratedTextLayoutFragment`** (custom `NSTextLayoutFragment`, in
|
|
`+TextKit2.swift`) draws two kinds of custom attributes behind/over the text:
|
|
- **`.blockDecoration`** (paragraph-level): callout boxes, quote bars, table
|
|
borders, thematic-break rules, code-block backgrounds. Fragments tile
|
|
vertically, so a multi-line run renders as one continuous box/bar. A box's
|
|
`bottomPad` grows the *last* fragment's frame (TextKit 2 omits trailing
|
|
paragraph spacing from the fragment, so padding done that way would be dead
|
|
space).
|
|
- **`.fragmentOverlay`** (character-level): an image *or stroked vector
|
|
path* drawn at a character's laid-out position — rendered math, list
|
|
bullets/checkboxes, the callout header icon+name image, the custom-title
|
|
callout icon (path). The anchor glyph is hidden and `.kern` reserves the
|
|
drawing's advance width (the same trick the table renderer uses).
|
|
- **Hiding** text = `hiddenFont` (≈0.01pt) + clear `foregroundColor`. This is
|
|
how delimiters and synthesized-but-in-source markers vanish without changing
|
|
the string.
|
|
- **Image overlays only work on single-line fragments.** Drawing an *image* on
|
|
a *multi-line* (wrapping) fragment re-triggers a layout pass that wedges it
|
|
to one line; drawing a *shape* does not. So the wrapping callout custom
|
|
title's icon is a stroked `CGPath` (`SVGPath` parses the vendored Lucide
|
|
geometry), never an image — see `docs/investigations/archives/callout-title-wrap-investigation.md`
|
|
for the full saga.
|
|
|
|
---
|
|
|
|
## 6. Feature map (where to look)
|
|
|
|
| Area | Files |
|
|
| --- | --- |
|
|
| Editor core / state | `TextView/EditorTextView.swift` (+ many extensions) |
|
|
| Parsing | `Parsing/BlockParser.swift`, `SyntaxHighlighter*.swift`, `CodeHighlighter.swift`, `CodeSyntaxPalette.swift` (shared Tomorrow/One-Dark hex table — read by both the editor's `NSColor` palette and the HTML CSS generator) |
|
|
| Block model | `Model/Block.swift`, `Callout.swift`, `EditorTheme.swift`, `ListIndentState.swift` |
|
|
| Rendering | `Rendering/EditorTextView+*Rendering.swift` (Callout, Code, Image, List, Math, Table, WikiLinks) |
|
|
| Read mode / Export | `Export/` — `HTMLRenderer` (MarkupVisitor → HTML; callout/checkbox icons are inline Lucide SVGs from `LucideIcons`), `HTMLTheme` (EditorTheme → CSS), `DocumentHTML` (assembly + asset inlining: math + local images → data URIs), `ReadModeWebView`, `MarkdownPrinter` (PDF/Print). `Document.refreshReadView()` keeps an open Read view in sync with edits and theme changes. |
|
|
| Icons | `Model/LucideIcons.swift` — vendored [Lucide](https://lucide.dev) SVGs (ISC, `LICENSES/lucide.txt`). Callout headers use them in **both** modes: Read inlines the SVG (CSS-tinted via `currentColor`); Edit rasterizes to a tinted `NSImage` overlay. We *can't* ship SF Symbols in exported PDFs (license). App-chrome (toolbar/settings) SF Symbols are fine (in-app UI). Edit-mode task checkboxes still use SF Symbols (on-screen only); Read-mode checkboxes are a composed Lucide SVG. |
|
|
| Edit behaviors | `Editing/EditorTextView+{List,Blockquote}Continuation.swift`, `+Indentation.swift` |
|
|
| Formatting commands | `Editing/EditorTextView+Formatting{Core,Commands}.swift` (Format-menu actions: bold/lists/links/callouts/…); menu built from `edmd/App/FormatMenu.swift` |
|
|
| Lazy/compose/undo/scroll | `TextView/EditorTextView+{Composition,LazyStyling,Undo,TypewriterScroll,SelectionTracking,ContentWidth,EditFlow}.swift` |
|
|
| App shell | `edmd/App/{main,Document,DocumentController}.swift`; menu bar in `main.swift` `setupMenuBar()` + `FormatMenu.swift`; Sparkle `SPUStandardUpdaterController` in `AppDelegate` |
|
|
| Settings (SwiftUI) | `edmd/Settings/*` (AppSettings = UserDefaults keys; FontSettings; Appearance/General/Advanced views) |
|
|
| Crash-log uploading | `EdmundCore/Diagnostics/CrashReporter.swift` (see §7) |
|
|
| Auto-update | Sparkle 2.x. `Info.plist`: `SUFeedURL` (raw GitHub URL to `appcast.xml`), `SUPublicEDKey` (ed25519 public key). `scripts/release.sh`: build → DMG (sindresorhus `create-dmg`, **npm** — not the homebrew tool) → EdDSA sign → update appcast → `gh release create`. The DMG is the Sparkle enclosure (it mounts the image and installs the `.app` inside). CI: `.github/workflows/release.yml` (tag-triggered). Full pipeline, signing, and the `RELEASE_TOKEN` PAT: §13. |
|
|
| Status bar | `edmd/Views/StatusBarView.swift` |
|
|
| Build/packaging | `scripts/build-app.sh` (release build + Sparkle.framework embedding + signing), `Package.swift`, `Info.plist`, `Resources/` |
|
|
|
|
Notable subsystems: **typewriter scroll** (keeps caret vertically centered;
|
|
must lay out the viewport↔caret span before measuring or it reads stale TK2
|
|
height estimates — and re-centers only on *typing*, not on clicks: a mouse-down
|
|
sets `suppressTypewriterCentering` so positioning the caret by clicking doesn't
|
|
yank the viewport), **content width** (`+ContentWidth.swift`: an **absolute
|
|
physical** max-column width — set in cm/in in Settings, stored as cm, converted
|
|
to points via the display's real PPI (`NSScreen.physicalPPI`, from
|
|
`CGDisplayScreenSize`). It's a CSS `max-width` cap applied as a symmetric
|
|
`textContainerInset.width`: windows wider than the cap center the column,
|
|
narrower ones fill. Recomputed on resize and when the window moves to a
|
|
differently-scaled display (`NSWindow.didChangeScreenNotification`)).
|
|
|
|
**Format menu & shortcuts** are pure AppKit (the app has no SwiftUI scene, so
|
|
SwiftUI `Commands` isn't an option). `FormatMenu.swift` is a declarative command
|
|
table (`MenuCommand` + `Shortcut`, each with a stable `id` so a later pass can
|
|
read per-command shortcut overrides from UserDefaults) that builds the menu; items
|
|
use a nil target and route through the responder chain to the focused
|
|
`EditorTextView`'s `@objc format…` actions — the same wiring as undo/redo. Those
|
|
actions funnel through two primitives in `+FormattingCore.swift`
|
|
(`applyFormattingEdit` for a single contiguous edit, modeled on the Tab-indent
|
|
path; `applyWholeDocumentEdit` for non-contiguous edits like footnotes). The
|
|
View-menu ⌘E item (and the toolbar button) *toggles* the editing view ↔ Read
|
|
via `Document.toggleViewMode`. **Source is not a third toggle stop** — it's a
|
|
persisted preference (`AppSettings.sourceMode`, a "Source Mode" checkbox in the
|
|
View menu and the toolbar button's right-click menu): when on, the editing half
|
|
of the toggle is Source instead of Edit (so ⌘E flips Source ↔ Read), and a
|
|
freshly opened document honors it. The toolbar's view-mode button left-clicks to
|
|
toggle and right-clicks for the full mode menu (see the §8 gotcha on why that
|
|
right-click is intercepted in `DocumentWindow.sendEvent`). The toolbar enables
|
|
`allowsUserCustomization` (Customize Toolbar… is an AppKit `NSToolbar` feature,
|
|
not a SwiftUI one).
|
|
|
|
**Read mode is a separate WKWebView**, not an editor styling mode. Entering
|
|
`.reading` swaps the editor's scroll view for a `ReadModeWebView` that renders
|
|
the document as themed HTML (the `Export/` group); Edit and Source stay on the
|
|
`EditorTextView`. The HTML path walks the *same* swift-markdown `Document` the
|
|
editor parses (one parser, two back-ends: `SpanCollector` → editor attributes,
|
|
`HTMLRenderer` → HTML), themed from the *same* `EditorTheme`/`CalloutStyle` via
|
|
`HTMLTheme`, so the two can't drift. The webview disables JavaScript, loads the
|
|
generated HTML against an explicit `about:blank` base URL, and inlines every
|
|
asset (math/icons as data URIs) so it needs no file/network reach; external
|
|
`http`/`https`/`mailto` links open in the default browser, while `file:` and
|
|
unknown schemes are cancelled instead of fetched in-view. Raw HTML passes
|
|
through per GFM, filtered by tagfilter + hardening, and the page carries a
|
|
`script-src 'none'` CSP meta (see §10 for the full policy). **File ▸ Export as
|
|
PDF… / Print… (⌘P)** run the same HTML through `WKWebView.printOperation` for
|
|
real vector (selectable) text — `MarkdownPrinter`. Math glyphs are high-DPI PNG
|
|
(SwiftMath has no SVG path yet); callout/checkbox icons are inline Lucide SVG
|
|
(vector); everything else is vector. Code blocks are syntax-colored by
|
|
`CodeHighlighter` (same tokenizer and `CodeSyntaxPalette` as Edit mode). Local
|
|
images are inlined as data URIs via a `baseURL` (document directory) threaded
|
|
through `DocumentHTML`/`ReadModeWebView`/`MarkdownPrinter`; remote images are
|
|
off by default (`ReadRenderOptions.allowRemoteImages`). `[[Wikilinks]]` and
|
|
relative markdown links use private URL schemes (`x-edmund-wiki:`,
|
|
`x-edmund-link:`) in the rendered HTML so the nav coordinator can intercept
|
|
them and route through the app's document graph without JavaScript.
|
|
|
|
---
|
|
|
|
## 7. Settings & persistence
|
|
|
|
- `AppSettings` (`edmd/Settings`) defines UserDefaults keys + typed accessors.
|
|
SwiftUI panes use `@AppStorage`. Live changes broadcast to every open
|
|
`Document.editor` (see the font/line-height/content-width `applyTo…` helpers).
|
|
- Theme/appearance/fonts flow into `EditorTheme` → the editor's derived
|
|
`bodyFont`, colors, paragraph styles.
|
|
- **Max content width** persists as **centimetres** (`maxContentWidthCm`); the
|
|
cm/in shown in Settings is a display unit (`contentWidthUnit`), the column
|
|
itself is always physical (see §6 content width). The default is locale-aware
|
|
— 5 in (US) / 12 cm (elsewhere) — and doubles as the slider's magnetic snap
|
|
point; the slider runs from a 3 in floor to the current screen's physical
|
|
width (`NSScreen.physicalWidthCm`).
|
|
- **Window size** persists as the last document window's full **frame** size
|
|
(`lastWindowSize`) — see the §8 gotcha on why it's the frame, not the content
|
|
size.
|
|
- **Diagnostic logging** (`EdmundCore/Diagnostics/Log.swift`): an always-on
|
|
(opt-out) file logger. `Log.{debug,info,error}(_:category:)` and
|
|
`Log.measure(_:) { … }` (single-line durations) write to
|
|
`~/.edmund/logs/edmund-YYYY-MM-DD.log` on a private serial queue. One
|
|
compile-time level threshold ships (DEBUG = `debug`+; release = `info`+) — the
|
|
user only toggles it on/off and picks a retention window (Settings ▸ General ▸
|
|
Diagnostics). `AppSettings.applyLogging()` pushes the toggle/retention into
|
|
`Log.configure` at launch and on change; retention is pruned there.
|
|
- **Crash-log uploading** (`EdmundCore/Diagnostics/CrashReporter.swift`): opt-in
|
|
(default off), fire-and-forget upload of the `.ips` crash reports macOS writes
|
|
to `~/Library/Logs/DiagnosticReports/`. On launch, if `AppSettings.sendCrashLogs`
|
|
is true, we POST any `edmd-*.ips` files not in `AppSettings.sentCrashReports`
|
|
(dedup) to `CrashReporter.reportingEndpoint`. Note: `edmd` is the Mach-O
|
|
executable name (not "Edmund") — that's the prefix in crash-report filenames.
|
|
**The Settings ▸ Advanced toggle is currently commented out** (the `// Crash
|
|
reports:` GridRow in `AdvancedSettingsView.swift`); uncomment it and replace the
|
|
placeholder `reportingEndpoint` URL once the receiving server exists. The upload
|
|
path reads `~/Library/Logs/DiagnosticReports/` directly, which only works
|
|
because the app is not sandboxed; if App Sandbox is ever adopted, switch to
|
|
MetricKit's `MXCrashDiagnostic` API instead.
|
|
|
|
---
|
|
|
|
## 8. Quirks & gotchas (will bite you)
|
|
|
|
- **SwiftMath fonts**: `build-app.sh` must copy `*.bundle` into the `.app` root
|
|
(it does). Without it, the app **crashes the instant it renders any LaTeX**.
|
|
- **Sparkle codesign — must seal the whole bundle**: at install time Sparkle
|
|
re-validates the downloaded update's Apple code signature
|
|
(`SUUpdateValidator`). Even with a valid EdDSA signature, a bundle that reports
|
|
as code-signed but fails `SecStaticCodeCheckValidity` is rejected as *"The
|
|
update is improperly signed and could not be validated."* The old build script
|
|
signed only the main binary as a standalone file and never sealed the bundle —
|
|
so it had no `_CodeSignature/CodeResources` and **every Sparkle update failed**
|
|
(the v0.1.0→0.1.1 update error). The fix: `build-app.sh` seals the whole `.app`
|
|
(`codesign --deep`). The obstacle is the SwiftMath resource bundle, which must
|
|
sit at the `.app` root (its generated `Bundle.module` accessor is hardcoded to
|
|
`Bundle.main.bundleURL`), and codesign refuses to seal a bundle with any item
|
|
at the root. So we seal while the root holds only `Contents/`, then copy the
|
|
SwiftMath bundle in **after** signing. That one unsealed root item makes
|
|
`codesign --verify` (CLI) and `--strict` complain, but Sparkle's actual check
|
|
is non-strict (`SecStaticCodeCheckValidityWithErrors` with
|
|
`kSecCSCheckAllArchitectures`) and tolerates it — verified against that exact
|
|
API. A Developer ID cert + notarization would be cleaner; ad-hoc is the current
|
|
limit.
|
|
- **Sparkle keypair**: set up and verified — public key in `Info.plist`
|
|
`SUPublicEDKey`, private key in the login keychain and the CI secret
|
|
`SPARKLE_ED_PRIVATE_KEY`. Don't let them diverge. Full release/signing details
|
|
in §13.
|
|
- **`create-dmg` — npm only, three quirks**:
|
|
- Install via **npm** (`npm install --global create-dmg`), not Homebrew — they
|
|
are different tools with incompatible CLIs. Requires Node ≥20 (`node --version`).
|
|
- Exits **2** (not 0) when it can't Developer-ID-sign the image (we ship
|
|
ad-hoc). It still produces the `.dmg`; `release.sh` and the CI step both
|
|
use `|| true` and then verify the file exists.
|
|
- Names the output `"Edmund <version>.dmg"` (space, not hyphen). Both scripts
|
|
rename it to `Edmund-<version>.dmg` before signing and uploading.
|
|
- **Stale release builds**: `swift build -c release` / `build-app.sh` sometimes
|
|
reuses stale object files (you'll run an old binary and be baffled). Debug
|
|
builds have the same disease: `swift build` can print `Build complete!`
|
|
after compiling a changed file **without relinking `edmd`** — the app then
|
|
runs old code. Detect it by grepping
|
|
`strings .build/arm64-apple-macosx/debug/edmd` for a long string literal
|
|
unique to the new code (literals ≤15 bytes are stored inline on arm64 and
|
|
never appear); cure with `swift package clean`. Never delete
|
|
`.build/…/edmd.build/` by hand — that corrupts the output-file-map and
|
|
wedges the target until a full clean. If a
|
|
visual change "doesn't take," `rm -rf .build` and rebuild. `shasum` the binary
|
|
to confirm it changed.
|
|
- **`open Edmund.app` foregrounds a *running* instance instead of relaunching.**
|
|
Always `pkill -x edmd` first, or launch the binary directly
|
|
(`build/Edmund.app/Contents/MacOS/edmd file.md &`).
|
|
- **Screencapture for visual verification**: capture a specific window by id
|
|
(reliable even if not frontmost) via Quartz:
|
|
`CGWindowListCopyWindowInfo` → find the window by `kCGWindowName` → `screencapture -x -o -l<id> out.png`.
|
|
The desktop wallpaper defeats brightness-based auto-cropping; crop by the
|
|
detected window bounds instead. Window-server state can glitch (tiny windows,
|
|
state restoration) after many rapid launch/kill cycles — `rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState` and relaunch.
|
|
- **Width not known at first styling**: on load the view may be unsized, so
|
|
anything that bakes the content width (e.g. callout header images) renders at
|
|
a fallback width until a width-settled re-render. Prefer real wrapping text
|
|
over width-baked images where possible.
|
|
- **Attribute-only changes don't re-measure geometry in TK2**: after restyling a
|
|
block whose height/indent changed, you must `invalidateLayout(for:)` its range
|
|
or the fragment keeps a stale frame (empty bands / clipped lines). `recompose
|
|
Dirty` and the idle drain already do this; new paths must too.
|
|
- **TextKit 2 height *estimates* are the root of most viewport glitches.** A
|
|
fragment has a real frame only once laid out; everything else (and the total
|
|
document height) is an estimate that gets corrected as layout reaches it —
|
|
which is what makes the scroller jump and scroll-to-target land wrong (a
|
|
widely documented TK2 limitation; even TextEdit shows it). Mitigations here:
|
|
documents ≤ `fullLayoutMaxLength` (100k UTF-16) are kept **fully laid out**
|
|
by a coalesced next-run-loop settle (`scheduleFullLayoutSettle`, wrapped in
|
|
`preservingViewportAnchor` so corrections never shift what's on screen);
|
|
`centerViewportOnCaret` re-measures after its first scroll and corrects the
|
|
residual error; and undo/redo avoids resetting layout at all (see §4). Don't
|
|
add code that trusts an off-screen fragment's y-coordinate without laying
|
|
out the span first.
|
|
- **TK2 can strand content above the document origin.** Edits near the top of
|
|
a tall document sometimes leave the first fragment at negative y: the first
|
|
line sits above the visible area and the scroller is already at the top, so
|
|
the user can't reach it. `repairContentAboveOrigin` (run by the layout
|
|
settle) detects a negative first-fragment origin and re-lays start→viewport
|
|
inside `preservingViewportAnchor` to renormalize.
|
|
- **A selection taller than the viewport must be revealed at its *nearest*
|
|
end** (`scrollRangeToVisible` override): always revealing the top fought the
|
|
drag-selection autoscroll and oscillated the viewport mid-drag.
|
|
- **Never mutate storage while an IME is composing (`hasMarkedText()`)**: during
|
|
composition the storage holds the provisional marked text, so `storage ==
|
|
rawSource` is transiently false and `didChangeText` defers syncing until
|
|
commit. Any styling that runs `beginEditing`/`setAttributes`/`invalidateLayout`
|
|
mid-composition can strand the marked text in the input context — after which
|
|
`didChangeText` keeps bailing on its own guard and the invariant stays broken,
|
|
so every later edit drifts the caret (the old "delete-drift" bug). 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 if a composition is ever left stranded. Full write-up:
|
|
`docs/investigations/delete-drift-investigation.md`.
|
|
- **AppKit does not pair every storage mutation with `didChangeText`.** A
|
|
drag-move of the 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 the
|
|
caret and autosave writes the stale `rawSource` (delete-drift round 4).
|
|
`shouldChangeText` therefore schedules a next-run-loop bypass check: a
|
|
storage `pendingEdit` still unconsumed by then means the closing
|
|
`didChangeText` never came, and the editor heals by running the same sync
|
|
(`+EditFlow`, `scheduleBypassedEditSyncCheck`; `healing storage edit that
|
|
bypassed didChangeText` in `~/.edmund/logs` is its breadcrumb). Never build
|
|
a sync path on the assumption that `didChangeText` follows every edit.
|
|
- **A bypassed edit also leaves TextKit 2's selection fixup queued** (delete-
|
|
drift round 6). The skipped
|
|
`-[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:]` fires at
|
|
the **next** `endEditing` — even an attribute-only restyle — where it maps
|
|
the stale selection against post-edit coordinates and leaps the caret blocks
|
|
away. It moves even a freshly set, valid caret, so the heal must set the
|
|
caret (derived from the pendingEdit hull) *before* the sync **and re-assert
|
|
it after** (`+EditFlow`). Corollary: any suspicious selection change arrives
|
|
mid-recompose (`up=Y` in traces); under verbose diagnostics
|
|
`traceSelectionOrigin` logs the call stack of whoever moved it — start there.
|
|
This class of bug does not reproduce headless (the test harness runs the
|
|
fixup synchronously): use the in-process repro driver — DEBUG builds accept
|
|
`-debug.reproScript <path>` (`Sources/edmd/App/ReproScript.swift`) and replay
|
|
keystroke scripts (`caret` / `type` / `backspace` / `bypassdelete` /
|
|
`assertcaret` / `logsel`) through the real `window.sendEvent` key path — no
|
|
Accessibility/TCC needed, works with the window on an invisible Space. Full
|
|
chronicle: `docs/investigations/delete-drift-investigation.md` round 6; step-by-step method
|
|
for producing such repros: `docs/dev-guides/live-repro-guide.md`.
|
|
- **A custom toolbar item can't win a right-click from a view-level handler.**
|
|
With `NSToolbar.allowsUserCustomization = true`, the toolbar turns any
|
|
secondary (right / control) click over the toolbar — *including* a custom item
|
|
view — into its own "Customize Toolbar…" context menu. Setting the view's
|
|
`menu`, overriding `rightMouseDown`, and a secondary-button
|
|
`NSClickGestureRecognizer` **all lose**, because the toolbar claims the click
|
|
downstream of them. The fix (view-mode button): intercept in
|
|
`DocumentWindow.sendEvent(_:)` — the documented funnel every window event
|
|
passes through *before* the toolbar acts — and when the click falls inside the
|
|
button's bounds, pop the menu and `return` (swallow it). Other right-clicks
|
|
fall through to `super`. (Caveat: in true fullscreen the toolbar moves to a
|
|
separate window, so this main-window hook wouldn't cover that case.)
|
|
- **Window-size persistence must round-trip the frame, not the content size.**
|
|
Saving `contentView.bounds.size` and re-applying it as the initializer's
|
|
`contentRect` makes the window grow by the title-bar + (unified) toolbar height
|
|
on every reopen — and content heights below the frame `minSize` get silently
|
|
rejected. Save `window.frame.size` and re-apply it with `window.setFrame(_:)`
|
|
**after the toolbar is installed** (the frame is only final then), so frame-in
|
|
== frame-out (`windowDidResize` ↔ `makeWindowControllers` in `Document.swift`).
|
|
|
|
---
|
|
|
|
## 9. Known issues / lurking problems
|
|
|
|
- **Images can't be drawn on multi-line (wrapping) fragments** (TextKit 2
|
|
image wedge — collapses the fragment's layout to one line). Resolved for the
|
|
callout custom-title icon by drawing it as a stroked `CGPath` instead
|
|
(shapes don't trigger the wedge); the constraint still holds for any *new*
|
|
overlay that could share a line with wrapping text. Full investigation:
|
|
`docs/investigations/archives/callout-title-wrap-investigation.md`.
|
|
- *(Add new ones here as you find them — with a one-line repro and a pointer to
|
|
any deeper write-up in `docs/`.)*
|
|
|
|
## 10. Still to address
|
|
|
|
- **Raw HTML (§G): Read renders it per GFM; Edit shows colored source** — the
|
|
GitHub split (their editor shows source too; rendered HTML in Edit is
|
|
impossible under the storage==rawSource invariant). Read mode passes raw
|
|
HTML through (GFM §6.10 inline / §4.6 blocks) filtered by
|
|
`HTMLRenderer.filterRawHTML` = the spec tagfilter (§6.11: the nine dangerous
|
|
tag names get `<` → `<`) **plus** Edmund hardening beyond spec: `on*`
|
|
event-handler attributes stripped, `javascript:`/`vbscript:` URL schemes
|
|
neutralized. Defense layers behind that filter: the read webview disables
|
|
JS, the page carries a `script-src 'none'` CSP meta (`DocumentHTML.full` —
|
|
Read view and Print/PDF both consume it), and it loads with `baseURL: nil`.
|
|
A raw `<img src=…>` (lone or inside a block) is rewritten to the `md-image`
|
|
placeholder — the only way images load under `baseURL: nil`, and the
|
|
remote-image-policy chokepoint (`DocumentHTML.fillImages`). Edit mode:
|
|
`.htmlBlock` blocks (all seven §4.6 start conditions in `BlockParser`) and
|
|
inline tags (full §6.10 grammar in `parseHTMLTags`) render as colored
|
|
source; the `SyntaxHighlighter.htmlFormatTags` whitelist
|
|
(`u`/`kbd`/`mark`/`sub`/`sup`/`small`) is *presentation* in Edit (rendered
|
|
formatting), not sanitization. Deliberate divergences from spec: the HTML
|
|
comment regex is laxer (interior `--` allowed); a `<`-line that forms a
|
|
valid table header+separator becomes a table (tables win); the type-1 tag
|
|
set is pinned to CommonMark 0.29 (`script|pre|style`, no `textarea`); a
|
|
lone self-closing `<script/>` is a type-7 HTML block, not a paragraph; the
|
|
Edit-mode `htmlPairRegex` attr swallow breaks on `>` inside a quoted
|
|
attribute of a whitelist open tag (falls back to colored source). Still
|
|
deferred in Edit mode: reference links/definitions, blockquote lazy
|
|
continuation, entity-reference styling, the 2-trailing-space hard-break
|
|
span.
|
|
- **Edit-mode table alignment** distributes each cell's slack via `.kern`,
|
|
putting the right/center "before" pad on the cell's *hidden* leading pipe
|
|
(0.01pt glyph) — kern still adds advance there. See
|
|
`EditorTextView+TableRendering.swift`. Column widths are clamped to the
|
|
available line width (`distributeColumnWidths`, `EditorTextView+TableSupport.swift`)
|
|
so one very wide cell can't stretch the table off screen; a cell whose
|
|
styled width still exceeds its clamped column hides its real characters and
|
|
is redrawn wrapped instead, via a `.tableCellWraps` attribute
|
|
(`EditorTextView+TextKit2.swift`) resolved through a detached scratch
|
|
`NSTextContentStorage`/`NSTextLayoutManager` sized to the column's content
|
|
width — the same "hide the real chars, draw the visual yourself" pattern as
|
|
`.fragmentOverlay`, needed because TextKit 2 only wraps a whole paragraph at
|
|
the container edge and has no per-cell flow region (that's what
|
|
NSTextTable/NSTextBlock are for, and they're banned, see §2). Click-to-caret
|
|
placement inside a wrapped, non-active cell is approximate as a result.
|
|
Every data row also draws a full-width bottom grid line (`.tableRow`'s
|
|
`bottomBorder`) — the header/body boundary already gets its line from
|
|
`separator`, so only rows after the separator set it.
|
|
- *(Track larger roadmap items in README/ROADMAP; track code-debt here.)*
|
|
|
|
---
|
|
|
|
## 11. Quick start for an agent
|
|
|
|
1. Skim README (what/why), then this doc (how).
|
|
2. `swift build && swift test` — confirm green before changing anything.
|
|
3. Find the feature's `Rendering/` or `TextView/` extension via §6.
|
|
4. Make the change; add/adjust tests in `Tests/EdmundTests` (helpers:
|
|
`makeEditor()`, `ensureFullLayout()`, `styleBlock()`).
|
|
5. Verify per §12 before committing.
|
|
|
|
Debugging a live-only bug (caret, IME, drag, viewport timing)? Follow
|
|
`docs/dev-guides/live-repro-guide.md` — trace-reading first, then the in-process
|
|
ReproScript driver; don't burn time on headless attempts for that class.
|
|
|
|
---
|
|
|
|
## 12. Working agreements (pre-commit checklist)
|
|
|
|
Do these **before every commit** (this is the workflow that worked; deviate
|
|
only with reason):
|
|
|
|
1. **`swift test` is green** (all pass). Add tests for new behavior / bug repros.
|
|
2. **Visual changes are eyeballed** — build the app and `screencapture` the
|
|
result (§8), or render offscreen to a PNG. Don't trust headless layout alone
|
|
for anything that draws.
|
|
3. **Frequent, small, logical commits** — one feature/fix each. Don't discard uncommited changes.
|
|
4. **Don't autopush, PR, or merge unless asked.** Branch off `main`
|
|
(don't commit straight to it); each fix on its own branch.
|
|
5. Touch only what the task needs; match surrounding style; don't refactor
|
|
unrelated code.
|
|
6. **Concurrent local work uses `git worktree`, not multiple clones or
|
|
branch-switching.** `git worktree add .worktrees/<branch> <branch>` — the
|
|
directory path mirrors the branch name (e.g. `.worktrees/fix/foo` for
|
|
`fix/foo`), so branches keep the normal `type/slug` naming and never get
|
|
renamed to `worktree-*`. `.worktrees/` is gitignored. This is distinct from
|
|
`.claude/worktrees/`, which Claude Code's own EnterWorktree tool manages
|
|
automatically for agent isolation — don't hand-edit that one.
|
|
|
|
If you (the agent) improve this workflow or discover a better verification
|
|
trick, update this section.
|
|
|
|
---
|
|
|
|
## 13. Release & CI pipeline
|
|
|
|
How a release happens, plus the non-obvious things that broke shipping 0.1.0.
|
|
|
|
**Flow (tag-triggered).** Push a `vX.Y.Z` tag → `.github/workflows/release.yml`
|
|
(`macos-14`): build the `.app` (`build-app.sh`) → DMG (npm `create-dmg`) →
|
|
EdDSA-sign the DMG → `gh release create` (notes are the matching `CHANGELOG.md`
|
|
section, extracted by `awk`) → commit the new `<item>` into `appcast.xml` on
|
|
`main`. `scripts/release.sh` mirrors this locally but leaves the appcast
|
|
commit/push to you. The new `<item>` also gets a `<description>` (HTML release
|
|
notes from the CHANGELOG section, via `scripts/changelog-to-html.py`) so
|
|
Sparkle's update dialog shows the changelog in its scrollable pane.
|
|
|
|
**To cut a release:** bump `CFBundleShortVersionString` / `CFBundleVersion` in
|
|
`Info.plist`, add a `## [x.y.z]` section to `CHANGELOG.md`, merge to `main`,
|
|
then `git tag vx.y.z && git push origin vx.y.z`.
|
|
|
|
**EdDSA keypair — set up, not a placeholder.** The keypair exists. The public
|
|
key is in `Info.plist` `SUPublicEDKey` (`0XdLbbuO…`); the private key lives in
|
|
two places that must stay the *same* keypair: the maintainer's **login
|
|
keychain** (used by `release.sh` locally, no flag) and the GitHub Actions secret
|
|
**`SPARKLE_ED_PRIVATE_KEY`** (used by CI). Verified end-to-end: a CI-signed DMG
|
|
verifies against the `Info.plist` public key (`sign_update --verify <dmg> <sig>`).
|
|
If the signing key and `SUPublicEDKey` ever diverge, the DMG signs fine but every
|
|
user's update fails signature verification.
|
|
|
|
**Signing flag — `sign_update -s` is fatal for newly generated keys.** Sparkle's
|
|
`sign_update` deprecated `-s <key>`; for keys generated after that change it
|
|
prints only a deprecation warning and **exits 1** ("no longer supported"). This
|
|
killed the first 0.1.0 release. Feed the key on **stdin** instead — both
|
|
`release.yml` and `release.sh` now use:
|
|
`echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>`.
|
|
|
|
**Appcast push to protected `main` needs an admin PAT.** `main` requires the
|
|
`test` status check. The workflow's last step commits the appcast update and
|
|
pushes to `main`, but the default `GITHUB_TOKEN` / `github-actions[bot]` is not
|
|
an admin, so the required check rejects it (`GH006 … protected branch hook
|
|
declined`). `main` protection has `enforce_admins: false`, so an **admin's**
|
|
push bypasses the check — `release.yml`'s **checkout step** therefore
|
|
authenticates with a fine-grained admin PAT in the **`RELEASE_TOKEN`** secret
|
|
(Contents: read/write). Set it on *checkout*, not by rewriting the push URL:
|
|
`actions/checkout` persists an `http.<host>.extraheader` credential that
|
|
otherwise overrides inline-URL creds, so the push would keep using the bot
|
|
token. **`RELEASE_TOKEN` expires 2027-06-27** — rotate it before then or
|
|
releases fail at the appcast push.
|
|
|
|
**Gatekeeper / notarization.** The app is ad-hoc signed, not notarized, so users
|
|
hit Gatekeeper on first launch; the README documents the
|
|
`xattr -dr com.apple.quarantine` / right-click-Open workarounds. A Developer ID
|
|
cert + notarization (see §8) would remove the prompt entirely.
|
|
|
|
**Never release anything through this pipeline except the main app.** The
|
|
tag-triggered flow above builds and ships `Edmund.app` only. Extension
|
|
payloads (e.g. the RaTeX WASM) are a separate concern with their own
|
|
hosting/release path — never bundled into or triggered by an Edmund version
|
|
tag. Case in point: the RaTeX WASM asset was pulled from release pending
|
|
`erweixin/RaTeX` inline-mode support and the Advanced Math extension's own
|
|
repo migration — see `misc/backlog.md`.
|
|
|
|
---
|
|
|
|
## 14. References
|
|
|
|
Dependencies and prior art worth consulting before designing something new here:
|
|
|
|
- [apple/swift-markdown](https://github.com/apple/swift-markdown) — the
|
|
CommonMark/GFM parser both back-ends walk (§3, §6 Read mode).
|
|
- [SwiftMath](https://github.com/mgriebling/SwiftMath) — LaTeX rendering
|
|
(raster only; no SVG output yet — why exported math is PNG, §6).
|
|
- [Sparkle](https://sparkle-project.org) — auto-update (§8, §13 for the
|
|
signing/appcast quirks).
|
|
- [Lucide](https://lucide.dev) — vendored icon SVGs (ISC), `Model/LucideIcons.swift`.
|
|
- [nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine)
|
|
— an independent AppKit + TextKit 2 live-preview markdown engine (Apache 2.0,
|
|
macOS 14+). Solves the same problems (viewport virtualization, live styling,
|
|
wiki links, reading column, LaTeX) with different trade-offs — useful
|
|
comparison point before inventing a new mechanism for an editing-experience
|
|
problem, and a candidate source of techniques (e.g. drag-select autoscroll,
|
|
overscroll) if we hit the same walls.
|