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}