git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/shaping.rs (5.5K)

  1 //! Measuring text the way the renderer will draw it: widths of runs, and
  2 //! the x of every character boundary in a run (a caret's or a click's
  3 //! position). Shared by `widget::markdown` and `widget::doc_editor`.
  4 
  5 use std::collections::HashMap;
  6 
  7 use crate::scene::paint::TextAttrs;
  8 
  9 pub use crate::backend::text::{ShapedCluster, ShapedRun};
 10 
 11 /// Text widths in logical px, for one run in one style.
 12 pub trait Measure {
 13     fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32;
 14 }
 15 
 16 /// [`Measure`] through the renderer's own shaping entry
 17 /// (`get_text_buffer_attrs`), so laid-out widths are the drawn widths. It
 18 /// owns its FontSystem: create it with the same font set the app's renderer
 19 /// loads — `system_fonts` mirroring the app's `load_system_fonts` — or
 20 /// face ids and widths will not match. Widths are logical px at the
 21 /// toolkit's current scale factor, cached per run.
 22 pub struct ShapingMeasure {
 23     fs: cosmic_text::FontSystem,
 24     cache: HashMap<(String, u32, u32, String, TextAttrs), f32>,
 25 }
 26 
 27 impl ShapingMeasure {
 28     pub fn new(system_fonts: bool) -> ShapingMeasure {
 29         let fs = if system_fonts { crate::create_font_system_with_system_fonts() } else { crate::create_font_system() };
 30         ShapingMeasure { fs, cache: HashMap::new() }
 31     }
 32 }
 33 
 34 impl Measure for ShapingMeasure {
 35     fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32 {
 36         let scale = crate::scale::scale_factor().max(0.01);
 37         let key = (text.to_string(), (size * 100.0) as u32, (scale * 1000.0) as u32, font.to_string(), attrs);
 38         if let Some(w) = self.cache.get(&key) {
 39             return *w;
 40         }
 41         let buf = crate::backend::text::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
 42         // The glyphs' extent, trailing spaces included — the same measure
 43         // `offsets` ends on. (A layout run's `line_w` leaves trailing
 44         // whitespace out, so a width taken from it disagreed with where
 45         // the next run was placed by a space.)
 46         let w = crate::backend::text::normalized_glyph_starts(&buf, text)
 47             .into_iter()
 48             .map(|(_, x, w)| x + w)
 49             .fold(0.0, f32::max)
 50             / scale;
 51         self.cache.insert(key, w);
 52         w
 53     }
 54 }
 55 
 56 impl ShapingMeasure {
 57     /// `text` shaped as one run, in an editor's terms: the caret's x at every char
 58     /// boundary, the clusters with their boxes, the width and the base direction — right
 59     /// for text of either direction (see [`ShapedRun`]).
 60     pub fn shape(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> ShapedRun {
 61         let scale = crate::scale::scale_factor().max(0.01);
 62         let buf = crate::backend::text::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
 63         crate::backend::text::shaped_run(&buf, text, scale)
 64     }
 65 
 66     /// The x (logical px) of every char boundary in `text` shaped as one
 67     /// run: `(byte offset, x)`, from `(0, _)` to `(text.len(), _)` — the
 68     /// caret's place before each char, [`ShapedRun::stops`]. In left-to-right
 69     /// text they run from 0 to the width; in right-to-left text they fall. A
 70     /// boundary inside a cluster (a ligature, a combining mark) takes the
 71     /// cluster's place. The width is [`Measure::width`].
 72     pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
 73         self.shape(text, size, font, attrs).stops
 74     }
 75 }
 76 
 77 #[cfg(test)]
 78 mod tests {
 79     use super::*;
 80 
 81     /// Carets in right-to-left text stand at each letter's right edge and fall across the
 82     /// run; the caret after it is its left end; a click finds the boundary nearest it; and a
 83     /// selection crossing a change of direction is two spans. The bidi levels come from the
 84     /// text, not the font, so this holds whatever face draws the letters.
 85     #[test]
 86     fn carets_follow_the_text_in_either_direction() {
 87         let mut m = ShapingMeasure::new(false);
 88         let font = crate::layout::control_label_font();
 89         let attrs = TextAttrs::default();
 90 
 91         let ltr = m.shape("abc", 14.0, &font, attrs);
 92         assert!(!ltr.rtl);
 93         let xs: Vec<f32> = ltr.stops.iter().map(|s| s.1).collect();
 94         assert!(xs.windows(2).all(|w| w[1] > w[0]), "left to right, carets rise: {xs:?}");
 95         assert!((xs[3] - ltr.width).abs() < 0.01, "the caret after it is its right end");
 96 
 97         let heb = "שלום"; // four letters, two bytes each
 98         let rtl = m.shape(heb, 14.0, &font, attrs);
 99         assert!(rtl.rtl, "the first strong letter is Hebrew: the paragraph is right to left");
100         let xs: Vec<f32> = rtl.stops.iter().map(|s| s.1).collect();
101         assert_eq!(rtl.stops.iter().map(|s| s.0).collect::<Vec<_>>(), [0, 2, 4, 6, 8]);
102         assert!(xs.windows(2).all(|w| w[1] < w[0]), "right to left, carets fall: {xs:?}");
103         assert!((xs[0] - rtl.width).abs() < 0.01 && xs[4].abs() < 0.01, "from the right end to the left: {xs:?}");
104         assert_eq!(rtl.index_at(rtl.width + 5.0), 0, "a click past the right end is the start");
105         assert_eq!(rtl.index_at(-5.0), heb.len(), "past the left end, the end");
106 
107         // "ab" then a Hebrew word: the logical range a..ש (bytes 1..4) is the "b" on the left
108         // and the Hebrew word's FIRST letter at its far right — two spans.
109         let mixed = m.shape("ab שלום", 14.0, &font, attrs);
110         assert!(!mixed.rtl, "the first strong letter is Latin");
111         let spans = mixed.spans(1, 5);
112         assert_eq!(spans.len(), 2, "visually apart: {spans:?} in {:?}", mixed.clusters);
113         assert!(spans[0].1 < spans[1].0);
114     }
115 }