Merge canonical main (#158, #156) into the docs lane

Both conflicts were docs-only and resolved as unions, with one repair
taken from main: #156 fixed a pre-existing corrupted duplicate of the
"GPU initial target LANDED — #148" bullet in the handoff, whose tail ran
into the protocol-version text. This branch still carried the broken
copy, so the resolution keeps main's repaired `- Protocol **v20**` bullet
and drops the stub, along with main's now-superseded "Stage 1 IN REVIEW
as PR #165" sub-bullet.

Refreshed the canonical base to `d152120` and widened the stale-header
note from two lanes to three: #158 merged but its lane still reads
"PR #158 OPEN".
This commit is contained in:
Levi Neuwirth 2026-07-25 18:22:07 -04:00
commit 7247f647c0
10 changed files with 3666 additions and 181 deletions

1
Cargo.lock generated
View File

@ -2630,6 +2630,7 @@ dependencies = [
"pollster",
"sys-locale",
"tempfile",
"ttf-parser",
"unicode-width",
"wgpu",
"winit",

View File

@ -5,13 +5,14 @@ landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
**Two lane headers below are stale on purpose**, pending the docs updates
their own lanes owe: multi-root LSP affinity **#161 has merged** (the
Lean 4 lane still says IN REVIEW; its continuation is PR #167) and GPU
terminal input **#166 has merged** (its lane still says IN REVIEW; PR
#168 records it). Trust the canonical-base line below over a lane header:
if a PR number appears in `git log --first-parent githubsucks/main`, it
has landed regardless of what its lane says.
**Three lane headers below are stale on purpose**, pending the docs
updates their own lanes owe: the inline-math slice **#158 has merged**
(its lane still says PR #158 OPEN), multi-root LSP affinity **#161 has
merged** (the Lean 4 lane still says IN REVIEW; its continuation is PR
#167), and GPU terminal input **#166 has merged** (its lane still says IN
REVIEW; PR #168 records it). Trust the canonical-base line below over a
lane header: if a PR number appears in `git log --first-parent
githubsucks/main`, it has landed regardless of what its lane says.
## Repository authority
@ -22,9 +23,9 @@ has landed regardless of what its lane says.
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `5aa9044` (the inline-math slice #158 atop dired
Stage 1 #165, GPU terminal input #166, multi-root LSP affinity #161,
the dired framing #164,
`githubsucks/main` @ `d152120` (the bottom-panel docs #156 atop the
inline-math slice #158, dired Stage 1 #165, GPU terminal input #166,
multi-root LSP affinity #161, the dired framing #164,
find-file #162, COHERENCE.md #163, Lean 4 Stage 1 #160, the minimap
blank-slab fix #159, bottom-panel Stage 1 #155, the inline-math re-scout
#154, the vterm PTY-flake fix #153, and the GPU initial-target doc
@ -67,6 +68,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
<merge-base>..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 511, 1416; 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
@ -253,153 +382,31 @@ If it does not, stop and repair the remote/fetch configuration.
**isolated-config workspace sweep 3,177 across 92 suites, zero failures**;
`git diff --check` clean. Gates were run against the committed tree.
## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW
## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next
- Portable branch: `githubsucks/bottom-panel`, worktree
`../pmacs-bottom-panel`, based on `githubsucks/main` @ `ddaa80d`.
- Approved framing: `docs/bottom-panel-framing.md` revision 4, committed
as the branch's first commit (`c27f75a`).
- **Stage 1 implemented; no wire change (protocol stays v20).** What
landed on the branch:
- `src/window.rs`: `WindowParams` (`side` / `fixed_rows` / `dedicated`
+ implementation-owned `quit_action` and `origin_document`), `Side`,
a depth-bounded `QuitAction`, `MIN_WINDOW_OUTER_ROWS = 2`,
`Layout::compute(area, fixed)`, the `subtree_min_rows` /
`interactive_min_rows` recursions, `boundary_below`, and the three
new `FrontendView` fields (`panel_capable`, `frame_geometry`,
`panel_hidden`).
- `src/editor_core.rs`: `primary_document_window`, the non-side target
rule, `display_buffer` + the Q#BP3 placement policy, `quit_window`,
`reconcile_panel_layout_core`, `resize_boundary`, per-frontend
`JumpEntry`s, and the shared `resolve_target_buffer` seam that the
#148 initial-target bootstrap now routes through as well.
- `src/editor.rs`: the reconciliation transaction, geometry
declaration, the side-window `dispatch_idle_for` gate, the divider
paint, and the divider drag.
- `src/lua_bindings/window_panel.rs`: the whole `pmacs.window` panel
surface plus the shared adopter-placement helpers;
`builtin/runtime/window.lua` owns `window.panel-height` /
`window.min-height` and the resize commands.
- Adopters: `listview.open`, `compile.run`, `pmacs.terminal.open` all
take `display = "current" | "panel"` (Stage 1 default `"current"`);
LSP/compile visits route through `display_file`.
- **Review round 1 addressed.** The load-bearing finding: the Q#BP6
side-window split guard (`try_split_active`) had **no production
caller** — `pmacs.window.split_horizontal` / `split_vertical`, and so
`C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a
focused panel made the root wrapper's final child a split rather than
`Leaf(side)`, which both `Layout::compute`'s fixed pass and
`document_subtree` key on. It survived the first round because the
acceptance test called the core method **directly**; it now goes
through the real Lua binding. This is the folding-arc round-2 lesson
repeating exactly: *after wiring a guard into a production hook, pin it
through the real path — a direct-call test misses the wiring.*
Also fixed: the armed divider drag was not scoped to its arming
frontend (it could cancel and swallow a peer's mouse events); a
recompile carries no `display` and duplicated a panel-placed
`*compilation*` into the document window; and
`paint_mode_line_graphemes` had lost its doc block to an insertion.
Five bite-verified fixes (three via `scripts/bite`, two by manual
revert since their tests share `src/daemon.rs` with the production
code).
- Two Stage-2 hazard pins now exist in `src/daemon.rs`, closing the gap
the review named: a fresh attach while `LOCAL` is focused in a panel
inherits `LOCAL`'s **document** buffer, and an initial-target bootstrap
whose `after-load` hook creates and selects a panel still reasserts
into a document window.
- **Review round 2 addressed.** The load-bearing finding: **Q#BP7 item 1
— "growth reaching the live tail re-arms follow" — was never
implemented.** `at_bottom` is the instantaneous geometric readout
`scroll_offset == 0`, which a still-anchored view satisfies whenever it
is momentarily tall enough to reach the tail, so the round-1 assertion
could not see the gap: the next rows the child printed pushed the
anchored view back into history. `src/terminal/view.rs` now has
`rearm_follow_on_growth`, reached by one shared `declare_view_size`
helper from every size-declaring path (`snapshot_for_view`,
`record_view_size`, `view_status_for_size`) so grid and semantic
declarations cannot disagree.
Also fixed: the PTY fixtures emitted LF-only output, which staircases
until every row clips to blanks — so the anchor assertions compared
`""` with `""` and could not fail (now CRLF, each guarded by
`assert!(!top_before.is_empty())`); acc33's contrast case asserted
nothing; `start_run` let `already_in_panel` override an **explicit**
`display = "current"`, which is the documented opt-out from the Stage 3
flip (now gated on omission); and `window_drag` was a daemon-global
slot that a peer's mode-line press could clear.
- Durable test lessons from this round, both the same class:
1. **A geometric readout is not a state predicate.** `at_bottom` says
"the viewport currently reaches the tail", not "this view follows
the tail". Pinning follow requires feeding MORE output and asserting
the view moved (acc32b uses a filesystem gate between two bursts).
2. **A PTY in the default mode does not translate LF to CRLF.** An
`echo`-driven fixture staircases rightward and clips to blanks past
the viewport width, so any text equality over it is vacuously true.
Emit `\r\n`, and guard text comparisons with a non-empty assertion
the way the daemon pin guards on `!panel_hidden`.
- **Round-2 self-review caught a regression the round-2 commit
introduced**, in the change it labelled "minor": routing
`pmacs.window.buffer()`'s **no-argument** arm through the fid-scoped
`selected_window` validator made it **fallible**, and
`acting_frontend` can name a frontend with **no registered view** (a
bare `dispatch_key` from an unattached peer does exactly that). The
runtime calls that function on ordinary edits from `killring`,
`syntax`, `autosave`, `pair`, `indent` and `comment` **without
`pcall`**, so the raise never surfaced as an error — it silently
dropped the operation. `kill_ring_acceptance` went 30/30 → 25/5
(`frontend_detached_drops_per_frontend_state`: "B has kill state").
The no-arg arm is back on ambient `active_buffer_id()` and documented
as deliberately infallible; the explicit-window arm keeps its Q#BP11
validation. New **acc19c** pins it through the real path (a
`buffer.after-edit` subscriber during a viewless peer's `dispatch_key`)
and bites against the regressing commit.
Generalizes: **a "uniformity" cleanup that changes a function's
fallibility is not minor** — check every caller's error discipline
first, and remember that an ambient resolver's fallback IS its
contract.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,817 default + 1,994 CRDT library tests;
`bottom_panel_stage1_acceptance` 46/46; kill ring 30 default + 30 CRDT;
vterm Stage 1 9 default + 10 CRDT; M4 121; required GPU 152;
compile 67; vterm Stage 2 4 / Stage 3 5 (7 CRDT); folding Stage 2 48;
statusline 7; listview 6;
**isolated-config workspace sweep 3,130 passed across 89 suites, zero
failures**; `git diff --check` clean.
- **Run the sweep with an isolated `XDG_CONFIG_HOME`.** The real
`~/.config/pmacs/init.lua` on this desktop calls
`pmacs.packages.install_local(...)`, so every editor the sweep builds
races on one shared install root; a losing race sets a status message
that leaks into the mode line and breaks
`folding_stage2_acceptance::unfolded_frame_is_identical_to_the_pre_folding_baseline`,
which compares whole painted frames. Standalone it is 48/48. This
generalizes the known `compile_mode_acceptance` real-config trap:
any suite that paints the status area inherits it.
- **A latent pre-existing `main` bug surfaced while gating and is NOT
this branch's**: `buffer::tests::proptests::rope_matches_crdt_projection_after_arbitrary_edits`
fails on `main` @ `352bf0b` with `ops = [Insert(0,"a"),
Insert(0,"aaa"), Replace(0,1,"a"), Undo]` — undo of a textually-null
`Replace` returns a no-op edit result still carrying `crdt_op =
Some`, violating the suite's own shape invariant. `src/buffer.rs` is
byte-identical here, and the seed was deliberately **not** committed
(it would make an unrelated failure deterministically red on this
PR). Needs its own lane.
- Durable test lesson from this round: `TerminalViewStatus.scroll_offset`
is documented as the retained rows between **this viewport** and the
live tail, so it necessarily tracks the viewport height. Asserting it
constant across a panel height change is either vacuous or wrong —
the invariant Q#BP7 actually states is that the **anchor** is frozen,
which the acceptance now pins by comparing the first visible row's
text, plus `at_bottom` for the follow re-arm.
- `compile_mode_acceptance` needs `--test-threads=1` locally; it is
67/67 there. Under default parallelism it fails roughly 1 run in 3,
with a *different* test each time (acc14/acc25a, then acc24) —
**verified pre-existing** by swapping in `githubsucks/main`'s
`builtin/runtime/compile.lua` and reproducing the same rate. The
`pmacs-gpu` bin tests have historically gone red under a loaded sweep
(wgpu device contention). Rerun isolated before treating either as a
regression.
- Stage 2 (the GPU panel band, next available protocol version) has its
own re-framing obligation before implementation; Stage 3 is the default
placement flip.
Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no
branch and no framing yet** — the approved parent framing
`docs/bottom-panel-framing.md` (rev 4) is what it re-scouts against.
- Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two
review rounds). No protocol change. Durable substrate facts live in
`docs/agent-handoff.md` §1; the two round lessons are in §5.
- Retained, carrying nothing unmerged: branch `bottom-panel` and worktree
`../pmacs-bottom-panel`.
- **Stage 2 obligations, already named by the framing** — the starting
point for its own framing doc: `InstanceMessage::PanelFrame` plus
`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`
at the next available protocol version, gated in both directions and
each extended enum byte-pinned on its own previous final variant;
extracting `paint_frame`'s per-window body *together with* the
active-window auto-scroll preparation; routing every consumer in the
framing's §1.3 census of 23 transitive active-context reads through
`primary_document_window`; the focus-chrome surface matrix (Q#BP14b);
and Q#BP17's fold-projection parameter plus the stale invariant comment
at `src/window.rs`. Stage 3 is the adopter default flip.
- **Folding Stage 3 and this arc's Stage 2 both touch the semantic
projection.** Whichever is framed second re-scouts the other's landed
state.
## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next
@ -489,6 +496,34 @@ git worktree add --track \
undifferentiated `ReplyKind::FsUnit` and so must be keyed on
`JobKind::FsRename`.
- **Bottom panel Stage 1 — MERGED as #155** (`main` @ `e745068`,
2026-07-24, after two review rounds). Window placement, window
parameters, TUI side windows, the divider, and the adopter `display`
opt-in, with no protocol change. Both rounds found the same class of
defect and are worth keeping:
- **Round 1**: the Q#BP6 side-window split guard had *no production
caller* — `C-x 2` still reached plain `split_active` — and survived
because the acceptance test called the core method directly.
- **Round 2**: Q#BP7's terminal growth re-arm had *never been
implemented*, and the assertion meant to pin it (`at_bottom`) is a
geometric readout that a still-anchored view satisfies; the anchor
assertions beside it compared `""` with `""` because the PTY fixture
emitted LF-only output.
- **Post-round-2 self-review**, caught by CI going red on all four Test
jobs: resolving `pmacs.window.buffer()`'s no-argument arm through the
acting frontend made a total function partial, and six runtime modules
silently dropped operations (`kill_ring_acceptance` 30/30 → 25/5).
Fixed in `9110f9f` before merge.
- Gating fact found on the way: **the workspace sweep must run with an
isolated `XDG_CONFIG_HOME`**, because the real user `init.lua`
installs a local package and the losing race leaks a status message
into painted-frame comparisons. There is also a latent pre-existing
`main` bug in the buffer CRDT undo path, unrelated to this arc.
- `compile_mode_acceptance` is load-sensitive under default
parallelism (~1 run in 3, a different test each time); verified
pre-existing by swapping in `main`'s `compile.lua`. It is 67/67 at
`--test-threads=1`.
- **GPU initial target — MERGED as #148** (`main` @ `0dd16a5`, 2026-07-24,
after two review rounds). `pmacs --gpu [--socket …] FILE` opens a target
before the GPU window appears. Protocol bumped 19 → 20: a semantic-session

View File

@ -146,14 +146,77 @@ commands, read `docs/active-work.md` immediately after this file.
unchanged, which is the additivity gate for the `read_dir` change; M4
121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep
3,205 across 93 suites. 15 claims bite-verified.
- **GPU initial target LANDED — #148**
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
refresh #150, folding Stage 2 #149, ledger refresh #147, web grammars #146,
LaTeX Stage 1 #144 / inline-math framing #145, and folding Stage 1 #142),
protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 =
the GPU initial-target semantic bootstrap family).
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now
has Emacs's `display-buffer` + window parameters, and a buffer can be
displayed in a fixed-height window pinned to the bottom of the frame that
feature code targets **by policy** instead of by stealing the selected
window.
- `src/window.rs`: `WindowParams { side, fixed_rows, dedicated }` plus
implementation-owned `quit_action` / `origin_document` (Lua reads them,
`set_params` refuses them); `MIN_WINDOW_OUTER_ROWS = 2`;
`Layout::compute(area, fixed)` subtracts fixed children before dividing
the remainder by weight, preserving last-flexible-takes-the-remainder, so
a tree with no fixed leaves computes byte-identically to before.
- **`Layout::compute` has TWO production callers**, and both must feed the
same shared `panel_fixed_rows` map: `window_placements` and
`src/overlay_paint.rs`'s peer-presence pass, which derives its own
text-area `Rect` and never routes through the first. Leaving the second
on unfixed geometry paints every peer cursor at the row it would occupy
with no panel open.
- **The minimum is recursive** (`subtree_min_rows`: horizontal splits sum,
vertical splits max). "Two rows at the root" does not give each nested
leaf two rows. `interactive_min_rows` is the same recursion over the
user's `window.min-height` preference, and applies to drag/keyboard
resize ONLY — the layout pass and frame-resize reconciliation use the
structural floor, so changing a preference can never invalidate an
existing layout.
- **Hiding a panel is a durable state transition, not a per-frame effect**
(`EditorState::reconcile_panel_layout`): it moves focus out and releases
the terminal controller, because the terminal resize path merely returns
on zero content without releasing. It runs after attach/resize/display/
close and defensively before input dispatch, terminal sync, and paint.
- `FrontendView` gains `panel_capable`, `frame_geometry`
(`None` = **unknown**, never the GPU attach request's permanent 24×80
placeholder), and derived `panel_hidden` — each spelled explicitly at
every construction site, preserving folding's non-`Default` discipline.
- `EditorCore::primary_document_window` is the Q#BP14 projection seam;
`display_buffer` is Phase 1 of the display transaction (exact target →
side affinity → ordinary reuse, with option-valued height/dedication);
the Lua layer owns Phase 2 (activate → hook → reconcile → revalidate →
final-focus matrix).
- **Optimistic input is gated per WINDOW, not per buffer**:
`dispatch_idle_for` returns `false` whenever the acting frontend's active
window is a side window. Marking the panel's BUFFER round-trip would be
wrong — `round_trip_buffers` is global by `BufferId`, so it would disable
optimistic apply for another frontend editing that buffer as its document.
- Jump entries are per frontend and carry their origin `WindowId`; a stale
**side** origin is SKIPPED, because degrading it to an active-window
switch is exactly the duplicate-panel corruption the arc removes.
- `pmacs.window.display / display_file / quit / panel / params /
set_params / resize / display_target`, plus `builtin/runtime/window.lua`
(`window.panel-height`, `window.min-height`, `C-x ^` / `C-x C-^`).
Adopters take `display = "current" | "panel"`; **Stage 1 default is
`"current"`** and Stage 3 flips it.
- The divider is the upper subtree's existing mode-line row — no row added
or consumed, `ui.divider` restyles every exposed segment of one boundary,
and drag state is `HashMap<FrontendId, _>` so frontends cannot steal each
other's gestures.
- `open_initial_target` now shares one `resolve_target_buffer` +
exact-window install seam with `display_file`, and reasserts into a
document window after hooks (a startup hook can now create a panel).
- Final gates: 1,817 default + 1,994 CRDT library tests; the new
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
Stage 2 48. All 12 CI checks green at merge.
- **Stage 2 (the GPU panel band) needs its own re-framing** before
implementation and takes the next available protocol version; the
framing's §1.3 census of 23 transitive active-context reads is its map.
Stage 3 is the adopter default flip.
- **GPU initial target LANDED — #148**
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
@ -651,8 +714,14 @@ commands, read `docs/active-work.md` immediately after this file.
(GPU) is next**, unframed.
- **Web grammars HTML+CSS LANDED — #146**, and **LaTeX Stage 1 — #144**
with its inline-math parent framing **#145**.
- Remaining ranked arcs: 6 folding Stage 3, 7 DAP, 8 GPU splits, plus
the `.ipynb` arc (its JSON-grammar prerequisite shipped in #123).
- **Arc 7 (bottom panel) Stage 1 LANDED — #155** — window placement,
window parameters, TUI side windows, the divider, and the adopter
`display` opt-in. **Stage 2 (the GPU band) is next and needs its own
re-framing**; Stage 3 is the default flip. DAP was parked awaiting
exactly this arc's Stage 1 and can now re-baseline its touch census.
- Remaining ranked arcs: 6 folding Stage 3, 7 bottom-panel Stages 23,
DAP, 8 GPU splits, plus the `.ipynb` arc (its JSON-grammar
prerequisite shipped in #123).
## 2. How we work (the part that must not drift)
@ -937,6 +1006,44 @@ final variant — its own round-trip cannot detect a discriminant shift.
before any `FrontendEvent` touches render/size/editor state. Generalizes:
when a new failure path can leave a handle installed without its owning
session, dropping a handle is not the same as tearing down the connection.
- **A guard with no production caller passes every direct-call test.**
#155 round 1: `EditorCore::try_split_active` implemented the side-window
split refusal, but `pmacs.window.split_horizontal` / `split_vertical` — and
therefore `C-x 2` / `C-x 3` — still called plain `split_active`. The
acceptance test called the core method directly, so reverting the guard
entirely would have left every test green. Same shape as folding #142
round 2. Assert through the outermost user-reachable seam
(`try_exec(&s, "pmacs.window.split_horizontal()")`), then falsify by
revert. When the test shares a file with the code it pins, `scripts/bite`
cannot swap it — break the production line by hand and `git checkout --`.
- **A geometric readout is not a state predicate.**
`TerminalViewStatus::at_bottom` is defined as `scroll_offset == 0` — "the
viewport currently reaches the tail", not "this view follows the tail". A
still-anchored view satisfies it whenever it happens to be tall enough, so
asserting it could not detect that Q#BP7's growth re-arm had never been
implemented (#155 round 2): the next rows the child printed pushed the
anchored view back into history. Pinning *following* requires advancing the
world — feed more child output through a filesystem gate — and asserting the
view came along. Related: `scroll_offset` is viewport-relative, so
"unchanged across a height change" is vacuous or wrong; the invariant is
the frozen ANCHOR.
- **A PTY in the default mode does not translate LF to CRLF.** An
`echo`-driven test fixture staircases rightward, and past the viewport
width every row clips to blanks — so `assert_eq!(top_before, top_after)`
compares `"" == ""` and passes for any regression (#155 round 2). Emit
`printf '...\r\n'`, and guard text comparisons with
`assert!(!observed.is_empty())` the same way the panel daemon pin guards on
`!panel_hidden`.
- **Widening an ambient resolver into a scoped one can make a total function
partial.** #155 round 2 resolved both arms of `pmacs.window.buffer()`
through the acting frontend "for uniformity". `acting_frontend` follows the
interactive origin, which can name a frontend with no registered view (a
bare `dispatch_key` from an unattached peer), so the no-argument arm began
raising instead of answering. No runtime caller `pcall`s it, so killring,
syntax, autosave, pair, indent and comment silently dropped operations —
`kill_ring_acceptance` went 30/30 to 25/5 on every CI platform. The ambient
resolver's fallback is what makes it *total*; keep it, and document that as
deliberate. Uniformity is not free when the paths have different totality.
- **An upgrade decision must be tracked independently of the outcome that
triggered it.** #148 published a target's fresh `BufferSnapshot` to
existing grid replicas only when the buffer was `newly_loaded ||

View File

@ -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 (F1F9); 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<MathNode>),
Script { base: Box<MathNode>, sub: Option<Box<MathNode>>, sup: Option<Box<MathNode>> },
Fraction { num: Box<MathNode>, den: Box<MathNode> },
}
```
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+1D434U+1D467, **with the U+210E hole for `h`** (Letterlike Symbols, not in the 1D4xx run) |
| Lowercase Greek | math italic | U+1D6FCU+1D714 |
| Uppercase Greek | **upright** | left at U+0391U+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 ~1214 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<MathItem> }
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.

View File

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

View File

@ -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-<fontid>.txt, where <fontid> is some unique identification
% of the font family. If a separate "readme" file accompanies the Work,
% we recommend a name of the form README-<fontid>.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.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,900 @@
//! OpenType MATH metrics and the math-italic mapping (Tier 3, part one).
//!
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS6 / Q#MS7.
//!
//! Two consumers read the same bundled font bytes: cosmic-text draws with it,
//! and this module measures with it. cosmic-text does not expose the MATH
//! table, which is why `ttf-parser` is a direct dependency (Q#MS7) — already
//! in the build graph via `fontdb`, declared with a feature subset that
//! widens nothing.
use ttf_parser::Face;
/// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`).
///
/// Distinct from `fonts/OFL.txt`, which covers `JetBrains` Mono only: Latin
/// Modern Math is GFL, an LPPL-derived licence, not the SIL OFL (framing F6).
pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf");
/// The MATH constants this slice's subset needs, in font units.
///
/// Deliberately narrow: Q#MS2 covers scripts and fractions, so these are the
/// constants those two require. Reading more would be speculative — the
/// values for deferred constructs are only meaningful once they have a
/// consumer (the Q#LX5 discipline, applied to metrics).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MathConstants {
/// Units per em, for scaling everything below into pixels.
pub units_per_em: u16,
/// Vertical position of the fraction bar / math axis.
pub axis_height: i16,
/// Percentage (0100) to scale one script level down.
pub script_percent_scale_down: i16,
/// Baseline shift for a superscript.
pub superscript_shift_up: i16,
/// Baseline shift for a subscript.
pub subscript_shift_down: i16,
/// Thickness of the fraction rule.
pub fraction_rule_thickness: i16,
/// Minimum gap between the numerator and the rule.
pub fraction_numerator_gap_min: i16,
/// Minimum gap between the rule and the denominator.
pub fraction_denominator_gap_min: i16,
}
/// Why the bundled font could not supply math metrics.
///
/// Q#MS7: this is a failure of the *math path only* — spans fall back to
/// source and the editor keeps running. It is surfaced rather than swallowed
/// so a bundled-font regression cannot be silent.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MathFontError {
/// The bytes are not a parseable font.
Unparseable,
/// Parsed, but carries no MATH table (e.g. a text-only font).
NoMathTable,
/// MATH table present but missing a constant the subset needs.
MissingConstant(&'static str),
/// The math font cannot draw this codepoint (F3). Q#MS8's rule is
/// "failure is always show the source", so layout REFUSES rather than
/// emitting a zero-width item that would render tofu over its neighbour.
/// Layout is fallible for this reason alone; the draw pass needs a
/// refusal signal, and it must exist before that pass consumes the API.
UncoverableGlyph(char),
}
impl MathConstants {
/// Read the subset's constants from font bytes.
///
/// # Errors
/// [`MathFontError`] when the face, the MATH table, or a needed constant
/// is absent.
pub fn from_font_bytes(bytes: &[u8]) -> Result<Self, MathFontError> {
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
let math = face.tables().math.ok_or(MathFontError::NoMathTable)?;
let constants = math
.constants
.ok_or(MathFontError::MissingConstant("constants"))?;
Ok(Self {
units_per_em: face.units_per_em(),
axis_height: constants.axis_height().value,
script_percent_scale_down: constants.script_percent_scale_down(),
superscript_shift_up: constants.superscript_shift_up().value,
subscript_shift_down: constants.subscript_shift_down().value,
fraction_rule_thickness: constants.fraction_rule_thickness().value,
fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value,
fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value,
})
}
/// Convert a font-unit value to pixels at `font_size_px`.
#[must_use]
pub fn to_px(self, value: i16, font_size_px: f32) -> f32 {
if self.units_per_em == 0 {
return 0.0;
}
f32::from(value) * font_size_px / f32::from(self.units_per_em)
}
/// The per-level script scale, as a fraction (e.g. 0.7).
#[must_use]
pub fn script_scale(self) -> f32 {
let pct = f32::from(self.script_percent_scale_down);
if pct <= 0.0 { 0.7 } else { pct / 100.0 }
}
}
/// Map a resolved codepoint to its math-mode presentation form (Q#MS2).
///
/// TeX's convention, which is why uppercase Greek is deliberately upright:
///
/// | Class | Treatment |
/// |---|---|
/// | ASCII letters | math italic, with the U+210E hole for `h` |
/// | Lowercase Greek | math italic |
/// | Uppercase Greek | upright |
/// | Digits, operators | upright |
///
/// Without this, `$x^2$` draws a roman `x` and `$\alpha x$` draws an upright
/// α beside an italic 𝑥 — mixed styles inside one expression (framing F7,
/// R2-2).
#[must_use]
pub fn math_italic(ch: char) -> char {
// U+210E PLANCK CONSTANT is the italic `h`; the 1D4xx run has a hole
// there, so mapping arithmetically would produce a reserved codepoint.
if ch == 'h' {
return '\u{210E}';
}
let mapped = match ch {
'A'..='Z' => 0x1D434 + (ch as u32 - 'A' as u32),
'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32),
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
'\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1),
// The SYMBOL forms TeX's \epsilon and \phi resolve to sit OUTSIDE
// that run, so they need explicit italic mappings — without them the
// seed map's correction would render them upright beside italic
// neighbours, which is the defect it was fixing.
'\u{3F5}' => 0x1D716, // ϵ lunate epsilon
'\u{3D5}' => 0x1D719, // ϕ phi symbol
// Uppercase Greek, digits, operators: upright, per TeX.
_ => return ch,
};
char::from_u32(mapped).unwrap_or(ch)
}
/// A laid-out expression. Baseline at `y = 0`, positive `y` upward.
///
/// Q#MS6: items carry CHARACTERS, not glyph IDs. Layout still resolves glyph
/// ids internally for advances and bounds — the boundary is on the emitted
/// items, so each is drawable by the existing text machinery. Glyph-id items
/// arrive with stretchy fences and big operators, both deferred.
#[derive(Clone, Debug, PartialEq)]
pub struct MathBox {
pub width: f32,
pub ascent: f32,
pub descent: f32,
pub items: Vec<MathItem>,
}
/// One drawable piece of a [`MathBox`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MathItem {
/// A character at its own size, `baseline` relative to the box baseline.
Glyph {
ch: char,
x: f32,
baseline: f32,
size_px: f32,
},
/// The fraction bar. Not a glyph — drawn on the existing quad pipeline.
Rule {
x: f32,
y: f32,
width: f32,
thickness: f32,
},
}
impl MathItem {
fn shifted(self, dx: f32, dy: f32) -> Self {
match self {
Self::Glyph {
ch,
x,
baseline,
size_px,
} => Self::Glyph {
ch,
x: x + dx,
baseline: baseline + dy,
size_px,
},
Self::Rule {
x,
y,
width,
thickness,
} => Self::Rule {
x: x + dx,
y: y + dy,
width,
thickness,
},
}
}
}
impl MathBox {
fn empty() -> Self {
Self {
width: 0.0,
ascent: 0.0,
descent: 0.0,
items: Vec::new(),
}
}
/// Absorb `other` at offset `(dx, dy)`, growing this box's extents.
fn absorb(&mut self, other: &Self, dx: f32, dy: f32) {
self.items
.extend(other.items.iter().map(|item| item.shifted(dx, dy)));
self.ascent = self.ascent.max(other.ascent + dy);
self.descent = self.descent.max(other.descent - dy);
}
/// Uniformly scale every extent and item (Q#MS10 fit-to-line).
#[must_use]
pub fn scaled(&self, factor: f32) -> Self {
Self {
width: self.width * factor,
ascent: self.ascent * factor,
descent: self.descent * factor,
items: self
.items
.iter()
.map(|item| match *item {
MathItem::Glyph {
ch,
x,
baseline,
size_px,
} => MathItem::Glyph {
ch,
x: x * factor,
baseline: baseline * factor,
size_px: size_px * factor,
},
MathItem::Rule {
x,
y,
width,
thickness,
} => MathItem::Rule {
x: x * factor,
y: y * factor,
width: width * factor,
thickness: thickness * factor,
},
})
.collect(),
}
}
}
/// The line-box height budget a math box must fit (Q#MS10), as
/// `(above_baseline, below_baseline)` pixels.
///
/// Extracted rather than left inside a test: the draw pass must compute the
/// SAME split the acceptance test asserts, and a duplicated derivation is
/// exactly how a renderer and its test drift apart while both stay green.
///
/// The baseline is placed by the CODE font, not the math font — using the
/// math font's own metrics understates the descent budget badly enough to
/// make a plain fraction appear not to fit.
#[must_use]
pub fn line_box_budget(code_font: &Face<'_>, font_size_px: f32, line_height_px: f32) -> (f32, f32) {
const MARGIN_PX: f32 = 1.0;
let upem = f32::from(code_font.units_per_em().max(1));
let baseline_from_top = f32::from(code_font.ascender()) * font_size_px / upem;
let above = (baseline_from_top - MARGIN_PX).max(0.0);
let below = (line_height_px - baseline_from_top - MARGIN_PX).max(0.0);
(above, below)
}
/// The smallest uniform scale the slice will apply before giving up (Q#MS10).
pub const MIN_FIT_SCALE: f32 = 0.6;
/// Scale `boxed` to fit `(ascent_budget, descent_budget)`, or `None` when
/// that would fall below [`MIN_FIT_SCALE`] — in which case Q#MS8 shows the
/// raw source rather than overdrawing into the neighbouring line.
#[must_use]
pub fn fit_to_line(boxed: &MathBox, ascent_budget: f32, descent_budget: f32) -> Option<MathBox> {
let need_up = boxed.ascent;
let need_down = boxed.descent;
let up = if need_up <= 0.0 {
1.0
} else {
ascent_budget / need_up
};
let down = if need_down <= 0.0 {
1.0
} else {
descent_budget / need_down
};
let scale = up.min(down).min(1.0);
if scale < MIN_FIT_SCALE {
return None;
}
if scale >= 1.0 {
return Some(boxed.clone());
}
Some(boxed.scaled(scale))
}
/// Lays a [`MathNode`] tree out against the bundled MATH font.
pub struct MathLayout<'a> {
face: Face<'a>,
constants: MathConstants,
}
impl<'a> MathLayout<'a> {
/// Build a layout engine over font bytes.
///
/// # Errors
/// [`MathFontError`] when the face or its MATH table is unusable.
pub fn new(bytes: &'a [u8]) -> Result<Self, MathFontError> {
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
let constants = MathConstants::from_font_bytes(bytes)?;
Ok(Self { face, constants })
}
/// Test-only introspection: the production draw path consumes the
/// constants through `layout`, never raw.
#[cfg(test)]
#[must_use]
pub fn constants(&self) -> MathConstants {
self.constants
}
/// Lay `node` out at `size_px`.
///
/// # Errors
/// [`MathFontError::UncoverableGlyph`] when the math font has no glyph
/// for a character, so the caller can fall back to source (Q#MS8).
pub fn layout(
&self,
node: &crate::math_parse::MathNode,
size_px: f32,
) -> Result<MathBox, MathFontError> {
use crate::math_parse::MathNode;
match node {
MathNode::Char(ch) => self.layout_char(*ch, size_px),
MathNode::Group(children) => {
let mut out = MathBox::empty();
let mut pen = 0.0;
for child in children {
let child_box = self.layout(child, size_px)?;
out.absorb(&child_box, pen, 0.0);
pen += child_box.width;
}
out.width = pen;
Ok(out)
}
MathNode::Script { base, sub, sup } => {
self.layout_script(base, sub.as_deref(), sup.as_deref(), size_px)
}
MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px),
}
}
fn layout_char(&self, ch: char, size_px: f32) -> Result<MathBox, MathFontError> {
let presented = math_italic(ch);
let upem = f32::from(self.constants.units_per_em.max(1));
let (advance, ascent, descent) = self
.face
.glyph_index(presented)
.map(|gid| {
let adv = self
.face
.glyph_hor_advance(gid)
.map_or(0.0, |a| f32::from(a) * size_px / upem);
// Per-glyph bounds keep boxes tight, which is what makes a
// fraction's extents honest; fall back to face metrics when
// a glyph has no bounding box (e.g. a space).
let (asc, desc) = self.face.glyph_bounding_box(gid).map_or_else(
|| {
(
f32::from(self.face.ascender()) * size_px / upem,
-f32::from(self.face.descender()) * size_px / upem,
)
},
|bb| {
(
f32::from(bb.y_max) * size_px / upem,
-f32::from(bb.y_min) * size_px / upem,
)
},
);
(adv, asc.max(0.0), desc.max(0.0))
})
// F3: no glyph means no honest box. Emitting a zero-width item
// would draw tofu on top of the next character.
.ok_or(MathFontError::UncoverableGlyph(ch))?;
Ok(MathBox {
width: advance,
ascent,
descent,
items: vec![MathItem::Glyph {
ch: presented,
x: 0.0,
baseline: 0.0,
size_px,
}],
})
}
fn layout_script(
&self,
base: &crate::math_parse::MathNode,
sub: Option<&crate::math_parse::MathNode>,
sup: Option<&crate::math_parse::MathNode>,
size_px: f32,
) -> Result<MathBox, MathFontError> {
let base_box = self.layout(base, size_px)?;
let script_px = size_px * self.constants.script_scale();
let mut out = MathBox::empty();
out.absorb(&base_box, 0.0, 0.0);
let mut widest = base_box.width;
if let Some(sup) = sup {
let sup_box = self.layout(sup, script_px)?;
let shift = self
.constants
.to_px(self.constants.superscript_shift_up, size_px);
out.absorb(&sup_box, base_box.width, shift);
widest = widest.max(base_box.width + sup_box.width);
}
if let Some(sub) = sub {
let sub_box = self.layout(sub, script_px)?;
let shift = self
.constants
.to_px(self.constants.subscript_shift_down, size_px);
out.absorb(&sub_box, base_box.width, -shift);
widest = widest.max(base_box.width + sub_box.width);
}
out.width = widest;
Ok(out)
}
fn layout_fraction(
&self,
num: &crate::math_parse::MathNode,
den: &crate::math_parse::MathNode,
size_px: f32,
) -> Result<MathBox, MathFontError> {
// TeX sets an inline \frac's operands one style down, which is also
// what the parent framing's Tier 3 specifies (70%). It is load-bearing
// for Q#MS10: full-size operands would not fit the line at all.
let operand_px = size_px * self.constants.script_scale();
let num_box = self.layout(num, operand_px)?;
let den_box = self.layout(den, operand_px)?;
let axis = self.constants.to_px(self.constants.axis_height, size_px);
let thickness = self
.constants
.to_px(self.constants.fraction_rule_thickness, size_px)
.max(1.0);
// F4: the gaps come from the MATH table, not a guess. An earlier
// revision used `thickness * 2.0`, which made fractions roughly twice
// as airy as the font specifies and inflated the height budget the
// fit-to-line scale is measured against.
let num_gap = self
.constants
.to_px(self.constants.fraction_numerator_gap_min, size_px)
.max(thickness);
let den_gap = self
.constants
.to_px(self.constants.fraction_denominator_gap_min, size_px)
.max(thickness);
let width = num_box.width.max(den_box.width);
let mut out = MathBox::empty();
// Numerator sits above the bar, denominator below it.
let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent;
let den_baseline = axis - thickness / 2.0 - den_gap - den_box.ascent;
out.absorb(&num_box, (width - num_box.width) / 2.0, num_baseline);
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
out.items.push(MathItem::Rule {
x: 0.0,
y: axis,
width,
thickness,
});
out.ascent = out.ascent.max(axis + thickness / 2.0);
out.descent = out.descent.max(-(axis - thickness / 2.0));
out.width = width;
Ok(out)
}
}
/// Spacer text reserving `width_px`, quantized UP to whole space advances.
///
/// Q#MS4 / B1': a `RichChunk`'s only width is its text, so a suppressed math
/// span reserves room the way `SourceTab` does — with spaces. Quantizing up
/// is deliberate: it keeps the projection grid-aligned with the surrounding
/// monospace text and keeps hit runs integral, at the cost of up to one
/// advance of slack on the right of the box.
#[must_use]
pub fn spacer_for_width(width_px: f32, space_advance_px: f32) -> String {
if !width_px.is_finite() || width_px <= 0.0 || space_advance_px <= 0.0 {
return String::new();
}
let n = (width_px / space_advance_px).ceil();
// Guard the cast: a pathological advance must not mint a giant string.
let n = n.clamp(0.0, 4096.0) as usize;
" ".repeat(n)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math_parse::parse;
/// Framing acceptance 16 (provenance half): the GUST licence ships
/// beside the font, names itself, and is not the OFL that covers
/// `JetBrains` Mono.
#[test]
fn bundled_licences_are_distinct_and_name_their_terms() {
let gust = include_str!("../fonts/GUST-FONT-LICENSE.txt");
let ofl = include_str!("../fonts/OFL.txt");
assert!(gust.contains("GUST Font License"));
assert!(gust.contains("LaTeX Project Public License"));
assert!(!ofl.contains("GUST"));
assert_ne!(gust, ofl);
}
#[test]
fn tex_symbol_greek_forms_are_italicised_too() {
// F5's trap: correcting the seed map alone leaves these upright,
// because they sit outside the U+03B1..03C9 run.
assert_eq!(math_italic('\u{3F5}'), '\u{1D716}');
assert_eq!(math_italic('\u{3D5}'), '\u{1D719}');
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("face");
for ch in ['\u{3F5}', '\u{3D5}'] {
assert!(
face.glyph_index(math_italic(ch)).is_some(),
"no glyph for the italic form of U+{:04X}",
ch as u32
);
}
}
#[test]
fn spacer_quantizes_up_to_whole_advances() {
// Exactly two advances stays two; a sliver over rounds up, so the
// box never overlaps the text that follows it.
assert_eq!(spacer_for_width(20.0, 10.0).len(), 2);
assert_eq!(spacer_for_width(20.1, 10.0).len(), 3);
assert_eq!(spacer_for_width(0.1, 10.0).len(), 1);
// Degenerate inputs reserve nothing rather than panicking or
// minting an enormous string.
assert!(spacer_for_width(0.0, 10.0).is_empty());
assert!(spacer_for_width(-5.0, 10.0).is_empty());
assert!(spacer_for_width(10.0, 0.0).is_empty());
assert!(spacer_for_width(f32::NAN, 10.0).is_empty());
assert!(spacer_for_width(f32::INFINITY, 10.0).is_empty());
assert!(spacer_for_width(1e9, 0.001).len() <= 4096);
}
#[test]
fn a_real_box_reserves_at_least_its_own_width() {
let boxed = lay(r"\frac{a}{b}", crate::BASE_CODE_FONT_SIZE);
let advance = 9.6_f32; // a plausible monospace advance at 16 px
let spacer = spacer_for_width(boxed.width, advance);
let reserved = spacer.len() as f32 * advance;
assert!(
reserved >= boxed.width,
"reserved {reserved} must cover box width {}",
boxed.width
);
assert!(
reserved - boxed.width < advance,
"slack stays under one advance"
);
}
fn engine() -> MathLayout<'static> {
MathLayout::new(LATIN_MODERN_MATH).expect("bundled font")
}
fn lay(src: &str, size: f32) -> MathBox {
let node = parse(src).expect("parses");
engine().layout(&node, size).expect("lays out")
}
/// F3 — a codepoint the math font cannot draw REFUSES, so the caller can
/// fall back to source (Q#MS8) instead of drawing tofu at zero advance
/// on top of the next character.
#[test]
fn an_uncoverable_character_refuses_layout_instead_of_emitting_a_void() {
let node = parse("x日").expect("parses — coverage is layout's problem");
assert_eq!(
engine().layout(&node, 16.0),
Err(MathFontError::UncoverableGlyph('日'))
);
// The covered neighbour on its own still lays out.
assert!(engine().layout(&parse("x").unwrap(), 16.0).is_ok());
}
/// Framing acceptance 3, including its bite: the MATH constant must be
/// READ, not hardcoded.
#[test]
fn superscript_is_raised_and_scaled_from_the_math_table() {
let plain = lay("x", 16.0);
let script = lay("x^2", 16.0);
assert!(script.width > plain.width, "the 2 adds width");
assert!(
script.ascent > plain.ascent,
"superscript must raise the box: {} vs {}",
script.ascent,
plain.ascent
);
let two = script
.items
.iter()
.find_map(|i| match *i {
MathItem::Glyph {
ch: '2',
baseline,
size_px,
..
} => Some((baseline, size_px)),
_ => None,
})
.expect("the 2 is emitted");
assert!(two.0 > 0.0, "raised above baseline: {}", two.0);
assert!(two.1 < 16.0, "scaled down: {}", two.1);
// Bite: with the script scale stubbed to 100%, the box changes —
// proving the constant is consulted rather than assumed.
let c = engine().constants();
assert!(
c.script_percent_scale_down < 100,
"font advertises a real script scale ({}%), so 100% is a \
meaningful stub",
c.script_percent_scale_down
);
let stubbed = MathConstants {
script_percent_scale_down: 100,
..c
};
assert!(
(stubbed.script_scale() - c.script_scale()).abs() > 0.01,
"stubbing the constant must change the scale actually used"
);
}
#[test]
fn subscript_drops_below_the_baseline() {
let script = lay("x_i", 16.0);
let i = script
.items
.iter()
.find_map(|item| match *item {
MathItem::Glyph { ch, baseline, .. } if ch == math_italic('i') => Some(baseline),
_ => None,
})
.expect("the i is emitted");
assert!(i < 0.0, "subscript sits below the baseline: {i}");
assert!(script.descent > lay("x", 16.0).descent);
}
/// Framing acceptance 4.
#[test]
fn fraction_stacks_operands_around_a_rule_at_the_axis() {
let frac = lay(r"\frac{a}{b}", 16.0);
let rule = frac
.items
.iter()
.find_map(|item| match *item {
MathItem::Rule {
y,
width,
thickness,
..
} => Some((y, width, thickness)),
MathItem::Glyph { .. } => None,
})
.expect("a fraction draws a rule");
assert!(rule.0 > 0.0, "rule sits at the math axis, above baseline");
assert!(rule.2 > 0.0 && rule.1 > 0.0);
let mut above = 0;
let mut below = 0;
for item in &frac.items {
if let MathItem::Glyph { baseline, .. } = *item {
if baseline > rule.0 {
above += 1;
} else if baseline < rule.0 {
below += 1;
}
}
}
assert_eq!((above, below), (1, 1), "one operand each side of the bar");
assert!(frac.ascent > 0.0 && frac.descent > 0.0);
}
/// F1 / B6 — the height budget, computed rather than guessed.
///
/// The round-2 review warned that acceptance 12's fallback case must be
/// derived by computation or it would "surprise-pass by rendering". It
/// was right, and rev 3's guess was wrong: a doubly-nested fraction still
/// fits. This test derives the budget the way Q#MS10 defines it — from
/// the LINE BOX, whose baseline the CODE font places — and then searches
/// for the depth that actually trips the floor, so the case can never
/// drift out from under the acceptance criterion.
#[test]
fn fit_to_line_admits_real_fractions_and_finds_the_true_fallback_depth() {
// Q#MS10: the budget is the line box less a one-pixel margin, split
// at the text baseline. The baseline is where the CODE font puts it
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
// NOT where the math font's own metrics would.
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
let (asc_budget, desc_budget) = line_box_budget(
&code,
crate::BASE_CODE_FONT_SIZE,
crate::BASE_CODE_LINE_HEIGHT,
);
assert!(
asc_budget > 0.0 && desc_budget > 0.0,
"budget must be positive: {asc_budget} / {desc_budget}"
);
let scale_of = |src: &str| {
let boxed = lay(src, crate::BASE_CODE_FONT_SIZE);
let up = asc_budget / boxed.ascent.max(f32::EPSILON);
let down = desc_budget / boxed.descent.max(f32::EPSILON);
(up.min(down).min(1.0), boxed)
};
// The flagship cases must RENDER, not fall back (B6).
for src in [r"\frac{a}{b}", r"\frac{x^2}{y}", "x^2", r"\alpha x"] {
let (scale, boxed) = scale_of(src);
eprintln!(
"{src}: asc={:.2} desc={:.2} scale={scale:.3}",
boxed.ascent, boxed.descent
);
assert!(
scale >= MIN_FIT_SCALE,
"{src} must render, not fall back: scale {scale:.3} < {MIN_FIT_SCALE}"
);
assert!(fit_to_line(&boxed, asc_budget, desc_budget).is_some());
}
// Now FIND the depth that trips the floor rather than assuming one.
// Nest fractions until the scale drops below it.
let mut src = String::from(r"\frac{a}{b}");
let mut depth = 1;
let tripped = loop {
let (scale, _) = scale_of(&src);
eprintln!("depth {depth}: scale={scale:.3}");
if scale < MIN_FIT_SCALE {
break Some((depth, src.clone()));
}
// Headroom above the real boundary (5 with the round-3 MATH
// gaps): if a metric shift pushed the boundary past this bound,
// the expect below would fire with a message reading "the floor
// is dead code" when the truth is "the boundary moved past the
// search". Keep the bound comfortably above the boundary.
if depth >= 8 {
break None;
}
src = format!(r"\frac{{{src}}}{{c}}");
depth += 1;
};
let (depth, deep_src) = tripped.expect(
"some nesting depth must exceed the floor, or Q#MS10's fallback \
arm is unreachable and the floor is dead code",
);
assert!(
depth > 2,
"rev 3 guessed a doubly-nested fraction would fall back; the real \
depth is {depth}, so acceptance 12 must use that case"
);
assert!(
fit_to_line(
&lay(&deep_src, crate::BASE_CODE_FONT_SIZE),
asc_budget,
desc_budget
)
.is_none()
);
}
#[test]
fn fitting_scales_extents_and_items_together() {
let boxed = lay(r"\frac{a}{b}", 16.0);
let half = boxed.scaled(0.5);
assert!((half.ascent - boxed.ascent * 0.5).abs() < 0.001);
assert!((half.width - boxed.width * 0.5).abs() < 0.001);
for (before, after) in boxed.items.iter().zip(half.items.iter()) {
if let (MathItem::Glyph { size_px: b, .. }, MathItem::Glyph { size_px: a, .. }) =
(before, after)
{
assert!((a - b * 0.5).abs() < 0.001, "glyph size scales too");
}
}
}
#[test]
fn a_group_advances_the_pen_left_to_right() {
let boxed = lay("abc", 16.0);
let xs: Vec<f32> = boxed
.items
.iter()
.filter_map(|item| match *item {
MathItem::Glyph { x, .. } => Some(x),
MathItem::Rule { .. } => None,
})
.collect();
assert_eq!(xs.len(), 3);
assert!(xs[0] < xs[1] && xs[1] < xs[2], "left to right: {xs:?}");
assert!(boxed.width > xs[2], "width covers the last advance");
}
/// B5 — `ttf-parser` supplies every constant the subset needs, from the
/// bundled font. This is the bet that would sink Tier 3 if false, so it
/// runs against the real embedded bytes rather than a fixture.
#[test]
fn bundled_font_yields_every_math_constant_the_subset_needs() {
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH)
.expect("bundled Latin Modern Math must expose MATH constants");
assert_eq!(c.units_per_em, 1000, "LM Math is a 1000 upem font");
assert!(c.axis_height > 0, "axis height: {}", c.axis_height);
assert!(
(50..=100).contains(&c.script_percent_scale_down),
"script scale percent out of range: {}",
c.script_percent_scale_down
);
assert!(c.superscript_shift_up > 0);
assert!(c.subscript_shift_down > 0);
assert!(c.fraction_rule_thickness > 0);
}
#[test]
fn a_text_font_without_a_math_table_is_rejected_not_defaulted() {
// Q#MS7: a font with no MATH table must surface, not silently
// produce plausible-looking zeros.
let err = MathConstants::from_font_bytes(crate::JETBRAINS_MONO)
.expect_err("JetBrains Mono has no MATH table");
assert_eq!(err, MathFontError::NoMathTable);
assert_eq!(
MathConstants::from_font_bytes(b"not a font"),
Err(MathFontError::Unparseable)
);
}
#[test]
fn font_units_convert_to_pixels_against_upem() {
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH).expect("constants");
// Half an em at 16 px is 8 px.
let half_em = i16::try_from(c.units_per_em / 2).expect("fits");
assert!((c.to_px(half_em, 16.0) - 8.0).abs() < 0.01);
let scale = c.script_scale();
assert!((0.5..=1.0).contains(&scale), "script scale: {scale}");
}
#[test]
fn math_italic_follows_tex_convention_including_the_planck_hole() {
// Framing acceptance 13.
assert_eq!(math_italic('x'), '\u{1D465}');
assert_eq!(math_italic('A'), '\u{1D434}');
// The 1D4xx run has a hole at italic `h`; arithmetic would land on a
// reserved codepoint, so `h` maps to U+210E instead.
assert_eq!(math_italic('h'), '\u{210E}');
// Lowercase Greek is italic...
assert_eq!(math_italic('α'), '\u{1D6FC}');
assert_eq!(math_italic('ω'), '\u{1D714}');
// ...uppercase Greek is NOT (TeX convention, deliberate).
assert_eq!(math_italic('Γ'), 'Γ');
assert_eq!(math_italic('Ω'), 'Ω');
// Digits and operators stay upright.
assert_eq!(math_italic('2'), '2');
assert_eq!(math_italic('+'), '+');
}
#[test]
fn every_italic_mapping_lands_on_a_real_glyph_in_the_bundled_font() {
// A mapping that produces codepoints the bundled font cannot draw
// would render tofu — worse than the roman fallback it replaced.
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("parse bundled font");
let sample = "abhxyzABXYZαβωΓΩ0129+=";
for ch in sample.chars() {
let mapped = math_italic(ch);
assert!(
face.glyph_index(mapped).is_some(),
"no glyph for {ch:?} -> {mapped:?} (U+{:04X})",
mapped as u32
);
}
}
}

