ailloli_ui_text/text_measure.rs
1//! Width measurement abstractions for lightweight text helpers.
2
3use ailloli_ui_core::FontId;
4
5/// Measures text width and per-character advance for simple layout/wrap.
6///
7/// Values are logical pixels. Implementations may be approximate and do not
8/// have to perform shaping, kerning, bidi reordering, or grapheme clustering.
9///
10/// # Examples
11///
12/// ```
13/// use ailloli_ui_core::FontId;
14/// use ailloli_ui_text::{ApproxTextMeasure, TextMeasure};
15/// assert!(ApproxTextMeasure.measure("abc", FontId::Ui, 12) > 0.0);
16/// ```
17pub trait TextMeasure {
18 /// Returns the estimated logical-pixel advance of `text` at `px_size`.
19 ///
20 /// Empty text conventionally measures zero. `px_size` is forwarded as-is,
21 /// including zero.
22 ///
23 /// # Examples
24 ///
25 /// ```
26 /// use ailloli_ui_core::FontId;
27 /// use ailloli_ui_text::{ApproxTextMeasure, TextMeasure};
28 /// assert_eq!(ApproxTextMeasure.measure("", FontId::Mono, 14), 0.0);
29 /// ```
30 fn measure(&self, text: &str, font: FontId, px_size: u16) -> f32;
31
32 /// Returns the logical-pixel advance of one Unicode scalar.
33 ///
34 /// The default implementation UTF-8 encodes `ch` and delegates to
35 /// [`TextMeasure::measure`]. Implementations may override it for efficiency.
36 ///
37 /// # Examples
38 ///
39 /// ```
40 /// use ailloli_ui_core::FontId;
41 /// use ailloli_ui_text::{ApproxTextMeasure, TextMeasure};
42 /// assert_eq!(
43 /// ApproxTextMeasure.advance('é', FontId::Ui, 10),
44 /// ApproxTextMeasure.measure("é", FontId::Ui, 10),
45 /// );
46 /// ```
47 fn advance(&self, ch: char, font: FontId, px_size: u16) -> f32 {
48 let mut buf = [0u8; 4];
49 let s = ch.encode_utf8(&mut buf);
50 self.measure(s, font, px_size)
51 }
52}
53
54/// Fast approximate measurement (fixed char width ratio).
55///
56/// Every Unicode scalar advances by exactly `0.58 * px_size` logical pixels;
57/// font family and scalar contents are ignored. This is deterministic but not
58/// suitable when shaped typography or exact caret placement matters.
59///
60/// # Examples
61///
62/// ```
63/// use ailloli_ui_core::FontId;
64/// use ailloli_ui_text::{ApproxTextMeasure, TextMeasure};
65/// let width = ApproxTextMeasure.measure("ab", FontId::Ui, 10);
66/// assert!((width - 11.6).abs() < 0.001);
67/// ```
68#[derive(Debug, Default, Clone, Copy)]
69pub struct ApproxTextMeasure;
70
71impl TextMeasure for ApproxTextMeasure {
72 fn measure(&self, text: &str, _font: FontId, px_size: u16) -> f32 {
73 let approx_char_w = (px_size as f32) * 0.58;
74 (text.chars().count() as f32) * approx_char_w
75 }
76}
77
78/// Bundled JetBrains Mono Nerd Font bytes used for the monospace slot.
79const JBM_NERD_REGULAR: &[u8] = include_bytes!("../assets/fonts/JetBrainsMonoNerdFont-Regular.ttf");
80
81/// `TextMeasure` implementation backed by `fontdue` (UI + bundled mono).
82///
83/// Construction probes a small list of common Linux sans-serif paths. The
84/// monospace slot first uses the bundled font. If a requested face is absent,
85/// measurement falls back to [`ApproxTextMeasure`], so construction and
86/// measurement are infallible and portable.
87///
88/// # Examples
89///
90/// ```
91/// use ailloli_ui_core::FontId;
92/// use ailloli_ui_text::{FontMetrics, TextMeasure};
93/// let metrics = FontMetrics::new();
94/// assert_eq!(metrics.measure("", FontId::Ui, 16), 0.0);
95/// ```
96pub struct FontMetrics {
97 /// Optional system sans-serif font.
98 font_ui: Option<fontdue::Font>,
99 /// Bundled or system monospace font.
100 font_mono: Option<fontdue::Font>,
101 /// Deterministic fallback used when no font is available.
102 approx: ApproxTextMeasure,
103}
104
105impl FontMetrics {
106 /// Loads the bundled monospace face and probes common Linux UI-font paths.
107 ///
108 /// Missing or invalid system files are silently skipped. This function does
109 /// filesystem I/O but never returns an error; unavailable faces use the
110 /// approximate fallback during measurement.
111 ///
112 /// # Examples
113 ///
114 /// ```
115 /// use ailloli_ui_core::FontId;
116 /// use ailloli_ui_text::{FontMetrics, TextMeasure};
117 /// let metrics = FontMetrics::new();
118 /// assert!(metrics.advance('x', FontId::Mono, 14) >= 0.0);
119 /// ```
120 pub fn new() -> Self {
121 let font_ui = load_font_from_common_paths(&[
122 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
123 "/usr/share/fonts/TTF/DejaVuSans.ttf",
124 "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
125 ]);
126 let font_mono =
127 fontdue::Font::from_bytes(JBM_NERD_REGULAR, fontdue::FontSettings::default())
128 .ok()
129 .or_else(|| {
130 load_font_from_common_paths(&[
131 "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
132 "/usr/share/fonts/TTF/DejaVuSansMono.ttf",
133 "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
134 ])
135 });
136 Self {
137 font_ui,
138 font_mono,
139 approx: ApproxTextMeasure,
140 }
141 }
142
143 /// Resolves a built-in font slot, allowing the UI face as mono fallback.
144 fn font(&self, font: FontId) -> Option<&fontdue::Font> {
145 match font {
146 FontId::Ui => self.font_ui.as_ref(),
147 FontId::Mono => self.font_mono.as_ref().or(self.font_ui.as_ref()),
148 }
149 }
150}
151
152impl Default for FontMetrics {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158impl TextMeasure for FontMetrics {
159 fn measure(&self, text: &str, font: FontId, px_size: u16) -> f32 {
160 let Some(f) = self.font(font) else {
161 return self.approx.measure(text, font, px_size);
162 };
163 let px = px_size as f32;
164 let mut w = 0.0_f32;
165 for ch in text.chars() {
166 let m = f.metrics(ch, px);
167 w += m.advance_width;
168 }
169 w
170 }
171
172 fn advance(&self, ch: char, font: FontId, px_size: u16) -> f32 {
173 let Some(f) = self.font(font) else {
174 return self.approx.advance(ch, font, px_size);
175 };
176 let px = px_size as f32;
177 f.metrics(ch, px).advance_width
178 }
179}
180
181/// Returns the first fontdue-compatible font read from `paths`.
182///
183/// I/O and parsing failures are intentionally skipped so startup is not tied
184/// to any one operating-system font layout.
185fn load_font_from_common_paths(paths: &[&str]) -> Option<fontdue::Font> {
186 for p in paths {
187 if let Ok(bytes) = std::fs::read(p) {
188 if let Ok(font) = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()) {
189 return Some(font);
190 }
191 }
192 }
193 None
194}