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    /// Which wrapped segment within the logical line (0-based).
46    pub visual_in_logical: usize,
47    /// Character offset (inclusive) of this segment in the document.
48    pub char_offset_start: usize,
49    /// Character offset (exclusive) of this segment in the document.
50    pub char_offset_end: usize,
51    /// Render x (in cells) where document text of this segment starts within the visual line.
52    ///
53    /// For wrapped segments this is typically the wrap-indent cells.
54    pub segment_x_start_cells: usize,
55    /// Whether a fold placeholder was appended to this segment.
56    pub is_fold_placeholder_appended: bool,
57    /// List of cells
58    pub cells: Vec<Cell>,
59}
60
61impl HeadlessLine {
62    /// Create an empty headless line.
63    pub fn new(logical_line_index: usize, is_wrapped_part: bool) -> Self {
64        Self {
65            logical_line_index,
66            is_wrapped_part,
67            visual_in_logical: if is_wrapped_part { 1 } else { 0 },
68            char_offset_start: 0,
69            char_offset_end: 0,
70            segment_x_start_cells: 0,
71            is_fold_placeholder_appended: false,
72            cells: Vec::new(),
73        }
74    }
75
76    /// Fill visual segment metadata for this line.
77    pub fn set_visual_metadata(
78        &mut self,
79        visual_in_logical: usize,
80        char_offset_start: usize,
81        char_offset_end: usize,
82        segment_x_start_cells: usize,
83    ) {
84        self.visual_in_logical = visual_in_logical;
85        self.char_offset_start = char_offset_start;
86        self.char_offset_end = char_offset_end;
87        self.segment_x_start_cells = segment_x_start_cells;
88    }
89
90    /// Mark whether this line has fold placeholder text appended.
91    pub fn set_fold_placeholder_appended(&mut self, appended: bool) {
92        self.is_fold_placeholder_appended = appended;
93    }
94
95    /// Append a cell to the line.
96    pub fn add_cell(&mut self, cell: Cell) {
97        self.cells.push(cell);
98    }
99
100    /// Get total visual width of this line
101    pub fn visual_width(&self) -> usize {
102        self.cells.iter().map(|c| c.width).sum()
103    }
104}
105
106/// Headless grid snapshot
107#[derive(Debug, Clone)]
108pub struct HeadlessGrid {
109    /// List of visual lines
110    pub lines: Vec<HeadlessLine>,
111    /// Starting visual row number
112    pub start_visual_row: usize,
113    /// Number of lines requested
114    pub count: usize,
115}
116
117impl HeadlessGrid {
118    /// Create an empty grid snapshot for a requested visual range.
119    pub fn new(start_visual_row: usize, count: usize) -> Self {
120        Self {
121            lines: Vec::new(),
122            start_visual_row,
123            count,
124        }
125    }
126
127    /// Append a visual line to the grid.
128    pub fn add_line(&mut self, line: HeadlessLine) {
129        self.lines.push(line);
130    }
131
132    /// Get actual number of lines returned
133    pub fn actual_line_count(&self) -> usize {
134        self.lines.len()
135    }
136}
137
138/// A lightweight minimap summary for one visual line.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct MinimapLine {
141    /// Corresponding logical line index.
142    pub logical_line_index: usize,
143    /// Which wrapped segment within the logical line (0-based).
144    pub visual_in_logical: usize,
145    /// Character offset (inclusive) of this segment in the document.
146    pub char_offset_start: usize,
147    /// Character offset (exclusive) of this segment in the document.
148    pub char_offset_end: usize,
149    /// Total rendered cell width for this visual line (including wrap indent and fold placeholder).
150    pub total_cells: usize,
151    /// Number of non-whitespace rendered cells.
152    pub non_whitespace_cells: usize,
153    /// Dominant style id on this line (if any style exists).
154    pub dominant_style: Option<StyleId>,
155    /// Whether a fold placeholder was appended.
156    pub is_fold_placeholder_appended: bool,
157}
158
159/// Lightweight minimap snapshot.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct MinimapGrid {
162    /// Minimap lines.
163    pub lines: Vec<MinimapLine>,
164    /// Requested start row.
165    pub start_visual_row: usize,
166    /// Requested row count.
167    pub count: usize,
168}
169
170impl MinimapGrid {
171    /// Create an empty minimap grid for a requested visual range.
172    pub fn new(start_visual_row: usize, count: usize) -> Self {
173        Self {
174            lines: Vec::new(),
175            start_visual_row,
176            count,
177        }
178    }
179
180    /// Get actual number of lines returned.
181    pub fn actual_line_count(&self) -> usize {
182        self.lines.len()
183    }
184}
185
186/// A cell in a composed (decoration-aware) snapshot.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ComposedCell {
189    /// The rendered character.
190    pub ch: char,
191    /// The rendered cell width (typically 1 or 2).
192    pub width: usize,
193    /// Style ids applied to this cell.
194    pub styles: Vec<crate::intervals::StyleId>,
195    /// Where this cell originated from (document text vs virtual text).
196    pub source: ComposedCellSource,
197}
198
199/// The origin of a composed cell.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum ComposedCellSource {
202    /// A document text character at the given character offset.
203    Document {
204        /// Character offset (Unicode scalar values) from the start of the document.
205        offset: usize,
206    },
207    /// A virtual cell anchored to a document character offset (e.g. inlay hints, code lens).
208    Virtual {
209        /// Anchor character offset in the document.
210        anchor_offset: usize,
211    },
212}
213
214/// The kind of a composed visual line.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum ComposedLineKind {
217    /// A line segment that corresponds to actual document text (wrap + folding aware).
218    Document {
219        /// Logical line index.
220        logical_line: usize,
221        /// Which wrapped segment within the logical line (0-based).
222        visual_in_logical: usize,
223    },
224    /// A virtual line inserted above a logical line (e.g. code lens).
225    VirtualAboveLine {
226        /// Logical line index that this virtual line is associated with.
227        logical_line: usize,
228    },
229}
230
231/// A decoration-aware visual line (document segment or virtual text line).
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct ComposedLine {
234    /// Line kind / anchor info.
235    pub kind: ComposedLineKind,
236    /// Rendered cells for this line.
237    pub cells: Vec<ComposedCell>,
238}
239
240/// A decoration-aware snapshot that can include virtual text (inlay hints, code lens, ...).
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct ComposedGrid {
243    /// Composed visual lines.
244    pub lines: Vec<ComposedLine>,
245    /// Requested start row (in composed visual rows).
246    pub start_visual_row: usize,
247    /// Requested row count.
248    pub count: usize,
249}
250
251impl ComposedGrid {
252    /// Create an empty composed grid snapshot for a requested visual range.
253    pub fn new(start_visual_row: usize, count: usize) -> Self {
254        Self {
255            lines: Vec::new(),
256            start_visual_row,
257            count,
258        }
259    }
260
261    /// Get the actual number of lines returned.
262    pub fn actual_line_count(&self) -> usize {
263        self.lines.len()
264    }
265}
266
267/// Headless snapshot generator
268///
269/// Integrates all components to generate snapshots needed for UI rendering
270pub struct SnapshotGenerator {
271    /// Document content (stored by lines)
272    lines: Vec<String>,
273    /// Viewport width
274    viewport_width: usize,
275    /// Tab width (in cells) used to expand `'\t'` during layout/measurement.
276    tab_width: usize,
277    /// Soft wrap layout engine (for logical line <-> visual line conversion)
278    layout_engine: LayoutEngine,
279}
280
281impl SnapshotGenerator {
282    /// Create a new generator for an empty document.
283    pub fn new(viewport_width: usize) -> Self {
284        let lines = vec![String::new()];
285        let mut layout_engine = LayoutEngine::new(viewport_width);
286        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
287        layout_engine.from_lines(&line_refs);
288
289        Self {
290            // Maintain consistency with common editor semantics: an empty document also has 1 empty line.
291            lines,
292            viewport_width,
293            tab_width: layout_engine.tab_width(),
294            layout_engine,
295        }
296    }
297
298    /// Initialize from text
299    pub fn from_text(text: &str, viewport_width: usize) -> Self {
300        Self::from_text_with_tab_width(text, viewport_width, DEFAULT_TAB_WIDTH)
301    }
302
303    /// Initialize from text, with explicit `tab_width` (in cells) for expanding `'\t'`.
304    pub fn from_text_with_tab_width(text: &str, viewport_width: usize, tab_width: usize) -> Self {
305        Self::from_text_with_options(text, viewport_width, tab_width, WrapMode::Char)
306    }
307
308    /// Initialize from text, with explicit options.
309    pub fn from_text_with_options(
310        text: &str,
311        viewport_width: usize,
312        tab_width: usize,
313        wrap_mode: WrapMode,
314    ) -> Self {
315        Self::from_text_with_layout_options(
316            text,
317            viewport_width,
318            tab_width,
319            wrap_mode,
320            WrapIndent::None,
321        )
322    }
323
324    /// Initialize from text, with explicit layout options.
325    pub fn from_text_with_layout_options(
326        text: &str,
327        viewport_width: usize,
328        tab_width: usize,
329        wrap_mode: WrapMode,
330        wrap_indent: WrapIndent,
331    ) -> Self {
332        let normalized = crate::text::normalize_crlf_to_lf(text);
333        let lines = crate::text::split_lines_preserve_trailing(normalized.as_ref());
334        let mut layout_engine = LayoutEngine::new(viewport_width);
335        layout_engine.set_tab_width(tab_width);
336        layout_engine.set_wrap_mode(wrap_mode);
337        layout_engine.set_wrap_indent(wrap_indent);
338        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
339        layout_engine.from_lines(&line_refs);
340        Self {
341            lines,
342            viewport_width,
343            tab_width: layout_engine.tab_width(),
344            layout_engine,
345        }
346    }
347
348    /// Update document content
349    pub fn set_lines(&mut self, lines: Vec<String>) {
350        self.lines = if lines.is_empty() {
351            vec![String::new()]
352        } else {
353            lines
354        };
355
356        let line_refs: Vec<&str> = self.lines.iter().map(|s| s.as_str()).collect();
357        self.layout_engine.from_lines(&line_refs);
358    }
359
360    /// Set viewport width
361    pub fn set_viewport_width(&mut self, width: usize) {
362        self.viewport_width = width;
363        self.layout_engine.set_viewport_width(width);
364    }
365
366    /// Set tab width (in cells) used for expanding `'\t'`.
367    pub fn set_tab_width(&mut self, tab_width: usize) {
368        self.tab_width = tab_width.max(1);
369        self.layout_engine.set_tab_width(self.tab_width);
370    }
371
372    /// Get tab width (in cells).
373    pub fn tab_width(&self) -> usize {
374        self.tab_width
375    }
376
377    /// Get headless grid snapshot
378    ///
379    /// This is the core API, returning visual line data for the specified range
380    pub fn get_headless_grid(&self, start_visual_row: usize, count: usize) -> HeadlessGrid {
381        let mut grid = HeadlessGrid::new(start_visual_row, count);
382
383        if count == 0 {
384            return grid;
385        }
386
387        let total_visual = self.layout_engine.visual_line_count();
388        if start_visual_row >= total_visual {
389            return grid;
390        }
391
392        let end_visual = start_visual_row.saturating_add(count).min(total_visual);
393        let mut current_visual = 0usize;
394
395        let mut line_start_offset = 0usize;
396        for logical_line in 0..self.layout_engine.logical_line_count() {
397            let Some(layout) = self.layout_engine.get_line_layout(logical_line) else {
398                continue;
399            };
400
401            let line_text = self
402                .lines
403                .get(logical_line)
404                .map(|s| s.as_str())
405                .unwrap_or("");
406            let line_char_len = line_text.chars().count();
407
408            for visual_in_line in 0..layout.visual_line_count {
409                if current_visual >= end_visual {
410                    return grid;
411                }
412
413                if current_visual >= start_visual_row {
414                    let segment_start_col = if visual_in_line == 0 {
415                        0
416                    } else {
417                        layout
418                            .wrap_points
419                            .get(visual_in_line - 1)
420                            .map(|wp| wp.char_index)
421                            .unwrap_or(0)
422                            .min(line_char_len)
423                    };
424
425                    let segment_end_col = if visual_in_line < layout.wrap_points.len() {
426                        layout.wrap_points[visual_in_line]
427                            .char_index
428                            .min(line_char_len)
429                    } else {
430                        line_char_len
431                    };
432
433                    let mut headless_line = HeadlessLine::new(logical_line, visual_in_line > 0);
434                    let mut segment_x_start_cells = 0usize;
435                    if visual_in_line > 0 {
436                        let indent_cells = wrap_indent_cells_for_line_text(
437                            line_text,
438                            self.layout_engine.wrap_indent(),
439                            self.viewport_width,
440                            self.tab_width,
441                        );
442                        segment_x_start_cells = indent_cells;
443                        for _ in 0..indent_cells {
444                            headless_line.add_cell(Cell::new(' ', 1));
445                        }
446                    }
447                    let seg_start_x_in_line =
448                        visual_x_for_column(line_text, segment_start_col, self.tab_width);
449                    let mut x_in_line = seg_start_x_in_line;
450                    for ch in line_text
451                        .chars()
452                        .skip(segment_start_col)
453                        .take(segment_end_col.saturating_sub(segment_start_col))
454                    {
455                        let w = cell_width_at(ch, x_in_line, self.tab_width);
456                        x_in_line = x_in_line.saturating_add(w);
457                        headless_line.add_cell(Cell::new(ch, w));
458                    }
459                    headless_line.set_visual_metadata(
460                        visual_in_line,
461                        line_start_offset.saturating_add(segment_start_col),
462                        line_start_offset.saturating_add(segment_end_col),
463                        segment_x_start_cells,
464                    );
465
466                    grid.add_line(headless_line);
467                }
468
469                current_visual = current_visual.saturating_add(1);
470            }
471
472            line_start_offset = line_start_offset.saturating_add(line_char_len);
473            if logical_line + 1 < self.layout_engine.logical_line_count() {
474                line_start_offset = line_start_offset.saturating_add(1);
475            }
476        }
477
478        grid
479    }
480
481    /// Get content of a specific logical line
482    pub fn get_line(&self, line_index: usize) -> Option<&str> {
483        self.lines.get(line_index).map(|s| s.as_str())
484    }
485
486    /// Get total number of logical lines
487    pub fn line_count(&self) -> usize {
488        self.lines.len()
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_cell_creation() {
498        let cell = Cell::new('a', 1);
499        assert_eq!(cell.ch, 'a');
500        assert_eq!(cell.width, 1);
501        assert!(cell.styles.is_empty());
502    }
503
504    #[test]
505    fn test_cell_with_styles() {
506        let cell = Cell::with_styles('你', 2, vec![1, 2, 3]);
507        assert_eq!(cell.ch, '你');
508        assert_eq!(cell.width, 2);
509        assert_eq!(cell.styles, vec![1, 2, 3]);
510    }
511
512    #[test]
513    fn test_headless_line() {
514        let mut line = HeadlessLine::new(0, false);
515        line.add_cell(Cell::new('H', 1));
516        line.add_cell(Cell::new('e', 1));
517        line.add_cell(Cell::new('你', 2));
518
519        assert_eq!(line.logical_line_index, 0);
520        assert!(!line.is_wrapped_part);
521        assert_eq!(line.visual_in_logical, 0);
522        assert_eq!(line.char_offset_start, 0);
523        assert_eq!(line.char_offset_end, 0);
524        assert_eq!(line.segment_x_start_cells, 0);
525        assert!(!line.is_fold_placeholder_appended);
526        assert_eq!(line.cells.len(), 3);
527        assert_eq!(line.visual_width(), 4); // 1 + 1 + 2
528    }
529
530    #[test]
531    fn test_snapshot_generator_basic() {
532        let text = "Hello\nWorld\nRust";
533        let generator = SnapshotGenerator::from_text(text, 80);
534
535        assert_eq!(generator.line_count(), 3);
536        assert_eq!(generator.get_line(0), Some("Hello"));
537        assert_eq!(generator.get_line(1), Some("World"));
538        assert_eq!(generator.get_line(2), Some("Rust"));
539    }
540
541    #[test]
542    fn test_get_headless_grid() {
543        let text = "Line 1\nLine 2\nLine 3\nLine 4";
544        let generator = SnapshotGenerator::from_text(text, 80);
545
546        // Get first 2 lines
547        let grid = generator.get_headless_grid(0, 2);
548        assert_eq!(grid.start_visual_row, 0);
549        assert_eq!(grid.count, 2);
550        assert_eq!(grid.actual_line_count(), 2);
551
552        // Verify first line
553        let line0 = &grid.lines[0];
554        assert_eq!(line0.logical_line_index, 0);
555        assert!(!line0.is_wrapped_part);
556        assert_eq!(line0.visual_in_logical, 0);
557        assert_eq!(line0.char_offset_start, 0);
558        assert_eq!(line0.char_offset_end, 6);
559        assert_eq!(line0.cells.len(), 6); // "Line 1"
560
561        // Get middle lines
562        let grid2 = generator.get_headless_grid(1, 2);
563        assert_eq!(grid2.actual_line_count(), 2);
564        assert_eq!(grid2.lines[0].logical_line_index, 1);
565        assert_eq!(grid2.lines[1].logical_line_index, 2);
566    }
567
568    #[test]
569    fn test_get_headless_grid_soft_wrap_single_line() {
570        let generator = SnapshotGenerator::from_text("abcd", 2);
571
572        let grid = generator.get_headless_grid(0, 10);
573        assert_eq!(grid.actual_line_count(), 2);
574
575        let line0_text: String = grid.lines[0].cells.iter().map(|c| c.ch).collect();
576        let line1_text: String = grid.lines[1].cells.iter().map(|c| c.ch).collect();
577
578        assert_eq!(grid.lines[0].logical_line_index, 0);
579        assert!(!grid.lines[0].is_wrapped_part);
580        assert_eq!(grid.lines[0].visual_in_logical, 0);
581        assert_eq!(line0_text, "ab");
582
583        assert_eq!(grid.lines[1].logical_line_index, 0);
584        assert!(grid.lines[1].is_wrapped_part);
585        assert_eq!(grid.lines[1].visual_in_logical, 1);
586        assert_eq!(line1_text, "cd");
587
588        // Starting from the 2nd visual line, get 1 line, should only return the wrapped part.
589        let grid2 = generator.get_headless_grid(1, 1);
590        assert_eq!(grid2.actual_line_count(), 1);
591        assert_eq!(grid2.lines[0].logical_line_index, 0);
592        assert!(grid2.lines[0].is_wrapped_part);
593        let text2: String = grid2.lines[0].cells.iter().map(|c| c.ch).collect();
594        assert_eq!(text2, "cd");
595    }
596
597    #[test]
598    fn test_grid_with_cjk() {
599        let text = "Hello\n你好世界\nRust";
600        let generator = SnapshotGenerator::from_text(text, 80);
601
602        let grid = generator.get_headless_grid(1, 1);
603        let line = &grid.lines[0];
604
605        assert_eq!(line.cells.len(), 4); // 4 CJK characters
606        assert_eq!(line.visual_width(), 8); // Each CJK character 2 cells
607
608        // Verify width of each character
609        assert_eq!(line.cells[0].ch, '你');
610        assert_eq!(line.cells[0].width, 2);
611        assert_eq!(line.cells[1].ch, '好');
612        assert_eq!(line.cells[1].width, 2);
613    }
614
615    #[test]
616    fn test_grid_with_emoji() {
617        let text = "Hello 👋\nWorld 🌍";
618        let generator = SnapshotGenerator::from_text(text, 80);
619
620        let grid = generator.get_headless_grid(0, 2);
621        assert_eq!(grid.actual_line_count(), 2);
622
623        // First line: "Hello 👋"
624        let line0 = &grid.lines[0];
625        assert_eq!(line0.cells.len(), 7); // H,e,l,l,o,space,👋
626        // "Hello " = 6, "👋" = 2
627        assert_eq!(line0.visual_width(), 8);
628    }
629
630    #[test]
631    fn test_grid_bounds() {
632        let text = "Line 1\nLine 2\nLine 3";
633        let generator = SnapshotGenerator::from_text(text, 80);
634
635        // Request lines beyond range
636        let grid = generator.get_headless_grid(1, 10);
637        // Should only return lines that actually exist
638        assert_eq!(grid.actual_line_count(), 2); // Only Line 2 and Line 3
639
640        // Completely out of range
641        let grid2 = generator.get_headless_grid(10, 5);
642        assert_eq!(grid2.actual_line_count(), 0);
643    }
644
645    #[test]
646    fn test_empty_document() {
647        let generator = SnapshotGenerator::new(80);
648        let grid = generator.get_headless_grid(0, 10);
649        assert_eq!(grid.actual_line_count(), 1);
650    }
651
652    #[test]
653    fn test_viewport_width_change() {
654        let text = "Hello World";
655        let mut generator = SnapshotGenerator::from_text(text, 40);
656
657        assert_eq!(generator.viewport_width, 40);
658
659        generator.set_viewport_width(20);
660        assert_eq!(generator.viewport_width, 20);
661        // Changing width should trigger soft wrap reflow (using a shorter width here to verify wrapping).
662        generator.set_viewport_width(5);
663        let grid = generator.get_headless_grid(0, 10);
664        assert!(grid.actual_line_count() > 1);
665    }
666}