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/// The measured layout of one piece of text. It maps byte offsets to x positions and back for
68/// cursor placement, selection highlights and hit testing.
69#[derive(Debug, Clone)]
70pub struct TextLayoutResult {
71    /// Total width of laid out text
72    pub width: f32,
73    /// Total height of laid out text
74    pub height: f32,
75    /// Height of a single line
76    pub line_height: f32,
77    glyph_x_positions: Vec<f32>,
78    cursor_positions_monotonic: bool,
79    char_to_byte: Vec<usize>,
80    /// Line layout information
81    pub lines: Vec<LineLayout>,
82    glyph_layouts: Vec<GlyphLayout>,
83    text_hash: u64,
84}
85
86impl TextLayoutResult {
87    /// Creates a new layout result with the given glyph positions.
88    pub fn new(text: &str, data: TextLayoutData) -> Self {
89        Self {
90            width: data.width,
91            height: data.height,
92            line_height: data.line_height,
93            cursor_positions_monotonic: data
94                .glyph_x_positions
95                .windows(2)
96                .all(|pair| pair[0] <= pair[1]),
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    /// Uses logical byte boundaries, including positions shared by a shaped cluster.
107    pub fn get_cursor_x(&self, byte_offset: usize) -> f32 {
108        let char_idx = self
109            .char_to_byte
110            .partition_point(|&byte| byte <= byte_offset)
111            .saturating_sub(1);
112
113        self.glyph_x_positions
114            .get(char_idx)
115            .copied()
116            .unwrap_or(self.width)
117    }
118
119    /// Returns byte offset for X position.
120    /// Uses binary search for monotonic positions and nearest-edge lookup for bidirectional text.
121    pub fn get_offset_for_x(&self, x: f32) -> usize {
122        if self.glyph_x_positions.is_empty() {
123            return 0;
124        }
125        if !self.cursor_positions_monotonic {
126            return self
127                .glyph_x_positions
128                .iter()
129                .enumerate()
130                .min_by(|(_, left), (_, right)| (**left - x).abs().total_cmp(&(**right - x).abs()))
131                .and_then(|(index, _)| self.char_to_byte.get(index))
132                .copied()
133                .unwrap_or(0);
134        }
135
136        let char_idx = match self
137            .glyph_x_positions
138            .binary_search_by(|pos| pos.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal))
139        {
140            Ok(i) => i,
141            Err(i) => {
142                if i == 0 {
143                    0
144                } else if i >= self.glyph_x_positions.len() {
145                    self.glyph_x_positions.len() - 1
146                } else {
147                    let before = self.glyph_x_positions[i - 1];
148                    let after = self.glyph_x_positions[i];
149                    if (x - before) < (after - x) { i - 1 } else { i }
150                }
151            }
152        };
153
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        glyph_x_positions.push(cursor_x);
186        char_to_byte.push(text.len());
187
188        let mut line_x = 0.0;
189        let mut line_y = 0.0;
190        let mut line_index = 0usize;
191        for (byte_offset, c) in text.char_indices() {
192            if c == '\n' {
193                line_index = line_index.saturating_add(1);
194                line_y += line_height;
195                line_x = 0.0;
196                continue;
197            }
198            let glyph_start = byte_offset;
199            let glyph_end = glyph_start + c.len_utf8();
200            glyph_layouts.push(GlyphLayout {
201                line_index,
202                start_offset: glyph_start,
203                end_offset: glyph_end,
204                x: line_x,
205                y: line_y,
206                width: char_width,
207                height: line_height,
208            });
209            line_x += char_width;
210        }
211
212        let line_texts: Vec<&str> = text.split('\n').collect();
213        let line_count = line_texts.len();
214        let mut lines = Vec::with_capacity(line_count);
215        let mut line_start = 0;
216        let mut y = 0.0;
217        let mut max_width: f32 = 0.0;
218
219        for (i, line_text) in line_texts.iter().enumerate() {
220            let line_end = if i == line_count - 1 {
221                text.len()
222            } else {
223                line_start + line_text.len()
224            };
225
226            let line_width = line_text.chars().count() as f32 * char_width;
227            max_width = max_width.max(line_width);
228
229            lines.push(LineLayout {
230                start_offset: line_start,
231                end_offset: line_end,
232                y,
233                height: line_height,
234            });
235
236            line_start = line_end + 1;
237            y += line_height;
238        }
239
240        if lines.is_empty() {
241            lines.push(LineLayout {
242                start_offset: 0,
243                end_offset: 0,
244                y: 0.0,
245                height: line_height,
246            });
247        }
248
249        Self::new(
250            text,
251            TextLayoutData {
252                width: max_width,
253                height: lines.len() as f32 * line_height,
254                line_height,
255                glyph_x_positions,
256                char_to_byte,
257                lines,
258                glyph_layouts,
259            },
260        )
261    }
262}
263
264#[cfg(test)]
265#[path = "tests/text_layout_result_tests.rs"]
266mod tests;