diff --git a/docs/inline-math-slice-framing.md b/docs/inline-math-slice-framing.md new file mode 100644 index 0000000..4428173 --- /dev/null +++ b/docs/inline-math-slice-framing.md @@ -0,0 +1,336 @@ +# Inline math — the first vertical slice (framing) + +**Revision 1 — pre-implementation, framing only. Ground truth scouted against +canonical `main` @ `352bf0b`, protocol v20, 2026-07-24.** + +Parent arc: `docs/inline-math-framing.md` (rev 2, merged as #154). Sibling +substrate lane: `docs/latex-grammar-math-substrate-framing.md` (rev 3), whose +Stage 1 landed as #144. + +This lane builds the **first end-to-end slice** of the parent's four-tier +pipeline: a deliberately small LaTeX-math subset that is detected, parsed, +laid out against a real OpenType MATH table, and **actually drawn on screen**. + +## 0. Why a slice, and not "Tier 2 + Tier 3" + +The obvious next unit was the parser (Tier 2) plus the layout engine +(Tier 3). It is rejected here for the parent arc's own reason. + +The substrate lane's **Q#LX5** refused to land the parser ahead of layout +because *"the `MathNode` shape is only validated once [a layout consumer] +exists"*. That argument does not stop at Tier 2. `MathBox` is only validated +once a **renderer** consumes it: an unrendered layout engine can be +self-consistent and still have the wrong shape — wrong units, wrong origin +convention, a baseline the draw path cannot use. Landing Tiers 2+3 with no +Tier 4 reproduces exactly the objection Q#LX5 raised, one layer up. + +So the unit of work is **thin and vertical, not broad and horizontal**: the +smallest grammar subset worth rendering, carried all the way to pixels. Every +layer acquires a real consumer immediately. Breadth — big operators, stretchy +fences, radicals, accents, display math — becomes follow-on work against an +API that has already been exercised rather than one that has only been +designed. + +The cost is honest and named in §7: the slice touches +`pmacs-gpu/src/main.rs`'s render path, which two other arcs also want. + +## 1. Ground truth (scouted 2026-07-24 @ `352bf0b`) + +### 1.1 Crate boundaries — the parent's file placement cannot work + +The parent framing's integration table lists `src/math_parse.rs` and +`src/math_layout.rs`, i.e. the **core `pmacs` crate**. Verified against the +tree, that placement is unusable: + +- **`pmacs-gpu` depends only on `pmacs-protocol`** (`pmacs-gpu/Cargo.toml:60`; + there is no `pmacs` dependency). A parser in the core crate is therefore + **unreachable from the frontend that renders it**. +- **`ttf-parser` reaches only `pmacs-gpu`.** Per-crate check: `pmacs` no, + `pmacs-protocol` no, `pmacs-gpu` yes (via `fontdb` → `cosmic-text` → + `glyphon`). A layout module in the core would be a genuinely new dependency + there, which is not what the parent's C1 established. + +Both also contradict the parent's own prose — its design contract ("the +instance never learns a pixel") and its protocol section ("math rendering is a +pure frontend responsibility in v0"). The table was the outlier. Q#MS1 fixes +it. + +### 1.2 The GPU text pipeline this slice hooks + +- `rebuild_code_slice` (`pmacs-gpu/src/main.rs:6136`) shapes **only the + visible byte slice**; spans/decorations/adornments arrive in whole-file + coordinates and are clipped and rebased onto it. +- Per line, `chunks_for_line` (`:5100`) produces `RichChunk`s whose + `ChunkSource` (`:7715`) is one of `Source { start }`, + `SourceTab { start }`, `Adornment { anchor }`. +- **Every existing variant is additive.** Adornments (inlay hints) inject text + *between* source bytes; nothing today *replaces* a source range with a box + of chosen width. That mechanism is what this slice must build (Q#MS4). +- `build_hit_runs` (`:7739`) derives the projected→source hit map from the + same chunks that feed glyphon, so the map and the shaped buffer cannot + disagree. Any new chunk kind must participate here or clicks land wrong. +- Custom drawing precedent: `SquiggleRenderer` (`:2825`) owns its WGSL shader + and pipeline; the menu/background quad pipeline is the precedent for filled + rectangles. +- Fonts are embedded with `include_bytes!` from `pmacs-gpu/fonts/` under OFL + (`JETBRAINS_MONO`, `:63`); `build_font_system` (`:217`) loads them into + `fontdb`. + +### 1.3 The acceptance seam already exists + +`headless_or_skip(w, h, text)` builds a real headless GPU state and +`render_offscreen()` returns mapped pixels (`copy_texture_to_buffer` at +`:6570`). `headless_diag_face_recolors_band_counter_despite_unchanged_text` +(`:12022`) is the precedent: render, mutate, render again, and assert on the +pixel difference. Real-GPU tests run under `PMACS_REQUIRE_GPU=1`. + +This matters because the slice's central claim — *math is actually drawn* — +is exactly the kind of claim that a non-rendering test would pass vacuously. + +## 2. What ships + +One PR: detection (inline `$…$` only) → parse → layout against the MATH table +→ draw, for the subset in Q#MS2, with the raw source shown whenever the +cursor is inside the span (Q#MS5). + +Explicitly **not** in this slice: display math `$$…$$`, big operators, +stretchy fences, radicals, accents, `\text{}`, style overrides, tree-sitter +injection detection, any wire surface, and the TUI. + +## 3. Decisions + +### Q#MS1 — Both modules live in `pmacs-gpu` + +`pmacs-gpu/src/math_parse.rs` and `pmacs-gpu/src/math_layout.rs`. Not +`src/`, for the three independent reasons in §1.1. This keeps v0 exactly what +the parent says it is — a pure frontend responsibility — and keeps the core +crate free of a font-metrics dependency it has no use for. + +If instance-side detection ever lands (the parent's v1 `MathSpans`), the +*parser* may move to a shared crate at that point. Nothing in this slice +should assume it will. + +### Q#MS2 — The subset: characters, sub/superscript, fraction + +`MathNode` for this slice: + +```rust +enum MathNode { + Char(char), // x, 2, + + Symbol(char), // \alpha → U+03B1 (seed map) + Group(Vec), + Script { base: Box, sub: Option>, sup: Option> }, + Fraction { num: Box, den: Box }, +} +``` + +This subset is chosen because it is the smallest one that **forces the MATH +table to matter**. Characters alone could be positioned by guesswork and prove +nothing. Scripts require `ScriptPercentScaleDown`, `SuperscriptShiftUp` and +`SubscriptShiftDown`; fractions require `AxisHeight` and the fraction rule +constants, plus nested box composition. Get those right and the remaining node +kinds are more of the same; get them wrong and no amount of breadth helps. + +The symbol map ships as a **seed** (Greek letters only, ~50 entries), not the +parent's full ~200. Growing it is mechanical and needs no design. + +### Q#MS3 — Detection is the frontend byte scanner, inline only + +A two-pass scan over the visible slice for unescaped `$…$` pairs, run where +the parent specifies — in `rebuild_code_slice`, after shaping decisions, not +on the edit path. `\$` is an escape and does not open or close a span. An +unpaired `$` yields no span (acceptance 6 of the parent). + +Tree-sitter injection detection is deliberately not used, even though #144 +gives us `math_environment` / `math_delimiter` for `.tex`: that path is +instance-side, the substrate lane already deferred it to this arc, and the +slice must work in the grammar-less buffers where most inline math is typed. +It stays available as the natural upgrade. + +### Q#MS4 — Suppression is a new chunk kind, and it owns its hit runs + +`ChunkSource` gains a variant carrying the suppressed source range and the +projected width the box occupies. The chunk contributes **no glyphs** to the +cosmic-text buffer; it reserves width so the surrounding text lays out around +it, and the math is drawn over that reserved space in a later pass. + +The invariant `build_hit_runs` exists to preserve — that the hit map is +derived from the same chunks glyphon shaped — is not weakened: the new +variant participates like any other. A click inside a math box maps to the +**start byte of the suppressed range**, the same "snap to anchor" rule +`Adornment` already uses. Sub-expression hit-testing is deferred; it needs a +box→byte map that this slice deliberately does not build. + +### Q#MS5 — The cursor rule: render math only when the cursor is outside + +When the caret is anywhere inside a math span (or on either delimiter), that +span is **not** suppressed — the raw `$…$` renders as ordinary source text. + +This is the parent's Q#IM5 proposal ("when the cursor approaches the boundary, +the raw `$` reappears") adopted as a hard rule, and it buys the slice a great +deal: there is no caret-inside-rendered-math problem to solve, because the two +states are mutually exclusive. Editing math shows source; moving away renders +it. Q#IM6's "best-effort fractional cursor projection" is then not needed at +all in v0, and is deferred rather than approximated. + +It also gives the feature an honest, self-explaining interaction model, which +is worth more in v0 than sub-glyph caret fidelity. + +### Q#MS6 — Layout positions CHARACTERS, not glyph IDs + +```rust +struct MathBox { width: f32, ascent: f32, descent: f32, items: Vec } +enum MathItem { + Glyph { ch: char, x: f32, baseline: f32, size_px: f32 }, + Rule { x: f32, y: f32, width: f32, thickness: f32 }, // fraction bar +} +``` + +Positions are in pixels relative to the box origin, resolved by the frontend +that owns font metrics — consistent with the parent's contract. + +**Characters, not glyph IDs, is a deliberate boundary.** Glyph-ID work exists +to select *variants* from the MATH table's `GlyphVariantRecord` / `Glyph‐ +Construction` chains — which is precisely what stretchy fences and big +operators need, and precisely what this slice defers. Positioning characters +lets each item be drawn with the existing text machinery. The slice must not +pretend this generalises: when stretchy delimiters arrive they will need glyph +IDs, and `MathItem` will gain a variant then. + +The fraction rule is a filled quad on the existing quad pipeline, not a glyph. + +### Q#MS7 — The MATH font and its feature declaration + +Bundle **Latin Modern Math** (OFL, GUST) as `pmacs-gpu/fonts/`, embedded with +`include_bytes!` beside JetBrains Mono, with its licence file. Two consumers +read the same bytes: `fontdb`/cosmic-text for drawing, and `ttf-parser` +directly for the MATH table, which cosmic-text does not expose. + +Declare the dependency exactly as the parent's rev-2 C1 records: + +```toml +ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] } +``` + +Bare `ttf-parser = "0.25"` unions `std` in and rebuilds the font chain. + +A font whose MATH table is absent or unparseable is a **hard startup error in +the math path only** — math spans fall back to raw source (Q#MS8), the editor +does not fail. Bundled-font regressions must not be silent. + +### Q#MS8 — Failure is always "show the source" + +Unparseable expression, unsupported node kind, missing MATH constant, or a box +that would exceed the line: the span is not suppressed and renders as ordinary +source. The parent's red-squiggle treatment (its Q#IM4) is **deferred** — it +reuses the diagnostic squiggle path, which is a second integration this slice +does not need in order to be correct. + +Consequence worth stating plainly: **an unsupported construct is +indistinguishable from ordinary text in v0.** That is acceptable precisely +because the subset is small and documented; it stops being acceptable when +breadth arrives, which is when Q#IM4 should land. + +### Q#MS9 — Caching is deferred + +The parent's hash-keyed `MathBox` cache is **not** in this slice. Layout runs +per visible span per reshape. This is a slice: the subset is tiny, the visible +span count is small, and an unmeasured cache is a guess. The parent's latency +targets stay as targets; the first measurement comes from this slice's own +render path, and the cache lands when a number justifies its invalidation +cost. + +## 4. Bets (falsifiable) + +- **B1 — the reserved-width chunk composes with the existing pipeline.** + Falsified if reserving width for a suppressed range requires changing how + cosmic-text shapes the surrounding line, rather than adding a chunk kind + that `chunks_for_line` and `build_hit_runs` both already iterate. +- **B2 — scripts and fractions are enough to validate `MathBox`.** Falsified + if adding a deferred node kind later forces a change to `MathBox`'s width / + ascent / descent / origin contract, rather than only adding a `MathItem` + variant. +- **B3 — character positioning suffices for the subset.** Falsified if any + node in Q#MS2 cannot be drawn correctly without selecting a glyph variant. +- **B4 — the cursor rule removes the caret problem rather than hiding it.** + Falsified if any caret position, selection, or click inside or across a math + span still needs a projected-position approximation to behave correctly. +- **B5 — `ttf-parser` supplies every constant the subset needs.** Falsified if + script or fraction layout requires a MATH value `ttf-parser` does not + expose. + +## 5. Acceptance + +Parser and layout are pure and get ordinary unit tests. Everything that claims +something reaches the screen runs on a real device through +`headless_or_skip` + `render_offscreen`, under `PMACS_REQUIRE_GPU=1`. + +1. **Parser** — `x^2`, `x_i`, `x_i^2`, `\frac{a}{b}`, `\alpha`, nested + `\frac{x^2}{y}` produce the expected `MathNode` trees. Unbalanced `{`, + unknown command, and an empty span are errors, not panics. +2. **Detection** — `$x^2$` yields one span; `Price: $5.00` yields none; + `\$5` yields none; `$a$ and $b$` yields two. +3. **MATH constants are actually consulted** — layout of `x^2` with the real + font places the `2` above the baseline and scaled down. Bite: stubbing + `ScriptPercentScaleDown` to 100% changes the laid-out box, proving the + constant is read rather than hardcoded. +4. **Fraction geometry** — numerator above, denominator below, rule at the + axis height, box ascent/descent enclose both. +5. **It renders** — a buffer containing `$x^2$` renders differently from the + same buffer with the math span suppressed. Asserted on pixels, so a layout + engine wired to nothing cannot pass it. +6. **The fraction rule is drawn** — `$\frac{a}{b}$` produces horizontal rule + pixels between the two operand rows. +7. **Cursor rule** — with the caret inside `$x^2$`, the raw `$x^2$` glyphs + render and no math is drawn; moving the caret out re-renders the math. + Both directions asserted. +8. **Hit mapping** — a click on a rendered math box places the caret at the + span's start byte, and the surrounding text's hit runs are unchanged. +9. **Failure shows source** — `$\frac{a$` and `$\unknown{}$` render as + ordinary source text with no panic and no missing glyphs. +10. **Surrounding layout is undisturbed** — `before $x^2$ after` keeps + `before`/`after` at the same positions as when the span is not math. +11. **Feature declaration** — `cargo tree -e features` shows `ttf-parser` + without `std`, i.e. the declaration did not widen the shared feature set. +12. Full gate suite per `CLAUDE.md`, including `PMACS_REQUIRE_GPU=1`. + +## 6. Deferred (named) + +Display math `$$…$$` and `\[…\]`; big operators; stretchy fences and glyph +variant/assembly (with the `MathItem` glyph-ID variant they require); +radicals; accents; `\text{}`; style overrides; the full ~200-entry symbol map; +the red-squiggle error treatment (parent Q#IM4); the `MathBox` cache (Q#MS9); +sub-expression hit-testing and caret projection inside rendered math (parent +Q#IM6); colour-by-context (parent Q#IM2); tree-sitter injection detection and +any `MathSpans` wire surface; the TUI's distinct-face fallback; Lua-registered +delimiters. + +## 7. Interaction with other work + +The slice's Tier 4 half edits `pmacs-gpu/src/main.rs`'s render path, which two +other lanes also claim: + +- **Bottom panel** — Stage 1 is in review as **#155**; its **Stage 2** takes + this render path *and* the next protocol version. +- **Folding Stage 3 (GPU)** — next ranked, still unframed, and inherits the + `BufferSnapshot` fold-mirror-clear obligation on the same path. + +This lane reserves **no protocol version** and adds no wire surface, so it +cannot collide there. For the render path the rule is the one the other two +framings already apply to each other: **whichever lands second re-scouts +against the first.** The parser and layout modules are new files and collide +with nothing; only the `rebuild_code_slice` / chunk / render hunks are +contended, and they are small and localised by design. + +Sequencing preference: land after #155's Stage 1, whose merge does not touch +this path, and re-scout if bottom-panel Stage 2 or folding Stage 3 lands +first. + +## 8. Prior art in pmacs + +`SquiggleRenderer` (`pmacs-gpu/src/main.rs:2825`) for owning a custom pipeline +beside glyphon; the menu/background quad path for filled rectangles; inlay +hints (`ChunkSource::Adornment`) for interleaving non-source content and for +the anchor-snapping hit rule; `headless_diag_face_recolors_band_counter…` +(`:12022`) for asserting a rendering claim on real pixels; #144's query +overlay for the eventual tree-sitter detection upgrade.