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