ailloli_ui_text/
engine_parley.rs

1//! Stateful Parley adapter used by higher-level text systems.
2
3use ailloli_ui_core::{style::TextStyle as UiTextStyle, FontId};
4
5use parley::{
6    Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, OverflowWrap, StyleProperty,
7    TextStyle,
8};
9
10use crate::WrapMode;
11
12/// Bundled monospace font registered by [`ParleyEngine::new`].
13const JBM_NERD_REGULAR: &[u8] = include_bytes!("../assets/fonts/JetBrainsMonoNerdFont-Regular.ttf");
14
15/// Thin wrapper around Parley:
16/// - holds shared `FontContext` / `LayoutContext`
17/// - builds `Layout<()>` with fractional positioning (`quantize = false`)
18///
19/// Reusing one engine preserves Parley's font and layout caches. The type is
20/// mutable and not intended for concurrent use without external synchronization.
21/// [`Default`] creates empty Parley contexts; the deprecated [`Self::new`]
22/// additionally registers the bundled monospace font and probes `assets/fonts`.
23/// Application code should normally construct [`crate::TextSystem`] instead.
24///
25/// # Examples
26///
27/// ```
28/// use ailloli_ui_text::ParleyEngine;
29/// let engine = ParleyEngine::default();
30/// let _font_context = &engine.font_cx;
31/// let _plain_layout_context = &engine.layout_cx;
32/// ```
33#[derive(Default)]
34pub struct ParleyEngine {
35    /// Parley font collection and font-selection state.
36    pub font_cx: FontContext,
37    /// Reusable context for layouts whose glyph runs have no color brush.
38    pub layout_cx: LayoutContext<()>,
39    /// Reusable context for layouts whose brushes are linear-RGBA arrays.
40    pub styled_layout_cx: LayoutContext<[f32; 4]>,
41}
42
43impl ParleyEngine {
44    /// Creates an engine with the bundled mono font and optional asset fonts.
45    ///
46    /// The method also scans the relative `assets/fonts` path. Failures there
47    /// are handled by Fontique and do not make construction fallible.
48    /// Prefer [`crate::TextSystem::new`] in widgets and applications because it
49    /// owns the font blobs needed by renderers.
50    ///
51    /// # Examples
52    ///
53    /// ```
54    /// use ailloli_ui_text::ParleyEngine;
55    /// #[allow(deprecated)]
56    /// let mut engine = ParleyEngine::new();
57    /// assert!(engine.font_cx.collection.family_names().next().is_some());
58    /// ```
59    #[deprecated(note = "prefer TextSystem::new() for app and widget code")]
60    pub fn new() -> Self {
61        let mut this = Self::default();
62
63        // Register bundled monospace from assets.
64        this.font_cx
65            .collection
66            .register_fonts(fontique::Blob::from(JBM_NERD_REGULAR.to_vec()), None);
67
68        // Optionally load additional fonts from `assets/fonts` when present.
69        this.font_cx
70            .collection
71            .load_fonts_from_paths(["assets/fonts"]);
72
73        this
74    }
75
76    /// Maps Ailloli's two font slots to Parley generic families.
77    fn parley_font_family(font: FontId) -> parley::FontFamily<'static> {
78        match font {
79            FontId::Ui => parley::GenericFamily::SansSerif.into(),
80            FontId::Mono => parley::GenericFamily::Monospace.into(),
81        }
82    }
83
84    /// Converts family and size into an uncolored Parley style.
85    fn parley_text_style(style: UiTextStyle) -> TextStyle<'static, 'static, ()> {
86        TextStyle {
87            font_family: Self::parley_font_family(style.font),
88            font_size: style.px_size as f32,
89            brush: (),
90            ..TextStyle::default()
91        }
92    }
93
94    /// Converts family, size, and color into a styled-layout Parley style.
95    fn parley_text_style_color(style: UiTextStyle) -> TextStyle<'static, 'static, [f32; 4]> {
96        TextStyle {
97            font_family: Self::parley_font_family(style.font),
98            font_size: style.px_size as f32,
99            brush: style.color.to_array(),
100            ..TextStyle::default()
101        }
102    }
103
104    /// Lays out `text` with the given style and optional max width (word wrap).
105    ///
106    /// `max_width` is in logical pixels; `None` leaves line breaking
107    /// unconstrained apart from explicit line separators. Color and decoration
108    /// are not stored in the returned `Layout<()>`. Fractional glyph positions
109    /// are retained.
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use ailloli_ui_core::{Color, FontId, TextStyle};
115    /// use ailloli_ui_text::ParleyEngine;
116    /// #[allow(deprecated)]
117    /// let mut engine = ParleyEngine::new();
118    /// let layout = engine.layout_text(
119    ///     "hello world",
120    ///     TextStyle::new(FontId::Ui, 16, Color::WHITE),
121    ///     Some(80.0),
122    /// );
123    /// assert!(layout.height() > 0.0);
124    /// ```
125    pub fn layout_text(
126        &mut self,
127        text: &str,
128        style: UiTextStyle,
129        max_width: Option<f32>,
130    ) -> Layout<()> {
131        self.layout_text_with_wrap(text, style, max_width, WrapMode::Word)
132    }
133
134    /// Lays out `text` with an explicit wrap strategy.
135    ///
136    /// This low-level method always forwards `max_width` to Parley's line
137    /// breaker. In particular, `WrapMode::NoWrap` does not discard a supplied
138    /// width here; use [`crate::layout_text`] for the higher-level no-wrap
139    /// contract. [`crate::WrapMode::WordOrAnywhere`] additionally enables
140    /// cluster-level overflow breaking. Positions remain fractional.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use ailloli_ui_core::{Color, FontId, TextStyle};
146    /// use ailloli_ui_text::{ParleyEngine, WrapMode};
147    /// #[allow(deprecated)]
148    /// let mut engine = ParleyEngine::new();
149    /// let layout = engine.layout_text_with_wrap(
150    ///     "an_unbroken_identifier",
151    ///     TextStyle::new(FontId::Mono, 14, Color::WHITE),
152    ///     Some(40.0),
153    ///     WrapMode::WordOrAnywhere,
154    /// );
155    /// assert!(layout.lines().count() >= 1);
156    /// ```
157    pub fn layout_text_with_wrap(
158        &mut self,
159        text: &str,
160        style: UiTextStyle,
161        max_width: Option<f32>,
162        wrap_mode: WrapMode,
163    ) -> Layout<()> {
164        let scale = 1.0_f32;
165        let quantize = false;
166        let mut builder = self
167            .layout_cx
168            .ranged_builder(&mut self.font_cx, text, scale, quantize);
169
170        let style = Self::parley_text_style(style);
171        builder.push_default(StyleProperty::FontFamily(style.font_family));
172        builder.push_default(StyleProperty::FontSize(style.font_size));
173        if matches!(wrap_mode, WrapMode::WordOrAnywhere) {
174            builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::Anywhere));
175        }
176
177        let mut layout: Layout<()> = builder.build(text);
178        layout.break_all_lines(max_width);
179        layout.align(Alignment::Start, AlignmentOptions::default());
180        layout
181    }
182
183    /// Lays out `text` with ranged text styles and an explicit wrap strategy.
184    ///
185    /// Each span replaces family, logical-pixel size, and linear-RGBA brush in
186    /// its half-open UTF-8 byte range. Ranges are passed to Parley in slice
187    /// order and must be valid for `text`. As in [`Self::layout_text_with_wrap`],
188    /// this low-level method does not suppress `max_width` for `NoWrap`.
189    ///
190    /// # Panics
191    ///
192    /// Parley may panic when a span range is reversed, out of bounds, or not on
193    /// UTF-8 boundaries; callers must validate externally sourced ranges.
194    ///
195    /// # Examples
196    ///
197    /// ```
198    /// use ailloli_ui_core::{Color, FontId, TextStyle};
199    /// use ailloli_ui_text::{ParleyEngine, StyledTextSpan, WrapMode};
200    /// #[allow(deprecated)]
201    /// let mut engine = ParleyEngine::new();
202    /// let base = TextStyle::new(FontId::Mono, 14, Color::WHITE);
203    /// let spans = [StyledTextSpan { range: 0..2, style: base.underline() }];
204    /// let layout = engine.layout_styled_text_with_wrap(
205    ///     "fn main", base, &spans, None, WrapMode::NoWrap,
206    /// );
207    /// assert!(layout.height() > 0.0);
208    /// ```
209    pub fn layout_styled_text_with_wrap(
210        &mut self,
211        text: &str,
212        base_style: UiTextStyle,
213        spans: &[crate::StyledTextSpan],
214        max_width: Option<f32>,
215        wrap_mode: WrapMode,
216    ) -> Layout<[f32; 4]> {
217        let scale = 1.0_f32;
218        let quantize = false;
219        let mut builder =
220            self.styled_layout_cx
221                .ranged_builder(&mut self.font_cx, text, scale, quantize);
222
223        let style = Self::parley_text_style_color(base_style);
224        builder.push_default(StyleProperty::FontFamily(style.font_family));
225        builder.push_default(StyleProperty::FontSize(style.font_size));
226        builder.push_default(StyleProperty::Brush(style.brush));
227        if matches!(wrap_mode, WrapMode::WordOrAnywhere) {
228            builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::Anywhere));
229        }
230
231        for span in spans {
232            let span_style = Self::parley_text_style_color(span.style);
233            builder.push(
234                StyleProperty::FontFamily(span_style.font_family),
235                span.range.clone(),
236            );
237            builder.push(
238                StyleProperty::FontSize(span_style.font_size),
239                span.range.clone(),
240            );
241            builder.push(StyleProperty::Brush(span_style.brush), span.range.clone());
242        }
243
244        let mut layout: Layout<[f32; 4]> = builder.build(text);
245        layout.break_all_lines(max_width);
246        layout.align(Alignment::Start, AlignmentOptions::default());
247        layout
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use ailloli_ui_core::style::Color;
255
256    #[test]
257    fn builds_layout_without_quantize() {
258        #[allow(deprecated)]
259        let mut eng = ParleyEngine::new();
260        let style = UiTextStyle::new(FontId::Ui, 16, Color::new(1.0, 1.0, 1.0, 1.0));
261        let layout = eng.layout_text("hello parley", style, Some(120.0));
262        assert!(layout.height() > 0.0);
263    }
264}