From 14c1c0104312ccd32e3e986f410a5cde83dc532a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 22:04:35 -0400 Subject: [PATCH] feat(math): caret-driven suppression, the draw pass, and the slice acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two halves that touch live rendering, landed together because the acceptance criteria that make either honest need both. Suppression (Q#MS3/MS4/MS5/MS11). Detection runs in the per-line chunk builder — the chunk-build path, never the edit path — and substitutes each suppressed span's source bytes with ONE spacer chunk BEFORE tab expansion, so a literal tab inside a span vanishes with it while tabs outside keep their SourceTab provenance. The gate reads the EFFECTIVE caret (own_cursor, which optimistic edits predict forward — F4's no-flap requirement holds by construction) plus both own-selection endpoints. Three motion paths can flip a gate without a content change, and each now re-runs the per-line chunk compare, gated on a one-scan "does the visible slice hold a $" check: the CursorByte arm, finish_optimistic_edit (the text re-chunks under the OLD caret there; without the hook a typed char rendered one keystroke stale), and the Decorations arm — whose "no decoration change needs a reshape" premise acquires exactly one exception, the Selection endpoints Q#MS11 made suppression inputs. The line-reuse predicate (acceptance 11, the #120 edge). Per-line math state is cached in lockstep with line_chunk_cache: every detected span with the gate bit it was built under. The scroll-reuse path refuses a retained line whose cached bits disagree with the CURRENT caret/selection — content is unchanged on every reuse path, so the cached span set is authoritative and the gate bits are the only variable. The acceptance test drives the stale-gate case through rebuild_lines_reusing_scroll directly and fails if the gate is removed from the predicate. The hit map (B1'). hit_test_source_byte rebuilds its runs from a whole-slice chunk walk, so it now reads the substitutions BACK from the per-line caches — never re-planned under a possibly-newer caret — keeping the map and the shaped glyphs one source of truth. The draw pass (Q#MS6/MS7). Every MathItem::Glyph draws from its own mini-buffer with Attrs pinned to the bundled math family (F8b), placed at layout's exact x and the shaped line's REAL baseline; the mini-buffer itself is positioned by the line_y cosmic-text actually produced for it, so no font-metric rederivation can drift. Fraction rules ride the bg quad batch after the decoration washes and under the glyphs. Wash geometry gains Q#MS11's intersection rule: a wash touching a suppressed span widens to the box's whole reserved rectangle (a match strictly inside the span produced a zero-width interval before), while the round-3 exclusive-end fix keeps a non-intersecting wash off the box. Acceptance (framing §5). Criteria 5-11 and 14-16 run on real pixels through render_to_view: drawn ink where a literal-spacer control renders none, with the before-region pixel-identical; the fraction rule as a full-width run with operand ink both sides; caret-inside rendering EXACTLY as math-disabled (driven through the real CursorByte arm, which owns the refresh — a direct helper call would not have pinned the wiring); every failure mode (unbalanced, unknown command, $$, uncoverable glyph) pixel-equal to disabled; box clicks snapping to the span start with the trailing edge landing after the span; the scroll-reuse stale-gate bite; reflow confined to the affected line with the after-text shifted by exactly the quantized projection difference; selection gating and the whole-rectangle wash; and the licence provenance pair. Criterion 17 is discharged differentially: cargo tree -e features output for ttf-parser is byte-identical with and without this crate's dependency line. Also folded in, per the round-3 close-out: the F6 documenting test ($a$$b$ is eaten by the $$-opaque rule; one separating character restores both spans), the depth-search bound raised 6 -> 8 so a metric shift cannot make the "floor is dead code" expect fire with a misleading message, the MathBox { end, .. } pattern nit, and the active-work.md lane entry. Named v0 approximations, deliberate: 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 one-rectangle model); 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, so only the fit margin is approximate. Clippy is CLEAN across the workspace at -D warnings for the first time on this branch: the draw pass consumed every formerly-dead item, and the three lints it could not fix (a test-only accessor, one doc string, one manual midpoint) are fixed here. Gates: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings; 1,815 default + 1,992 CRDT library tests; M4 121 (basedpyright skipped); 199 pmacs-gpu tests under PMACS_REQUIRE_GPU=1; workspace sweep 3,131 across 88 suites (isolated XDG_CONFIG_HOME); git diff --check. Co-Authored-By: Claude Fable 5 --- docs/active-work.md | 35 ++ pmacs-gpu/src/main.rs | 1042 +++++++++++++++++++++++++++++++++- pmacs-gpu/src/math_layout.rs | 23 +- pmacs-gpu/src/math_parse.rs | 16 + 4 files changed, 1096 insertions(+), 20 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 2b258bd..e5aa3ba 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,6 +54,41 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Inline-math slice lane — IN FLIGHT + +- Portable branch: `githubsucks/inline-math-slice`; worktree + `../pmacs-math-slice`. +- 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 at the tip: 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`). +- Remaining before PR: nothing known; awaiting 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). + ## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next Both shipped stages are on `main`; nothing in this arc is in flight. Stage 3 diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 677a0be..bf6766a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -224,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)))) @@ -277,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 @@ -343,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. @@ -1488,6 +1524,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, @@ -1538,6 +1589,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 @@ -3017,6 +3074,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); @@ -3036,6 +3104,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); @@ -3190,6 +3261,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(), @@ -3210,6 +3284,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, @@ -3434,6 +3509,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, @@ -3932,7 +4013,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 } @@ -4127,6 +4214,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); } @@ -4899,18 +4993,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; @@ -5050,15 +5191,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 @@ -5099,14 +5244,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 @@ -5129,30 +5408,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)); @@ -5183,6 +5484,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(); @@ -5190,12 +5492,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); @@ -6147,13 +6454,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)); @@ -6416,6 +6726,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 @@ -6675,6 +6988,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 { @@ -6682,6 +7005,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 @@ -6867,6 +7195,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