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