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::collections::hash_map::DefaultHasher;
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/// Cached text layout result with pre-computed glyph positions.
24///
25/// Compute once during `measure()`, reuse for:
26/// - Cursor X position rendering
27/// - Selection highlight geometry
28/// - Click-to-position cursor
29#[derive(Debug, Clone)]
30pub struct TextLayoutResult {
31    /// Total width of laid out text
32    pub width: f32,
33    /// Total height of laid out text
34    pub height: f32,
35    /// Height of a single line
36    pub line_height: f32,
37    /// X position at each character boundary (including end)
38    /// glyph_x_positions[i] = x position before character at char index i
39    /// glyph_x_positions[char_count] = x position at end of text
40    glyph_x_positions: Vec<f32>,
41    /// Byte offset for each character index
42    /// char_to_byte[i] = byte offset of character at char index i
43    char_to_byte: Vec<usize>,
44    /// Line layout information
45    pub lines: Vec<LineLayout>,
46    /// Hash of text this was computed for (for validation)
47    text_hash: u64,
48}
49
50impl TextLayoutResult {
51    /// Creates a new layout result with the given glyph positions.
52    pub fn new(
53        width: f32,
54        height: f32,
55        line_height: f32,
56        glyph_x_positions: Vec<f32>,
57        char_to_byte: Vec<usize>,
58        lines: Vec<LineLayout>,
59        text: &str,
60    ) -> Self {
61        Self {
62            width,
63            height,
64            line_height,
65            glyph_x_positions,
66            char_to_byte,
67            lines,
68            text_hash: Self::hash_text(text),
69        }
70    }
71
72    /// Returns X position for cursor at given byte offset.
73    /// O(1) lookup from pre-computed positions.
74    pub fn get_cursor_x(&self, byte_offset: usize) -> f32 {
75        // Binary search for char index containing this byte offset
76        let char_idx = self
77            .char_to_byte
78            .iter()
79            .position(|&b| b > byte_offset)
80            .map(|i| i.saturating_sub(1))
81            .unwrap_or(self.char_to_byte.len().saturating_sub(1));
82
83        // Return X position at that char boundary
84        self.glyph_x_positions
85            .get(char_idx)
86            .copied()
87            .unwrap_or(self.width)
88    }
89
90    /// Returns byte offset for X position.
91    /// O(log n) binary search through glyph positions.
92    pub fn get_offset_for_x(&self, x: f32) -> usize {
93        if self.glyph_x_positions.is_empty() {
94            return 0;
95        }
96
97        // Binary search for closest glyph boundary
98        let char_idx = match self
99            .glyph_x_positions
100            .binary_search_by(|pos| pos.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal))
101        {
102            Ok(i) => i,
103            Err(i) => {
104                // Between two positions - pick closest
105                if i == 0 {
106                    0
107                } else if i >= self.glyph_x_positions.len() {
108                    self.glyph_x_positions.len() - 1
109                } else {
110                    let before = self.glyph_x_positions[i - 1];
111                    let after = self.glyph_x_positions[i];
112                    if (x - before) < (after - x) {
113                        i - 1
114                    } else {
115                        i
116                    }
117                }
118            }
119        };
120
121        // Convert char index to byte offset
122        self.char_to_byte.get(char_idx).copied().unwrap_or(0)
123    }
124
125    /// Checks if this layout result is valid for the given text.
126    pub fn is_valid_for(&self, text: &str) -> bool {
127        self.text_hash == Self::hash_text(text)
128    }
129
130    fn hash_text(text: &str) -> u64 {
131        let mut hasher = DefaultHasher::new();
132        text.hash(&mut hasher);
133        hasher.finish()
134    }
135
136    /// Creates a simple layout for monospaced text (for fallback).
137    pub fn monospaced(text: &str, char_width: f32, line_height: f32) -> Self {
138        let mut glyph_x_positions = Vec::new();
139        let mut char_to_byte = Vec::new();
140        let mut x = 0.0;
141
142        for (byte_offset, _c) in text.char_indices() {
143            glyph_x_positions.push(x);
144            char_to_byte.push(byte_offset);
145            x += char_width;
146        }
147        // Add end position
148        glyph_x_positions.push(x);
149        char_to_byte.push(text.len());
150
151        // Compute lines - collect once and reuse
152        let line_texts: Vec<&str> = text.split('\n').collect();
153        let line_count = line_texts.len();
154        let mut lines = Vec::with_capacity(line_count);
155        let mut line_start = 0;
156        let mut y = 0.0;
157        let mut max_width: f32 = 0.0;
158
159        for (i, line_text) in line_texts.iter().enumerate() {
160            let line_end = if i == line_count - 1 {
161                text.len()
162            } else {
163                line_start + line_text.len()
164            };
165
166            // Track max width while iterating
167            let line_width = line_text.chars().count() as f32 * char_width;
168            max_width = max_width.max(line_width);
169
170            lines.push(LineLayout {
171                start_offset: line_start,
172                end_offset: line_end,
173                y,
174                height: line_height,
175            });
176
177            line_start = line_end + 1; // +1 for newline
178            y += line_height;
179        }
180
181        // Ensure at least one line
182        if lines.is_empty() {
183            lines.push(LineLayout {
184                start_offset: 0,
185                end_offset: 0,
186                y: 0.0,
187                height: line_height,
188            });
189        }
190
191        Self::new(
192            max_width,
193            lines.len() as f32 * line_height,
194            line_height,
195            glyph_x_positions,
196            char_to_byte,
197            lines,
198            text,
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_monospaced_layout() {
209        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
210
211        // Check positions
212        assert_eq!(layout.get_cursor_x(0), 0.0); // Before 'H'
213        assert_eq!(layout.get_cursor_x(5), 50.0); // After 'o'
214    }
215
216    #[test]
217    fn test_get_offset_for_x() {
218        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
219
220        // Click at x=25 should be closest to offset 2 or 3
221        let offset = layout.get_offset_for_x(25.0);
222        assert!(offset == 2 || offset == 3);
223    }
224
225    #[test]
226    fn test_multiline() {
227        let layout = TextLayoutResult::monospaced("Hi\nWorld", 10.0, 20.0);
228
229        assert_eq!(layout.lines.len(), 2);
230        assert_eq!(layout.lines[0].start_offset, 0);
231        assert_eq!(layout.lines[1].start_offset, 3); // After "Hi\n"
232    }
233
234    #[test]
235    fn test_validity() {
236        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
237
238        assert!(layout.is_valid_for("Hello"));
239        assert!(!layout.is_valid_for("World"));
240    }
241}