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 }