Skip to main content

wisp/text/
flexible.rs

1//! Flexible text backend — Cosmic Text layout (M-TEXT.2 / AUT-76).
2//!
3//! `FlexibleText` is the styled / wrapped / shaped text path. It uses
4//! `cosmic_text` for layout (line breaking, `BiDi`, font fallback,
5//! shaping) and — once M-TEXT.3 lands — `glyphon` for rasterization.
6//!
7//! This chunk is the **layout half**. The renderer half (`glyphon`
8//! pipeline + `WispTextRenderer` impl) lands in M-TEXT.3 / AUT-77.
9//! Until then, the engine produces `FlexibleTextLayout` instances that
10//! carry an internal `cosmic_text::Buffer` ready to be fed to glyphon.
11//!
12//! # Boundary
13//!
14//! The trait surface ([`WispTextEngine`], [`WispTextLayout`]) does
15//! **not** expose `cosmic_text::*` types — they live behind private
16//! fields on [`FlexibleTextLayout`]. App / editor / project code only
17//! sees `WispText` + `Box<dyn WispTextLayout>`. The renderer
18//! (`glyphon`) downcasts to read the buffer.
19//!
20//! # Layout reference basis
21//!
22//! Cosmic Text works in pixels; wisp works in NDC. The engine adopts
23//! a **reference height** of `REFERENCE_PX` pixels — `style.size_ndc`
24//! is multiplied by this constant to derive the cosmic-text font
25//! size. Per-glyph positions are converted back to NDC by dividing by
26//! the same constant. The renderer (M-TEXT.3) re-scales to the actual
27//! target dimensions at draw time.
28//!
29//! Picking 1000 px as the basis keeps numbers well within f32
30//! precision, gives sub-pixel positioning headroom for `size_ndc =
31//! 0.06` (= 60 px ≈ caption-y), and matches what glyphon's atlas
32//! cache expects for typical desktop UIs.
33
34use std::path::Path;
35use std::sync::{Arc, Mutex};
36
37use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Style, Weight, Wrap};
38
39use super::{WispText, WispTextAlign, WispTextEngine, WispTextLayout, WispTextMetrics};
40
41/// Pixel basis for NDC ↔ cosmic-text px conversion. See module docs.
42pub const REFERENCE_PX: f32 = 1000.0;
43
44/// Result of [`FlexibleTextEngine::layout`] — a shaped cosmic-text
45/// `Buffer` plus metrics.
46///
47/// The buffer is held privately so callers can't reach into the
48/// cosmic-text API through this struct. The renderer (M-TEXT.3) uses
49/// the crate-private `buffer()` accessor.
50pub struct FlexibleTextLayout {
51    /// Underlying cosmic-text buffer. Crate-public so the
52    /// `FlexibleTextRenderer` (glyphon) can read it without a
53    /// downcast; not exposed outside the crate.
54    pub(crate) buffer: Buffer,
55    metrics: WispTextMetrics,
56}
57
58impl std::fmt::Debug for FlexibleTextLayout {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("FlexibleTextLayout")
61            .field("metrics", &self.metrics)
62            .finish_non_exhaustive()
63    }
64}
65
66impl WispTextLayout for FlexibleTextLayout {
67    fn metrics(&self) -> WispTextMetrics {
68        self.metrics
69    }
70}
71
72/// Cosmic-Text-backed layout engine.
73///
74/// Wraps a `FontSystem` (which loads system fonts on construction).
75/// Cosmic Text's `FontSystem` is `!Sync`, so we wrap it in a `Mutex`
76/// so the engine can be shared across threads — necessary for caches
77/// (M-DYN.2-style) and the renderer's `&self` access pattern.
78pub struct FlexibleTextEngine {
79    font_system: Arc<Mutex<FontSystem>>,
80}
81
82impl std::fmt::Debug for FlexibleTextEngine {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("FlexibleTextEngine").finish_non_exhaustive()
85    }
86}
87
88impl Default for FlexibleTextEngine {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl FlexibleTextEngine {
95    /// Build the engine with a system-fonts `FontSystem`.
96    #[must_use]
97    pub fn new() -> Self {
98        Self {
99            font_system: Arc::new(Mutex::new(FontSystem::new())),
100        }
101    }
102
103    /// Build the engine with a pre-existing `FontSystem`.
104    ///
105    /// Useful for tests that want a deterministic font set, or for
106    /// hosts that have already loaded a `FontSystem` for another
107    /// purpose.
108    #[must_use]
109    pub fn with_font_system(font_system: FontSystem) -> Self {
110        Self {
111            font_system: Arc::new(Mutex::new(font_system)),
112        }
113    }
114
115    /// Build the engine with a `FontSystem` seeded from a list of
116    /// font file paths. No system fonts are loaded — only the
117    /// supplied files are available, and family-name lookups
118    /// ([`WispText::with_font_family`](super::WispText::with_font_family))
119    /// resolve against this set.
120    ///
121    /// Used by storybook exporters and tests that want byte-identical
122    /// output across hosts (CI runners have different system fonts).
123    ///
124    /// # Errors
125    ///
126    /// Returns `io::Error` if any path can't be opened or isn't a
127    /// recognized font file.
128    pub fn from_font_paths<P: AsRef<Path>>(
129        paths: impl IntoIterator<Item = P>,
130    ) -> std::io::Result<Self> {
131        let mut db = cosmic_text::fontdb::Database::new();
132        for p in paths {
133            db.load_font_file(p.as_ref())?;
134        }
135        Ok(Self::with_font_system(FontSystem::new_with_locale_and_db(
136            "en-US".to_owned(),
137            db,
138        )))
139    }
140
141    /// Build the engine with a `FontSystem` seeded from raw font
142    /// bytes. Useful for `wasm32` targets (no FS access) and for
143    /// crates that embed their bundled fonts via `include_bytes!`
144    /// — see `wisp_chart::chart_text` for the canonical caller.
145    /// No system fonts are loaded — only the supplied buffers are
146    /// available, and family-name lookups
147    /// ([`WispText::with_font_family`](super::WispText::with_font_family))
148    /// resolve against this set.
149    #[must_use]
150    pub fn from_font_bytes(bytes: impl IntoIterator<Item = Vec<u8>>) -> Self {
151        let mut db = cosmic_text::fontdb::Database::new();
152        for data in bytes {
153            db.load_font_data(data);
154        }
155        Self::with_font_system(FontSystem::new_with_locale_and_db("en-US".to_owned(), db))
156    }
157
158    /// Borrow the shared `FontSystem` handle. The
159    /// [`FlexibleTextRenderer`](super::FlexibleTextRenderer) constructor
160    /// uses this to wire layout + rasterization to the same font
161    /// database (so glyph metrics agree).
162    #[must_use]
163    pub fn font_system_handle(&self) -> Arc<Mutex<FontSystem>> {
164        Arc::clone(&self.font_system)
165    }
166
167    /// Concrete-typed layout entrypoint — preserves the
168    /// `FlexibleTextLayout` type so the renderer half can read the
169    /// buffer without going through `dyn` downcasting.
170    #[must_use]
171    pub fn layout_concrete(&self, text: &WispText) -> FlexibleTextLayout {
172        let mut fs = self
173            .font_system
174            .lock()
175            .expect("FlexibleTextEngine font_system poisoned");
176        layout_flexible(&mut fs, text)
177    }
178}
179
180impl WispTextEngine for FlexibleTextEngine {
181    fn layout(&self, text: &WispText) -> Box<dyn WispTextLayout> {
182        Box::new(self.layout_concrete(text))
183    }
184}
185
186fn layout_flexible(font_system: &mut FontSystem, text: &WispText) -> FlexibleTextLayout {
187    let style = text.style;
188    let font_size_px = style.size_ndc * REFERENCE_PX;
189    let line_height_px = font_size_px * style.line_height;
190    let metrics = Metrics::new(font_size_px, line_height_px);
191
192    let mut buffer = Buffer::new(font_system, metrics);
193
194    let wrap_width_px = text.max_width_ndc.map(|w| w * REFERENCE_PX);
195    buffer.set_wrap(
196        font_system,
197        if wrap_width_px.is_some() {
198            Wrap::Word
199        } else {
200            Wrap::None
201        },
202    );
203    let wrap_height_px = wrap_width_px.map_or(f32::INFINITY, |_| f32::INFINITY);
204    buffer.set_size(font_system, wrap_width_px, Some(wrap_height_px));
205
206    let family = text
207        .font_family
208        .as_deref()
209        .map_or(Family::SansSerif, Family::Name);
210    let attrs = Attrs::new()
211        .family(family)
212        .weight(Weight(style.weight.value()))
213        .style(match style.style {
214            super::WispFontStyle::Normal => Style::Normal,
215            super::WispFontStyle::Italic => Style::Italic,
216        });
217    buffer.set_text(font_system, &text.content, attrs, Shaping::Advanced);
218
219    buffer.shape_until_scroll(font_system, false);
220
221    let mut max_width_px: f32 = 0.0;
222    let mut line_count: u32 = 0;
223    let mut last_baseline_px: f32 = 0.0;
224    for run in buffer.layout_runs() {
225        line_count += 1;
226        max_width_px = max_width_px.max(run.line_w);
227        last_baseline_px = run.line_top + line_height_px;
228    }
229    let total_height_px = if line_count == 0 {
230        0.0
231    } else {
232        last_baseline_px
233    };
234
235    let metrics_out = WispTextMetrics {
236        line_count,
237        max_width_ndc: max_width_px / REFERENCE_PX,
238        total_height_ndc: total_height_px / REFERENCE_PX,
239        baseline_ndc: line_height_px / REFERENCE_PX,
240    };
241
242    // Alignment is layered on at render time once the line widths
243    // are known relative to the wrap box; cosmic-text's per-line
244    // alignment hooks land in M-TEXT.3 with the rasterization pass.
245    let _ = WispTextAlign::Left;
246
247    FlexibleTextLayout {
248        buffer,
249        metrics: metrics_out,
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::text::{WispFontWeight, WispText, WispTextStyle};
257
258    fn engine() -> FlexibleTextEngine {
259        FlexibleTextEngine::new()
260    }
261
262    #[test]
263    fn empty_string_yields_metrics_with_zero_width() {
264        let eng = engine();
265        let layout = eng.layout_concrete(&WispText::new(""));
266        let m = layout.metrics();
267        assert!(
268            m.max_width_ndc.abs() < 1e-3,
269            "expected ~0 width for empty content, got {}",
270            m.max_width_ndc
271        );
272    }
273
274    #[test]
275    fn single_line_has_one_run_and_positive_width() {
276        let eng = engine();
277        let layout = eng.layout_concrete(&WispText::new("Hello, world!"));
278        let m = layout.metrics();
279        assert_eq!(m.line_count, 1);
280        assert!(m.max_width_ndc > 0.0, "expected positive width");
281        // Baseline ≈ size_ndc * line_height = 0.06 * 1.2 = 0.072.
282        let expected = 0.06_f32 * 1.2;
283        assert!(
284            (m.baseline_ndc - expected).abs() < 1e-3,
285            "baseline_ndc={} expected~{expected}",
286            m.baseline_ndc
287        );
288    }
289
290    #[test]
291    fn explicit_newlines_produce_multiple_runs() {
292        let eng = engine();
293        let layout = eng.layout_concrete(&WispText::new("first\nsecond\nthird"));
294        let m = layout.metrics();
295        assert_eq!(m.line_count, 3, "expected 3 runs, got {}", m.line_count);
296        // Total height ≈ line_height_ndc * line_count = 0.072 * 3 = 0.216.
297        let expected = 0.06_f32 * 1.2 * 3.0;
298        assert!(
299            (m.total_height_ndc - expected).abs() < 1e-3,
300            "total_height={} expected~{expected}",
301            m.total_height_ndc
302        );
303    }
304
305    #[test]
306    fn word_wrap_increases_line_count_when_wrap_width_is_tight() {
307        let eng = engine();
308        let unwrapped = eng.layout_concrete(&WispText::new(
309            "the quick brown fox jumps over the lazy dog",
310        ));
311        let wrapped = eng.layout_concrete(
312            &WispText::new("the quick brown fox jumps over the lazy dog").with_wrap(0.20),
313        );
314        assert_eq!(unwrapped.metrics().line_count, 1);
315        assert!(
316            wrapped.metrics().line_count >= 2,
317            "wrap=0.20 should have produced ≥2 lines, got {}",
318            wrapped.metrics().line_count
319        );
320    }
321
322    #[test]
323    fn weight_and_italic_style_are_passed_through_attrs() {
324        // We can't assert specific glyph metrics (depends on system fonts),
325        // but we can assert layout still produces non-zero metrics for the
326        // styled variant — i.e. the attrs path doesn't blow up.
327        let eng = engine();
328        let style = WispTextStyle::default()
329            .with_weight(WispFontWeight::Bold)
330            .italic();
331        let layout = eng.layout_concrete(&WispText::new("Bold italic").with_style(style));
332        assert_eq!(layout.metrics().line_count, 1);
333        assert!(layout.metrics().max_width_ndc > 0.0);
334    }
335
336    #[test]
337    fn custom_font_family_lays_out_without_panic() {
338        // Family name doesn't need to resolve to a loaded face for
339        // layout to succeed — cosmic-text falls back to sans-serif when
340        // the name is unknown. The contract under test is "Family::Name
341        // path doesn't crash and still produces metrics."
342        let eng = engine();
343        let layout = eng.layout_concrete(&WispText::new("hello").with_font_family("Inter"));
344        assert_eq!(layout.metrics().line_count, 1);
345        assert!(layout.metrics().max_width_ndc > 0.0);
346    }
347
348    #[test]
349    fn engine_is_send_and_sync() {
350        fn assert_send<T: Send>() {}
351        fn assert_sync<T: Sync>() {}
352        assert_send::<FlexibleTextEngine>();
353        assert_sync::<FlexibleTextEngine>();
354    }
355}