From 8e4bc00015786baea71de4c596d5004553efece4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 18:57:18 -0400 Subject: [PATCH] feat(math): ChunkSource::MathBox and spacer width quantization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suppression mechanism F2 forced: a RichChunk's only width is its text, so a suppressed span reserves room with SPACER SPACES the way SourceTab already does, quantized up to whole advances. Quantizing up keeps the projection grid-aligned with the surrounding monospace text and keeps hit runs integral, at the cost of under one advance of slack on the right. Adding the variant to an exhaustive enum made the compiler enumerate every seam it must participate in, which is why it is wired through all five rather than the two I had in mind: projected_to_source, source_to_projected, the tab expander's source remap, and offset_chunk_source. Hits anywhere inside a box snap to the span start — the Adornment rule, because Q#MS4 gives the box no interior byte map — and source positions inside it collapse to the box's left edge, so text after the span accounts for the whole reserved width. Two details the tab expander needed: a math chunk's spacer text is generated rather than source, so it holds no tab byte to expand, and its suppressed range is already in slice coordinates and never split, so a within-chunk offset does not move it. spacer_for_width guards its inputs: a non-finite width, a non-positive advance, or a pathological ratio reserves nothing or clamps, rather than panicking or minting an enormous string from a cast. 184 pmacs-gpu tests pass, including the 155 that predate this branch. Co-Authored-By: Claude Opus 5 (1M context) --- pmacs-gpu/src/main.rs | 115 +++++++++++++++++++++++++++++++++++ pmacs-gpu/src/math_layout.rs | 52 ++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 90bb515..961e0fc 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -7722,6 +7722,13 @@ enum ChunkSource { /// Injected adornment text (inlay hint) anchored at this slice /// byte offset. Hits inside it snap to the anchor. Adornment { anchor: u64 }, + /// A suppressed inline-math span (Q#MS4). The chunk's text is SPACER + /// spaces reserving the laid-out box's width — a `RichChunk`'s only + /// width is its text, so there is no zero-glyph strut to reserve with + /// (framing F2). `start`..`end` is the suppressed source range, + /// delimiters included; hits inside snap to `start`, the same rule + /// `Adornment` uses, because the box has no interior byte map. + MathBox { start: u64, end: u64 }, } /// One run of the projected→source hit map (Q#M2), built by @@ -7738,6 +7745,95 @@ struct ProjectedRun { /// Build the projected→source run map plus the projected text's line /// start table (cosmic-text reports hits as line + byte-within-line). + +#[cfg(test)] +mod math_chunk_tests { + use super::*; + + fn spacer_chunk(start: u64, end: u64, spaces: usize) -> RichChunk { + RichChunk { + text: " ".repeat(spaces), + color: None, + source: ChunkSource::MathBox { start, end }, + } + } + + /// Q#MS4: hits anywhere inside a suppressed span snap to its start, the + /// same rule `Adornment` uses, because the box has no interior byte map. + #[test] + fn hits_inside_a_math_box_snap_to_the_span_start() { + // `ab` + `$x^2$` suppressed to 3 spacer columns + `cd` + let chunks = vec![ + RichChunk { + text: "ab".to_owned(), + color: None, + source: ChunkSource::Source { start: 0 }, + }, + spacer_chunk(2, 7, 3), + RichChunk { + text: "cd".to_owned(), + color: None, + source: ChunkSource::Source { start: 7 }, + }, + ]; + let (runs, _) = build_hit_runs(&chunks); + assert_eq!(projected_to_source(&runs, 0), Some(0)); + assert_eq!(projected_to_source(&runs, 1), Some(1)); + // Every boundary within the spacer maps to the span start (2). + for projected in 2..=4 { + assert_eq!( + projected_to_source(&runs, projected), + Some(2), + "projected {projected} must snap to the span start" + ); + } + // Past the box, ordinary source mapping resumes. + assert_eq!(projected_to_source(&runs, 5), Some(7)); + assert_eq!(projected_to_source(&runs, 6), Some(8)); + } + + /// The inverse direction: a caret anywhere in the suppressed range sits + /// at the box's left edge, and text after it accounts for the full + /// reserved width (acceptance 10's "shifts by the quantized difference"). + #[test] + fn source_positions_inside_a_math_box_map_to_its_left_edge() { + let chunks = vec![ + RichChunk { + text: "ab".to_owned(), + color: None, + source: ChunkSource::Source { start: 0 }, + }, + spacer_chunk(2, 7, 3), + RichChunk { + text: "cd".to_owned(), + color: None, + source: ChunkSource::Source { start: 7 }, + }, + ]; + assert_eq!(source_to_projected(&chunks, 0), Some(0)); + assert_eq!(source_to_projected(&chunks, 2), Some(2)); + // Interior source bytes collapse onto the left edge. + assert_eq!(source_to_projected(&chunks, 4), Some(2)); + assert_eq!(source_to_projected(&chunks, 7), Some(2)); + // The first byte after the span lands past the reserved width. + assert_eq!(source_to_projected(&chunks, 8), Some(6)); + } + + /// A math chunk carries generated spacer text, so tab expansion must + /// leave it alone rather than treating a space as a source tab. + #[test] + fn tab_expansion_preserves_a_math_chunk_untouched() { + let chunks = vec![spacer_chunk(0, 5, 4)]; + let expanded = expand_chunk_tabs(chunks); + assert_eq!(expanded.len(), 1); + assert_eq!(expanded[0].text, " "); + assert!(matches!( + expanded[0].source, + ChunkSource::MathBox { start: 0, end: 5 } + )); + } +} + fn build_hit_runs(chunks: &[RichChunk]) -> (Vec, Vec) { let mut runs = Vec::with_capacity(chunks.len()); let mut line_starts = vec![0u64]; @@ -7776,6 +7872,9 @@ fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option { ChunkSource::Source { start } => Some(start + within), ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)), ChunkSource::Adornment { anchor } => Some(anchor), + // Q#MS4: the box has no interior byte map, so every boundary inside + // it snaps to the span start rather than inventing a sub-position. + ChunkSource::MathBox { start, .. } => Some(start), } } @@ -7810,6 +7909,16 @@ fn source_to_projected(chunks: &[RichChunk], source: u64) -> Option { return Some(projected); } } + ChunkSource::MathBox { start, end } => { + // Anywhere within the suppressed range maps to the box's + // left edge; past it, the box's full reserved width applies. + if source <= start { + return Some(projected); + } + if source <= end { + return Some(projected); + } + } } projected += len; } @@ -9131,6 +9240,9 @@ fn expand_chunk_tabs(chunks: Vec) -> Vec { }, ChunkSource::Adornment { anchor } => ChunkSource::Adornment { anchor }, ChunkSource::SourceTab { start } => ChunkSource::SourceTab { start }, + // A suppressed math span's spacer text is generated, not + // source, so it holds no tab byte to expand. + ChunkSource::MathBox { start, end } => ChunkSource::MathBox { start, end }, }, }); column += tab_width; @@ -9156,6 +9268,9 @@ fn offset_chunk_source(source: ChunkSource, byte_offset: u64) -> ChunkSource { }, ChunkSource::SourceTab { start } => ChunkSource::SourceTab { start }, ChunkSource::Adornment { anchor } => ChunkSource::Adornment { anchor }, + // The suppressed range is already in slice coordinates and is never + // split, so a within-chunk offset does not move it. + ChunkSource::MathBox { start, end } => ChunkSource::MathBox { start, end }, } } diff --git a/pmacs-gpu/src/math_layout.rs b/pmacs-gpu/src/math_layout.rs index f41c4cb..be0cec1 100644 --- a/pmacs-gpu/src/math_layout.rs +++ b/pmacs-gpu/src/math_layout.rs @@ -432,12 +432,64 @@ impl<'a> MathLayout<'a> { } } +/// Spacer text reserving `width_px`, quantized UP to whole space advances. +/// +/// Q#MS4 / B1': a `RichChunk`'s only width is its text, so a suppressed math +/// span reserves room the way `SourceTab` does — with spaces. Quantizing up +/// is deliberate: it keeps the projection grid-aligned with the surrounding +/// monospace text and keeps hit runs integral, at the cost of up to one +/// advance of slack on the right of the box. +#[must_use] +pub fn spacer_for_width(width_px: f32, space_advance_px: f32) -> String { + if !width_px.is_finite() || width_px <= 0.0 || space_advance_px <= 0.0 { + return String::new(); + } + let n = (width_px / space_advance_px).ceil(); + // Guard the cast: a pathological advance must not mint a giant string. + let n = n.clamp(0.0, 4096.0) as usize; + " ".repeat(n) +} + #[cfg(test)] mod tests { use super::*; use crate::math_parse::parse; + #[test] + fn spacer_quantizes_up_to_whole_advances() { + // Exactly two advances stays two; a sliver over rounds up, so the + // box never overlaps the text that follows it. + assert_eq!(spacer_for_width(20.0, 10.0).len(), 2); + assert_eq!(spacer_for_width(20.1, 10.0).len(), 3); + assert_eq!(spacer_for_width(0.1, 10.0).len(), 1); + // Degenerate inputs reserve nothing rather than panicking or + // minting an enormous string. + assert!(spacer_for_width(0.0, 10.0).is_empty()); + assert!(spacer_for_width(-5.0, 10.0).is_empty()); + assert!(spacer_for_width(10.0, 0.0).is_empty()); + assert!(spacer_for_width(f32::NAN, 10.0).is_empty()); + assert!(spacer_for_width(f32::INFINITY, 10.0).is_empty()); + assert!(spacer_for_width(1e9, 0.001).len() <= 4096); + } + + #[test] + fn a_real_box_reserves_at_least_its_own_width() { + let boxed = lay(r"\frac{a}{b}", crate::BASE_CODE_FONT_SIZE); + let advance = 9.6_f32; // a plausible monospace advance at 16 px + let spacer = spacer_for_width(boxed.width, advance); + let reserved = spacer.len() as f32 * advance; + assert!( + reserved >= boxed.width, + "reserved {reserved} must cover box width {}", + boxed.width + ); + assert!( + reserved - boxed.width < advance, + "slack stays under one advance" + ); + } + fn engine() -> MathLayout<'static> { MathLayout::new(LATIN_MODERN_MATH).expect("bundled font") }