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") }