Skip to main content

oxiui_text/
layout.rs

1//! Text layout with alignment and hit-testing.
2//!
3//! [`TextLayout`] wraps a [`ShapedText`] together with an alignment mode and
4//! maximum bounds, providing aligned glyph positions and click-to-caret
5//! hit-testing.
6
7use crate::{GlyphPosition, ShapedText, TextPipeline, TextStyle};
8
9// ── TextAlign ─────────────────────────────────────────────────────────────────
10
11/// Horizontal alignment of laid-out text.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum TextAlign {
14    /// Align to the left edge.
15    #[default]
16    Left,
17    /// Centre each line within the bounds.
18    Center,
19    /// Align to the right edge.
20    Right,
21    /// Justify all complete lines (distribute inter-word spacing).
22    Justify,
23}
24
25// ── TextLayout ────────────────────────────────────────────────────────────────
26
27/// A shaped text block with alignment and bounds information.
28pub struct TextLayout {
29    /// The raw shaped text (glyph positions, line metrics).
30    pub shaped: ShapedText,
31    /// Requested alignment.
32    pub align: TextAlign,
33    /// `(max_width, max_height)` — the layout box dimensions.
34    pub bounds: (f32, f32),
35}
36
37impl TextLayout {
38    /// Shape `text` and apply `align` within `max_width`.
39    ///
40    /// # Errors
41    /// Propagates shaping errors from the pipeline.
42    pub fn new(
43        pipeline: &mut TextPipeline,
44        text: &str,
45        style: &TextStyle,
46        max_width: f32,
47        align: TextAlign,
48    ) -> Result<Self, crate::TextError> {
49        let mut style_with_width = style.clone();
50        style_with_width.max_width = max_width;
51        let shaped = pipeline.shape(text, &style_with_width)?;
52        let total_height = shaped.total_height;
53        Ok(Self {
54            shaped,
55            align,
56            bounds: (max_width, total_height),
57        })
58    }
59
60    /// Apply alignment offsets to glyph positions within `bounds`.
61    ///
62    /// Returns per-line glyph positions adjusted for the requested alignment.
63    pub fn align_glyphs(&self) -> Vec<Vec<GlyphPosition>> {
64        let max_w = self.bounds.0;
65        self.shaped
66            .lines
67            .iter()
68            .map(|line| {
69                if line.is_empty() {
70                    return line.clone();
71                }
72
73                let line_w = line.iter().map(|g| g.x + g.width).fold(0.0_f32, f32::max);
74
75                let offset_x = match self.align {
76                    TextAlign::Left => 0.0,
77                    TextAlign::Right => (max_w - line_w).max(0.0),
78                    TextAlign::Center => ((max_w - line_w) / 2.0).max(0.0),
79                    TextAlign::Justify => 0.0, // justify gaps handled below
80                };
81
82                if matches!(self.align, TextAlign::Justify) {
83                    // Justify: distribute whitespace evenly between glyphs.
84                    let gap = (max_w - line_w) / (line.len().saturating_sub(1).max(1)) as f32;
85                    line.iter()
86                        .enumerate()
87                        .map(|(i, g)| GlyphPosition {
88                            x: g.x + gap * i as f32,
89                            ..g.clone()
90                        })
91                        .collect()
92                } else {
93                    line.iter()
94                        .map(|g| GlyphPosition {
95                            x: g.x + offset_x,
96                            ..g.clone()
97                        })
98                        .collect()
99                }
100            })
101            .collect()
102    }
103
104    /// Fast O(log n) hit-test over a single horizontal sweep using binary
105    /// search over sorted glyph x-positions.
106    ///
107    /// Returns the **glyph index** (into the concatenated flat list of all
108    /// glyphs across all lines) of the entry whose left edge is closest to `x`.
109    /// This is O(log n) in the total number of glyphs, vs. the O(n) linear
110    /// scan in [`Self::hit_test`].
111    ///
112    /// Unlike [`Self::hit_test`] this method ignores the y-coordinate and
113    /// operates on the full concatenated glyph stream — callers that need
114    /// per-line hit-testing should pre-filter by line before calling.
115    pub fn hit_test_fast(&self, x: f32) -> usize {
116        // Collect the left-edge x-position of every glyph in layout order.
117        let positions: Vec<f32> = self
118            .shaped
119            .lines
120            .iter()
121            .flat_map(|line| line.iter().map(|g| g.x))
122            .collect();
123
124        if positions.is_empty() {
125            return 0;
126        }
127
128        // `partition_point` returns the first index where `pos >= x`, i.e. the
129        // insertion point.  The closest glyph is either at that index or one
130        // before it.
131        let insert = positions.partition_point(|&pos| pos < x);
132
133        if insert == 0 {
134            return 0;
135        }
136        if insert >= positions.len() {
137            return positions.len() - 1;
138        }
139
140        // Choose whichever neighbour is closer to `x`.
141        let prev = insert - 1;
142        if (x - positions[prev]).abs() <= (x - positions[insert]).abs() {
143            prev
144        } else {
145            insert
146        }
147    }
148
149    /// Return the byte offset of the glyph closest to `(x, y)`.
150    ///
151    /// Useful for click-to-caret positioning.
152    pub fn hit_test(&self, x: f32, y: f32) -> usize {
153        if self.shaped.lines.is_empty() {
154            return 0;
155        }
156
157        // Find the closest line by y coordinate.
158        let line = {
159            let mut best_line: &Vec<GlyphPosition> = &self.shaped.lines[0];
160            let mut best_dist = f32::MAX;
161            for line in &self.shaped.lines {
162                if line.is_empty() {
163                    continue;
164                }
165                let top = line[0].y;
166                let bottom = top + line[0].height;
167                let mid = (top + bottom) * 0.5;
168                let dist = (y - mid).abs();
169                if dist < best_dist {
170                    best_dist = dist;
171                    best_line = line;
172                }
173            }
174            best_line
175        };
176
177        if line.is_empty() {
178            return 0;
179        }
180
181        // Find the closest glyph by x coordinate within that line.
182        let mut best_offset = line[0].byte_offset;
183        let mut best_dist = f32::MAX;
184        for g in line {
185            let mid = g.x + g.width * 0.5;
186            let dist = (x - mid).abs();
187            if dist < best_dist {
188                best_dist = dist;
189                best_offset = g.byte_offset;
190            }
191        }
192        best_offset
193    }
194}
195
196// ── Tests ─────────────────────────────────────────────────────────────────────
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::GlyphPosition;
202
203    fn fake_shaped(lines: Vec<Vec<GlyphPosition>>) -> ShapedText {
204        let total_width = lines
205            .iter()
206            .flat_map(|l| l.iter())
207            .map(|g| g.x + g.width)
208            .fold(0.0_f32, f32::max);
209        let total_height = lines
210            .iter()
211            .flat_map(|l| l.iter())
212            .map(|g| g.y + g.height)
213            .fold(0.0_f32, f32::max);
214        ShapedText {
215            lines,
216            total_width,
217            total_height,
218        }
219    }
220
221    fn single_line_layout(align: TextAlign, max_w: f32) -> TextLayout {
222        let line = vec![
223            GlyphPosition {
224                byte_offset: 0,
225                x: 0.0,
226                y: 0.0,
227                width: 10.0,
228                height: 16.0,
229            },
230            GlyphPosition {
231                byte_offset: 1,
232                x: 10.0,
233                y: 0.0,
234                width: 10.0,
235                height: 16.0,
236            },
237        ];
238        let shaped = fake_shaped(vec![line]);
239        TextLayout {
240            shaped,
241            align,
242            bounds: (max_w, 16.0),
243        }
244    }
245
246    #[test]
247    fn layout_left_align_starts_at_zero() {
248        let layout = single_line_layout(TextAlign::Left, 200.0);
249        let aligned = layout.align_glyphs();
250        let first_x = aligned[0][0].x;
251        assert!(
252            (first_x - 0.0).abs() < f32::EPSILON,
253            "left-aligned glyph should start at x=0"
254        );
255    }
256
257    #[test]
258    fn layout_right_align_ends_at_max_width() {
259        let layout = single_line_layout(TextAlign::Right, 200.0);
260        let aligned = layout.align_glyphs();
261        let last = aligned[0].last().unwrap();
262        let end_x = last.x + last.width;
263        assert!(
264            (end_x - 200.0).abs() < f32::EPSILON,
265            "right-aligned line should end at max_width"
266        );
267    }
268
269    #[test]
270    fn layout_center_align_midpoint() {
271        let layout = single_line_layout(TextAlign::Center, 100.0);
272        let aligned = layout.align_glyphs();
273        // Line width = 20, max_width = 100 → offset = 40
274        let first_x = aligned[0][0].x;
275        assert!(
276            (first_x - 40.0).abs() < f32::EPSILON,
277            "center first glyph x should be 40"
278        );
279    }
280
281    #[test]
282    fn layout_hit_test_basic() {
283        let layout = single_line_layout(TextAlign::Left, 100.0);
284        // Click at x=5, y=8 should hit glyph at byte_offset 0
285        let offset = layout.hit_test(5.0, 8.0);
286        assert_eq!(offset, 0);
287        // Click at x=15 should hit byte_offset 1
288        let offset2 = layout.hit_test(15.0, 8.0);
289        assert_eq!(offset2, 1);
290    }
291
292    // ── hit_test_fast ─────────────────────────────────────────────────────
293
294    #[test]
295    fn hit_test_fast_returns_index_zero_for_leftmost() {
296        let layout = single_line_layout(TextAlign::Left, 100.0);
297        // x=0 → closest to glyph at index 0 (x=0.0)
298        let idx = layout.hit_test_fast(0.0);
299        assert_eq!(idx, 0);
300    }
301
302    #[test]
303    fn hit_test_fast_returns_last_index_for_far_right() {
304        let layout = single_line_layout(TextAlign::Left, 100.0);
305        // x=100 → closest to glyph at index 1 (x=10.0), which is the last
306        let idx = layout.hit_test_fast(100.0);
307        assert_eq!(idx, 1);
308    }
309
310    #[test]
311    fn hit_test_fast_midpoint_tie_breaks_to_left() {
312        // Two glyphs at x=0 and x=10: midpoint = 5. At exactly 5 the
313        // previous glyph wins (<=).
314        let layout = single_line_layout(TextAlign::Left, 100.0);
315        let idx = layout.hit_test_fast(5.0);
316        assert_eq!(idx, 0);
317    }
318
319    #[test]
320    fn hit_test_fast_empty_layout_returns_zero() {
321        let shaped = ShapedText {
322            lines: Vec::new(),
323            total_width: 0.0,
324            total_height: 0.0,
325        };
326        let layout = TextLayout {
327            shaped,
328            align: TextAlign::Left,
329            bounds: (100.0, 0.0),
330        };
331        assert_eq!(layout.hit_test_fast(50.0), 0);
332    }
333}