Skip to main content

editor_core/
snapshot.rs

1//! Phase 6: Headless Output Snapshot (Headless Snapshot API)
2//!
3//! Provides data structures needed by UI renderers, simulating "text grid" output.
4
5use crate::intervals::StyleId;
6use crate::layout::{DEFAULT_TAB_WIDTH, LayoutEngine, cell_width_at, visual_x_for_column};
7
8/// Cell (character) information
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Cell {
11    /// Character content
12    pub ch: char,
13    /// Visual width (1 or 2 cells)
14    pub width: usize,
15    /// List of applied style IDs
16    pub styles: Vec<StyleId>,
17}
18
19impl Cell {
20    /// Create a cell without any styles applied.
21    pub fn new(ch: char, width: usize) -> Self {
22        Self {
23            ch,
24            width,
25            styles: Vec::new(),
26        }
27    }
28
29    /// Create a cell with an explicit style list.
30    pub fn with_styles(ch: char, width: usize, styles: Vec<StyleId>) -> Self {
31        Self { ch, width, styles }
32    }
33}
34
35/// Headless line information
36#[derive(Debug, Clone)]
37pub struct HeadlessLine {
38    /// Corresponding logical line index
39    pub logical_line_index: usize,
40    /// Whether this is a part created by wrapping (soft wrap)
41    pub is_wrapped_part: bool,
42    /// List of cells
43    pub cells: Vec<Cell>,
44}
45
46impl HeadlessLine {
47    /// Create an empty headless line.
48    pub fn new(logical_line_index: usize, is_wrapped_part: bool) -> Self {
49        Self {
50            logical_line_index,
51            is_wrapped_part,
52            cells: Vec::new(),
53        }
54    }
55
56    /// Append a cell to the line.
57    pub fn add_cell(&mut self, cell: Cell) {
58        self.cells.push(cell);
59    }
60
61    /// Get total visual width of this line
62    pub fn visual_width(&self) -> usize {
63        self.cells.iter().map(|c| c.width).sum()
64    }
65}
66
67/// Headless grid snapshot
68#[derive(Debug, Clone)]
69pub struct HeadlessGrid {
70    /// List of visual lines
71    pub lines: Vec<HeadlessLine>,
72    /// Starting visual row number
73    pub start_visual_row: usize,
74    /// Number of lines requested
75    pub count: usize,
76}
77
78impl HeadlessGrid {
79    /// Create an empty grid snapshot for a requested visual range.
80    pub fn new(start_visual_row: usize, count: usize) -> Self {
81        Self {
82            lines: Vec::new(),
83            start_visual_row,
84            count,
85        }
86    }
87
88    /// Append a visual line to the grid.
89    pub fn add_line(&mut self, line: HeadlessLine) {
90        self.lines.push(line);
91    }
92
93    /// Get actual number of lines returned
94    pub fn actual_line_count(&self) -> usize {
95        self.lines.len()
96    }
97}
98
99/// Headless snapshot generator
100///
101/// Integrates all components to generate snapshots needed for UI rendering
102pub struct SnapshotGenerator {
103    /// Document content (stored by lines)
104    lines: Vec<String>,
105    /// Viewport width
106    viewport_width: usize,
107    /// Tab width (in cells) used to expand `'\t'` during layout/measurement.
108    tab_width: usize,
109    /// Soft wrap layout engine (for logical line <-> visual line conversion)
110    layout_engine: LayoutEngine,
111}
112
113impl SnapshotGenerator {
114    /// Create a new generator for an empty document.
115    pub fn new(viewport_width: usize) -> Self {
116        let lines = vec![String::new()];
117        let mut layout_engine = LayoutEngine::new(viewport_width);
118        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
119        layout_engine.from_lines(&line_refs);
120
121        Self {
122            // Maintain consistency with common editor semantics: an empty document also has 1 empty line.
123            lines,
124            viewport_width,
125            tab_width: layout_engine.tab_width(),
126            layout_engine,
127        }
128    }
129
130    /// Initialize from text
131    pub fn from_text(text: &str, viewport_width: usize) -> Self {
132        Self::from_text_with_tab_width(text, viewport_width, DEFAULT_TAB_WIDTH)
133    }
134
135    /// Initialize from text, with explicit `tab_width` (in cells) for expanding `'\t'`.
136    pub fn from_text_with_tab_width(text: &str, viewport_width: usize, tab_width: usize) -> Self {
137        let lines = crate::text::split_lines_preserve_trailing(text);
138        let mut layout_engine = LayoutEngine::new(viewport_width);
139        layout_engine.set_tab_width(tab_width);
140        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
141        layout_engine.from_lines(&line_refs);
142        Self {
143            lines,
144            viewport_width,
145            tab_width: layout_engine.tab_width(),
146            layout_engine,
147        }
148    }
149
150    /// Update document content
151    pub fn set_lines(&mut self, lines: Vec<String>) {
152        self.lines = if lines.is_empty() {
153            vec![String::new()]
154        } else {
155            lines
156        };
157
158        let line_refs: Vec<&str> = self.lines.iter().map(|s| s.as_str()).collect();
159        self.layout_engine.from_lines(&line_refs);
160    }
161
162    /// Set viewport width
163    pub fn set_viewport_width(&mut self, width: usize) {
164        self.viewport_width = width;
165        self.layout_engine.set_viewport_width(width);
166    }
167
168    /// Set tab width (in cells) used for expanding `'\t'`.
169    pub fn set_tab_width(&mut self, tab_width: usize) {
170        self.tab_width = tab_width.max(1);
171        self.layout_engine.set_tab_width(self.tab_width);
172    }
173
174    /// Get tab width (in cells).
175    pub fn tab_width(&self) -> usize {
176        self.tab_width
177    }
178
179    /// Get headless grid snapshot
180    ///
181    /// This is the core API, returning visual line data for the specified range
182    pub fn get_headless_grid(&self, start_visual_row: usize, count: usize) -> HeadlessGrid {
183        let mut grid = HeadlessGrid::new(start_visual_row, count);
184
185        if count == 0 {
186            return grid;
187        }
188
189        let total_visual = self.layout_engine.visual_line_count();
190        if start_visual_row >= total_visual {
191            return grid;
192        }
193
194        let end_visual = start_visual_row.saturating_add(count).min(total_visual);
195        let mut current_visual = 0usize;
196
197        for logical_line in 0..self.layout_engine.logical_line_count() {
198            let Some(layout) = self.layout_engine.get_line_layout(logical_line) else {
199                continue;
200            };
201
202            let line_text = self
203                .lines
204                .get(logical_line)
205                .map(|s| s.as_str())
206                .unwrap_or("");
207            let line_char_len = line_text.chars().count();
208
209            for visual_in_line in 0..layout.visual_line_count {
210                if current_visual >= end_visual {
211                    return grid;
212                }
213
214                if current_visual >= start_visual_row {
215                    let segment_start_col = if visual_in_line == 0 {
216                        0
217                    } else {
218                        layout
219                            .wrap_points
220                            .get(visual_in_line - 1)
221                            .map(|wp| wp.char_index)
222                            .unwrap_or(0)
223                            .min(line_char_len)
224                    };
225
226                    let segment_end_col = if visual_in_line < layout.wrap_points.len() {
227                        layout.wrap_points[visual_in_line]
228                            .char_index
229                            .min(line_char_len)
230                    } else {
231                        line_char_len
232                    };
233
234                    let mut headless_line = HeadlessLine::new(logical_line, visual_in_line > 0);
235                    let seg_start_x_in_line =
236                        visual_x_for_column(line_text, segment_start_col, self.tab_width);
237                    let mut x_in_line = seg_start_x_in_line;
238                    for ch in line_text
239                        .chars()
240                        .skip(segment_start_col)
241                        .take(segment_end_col.saturating_sub(segment_start_col))
242                    {
243                        let w = cell_width_at(ch, x_in_line, self.tab_width);
244                        x_in_line = x_in_line.saturating_add(w);
245                        headless_line.add_cell(Cell::new(ch, w));
246                    }
247
248                    grid.add_line(headless_line);
249                }
250
251                current_visual = current_visual.saturating_add(1);
252            }
253        }
254
255        grid
256    }
257
258    /// Get content of a specific logical line
259    pub fn get_line(&self, line_index: usize) -> Option<&str> {
260        self.lines.get(line_index).map(|s| s.as_str())
261    }
262
263    /// Get total number of logical lines
264    pub fn line_count(&self) -> usize {
265        self.lines.len()
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_cell_creation() {
275        let cell = Cell::new('a', 1);
276        assert_eq!(cell.ch, 'a');
277        assert_eq!(cell.width, 1);
278        assert!(cell.styles.is_empty());
279    }
280
281    #[test]
282    fn test_cell_with_styles() {
283        let cell = Cell::with_styles('你', 2, vec![1, 2, 3]);
284        assert_eq!(cell.ch, '你');
285        assert_eq!(cell.width, 2);
286        assert_eq!(cell.styles, vec![1, 2, 3]);
287    }
288
289    #[test]
290    fn test_headless_line() {
291        let mut line = HeadlessLine::new(0, false);
292        line.add_cell(Cell::new('H', 1));
293        line.add_cell(Cell::new('e', 1));
294        line.add_cell(Cell::new('你', 2));
295
296        assert_eq!(line.logical_line_index, 0);
297        assert!(!line.is_wrapped_part);
298        assert_eq!(line.cells.len(), 3);
299        assert_eq!(line.visual_width(), 4); // 1 + 1 + 2
300    }
301
302    #[test]
303    fn test_snapshot_generator_basic() {
304        let text = "Hello\nWorld\nRust";
305        let generator = SnapshotGenerator::from_text(text, 80);
306
307        assert_eq!(generator.line_count(), 3);
308        assert_eq!(generator.get_line(0), Some("Hello"));
309        assert_eq!(generator.get_line(1), Some("World"));
310        assert_eq!(generator.get_line(2), Some("Rust"));
311    }
312
313    #[test]
314    fn test_get_headless_grid() {
315        let text = "Line 1\nLine 2\nLine 3\nLine 4";
316        let generator = SnapshotGenerator::from_text(text, 80);
317
318        // Get first 2 lines
319        let grid = generator.get_headless_grid(0, 2);
320        assert_eq!(grid.start_visual_row, 0);
321        assert_eq!(grid.count, 2);
322        assert_eq!(grid.actual_line_count(), 2);
323
324        // Verify first line
325        let line0 = &grid.lines[0];
326        assert_eq!(line0.logical_line_index, 0);
327        assert!(!line0.is_wrapped_part);
328        assert_eq!(line0.cells.len(), 6); // "Line 1"
329
330        // Get middle lines
331        let grid2 = generator.get_headless_grid(1, 2);
332        assert_eq!(grid2.actual_line_count(), 2);
333        assert_eq!(grid2.lines[0].logical_line_index, 1);
334        assert_eq!(grid2.lines[1].logical_line_index, 2);
335    }
336
337    #[test]
338    fn test_get_headless_grid_soft_wrap_single_line() {
339        let generator = SnapshotGenerator::from_text("abcd", 2);
340
341        let grid = generator.get_headless_grid(0, 10);
342        assert_eq!(grid.actual_line_count(), 2);
343
344        let line0_text: String = grid.lines[0].cells.iter().map(|c| c.ch).collect();
345        let line1_text: String = grid.lines[1].cells.iter().map(|c| c.ch).collect();
346
347        assert_eq!(grid.lines[0].logical_line_index, 0);
348        assert!(!grid.lines[0].is_wrapped_part);
349        assert_eq!(line0_text, "ab");
350
351        assert_eq!(grid.lines[1].logical_line_index, 0);
352        assert!(grid.lines[1].is_wrapped_part);
353        assert_eq!(line1_text, "cd");
354
355        // Starting from the 2nd visual line, get 1 line, should only return the wrapped part.
356        let grid2 = generator.get_headless_grid(1, 1);
357        assert_eq!(grid2.actual_line_count(), 1);
358        assert_eq!(grid2.lines[0].logical_line_index, 0);
359        assert!(grid2.lines[0].is_wrapped_part);
360        let text2: String = grid2.lines[0].cells.iter().map(|c| c.ch).collect();
361        assert_eq!(text2, "cd");
362    }
363
364    #[test]
365    fn test_grid_with_cjk() {
366        let text = "Hello\n你好世界\nRust";
367        let generator = SnapshotGenerator::from_text(text, 80);
368
369        let grid = generator.get_headless_grid(1, 1);
370        let line = &grid.lines[0];
371
372        assert_eq!(line.cells.len(), 4); // 4 CJK characters
373        assert_eq!(line.visual_width(), 8); // Each CJK character 2 cells
374
375        // Verify width of each character
376        assert_eq!(line.cells[0].ch, '你');
377        assert_eq!(line.cells[0].width, 2);
378        assert_eq!(line.cells[1].ch, '好');
379        assert_eq!(line.cells[1].width, 2);
380    }
381
382    #[test]
383    fn test_grid_with_emoji() {
384        let text = "Hello 👋\nWorld 🌍";
385        let generator = SnapshotGenerator::from_text(text, 80);
386
387        let grid = generator.get_headless_grid(0, 2);
388        assert_eq!(grid.actual_line_count(), 2);
389
390        // First line: "Hello 👋"
391        let line0 = &grid.lines[0];
392        assert_eq!(line0.cells.len(), 7); // H,e,l,l,o,space,👋
393        // "Hello " = 6, "👋" = 2
394        assert_eq!(line0.visual_width(), 8);
395    }
396
397    #[test]
398    fn test_grid_bounds() {
399        let text = "Line 1\nLine 2\nLine 3";
400        let generator = SnapshotGenerator::from_text(text, 80);
401
402        // Request lines beyond range
403        let grid = generator.get_headless_grid(1, 10);
404        // Should only return lines that actually exist
405        assert_eq!(grid.actual_line_count(), 2); // Only Line 2 and Line 3
406
407        // Completely out of range
408        let grid2 = generator.get_headless_grid(10, 5);
409        assert_eq!(grid2.actual_line_count(), 0);
410    }
411
412    #[test]
413    fn test_empty_document() {
414        let generator = SnapshotGenerator::new(80);
415        let grid = generator.get_headless_grid(0, 10);
416        assert_eq!(grid.actual_line_count(), 1);
417    }
418
419    #[test]
420    fn test_viewport_width_change() {
421        let text = "Hello World";
422        let mut generator = SnapshotGenerator::from_text(text, 40);
423
424        assert_eq!(generator.viewport_width, 40);
425
426        generator.set_viewport_width(20);
427        assert_eq!(generator.viewport_width, 20);
428        // Changing width should trigger soft wrap reflow (using a shorter width here to verify wrapping).
429        generator.set_viewport_width(5);
430        let grid = generator.get_headless_grid(0, 10);
431        assert!(grid.actual_line_count() > 1);
432    }
433}