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::{LayoutEngine, char_width};
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    /// Soft wrap layout engine (for logical line <-> visual line conversion)
108    layout_engine: LayoutEngine,
109}
110
111impl SnapshotGenerator {
112    /// Create a new generator for an empty document.
113    pub fn new(viewport_width: usize) -> Self {
114        let lines = vec![String::new()];
115        let mut layout_engine = LayoutEngine::new(viewport_width);
116        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
117        layout_engine.from_lines(&line_refs);
118
119        Self {
120            // Maintain consistency with common editor semantics: an empty document also has 1 empty line.
121            lines,
122            viewport_width,
123            layout_engine,
124        }
125    }
126
127    /// Initialize from text
128    pub fn from_text(text: &str, viewport_width: usize) -> Self {
129        let lines = crate::text::split_lines_preserve_trailing(text);
130        let mut layout_engine = LayoutEngine::new(viewport_width);
131        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
132        layout_engine.from_lines(&line_refs);
133        Self {
134            lines,
135            viewport_width,
136            layout_engine,
137        }
138    }
139
140    /// Update document content
141    pub fn set_lines(&mut self, lines: Vec<String>) {
142        self.lines = if lines.is_empty() {
143            vec![String::new()]
144        } else {
145            lines
146        };
147
148        let line_refs: Vec<&str> = self.lines.iter().map(|s| s.as_str()).collect();
149        self.layout_engine.from_lines(&line_refs);
150    }
151
152    /// Set viewport width
153    pub fn set_viewport_width(&mut self, width: usize) {
154        self.viewport_width = width;
155        self.layout_engine.set_viewport_width(width);
156    }
157
158    /// Get headless grid snapshot
159    ///
160    /// This is the core API, returning visual line data for the specified range
161    pub fn get_headless_grid(&self, start_visual_row: usize, count: usize) -> HeadlessGrid {
162        let mut grid = HeadlessGrid::new(start_visual_row, count);
163
164        if count == 0 {
165            return grid;
166        }
167
168        let total_visual = self.layout_engine.visual_line_count();
169        if start_visual_row >= total_visual {
170            return grid;
171        }
172
173        let end_visual = start_visual_row.saturating_add(count).min(total_visual);
174        let mut current_visual = 0usize;
175
176        for logical_line in 0..self.layout_engine.logical_line_count() {
177            let Some(layout) = self.layout_engine.get_line_layout(logical_line) else {
178                continue;
179            };
180
181            let line_text = self
182                .lines
183                .get(logical_line)
184                .map(|s| s.as_str())
185                .unwrap_or("");
186            let line_char_len = line_text.chars().count();
187
188            for visual_in_line in 0..layout.visual_line_count {
189                if current_visual >= end_visual {
190                    return grid;
191                }
192
193                if current_visual >= start_visual_row {
194                    let segment_start_col = if visual_in_line == 0 {
195                        0
196                    } else {
197                        layout
198                            .wrap_points
199                            .get(visual_in_line - 1)
200                            .map(|wp| wp.char_index)
201                            .unwrap_or(0)
202                            .min(line_char_len)
203                    };
204
205                    let segment_end_col = if visual_in_line < layout.wrap_points.len() {
206                        layout.wrap_points[visual_in_line]
207                            .char_index
208                            .min(line_char_len)
209                    } else {
210                        line_char_len
211                    };
212
213                    let mut headless_line = HeadlessLine::new(logical_line, visual_in_line > 0);
214                    for ch in line_text
215                        .chars()
216                        .skip(segment_start_col)
217                        .take(segment_end_col.saturating_sub(segment_start_col))
218                    {
219                        headless_line.add_cell(Cell::new(ch, char_width(ch)));
220                    }
221
222                    grid.add_line(headless_line);
223                }
224
225                current_visual = current_visual.saturating_add(1);
226            }
227        }
228
229        grid
230    }
231
232    /// Get content of a specific logical line
233    pub fn get_line(&self, line_index: usize) -> Option<&str> {
234        self.lines.get(line_index).map(|s| s.as_str())
235    }
236
237    /// Get total number of logical lines
238    pub fn line_count(&self) -> usize {
239        self.lines.len()
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_cell_creation() {
249        let cell = Cell::new('a', 1);
250        assert_eq!(cell.ch, 'a');
251        assert_eq!(cell.width, 1);
252        assert!(cell.styles.is_empty());
253    }
254
255    #[test]
256    fn test_cell_with_styles() {
257        let cell = Cell::with_styles('你', 2, vec![1, 2, 3]);
258        assert_eq!(cell.ch, '你');
259        assert_eq!(cell.width, 2);
260        assert_eq!(cell.styles, vec![1, 2, 3]);
261    }
262
263    #[test]
264    fn test_headless_line() {
265        let mut line = HeadlessLine::new(0, false);
266        line.add_cell(Cell::new('H', 1));
267        line.add_cell(Cell::new('e', 1));
268        line.add_cell(Cell::new('你', 2));
269
270        assert_eq!(line.logical_line_index, 0);
271        assert!(!line.is_wrapped_part);
272        assert_eq!(line.cells.len(), 3);
273        assert_eq!(line.visual_width(), 4); // 1 + 1 + 2
274    }
275
276    #[test]
277    fn test_snapshot_generator_basic() {
278        let text = "Hello\nWorld\nRust";
279        let generator = SnapshotGenerator::from_text(text, 80);
280
281        assert_eq!(generator.line_count(), 3);
282        assert_eq!(generator.get_line(0), Some("Hello"));
283        assert_eq!(generator.get_line(1), Some("World"));
284        assert_eq!(generator.get_line(2), Some("Rust"));
285    }
286
287    #[test]
288    fn test_get_headless_grid() {
289        let text = "Line 1\nLine 2\nLine 3\nLine 4";
290        let generator = SnapshotGenerator::from_text(text, 80);
291
292        // Get first 2 lines
293        let grid = generator.get_headless_grid(0, 2);
294        assert_eq!(grid.start_visual_row, 0);
295        assert_eq!(grid.count, 2);
296        assert_eq!(grid.actual_line_count(), 2);
297
298        // Verify first line
299        let line0 = &grid.lines[0];
300        assert_eq!(line0.logical_line_index, 0);
301        assert!(!line0.is_wrapped_part);
302        assert_eq!(line0.cells.len(), 6); // "Line 1"
303
304        // Get middle lines
305        let grid2 = generator.get_headless_grid(1, 2);
306        assert_eq!(grid2.actual_line_count(), 2);
307        assert_eq!(grid2.lines[0].logical_line_index, 1);
308        assert_eq!(grid2.lines[1].logical_line_index, 2);
309    }
310
311    #[test]
312    fn test_get_headless_grid_soft_wrap_single_line() {
313        let generator = SnapshotGenerator::from_text("abcd", 2);
314
315        let grid = generator.get_headless_grid(0, 10);
316        assert_eq!(grid.actual_line_count(), 2);
317
318        let line0_text: String = grid.lines[0].cells.iter().map(|c| c.ch).collect();
319        let line1_text: String = grid.lines[1].cells.iter().map(|c| c.ch).collect();
320
321        assert_eq!(grid.lines[0].logical_line_index, 0);
322        assert!(!grid.lines[0].is_wrapped_part);
323        assert_eq!(line0_text, "ab");
324
325        assert_eq!(grid.lines[1].logical_line_index, 0);
326        assert!(grid.lines[1].is_wrapped_part);
327        assert_eq!(line1_text, "cd");
328
329        // Starting from the 2nd visual line, get 1 line, should only return the wrapped part.
330        let grid2 = generator.get_headless_grid(1, 1);
331        assert_eq!(grid2.actual_line_count(), 1);
332        assert_eq!(grid2.lines[0].logical_line_index, 0);
333        assert!(grid2.lines[0].is_wrapped_part);
334        let text2: String = grid2.lines[0].cells.iter().map(|c| c.ch).collect();
335        assert_eq!(text2, "cd");
336    }
337
338    #[test]
339    fn test_grid_with_cjk() {
340        let text = "Hello\n你好世界\nRust";
341        let generator = SnapshotGenerator::from_text(text, 80);
342
343        let grid = generator.get_headless_grid(1, 1);
344        let line = &grid.lines[0];
345
346        assert_eq!(line.cells.len(), 4); // 4 CJK characters
347        assert_eq!(line.visual_width(), 8); // Each CJK character 2 cells
348
349        // Verify width of each character
350        assert_eq!(line.cells[0].ch, '你');
351        assert_eq!(line.cells[0].width, 2);
352        assert_eq!(line.cells[1].ch, '好');
353        assert_eq!(line.cells[1].width, 2);
354    }
355
356    #[test]
357    fn test_grid_with_emoji() {
358        let text = "Hello 👋\nWorld 🌍";
359        let generator = SnapshotGenerator::from_text(text, 80);
360
361        let grid = generator.get_headless_grid(0, 2);
362        assert_eq!(grid.actual_line_count(), 2);
363
364        // First line: "Hello 👋"
365        let line0 = &grid.lines[0];
366        assert_eq!(line0.cells.len(), 7); // H,e,l,l,o,space,👋
367        // "Hello " = 6, "👋" = 2
368        assert_eq!(line0.visual_width(), 8);
369    }
370
371    #[test]
372    fn test_grid_bounds() {
373        let text = "Line 1\nLine 2\nLine 3";
374        let generator = SnapshotGenerator::from_text(text, 80);
375
376        // Request lines beyond range
377        let grid = generator.get_headless_grid(1, 10);
378        // Should only return lines that actually exist
379        assert_eq!(grid.actual_line_count(), 2); // Only Line 2 and Line 3
380
381        // Completely out of range
382        let grid2 = generator.get_headless_grid(10, 5);
383        assert_eq!(grid2.actual_line_count(), 0);
384    }
385
386    #[test]
387    fn test_empty_document() {
388        let generator = SnapshotGenerator::new(80);
389        let grid = generator.get_headless_grid(0, 10);
390        assert_eq!(grid.actual_line_count(), 1);
391    }
392
393    #[test]
394    fn test_viewport_width_change() {
395        let text = "Hello World";
396        let mut generator = SnapshotGenerator::from_text(text, 40);
397
398        assert_eq!(generator.viewport_width, 40);
399
400        generator.set_viewport_width(20);
401        assert_eq!(generator.viewport_width, 20);
402        // Changing width should trigger soft wrap reflow (using a shorter width here to verify wrapping).
403        generator.set_viewport_width(5);
404        let grid = generator.get_headless_grid(0, 10);
405        assert!(grid.actual_line_count() > 1);
406    }
407}