ailloli_ui_text/layout/mod.rs
1//! Cluster-aware shaping, line metrics, glyph extraction, and caret mapping.
2
3use std::sync::Arc;
4
5use ailloli_ui_core::{style::TextStyle, Rect};
6use parley::{Affinity, Cursor};
7
8use crate::engine_parley::ParleyEngine;
9
10pub use crate::glyph::GlyphInstance;
11pub use crate::params::{LaidOutLine, TextLayoutParams, TextMetrics, WrapMode};
12
13/// Backend-agnostic layout result (Parley types not exposed).
14///
15/// The value owns a copy of the input text and shares an immutable Parley
16/// layout through an [`Arc`]. Dimensions and positions are logical pixels.
17/// Cloning is therefore cheap except for the small metadata vectors.
18///
19/// # Examples
20///
21/// ```
22/// use ailloli_ui_core::{Color, FontId, TextStyle};
23/// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams};
24/// #[allow(deprecated)]
25/// let mut engine = ParleyEngine::new();
26/// let laid = layout_text(
27/// &mut engine,
28/// TextLayoutParams::new("hello", TextStyle::new(FontId::Ui, 16, Color::WHITE)),
29/// );
30/// assert_eq!(laid.text(), "hello");
31/// assert!(!laid.lines.is_empty());
32/// ```
33#[allow(dead_code)]
34#[derive(Clone)]
35pub struct LaidOutText {
36 /// Owned source text whose byte offsets are referenced by line metrics.
37 text: String,
38 /// Base Ailloli style used to construct the unstyled Parley layout.
39 style: TextStyle,
40 /// Immutable shaped layout shared by clones.
41 layout: Arc<parley::Layout<()>>,
42 /// Public per-line summaries in source order.
43 pub lines: Vec<LaidOutLine>,
44 /// Overall logical-pixel layout extent.
45 pub metrics: TextMetrics,
46}
47
48impl core::fmt::Debug for LaidOutText {
49 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
50 f.debug_struct("LaidOutText")
51 .field("text_len", &self.text.len())
52 .field("style", &self.style)
53 .field("lines", &self.lines.len())
54 .field("metrics", &self.metrics)
55 .finish()
56 }
57}
58
59impl LaidOutText {
60 /// Returns the owned source text used to build this layout.
61 ///
62 /// # Examples
63 ///
64 /// ```
65 /// use ailloli_ui_core::{Color, FontId, TextStyle};
66 /// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams};
67 /// #[allow(deprecated)] let mut engine = ParleyEngine::new();
68 /// let laid = layout_text(&mut engine, TextLayoutParams::new("รฉ", TextStyle::new(FontId::Ui, 14, Color::WHITE)));
69 /// assert_eq!(laid.text().as_bytes().len(), 2);
70 /// ```
71 pub fn text(&self) -> &str {
72 &self.text
73 }
74
75 /// Returns the copyable base style supplied to [`layout_text`].
76 ///
77 /// The style color is metadata for the painter; unstyled glyph instances
78 /// returned by [`Self::glyph_instances`] have `color == None`.
79 ///
80 /// # Examples
81 ///
82 /// ```
83 /// use ailloli_ui_core::{Color, FontId, TextStyle};
84 /// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams};
85 /// #[allow(deprecated)] let mut engine = ParleyEngine::new();
86 /// let style = TextStyle::new(FontId::Mono, 13, Color::WHITE);
87 /// let laid = layout_text(&mut engine, TextLayoutParams::new("x", style));
88 /// assert_eq!(laid.style(), style);
89 /// ```
90 pub fn style(&self) -> TextStyle {
91 self.style
92 }
93
94 /// Flattens all positioned glyph runs into renderer-facing instances.
95 ///
96 /// The returned vector is newly allocated on every call. Positions are
97 /// logical pixels and keep fractional values. Font sizes are rounded to the
98 /// nearest integer and clamped to at least one before conversion to `u16`;
99 /// colors are `None` because this layout stores `()` brushes.
100 ///
101 /// # Examples
102 ///
103 /// ```
104 /// use ailloli_ui_core::{Color, FontId, TextStyle};
105 /// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams};
106 /// #[allow(deprecated)] let mut engine = ParleyEngine::new();
107 /// let laid = layout_text(&mut engine, TextLayoutParams::new("Hi", TextStyle::new(FontId::Ui, 16, Color::WHITE)));
108 /// let glyphs = laid.glyph_instances();
109 /// assert!(!glyphs.is_empty());
110 /// assert!(glyphs.iter().all(|glyph| glyph.px_size >= 1 && glyph.color.is_none()));
111 /// ```
112 pub fn glyph_instances(&self) -> Vec<GlyphInstance> {
113 let layout = self.parley_layout();
114 let mut out = Vec::new();
115 for line in layout.lines() {
116 for item in line.items() {
117 use parley::PositionedLayoutItem;
118 let PositionedLayoutItem::GlyphRun(run) = item else {
119 continue;
120 };
121 let font = run.run().font();
122 let face_id = font.data.id();
123 let font_index = font.index;
124 let px_size = run.run().font_size().round().max(1.0) as u16;
125 for g in run.positioned_glyphs() {
126 out.push(GlyphInstance {
127 face_id,
128 font_index,
129 glyph_id: g.id,
130 px_size,
131 x: g.x,
132 y: g.y,
133 color: None,
134 });
135 }
136 }
137 }
138 out
139 }
140
141 /// Returns the overall Parley layout width in logical pixels.
142 ///
143 /// # Examples
144 ///
145 /// ```
146 /// use ailloli_ui_core::{Color, FontId, TextStyle};
147 /// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams};
148 /// #[allow(deprecated)] let mut engine = ParleyEngine::new();
149 /// let laid = layout_text(&mut engine, TextLayoutParams::new("width", TextStyle::new(FontId::Ui, 16, Color::WHITE)));
150 /// assert_eq!(laid.width(), laid.metrics.width);
151 /// ```
152 pub fn width(&self) -> f32 {
153 self.metrics.width
154 }
155
156 /// Returns the overall Parley layout height in logical pixels.
157 ///
158 /// # Examples
159 ///
160 /// ```
161 /// use ailloli_ui_core::{Color, FontId, TextStyle};
162 /// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams};
163 /// #[allow(deprecated)] let mut engine = ParleyEngine::new();
164 /// let laid = layout_text(&mut engine, TextLayoutParams::new("height", TextStyle::new(FontId::Ui, 16, Color::WHITE)));
165 /// assert_eq!(laid.height(), laid.metrics.height);
166 /// assert!(laid.height() > 0.0);
167 /// ```
168 pub fn height(&self) -> f32 {
169 self.metrics.height
170 }
171
172 #[allow(dead_code)]
173 /// Borrows the internal Parley layout for crate-internal preparation.
174 ///
175 /// # Examples
176 ///
177 /// Public callers use stable accessors such as [`Self::width`] instead:
178 ///
179 /// ```
180 /// use ailloli_ui_core::{Color, FontId, TextStyle};
181 /// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams};
182 /// #[allow(deprecated)] let mut engine = ParleyEngine::new();
183 /// let laid = layout_text(&mut engine, TextLayoutParams::new("x", TextStyle::new(FontId::Ui, 12, Color::WHITE)));
184 /// assert_eq!(laid.width(), laid.metrics.width);
185 /// ```
186 pub(crate) fn parley_layout(&self) -> &parley::Layout<()> {
187 self.layout.as_ref()
188 }
189}
190
191/// Maps a UTF-8 byte index to X in layout space (cluster-aware).
192///
193/// The returned logical-pixel X is the left edge of a zero-width downstream
194/// caret. Byte positions inside one shaped cluster map to that cluster's
195/// caret geometry.
196///
197/// # Examples
198///
199/// ```
200/// use ailloli_ui_core::{Color, FontId, TextStyle};
201/// use ailloli_ui_text::{caret_x_at, layout_text, ParleyEngine, TextLayoutParams};
202/// #[allow(deprecated)] let mut engine = ParleyEngine::new();
203/// let laid = layout_text(&mut engine, TextLayoutParams::new("abc", TextStyle::new(FontId::Ui, 16, Color::WHITE)));
204/// assert!(caret_x_at(&laid, laid.text().len()) >= caret_x_at(&laid, 0));
205/// ```
206pub fn caret_x_at(laid: &LaidOutText, byte_idx: usize) -> f32 {
207 caret_rect_at(laid, byte_idx, 0.0).x
208}
209
210/// Maps a UTF-8 byte index to a caret rectangle in layout space.
211///
212/// `width` is the requested logical-pixel caret thickness. Negative and NaN
213/// values behave as zero. The rectangle height follows the shaped line; its
214/// width is at least the normalized requested width. A downstream affinity is
215/// used at ambiguous boundaries.
216///
217/// # Examples
218///
219/// ```
220/// use ailloli_ui_core::{Color, FontId, TextStyle};
221/// use ailloli_ui_text::{caret_rect_at, layout_text, ParleyEngine, TextLayoutParams};
222/// #[allow(deprecated)] let mut engine = ParleyEngine::new();
223/// let laid = layout_text(&mut engine, TextLayoutParams::new("abc", TextStyle::new(FontId::Ui, 16, Color::WHITE)));
224/// let caret = caret_rect_at(&laid, 0, 2.0);
225/// assert!(caret.w >= 2.0);
226/// assert!(caret.h >= 0.0);
227/// ```
228pub fn caret_rect_at(laid: &LaidOutText, byte_idx: usize, width: f32) -> Rect {
229 let layout = laid.parley_layout();
230 let cur = Cursor::from_byte_index(layout, byte_idx, Affinity::Downstream);
231 let rect = cur.geometry(layout, width.max(0.0));
232 Rect::new(
233 rect.x0 as f32,
234 rect.y0 as f32,
235 (rect.x1 - rect.x0).max(width.max(0.0) as f64) as f32,
236 (rect.y1 - rect.y0).max(0.0) as f32,
237 )
238}
239
240/// Maps layout-space `(x, y)` to a UTF-8 byte index (cluster-aware).
241///
242/// Coordinates are logical pixels relative to the layout origin. Parley
243/// resolves points outside glyph bounds to its nearest valid cursor; the
244/// returned index is therefore suitable for slicing only after ordinary UTF-8
245/// boundary checks required by the caller's text handling.
246///
247/// # Examples
248///
249/// ```
250/// use ailloli_ui_core::{Color, FontId, TextStyle};
251/// use ailloli_ui_text::{caret_index_at_point, layout_text, ParleyEngine, TextLayoutParams};
252/// #[allow(deprecated)] let mut engine = ParleyEngine::new();
253/// let laid = layout_text(&mut engine, TextLayoutParams::new("abc", TextStyle::new(FontId::Ui, 16, Color::WHITE)));
254/// assert_eq!(caret_index_at_point(&laid, -100.0, 0.0), 0);
255/// ```
256pub fn caret_index_at_point(laid: &LaidOutText, x: f32, y: f32) -> usize {
257 let layout = laid.parley_layout();
258 Cursor::from_point(layout, x, y).index()
259}
260
261/// Shapes text and returns backend-neutral lines, metrics, and glyph access.
262///
263/// For [`WrapMode::NoWrap`], `params.max_width` is deliberately ignored.
264/// Other modes pass the optional logical-pixel width to Parley. The source text
265/// is copied into the result; the engine may be reused immediately afterward.
266/// Line widths exclude trailing whitespace, while overall metrics retain
267/// Parley's layout extent.
268///
269/// # Examples
270///
271/// ```
272/// use ailloli_ui_core::{Color, FontId, TextStyle};
273/// use ailloli_ui_text::{layout_text, ParleyEngine, TextLayoutParams, WrapMode};
274/// #[allow(deprecated)] let mut engine = ParleyEngine::new();
275/// let laid = layout_text(&mut engine, TextLayoutParams {
276/// text: "hello world",
277/// style: TextStyle::new(FontId::Ui, 16, Color::WHITE),
278/// max_width: Some(60.0),
279/// wrap_mode: WrapMode::Word,
280/// });
281/// assert_eq!(laid.text(), "hello world");
282/// assert!(!laid.lines.is_empty());
283/// ```
284pub fn layout_text(engine: &mut ParleyEngine, params: TextLayoutParams<'_>) -> LaidOutText {
285 let max_width = match params.wrap_mode {
286 WrapMode::NoWrap => None,
287 WrapMode::Word | WrapMode::WordOrAnywhere => params.max_width,
288 };
289
290 let layout =
291 engine.layout_text_with_wrap(params.text, params.style, max_width, params.wrap_mode);
292 let metrics = TextMetrics {
293 width: layout.width(),
294 height: layout.height(),
295 };
296
297 let mut lines = Vec::new();
298 for line in layout.lines() {
299 let m = *line.metrics();
300 let width = (m.advance - m.trailing_whitespace).max(0.0);
301 lines.push(LaidOutLine {
302 text_range: line.text_range(),
303 width,
304 ascent: m.ascent,
305 descent: m.descent,
306 baseline_y: m.baseline,
307 });
308 }
309
310 LaidOutText {
311 text: params.text.to_owned(),
312 style: params.style,
313 layout: Arc::new(layout),
314 lines,
315 metrics,
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use ailloli_ui_core::{style::Color, FontId};
323
324 #[test]
325 fn laid_out_text_has_lines_and_metrics() {
326 #[allow(deprecated)]
327 let mut eng = ParleyEngine::new();
328 let style = TextStyle::new(FontId::Ui, 16, Color::new(1.0, 1.0, 1.0, 1.0));
329 let params = TextLayoutParams {
330 text: "hello\nworld",
331 style,
332 max_width: Some(80.0),
333 wrap_mode: WrapMode::Word,
334 };
335 let laid = layout_text(&mut eng, params);
336 assert!(!laid.lines.is_empty());
337 assert!(laid.height() > 0.0);
338 }
339
340 #[test]
341 fn caret_is_cluster_aware_for_combining_mark() {
342 #[allow(deprecated)]
343 let mut eng = ParleyEngine::new();
344 let style = TextStyle::new(FontId::Ui, 16, Color::new(1.0, 1.0, 1.0, 1.0));
345 let s = "e\u{301}"; // "e" + combining acute
346 let laid = layout_text(
347 &mut eng,
348 TextLayoutParams {
349 text: s,
350 style,
351 max_width: None,
352 wrap_mode: WrapMode::NoWrap,
353 },
354 );
355 assert_eq!(caret_x_at(&laid, 1), caret_x_at(&laid, 2));
356 assert!(caret_x_at(&laid, s.len()) >= caret_x_at(&laid, 0));
357 }
358
359 #[test]
360 fn caret_is_cluster_aware_for_emoji_sequence() {
361 #[allow(deprecated)]
362 let mut eng = ParleyEngine::new();
363 let style = TextStyle::new(FontId::Ui, 16, Color::new(1.0, 1.0, 1.0, 1.0));
364 let s = "๐จโ๐ฉโ๐งโ๐ฆ";
365 let laid = layout_text(
366 &mut eng,
367 TextLayoutParams {
368 text: s,
369 style,
370 max_width: None,
371 wrap_mode: WrapMode::NoWrap,
372 },
373 );
374 assert_eq!(caret_x_at(&laid, 0), caret_x_at(&laid, 1));
375 assert!(caret_x_at(&laid, s.len()) >= caret_x_at(&laid, 0));
376 }
377}