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}