Skip to main content

cranpose_ui/
text_layout_result.rs

1//! Text layout result with cached glyph positions.
2//!
3//! This module provides `TextLayoutResult` which caches glyph X positions
4//! computed during text measurement, enabling O(1) cursor positioning and
5//! selection rendering instead of O(n²) substring measurements.
6
7use std::hash::{Hash, Hasher};
8
9use cranpose_core::hash::default;
10
11/// Layout information for a single line of text.
12#[derive(Debug, Clone)]
13pub struct LineLayout {
14    /// Byte offset where line starts
15    pub start_offset: usize,
16    /// Byte offset where line ends (exclusive, before \n or at text end)
17    pub end_offset: usize,
18    /// Y position of line top
19    pub y: f32,
20    /// Height of line
21    pub height: f32,
22}
23
24/// Visual glyph bounds emitted by the text shaper.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct GlyphLayout {
27    /// Logical line index for this glyph box.
28    pub line_index: usize,
29    /// Byte offset where glyph coverage starts.
30    pub start_offset: usize,
31    /// Byte offset where glyph coverage ends (exclusive).
32    pub end_offset: usize,
33    /// X position from line origin.
34    pub x: f32,
35    /// Y position from paragraph top.
36    pub y: f32,
37    /// Glyph box width.
38    pub width: f32,
39    /// Glyph box height.
40    pub height: f32,
41}
42
43/// Cached text layout result with pre-computed glyph positions.
44///
45/// Compute once during `measure()`, reuse for:
46/// - Cursor X position rendering
47/// - Selection highlight geometry
48/// - Click-to-position cursor
49#[derive(Debug, Clone)]
50pub struct TextLayoutData {
51    /// Total width of laid out text
52    pub width: f32,
53    /// Total height of laid out text
54    pub height: f32,
55    /// Height of a single line
56    pub line_height: f32,
57    /// X position at each character boundary (including end)
58    pub glyph_x_positions: Vec<f32>,
59    /// Byte offset for each character index
60    pub char_to_byte: Vec<usize>,
61    /// Line layout information
62    pub lines: Vec<LineLayout>,
63    /// Visual glyph boxes in shaped order.
64    pub glyph_layouts: Vec<GlyphLayout>,
65}
66
67#[derive(Debug, Clone)]
68pub struct TextLayoutResult {
69    /// Total width of laid out text
70    pub width: f32,
71    /// Total height of laid out text
72    pub height: f32,
73    /// Height of a single line
74    pub line_height: f32,
75    /// X position at each character boundary (including end)
76    /// glyph_x_positions[i] = x position before character at char index i
77    /// glyph_x_positions[char_count] = x position at end of text
78    glyph_x_positions: Vec<f32>,
79    /// Byte offset for each character index
80    /// char_to_byte[i] = byte offset of character at char index i
81    char_to_byte: Vec<usize>,
82    /// Line layout information
83    pub lines: Vec<LineLayout>,
84    /// Visual glyph boxes in shaped order.
85    glyph_layouts: Vec<GlyphLayout>,
86    /// Hash of text this was computed for (for validation)
87    text_hash: u64,
88}
89
90impl TextLayoutResult {
91    /// Creates a new layout result with the given glyph positions.
92    pub fn new(text: &str, data: TextLayoutData) -> Self {
93        Self {
94            width: data.width,
95            height: data.height,
96            line_height: data.line_height,
97            glyph_x_positions: data.glyph_x_positions,
98            char_to_byte: data.char_to_byte,
99            lines: data.lines,
100            glyph_layouts: data.glyph_layouts,
101            text_hash: Self::hash_text(text),
102        }
103    }
104
105    /// Returns X position for cursor at given byte offset.
106    /// O(1) lookup from pre-computed positions.
107    pub fn get_cursor_x(&self, byte_offset: usize) -> f32 {
108        // Binary search for char index containing this byte offset
109        let char_idx = self
110            .char_to_byte
111            .iter()
112            .position(|&b| b > byte_offset)
113            .map(|i| i.saturating_sub(1))
114            .unwrap_or(self.char_to_byte.len().saturating_sub(1));
115
116        // Return X position at that char boundary
117        self.glyph_x_positions
118            .get(char_idx)
119            .copied()
120            .unwrap_or(self.width)
121    }
122
123    /// Returns byte offset for X position.
124    /// O(log n) binary search through glyph positions.
125    pub fn get_offset_for_x(&self, x: f32) -> usize {
126        if self.glyph_x_positions.is_empty() {
127            return 0;
128        }
129
130        // Binary search for closest glyph boundary
131        let char_idx = match self
132            .glyph_x_positions
133            .binary_search_by(|pos| pos.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal))
134        {
135            Ok(i) => i,
136            Err(i) => {
137                // Between two positions - pick closest
138                if i == 0 {
139                    0
140                } else if i >= self.glyph_x_positions.len() {
141                    self.glyph_x_positions.len() - 1
142                } else {
143                    let before = self.glyph_x_positions[i - 1];
144                    let after = self.glyph_x_positions[i];
145                    if (x - before) < (after - x) {
146                        i - 1
147                    } else {
148                        i
149                    }
150                }
151            }
152        };
153
154        // Convert char index to byte offset
155        self.char_to_byte.get(char_idx).copied().unwrap_or(0)
156    }
157
158    /// Checks if this layout result is valid for the given text.
159    pub fn is_valid_for(&self, text: &str) -> bool {
160        self.text_hash == Self::hash_text(text)
161    }
162
163    /// Returns visual glyph boxes emitted by shaping/layout.
164    pub fn glyph_layouts(&self) -> &[GlyphLayout] {
165        &self.glyph_layouts
166    }
167
168    fn hash_text(text: &str) -> u64 {
169        let mut hasher = default::new();
170        text.hash(&mut hasher);
171        hasher.finish()
172    }
173
174    /// Creates a simple layout for monospaced text (for fallback).
175    pub fn monospaced(text: &str, char_width: f32, line_height: f32) -> Self {
176        let mut glyph_x_positions = Vec::new();
177        let mut char_to_byte = Vec::new();
178        let mut glyph_layouts = Vec::new();
179        let mut cursor_x = 0.0;
180
181        for (byte_offset, _c) in text.char_indices() {
182            glyph_x_positions.push(cursor_x);
183            char_to_byte.push(byte_offset);
184            cursor_x += char_width;
185        }
186        // Add end position
187        glyph_x_positions.push(cursor_x);
188        char_to_byte.push(text.len());
189
190        let mut line_x = 0.0;
191        let mut line_y = 0.0;
192        let mut line_index = 0usize;
193        for (byte_offset, c) in text.char_indices() {
194            if c == '\n' {
195                line_index = line_index.saturating_add(1);
196                line_y += line_height;
197                line_x = 0.0;
198                continue;
199            }
200            let glyph_start = byte_offset;
201            let glyph_end = glyph_start + c.len_utf8();
202            glyph_layouts.push(GlyphLayout {
203                line_index,
204                start_offset: glyph_start,
205                end_offset: glyph_end,
206                x: line_x,
207                y: line_y,
208                width: char_width,
209                height: line_height,
210            });
211            line_x += char_width;
212        }
213
214        // Compute lines - collect once and reuse
215        let line_texts: Vec<&str> = text.split('\n').collect();
216        let line_count = line_texts.len();
217        let mut lines = Vec::with_capacity(line_count);
218        let mut line_start = 0;
219        let mut y = 0.0;
220        let mut max_width: f32 = 0.0;
221
222        for (i, line_text) in line_texts.iter().enumerate() {
223            let line_end = if i == line_count - 1 {
224                text.len()
225            } else {
226                line_start + line_text.len()
227            };
228
229            // Track max width while iterating
230            let line_width = line_text.chars().count() as f32 * char_width;
231            max_width = max_width.max(line_width);
232
233            lines.push(LineLayout {
234                start_offset: line_start,
235                end_offset: line_end,
236                y,
237                height: line_height,
238            });
239
240            line_start = line_end + 1; // +1 for newline
241            y += line_height;
242        }
243
244        // Ensure at least one line
245        if lines.is_empty() {
246            lines.push(LineLayout {
247                start_offset: 0,
248                end_offset: 0,
249                y: 0.0,
250                height: line_height,
251            });
252        }
253
254        Self::new(
255            text,
256            TextLayoutData {
257                width: max_width,
258                height: lines.len() as f32 * line_height,
259                line_height,
260                glyph_x_positions,
261                char_to_byte,
262                lines,
263                glyph_layouts,
264            },
265        )
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_monospaced_layout() {
275        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
276
277        // Check positions
278        assert_eq!(layout.get_cursor_x(0), 0.0); // Before 'H'
279        assert_eq!(layout.get_cursor_x(5), 50.0); // After 'o'
280    }
281
282    #[test]
283    fn test_get_offset_for_x() {
284        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
285
286        // Click at x=25 should be closest to offset 2 or 3
287        let offset = layout.get_offset_for_x(25.0);
288        assert!(offset == 2 || offset == 3);
289    }
290
291    #[test]
292    fn test_multiline() {
293        let layout = TextLayoutResult::monospaced("Hi\nWorld", 10.0, 20.0);
294
295        assert_eq!(layout.lines.len(), 2);
296        assert_eq!(layout.lines[0].start_offset, 0);
297        assert_eq!(layout.lines[1].start_offset, 3); // After "Hi\n"
298    }
299
300    #[test]
301    fn test_validity() {
302        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
303
304        assert!(layout.is_valid_for("Hello"));
305        assert!(!layout.is_valid_for("World"));
306    }
307}