diff --git a/Cargo.lock b/Cargo.lock index 498f267..b80dbae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2630,6 +2630,7 @@ dependencies = [ "pollster", "sys-locale", "tempfile", + "ttf-parser", "unicode-width", "wgpu", "winit", diff --git a/docs/active-work.md b/docs/active-work.md index c934c30..cf2b025 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,6 +55,134 @@ git status --short --branch The `git log` command must expose `8c86d34` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Inline-math slice lane — PR #158 OPEN, main integrated + +- Portable branch: `githubsucks/inline-math-slice`; worktree + `../pmacs-math-slice`. **PR #158**, base `main`. +- **Canonical `main` merged into the lane three times on 2026-07-25**, + every time merged rather than rebased, per the #135/#137 precedent: the + PR is awaiting review rounds and a rebase would break every review + anchor. + - First at `8c86d34` (28 commits behind). The conflict was + **pre-existing**, not introduced by the dired (#164) or Lean 4 + ledger commits; it already conflicted against `main` @ `e745068`. + - Then at `46a1b8f`, after Lean 4 Stage 2 (#161) landed while this + branch's CI was still running. Same single conflict, same shape, + same resolution. + - Then at `b889873`, after the GPU terminal input fix (#166) landed. + **No conflict at all this time** — and that is exactly why it still + needed a real integration, see below. +- **The first two conflicts were this ledger and nothing else** — both + sides' lanes kept verbatim each time. That is the standing cost of a + long-lived PR here: every merge to `main` edits this file, so a branch + awaiting review re-conflicts on it and only on it. It is a docs + collision, never a code one, and it says nothing about integration + risk — do not read a `CONFLICTING` badge on this PR as a code signal + without checking which file `git merge-tree` names. +- **The inverse trap matters more, and #166 is the case in point: a + CLEAN `git merge-tree` is not a reason to skip integrating.** #166 + landed 41 lines in `pmacs-gpu/src/main.rs`, the same heavily-rewritten + file as the first integration, and git merged it without a murmur + because the two edits sit in different regions (#166 is entirely in the + headless probe — `PMACS_GPU_PROBE_OBSERVE_MS`, `PROBE_INPUT_CHAR`, + `input_echo_observed` — while this lane rewrites the render path). + Merging the PR on the strength of that clean auto-merge would have + shipped a combination no gate had ever run. **Decide whether to + integrate from the shared-FILE set, not from whether git complained.** +- **First integration's surface** (derived from `git diff + ..main`, not from another PR's file list): + `pmacs-gpu/src/main.rs` gained 72 lines on main from `e547a90` — the + minimap all-blank-slab divide-by-zero fix — and this lane rewrites + large parts of the same file. Git auto-merged it **textually**; a + clean auto-merge is not evidence the tree compiles (the folding-arc + lesson), so the full gate suite below is what actually discharges it. +- **Second integration's surface is code-disjoint.** #161 touched + `COHERENCE.md`, `builtin/runtime/lsp.lua`, `src/lua_bindings/mod.rs`, + and a new `tests/lsp_multi_root_acceptance.rs`; intersecting that + against this lane's own changed-file set leaves exactly one entry, + `docs/active-work.md`. No source file is touched by both sides, so + this one carries none of the first integration's semantic risk. +- **CI ran on this branch for the first time on 2026-07-25 and passed + all twelve** (Format, both Lints, GPU Render headless, all four Test + matrix jobs, M1/M4/M5/M6 gates) at `8b457de` — the first-integration + tip. Before that there were zero workflow runs since the PR opened on + 2026-07-24, while every other open PR had a full run; not a fork and + not a trigger-config issue (the workflow fires on all `pull_request` + events), cause never identified. So the green run **validates the + first integration, including the `pmacs-gpu/src/main.rs` auto-merge, + on macOS and Linux both** — the platforms local gating could not + cover. The second and third integrations get their own CI run on the + push that carries them. +- Framing: `docs/inline-math-slice-framing.md` rev 3, approved after two + review rounds; parent arc framing merged as #154. +- State: parser, font bundle (GUST licence), MATH-table layout with the + measured height budget, currency-guarded detection, and the + `ChunkSource::MathBox` spacer substrate are implemented and + round-3-reviewed (review fixes at `cbf7782`: the exclusive-`end` + mapping bug its own test had pinned, script-marker whitespace, fallible + layout via `UncoverableGlyph`, real fraction gap-min constants — + flagship scale 0.867, fallback depth 5). +- Caret-driven suppression (the Q#MS5 gate over the effective caret and + Q#MS11 selection endpoints, chunk substitution before tab expansion, + the line-reuse predicate's third input, and the CursorByte / + optimistic-edit / Decorations refresh triggers), the draw pass + (per-glyph mini-buffers positioned by each shaped line's real + baseline, fraction-rule quads over the washes, the F8b family pin), + the Q#MS11 whole-rectangle wash widening, and the pixel acceptance + battery (criteria 5–11, 14–16; 17 discharged by the differential + `cargo tree -e features` check — byte-identical with and without the + dependency line) are implemented on the branch tip. +- Clippy is CLEAN on the whole workspace at `-D warnings` — the draw + pass consumed every formerly-dead item. +- Verification **pre-integration** (at `14c1c01`, against the old base): + 199 `pmacs-gpu` tests under `PMACS_REQUIRE_GPU=1`; 1,815 default + + 1,992 CRDT library tests; M4 121; full workspace sweep green (isolated + `XDG_CONFIG_HOME`). Superseded by the post-integration run below — + those numbers describe a tree 28 commits behind. +- Verification after the **first** integration: `cargo fmt --check` + clean; strict workspace Clippy clean; 1,826 default + 2,003 CRDT + library tests; **202 `pmacs-gpu` tests under `PMACS_REQUIRE_GPU=1`**; + M4 121; isolated-`XDG_CONFIG_HOME` `--no-fail-fast` workspace sweep + 3,208 across 91 suites, zero failures; `git diff --check` clean. +- Verification after the **third** integration (this is the set that + describes what the PR now proposes): fmt clean; `git diff --check` + clean; strict workspace Clippy clean; **1,829 default + 2,006 CRDT** + library tests; **202 `pmacs-gpu`** under `PMACS_REQUIRE_GPU=1`; M4 121; + **isolated-`XDG_CONFIG_HOME` `--no-fail-fast` sweep 3,224 across 92 + suites, zero failures**. +- **Test-count reconciliation is the integration proof, not the pass.** + Run it against what the other side actually added, per merge: + - First: GPU 199 → **202**, and `e547a90` added exactly **3** + `pmacs-gpu` tests — the whole delta on main since the merge base. + Structurally spot-checked too: main's fix survives as + `(count > 0).then(|| MinimapLineShape {` (the deferred closure, + **not** the eager `then_some`) with its regression test. + - Third: #166 adds **3** library tests, **2** to + `vterm_stage3_acceptance`, and **0** to `pmacs-gpu`. Predicted lib + 1,826 → 1,829, CRDT 2,003 → 2,006, GPU unchanged at 202 — and that + is exactly what ran. Suite count 91 → **92** is #161's new + `tests/lsp_multi_root_acceptance.rs` binary. All three sides' + markers confirmed live in `pmacs-gpu/src/main.rs`: #166's probe + symbols, this lane's `math_plan_for_line` / `math_gates_match` / + `cached_math_subs_for_slice` / `widen_over_math_chunks` / 21 + `MathBox` references, and the first integration's minimap fix. +- **Ops trap, cost hours: `m4_5_basedpyright_initializes_and_negotiates_ + encoding` does not time out — it hangs forever.** A `--workspace` + sweep parks on `m4_acceptance` with a live + `basedpyright/langserver.index.js` child and never advances (observed + stuck at 38 of 92 suites for 2h26m). The per-suite M4 gate already + carries `-- --skip basedpyright`; **the workspace sweep needs the same + flag** — `cargo test --workspace --no-fail-fast -- --skip + basedpyright` (libtest filters apply to every binary; verify it bit by + checking the run reports exactly 1 filtered out). Do not read a + long-running sweep as "slow": check whether the suite count is + advancing. +- Remaining: the user's review pass. + Named v0 approximations: the peer-caret half of acceptance 14 is + pinned at the mapping level (unit tests), not pixels; a soft-wrapped + spacer draws its box whole at the first run's origin; the fit budget + reads the bundled code face even under a custom `set_font` family + (the draw anchors to the real shaped baseline either way). ## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161) - Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review diff --git a/docs/inline-math-slice-framing.md b/docs/inline-math-slice-framing.md new file mode 100644 index 0000000..2c9f626 --- /dev/null +++ b/docs/inline-math-slice-framing.md @@ -0,0 +1,628 @@ +# Inline math — the first vertical slice (framing) + +**Revision 3 — pre-implementation, framing only. Ground truth scouted against +canonical `main` @ `352bf0b`, protocol v20, 2026-07-24. Rev 2 closed review +round 1 (F1–F9); rev 3 closes round 2 (R2-1 – R2-4).** + +### Round 2 (rev 2 → rev 3) + +Verdict: converging — one deletion, one real gap, two nits. + +| # | Finding | Closed in | +| --- | --- | --- | +| R2-1 | The tree-sitter paragraph appeared **twice** in Q#MS3; the second was stale rev-1 text. Introduced by rev 2's own rewrite, which added a copy without removing the original | Q#MS3 | +| R2-2 | The F7 italic fix stopped at ASCII, so `$\alpha x$` drew an **upright α beside an italic 𝑥** — mixed styles inside one expression, and Greek is the slice's second flagship | Q#MS2, acceptance 13 | +| R2-3 | §9 sat between §6 and §7 | section order | +| R2-4 | Q#MS11 said a wash "covers" a span; a search match can **partially overlap** (`2$ af` in `before $x^2$ after`), which "covers" leaves unspecified | Q#MS11 | + +Carried into acceptance from a round-2 non-finding: the Q#MS10 arithmetic puts +`\frac{a}{b}` near 0.85 and suggests `\frac{x^2}{y}` also clears the 0.6 +floor, so criterion 12's fallback case must be **computed rather than guessed** +or it will surprise-pass by rendering. + +### Round 1 (rev 1 → rev 2) + +Verdict: the slice's shape survived, both load-bearing corrections held, and +nine findings landed — two of them decisions the implementation could not have +proceeded without, one a compliance error. + +| # | Finding | Closed in | +| --- | --- | --- | +| F1 | The fraction height budget was never confronted; lines cannot grow (`BASE_CODE_LINE_HEIGHT = 22.0` fixed) and a textstyle fraction does not fit | Q#MS10 (new) | +| F2 | "Contributes no glyphs, reserves width" is a mechanism the chunk model does not have — a `RichChunk`'s only width is its `text` | Q#MS4, B1 | +| F3 | Criterion 10 required source-width boxes while Q#MS4 implied layout-chosen width; the contradiction *is* the caret-toggle reflow question | Q#MS4, acceptance 10 | +| F4 | Q#MS5 makes shaping depend on the caret — a new invalidation edge, and it must read the *effective* caret or flap during optimistic typing | Q#MS5 | +| F5 | Detection had no currency guard (`$5 and $6` pairs) and no newline rule | Q#MS3 | +| F6 | **Factual:** Latin Modern Math is GUST Font License and ~717 KiB, not OFL and ~200 KB | Q#MS7, §9 | +| F7 | Without a math-italic mapping, `$x^2$` renders an upright roman `x` | Q#MS2 | +| F8 | Layout still resolves glyph IDs internally; drawing must pin `Attrs` to the math family or measured and drawn advances diverge | Q#MS6, Q#MS7 | +| F9 | Smaller: "after shaping decisions" contradicts Q#MS4; no selection/wash rule; `$$…$$` degradation untested; `Char` vs `Symbol` unmotivated | Q#MS3, Q#MS2, Q#MS11 (new), acceptance | + +Two rev-1 claims were **wrong, not merely imprecise**, and are called out +where they occur: the zero-glyph strut (F2) and the font licence (F6). + +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), // resolved codepoint: x, 2, +, α + Group(Vec), + Script { base: Box, sub: Option>, sup: Option> }, + Fraction { num: Box, den: Box }, +} +``` + +Rev 1 had both `Char` and `Symbol`, each carrying a `char`, with no stated +difference (F9d). Folded: `\alpha` resolves to `'α'` **in the parser**, so +layout sees one kind. Provenance would only matter for error messages, which +Q#MS8 does not produce. + +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. + +**Math italic is in scope, and it covers Greek too (F7, R2-2).** Neither rev 1 +nor the parent mentioned italics, and without them `$x^2$` renders an upright +roman `x` — which does not look like math, and would make the slice's flagship +acceptance case visibly wrong. + +Rev 2 fixed that for ASCII only, which reintroduced the same defect one symbol +over: `\alpha` resolves to U+03B1 in the parser, so `$\alpha x$` would have +drawn an upright α beside an italic 𝑥 — **mixed styles inside one +expression**, with the Greek seed map being the slice's *second* flagship case. +The mapping therefore follows TeX's actual convention: + +| Class | Treatment | Range | +| --- | --- | --- | +| ASCII letters | math italic | U+1D434–U+1D467, **with the U+210E hole for `h`** (Letterlike Symbols, not in the 1D4xx run) | +| Lowercase Greek | math italic | U+1D6FC–U+1D714 | +| Uppercase Greek | **upright** | left at U+0391–U+03A9 | +| Digits, operators | upright | unchanged | + +Uppercase-Greek-upright is not an omission; it is what TeX does, and matching +it is why the table is stated rather than left as "letters become italic". +Because the slice positions characters, this stays a pure char→char mapping — +the same mechanical class as the Greek seed itself. + +### Q#MS3 — Detection is the frontend byte scanner, inline only, currency-guarded + +A two-pass scan over the visible slice for unescaped `$…$` pairs, run in +`rebuild_code_slice` **off the edit path**. (Rev 1 said "after shaping +decisions", inherited from the parent's "post-shape hook"; that contradicts +Q#MS4, since a suppression chunk must exist *before* the line is shaped. The +property that actually matters is that detection does not run per keystroke — +F9a.) + +**Currency guards are mandatory, not a refinement (F5).** Rev 1 relied on the +parent's lone-`$` case and would have rendered `prices are $5 and $6 today` as +math over `5 and ` — in exactly the grammar-less prose buffers this rule +targets. Adopt Pandoc's rule: + +- an opening `$` must be followed by a **non-space**; +- a closing `$` must be preceded by a **non-space** and not followed by a + **digit**; +- `\$` is an escape and neither opens nor closes. + +**A span may not cross a newline in v0.** Chunking is per line and the visible +slice is line-ranged, so single-line spans are what keep visible-slice-scoped +scanning stable under scroll. A `$` with no same-line partner yields no span. + +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 — and it is the principled fix for +currency false-positives, which guards only approximate. + +### Q#MS4 — Suppression is a spacer chunk, width-quantized, layout-chosen (F2, F3) + +**Rev 1 was wrong about the mechanism.** It said the chunk "contributes no +glyphs… reserves width". A `RichChunk`'s only width *is* its `text: String` +(`pmacs-gpu/src/main.rs:7703`), which `line_from_chunks` feeds straight into a +`BufferLine`; cosmic-text has no zero-glyph strut. There is nothing to reserve +width with except text. + +The mechanism is therefore the **`SourceTab` precedent**: `ChunkSource` gains a +variant carrying the suppressed source range, and the chunk projects **spacer +text** — runs of spaces — whose advance covers the box. Reserved width is +consequently **quantized up to whole space advances**, which is a feature, not +a rounding error: the projection stays grid-aligned with the surrounding +monospace text, and hit runs stay integral. + +**Width is layout-chosen, not pinned to the source width (F3).** Rev 1 implied +both, and acceptance 10 demanded the latter. Resolved deliberately in favour of +layout-chosen: + +- Pinning to source width removes reflow, but `$\frac{a}{b}$` is 13 source + columns against a box roughly 2 wide, so every fraction would sit in a large + blank gap. That defect is permanent and visible on every render. +- Layout-chosen width means the line **reflows when the caret crosses a span + boundary** (Q#MS5 toggles suppression). That is a jump, but it is confined to + one line, it happens only on a deliberate caret move, and it is the same + behaviour `org-appear` has trained users to expect from Emacs. + +A permanent visual defect is worse than a transient one tied to an explicit +user action. Acceptance 10 is rewritten to match: text *before* the span never +moves, text *after* it moves by exactly the quantized difference, and the +reflow is confined to the affected line. + +`build_hit_runs`'s invariant — the hit map derives 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` uses. Sub-expression hit-testing is +deferred; it needs a box→byte map this slice deliberately does not build. + +### Q#MS10 — The height budget: fit to the line, or fall back (F1) + +The code buffer is one cosmic-text `Buffer` with uniform metrics — +`BASE_CODE_FONT_SIZE = 16.0`, `BASE_CODE_LINE_HEIGHT = 22.0` +(`pmacs-gpu/src/main.rs:362`, `:359`). **Lines cannot grow.** A textstyle +fraction at those metrics is roughly 17 px tall against an above-baseline +budget of ~12–14 px, so a simple fraction is marginal and acceptance 1's own +nested `\frac{x^2}{y}` plainly exceeds. Rev 1 hid this inside Q#MS8's "a box +that would exceed the line" without saying whether that meant width or height, +or what the budget was. + +**Rule: the box is uniformly scaled to fit the line box, down to a floor of +0.6×; below the floor the span falls back to source (Q#MS8).** No overdraw, no +reflow of line height, no clipping surprises. The available budget is the line +box less a one-pixel margin, split at the text baseline. + +Rejected alternatives, for the record: + +- **Overdraw into adjacent lines' leading.** The math pass draws after + glyphon and *could* paint outside the line box, but a tall fraction would + then visually collide with the line above — a defect the user cannot fix + except by not writing math. +- **Growing the line.** Not available: metrics are uniform for the whole + buffer. + +The honest consequence: **v0 shrinks nested math uniformly rather than by +proper style level.** TeX shrinks nested fractions too, but it does so through +display/text/script/scriptscript levels with per-level constants, which is the +real answer and is deferred by name in §6. A uniform scale is a visibly +cruder approximation of the same idea, and it is what keeps the slice thin. + +### 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. + +**This creates a new shaping-invalidation edge, and it is the #120 trap class +(F4).** Today caret motion within the visible slice touches no shaped line: +the `CursorByte` arm updates the cursor and reshapes only on scroll-follow, +and `rebuild_lines_reusing_scroll` (`pmacs-gpu/src/main.rs:5116`) retains +lines on the premise that content and styling are unchanged. Making +suppression a function of the caret breaks that premise. Two obligations +follow, both of which the implementation owns explicitly: + +- **Caret motion that crosses a span boundary must dirty the affected + lines**, and the line-reuse predicate gains suppression state as a third + input beside content and styling. A retained line computed under the + opposite suppression state is exactly the stale-mirror failure #120 taught. +- **The rule reads the *effective* caret the frontend draws**, not the last + confirmed `CursorByte`. The GPU holds an optimistic cursor during + unconfirmed edits; keying suppression off the confirmed value would make + spans flap between rendered and source while typing. + +Acceptance 7 exercises the behaviour; these two are named here because a test +that only moves the caret and re-renders would pass even if the reuse +predicate were left untouched, as long as something else happened to dirty the +line. + +### 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 — on the OUTPUT only +(F8a).** Layout still resolves glyph IDs *internally*: advances and +`MathItalicsCorrection` are glyph-keyed, so a `cmap` lookup happens whatever +the item type. What the boundary buys is that the *emitted* items are +drawable by the existing text machinery. + +Glyph-ID **output** exists to select *variants* from the MATH table's +`GlyphVariantRecord` / `GlyphConstruction` chains — precisely what stretchy +fences and big operators need, and precisely what this slice defers. The slice +must not pretend this generalises: when stretchy delimiters arrive they will +need glyph-ID items, 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** in `pmacs-gpu/fonts/`, embedded with +`include_bytes!` beside JetBrains Mono. 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. + +**Licence and size, corrected (F6).** Rev 1 said "OFL, GUST" and the parent's +table says "OFL (GUST)". Both are **wrong**. Verified against a local TeX Live +copy, `latinmodern-math.otf` is **733,736 bytes (~717 KiB)** and its own +copyright string reads *"released under the GUST Font License"* — an +LPPL-derived licence, not the SIL OFL. Consequences: + +- the bundled licence file must be the **GUST Font License**, named as such, + not `OFL.txt` (the existing `fonts/OFL.txt` covers JetBrains Mono only); +- the size claim must be honest: at ~717 KiB this becomes **the largest single + embedded asset in the repository**, roughly 3.5× the figure rev 1 quoted; +- GFL permits redistribution with its licence text, so the plan stands — but + it is a *different* obligation from OFL and must be discharged as one; +- if OFL-only ever becomes a requirement, **STIX Two Math** is the OFL + alternative already listed in the parent's font table. + +The parent framing carries the same error and needs the same correction; that +is recorded in §9 as a follow-up rather than smuggled into this lane. + +**Pin `Attrs` to the math family when drawing (F8b).** Layout measures with +`ttf-parser` against the bundled bytes; drawing goes through cosmic-text. If +fallback selects a different face for `α` or a math-italic `𝑥` than the one +measured, drawn advances diverge silently from computed geometry and the box +is subtly wrong everywhere. The draw path sets the family explicitly and does +not rely on fallback. + +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#MS11 — Selection, search washes, and peer carets over a box (F9b) + +Rev 1 named selection as a falsifier of B4 without proposing a rule. Any +overlay addressed in *source* bytes meets a span whose source is suppressed. + +- **A selection endpoint inside a span unsuppresses it.** This is Q#MS5's rule + generalised from the caret to any selection boundary: if the user is + addressing bytes inside the math, they see the bytes. A selection that + merely *spans* the region (both endpoints outside) leaves it rendered. +- **A wash that *intersects* a rendered span washes the whole reserved + rectangle.** Intersection, not containment (R2-4): for selections the + distinction is vacuous, since a contiguous selection with both endpoints + outside a span necessarily contains it — but a **search match can partially + overlap**, e.g. searching `2$ af` in `before $x^2$ after` matches from + inside the span to outside it. Search hits and peer highlights paint the + projected box, never a sub-range of it: the box has no interior byte map + (Q#MS4), so a partial wash cannot be placed honestly. +- **Peer carets snap to the span start**, the same rule as hits. + +This keeps every overlay addressable without inventing a box→byte projection +the slice does not build, and it makes "you are addressing this text" and "you +see this text" the same condition throughout. + +### 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' (restated after F2) — a spacer chunk composes with the existing + pipeline.** Reserving width via projected spaces, quantized to whole space + advances, needs only a new `ChunkSource` variant that `chunks_for_line` and + `build_hit_runs` already iterate. Falsified if it requires changing how + cosmic-text shapes the surrounding line, or if quantized spacer width cannot + keep the projected hit map integral. *(Rev 1's "zero-glyph strut" wording is + withdrawn: no such mechanism exists.)* +- **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' (sharpened after F9b) — the cursor rule plus Q#MS11 remove the caret + problem rather than hiding it.** Falsified if any caret position, selection + endpoint, search wash, or peer caret 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. +- **B6 (new, F1) — fit-to-line with a 0.6× floor keeps the subset legible.** + Falsified if a plain `\frac{a}{b}` at default metrics lands below the floor + (making the flagship case fall back to source), or if scaled output is + illegible at the floor. Either outcome means the slice needs real TeX style + levels rather than a uniform scale, which would be a scope change. + +## 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; `$a$ and $b$` yields two; + `\$5` yields none. **Currency guards (F5):** `Price: $5.00` yields none, + `prices are $5 and $6 today` yields **none** (the rev-1 rule would have + matched `5 and `), `$ x $` yields none (space after opener), and a `$` + whose only partner is on the next line yields none. +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. **Reflow is bounded and predictable (F3)** — in `before $x^2$ after`, + `before` occupies identical pixels whether or not the span is rendered; + `after` shifts by exactly the quantized width difference; no other line + moves. Toggling via the Q#MS5 caret rule reflows only the affected line. +11. **Line reuse honours suppression (F4)** — moving the caret across a span + boundary changes the rendered output. Bite: with suppression left out of + the line-reuse predicate, the retained line keeps the stale state and this + fails. Suppression follows the **effective** caret, so it does not flap + during an unconfirmed optimistic edit. +12. **Height budget (F1) — measured, not assumed.** Computed against the + bundled font, with the budget derived as Q#MS10 defines it (the line box + less a 1 px margin, baseline placed by the **code** font — JetBrains Mono + asc 16.32 / desc 4.80 at 16 px inside the 22 px line, *not* the math + font's own 12.90/3.10): + + | expression | ascent | descent | scale | + | --- | --- | --- | --- | + | `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 | + | `\frac{a}{b}` | 10.57 | 5.40 | **0.867** | + | `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 | + | nesting depth 2 | — | — | 0.872 | + | nesting depth 4 | — | — | 0.613 | + | nesting depth 5 | — | — | **0.540** | + + So **B6 holds** — the flagship fraction renders at 0.867 — and the + fallback case is **depth 5**. Rev 3 guessed depth 2; the first + measurement said depth 3 while the fraction gap was still a hardcoded + `2 × thickness` guess; reading the MATH table's real + `FractionNumeratorGapMin` / `FractionDenominatorGapMin` (round-3 F4) + moved the flagship from 0.732 to 0.867 and the boundary to depth 5. The + round-2 hand-arithmetic estimate of ~0.85 was right all along; the 0.732 + was inflated by the guessed gap. + Round 2 predicted exactly this trap. Two things worth keeping: depth 2 + scores *higher* than depth 1 because the binding constraint flips from + descent to ascent as nesting grows asymmetrically, so "deeper is always + tighter" is false; and the test **searches** for the tripping depth rather + than hardcoding it, so a font or metric change cannot silently leave the + fallback arm unexercised. +13. **Math italic (F7, R2-2)** — `$x$` renders the math-italic glyph, not + roman `x`; `$h$` resolves through the U+210E hole rather than the 1D4xx + run; digits in `$x2$` stay upright; **`$\alpha$` renders math-italic Greek + and `$\Gamma$` stays upright**, so `$\alpha x$` is uniformly italic rather + than mixed. +14. **Overlays (Q#MS11)** — a selection endpoint inside a span unsuppresses + it; a selection enclosing a rendered span leaves it rendered and washes + the whole reserved rectangle; a peer caret inside a rendered span draws at + the span start. +15. **Deferred syntax degrades, not corrupts** — `$$x$$` renders as ordinary + source text through the empty-span error path, with no panic and no + half-rendered box (F9c). +16. **Font provenance** — the bundled licence file is the GUST Font License + and is distinct from the existing `fonts/OFL.txt`; a build with the MATH + table absent or unparseable falls back to source and surfaces the error + rather than failing silently (Q#MS7). +17. **Feature declaration is differential, not absolute** — the `ttf-parser` + feature set from `cargo tree -e features` is **byte-identical with and + without this crate's dependency line**. Asserting "`std` is absent" would + be wrong and would fail a correct implementation: `std` is *already* + enabled upstream, because `fontdb` declares `std = ["ttf-parser/std"]`. + What the declaration must not do is *widen* the set, which only a + before/after comparison can show. +18. 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; **proper TeX style levels** (display/text/script/scriptscript with +per-level MATH constants), for which Q#MS10's uniform fit-to-line scale is a +deliberately cruder stand-in; **sub-range washes** inside a rendered box +(Q#MS11 washes the whole rectangle). + + +## 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. + +## 9. Follow-up outside this lane + +The parent framing (`docs/inline-math-framing.md`, rev 2, merged as #154) +carries the same font error F6 found here: its table row reads "Latin Modern +Math | Full | OFL (GUST)". It should be corrected to the GUST Font License, +with the ~717 KiB size, in its own docs change rather than in this branch — +the parent is a merged document and this lane should not quietly edit it. diff --git a/pmacs-gpu/Cargo.toml b/pmacs-gpu/Cargo.toml index 40bc3ee..2715d57 100644 --- a/pmacs-gpu/Cargo.toml +++ b/pmacs-gpu/Cargo.toml @@ -37,6 +37,11 @@ similar_names = "allow" multiple_crate_versions = "allow" [dependencies] +# OpenType MATH table reader for inline math layout (Q#MS7). Already in the +# build graph via fontdb -> cosmic-text -> glyphon, so this declares a crate +# the build compiles anyway. The feature set is a SUBSET of fontdb's; a bare +# `ttf-parser = "0.25"` would union `std` in and rebuild the whole font chain. +ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] } # OS clipboard for cut/copy/paste (Q#CM6). `wayland-data-control` adds # the zwlr_data_control backend so the clipboard works under Wayland # without a window handle; the default X11 backend covers X sessions. diff --git a/pmacs-gpu/fonts/GUST-FONT-LICENSE.txt b/pmacs-gpu/fonts/GUST-FONT-LICENSE.txt new file mode 100644 index 0000000..604bf75 --- /dev/null +++ b/pmacs-gpu/fonts/GUST-FONT-LICENSE.txt @@ -0,0 +1,28 @@ +% This is version 1.0, dated 22 June 2009, of the GUST Font License. +% (GUST is the Polish TeX Users Group, http://www.gust.org.pl) +% +% For the most recent version of this license see +% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt +% or +% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt +% +% This work may be distributed and/or modified under the conditions +% of the LaTeX Project Public License, either version 1.3c of this +% license or (at your option) any later version. +% +% Please also observe the following clause: +% 1) it is requested, but not legally required, that derived works be +% distributed only after changing the names of the fonts comprising this +% work and given in an accompanying "manifest", and that the +% files comprising the Work, as listed in the manifest, also be given +% new names. Any exceptions to this request are also given in the +% manifest. +% +% We recommend the manifest be given in a separate file named +% MANIFEST-.txt, where is some unique identification +% of the font family. If a separate "readme" file accompanies the Work, +% we recommend a name of the form README-.txt. +% +% The latest version of the LaTeX Project Public License is in +% http://www.latex-project.org/lppl.txt and version 1.3c or later +% is part of all distributions of LaTeX version 2006/05/20 or later. diff --git a/pmacs-gpu/fonts/latinmodern-math.otf b/pmacs-gpu/fonts/latinmodern-math.otf new file mode 100644 index 0000000..0e4642e Binary files /dev/null and b/pmacs-gpu/fonts/latinmodern-math.otf differ diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 665194c..a26f340 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -23,6 +23,8 @@ //! the SIL Open Font License 1.1 (see `fonts/OFL.txt`). mod attach; +mod math_layout; +mod math_parse; mod terminal; use std::collections::HashMap; @@ -222,6 +224,13 @@ fn build_font_system(extra_sources: &[&'static [u8]]) -> (FontSystem, FontDefaul let bundled_id = *bundled_ids .first() .expect("bundled JetBrains Mono contains one face"); + // Inline-math slice (Q#MS7): the math glyphs draw through + // cosmic-text, so the same bytes the layout engine measures must + // resolve as a family here — the F8b pin. Proportional, so the + // same-family monospace filter below cannot touch it. + db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new( + math_layout::LATIN_MODERN_MATH, + ))); let extra_ids: Vec = extra_sources .iter() .flat_map(|bytes| db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(*bytes)))) @@ -275,6 +284,22 @@ fn query_normal_face(db: &fontdb::Database, family: &str) -> Option }) } +/// The family name the bundled math font resolves to in fontdb; the +/// draw pass pins `Attrs` to it so drawn advances come from the same +/// face layout measured (framing F8b). +const MATH_FONT_FAMILY: &str = "Latin Modern Math"; + +/// The Q#MS10 fit budget at the given code metrics, derived from the +/// bundled code face's baseline placement (the framing's pinned rule). +/// A custom `set_font` family shifts the painted baseline slightly; v0 +/// accepts that — boxes draw against the real shaped baseline, so only +/// the fit margin is approximate. +fn math_code_budget(fm: FontMetrics) -> (f32, f32) { + ttf_parser::Face::parse(JETBRAINS_MONO, 0).map_or((0.0, 0.0), |face| { + math_layout::line_box_budget(&face, fm.code_font_size(), fm.code_line_height()) + }) +} + /// The fixed ASCII advance probe (framing Q#F6). The measurement uses /// its total shaped width divided by this logical cell count; it does /// not assume one glyph per digit because a valid monospace face may @@ -341,6 +366,19 @@ const TERMINAL_CURSOR_RGBA: [f32; 4] = [0.85, 0.85, 0.9, 0.55]; /// B1. const CARET_WIDTH: f32 = 2.0; const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90]; + +/// Math ink (Q#MS6): the plain code text color, as glyph color for +/// the mini-buffers and quad rgba for the fraction rule. Colour-by- +/// context is the parent arc's deferred Q#IM2. +const MATH_INK_COLOR: Color = Color::rgb(230, 230, 235); +const MATH_INK_RGBA: [f32; 4] = [230.0 / 255.0, 230.0 / 255.0, 235.0 / 255.0, 1.0]; + +/// Line-height factor for a math glyph's mini-buffer: roomy enough +/// that a lone glyph's ascender/descender never clips against the +/// buffer's own line box. Positioning ignores it — the `TextArea` top +/// is set from the mini-buffer's SHAPED `line_y`, so the glyph's +/// baseline lands exactly where layout put it. +const MATH_GLYPH_LINE_FACTOR: f32 = 2.0; /// Extra source lines shaped beyond the visible window so a 1-line /// scroll doesn't always re-slice and the bottom partial line renders /// (Q#S3). Kept small — overscan is wasted shaping. @@ -1517,6 +1555,21 @@ struct State { /// frames re-shape ONLY lines whose styling actually changed, and /// lets scroll reuse retained lines wholesale. line_chunk_cache: Vec>, + /// Per-shaped-line math suppression state, in lockstep with + /// `line_chunk_cache`: the detected spans with the Q#MS5 gate bit + /// each was built under (the line-reuse predicate's third input + /// beside content and styling), and the placed boxes the draw + /// pass paints into the reserved spacer rectangles. + line_math_cache: Vec, + /// The MATH layout engine over the bundled font, or `None` when + /// the font failed to yield math metrics at startup — a hard + /// error in the math path only (Q#MS7): spans render as source + /// and the editor keeps running. + math_engine: Option>, + /// `(above_baseline, below_baseline)` budget a math box must fit + /// (Q#MS10), derived by `math_layout::line_box_budget` from the + /// CODE font's baseline placement. Recomputed when metrics change. + math_budget: (f32, f32), /// Absolute source-line index of `buffer.lines[0]`. shaped_top: usize, bg_vertex_buffer: ReusableVertexBuffer, @@ -1567,6 +1620,12 @@ struct State { /// Dedicated text renderer for the minibuffer dropdown (its own /// layer over the buffer, like the menu's). mb_text_renderer: TextRenderer, + /// Dedicated text renderer for inline-math glyphs (Q#MS6): each + /// `MathItem::Glyph` draws from its own mini-buffer positioned at + /// layout's exact x/baseline, so an accumulated shaping advance + /// can never move a glyph off its measured origin — the same + /// per-run argument the terminal renderer made. + math_text_renderer: TextRenderer, /// Minibuffer dropdown background + selection quads (Q#MB1). mb_bg_vertex_buffer: ReusableVertexBuffer, /// Arc 1a Q#C5 — the live in-buffer completion popup (protocol @@ -3046,6 +3105,17 @@ impl State { // filter, generic defaults, THEN FontSystem construction so // its monospace-ID set sees the final database. let (mut font_system, font_defaults) = build_font_system(extra_font_sources); + // Inline-math slice (Q#MS7): the layout engine over the bundled + // Latin Modern Math. Failure is surfaced once and disables only + // the math path — spans keep rendering as source. + let math_engine = match math_layout::MathLayout::new(math_layout::LATIN_MODERN_MATH) { + Ok(engine) => Some(engine), + Err(e) => { + eprintln!("pmacs-gpu: bundled math font unusable ({e:?}); inline math disabled"); + None + } + }; + let math_budget = math_code_budget(fm); let swash_cache = SwashCache::new(); let cache = Cache::new(&device); let mut viewport = Viewport::new(&device, &cache); @@ -3065,6 +3135,9 @@ impl State { // Q#MB1 — a third renderer for the minibuffer dropdown layer. let mb_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + // Inline-math slice (Q#MS6) — a renderer for the math glyph layer. + let math_text_renderer = + TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); // Arc 1a Q#C5 — a renderer for the completion dropdown layer. let completion_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); @@ -3219,6 +3292,9 @@ impl State { styled_redraw_deadline: None, hit_map_dirty: false, line_chunk_cache: Vec::new(), + line_math_cache: Vec::new(), + math_engine, + math_budget, shaped_top: 0, bg_vertex_buffer: ReusableVertexBuffer::new(), squiggle_vertex_buffer: ReusableVertexBuffer::new(), @@ -3239,6 +3315,7 @@ impl State { menu_bg_vertex_buffer: ReusableVertexBuffer::new(), mb_buffer, mb_text_renderer, + math_text_renderer, mb_bg_vertex_buffer: ReusableVertexBuffer::new(), completion: None, completion_buffer, @@ -3463,6 +3540,12 @@ impl State { // `moved == false`, so deferring wrapped-run repair here // would leave a newly wrapped caret off-screen indefinitely. self.ensure_caret_painted(); + // Q#MS5/F4: the text applied above re-chunked under the OLD + // caret; the effective caret only just moved. Suppression keys + // on `own_cursor`, so re-run the compare — without this, a + // typed char that lands the caret against a span boundary + // renders one keystroke stale. + self.refresh_math_suppression(); let viewport = self.viewport_send_if_changed(predicted.buffer_id); CrdtOpSend { buffer_id: predicted.buffer_id, @@ -3961,7 +4044,13 @@ impl State { // in `render()`. No decoration change needs a // reshape, so none triggers one — diagnostic // publishes no longer pay set_rich_text + - // shape_until_scroll. + // shape_until_scroll... with ONE exception since the + // inline-math slice: a Selection endpoint is a Q#MS11 + // suppression gate, so a selection change re-runs the + // per-line compare when the slice could hold math + // (no-op for every other decoration kind and for + // math-free text). + self.refresh_math_suppression(); self.request_redraw(); None } @@ -4156,6 +4245,13 @@ impl State { // the band never scrolled into view. if moved { self.ensure_caret_painted(); + // Q#MS5: the caret is a suppression input now. A + // move that crosses a math-span boundary must + // re-chunk the affected lines even when no scroll + // or edit follows — the follow above only reshapes + // on scroll, and a retained line under the stale + // suppression state is the #120 edge. + self.refresh_math_suppression(); if let Some(vp) = self.viewport_send_if_changed(buffer_id) { return Some(vp); } @@ -4928,18 +5024,65 @@ impl State { } } + /// The shaped slice's math substitutions, read back from the + /// per-line chunk caches and rebased to slice-relative offsets — + /// the spacer text and suppressed range are both in the cached + /// `MathBox` chunks, so the hit map reproduces the shaped state + /// exactly instead of re-planning under a possibly-newer caret. + fn cached_math_subs_for_slice(&self, vstart: u64, vend: u64) -> Vec { + let mut subs = Vec::new(); + if self.math_engine.is_none() { + return subs; + } + let (top, ranges) = self.slice_line_ranges(vstart, vend); + if top != self.shaped_top || ranges.len() != self.line_chunk_cache.len() { + // The caches describe a different slice; a math-blind map + // (boxes unclickable at worst) beats a wrong one. + return subs; + } + for (i, &(ls, _)) in ranges.iter().enumerate() { + let base = ls - vstart; + for chunk in &self.line_chunk_cache[i] { + if let ChunkSource::MathBox { start, end } = chunk.source { + subs.push(MathSubstitution { + span: math_parse::MathSpan { + start: (base + start) as usize, + end: (base + end) as usize, + }, + spacer: chunk.text.clone(), + boxed: math_layout::MathBox { + width: 0.0, + ascent: 0.0, + descent: 0.0, + items: Vec::new(), + }, + }); + } + } + } + subs + } + fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option { self.current_buffer_id?; if self.hit_map_dirty { // Q#R2 — a per-line reshape deferred this; rebuild from // the same chunk source the shaped buffer was built from. let (vstart, vend) = self.view_range; + // B1': the hit map must see the SAME math suppressions the + // shaped lines carry, so the slice-wide substitution list + // is read back from the per-line caches (never recomputed + // — a caret that moved since the last reshape must not + // make the map disagree with the glyphs), rebased from + // line-relative to slice-relative offsets. + let subs = self.cached_math_subs_for_slice(vstart, vend); let rich = clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, vstart, vend, + &subs, ); let (hit_runs, projected_line_starts) = build_hit_runs(&rich); self.current_hit_runs = hit_runs; @@ -5079,15 +5222,19 @@ impl State { let Some(shaped_idx) = line_idx.checked_sub(self.shaped_top) else { return false; }; - if shaped_idx >= self.buffer.lines.len() || shaped_idx >= self.line_chunk_cache.len() { + if shaped_idx >= self.buffer.lines.len() + || shaped_idx >= self.line_chunk_cache.len() + || shaped_idx >= self.line_math_cache.len() + { // E.g. typing on the phantom empty line after a trailing // newline — no BufferLine exists for it; full reshape // handles those shapes correctly. return false; } - let chunks = self.chunks_for_line(line_start, content_end); + let (chunks, math) = self.chunks_for_line(line_start, content_end); self.buffer.lines[shaped_idx] = line_from_chunks(&chunks, &self.resolved_family); self.line_chunk_cache[shaped_idx] = chunks; + self.line_math_cache[shaped_idx] = math; self.view_range = (vstart, vend); self.buffer.shape_until_scroll(&mut self.font_system, false); // The edited line's wrap count can shrink under a retained @@ -5128,14 +5275,148 @@ impl State { (top, ranges) } - fn chunks_for_line(&self, line_start: u64, content_end: u64) -> Vec { - clipped_chunks_for_range( + fn chunks_for_line( + &self, + line_start: u64, + content_end: u64, + ) -> (Vec, MathLineState) { + let (subs, mut state) = self.math_plan_for_line(line_start, content_end); + let chunks = clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, line_start, content_end, - ) + &subs, + ); + state.placed = placed_math_boxes(&chunks, &subs); + (chunks, state) + } + + /// The line's math suppression plan (Q#MS3/Q#MS4). Detection runs + /// here — the chunk-build path, never the edit path — and the gate + /// reads the EFFECTIVE caret (`own_cursor`, which optimistic edits + /// predict forward) plus the own-selection endpoints, so + /// suppression cannot flap during an unconfirmed edit (framing + /// Q#MS5/F4). Any failure — parse, layout, fit, degenerate spacer — + /// leaves that span as source (Q#MS8). + fn math_plan_for_line( + &self, + line_start: u64, + content_end: u64, + ) -> (Vec, MathLineState) { + let mut subs = Vec::new(); + let mut state = MathLineState::default(); + let Some(engine) = self.math_engine.as_ref() else { + return (subs, state); + }; + let Some(line) = self + .current_text + .get(line_start as usize..content_end as usize) + else { + return (subs, state); + }; + if !line.as_bytes().contains(&b'$') { + return (subs, state); + } + let spans = math_parse::detect_math_spans(line); + if spans.is_empty() { + return (subs, state); + } + let gates = self.math_gate_positions(line_start, content_end); + let advance = self.mono_advance(); + for span in spans { + let gated = gates + .iter() + .any(|&p| span.start as u64 <= p && p <= span.end as u64); + state.gates.push((span, gated)); + if gated { + continue; + } + let Ok(node) = math_parse::parse(&line[span.interior()]) else { + continue; + }; + let Ok(boxed) = engine.layout(&node, self.fm.code_font_size()) else { + continue; + }; + let Some(fitted) = + math_layout::fit_to_line(&boxed, self.math_budget.0, self.math_budget.1) + else { + continue; + }; + let spacer = math_layout::spacer_for_width(fitted.width, advance); + if spacer.is_empty() { + continue; + } + subs.push(MathSubstitution { + span, + spacer, + boxed: fitted, + }); + } + (subs, state) + } + + /// Line-relative byte positions whose presence inside a span + /// unsuppresses it: the effective caret and both own-selection + /// endpoints (Q#MS5 generalised by Q#MS11 — "you are addressing + /// this text" and "you see this text" are the same condition). + fn math_gate_positions(&self, line_start: u64, content_end: u64) -> Vec { + let mut gates = Vec::new(); + let mut push = |byte: u64| { + if byte >= line_start && byte <= content_end { + gates.push(byte - line_start); + } + }; + if let Some(own) = self.own_cursor + && Some(own.buffer_id) == self.current_buffer_id + { + push(own.byte); + } + for d in &self.current_decorations { + if d.kind == DecorationKind::Selection { + push(d.range.start); + push(d.range.end); + } + } + gates + } + + /// Whether a retained line's cached gate bits match the current + /// effective caret/selection — the suppression input to the + /// line-reuse predicate beside content and styling (Q#MS5; a + /// retained line shaped under the opposite suppression state is + /// the #120 stale-mirror failure). Content is unchanged on every + /// reuse path, so the cached span set is authoritative and only + /// the gate bits can differ. + fn math_gates_match(&self, line_start: u64, content_end: u64, state: &MathLineState) -> bool { + if state.gates.is_empty() { + return true; + } + let gates = self.math_gate_positions(line_start, content_end); + state.gates.iter().all(|&(span, was_gated)| { + let now = gates + .iter() + .any(|&p| span.start as u64 <= p && p <= span.end as u64); + now == was_gated + }) + } + + /// Caret or selection motion can flip a span's Q#MS5 gate without + /// any content change, which the frame-driven refresh paths never + /// see; re-run the per-line chunk compare when the visible slice + /// could hold math at all. Cheap when it cannot (one `$` scan). + fn refresh_math_suppression(&mut self) { + if self.math_engine.is_none() || self.terminal.is_some() { + return; + } + let (vstart, vend) = self.view_range; + let Some(slice) = self.current_text.get(vstart as usize..vend as usize) else { + return; + }; + if slice.as_bytes().contains(&b'$') { + self.refresh_changed_lines(); + } } /// Rebuild the shaped slice, reusing any retained line whose @@ -5158,30 +5439,52 @@ impl State { .into_iter() .map(Some) .collect(); + let mut old_math: Vec> = std::mem::take(&mut self.line_math_cache) + .into_iter() + .map(Some) + .collect(); let mut lines = Vec::with_capacity(ranges.len()); let mut cache = Vec::with_capacity(ranges.len()); + let mut math = Vec::with_capacity(ranges.len()); let mut any_reused = false; for (i, &(ls, ce)) in ranges.iter().enumerate() { let abs = new_top + i; let reused = abs.checked_sub(old_top).and_then(|j| { - if j < old_lines.len() && j < old_cache.len() { - old_lines[j].take().zip(old_cache[j].take()) + if j < old_lines.len() && j < old_cache.len() && j < old_math.len() { + // Reuse is sound only when suppression state is a + // third invariant beside content and styling + // (Q#MS5): a caret-follow scroll lands here with a + // caret that may have crossed a span boundary, and + // a retained line shaped under the opposite + // suppression state is the #120 stale-mirror + // failure (framing acceptance 11). + if !self.math_gates_match(ls, ce, old_math[j].as_ref()?) { + return None; + } + Some(( + old_lines[j].take()?, + old_cache[j].take()?, + old_math[j].take()?, + )) } else { None } }); - if let Some((line, chunks)) = reused { + if let Some((line, chunks, m)) = reused { any_reused = true; lines.push(line); cache.push(chunks); + math.push(m); } else { - let chunks = self.chunks_for_line(ls, ce); + let (chunks, m) = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); + math.push(m); } } self.buffer.lines = lines; self.line_chunk_cache = cache; + self.line_math_cache = math; self.shaped_top = new_top; self.buffer .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); @@ -5212,6 +5515,7 @@ impl State { if (vstart, vend) != self.view_range || top != self.shaped_top || ranges.len() != self.line_chunk_cache.len() + || ranges.len() != self.line_math_cache.len() || ranges.len() != self.buffer.lines.len() { self.reshape(); @@ -5219,12 +5523,17 @@ impl State { } let mut any = false; for (i, &(ls, ce)) in ranges.iter().enumerate() { - let chunks = self.chunks_for_line(ls, ce); + let (chunks, m) = self.chunks_for_line(ls, ce); if chunks != self.line_chunk_cache[i] { self.buffer.lines[i] = line_from_chunks(&chunks, &self.resolved_family); self.line_chunk_cache[i] = chunks; any = true; } + // Always current, even when the chunks are unchanged: an + // unsuppressible span's gate bit can flip with no chunk + // difference, and a stale bit would defeat the reuse + // comparison later. + self.line_math_cache[i] = m; } if any { self.buffer.shape_until_scroll(&mut self.font_system, false); @@ -6176,13 +6485,16 @@ impl State { let (top, ranges) = self.slice_line_ranges(vstart, vend); let mut lines = Vec::with_capacity(ranges.len()); let mut cache = Vec::with_capacity(ranges.len()); + let mut math = Vec::with_capacity(ranges.len()); for &(ls, ce) in &ranges { - let chunks = self.chunks_for_line(ls, ce); + let (chunks, m) = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); + math.push(m); } self.buffer.lines = lines; self.line_chunk_cache = cache; + self.line_math_cache = math; self.shaped_top = top; self.buffer .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); @@ -6445,6 +6757,9 @@ impl State { scale, advance_ratio, }; + // The Q#MS10 fit budget follows the code metrics; the reshape + // below rebuilds every line's math plan against it. + self.math_budget = math_code_budget(self.fm); // The default family already has an exact, pre-preference // geometry path: a shaped glyph when present, otherwise the // ratio-scaled baseline constant. Keep using it so resetting @@ -6704,6 +7019,16 @@ impl State { // status band and the popup layers above it stay, because they // are buffer-independent chrome the daemon still drives. let terminal_mode = self.terminal.is_some(); + // Inline-math ink (Q#MS6): glyph mini-buffers plus fraction-rule + // quads. The rules ride the bg quad batch AFTER the decoration + // washes, so a selection/search wash under a rendered box never + // paints over its fraction bar; the glyphs get their own layer + // in the code z-slot below. + let (math_buffers, math_rules) = if terminal_mode { + (Vec::new(), Vec::new()) + } else { + self.build_math_paint() + }; // The band's strip rides the bg quad batch so it draws under // the band text (text renders after the first quad draw). let mut bg_vertices = if terminal_mode { @@ -6711,6 +7036,11 @@ impl State { } else { self.decoration_background_vertex_bytes() }; + bg_vertices.extend(rects_to_vertex_bytes( + &math_rules, + self.config.width, + self.config.height, + )); bg_vertices.extend(self.status_band_vertex_bytes()); let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let bg_buffer = self @@ -6896,6 +7226,37 @@ impl State { ) .expect("text_renderer prepare"); + // Inline-math glyph layer (Q#MS6): one TextArea per glyph, + // clipped by the same code-area bounds as the code layer. + let math_areas: Vec