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}