fresh/view/ui/view_pipeline.rs
1//! Token-based view rendering pipeline
2//!
3//! This module provides a clean pipeline for rendering view tokens:
4//!
5//! ```text
6//! source buffer
7//! ↓ build_base_tokens()
8//! Vec<ViewTokenWire> (base tokens with source mappings)
9//! ↓ plugin transform (optional)
10//! Vec<ViewTokenWire> (transformed tokens, may have injected content)
11//! ↓ apply_wrapping() (optional)
12//! Vec<ViewTokenWire> (with Break tokens for wrapped lines)
13//! ↓ ViewLineIterator
14//! Iterator<ViewLine> (one per display line, preserves token info)
15//! ↓ render
16//! Display output
17//! ```
18//!
19//! The key design principle: preserve token-level information through the pipeline
20//! so rendering decisions (like line numbers) can be made based on token types,
21//! not reconstructed from flattened text.
22
23use crate::primitives::ansi::AnsiParser;
24use crate::primitives::display_width::str_width;
25use fresh_core::api::{ViewTokenStyle, ViewTokenWire, ViewTokenWireKind};
26use std::collections::HashSet;
27use std::ops::Range;
28use unicode_segmentation::UnicodeSegmentation;
29
30/// A display line built from tokens, preserving token-level information
31#[derive(Debug, Clone)]
32pub struct ViewLine {
33 /// The display text for this line (tabs expanded to spaces, etc.)
34 pub text: String,
35
36 /// Absolute source byte offset of the start of this line (if it has one)
37 pub source_start_byte: Option<usize>,
38
39 // === Per-CHARACTER mappings (indexed by char position in text) ===
40 /// Source byte offset for each character
41 /// Length == text.chars().count()
42 pub char_source_bytes: Vec<Option<usize>>,
43 /// Style for each character (from token styles)
44 pub char_styles: Vec<Option<ViewTokenStyle>>,
45 /// Visual column where each character starts
46 pub char_visual_cols: Vec<usize>,
47
48 // === Per-VISUAL-COLUMN mapping (indexed by visual column) ===
49 /// Character index at each visual column (for O(1) mouse clicks)
50 /// For double-width chars, consecutive visual columns map to the same char index
51 /// Length == total visual width of line
52 pub visual_to_char: Vec<usize>,
53
54 /// Positions that are the start of a tab expansion
55 pub tab_starts: HashSet<usize>,
56 /// How this line started (what kind of token/boundary preceded it)
57 pub line_start: LineStart,
58 /// Whether this line ends with a newline character
59 pub ends_with_newline: bool,
60 /// Gutter glyph to render in the line-number column. Only set on
61 /// the first visual row of a virtual line (`AfterInjectedNewline`)
62 /// whose source `VirtualText` carried a `gutter_glyph`. None on
63 /// source lines and on continuation rows of wrapped virtual
64 /// lines, so a multi-row deletion places a single "-" next to its
65 /// first row, not on every wrapped sub-row.
66 pub virtual_gutter_glyph: Option<(String, ratatui::style::Color)>,
67 /// Line-level style for plugin-injected virtual lines
68 /// (`AfterInjectedNewline`). Carries the `bg` the plugin asked for
69 /// even when `text` is empty, so the renderer's row-fill path can
70 /// stripe an empty deletion virtual line with the diff-remove bg
71 /// (it can't recover the bg from `char_styles.first()` when there
72 /// are no chars). `None` for source lines.
73 pub virtual_line_style: Option<ViewTokenStyle>,
74}
75
76impl ViewLine {
77 /// Get source byte at a given character index (O(1))
78 #[inline]
79 pub fn source_byte_at_char(&self, char_idx: usize) -> Option<usize> {
80 self.char_source_bytes.get(char_idx).copied().flatten()
81 }
82
83 /// Get character index at a given visual column (O(1))
84 #[inline]
85 pub fn char_at_visual_col(&self, visual_col: usize) -> usize {
86 self.visual_to_char
87 .get(visual_col)
88 .copied()
89 .unwrap_or_else(|| self.char_source_bytes.len().saturating_sub(1))
90 }
91
92 /// Get source byte at a given visual column (O(1) for mouse clicks)
93 #[inline]
94 pub fn source_byte_at_visual_col(&self, visual_col: usize) -> Option<usize> {
95 let char_idx = self.char_at_visual_col(visual_col);
96 self.source_byte_at_char(char_idx)
97 }
98
99 /// Get the visual column for a character at the given index
100 #[inline]
101 pub fn visual_col_at_char(&self, char_idx: usize) -> usize {
102 self.char_visual_cols.get(char_idx).copied().unwrap_or(0)
103 }
104
105 /// Total visual width of this line
106 #[inline]
107 pub fn visual_width(&self) -> usize {
108 self.visual_to_char.len()
109 }
110}
111
112/// What preceded the start of a display line
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum LineStart {
115 /// First line of the view (no preceding token)
116 Beginning,
117 /// Line after a source Newline token (source_offset: Some)
118 AfterSourceNewline,
119 /// Line after an injected Newline token (source_offset: None)
120 AfterInjectedNewline,
121 /// Line after a Break token (wrapped continuation)
122 AfterBreak,
123}
124
125impl LineStart {
126 /// Should this line show a line number in the gutter?
127 ///
128 /// - Beginning: yes (first source line)
129 /// - AfterSourceNewline: yes (new source line)
130 /// - AfterInjectedNewline: depends on content (if injected, no; if source, yes)
131 /// - AfterBreak: no (wrapped continuation of same line)
132 pub fn is_continuation(&self) -> bool {
133 matches!(self, LineStart::AfterBreak)
134 }
135}
136
137/// Iterator that converts a token stream into display lines
138pub struct ViewLineIterator<'a> {
139 tokens: &'a [ViewTokenWire],
140 token_idx: usize,
141 /// How the next line should start (based on what ended the previous line)
142 next_line_start: LineStart,
143 /// Whether to render in binary mode (unprintable chars shown as code points)
144 binary_mode: bool,
145 /// Whether to parse ANSI escape sequences (giving them zero visual width)
146 ansi_aware: bool,
147 /// Tab width for rendering (number of spaces per tab)
148 tab_size: usize,
149 /// Whether the token stream covers the end of the buffer.
150 /// When true, a trailing empty line is emitted after a final source newline
151 /// (representing the empty line after a file's trailing '\n').
152 at_buffer_end: bool,
153 /// Sorted, non-overlapping source-byte ranges whose tokens should be
154 /// skipped at the source level (collapsed folds). Empty slice disables
155 /// skipping. Set via [`ViewLineIterator::with_fold_skip`].
156 fold_skip: &'a [Range<usize>],
157 /// Advances monotonically through `fold_skip` as token source offsets
158 /// advance. Lets the per-token skip check run in O(1) amortised.
159 fold_cursor: usize,
160}
161
162impl<'a> ViewLineIterator<'a> {
163 /// Create a new ViewLineIterator with all options
164 ///
165 /// - `tokens`: The token stream to convert to display lines
166 /// - `binary_mode`: Whether to render unprintable chars as code points
167 /// - `ansi_aware`: Whether to parse ANSI escape sequences (giving them zero visual width)
168 /// - `tab_size`: Tab width for rendering (number of spaces per tab, should be > 0)
169 /// - `at_buffer_end`: Whether the token stream covers the end of the buffer.
170 /// When true, a trailing empty line is emitted after a final source newline.
171 ///
172 /// Note: If tab_size is 0, it will be treated as 4 (the default) to prevent division by zero.
173 /// This is a defensive measure to handle invalid configuration gracefully.
174 pub fn new(
175 tokens: &'a [ViewTokenWire],
176 binary_mode: bool,
177 ansi_aware: bool,
178 tab_size: usize,
179 at_buffer_end: bool,
180 ) -> Self {
181 // Defensive: treat 0 as 4 (default) to prevent division by zero in tab_expansion_width
182 // This can happen if invalid config (tab_size: 0) is loaded
183 let tab_size = if tab_size == 0 { 4 } else { tab_size };
184 Self {
185 tokens,
186 token_idx: 0,
187 next_line_start: LineStart::Beginning,
188 binary_mode,
189 ansi_aware,
190 tab_size,
191 at_buffer_end,
192 fold_skip: &[],
193 fold_cursor: 0,
194 }
195 }
196
197 /// Configure source-byte ranges to skip during iteration. `skip` must be
198 /// sorted by `start` ascending and non-overlapping; caller is responsible
199 /// (derived once per render from `FoldManager::resolved_ranges`). Tokens
200 /// whose `source_offset` lies inside a skip range are consumed without
201 /// contributing to a ViewLine, so folded content is never materialised.
202 pub fn with_fold_skip(mut self, skip: &'a [Range<usize>]) -> Self {
203 self.fold_skip = skip;
204 self.fold_cursor = 0;
205 self
206 }
207
208 /// Expand a tab to spaces based on current column and configured tab_size
209 #[inline]
210 fn tab_expansion_width(&self, col: usize) -> usize {
211 self.tab_size - (col % self.tab_size)
212 }
213
214 /// Advance past tokens whose `source_offset` is inside a fold skip range.
215 /// Monotonic in source offsets, so `fold_cursor` only moves forward.
216 /// Tokens with `source_offset == None` (injected / virtual) are never
217 /// skipped. Line-start transitions are NOT updated: the next emitted
218 /// ViewLine's `line_start` continues to reflect the *last emitted*
219 /// line's terminator (typically the fold header's source newline).
220 #[inline]
221 fn skip_folded_tokens(&mut self) {
222 while self.token_idx < self.tokens.len() {
223 let token = &self.tokens[self.token_idx];
224 let Some(offset) = token.source_offset else {
225 return;
226 };
227 while self.fold_cursor < self.fold_skip.len()
228 && self.fold_skip[self.fold_cursor].end <= offset
229 {
230 self.fold_cursor += 1;
231 }
232 let in_skip = self
233 .fold_skip
234 .get(self.fold_cursor)
235 .is_some_and(|r| r.start <= offset && offset < r.end);
236 if !in_skip {
237 return;
238 }
239 self.token_idx += 1;
240 }
241 }
242}
243
244/// Check if a byte is an unprintable control character that should be rendered as <XX>
245/// Returns true for control characters (0x00-0x1F, 0x7F) except tab and newline
246fn is_unprintable_byte(b: u8) -> bool {
247 // Only allow tab (0x09) and newline (0x0A) to render normally
248 // Everything else in control range should be shown as <XX>
249 if b == 0x09 || b == 0x0A {
250 return false;
251 }
252 // Control characters (0x00-0x1F) including CR, VT, FF, ESC are unprintable
253 if b < 0x20 {
254 return true;
255 }
256 // DEL character (0x7F) is also unprintable
257 if b == 0x7F {
258 return true;
259 }
260 false
261}
262
263/// Format an unprintable byte as a code point string like "<00>"
264fn format_unprintable_byte(b: u8) -> String {
265 format!("<{:02X}>", b)
266}
267
268impl<'a> Iterator for ViewLineIterator<'a> {
269 type Item = ViewLine;
270
271 fn next(&mut self) -> Option<Self::Item> {
272 // Fold skip: advance past any tokens whose source bytes live inside
273 // a collapsed fold range before inspecting the next visible token.
274 self.skip_folded_tokens();
275
276 if self.token_idx >= self.tokens.len() {
277 // All tokens consumed. If the previous line ended with a source
278 // newline there is one more real (empty) document line to emit —
279 // e.g. the empty line after a file's trailing '\n'. Produce it
280 // exactly once, then stop. Only do this when the tokens cover
281 // the actual end of the buffer (not a viewport slice).
282 if self.at_buffer_end && matches!(self.next_line_start, LineStart::AfterSourceNewline) {
283 // Flip to Beginning so the *next* call returns None.
284 self.next_line_start = LineStart::Beginning;
285 let last_source_byte = self.tokens.last().and_then(|t| t.source_offset);
286 return Some(ViewLine {
287 text: String::new(),
288 source_start_byte: last_source_byte.map(|s| s + 1),
289 char_source_bytes: vec![],
290 char_styles: vec![],
291 char_visual_cols: vec![],
292 visual_to_char: vec![],
293 tab_starts: HashSet::new(),
294 line_start: LineStart::AfterSourceNewline,
295 ends_with_newline: false,
296 virtual_gutter_glyph: None,
297 virtual_line_style: None,
298 });
299 }
300 return None;
301 }
302
303 let line_start = self.next_line_start;
304 let mut text = String::new();
305
306 // Per-character tracking (indexed by character position)
307 let mut char_source_bytes: Vec<Option<usize>> = Vec::new();
308 let mut char_styles: Vec<Option<ViewTokenStyle>> = Vec::new();
309 let mut char_visual_cols: Vec<usize> = Vec::new();
310
311 // Per-visual-column tracking (indexed by visual column)
312 let mut visual_to_char: Vec<usize> = Vec::new();
313
314 let mut tab_starts = HashSet::new();
315 let mut col = 0usize; // Current visual column
316 let mut ends_with_newline = false;
317
318 // ANSI parser for tracking escape sequences (reuse existing implementation)
319 let mut ansi_parser = if self.ansi_aware {
320 Some(AnsiParser::new())
321 } else {
322 None
323 };
324
325 /// Helper to add a character with all its mappings
326 macro_rules! add_char {
327 ($ch:expr, $source:expr, $style:expr, $width:expr) => {{
328 let char_idx = char_source_bytes.len();
329
330 // Per-character data
331 text.push($ch);
332 char_source_bytes.push($source);
333 char_styles.push($style);
334 char_visual_cols.push(col);
335
336 // Per-visual-column data (for O(1) mouse clicks).
337 // Note: $width is 0 for zero-width codepoints (combining
338 // marks, ZWJ, continuation codepoints within a grapheme
339 // cluster) — we deliberately emit no visual_to_char
340 // entries for them.
341 #[allow(clippy::reversed_empty_ranges)]
342 for _ in 0..$width {
343 visual_to_char.push(char_idx);
344 }
345
346 col += $width;
347 }};
348 }
349
350 // Process tokens until we hit a line break
351 while self.token_idx < self.tokens.len() {
352 // Skip tokens that fall inside a collapsed fold before
353 // touching the current line's accumulators.
354 self.skip_folded_tokens();
355 if self.token_idx >= self.tokens.len() {
356 break;
357 }
358 let token = &self.tokens[self.token_idx];
359 let token_style = token.style.clone();
360
361 match &token.kind {
362 ViewTokenWireKind::Text(t) => {
363 let base = token.source_offset;
364 let t_bytes = t.as_bytes();
365 let mut byte_idx = 0;
366
367 while byte_idx < t_bytes.len() {
368 let b = t_bytes[byte_idx];
369
370 // In binary mode, render unprintable bytes as <XX> code points.
371 // These are never part of a grapheme cluster.
372 if self.binary_mode && is_unprintable_byte(b) {
373 let source = base.map(|s| s + byte_idx);
374 let formatted = format_unprintable_byte(b);
375 for display_ch in formatted.chars() {
376 add_char!(display_ch, source, token_style.clone(), 1);
377 }
378 byte_idx += 1;
379 continue;
380 }
381
382 // Decode the largest valid UTF-8 slice starting here so we can
383 // segment it into grapheme clusters. Any invalid byte is
384 // handled as a single-byte replacement char and we resume
385 // decoding afterwards.
386 let remaining = &t_bytes[byte_idx..];
387 let valid = match std::str::from_utf8(remaining) {
388 Ok(s) => s,
389 Err(e) => {
390 let valid_up_to = e.valid_up_to();
391 if valid_up_to == 0 {
392 let source = base.map(|s| s + byte_idx);
393 if self.binary_mode {
394 let formatted = format_unprintable_byte(b);
395 for display_ch in formatted.chars() {
396 add_char!(display_ch, source, token_style.clone(), 1);
397 }
398 } else {
399 add_char!('\u{FFFD}', source, token_style.clone(), 1);
400 }
401 byte_idx += 1;
402 continue;
403 } else {
404 // SAFETY: `valid_up_to` is a char boundary.
405 unsafe {
406 std::str::from_utf8_unchecked(&remaining[..valid_up_to])
407 }
408 }
409 }
410 };
411
412 // Canonical Unicode handling: iterate grapheme clusters, not
413 // codepoints. The width of a cluster is `str_width(cluster)` —
414 // `unicode-width` 0.2 correctly returns 2 for ZWJ family emoji,
415 // 1 for a base+combining sequence like "é", 2 for fullwidth
416 // letters, and so on. This is the same width ratatui computes
417 // when it re-segments the span, so every stage of the pipeline
418 // (wrap, column tracking, span placement) agrees on how many
419 // cells each cluster occupies.
420 //
421 // We still record per-codepoint entries in the char-indexed
422 // arrays (char_source_bytes / char_styles / char_visual_cols)
423 // so byte↔column mapping stays exact for LSP positions, mouse
424 // clicks, and cursor arithmetic. But `col` advances exactly
425 // once per grapheme: the first codepoint of a cluster carries
426 // the full width, the rest carry 0.
427 let mut segmented_bytes = 0usize;
428 for (g_byte_offset, grapheme) in valid.grapheme_indices(true) {
429 segmented_bytes = g_byte_offset + grapheme.len();
430
431 // In binary mode, any ASCII unprintable byte inside the
432 // decoded slice must still be rendered as `<XX>`. This
433 // covers graphemes consisting entirely of one unprintable
434 // byte (e.g. `\x1A`) and CRLF (`\r\n`) where only the
435 // `\r` half is unprintable — we split those out.
436 if self.binary_mode {
437 let bytes = grapheme.as_bytes();
438 let has_unprintable =
439 bytes.iter().any(|&b| b < 0x80 && is_unprintable_byte(b));
440 if has_unprintable {
441 let mut inner = 0usize;
442 for ch in grapheme.chars() {
443 let ch_len = ch.len_utf8();
444 let src =
445 base.map(|s| s + byte_idx + g_byte_offset + inner);
446 let ch_byte = ch as u32;
447 if ch_byte < 0x80 && is_unprintable_byte(ch_byte as u8) {
448 let formatted = format_unprintable_byte(ch_byte as u8);
449 for display_ch in formatted.chars() {
450 add_char!(display_ch, src, token_style.clone(), 1);
451 }
452 } else {
453 add_char!(ch, src, token_style.clone(), 1);
454 }
455 inner += ch_len;
456 }
457 continue;
458 }
459 }
460
461 // Tab: a single codepoint forming its own grapheme, expanded to spaces.
462 if grapheme == "\t" {
463 let source = base.map(|s| s + byte_idx + g_byte_offset);
464 let tab_start_pos = char_source_bytes.len();
465 tab_starts.insert(tab_start_pos);
466 let spaces = self.tab_expansion_width(col);
467
468 let char_idx = char_source_bytes.len();
469 text.push(' ');
470 char_source_bytes.push(source);
471 char_styles.push(token_style.clone());
472 char_visual_cols.push(col);
473
474 for _ in 0..spaces {
475 visual_to_char.push(char_idx);
476 }
477 col += spaces;
478
479 for _ in 1..spaces {
480 text.push(' ');
481 char_source_bytes.push(source);
482 char_styles.push(token_style.clone());
483 char_visual_cols
484 .push(col - spaces + char_source_bytes.len() - char_idx);
485 }
486 continue;
487 }
488
489 // ANSI escape sequences. Process char-by-char so the
490 // AnsiParser state machine keeps track of the escape,
491 // and keep them as width 0. In practice ESC never sits
492 // inside a grapheme with visible content, so treating
493 // a grapheme that starts with ESC as width-0 here is
494 // correct.
495 if let Some(ref mut parser) = ansi_parser {
496 let first_ch = grapheme.chars().next().unwrap_or('\0');
497 if parser.parse_char(first_ch).is_none() {
498 for ch in grapheme.chars() {
499 // All codepoints of an escape grapheme are width 0.
500 let src = base.map(|s| s + byte_idx + g_byte_offset);
501 // Keep the parser fed so state transitions work
502 // even across a multi-codepoint escape (rare).
503 if ch != first_ch {
504 let _ = parser.parse_char(ch);
505 }
506 add_char!(ch, src, token_style.clone(), 0);
507 }
508 continue;
509 }
510 }
511
512 // Normal case: emit one display unit per grapheme.
513 // Width goes on the FIRST codepoint, the rest are 0.
514 let cluster_width = str_width(grapheme);
515 let mut first = true;
516 let mut inner_byte_offset = 0usize;
517 for ch in grapheme.chars() {
518 let source =
519 base.map(|s| s + byte_idx + g_byte_offset + inner_byte_offset);
520 let w = if first {
521 first = false;
522 cluster_width
523 } else {
524 0
525 };
526 add_char!(ch, source, token_style.clone(), w);
527 inner_byte_offset += ch.len_utf8();
528 }
529 }
530
531 byte_idx += segmented_bytes.max(1);
532 }
533 self.token_idx += 1;
534 }
535 ViewTokenWireKind::Space => {
536 add_char!(' ', token.source_offset, token_style, 1);
537 self.token_idx += 1;
538 }
539 ViewTokenWireKind::Newline => {
540 // Newline ends this line - width 1 for the newline char
541 add_char!('\n', token.source_offset, token_style, 1);
542 ends_with_newline = true;
543
544 // Determine how the next line starts
545 self.next_line_start = if token.source_offset.is_some() {
546 LineStart::AfterSourceNewline
547 } else {
548 LineStart::AfterInjectedNewline
549 };
550 self.token_idx += 1;
551 break;
552 }
553 ViewTokenWireKind::Break => {
554 // Break is a synthetic line break from wrapping
555 add_char!('\n', None, None, 1);
556 ends_with_newline = true;
557
558 self.next_line_start = LineStart::AfterBreak;
559 self.token_idx += 1;
560 break;
561 }
562 ViewTokenWireKind::BinaryByte(b) => {
563 // Binary byte rendered as <XX> - all 4 chars map to same source byte
564 let formatted = format_unprintable_byte(*b);
565 for display_ch in formatted.chars() {
566 add_char!(display_ch, token.source_offset, token_style.clone(), 1);
567 }
568 self.token_idx += 1;
569 }
570 }
571 }
572
573 // col's final value is intentionally unused (only needed during iteration)
574 let _ = col;
575
576 // If we consumed all remaining tokens without hitting a Newline or Break,
577 // the content didn't end with a line terminator. Reset next_line_start
578 // so the trailing-empty-line logic (at the top of next()) doesn't
579 // incorrectly fire on the subsequent call. The `ends_with_newline` flag
580 // tells us whether the loop exited via a Newline/Break (true) or by
581 // exhausting all tokens (false).
582 if !ends_with_newline && self.token_idx >= self.tokens.len() {
583 self.next_line_start = LineStart::Beginning;
584 }
585
586 // Don't return empty injected/virtual lines at the end of the token
587 // stream. However, DO return a trailing empty line that follows a source
588 // newline — it represents a real document line (e.g. after a file's
589 // trailing '\n') and the cursor may sit on it — but only when
590 // at_buffer_end is set (otherwise this is just a viewport slice).
591 if text.is_empty()
592 && self.token_idx >= self.tokens.len()
593 && !(self.at_buffer_end && matches!(line_start, LineStart::AfterSourceNewline))
594 {
595 return None;
596 }
597
598 Some(ViewLine {
599 text,
600 source_start_byte: char_source_bytes.iter().find_map(|s| *s),
601 char_source_bytes,
602 char_styles,
603 char_visual_cols,
604 visual_to_char,
605 tab_starts,
606 line_start,
607 ends_with_newline,
608 virtual_gutter_glyph: None,
609 virtual_line_style: None,
610 })
611 }
612}
613
614/// Determine if a display line should show a line number
615///
616/// Rules:
617/// - Wrapped continuation (line_start == AfterBreak): no line number
618/// - Injected content (first char has source_offset: None): no line number
619/// - Empty line at beginning or after source newline: yes line number
620/// - Otherwise: show line number
621pub fn should_show_line_number(line: &ViewLine) -> bool {
622 // Wrapped continuations never show line numbers
623 if line.line_start.is_continuation() {
624 return false;
625 }
626
627 // Check if this line contains injected (non-source) content
628 // An empty line is NOT injected if it's at the beginning or after a source newline
629 if line.char_source_bytes.is_empty() {
630 // Empty line - show line number if it's at beginning or after source newline
631 // (not after injected newline or break)
632 return matches!(
633 line.line_start,
634 LineStart::Beginning | LineStart::AfterSourceNewline
635 );
636 }
637
638 let first_char_is_source = line
639 .char_source_bytes
640 .first()
641 .map(|m| m.is_some())
642 .unwrap_or(false);
643
644 if !first_char_is_source {
645 // Injected line (header, etc.) - no line number
646 return false;
647 }
648
649 // Source content after a real line break - show line number
650 true
651}
652
653// ============================================================================
654// Layout: The computed display state for a view
655// ============================================================================
656
657use std::collections::BTreeMap;
658
659/// The Layout represents the computed display state for a view.
660///
661/// This is **View state**, not Buffer state. Each split has its own Layout
662/// computed from its view_transform (or base tokens if no transform).
663///
664/// The Layout provides:
665/// - ViewLines for the current viewport region
666/// - Bidirectional mapping between source bytes and view positions
667/// - Scroll limit information
668#[derive(Debug, Clone)]
669pub struct Layout {
670 /// Display lines for the current viewport region
671 pub lines: Vec<ViewLine>,
672
673 /// Source byte range this layout covers
674 pub source_range: Range<usize>,
675
676 /// Total view lines in entire document (estimated or exact)
677 pub total_view_lines: usize,
678
679 /// Total injected lines in entire document (from view transform)
680 pub total_injected_lines: usize,
681
682 /// Fast lookup: source byte → view line index
683 byte_to_line: BTreeMap<usize, usize>,
684}
685
686impl Layout {
687 /// Create a new Layout from ViewLines
688 pub fn new(lines: Vec<ViewLine>, source_range: Range<usize>) -> Self {
689 let mut byte_to_line = BTreeMap::new();
690
691 // Build the byte→line index from char_source_bytes
692 for (line_idx, line) in lines.iter().enumerate() {
693 // Find the first source byte in this line
694 if let Some(first_byte) = line.char_source_bytes.iter().find_map(|m| *m) {
695 byte_to_line.insert(first_byte, line_idx);
696 }
697 }
698
699 // Estimate total view lines (for now, just use what we have)
700 let total_view_lines = lines.len();
701 let total_injected_lines = lines.iter().filter(|l| !should_show_line_number(l)).count();
702
703 Self {
704 lines,
705 source_range,
706 total_view_lines,
707 total_injected_lines,
708 byte_to_line,
709 }
710 }
711
712 /// Build a Layout from a token stream
713 pub fn from_tokens(
714 tokens: &[ViewTokenWire],
715 source_range: Range<usize>,
716 tab_size: usize,
717 ) -> Self {
718 let lines: Vec<ViewLine> =
719 ViewLineIterator::new(tokens, false, false, tab_size, false).collect();
720 Self::new(lines, source_range)
721 }
722
723 /// Find the view position (line, visual column) for a source byte
724 pub fn source_byte_to_view_position(&self, byte: usize) -> Option<(usize, usize)> {
725 // Find the view line containing this byte
726 if let Some((&_line_start_byte, &line_idx)) = self.byte_to_line.range(..=byte).last() {
727 if line_idx < self.lines.len() {
728 let line = &self.lines[line_idx];
729 // Find the character with this source byte, then get its visual column
730 for (char_idx, mapping) in line.char_source_bytes.iter().enumerate() {
731 if *mapping == Some(byte) {
732 return Some((line_idx, line.visual_col_at_char(char_idx)));
733 }
734 }
735 // Byte is in this line's range but not at a character boundary
736 // Return end of line (visual width)
737 return Some((line_idx, line.visual_width()));
738 }
739 }
740 None
741 }
742
743 /// Find the source byte for a view position (line, visual column)
744 pub fn view_position_to_source_byte(&self, line_idx: usize, col: usize) -> Option<usize> {
745 if line_idx >= self.lines.len() {
746 return None;
747 }
748 let line = &self.lines[line_idx];
749 if col < line.visual_width() {
750 // Use O(1) lookup via visual_to_char -> char_source_bytes
751 line.source_byte_at_visual_col(col)
752 } else if !line.char_source_bytes.is_empty() {
753 // Past end of line, return last valid byte
754 line.char_source_bytes.iter().rev().find_map(|m| *m)
755 } else {
756 None
757 }
758 }
759
760 /// Get the source byte for the start of a view line
761 pub fn get_source_byte_for_line(&self, line_idx: usize) -> Option<usize> {
762 if line_idx >= self.lines.len() {
763 return None;
764 }
765 self.lines[line_idx]
766 .char_source_bytes
767 .iter()
768 .find_map(|m| *m)
769 }
770
771 /// Find the nearest view line for a source byte (for stabilization)
772 pub fn find_nearest_view_line(&self, byte: usize) -> usize {
773 if let Some((&_line_start_byte, &line_idx)) = self.byte_to_line.range(..=byte).last() {
774 line_idx.min(self.lines.len().saturating_sub(1))
775 } else {
776 0
777 }
778 }
779
780 /// Calculate the maximum top line for scrolling
781 pub fn max_top_line(&self, viewport_height: usize) -> usize {
782 self.lines.len().saturating_sub(viewport_height)
783 }
784
785 /// Check if there's content below the current layout
786 pub fn has_content_below(&self, buffer_len: usize) -> bool {
787 self.source_range.end < buffer_len
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 fn make_text_token(text: &str, source_offset: Option<usize>) -> ViewTokenWire {
796 ViewTokenWire {
797 kind: ViewTokenWireKind::Text(text.to_string()),
798 source_offset,
799 style: None,
800 }
801 }
802
803 fn make_newline_token(source_offset: Option<usize>) -> ViewTokenWire {
804 ViewTokenWire {
805 kind: ViewTokenWireKind::Newline,
806 source_offset,
807 style: None,
808 }
809 }
810
811 fn make_break_token() -> ViewTokenWire {
812 ViewTokenWire {
813 kind: ViewTokenWireKind::Break,
814 source_offset: None,
815 style: None,
816 }
817 }
818
819 #[test]
820 fn test_simple_source_lines() {
821 let tokens = vec![
822 make_text_token("Line 1", Some(0)),
823 make_newline_token(Some(6)),
824 make_text_token("Line 2", Some(7)),
825 make_newline_token(Some(13)),
826 ];
827
828 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
829
830 assert_eq!(lines.len(), 2);
831 assert_eq!(lines[0].text, "Line 1\n");
832 assert_eq!(lines[0].line_start, LineStart::Beginning);
833 assert!(should_show_line_number(&lines[0]));
834
835 assert_eq!(lines[1].text, "Line 2\n");
836 assert_eq!(lines[1].line_start, LineStart::AfterSourceNewline);
837 assert!(should_show_line_number(&lines[1]));
838 }
839
840 #[test]
841 fn test_wrapped_continuation() {
842 let tokens = vec![
843 make_text_token("Line 1 start", Some(0)),
844 make_break_token(), // Wrapped
845 make_text_token("continued", Some(12)),
846 make_newline_token(Some(21)),
847 ];
848
849 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
850
851 assert_eq!(lines.len(), 2);
852 assert_eq!(lines[0].line_start, LineStart::Beginning);
853 assert!(should_show_line_number(&lines[0]));
854
855 assert_eq!(lines[1].line_start, LineStart::AfterBreak);
856 assert!(
857 !should_show_line_number(&lines[1]),
858 "Wrapped continuation should NOT show line number"
859 );
860 }
861
862 #[test]
863 fn test_injected_header_then_source() {
864 // This is the bug scenario: header (injected) followed by source content
865 let tokens = vec![
866 // Injected header
867 make_text_token("== HEADER ==", None),
868 make_newline_token(None),
869 // Source content
870 make_text_token("Line 1", Some(0)),
871 make_newline_token(Some(6)),
872 ];
873
874 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
875
876 assert_eq!(lines.len(), 2);
877
878 // Header line - no line number (injected content)
879 assert_eq!(lines[0].text, "== HEADER ==\n");
880 assert_eq!(lines[0].line_start, LineStart::Beginning);
881 assert!(
882 !should_show_line_number(&lines[0]),
883 "Injected header should NOT show line number"
884 );
885
886 // Source line after header - SHOULD show line number
887 assert_eq!(lines[1].text, "Line 1\n");
888 assert_eq!(lines[1].line_start, LineStart::AfterInjectedNewline);
889 assert!(
890 should_show_line_number(&lines[1]),
891 "BUG: Source line after injected header SHOULD show line number!\n\
892 line_start={:?}, first_char_is_source={}",
893 lines[1].line_start,
894 lines[1]
895 .char_source_bytes
896 .first()
897 .map(|m| m.is_some())
898 .unwrap_or(false)
899 );
900 }
901
902 #[test]
903 fn test_mixed_scenario() {
904 // Header -> Source Line 1 -> Source Line 2 (wrapped) -> Source Line 3
905 let tokens = vec![
906 // Injected header
907 make_text_token("== Block 1 ==", None),
908 make_newline_token(None),
909 // Source line 1
910 make_text_token("Line 1", Some(0)),
911 make_newline_token(Some(6)),
912 // Source line 2 (gets wrapped)
913 make_text_token("Line 2 start", Some(7)),
914 make_break_token(),
915 make_text_token("wrapped", Some(19)),
916 make_newline_token(Some(26)),
917 // Source line 3
918 make_text_token("Line 3", Some(27)),
919 make_newline_token(Some(33)),
920 ];
921
922 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
923
924 assert_eq!(lines.len(), 5);
925
926 // Header - no line number
927 assert!(!should_show_line_number(&lines[0]));
928
929 // Line 1 - yes line number (source after header)
930 assert!(should_show_line_number(&lines[1]));
931
932 // Line 2 start - yes line number
933 assert!(should_show_line_number(&lines[2]));
934
935 // Line 2 wrapped - no line number (continuation)
936 assert!(!should_show_line_number(&lines[3]));
937
938 // Line 3 - yes line number
939 assert!(should_show_line_number(&lines[4]));
940 }
941
942 #[test]
943 fn test_is_unprintable_byte() {
944 // Null byte is unprintable
945 assert!(is_unprintable_byte(0x00));
946
947 // Control characters 0x01-0x08 are unprintable
948 assert!(is_unprintable_byte(0x01));
949 assert!(is_unprintable_byte(0x02));
950 assert!(is_unprintable_byte(0x08));
951
952 // Tab (0x09) and LF (0x0A) are allowed
953 assert!(!is_unprintable_byte(0x09)); // tab
954 assert!(!is_unprintable_byte(0x0A)); // newline
955
956 // VT (0x0B), FF (0x0C), CR (0x0D) are unprintable in binary mode
957 assert!(is_unprintable_byte(0x0B)); // vertical tab
958 assert!(is_unprintable_byte(0x0C)); // form feed
959 assert!(is_unprintable_byte(0x0D)); // carriage return
960
961 // 0x0E-0x1F are all unprintable (including ESC)
962 assert!(is_unprintable_byte(0x0E));
963 assert!(is_unprintable_byte(0x1A)); // SUB - this is in PNG headers
964 assert!(is_unprintable_byte(0x1B)); // ESC
965 assert!(is_unprintable_byte(0x1C));
966 assert!(is_unprintable_byte(0x1F));
967
968 // Printable ASCII (0x20-0x7E) is allowed
969 assert!(!is_unprintable_byte(0x20)); // space
970 assert!(!is_unprintable_byte(0x41)); // 'A'
971 assert!(!is_unprintable_byte(0x7E)); // '~'
972
973 // DEL (0x7F) is unprintable
974 assert!(is_unprintable_byte(0x7F));
975
976 // High bytes (0x80+) are allowed (could be UTF-8)
977 assert!(!is_unprintable_byte(0x80));
978 assert!(!is_unprintable_byte(0xFF));
979 }
980
981 #[test]
982 fn test_format_unprintable_byte() {
983 assert_eq!(format_unprintable_byte(0x00), "<00>");
984 assert_eq!(format_unprintable_byte(0x01), "<01>");
985 assert_eq!(format_unprintable_byte(0x1A), "<1A>");
986 assert_eq!(format_unprintable_byte(0x7F), "<7F>");
987 assert_eq!(format_unprintable_byte(0xFF), "<FF>");
988 }
989
990 #[test]
991 fn test_binary_mode_renders_control_chars() {
992 // Text with null byte and control character
993 let tokens = vec![
994 ViewTokenWire {
995 kind: ViewTokenWireKind::Text("Hello\x00World\x01End".to_string()),
996 source_offset: Some(0),
997 style: None,
998 },
999 make_newline_token(Some(15)),
1000 ];
1001
1002 // Without binary mode - control chars would be rendered raw or as replacement
1003 let lines_normal: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1004 assert_eq!(lines_normal.len(), 1);
1005 // In normal mode, we don't format control chars specially
1006
1007 // With binary mode - control chars should be formatted as <XX>
1008 let lines_binary: Vec<_> = ViewLineIterator::new(&tokens, true, false, 4, false).collect();
1009 assert_eq!(lines_binary.len(), 1);
1010 assert!(
1011 lines_binary[0].text.contains("<00>"),
1012 "Binary mode should format null byte as <00>, got: {}",
1013 lines_binary[0].text
1014 );
1015 assert!(
1016 lines_binary[0].text.contains("<01>"),
1017 "Binary mode should format 0x01 as <01>, got: {}",
1018 lines_binary[0].text
1019 );
1020 }
1021
1022 #[test]
1023 fn test_binary_mode_png_header() {
1024 // PNG-like content with SUB control char (0x1A)
1025 // Using valid UTF-8 string with embedded control character
1026 let png_like = "PNG\r\n\x1A\n";
1027 let tokens = vec![ViewTokenWire {
1028 kind: ViewTokenWireKind::Text(png_like.to_string()),
1029 source_offset: Some(0),
1030 style: None,
1031 }];
1032
1033 let lines: Vec<_> = ViewLineIterator::new(&tokens, true, false, 4, false).collect();
1034
1035 // Should have rendered the 0x1A as <1A>
1036 let combined: String = lines.iter().map(|l| l.text.as_str()).collect();
1037 assert!(
1038 combined.contains("<1A>"),
1039 "PNG SUB byte (0x1A) should be rendered as <1A>, got: {:?}",
1040 combined
1041 );
1042 }
1043
1044 #[test]
1045 fn test_binary_mode_preserves_printable_chars() {
1046 let tokens = vec![
1047 ViewTokenWire {
1048 kind: ViewTokenWireKind::Text("Normal text 123".to_string()),
1049 source_offset: Some(0),
1050 style: None,
1051 },
1052 make_newline_token(Some(15)),
1053 ];
1054
1055 let lines: Vec<_> = ViewLineIterator::new(&tokens, true, false, 4, false).collect();
1056 assert_eq!(lines.len(), 1);
1057 assert!(
1058 lines[0].text.contains("Normal text 123"),
1059 "Printable chars should be preserved in binary mode"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_double_width_visual_mappings() {
1065 // "你好" - two Chinese characters, each 3 bytes and 2 columns wide
1066 // Byte layout: 你=bytes 0-2, 好=bytes 3-5
1067 // Visual layout: 你 takes columns 0-1, 好 takes columns 2-3
1068 let tokens = vec![
1069 make_text_token("你好", Some(0)),
1070 make_newline_token(Some(6)),
1071 ];
1072
1073 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1074 assert_eq!(lines.len(), 1);
1075
1076 // visual_to_char should have one entry per visual column
1077 // 你 = 2 columns, 好 = 2 columns, \n = 1 column = 5 total
1078 assert_eq!(
1079 lines[0].visual_width(),
1080 5,
1081 "Expected 5 visual columns (2 for 你 + 2 for 好 + 1 for newline), got {}",
1082 lines[0].visual_width()
1083 );
1084
1085 // char_source_bytes should have one entry per character
1086 // 3 characters: 你, 好, \n
1087 assert_eq!(
1088 lines[0].char_source_bytes.len(),
1089 3,
1090 "Expected 3 char entries (你, 好, newline), got {}",
1091 lines[0].char_source_bytes.len()
1092 );
1093
1094 // Both columns of 你 should map to byte 0 via O(1) lookup
1095 assert_eq!(
1096 lines[0].source_byte_at_visual_col(0),
1097 Some(0),
1098 "Column 0 should map to byte 0"
1099 );
1100 assert_eq!(
1101 lines[0].source_byte_at_visual_col(1),
1102 Some(0),
1103 "Column 1 should map to byte 0"
1104 );
1105
1106 // Both columns of 好 should map to byte 3
1107 assert_eq!(
1108 lines[0].source_byte_at_visual_col(2),
1109 Some(3),
1110 "Column 2 should map to byte 3"
1111 );
1112 assert_eq!(
1113 lines[0].source_byte_at_visual_col(3),
1114 Some(3),
1115 "Column 3 should map to byte 3"
1116 );
1117
1118 // Newline maps to byte 6
1119 assert_eq!(
1120 lines[0].source_byte_at_visual_col(4),
1121 Some(6),
1122 "Column 4 (newline) should map to byte 6"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_mixed_width_visual_mappings() {
1128 // "a你b" - ASCII, Chinese (2 cols), ASCII
1129 // Byte layout: a=0, 你=1-3, b=4
1130 // Visual columns: a=0, 你=1-2, b=3
1131 let tokens = vec![
1132 make_text_token("a你b", Some(0)),
1133 make_newline_token(Some(5)),
1134 ];
1135
1136 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1137 assert_eq!(lines.len(), 1);
1138
1139 // a=1 col, 你=2 cols, b=1 col, \n=1 col = 5 total visual width
1140 assert_eq!(
1141 lines[0].visual_width(),
1142 5,
1143 "Expected 5 visual columns, got {}",
1144 lines[0].visual_width()
1145 );
1146
1147 // 4 characters: a, 你, b, \n
1148 assert_eq!(
1149 lines[0].char_source_bytes.len(),
1150 4,
1151 "Expected 4 char entries, got {}",
1152 lines[0].char_source_bytes.len()
1153 );
1154
1155 // Test O(1) visual column to byte lookup
1156 assert_eq!(
1157 lines[0].source_byte_at_visual_col(0),
1158 Some(0),
1159 "Column 0 (a) should map to byte 0"
1160 );
1161 assert_eq!(
1162 lines[0].source_byte_at_visual_col(1),
1163 Some(1),
1164 "Column 1 (你 col 1) should map to byte 1"
1165 );
1166 assert_eq!(
1167 lines[0].source_byte_at_visual_col(2),
1168 Some(1),
1169 "Column 2 (你 col 2) should map to byte 1"
1170 );
1171 assert_eq!(
1172 lines[0].source_byte_at_visual_col(3),
1173 Some(4),
1174 "Column 3 (b) should map to byte 4"
1175 );
1176 assert_eq!(
1177 lines[0].source_byte_at_visual_col(4),
1178 Some(5),
1179 "Column 4 (newline) should map to byte 5"
1180 );
1181 }
1182
1183 // ==================== CRLF Mode Tests ====================
1184
1185 /// Test that ViewLineIterator correctly maps char_source_bytes for CRLF content.
1186 /// In CRLF mode, the Newline token is emitted at the \r position, and \n is skipped.
1187 /// This test verifies that char_source_bytes correctly tracks source byte positions.
1188 #[test]
1189 fn test_crlf_char_source_bytes_single_line() {
1190 // Simulate CRLF content "abc\r\n" where:
1191 // - bytes: a=0, b=1, c=2, \r=3, \n=4
1192 // - Newline token at source_offset=3 (position of \r)
1193 let tokens = vec![
1194 make_text_token("abc", Some(0)),
1195 make_newline_token(Some(3)), // \r position in CRLF
1196 ];
1197
1198 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1199 assert_eq!(lines.len(), 1);
1200
1201 // The ViewLine should have: 'a', 'b', 'c', '\n'
1202 assert_eq!(lines[0].text, "abc\n");
1203
1204 // char_source_bytes should correctly map each display char to source bytes
1205 assert_eq!(
1206 lines[0].char_source_bytes.len(),
1207 4,
1208 "Expected 4 chars: a, b, c, newline"
1209 );
1210 assert_eq!(
1211 lines[0].char_source_bytes[0],
1212 Some(0),
1213 "char 'a' should map to byte 0"
1214 );
1215 assert_eq!(
1216 lines[0].char_source_bytes[1],
1217 Some(1),
1218 "char 'b' should map to byte 1"
1219 );
1220 assert_eq!(
1221 lines[0].char_source_bytes[2],
1222 Some(2),
1223 "char 'c' should map to byte 2"
1224 );
1225 assert_eq!(
1226 lines[0].char_source_bytes[3],
1227 Some(3),
1228 "newline should map to byte 3 (\\r position)"
1229 );
1230 }
1231
1232 /// Test CRLF char_source_bytes across multiple lines.
1233 /// This is the critical test for the accumulating offset bug.
1234 #[test]
1235 fn test_crlf_char_source_bytes_multiple_lines() {
1236 // Simulate CRLF content "abc\r\ndef\r\nghi\r\n" where:
1237 // Line 1: a=0, b=1, c=2, \r=3, \n=4 (5 bytes)
1238 // Line 2: d=5, e=6, f=7, \r=8, \n=9 (5 bytes)
1239 // Line 3: g=10, h=11, i=12, \r=13, \n=14 (5 bytes)
1240 let tokens = vec![
1241 // Line 1
1242 make_text_token("abc", Some(0)),
1243 make_newline_token(Some(3)), // \r at byte 3
1244 // Line 2
1245 make_text_token("def", Some(5)),
1246 make_newline_token(Some(8)), // \r at byte 8
1247 // Line 3
1248 make_text_token("ghi", Some(10)),
1249 make_newline_token(Some(13)), // \r at byte 13
1250 ];
1251
1252 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1253 assert_eq!(lines.len(), 3);
1254
1255 // Line 1 verification
1256 assert_eq!(lines[0].text, "abc\n");
1257 assert_eq!(
1258 lines[0].char_source_bytes,
1259 vec![Some(0), Some(1), Some(2), Some(3)],
1260 "Line 1 char_source_bytes mismatch"
1261 );
1262
1263 // Line 2 verification - THIS IS WHERE THE BUG WOULD MANIFEST
1264 // If there's an off-by-one per line, line 2 might have wrong offsets
1265 assert_eq!(lines[1].text, "def\n");
1266 assert_eq!(
1267 lines[1].char_source_bytes,
1268 vec![Some(5), Some(6), Some(7), Some(8)],
1269 "Line 2 char_source_bytes mismatch - possible CRLF offset drift"
1270 );
1271
1272 // Line 3 verification - error accumulates
1273 assert_eq!(lines[2].text, "ghi\n");
1274 assert_eq!(
1275 lines[2].char_source_bytes,
1276 vec![Some(10), Some(11), Some(12), Some(13)],
1277 "Line 3 char_source_bytes mismatch - CRLF offset drift accumulated"
1278 );
1279 }
1280
1281 /// Test CRLF visual column to source byte mapping.
1282 /// Verifies source_byte_at_visual_col works correctly for CRLF content.
1283 #[test]
1284 fn test_crlf_visual_to_source_mapping() {
1285 // CRLF content "ab\r\ncd\r\n"
1286 // Line 1: a=0, b=1, \r=2, \n=3
1287 // Line 2: c=4, d=5, \r=6, \n=7
1288 let tokens = vec![
1289 make_text_token("ab", Some(0)),
1290 make_newline_token(Some(2)),
1291 make_text_token("cd", Some(4)),
1292 make_newline_token(Some(6)),
1293 ];
1294
1295 let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1296
1297 // Line 1: visual columns 0,1 should map to bytes 0,1
1298 assert_eq!(
1299 lines[0].source_byte_at_visual_col(0),
1300 Some(0),
1301 "Line 1 col 0"
1302 );
1303 assert_eq!(
1304 lines[0].source_byte_at_visual_col(1),
1305 Some(1),
1306 "Line 1 col 1"
1307 );
1308 assert_eq!(
1309 lines[0].source_byte_at_visual_col(2),
1310 Some(2),
1311 "Line 1 col 2 (newline)"
1312 );
1313
1314 // Line 2: visual columns 0,1 should map to bytes 4,5
1315 assert_eq!(
1316 lines[1].source_byte_at_visual_col(0),
1317 Some(4),
1318 "Line 2 col 0"
1319 );
1320 assert_eq!(
1321 lines[1].source_byte_at_visual_col(1),
1322 Some(5),
1323 "Line 2 col 1"
1324 );
1325 assert_eq!(
1326 lines[1].source_byte_at_visual_col(2),
1327 Some(6),
1328 "Line 2 col 2 (newline)"
1329 );
1330 }
1331}