ailloli_ui_text/
text_layout.rs

1//! Simple per-character layout helpers independent of Parley shaping.
2
3use ailloli_ui_core::FontId;
4
5use crate::text_measure::TextMeasure;
6
7/// Per-line byte offsets and cumulative advances (simple fontdue metrics).
8///
9/// Entries represent Unicode scalar boundaries rather than grapheme or shaped
10/// cluster boundaries. Consumers that need ligature-, bidi-, or grapheme-aware
11/// carets should use [`crate::LaidOutText`] and [`crate::caret_rect_at`].
12///
13/// # Examples
14///
15/// ```
16/// use ailloli_ui_text::LineLayout;
17/// let layout = LineLayout { byte_offsets: vec![0, 1], advances: vec![0.0, 7.0] };
18/// assert_eq!(layout.width(), 7.0);
19/// ```
20#[derive(Debug, Clone, Default, PartialEq)]
21pub struct LineLayout {
22    /// Cumulative advances in pixels after each UTF-8 boundary.
23    ///
24    /// Invariant: `byte_offsets.len() == advances.len()` and `byte_offsets[0] == 0`.
25    pub byte_offsets: Vec<usize>,
26    /// Cumulative logical-pixel advances corresponding one-for-one to `byte_offsets`.
27    pub advances: Vec<f32>,
28}
29
30impl LineLayout {
31    /// Returns the final cumulative advance in logical pixels, or zero if empty.
32    ///
33    /// # Examples
34    ///
35    /// ```
36    /// use ailloli_ui_text::LineLayout;
37    /// assert_eq!(LineLayout::default().width(), 0.0);
38    /// assert_eq!(LineLayout { byte_offsets: vec![0, 1], advances: vec![0.0, 8.5] }.width(), 8.5);
39    /// ```
40    pub fn width(&self) -> f32 {
41        self.advances.last().copied().unwrap_or(0.0)
42    }
43}
44
45/// Measures each Unicode scalar and records its next UTF-8 boundary.
46///
47/// `px` is a logical-pixel font size forwarded unchanged to `m`. The generated
48/// vectors always start with zero and have `text.chars().count() + 1` entries.
49/// No kerning or shaping across adjacent characters is performed.
50///
51/// # Examples
52///
53/// ```
54/// use ailloli_ui_core::FontId;
55/// use ailloli_ui_text::{line_layout, ApproxTextMeasure};
56/// let layout = line_layout("éa", FontId::Ui, 10, &ApproxTextMeasure);
57/// assert_eq!(layout.byte_offsets, [0, 2, 3]);
58/// assert_eq!(layout.advances.len(), 3);
59/// ```
60pub fn line_layout(text: &str, font: FontId, px: u16, m: &dyn TextMeasure) -> LineLayout {
61    let mut byte_offsets = Vec::with_capacity(text.chars().count() + 1);
62    let mut advances = Vec::with_capacity(text.chars().count() + 1);
63
64    byte_offsets.push(0);
65    advances.push(0.0);
66
67    let mut acc = 0.0f32;
68    for (byte_idx, ch) in text.char_indices() {
69        let next = byte_idx + ch.len_utf8();
70        acc += m.advance(ch, font, px);
71        byte_offsets.push(next);
72        advances.push(acc);
73    }
74
75    LineLayout {
76        byte_offsets,
77        advances,
78    }
79}
80
81/// Maps a UTF-8 byte position to the preceding recorded X advance.
82///
83/// Positions inside a multi-byte scalar snap backward to that scalar's leading
84/// boundary. Values beyond the line snap to its width. Malformed layouts are
85/// tolerated by returning zero if the matching advance is absent.
86///
87/// # Examples
88///
89/// ```
90/// use ailloli_ui_text::{text_layout::caret_x_at, LineLayout};
91/// let layout = LineLayout { byte_offsets: vec![0, 2, 3], advances: vec![0.0, 6.0, 12.0] };
92/// assert_eq!(caret_x_at(&layout, 1), 0.0);
93/// assert_eq!(caret_x_at(&layout, usize::MAX), 12.0);
94/// ```
95pub fn caret_x_at(layout: &LineLayout, byte_idx: usize) -> f32 {
96    if layout.byte_offsets.is_empty() {
97        return 0.0;
98    }
99
100    let mut best_i = 0usize;
101    for (i, &off) in layout.byte_offsets.iter().enumerate() {
102        if off <= byte_idx {
103            best_i = i;
104        } else {
105            break;
106        }
107    }
108    layout.advances.get(best_i).copied().unwrap_or(0.0)
109}
110
111/// Returns the first cumulative-advance index at or beyond `x`.
112///
113/// The result indexes `layout.advances` and `layout.byte_offsets`; callers can
114/// obtain the UTF-8 offset with `layout.byte_offsets[result]`. Negative and NaN
115/// X values behave as zero. Values beyond the width return the last advance
116/// index. An empty layout returns zero.
117///
118/// # Examples
119///
120/// ```
121/// use ailloli_ui_text::{caret_index_at_x, LineLayout};
122/// let layout = LineLayout { byte_offsets: vec![0, 2, 3], advances: vec![0.0, 6.0, 12.0] };
123/// let position = caret_index_at_x(&layout, 7.0);
124/// assert_eq!(position, 2);
125/// assert_eq!(layout.byte_offsets[position], 3);
126/// ```
127pub fn caret_index_at_x(layout: &LineLayout, x: f32) -> usize {
128    let x = x.max(0.0);
129    if layout.advances.is_empty() {
130        return 0;
131    }
132
133    for (i, &ax) in layout.advances.iter().enumerate() {
134        if ax >= x {
135            return i;
136        }
137    }
138    layout.advances.len().saturating_sub(1)
139}