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    glyph_x_positions: Vec<f32>,
76    char_to_byte: Vec<usize>,
77    /// Line layout information
78    pub lines: Vec<LineLayout>,
79    glyph_layouts: Vec<GlyphLayout>,
80    text_hash: u64,
81}
82
83impl TextLayoutResult {
84    /// Creates a new layout result with the given glyph positions.
85    pub fn new(text: &str, data: TextLayoutData) -> Self {
86        Self {
87            width: data.width,
88            height: data.height,
89            line_height: data.line_height,
90            glyph_x_positions: data.glyph_x_positions,
91            char_to_byte: data.char_to_byte,
92            lines: data.lines,
93            glyph_layouts: data.glyph_layouts,
94            text_hash: Self::hash_text(text),
95        }
96    }
97
98    /// Returns X position for cursor at given byte offset.
99    /// O(1) lookup from pre-computed positions.
100    pub fn get_cursor_x(&self, byte_offset: usize) -> f32 {
101        let char_idx = self
102            .char_to_byte
103            .iter()
104            .position(|&b| b > byte_offset)
105            .map(|i| i.saturating_sub(1))
106            .unwrap_or(self.char_to_byte.len().saturating_sub(1));
107
108        self.glyph_x_positions
109            .get(char_idx)
110            .copied()
111            .unwrap_or(self.width)
112    }
113
114    /// Returns byte offset for X position.
115    /// O(log n) binary search through glyph positions.
116    pub fn get_offset_for_x(&self, x: f32) -> usize {
117        if self.glyph_x_positions.is_empty() {
118            return 0;
119        }
120
121        let char_idx = match self
122            .glyph_x_positions
123            .binary_search_by(|pos| pos.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal))
124        {
125            Ok(i) => i,
126            Err(i) => {
127                if i == 0 {
128                    0
129                } else if i >= self.glyph_x_positions.len() {
130                    self.glyph_x_positions.len() - 1
131                } else {
132                    let before = self.glyph_x_positions[i - 1];
133                    let after = self.glyph_x_positions[i];
134                    if (x - before) < (after - x) { i - 1 } else { i }
135                }
136            }
137        };
138
139        self.char_to_byte.get(char_idx).copied().unwrap_or(0)
140    }
141
142    /// Checks if this layout result is valid for the given text.
143    pub fn is_valid_for(&self, text: &str) -> bool {
144        self.text_hash == Self::hash_text(text)
145    }
146
147    /// Returns visual glyph boxes emitted by shaping/layout.
148    pub fn glyph_layouts(&self) -> &[GlyphLayout] {
149        &self.glyph_layouts
150    }
151
152    fn hash_text(text: &str) -> u64 {
153        let mut hasher = default::new();
154        text.hash(&mut hasher);
155        hasher.finish()
156    }
157
158    /// Creates a simple layout for monospaced text (for fallback).
159    pub fn monospaced(text: &str, char_width: f32, line_height: f32) -> Self {
160        let mut glyph_x_positions = Vec::new();
161        let mut char_to_byte = Vec::new();
162        let mut glyph_layouts = Vec::new();
163        let mut cursor_x = 0.0;
164
165        for (byte_offset, _c) in text.char_indices() {
166            glyph_x_positions.push(cursor_x);
167            char_to_byte.push(byte_offset);
168            cursor_x += char_width;
169        }
170        glyph_x_positions.push(cursor_x);
171        char_to_byte.push(text.len());
172
173        let mut line_x = 0.0;
174        let mut line_y = 0.0;
175        let mut line_index = 0usize;
176        for (byte_offset, c) in text.char_indices() {
177            if c == '\n' {
178                line_index = line_index.saturating_add(1);
179                line_y += line_height;
180                line_x = 0.0;
181                continue;
182            }
183            let glyph_start = byte_offset;
184            let glyph_end = glyph_start + c.len_utf8();
185            glyph_layouts.push(GlyphLayout {
186                line_index,
187                start_offset: glyph_start,
188                end_offset: glyph_end,
189                x: line_x,
190                y: line_y,
191                width: char_width,
192                height: line_height,
193            });
194            line_x += char_width;
195        }
196
197        let line_texts: Vec<&str> = text.split('\n').collect();
198        let line_count = line_texts.len();
199        let mut lines = Vec::with_capacity(line_count);
200        let mut line_start = 0;
201        let mut y = 0.0;
202        let mut max_width: f32 = 0.0;
203
204        for (i, line_text) in line_texts.iter().enumerate() {
205            let line_end = if i == line_count - 1 {
206                text.len()
207            } else {
208                line_start + line_text.len()
209            };
210
211            let line_width = line_text.chars().count() as f32 * char_width;
212            max_width = max_width.max(line_width);
213
214            lines.push(LineLayout {
215                start_offset: line_start,
216                end_offset: line_end,
217                y,
218                height: line_height,
219            });
220
221            line_start = line_end + 1;
222            y += line_height;
223        }
224
225        if lines.is_empty() {
226            lines.push(LineLayout {
227                start_offset: 0,
228                end_offset: 0,
229                y: 0.0,
230                height: line_height,
231            });
232        }
233
234        Self::new(
235            text,
236            TextLayoutData {
237                width: max_width,
238                height: lines.len() as f32 * line_height,
239                line_height,
240                glyph_x_positions,
241                char_to_byte,
242                lines,
243                glyph_layouts,
244            },
245        )
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_monospaced_layout() {
255        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
256
257        assert_eq!(layout.get_cursor_x(0), 0.0);
258        assert_eq!(layout.get_cursor_x(5), 50.0);
259    }
260
261    #[test]
262    fn test_get_offset_for_x() {
263        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
264
265        let offset = layout.get_offset_for_x(25.0);
266        assert!(offset == 2 || offset == 3);
267    }
268
269    #[test]
270    fn test_multiline() {
271        let layout = TextLayoutResult::monospaced("Hi\nWorld", 10.0, 20.0);
272
273        assert_eq!(layout.lines.len(), 2);
274        assert_eq!(layout.lines[0].start_offset, 0);
275        assert_eq!(layout.lines[1].start_offset, 3);
276    }
277
278    #[test]
279    fn test_validity() {
280        let layout = TextLayoutResult::monospaced("Hello", 10.0, 20.0);
281
282        assert!(layout.is_valid_for("Hello"));
283        assert!(!layout.is_valid_for("World"));
284    }
285}