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