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}