diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 633474f2b1..5e85cefb8c 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -610,15 +610,7 @@ impl LineLayoutCache { .layout_line(&text, font_size, runs); if let Some(force_width) = force_width { - let mut glyph_pos = 0; - for run in layout.runs.iter_mut() { - for glyph in run.glyphs.iter_mut() { - if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) { - glyph.position.x = glyph_pos * force_width; - } - glyph_pos += 1; - } - } + apply_force_width_to_layout(&mut layout, force_width); } let key = Arc::new(CacheKey { @@ -767,15 +759,7 @@ impl LineLayoutCache { .layout_line(&text, font_size, runs); if let Some(force_width) = force_width { - let mut glyph_pos = 0; - for run in layout.runs.iter_mut() { - for glyph in run.glyphs.iter_mut() { - if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) { - glyph.position.x = glyph_pos * force_width; - } - glyph_pos += 1; - } - } + apply_force_width_to_layout(&mut layout, force_width); } let key = Arc::new(HashedCacheKey { @@ -795,6 +779,36 @@ impl LineLayoutCache { } } +// Combining marks (e.g. Thai vowel signs, Arabic diacritics) are shaped by +// HarfBuzz at the same x position as their base character. The force-width +// loop must not advance the cell counter for these zero-advance glyphs, +// otherwise they get displaced into the next cell. We detect them by checking +// whether shaped x has advanced by at least half a cell beyond the last base. +fn apply_force_width_to_layout(layout: &mut LineLayout, force_width: Pixels) { + let mut glyph_pos: usize = 0; + // NEG_INFINITY ensures the first glyph is always classified as a base. + let mut last_base_shaped_x = px(f32::NEG_INFINITY); + let mut last_base_actual_x = px(0.); + + for run in layout.runs.iter_mut() { + for glyph in run.glyphs.iter_mut() { + let shaped_x = glyph.position.x; + + if shaped_x > last_base_shaped_x + force_width * 0.5 { + let forced_x = glyph_pos * force_width; + if (shaped_x - forced_x).abs() > px(1.) { + glyph.position.x = forced_x; + } + last_base_shaped_x = shaped_x; + last_base_actual_x = glyph.position.x; + glyph_pos += 1; + } else { + glyph.position.x = last_base_actual_x + (shaped_x - last_base_shaped_x); + } + } + } +} + /// A run of text with a single font. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] #[expect(missing_docs)] @@ -942,3 +956,123 @@ impl AsCacheKeyRef for CacheKeyRef<'_> { *self } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::GlyphId; + + fn glyph_at(x: f32, index: usize) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(0), + position: point(px(x), px(0.)), + index, + is_emoji: false, + } + } + + fn make_layout(glyphs: Vec) -> LineLayout { + LineLayout { + font_size: px(16.), + width: px(100.), + ascent: px(12.), + descent: px(4.), + runs: vec![ShapedRun { + font_id: FontId(0), + glyphs, + }], + len: 0, + } + } + + fn glyph_x_positions(layout: &LineLayout) -> Vec { + layout.runs[0] + .glyphs + .iter() + .map(|g| f32::from(g.position.x)) + .collect() + } + + #[test] + fn test_force_width_latin_unchanged() { + let cell_width = px(8.); + let mut layout = make_layout(vec![glyph_at(0., 0), glyph_at(8., 1), glyph_at(16., 2)]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 8., 16.]); + } + + #[test] + fn test_force_width_combining_marks_not_advanced() { + let cell_width = px(8.); + // Simulates Thai "กี" — base consonant at x=0, combining vowel also at x=0 + let mut layout = make_layout(vec![ + glyph_at(0., 0), // ก (base) + glyph_at(0., 3), // ี (combining mark, same x) + ]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 0.]); + } + + #[test] + fn test_force_width_base_after_combining_mark() { + let cell_width = px(8.); + let mut layout = make_layout(vec![glyph_at(0., 0), glyph_at(0., 3), glyph_at(8., 6)]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 0., 8.]); + } + + #[test] + fn test_force_width_multiple_combining_marks() { + let cell_width = px(8.); + // Simulates "ก้" — base + vowel + tone mark (two combining marks stacked) + let mut layout = make_layout(vec![ + glyph_at(0., 0), // ก (base) + glyph_at(0., 3), // vowel (combining) + glyph_at(0., 6), // tone mark (combining) + glyph_at(8., 9), // next base + ]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 0., 0., 8.]); + } + + #[test] + fn test_force_width_corrects_drifted_base_positions() { + let cell_width = px(8.); + // Font metrics don't perfectly match cell grid — glyphs drift >1px from cell boundary + let mut layout = make_layout(vec![ + glyph_at(0.5, 0), // within 1px tolerance, kept as-is + glyph_at(10.2, 1), // >1px off from 8.0, corrected + glyph_at(19.8, 2), // >1px off from 16.0, corrected + ]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0.5, 8., 16.]); + } + + #[test] + fn test_force_width_combining_mark_after_within_tolerance_base() { + let cell_width = px(8.); + // Base glyph is within 1px of grid so it keeps its shaped position. + // The combining mark must align to the base's actual position, not the grid slot. + let mut layout = make_layout(vec![glyph_at(0.5, 0), glyph_at(0.5, 3)]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0.5, 0.5]); + } +}