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    /// Character offset (inclusive) of this visual line segment in the document.
237    ///
238    /// For [`ComposedLineKind::Document`], this is the segment start offset.
239    /// For [`ComposedLineKind::VirtualAboveLine`], this is the anchor offset (typically the start
240    /// of the associated logical line).
241    pub char_offset_start: usize,
242    /// Character offset (exclusive) of this visual line segment in the document.
243    ///
244    /// For [`ComposedLineKind::Document`], this is the segment end offset.
245    /// For [`ComposedLineKind::VirtualAboveLine`], this equals [`Self::char_offset_start`].
246    pub char_offset_end: usize,
247    /// Rendered cells for this line.
248    pub cells: Vec<ComposedCell>,
249}
250
251/// A decoration-aware snapshot that can include virtual text (inlay hints, code lens, ...).
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct ComposedGrid {
254    /// Composed visual lines.
255    pub lines: Vec<ComposedLine>,
256    /// Requested start row (in composed visual rows).
257    pub start_visual_row: usize,
258    /// Requested row count.
259    pub count: usize,
260}
261
262impl ComposedGrid {
263    /// Create an empty composed grid snapshot for a requested visual range.
264    pub fn new(start_visual_row: usize, count: usize) -> Self {
265        Self {
266            lines: Vec::new(),
267            start_visual_row,
268            count,
269        }
270    }
271
272    /// Get the actual number of lines returned.
273    pub fn actual_line_count(&self) -> usize {
274        self.lines.len()
275    }
276}
277
278/// Headless snapshot generator
279///
280/// Integrates all components to generate snapshots needed for UI rendering
281pub struct SnapshotGenerator {
282    /// Document content (stored by lines)
283    lines: Vec<String>,
284    /// Viewport width
285    viewport_width: usize,
286    /// Tab width (in cells) used to expand `'\t'` during layout/measurement.
287    tab_width: usize,
288    /// Soft wrap layout engine (for logical line <-> visual line conversion)
289    layout_engine: LayoutEngine,
290}
291
292impl SnapshotGenerator {
293    /// Create a new generator for an empty document.
294    pub fn new(viewport_width: usize) -> Self {
295        let lines = vec![String::new()];
296        let mut layout_engine = LayoutEngine::new(viewport_width);
297        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
298        layout_engine.from_lines(&line_refs);
299
300        Self {
301            // Maintain consistency with common editor semantics: an empty document also has 1 empty line.
302            lines,
303            viewport_width,
304            tab_width: layout_engine.tab_width(),
305            layout_engine,
306        }
307    }
308
309    /// Initialize from text
310    pub fn from_text(text: &str, viewport_width: usize) -> Self {
311        Self::from_text_with_tab_width(text, viewport_width, DEFAULT_TAB_WIDTH)
312    }
313
314    /// Initialize from text, with explicit `tab_width` (in cells) for expanding `'\t'`.
315    pub fn from_text_with_tab_width(text: &str, viewport_width: usize, tab_width: usize) -> Self {
316        Self::from_text_with_options(text, viewport_width, tab_width, WrapMode::Char)
317    }
318
319    /// Initialize from text, with explicit options.
320    pub fn from_text_with_options(
321        text: &str,
322        viewport_width: usize,
323        tab_width: usize,
324        wrap_mode: WrapMode,
325    ) -> Self {
326        Self::from_text_with_layout_options(
327            text,
328            viewport_width,
329            tab_width,
330            wrap_mode,
331            WrapIndent::None,
332        )
333    }
334
335    /// Initialize from text, with explicit layout options.
336    pub fn from_text_with_layout_options(
337        text: &str,
338        viewport_width: usize,
339        tab_width: usize,
340        wrap_mode: WrapMode,
341        wrap_indent: WrapIndent,
342    ) -> Self {
343        let normalized = crate::text::normalize_crlf_to_lf(text);
344        let lines = crate::text::split_lines_preserve_trailing(normalized.as_ref());
345        let mut layout_engine = LayoutEngine::new(viewport_width);
346        layout_engine.set_tab_width(tab_width);
347        layout_engine.set_wrap_mode(wrap_mode);
348        layout_engine.set_wrap_indent(wrap_indent);
349        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
350        layout_engine.from_lines(&line_refs);
351        Self {
352            lines,
353            viewport_width,
354            tab_width: layout_engine.tab_width(),
355            layout_engine,
356        }
357    }
358
359    /// Update document content
360    pub fn set_lines(&mut self, lines: Vec<String>) {
361        self.lines = if lines.is_empty() {
362            vec![String::new()]
363        } else {
364            lines
365        };
366
367        self.reflow_layout();
368    }
369
370    /// Set viewport width
371    pub fn set_viewport_width(&mut self, width: usize) {
372        self.viewport_width = width;
373        self.layout_engine.set_viewport_width(width);
374        self.reflow_layout();
375    }
376
377    /// Set tab width (in cells) used for expanding `'\t'`.
378    pub fn set_tab_width(&mut self, tab_width: usize) {
379        self.tab_width = tab_width.max(1);
380        self.layout_engine.set_tab_width(self.tab_width);
381        self.reflow_layout();
382    }
383
384    /// Get tab width (in cells).
385    pub fn tab_width(&self) -> usize {
386        self.tab_width
387    }
388
389    fn reflow_layout(&mut self) {
390        self.layout_engine
391            .recalculate_all_from_lines(self.lines.iter().map(String::as_str));
392    }
393
394    /// Get headless grid snapshot
395    ///
396    /// This is the core API, returning visual line data for the specified range
397    pub fn get_headless_grid(&self, start_visual_row: usize, count: usize) -> HeadlessGrid {
398        let mut grid = HeadlessGrid::new(start_visual_row, count);
399
400        if count == 0 {
401            return grid;
402        }
403
404        let total_visual = self.layout_engine.visual_line_count();
405        if start_visual_row >= total_visual {
406            return grid;
407        }
408
409        let end_visual = start_visual_row.saturating_add(count).min(total_visual);
410        let mut current_visual = 0usize;
411
412        let mut line_start_offset = 0usize;
413        for logical_line in 0..self.layout_engine.logical_line_count() {
414            let Some(layout) = self.layout_engine.get_line_layout(logical_line) else {
415                continue;
416            };
417
418            let line_text = self
419                .lines
420                .get(logical_line)
421                .map(|s| s.as_str())
422                .unwrap_or("");
423            let line_char_len = line_text.chars().count();
424
425            for visual_in_line in 0..layout.visual_line_count {
426                if current_visual >= end_visual {
427                    return grid;
428                }
429
430                if current_visual >= start_visual_row {
431                    let segment_start_col = if visual_in_line == 0 {
432                        0
433                    } else {
434                        layout
435                            .wrap_points
436                            .get(visual_in_line - 1)
437                            .map(|wp| wp.char_index)
438                            .unwrap_or(0)
439                            .min(line_char_len)
440                    };
441
442                    let segment_end_col = if visual_in_line < layout.wrap_points.len() {
443                        layout.wrap_points[visual_in_line]
444                            .char_index
445                            .min(line_char_len)
446                    } else {
447                        line_char_len
448                    };
449
450                    let mut headless_line = HeadlessLine::new(logical_line, visual_in_line > 0);
451                    let mut segment_x_start_cells = 0usize;
452                    if visual_in_line > 0 {
453                        let indent_cells = wrap_indent_cells_for_line_text(
454                            line_text,
455                            self.layout_engine.wrap_indent(),
456                            self.viewport_width,
457                            self.tab_width,
458                        );
459                        segment_x_start_cells = indent_cells;
460                        for _ in 0..indent_cells {
461                            headless_line.add_cell(Cell::new(' ', 1));
462                        }
463                    }
464                    let seg_start_x_in_line =
465                        visual_x_for_column(line_text, segment_start_col, self.tab_width);
466                    let mut x_in_line = seg_start_x_in_line;
467                    for ch in line_text
468                        .chars()
469                        .skip(segment_start_col)
470                        .take(segment_end_col.saturating_sub(segment_start_col))
471                    {
472                        let w = cell_width_at(ch, x_in_line, self.tab_width);
473                        x_in_line = x_in_line.saturating_add(w);
474                        headless_line.add_cell(Cell::new(ch, w));
475                    }
476                    headless_line.set_visual_metadata(
477                        visual_in_line,
478                        line_start_offset.saturating_add(segment_start_col),
479                        line_start_offset.saturating_add(segment_end_col),
480                        segment_x_start_cells,
481                    );
482
483                    grid.add_line(headless_line);
484                }
485
486                current_visual = current_visual.saturating_add(1);
487            }
488
489            line_start_offset = line_start_offset.saturating_add(line_char_len);
490            if logical_line + 1 < self.layout_engine.logical_line_count() {
491                line_start_offset = line_start_offset.saturating_add(1);
492            }
493        }
494
495        grid
496    }
497
498    /// Get content of a specific logical line
499    pub fn get_line(&self, line_index: usize) -> Option<&str> {
500        self.lines.get(line_index).map(|s| s.as_str())
501    }
502
503    /// Get total number of logical lines
504    pub fn line_count(&self) -> usize {
505        self.lines.len()
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn test_cell_creation() {
515        let cell = Cell::new('a', 1);
516        assert_eq!(cell.ch, 'a');
517        assert_eq!(cell.width, 1);
518        assert!(cell.styles.is_empty());
519    }
520
521    #[test]
522    fn test_cell_with_styles() {
523        let cell = Cell::with_styles('你', 2, vec![1, 2, 3]);
524        assert_eq!(cell.ch, '你');
525        assert_eq!(cell.width, 2);
526        assert_eq!(cell.styles, vec![1, 2, 3]);
527    }
528
529    #[test]
530    fn test_headless_line() {
531        let mut line = HeadlessLine::new(0, false);
532        line.add_cell(Cell::new('H', 1));
533        line.add_cell(Cell::new('e', 1));
534        line.add_cell(Cell::new('你', 2));
535
536        assert_eq!(line.logical_line_index, 0);
537        assert!(!line.is_wrapped_part);
538        assert_eq!(line.visual_in_logical, 0);
539        assert_eq!(line.char_offset_start, 0);
540        assert_eq!(line.char_offset_end, 0);
541        assert_eq!(line.segment_x_start_cells, 0);
542        assert!(!line.is_fold_placeholder_appended);
543        assert_eq!(line.cells.len(), 3);
544        assert_eq!(line.visual_width(), 4); // 1 + 1 + 2
545    }
546
547    #[test]
548    fn test_snapshot_generator_basic() {
549        let text = "Hello\nWorld\nRust";
550        let generator = SnapshotGenerator::from_text(text, 80);
551
552        assert_eq!(generator.line_count(), 3);
553        assert_eq!(generator.get_line(0), Some("Hello"));
554        assert_eq!(generator.get_line(1), Some("World"));
555        assert_eq!(generator.get_line(2), Some("Rust"));
556    }
557
558    #[test]
559    fn test_get_headless_grid() {
560        let text = "Line 1\nLine 2\nLine 3\nLine 4";
561        let generator = SnapshotGenerator::from_text(text, 80);
562
563        // Get first 2 lines
564        let grid = generator.get_headless_grid(0, 2);
565        assert_eq!(grid.start_visual_row, 0);
566        assert_eq!(grid.count, 2);
567        assert_eq!(grid.actual_line_count(), 2);
568
569        // Verify first line
570        let line0 = &grid.lines[0];
571        assert_eq!(line0.logical_line_index, 0);
572        assert!(!line0.is_wrapped_part);
573        assert_eq!(line0.visual_in_logical, 0);
574        assert_eq!(line0.char_offset_start, 0);
575        assert_eq!(line0.char_offset_end, 6);
576        assert_eq!(line0.cells.len(), 6); // "Line 1"
577
578        // Get middle lines
579        let grid2 = generator.get_headless_grid(1, 2);
580        assert_eq!(grid2.actual_line_count(), 2);
581        assert_eq!(grid2.lines[0].logical_line_index, 1);
582        assert_eq!(grid2.lines[1].logical_line_index, 2);
583    }
584
585    #[test]
586    fn test_get_headless_grid_soft_wrap_single_line() {
587        let generator = SnapshotGenerator::from_text("abcd", 2);
588
589        let grid = generator.get_headless_grid(0, 10);
590        assert_eq!(grid.actual_line_count(), 2);
591
592        let line0_text: String = grid.lines[0].cells.iter().map(|c| c.ch).collect();
593        let line1_text: String = grid.lines[1].cells.iter().map(|c| c.ch).collect();
594
595        assert_eq!(grid.lines[0].logical_line_index, 0);
596        assert!(!grid.lines[0].is_wrapped_part);
597        assert_eq!(grid.lines[0].visual_in_logical, 0);
598        assert_eq!(line0_text, "ab");
599
600        assert_eq!(grid.lines[1].logical_line_index, 0);
601        assert!(grid.lines[1].is_wrapped_part);
602        assert_eq!(grid.lines[1].visual_in_logical, 1);
603        assert_eq!(line1_text, "cd");
604
605        // Starting from the 2nd visual line, get 1 line, should only return the wrapped part.
606        let grid2 = generator.get_headless_grid(1, 1);
607        assert_eq!(grid2.actual_line_count(), 1);
608        assert_eq!(grid2.lines[0].logical_line_index, 0);
609        assert!(grid2.lines[0].is_wrapped_part);
610        let text2: String = grid2.lines[0].cells.iter().map(|c| c.ch).collect();
611        assert_eq!(text2, "cd");
612    }
613
614    #[test]
615    fn test_grid_with_cjk() {
616        let text = "Hello\n你好世界\nRust";
617        let generator = SnapshotGenerator::from_text(text, 80);
618
619        let grid = generator.get_headless_grid(1, 1);
620        let line = &grid.lines[0];
621
622        assert_eq!(line.cells.len(), 4); // 4 CJK characters
623        assert_eq!(line.visual_width(), 8); // Each CJK character 2 cells
624
625        // Verify width of each character
626        assert_eq!(line.cells[0].ch, '你');
627        assert_eq!(line.cells[0].width, 2);
628        assert_eq!(line.cells[1].ch, '好');
629        assert_eq!(line.cells[1].width, 2);
630    }
631
632    #[test]
633    fn test_grid_with_emoji() {
634        let text = "Hello 👋\nWorld 🌍";
635        let generator = SnapshotGenerator::from_text(text, 80);
636
637        let grid = generator.get_headless_grid(0, 2);
638        assert_eq!(grid.actual_line_count(), 2);
639
640        // First line: "Hello 👋"
641        let line0 = &grid.lines[0];
642        assert_eq!(line0.cells.len(), 7); // H,e,l,l,o,space,👋
643        // "Hello " = 6, "👋" = 2
644        assert_eq!(line0.visual_width(), 8);
645    }
646
647    #[test]
648    fn test_grid_bounds() {
649        let text = "Line 1\nLine 2\nLine 3";
650        let generator = SnapshotGenerator::from_text(text, 80);
651
652        // Request lines beyond range
653        let grid = generator.get_headless_grid(1, 10);
654        // Should only return lines that actually exist
655        assert_eq!(grid.actual_line_count(), 2); // Only Line 2 and Line 3
656
657        // Completely out of range
658        let grid2 = generator.get_headless_grid(10, 5);
659        assert_eq!(grid2.actual_line_count(), 0);
660    }
661
662    #[test]
663    fn test_empty_document() {
664        let generator = SnapshotGenerator::new(80);
665        let grid = generator.get_headless_grid(0, 10);
666        assert_eq!(grid.actual_line_count(), 1);
667    }
668
669    #[test]
670    fn test_viewport_width_change() {
671        let text = "Hello World";
672        let mut generator = SnapshotGenerator::from_text(text, 40);
673
674        assert_eq!(generator.viewport_width, 40);
675
676        generator.set_viewport_width(20);
677        assert_eq!(generator.viewport_width, 20);
678        // Changing width should trigger soft wrap reflow (using a shorter width here to verify wrapping).
679        generator.set_viewport_width(5);
680        let grid = generator.get_headless_grid(0, 10);
681        assert!(grid.actual_line_count() > 1);
682    }
683}