632
pmacs-gpu/src/math_parse.rs Normal file
View File

@ -0,0 +1,632 @@
//! LaTeX math-mode parser for the first inline-math slice.
//!
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS2. This parses
//! the deliberately small subset the slice renders — characters, groups,
//! sub/superscripts and fractions — and nothing else. Every other LaTeX
//! construct is an error, which Q#MS8 turns into "show the raw source".
//!
//! The AST is *semantic*, not presentational: `\alpha` resolves to `'α'`
//! here, but the math-italic mapping (Q#MS2's table) belongs to layout, which
//! is where a codepoint becomes a glyph. Keeping the split here means the AST
//! matches what the user wrote, and a future non-italic style context does
//! not have to unpick a decision the parser baked in.
/// One node of the slice's math subset (Q#MS2).
///
/// Rev 2 of the framing folded `Symbol` into `Char`: both carried a `char`,
/// and after symbol resolution layout cannot act on the difference.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MathNode {
/// A resolved codepoint: `x`, `2`, `+`, `α`.
Char(char),
/// A braced group, or the top-level expression.
Group(Vec<MathNode>),
/// A base with optional sub- and superscript.
Script {
base: Box<MathNode>,
sub: Option<Box<MathNode>>,
sup: Option<Box<MathNode>>,
},
/// `\frac{num}{den}`.
Fraction {
num: Box<MathNode>,
den: Box<MathNode>,
},
}
/// Why a span could not be parsed. Q#MS8 renders the raw source for all of
/// these; the variants exist so tests can assert *which* rejection fired.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MathParseError {
/// The span held no math (`$$` after delimiter stripping).
Empty,
/// A `{` with no matching `}`, or a stray `}`.
UnbalancedBrace,
/// A control sequence outside the subset, e.g. `\sqrt`.
UnknownCommand(String),
/// `\frac` without two braced arguments.
MalformedCommand(&'static str),
/// `^` or `_` with nothing to attach to, or given twice for one base.
MalformedScript(&'static str),
/// A `$` inside the span: the delimiters are the caller's business, and
/// a bare one here means detection handed us something it should not
/// have (framing acceptance 15 — `$$x$$` degrades through this path).
UnexpectedDollar,
}
/// Greek seed map (Q#MS2). Deliberately partial — growing it is mechanical.
const GREEK: &[(&str, char)] = &[
("alpha", 'α'),
("beta", 'β'),
("gamma", 'γ'),
("delta", 'δ'),
// TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon.
("epsilon", '\u{3F5}'),
("zeta", 'ζ'),
("eta", 'η'),
("theta", 'θ'),
("iota", 'ι'),
("kappa", 'κ'),
("lambda", 'λ'),
("mu", 'μ'),
("nu", 'ν'),
("xi", 'ξ'),
("pi", 'π'),
("rho", 'ρ'),
("sigma", 'σ'),
("tau", 'τ'),
("upsilon", 'υ'),
// TeX's \phi is U+03D5; U+03C6 is \varphi.
("phi", '\u{3D5}'),
("chi", 'χ'),
("psi", 'ψ'),
("omega", 'ω'),
("Gamma", 'Γ'),
("Delta", 'Δ'),
("Theta", 'Θ'),
("Lambda", 'Λ'),
("Xi", 'Ξ'),
("Pi", 'Π'),
("Sigma", 'Σ'),
("Upsilon", 'Υ'),
("Phi", 'Φ'),
("Psi", 'Ψ'),
("Omega", 'Ω'),
];
/// Parse the *interior* of a math span — delimiters already stripped.
///
/// # Errors
/// Returns [`MathParseError`] for anything outside the Q#MS2 subset.
pub fn parse(source: &str) -> Result<MathNode, MathParseError> {
let mut parser = Parser {
chars: source.chars().collect(),
pos: 0,
};
let nodes = parser.parse_sequence(None)?;
if parser.pos < parser.chars.len() {
// Only a stray `}` can stop the top-level sequence early.
return Err(MathParseError::UnbalancedBrace);
}
if nodes.is_empty() {
return Err(MathParseError::Empty);
}
Ok(MathNode::Group(nodes))
}
struct Parser {
chars: Vec<char>,
pos: usize,
}
impl Parser {
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn bump(&mut self) -> Option<char> {
let ch = self.peek();
if ch.is_some() {
self.pos += 1;
}
ch
}
/// Parse until `close` (or end of input when `None`).
fn parse_sequence(&mut self, close: Option<char>) -> Result<Vec<MathNode>, MathParseError> {
let mut out: Vec<MathNode> = Vec::new();
loop {
// Whitespace is insignificant in math mode, and it must be
// skipped HERE rather than inside `parse_atom`: the `^`/`_`
// dispatch below happens before atoms are read, so leaving a
// space in front of a marker would make `x ^ 2` parse the caret
// as a literal character.
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
match self.peek() {
None => {
if close.is_some() {
return Err(MathParseError::UnbalancedBrace);
}
return Ok(out);
}
Some(ch) if Some(ch) == close => {
self.pos += 1;
return Ok(out);
}
// A `}` we were not asked to stop at is unbalanced.
Some('}') => return Err(MathParseError::UnbalancedBrace),
Some('$') => return Err(MathParseError::UnexpectedDollar),
Some('^' | '_') => {
let base = out.pop().ok_or(MathParseError::MalformedScript(
"sub/superscript with no base",
))?;
out.push(self.parse_scripts(base)?);
}
Some(_) => {
let atom = self.parse_atom()?;
out.push(atom);
}
}
}
}
/// One atom: a group, a command, or a single character.
fn parse_atom(&mut self) -> Result<MathNode, MathParseError> {
match self.bump() {
Some('{') => Ok(MathNode::Group(self.parse_sequence(Some('}'))?)),
Some('\\') => self.parse_command(),
Some(ch) => Ok(MathNode::Char(ch)),
None => Err(MathParseError::MalformedCommand("unexpected end of input")),
}
}
fn parse_command(&mut self) -> Result<MathNode, MathParseError> {
let mut name = String::new();
while let Some(ch) = self.peek() {
if ch.is_ascii_alphabetic() {
name.push(ch);
self.pos += 1;
} else {
break;
}
}
if name.is_empty() {
// `\$`, `\{` … — an escaped literal.
return match self.bump() {
Some(ch) => Ok(MathNode::Char(ch)),
None => Err(MathParseError::MalformedCommand("trailing backslash")),
};
}
if name == "frac" {
let num = self.parse_required_group("\\frac numerator")?;
let den = self.parse_required_group("\\frac denominator")?;
return Ok(MathNode::Fraction {
num: Box::new(num),
den: Box::new(den),
});
}
if let Some((_, ch)) = GREEK.iter().find(|(n, _)| *n == name) {
return Ok(MathNode::Char(*ch));
}
Err(MathParseError::UnknownCommand(name))
}
/// A `{…}` argument, skipping leading whitespace.
fn parse_required_group(&mut self, what: &'static str) -> Result<MathNode, MathParseError> {
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
match self.peek() {
Some('{') => {
self.pos += 1;
Ok(MathNode::Group(self.parse_sequence(Some('}'))?))
}
_ => Err(MathParseError::MalformedCommand(what)),
}
}
/// Attach `^`/`_` to `base`, in either order, at most one each.
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
let mut sub: Option<Box<MathNode>> = None;
let mut sup: Option<Box<MathNode>> = None;
loop {
// Whitespace is insignificant, here too: without this skip
// `x^2 _i` builds a nested Script instead of one merged double
// script (drawing the subscript displaced right by the
// superscript's width), and `x^2 ^3` parses where TeX errors.
let resume = self.pos;
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
let Some(marker @ ('^' | '_')) = self.peek() else {
self.pos = resume;
break;
};
self.pos += 1;
let slot = self.parse_script_operand()?;
match marker {
'^' if sup.is_some() => {
return Err(MathParseError::MalformedScript("double superscript"));
}
'_' if sub.is_some() => {
return Err(MathParseError::MalformedScript("double subscript"));
}
'^' => sup = Some(Box::new(slot)),
_ => sub = Some(Box::new(slot)),
}
}
Ok(MathNode::Script {
base: Box::new(base),
sub,
sup,
})
}
/// The operand of `^`/`_`: a braced group, or exactly one atom.
fn parse_script_operand(&mut self) -> Result<MathNode, MathParseError> {
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
match self.peek() {
None | Some('^' | '_' | '}') => {
Err(MathParseError::MalformedScript("script with no operand"))
}
Some(_) => self.parse_atom(),
}
}
}
/// One detected inline span, as byte offsets into the scanned line.
///
/// `start`/`end` bracket the WHOLE span including both `$` delimiters, which
/// is what Q#MS4 suppresses; [`Self::interior`] is what the parser sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MathSpan {
pub start: usize,
pub end: usize,
}
impl MathSpan {
/// Byte range of the math source between the delimiters.
#[must_use]
pub fn interior(self) -> std::ops::Range<usize> {
self.start + 1..self.end - 1
}
}
/// Find inline `$…$` spans in ONE line (Q#MS3).
///
/// Spans never cross a newline: 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. Callers pass one line at a time.
///
/// Currency guards are mandatory, not a refinement (framing F5). Without
/// them `prices are $5 and $6 today` pairs the two `$` and renders `5 and `
/// as math — in exactly the grammar-less prose buffers this scanner targets.
/// 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.
#[must_use]
pub fn detect_math_spans(line: &str) -> Vec<MathSpan> {
let bytes = line.as_bytes();
let mut spans = Vec::new();
let mut i = 0;
let mut open: Option<usize> = None;
while i < bytes.len() {
if bytes[i] == b'\\' {
// Skip the escaped byte: `\$` is literal, so it can neither open
// nor close. Stepping two also stops `\\$` from being read as an
// escape of the dollar.
i += 2;
continue;
}
if bytes[i] != b'$' {
i += 1;
continue;
}
if bytes.get(i + 1) == Some(&b'$') {
// `$$` is display math, which this slice defers. It is NOT two
// inline delimiters: reading it that way makes `$$x$$` match the
// inner `$x$`, which parses, so the span would half-render as
// math with a stray `$` on each side. Acceptance 15 requires it
// to degrade to source, so `$$` is opaque — it neither opens nor
// closes, and abandons any pending opener.
i += 2;
open = None;
continue;
}
match open {
None => {
// Opener: next byte must exist and be a non-space.
let opens = bytes
.get(i + 1)
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$');
if opens {
open = Some(i);
}
}
Some(start) => {
let prev_ok = i > start + 1 && !bytes[i - 1].is_ascii_whitespace();
let next_ok = bytes.get(i + 1).is_none_or(|b| !b.is_ascii_digit());
if prev_ok && next_ok {
spans.push(MathSpan { start, end: i + 1 });
open = None;
} else if !prev_ok {
// `$foo $` — the closer is disqualified by the space
// before it. Treat this `$` as a fresh opener candidate
// rather than letting the span run to the next one.
open = bytes
.get(i + 1)
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$')
.then_some(i);
}
}
}
i += 1;
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
fn ch(c: char) -> MathNode {
MathNode::Char(c)
}
fn group(nodes: Vec<MathNode>) -> MathNode {
MathNode::Group(nodes)
}
#[test]
fn detection_finds_inline_spans() {
assert_eq!(
detect_math_spans("$x^2$"),
vec![MathSpan { start: 0, end: 5 }]
);
let two = detect_math_spans("$a$ and $b$");
assert_eq!(two.len(), 2, "{two:?}");
let line = "before $x^2$ after";
let span = detect_math_spans(line)[0];
assert_eq!(&line[span.start..span.end], "$x^2$");
assert_eq!(&line[span.interior()], "x^2");
}
/// Framing F5 / acceptance 2 — the case rev 1's rule would have
/// mis-rendered as math over "5 and ".
#[test]
fn currency_guards_reject_prose_dollars() {
assert!(detect_math_spans("Price: $5.00").is_empty());
assert!(
detect_math_spans("prices are $5 and $6 today").is_empty(),
"a digit after the closer disqualifies it"
);
assert!(
detect_math_spans("$ x $").is_empty(),
"space after the opener disqualifies it"
);
assert!(
detect_math_spans("costs $5 or $6").is_empty(),
"both guards together"
);
}
#[test]
fn escaped_dollars_neither_open_nor_close() {
assert!(detect_math_spans(r"\$5 and \$6").is_empty());
// An escaped dollar inside a span does not close it.
let line = r"$a\$b$";
let spans = detect_math_spans(line);
assert_eq!(spans.len(), 1);
assert_eq!(&line[spans[0].start..spans[0].end], r"$a\$b$");
}
#[test]
fn an_unpaired_dollar_yields_nothing() {
assert!(detect_math_spans("$x").is_empty());
assert!(detect_math_spans("x$").is_empty());
// Q#MS3: spans never cross a newline. Callers scan per line, so a
// partner on the next line is simply not visible to this call.
assert!(detect_math_spans("$x").is_empty());
assert!(detect_math_spans("y$").is_empty());
}
#[test]
fn empty_and_display_delimiters_degrade_rather_than_half_match() {
// Acceptance 15. `$$` is opaque, so display math yields NO span and
// falls through to source. Asserting emptiness rather than "any span
// found must fail to parse" matters: the interior of the inner `$x$`
// parses perfectly well, so the weaker form passed vacuously while
// `$$x$$` half-rendered with a stray `$` on each side.
assert!(detect_math_spans("$$").is_empty());
assert!(
detect_math_spans("$$x$$").is_empty(),
"display math must not match the inner $x$"
);
assert!(detect_math_spans(r"$$\frac{a}{b}$$").is_empty());
// A real inline span beside display math is still found.
let mixed = detect_math_spans("$a$ then $$b$$");
assert_eq!(mixed.len(), 1, "{mixed:?}");
}
/// Round-3 F6 — a DOCUMENTED casualty of the `$$`-opaque rule, not a
/// guard failure: in `$a$$b$` the first span's legitimate closer is
/// immediately followed by the second span's opener, the lookahead
/// reads that pair as display-math `$$`, and the pending opener is
/// abandoned. Adjacent inline spans therefore need a separating
/// character. Pandoc finds two spans here; this scanner deliberately
/// finds none, because distinguishing `$a$$b$` from `$$x$$` requires
/// closer-context the framing's opaque-`$$` rule gave away.
#[test]
fn adjacent_inline_spans_are_eaten_by_the_display_guard() {
assert!(detect_math_spans("$a$$b$").is_empty());
assert!(detect_math_spans("$x^2$$y^2$").is_empty());
// One separating character restores both spans.
assert_eq!(detect_math_spans("$a$ $b$").len(), 2);
}
#[test]
fn whitespace_before_a_script_marker_still_merges_the_scripts() {
// F2: without skipping whitespace in `parse_scripts`, `x^2 _i` built
// a NESTED script and drew the subscript displaced right.
assert_eq!(parse("x^2 _i"), parse("x^2_i"));
assert_eq!(parse("x _i ^2"), parse("x_i^2"));
// And a doubled script is still an error with space between.
assert!(matches!(
parse("x^2 ^3"),
Err(MathParseError::MalformedScript(_))
));
}
#[test]
fn the_greek_seed_uses_tex_letter_forms() {
// F5: TeX's \epsilon is lunate and \phi is the symbol form; the
// U+03B5 / U+03C6 glyphs are \varepsilon / \varphi.
assert_eq!(parse(r"\epsilon"), Ok(group(vec![ch('\u{3F5}')])));
assert_eq!(parse(r"\phi"), Ok(group(vec![ch('\u{3D5}')])));
}
#[test]
fn plain_characters_parse_in_order() {
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));
}
#[test]
fn superscript_and_subscript_attach_to_the_preceding_atom() {
// Framing acceptance 1.
assert_eq!(
parse("x^2"),
Ok(group(vec![MathNode::Script {
base: Box::new(ch('x')),
sub: None,
sup: Some(Box::new(ch('2'))),
}]))
);
assert_eq!(
parse("x_i"),
Ok(group(vec![MathNode::Script {
base: Box::new(ch('x')),
sub: Some(Box::new(ch('i'))),
sup: None,
}]))
);
}
#[test]
fn both_scripts_parse_in_either_order() {
let expected = MathNode::Script {
base: Box::new(ch('x')),
sub: Some(Box::new(ch('i'))),
sup: Some(Box::new(ch('2'))),
};
assert_eq!(parse("x_i^2"), Ok(group(vec![expected.clone()])));
assert_eq!(parse("x^2_i"), Ok(group(vec![expected])));
}
#[test]
fn braced_script_operands_group() {
assert_eq!(
parse("x^{i+1}"),
Ok(group(vec![MathNode::Script {
base: Box::new(ch('x')),
sub: None,
sup: Some(Box::new(group(vec![ch('i'), ch('+'), ch('1')]))),
}]))
);
}
#[test]
fn fraction_takes_two_braced_arguments() {
assert_eq!(
parse(r"\frac{a}{b}"),
Ok(group(vec![MathNode::Fraction {
num: Box::new(group(vec![ch('a')])),
den: Box::new(group(vec![ch('b')])),
}]))
);
}
#[test]
fn fractions_nest() {
// Framing acceptance 1 and 12's over-tall candidate.
let inner = MathNode::Fraction {
num: Box::new(group(vec![ch('a')])),
den: Box::new(group(vec![ch('b')])),
};
assert_eq!(
parse(r"\frac{\frac{a}{b}}{c}"),
Ok(group(vec![MathNode::Fraction {
num: Box::new(group(vec![inner])),
den: Box::new(group(vec![ch('c')])),
}]))
);
}
#[test]
fn greek_seed_resolves_to_codepoints_not_markup() {
assert_eq!(parse(r"\alpha"), Ok(group(vec![ch('α')])));
assert_eq!(parse(r"\Gamma"), Ok(group(vec![ch('Γ')])));
// The AST stays semantic: no italic mapping here (that is layout's,
// per this module's header and Q#MS2).
assert_eq!(parse(r"\alpha x"), Ok(group(vec![ch('α'), ch('x')])));
}
#[test]
fn whitespace_is_insignificant() {
assert_eq!(parse("x ^ 2"), parse("x^2"));
assert_eq!(parse(r"\frac {a} {b}"), parse(r"\frac{a}{b}"));
}
#[test]
fn subset_violations_are_errors_not_panics() {
// Framing acceptance 1 and 9.
assert_eq!(parse(""), Err(MathParseError::Empty));
assert_eq!(parse(" "), Err(MathParseError::Empty));
assert_eq!(parse("{a"), Err(MathParseError::UnbalancedBrace));
assert_eq!(parse("a}"), Err(MathParseError::UnbalancedBrace));
assert_eq!(
parse(r"\sqrt{2}"),
Err(MathParseError::UnknownCommand("sqrt".to_owned()))
);
assert!(matches!(
parse(r"\frac{a}"),
Err(MathParseError::MalformedCommand(_))
));
assert!(matches!(
parse(r"\frac a b"),
Err(MathParseError::MalformedCommand(_))
));
assert!(matches!(
parse("^2"),
Err(MathParseError::MalformedScript(_))
));
assert!(matches!(
parse("x^"),
Err(MathParseError::MalformedScript(_))
));
assert!(matches!(
parse("x^2^3"),
Err(MathParseError::MalformedScript(_))
));
}
#[test]
fn an_interior_dollar_is_rejected_so_display_math_degrades() {
// Framing acceptance 15: `$$x$$` reaches us as the interior `$x$`
// (outer delimiters stripped), and must degrade to source rather
// than half-render. The empty-span path covers `$$` alone.
assert_eq!(parse("$x$"), Err(MathParseError::UnexpectedDollar));
assert_eq!(parse(""), Err(MathParseError::Empty));
}
#[test]
fn escaped_literals_survive_as_characters() {
assert_eq!(parse(r"\{"), Ok(group(vec![ch('{')])));
assert_eq!(parse(r"\$"), Ok(group(vec![ch('$')])));
}
}