Skip to main content

fresh/view/ui/split_rendering/
mod.rs

1//! Split pane layout and buffer rendering.
2//!
3//! This module is organized into two tiers:
4//!
5//! - **Self-contained leaves** (`spans`, `style`, `char_style`, `base_tokens`,
6//!   `transforms`, `view_data`, `folding`, `scrollbar`, `layout`, `gutter`,
7//!   `post_pass`) — none of these depend on any shared render-time carrier.
8//! - **Orchestration** (`orchestration::*`) — the only files that share
9//!   `SelectionContext` / `DecorationContext`. Quarantined in a subdirectory
10//!   so the coupling is visible from `ls` alone.
11//!
12//! The public API is re-exposed via the [`SplitRenderer`] façade at the
13//! bottom of this file; it forwards to `orchestration::*`.
14
15pub(crate) mod base_tokens;
16mod char_style;
17mod folding;
18mod gutter;
19mod layout;
20mod orchestration;
21mod post_pass;
22mod scrollbar;
23mod spans;
24mod style;
25pub(crate) mod transforms;
26mod view_data;
27
28use crate::app::types::ViewLineMapping;
29use crate::app::BufferMetadata;
30use crate::model::buffer::Buffer;
31use crate::model::event::{BufferId, EventLog, LeafId, SplitDirection};
32use crate::primitives::ansi_background::AnsiBackground;
33use crate::state::EditorState;
34use crate::view::split::SplitManager;
35use ratatui::layout::Rect;
36use ratatui::Frame;
37use std::collections::HashMap;
38
39/// Maximum line width before forced wrapping is applied, even when line wrapping is disabled.
40/// This prevents memory exhaustion when opening files with extremely long lines (e.g., 10MB
41/// single-line JSON files). Lines exceeding this width are wrapped into multiple visual lines,
42/// each bounded to this width. 10,000 columns is far wider than any monitor while keeping
43/// memory usage reasonable (~80KB per ViewLine instead of hundreds of MB).
44const MAX_SAFE_LINE_WIDTH: usize = 10_000;
45
46/// Public façade for split-pane rendering.
47///
48/// All logic lives in `orchestration::*`. This struct exists only to
49/// preserve the `SplitRenderer::…` call sites in the rest of the crate;
50/// nothing inside the `split_rendering` module references it.
51pub struct SplitRenderer;
52
53impl SplitRenderer {
54    #[allow(clippy::too_many_arguments)]
55    #[allow(clippy::type_complexity)]
56    pub fn render_content(
57        frame: &mut Frame,
58        area: Rect,
59        split_manager: &SplitManager,
60        buffers: &mut HashMap<BufferId, EditorState>,
61        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
62        event_logs: &mut HashMap<BufferId, EventLog>,
63        composite_buffers: &mut HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
64        composite_view_states: &mut HashMap<
65            (LeafId, BufferId),
66            crate::view::composite_view::CompositeViewState,
67        >,
68        theme: &crate::view::theme::Theme,
69        ansi_background: Option<&AnsiBackground>,
70        background_fade: f32,
71        lsp_waiting: bool,
72        large_file_threshold_bytes: u64,
73        line_wrap: bool,
74        estimated_line_length: usize,
75        highlight_context_bytes: usize,
76        split_view_states: Option<&mut HashMap<LeafId, crate::view::split::SplitViewState>>,
77        grouped_subtrees: &HashMap<LeafId, crate::view::split::SplitNode>,
78        hide_cursor: bool,
79        hovered_tab: Option<(crate::view::split::TabTarget, LeafId, bool)>,
80        hovered_close_split: Option<LeafId>,
81        hovered_maximize_split: Option<LeafId>,
82        is_maximized: bool,
83        relative_line_numbers: bool,
84        tab_bar_visible: bool,
85        use_terminal_bg: bool,
86        session_mode: bool,
87        software_cursor_only: bool,
88        show_vertical_scrollbar: bool,
89        show_horizontal_scrollbar: bool,
90        diagnostics_inline_text: bool,
91        show_tilde: bool,
92        highlight_current_column: bool,
93        cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
94        screen_width: u16,
95        pending_hardware_cursor: &mut Option<(u16, u16)>,
96    ) -> (
97        Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
98        HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
99        Vec<(LeafId, u16, u16, u16)>,
100        Vec<(LeafId, u16, u16, u16)>,
101        HashMap<LeafId, Vec<ViewLineMapping>>,
102        Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
103        Vec<(
104            crate::model::event::ContainerId,
105            SplitDirection,
106            u16,
107            u16,
108            u16,
109        )>,
110    ) {
111        orchestration::render_content(
112            frame,
113            area,
114            split_manager,
115            buffers,
116            buffer_metadata,
117            event_logs,
118            composite_buffers,
119            composite_view_states,
120            theme,
121            ansi_background,
122            background_fade,
123            lsp_waiting,
124            large_file_threshold_bytes,
125            line_wrap,
126            estimated_line_length,
127            highlight_context_bytes,
128            split_view_states,
129            grouped_subtrees,
130            hide_cursor,
131            hovered_tab,
132            hovered_close_split,
133            hovered_maximize_split,
134            is_maximized,
135            relative_line_numbers,
136            tab_bar_visible,
137            use_terminal_bg,
138            session_mode,
139            software_cursor_only,
140            show_vertical_scrollbar,
141            show_horizontal_scrollbar,
142            diagnostics_inline_text,
143            show_tilde,
144            highlight_current_column,
145            cell_theme_map,
146            screen_width,
147            pending_hardware_cursor,
148        )
149    }
150
151    #[allow(clippy::too_many_arguments)]
152    pub fn compute_content_layout(
153        area: Rect,
154        split_manager: &SplitManager,
155        buffers: &mut HashMap<BufferId, EditorState>,
156        split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
157        theme: &crate::view::theme::Theme,
158        lsp_waiting: bool,
159        estimated_line_length: usize,
160        highlight_context_bytes: usize,
161        relative_line_numbers: bool,
162        use_terminal_bg: bool,
163        session_mode: bool,
164        software_cursor_only: bool,
165        tab_bar_visible: bool,
166        show_vertical_scrollbar: bool,
167        show_horizontal_scrollbar: bool,
168        diagnostics_inline_text: bool,
169        show_tilde: bool,
170    ) -> HashMap<LeafId, Vec<ViewLineMapping>> {
171        orchestration::compute_content_layout(
172            area,
173            split_manager,
174            buffers,
175            split_view_states,
176            theme,
177            lsp_waiting,
178            estimated_line_length,
179            highlight_context_bytes,
180            relative_line_numbers,
181            use_terminal_bg,
182            session_mode,
183            software_cursor_only,
184            tab_bar_visible,
185            show_vertical_scrollbar,
186            show_horizontal_scrollbar,
187            diagnostics_inline_text,
188            show_tilde,
189        )
190    }
191
192    /// Public wrapper for building base tokens - used by render.rs for the
193    /// view_transform_request hook.
194    pub fn build_base_tokens_for_hook(
195        buffer: &mut Buffer,
196        top_byte: usize,
197        estimated_line_length: usize,
198        visible_count: usize,
199        is_binary: bool,
200        line_ending: crate::model::buffer::LineEnding,
201    ) -> Vec<fresh_core::api::ViewTokenWire> {
202        orchestration::build_base_tokens_for_hook(
203            buffer,
204            top_byte,
205            estimated_line_length,
206            visible_count,
207            is_binary,
208            line_ending,
209        )
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::folding::fold_indicators_for_viewport;
216    use super::layout::{calculate_view_anchor, calculate_viewport_end};
217    use super::orchestration::overlays::{decoration_context, selection_context};
218    use super::orchestration::render_buffer::resolve_cursor_fallback;
219    use super::orchestration::render_line::{
220        render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
221    };
222    use super::post_pass::apply_osc8_to_cells;
223    use super::transforms::apply_wrapping_transform;
224    use super::view_data::build_view_data;
225    use super::*;
226
227    use crate::model::buffer::{Buffer, LineEnding};
228    use crate::model::filesystem::StdFileSystem;
229    use crate::primitives::display_width::str_width;
230    use crate::state::{EditorState, ViewMode};
231    use crate::view::folding::FoldManager;
232    use crate::view::theme;
233    use crate::view::theme::Theme;
234    use crate::view::ui::view_pipeline::{LineStart, ViewLine};
235    use crate::view::viewport::Viewport;
236    use fresh_core::api::ViewTokenWire;
237    use lsp_types::FoldingRange;
238    use std::collections::HashSet;
239    use std::sync::Arc;
240
241    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
242        Arc::new(StdFileSystem)
243    }
244
245    fn render_output_for(
246        content: &str,
247        cursor_pos: usize,
248    ) -> (LineRenderOutput, usize, bool, usize) {
249        render_output_for_with_gutters(content, cursor_pos, false)
250    }
251
252    fn render_output_for_with_gutters(
253        content: &str,
254        cursor_pos: usize,
255        gutters_enabled: bool,
256    ) -> (LineRenderOutput, usize, bool, usize) {
257        let mut state = EditorState::new(20, 6, 1024, test_fs());
258        state.buffer = Buffer::from_str(content, 1024, test_fs());
259        let mut cursors = crate::model::cursor::Cursors::new();
260        cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
261        // Create a standalone viewport (no longer part of EditorState)
262        let viewport = Viewport::new(20, 4);
263        // Enable/disable line numbers/gutters based on parameter
264        state.margins.left_config.enabled = gutters_enabled;
265
266        let render_area = Rect::new(0, 0, 20, 4);
267        let visible_count = viewport.visible_line_count();
268        let gutter_width = state.margins.left_total_width();
269        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
270        let empty_folds = FoldManager::new();
271
272        let view_data = build_view_data(
273            &mut state,
274            &viewport,
275            None,
276            content.len().max(1),
277            visible_count,
278            false, // line wrap disabled for tests
279            render_area.width as usize,
280            gutter_width,
281            &ViewMode::Source, // Tests use source mode
282            &empty_folds,
283            &theme,
284        );
285        let view_anchor = calculate_view_anchor(&view_data.lines, 0);
286
287        let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
288        state.margins.update_width_for_buffer(estimated_lines, true);
289        let gutter_width = state.margins.left_total_width();
290
291        let selection = selection_context(&state, &cursors);
292        let _ = state
293            .buffer
294            .populate_line_cache(viewport.top_byte, visible_count);
295        let viewport_start = viewport.top_byte;
296        let viewport_end = calculate_viewport_end(
297            &mut state,
298            viewport_start,
299            content.len().max(1),
300            visible_count,
301        );
302        let decorations = decoration_context(
303            &mut state,
304            viewport_start,
305            viewport_end,
306            selection.primary_cursor_position,
307            &empty_folds,
308            &theme,
309            100_000,           // default highlight context bytes
310            &ViewMode::Source, // Tests use source mode
311            false,             // inline diagnostics off for test
312            &[],
313        );
314
315        let mut dummy_theme_map = Vec::new();
316        let output = render_view_lines(LineRenderInput {
317            state: &state,
318            theme: &theme,
319            view_lines: &view_data.lines,
320            view_anchor,
321            render_area,
322            gutter_width,
323            selection: &selection,
324            decorations: &decorations,
325            visible_line_count: visible_count,
326            lsp_waiting: false,
327            is_active: true,
328            line_wrap: viewport.line_wrap_enabled,
329            estimated_lines,
330            left_column: viewport.left_column,
331            relative_line_numbers: false,
332            session_mode: false,
333            software_cursor_only: false,
334            show_line_numbers: true, // Tests show line numbers
335            byte_offset_mode: false, // Tests use exact line numbers
336            show_tilde: true,
337            highlight_current_line: true,
338            cell_theme_map: &mut dummy_theme_map,
339            screen_width: 0,
340        });
341
342        (
343            output,
344            state.buffer.len(),
345            content.ends_with('\n'),
346            selection.primary_cursor_position,
347        )
348    }
349
350    #[test]
351    fn test_folding_hides_lines_and_adds_placeholder() {
352        let content = "header\nline1\nline2\ntail\n";
353        let mut state = EditorState::new(40, 6, 1024, test_fs());
354        state.buffer = Buffer::from_str(content, 1024, test_fs());
355
356        let start = state.buffer.line_start_offset(1).unwrap();
357        let end = state.buffer.line_start_offset(3).unwrap();
358        let mut folds = FoldManager::new();
359        folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
360
361        let viewport = Viewport::new(40, 6);
362        let gutter_width = state.margins.left_total_width();
363        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
364        let view_data = build_view_data(
365            &mut state,
366            &viewport,
367            None,
368            content.len().max(1),
369            viewport.visible_line_count(),
370            false,
371            40,
372            gutter_width,
373            &ViewMode::Source,
374            &folds,
375            &theme,
376        );
377
378        let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
379        assert!(lines.iter().any(|l| l.contains("header")));
380        assert!(lines.iter().any(|l| l.contains("tail")));
381        assert!(!lines.iter().any(|l| l.contains("line1")));
382        assert!(!lines.iter().any(|l| l.contains("line2")));
383        assert!(lines
384            .iter()
385            .any(|l| l.contains("header") && l.contains("...")));
386    }
387
388    #[test]
389    fn test_fold_indicators_collapsed_and_expanded() {
390        let content = "a\nb\nc\nd\n";
391        let mut state = EditorState::new(40, 6, 1024, test_fs());
392        state.buffer = Buffer::from_str(content, 1024, test_fs());
393
394        let lsp_ranges = vec![
395            FoldingRange {
396                start_line: 0,
397                end_line: 1,
398                start_character: None,
399                end_character: None,
400                kind: None,
401                collapsed_text: None,
402            },
403            FoldingRange {
404                start_line: 1,
405                end_line: 2,
406                start_character: None,
407                end_character: None,
408                kind: None,
409                collapsed_text: None,
410            },
411        ];
412        state
413            .folding_ranges
414            .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
415
416        let start = state.buffer.line_start_offset(1).unwrap();
417        let end = state.buffer.line_start_offset(2).unwrap();
418        let mut folds = FoldManager::new();
419        folds.add(&mut state.marker_list, start, end, None);
420
421        let line1_byte = state.buffer.line_start_offset(1).unwrap();
422        let view_lines = vec![ViewLine {
423            text: "b\n".to_string(),
424            source_start_byte: Some(line1_byte),
425            char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
426            char_styles: vec![None, None],
427            char_visual_cols: vec![0, 1],
428            visual_to_char: vec![0, 1],
429            tab_starts: HashSet::new(),
430            line_start: LineStart::AfterSourceNewline,
431            ends_with_newline: true,
432        }];
433
434        let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
435
436        // Collapsed fold: header is line 0 (byte 0)
437        assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
438        // LSP range starting at line 1 (byte 2, since "a\n" is 2 bytes)
439        assert_eq!(
440            indicators.get(&line1_byte).map(|i| i.collapsed),
441            Some(false)
442        );
443    }
444
445    #[test]
446    fn last_line_end_tracks_trailing_newline() {
447        let output = render_output_for("abc\n", 4);
448        assert_eq!(
449            output.0.last_line_end,
450            Some(LastLineEnd {
451                pos: (3, 0),
452                terminated_with_newline: true
453            })
454        );
455    }
456
457    #[test]
458    fn last_line_end_tracks_no_trailing_newline() {
459        let output = render_output_for("abc", 3);
460        assert_eq!(
461            output.0.last_line_end,
462            Some(LastLineEnd {
463                pos: (3, 0),
464                terminated_with_newline: false
465            })
466        );
467    }
468
469    #[test]
470    fn cursor_after_newline_places_on_next_line() {
471        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
472        let cursor = resolve_cursor_fallback(
473            output.cursor,
474            cursor_pos,
475            buffer_len,
476            buffer_newline,
477            output.last_line_end,
478            output.content_lines_rendered,
479            0, // gutter_width (gutters disabled in tests)
480        );
481        assert_eq!(cursor, Some((0, 1)));
482    }
483
484    #[test]
485    fn cursor_at_end_without_newline_stays_on_line() {
486        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
487        let cursor = resolve_cursor_fallback(
488            output.cursor,
489            cursor_pos,
490            buffer_len,
491            buffer_newline,
492            output.last_line_end,
493            output.content_lines_rendered,
494            0, // gutter_width (gutters disabled in tests)
495        );
496        assert_eq!(cursor, Some((3, 0)));
497    }
498
499    // Helper to count all cursor positions in rendered output
500    // Cursors can appear as:
501    // 1. Primary cursor in output.cursor (hardware cursor position)
502    // 2. Visual spans with REVERSED modifier (secondary cursors, or primary cursor with contrast fix)
503    // 3. Visual spans with special background color (inactive cursors)
504    fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
505        let mut cursor_positions = Vec::new();
506
507        // Check for primary cursor in output.cursor field
508        let primary_cursor = output.cursor;
509        if let Some(cursor_pos) = primary_cursor {
510            cursor_positions.push(cursor_pos);
511        }
512
513        // Check for visual cursor indicators in rendered spans (secondary/inactive cursors)
514        for (line_idx, line) in output.lines.iter().enumerate() {
515            let mut col = 0u16;
516            for span in line.spans.iter() {
517                // Check if this span has the REVERSED modifier (secondary cursor)
518                if span
519                    .style
520                    .add_modifier
521                    .contains(ratatui::style::Modifier::REVERSED)
522                {
523                    let pos = (col, line_idx as u16);
524                    // Only add if this is not the primary cursor position
525                    // (primary cursor may also have REVERSED for contrast)
526                    if primary_cursor != Some(pos) {
527                        cursor_positions.push(pos);
528                    }
529                }
530                // Count the visual width of this span's content
531                col += str_width(&span.content) as u16;
532            }
533        }
534
535        cursor_positions
536    }
537
538    // Helper to dump rendered output for debugging
539    fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
540        eprintln!("\n=== RENDER DEBUG ===");
541        eprintln!("Content: {:?}", content);
542        eprintln!("Cursor position: {}", cursor_pos);
543        eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
544        eprintln!("Last line end: {:?}", output.last_line_end);
545        eprintln!("Content lines rendered: {}", output.content_lines_rendered);
546        eprintln!("\nRendered lines:");
547        for (line_idx, line) in output.lines.iter().enumerate() {
548            eprintln!("  Line {}: {} spans", line_idx, line.spans.len());
549            for (span_idx, span) in line.spans.iter().enumerate() {
550                let has_reversed = span
551                    .style
552                    .add_modifier
553                    .contains(ratatui::style::Modifier::REVERSED);
554                let bg_color = format!("{:?}", span.style.bg);
555                eprintln!(
556                    "    Span {}: {:?} (REVERSED: {}, BG: {})",
557                    span_idx, span.content, has_reversed, bg_color
558                );
559            }
560        }
561        eprintln!("===================\n");
562    }
563
564    // Helper to get final cursor position after fallback resolution
565    // Also validates that exactly one cursor is present
566    fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
567        let (output, buffer_len, buffer_newline, cursor_pos) =
568            render_output_for(content, cursor_pos);
569
570        // Count all cursors (hardware + visual) in the rendered output
571        let all_cursors = count_all_cursors(&output);
572
573        // Validate that at most one cursor is present in rendered output
574        // (Some cursors are added by fallback logic, not during rendering)
575        assert!(
576            all_cursors.len() <= 1,
577            "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
578            all_cursors.len(),
579            all_cursors
580        );
581
582        let final_cursor = resolve_cursor_fallback(
583            output.cursor,
584            cursor_pos,
585            buffer_len,
586            buffer_newline,
587            output.last_line_end,
588            output.content_lines_rendered,
589            0, // gutter_width (gutters disabled in tests)
590        );
591
592        // Debug dump if we find unexpected results
593        if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
594        {
595            dump_render_output(content, cursor_pos, &output);
596        }
597
598        // If a cursor was rendered, it should match the final cursor position
599        if let Some(rendered_cursor) = all_cursors.first() {
600            assert_eq!(
601                Some(*rendered_cursor),
602                final_cursor,
603                "Rendered cursor at {:?} doesn't match final cursor {:?}",
604                rendered_cursor,
605                final_cursor
606            );
607        }
608
609        // Validate that we have a final cursor position (either rendered or from fallback)
610        assert!(
611            final_cursor.is_some(),
612            "Expected a final cursor position, but got None. Rendered cursors: {:?}",
613            all_cursors
614        );
615
616        final_cursor
617    }
618
619    // Helper to simulate typing a character and check if it appears at cursor position
620    fn check_typing_at_cursor(
621        content: &str,
622        cursor_pos: usize,
623        char_to_type: char,
624    ) -> (Option<(u16, u16)>, String) {
625        // Get cursor position before typing
626        let cursor_before = get_final_cursor(content, cursor_pos);
627
628        // Simulate inserting the character at cursor position
629        let mut new_content = content.to_string();
630        if cursor_pos <= content.len() {
631            new_content.insert(cursor_pos, char_to_type);
632        }
633
634        (cursor_before, new_content)
635    }
636
637    #[test]
638    fn e2e_cursor_at_start_of_nonempty_line() {
639        // "abc" with cursor at position 0 (before 'a')
640        let cursor = get_final_cursor("abc", 0);
641        assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
642
643        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
644        assert_eq!(
645            new_content, "Xabc",
646            "Typing should insert at cursor position"
647        );
648        assert_eq!(cursor_pos, Some((0, 0)));
649    }
650
651    #[test]
652    fn e2e_cursor_in_middle_of_line() {
653        // "abc" with cursor at position 1 (on 'b')
654        let cursor = get_final_cursor("abc", 1);
655        assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
656
657        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
658        assert_eq!(
659            new_content, "aXbc",
660            "Typing should insert at cursor position"
661        );
662        assert_eq!(cursor_pos, Some((1, 0)));
663    }
664
665    #[test]
666    fn e2e_cursor_at_end_of_line_no_newline() {
667        // "abc" with cursor at position 3 (after 'c', at EOF)
668        let cursor = get_final_cursor("abc", 3);
669        assert_eq!(
670            cursor,
671            Some((3, 0)),
672            "Cursor should be at column 3, line 0 (after last char)"
673        );
674
675        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
676        assert_eq!(new_content, "abcX", "Typing should append at end");
677        assert_eq!(cursor_pos, Some((3, 0)));
678    }
679
680    #[test]
681    fn e2e_cursor_at_empty_line() {
682        // "\n" with cursor at position 0 (on the newline itself)
683        let cursor = get_final_cursor("\n", 0);
684        assert_eq!(
685            cursor,
686            Some((0, 0)),
687            "Cursor on empty line should be at column 0"
688        );
689
690        let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
691        assert_eq!(new_content, "X\n", "Typing should insert before newline");
692        assert_eq!(cursor_pos, Some((0, 0)));
693    }
694
695    #[test]
696    fn e2e_cursor_after_newline_at_eof() {
697        // "abc\n" with cursor at position 4 (after newline, at EOF)
698        let cursor = get_final_cursor("abc\n", 4);
699        assert_eq!(
700            cursor,
701            Some((0, 1)),
702            "Cursor after newline at EOF should be on next line"
703        );
704
705        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
706        assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
707        assert_eq!(cursor_pos, Some((0, 1)));
708    }
709
710    #[test]
711    fn e2e_cursor_on_newline_with_content() {
712        // "abc\n" with cursor at position 3 (on the newline character)
713        let cursor = get_final_cursor("abc\n", 3);
714        assert_eq!(
715            cursor,
716            Some((3, 0)),
717            "Cursor on newline after content should be after last char"
718        );
719
720        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
721        assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
722        assert_eq!(cursor_pos, Some((3, 0)));
723    }
724
725    #[test]
726    fn e2e_cursor_multiline_start_of_second_line() {
727        // "abc\ndef" with cursor at position 4 (start of second line, on 'd')
728        let cursor = get_final_cursor("abc\ndef", 4);
729        assert_eq!(
730            cursor,
731            Some((0, 1)),
732            "Cursor at start of second line should be at column 0, line 1"
733        );
734
735        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
736        assert_eq!(
737            new_content, "abc\nXdef",
738            "Typing should insert at start of second line"
739        );
740        assert_eq!(cursor_pos, Some((0, 1)));
741    }
742
743    #[test]
744    fn e2e_cursor_multiline_end_of_first_line() {
745        // "abc\ndef" with cursor at position 3 (on newline of first line)
746        let cursor = get_final_cursor("abc\ndef", 3);
747        assert_eq!(
748            cursor,
749            Some((3, 0)),
750            "Cursor on newline of first line should be after content"
751        );
752
753        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
754        assert_eq!(
755            new_content, "abcX\ndef",
756            "Typing should insert before newline"
757        );
758        assert_eq!(cursor_pos, Some((3, 0)));
759    }
760
761    #[test]
762    fn e2e_cursor_empty_buffer() {
763        // Empty buffer with cursor at position 0
764        let cursor = get_final_cursor("", 0);
765        assert_eq!(
766            cursor,
767            Some((0, 0)),
768            "Cursor in empty buffer should be at origin"
769        );
770
771        let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
772        assert_eq!(
773            new_content, "X",
774            "Typing in empty buffer should insert character"
775        );
776        assert_eq!(cursor_pos, Some((0, 0)));
777    }
778
779    #[test]
780    fn e2e_cursor_empty_buffer_with_gutters() {
781        // Empty buffer with cursor at position 0, with gutters enabled
782        // The cursor should be positioned at the gutter width (right after the gutter),
783        // NOT at column 0 (which would be in the gutter area)
784        let (output, buffer_len, buffer_newline, cursor_pos) =
785            render_output_for_with_gutters("", 0, true);
786
787        // With gutters enabled, the gutter width should be > 0
788        // Default gutter includes: 1 char indicator + line number width + separator
789        // For a 1-line buffer, line number width is typically 1 digit + padding
790        let gutter_width = {
791            let mut state = EditorState::new(20, 6, 1024, test_fs());
792            state.margins.left_config.enabled = true;
793            state.margins.update_width_for_buffer(1, true);
794            state.margins.left_total_width()
795        };
796        assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
797
798        // CRITICAL: Check the RENDERED cursor position directly from output.cursor
799        // This is what the terminal will actually use for cursor positioning
800        // The cursor should be rendered at gutter_width, not at 0
801        assert_eq!(
802            output.cursor,
803            Some((gutter_width as u16, 0)),
804            "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
805            gutter_width,
806            output.cursor
807        );
808
809        let final_cursor = resolve_cursor_fallback(
810            output.cursor,
811            cursor_pos,
812            buffer_len,
813            buffer_newline,
814            output.last_line_end,
815            output.content_lines_rendered,
816            gutter_width,
817        );
818
819        // Cursor should be at (gutter_width, 0) - right after the gutter on line 0
820        assert_eq!(
821            final_cursor,
822            Some((gutter_width as u16, 0)),
823            "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
824        );
825    }
826
827    #[test]
828    fn e2e_cursor_between_empty_lines() {
829        // "\n\n" with cursor at position 1 (on second newline)
830        let cursor = get_final_cursor("\n\n", 1);
831        assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
832
833        let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
834        assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
835        assert_eq!(cursor_pos, Some((0, 1)));
836    }
837
838    #[test]
839    fn e2e_cursor_at_eof_after_multiple_lines() {
840        // "abc\ndef\nghi" with cursor at position 11 (at EOF, no trailing newline)
841        let cursor = get_final_cursor("abc\ndef\nghi", 11);
842        assert_eq!(
843            cursor,
844            Some((3, 2)),
845            "Cursor at EOF after 'i' should be at column 3, line 2"
846        );
847
848        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
849        assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
850        assert_eq!(cursor_pos, Some((3, 2)));
851    }
852
853    #[test]
854    fn e2e_cursor_at_eof_with_trailing_newline() {
855        // "abc\ndef\nghi\n" with cursor at position 12 (after trailing newline)
856        let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
857        assert_eq!(
858            cursor,
859            Some((0, 3)),
860            "Cursor after trailing newline should be on line 3"
861        );
862
863        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
864        assert_eq!(
865            new_content, "abc\ndef\nghi\nX",
866            "Typing should insert on new line"
867        );
868        assert_eq!(cursor_pos, Some((0, 3)));
869    }
870
871    #[test]
872    fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
873        // Simulate Ctrl+End: jump from start to end of buffer without trailing newline
874        let content = "abc\ndef\nghi";
875
876        // Start at position 0
877        let cursor_at_start = get_final_cursor(content, 0);
878        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
879
880        // Jump to EOF (position 11, after 'i')
881        let cursor_at_eof = get_final_cursor(content, 11);
882        assert_eq!(
883            cursor_at_eof,
884            Some((3, 2)),
885            "After Ctrl+End, cursor at column 3, line 2"
886        );
887
888        // Type a character at EOF
889        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
890        assert_eq!(cursor_before_typing, Some((3, 2)));
891        assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
892
893        // Verify cursor position in the new content
894        let cursor_after_typing = get_final_cursor(&new_content, 12);
895        assert_eq!(
896            cursor_after_typing,
897            Some((4, 2)),
898            "After typing, cursor moved to column 4"
899        );
900
901        // Move cursor to start of buffer - verify cursor is no longer at end
902        let cursor_moved_away = get_final_cursor(&new_content, 0);
903        assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
904        // The cursor should NOT be at the end anymore - verify by rendering without cursor at end
905        // This implicitly tests that only one cursor is rendered
906    }
907
908    #[test]
909    fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
910        // Simulate Ctrl+End: jump from start to end of buffer WITH trailing newline
911        let content = "abc\ndef\nghi\n";
912
913        // Start at position 0
914        let cursor_at_start = get_final_cursor(content, 0);
915        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
916
917        // Jump to EOF (position 12, after trailing newline)
918        let cursor_at_eof = get_final_cursor(content, 12);
919        assert_eq!(
920            cursor_at_eof,
921            Some((0, 3)),
922            "After Ctrl+End, cursor at column 0, line 3 (new line)"
923        );
924
925        // Type a character at EOF
926        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
927        assert_eq!(cursor_before_typing, Some((0, 3)));
928        assert_eq!(
929            new_content, "abc\ndef\nghi\nX",
930            "Character inserted on new line"
931        );
932
933        // After typing, the cursor should move forward
934        let cursor_after_typing = get_final_cursor(&new_content, 13);
935        assert_eq!(
936            cursor_after_typing,
937            Some((1, 3)),
938            "After typing, cursor should be at column 1, line 3"
939        );
940
941        // Move cursor to middle of buffer - verify cursor is no longer at end
942        let cursor_moved_away = get_final_cursor(&new_content, 4);
943        assert_eq!(
944            cursor_moved_away,
945            Some((0, 1)),
946            "Cursor moved to start of line 1 (position 4 = start of 'def')"
947        );
948    }
949
950    #[test]
951    fn e2e_jump_to_end_of_empty_buffer() {
952        // Edge case: Ctrl+End in empty buffer should stay at (0,0)
953        let content = "";
954
955        let cursor_at_eof = get_final_cursor(content, 0);
956        assert_eq!(
957            cursor_at_eof,
958            Some((0, 0)),
959            "Empty buffer: cursor at origin"
960        );
961
962        // Type a character
963        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
964        assert_eq!(cursor_before_typing, Some((0, 0)));
965        assert_eq!(new_content, "X", "Character inserted");
966
967        // Verify cursor after typing
968        let cursor_after_typing = get_final_cursor(&new_content, 1);
969        assert_eq!(
970            cursor_after_typing,
971            Some((1, 0)),
972            "After typing, cursor at column 1"
973        );
974
975        // Move cursor back to start - verify cursor is no longer at end
976        let cursor_moved_away = get_final_cursor(&new_content, 0);
977        assert_eq!(
978            cursor_moved_away,
979            Some((0, 0)),
980            "Cursor moved back to start"
981        );
982    }
983
984    #[test]
985    fn e2e_jump_to_end_of_single_empty_line() {
986        // Edge case: buffer with just a newline
987        let content = "\n";
988
989        // Position 0 is ON the newline
990        let cursor_on_newline = get_final_cursor(content, 0);
991        assert_eq!(
992            cursor_on_newline,
993            Some((0, 0)),
994            "Cursor on the newline character"
995        );
996
997        // Position 1 is AFTER the newline (EOF)
998        let cursor_at_eof = get_final_cursor(content, 1);
999        assert_eq!(
1000            cursor_at_eof,
1001            Some((0, 1)),
1002            "After Ctrl+End, cursor on line 1"
1003        );
1004
1005        // Type at EOF
1006        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1007        assert_eq!(cursor_before_typing, Some((0, 1)));
1008        assert_eq!(new_content, "\nX", "Character on second line");
1009
1010        let cursor_after_typing = get_final_cursor(&new_content, 2);
1011        assert_eq!(
1012            cursor_after_typing,
1013            Some((1, 1)),
1014            "After typing, cursor at column 1, line 1"
1015        );
1016
1017        // Move cursor to the newline - verify cursor is no longer at end
1018        let cursor_moved_away = get_final_cursor(&new_content, 0);
1019        assert_eq!(
1020            cursor_moved_away,
1021            Some((0, 0)),
1022            "Cursor moved to the newline on line 0"
1023        );
1024    }
1025    // NOTE: Tests for view transform header handling have been moved to src/ui/view_pipeline.rs
1026    // where the elegant token-based pipeline properly handles these cases.
1027    // The view_pipeline tests cover:
1028    // - test_simple_source_lines
1029    // - test_wrapped_continuation
1030    // - test_injected_header_then_source
1031    // - test_mixed_scenario
1032
1033    // ==================== CRLF Tokenization Tests ====================
1034
1035    use fresh_core::api::ViewTokenWireKind;
1036
1037    /// Helper to extract source_offset from tokens for easier assertion
1038    fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1039        tokens
1040            .iter()
1041            .map(|t| {
1042                let kind_str = match &t.kind {
1043                    ViewTokenWireKind::Text(s) => format!("Text({})", s),
1044                    ViewTokenWireKind::Newline => "Newline".to_string(),
1045                    ViewTokenWireKind::Space => "Space".to_string(),
1046                    ViewTokenWireKind::Break => "Break".to_string(),
1047                    ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1048                };
1049                (kind_str, t.source_offset)
1050            })
1051            .collect()
1052    }
1053
1054    /// Test tokenization of CRLF content with a single line.
1055    /// Verifies that Newline token is at \r position and \n is skipped.
1056    #[test]
1057    fn test_build_base_tokens_crlf_single_line() {
1058        // Content: "abc\r\n" (5 bytes: a=0, b=1, c=2, \r=3, \n=4)
1059        let content = b"abc\r\n";
1060        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1061        buffer.set_line_ending(LineEnding::CRLF);
1062
1063        let tokens = SplitRenderer::build_base_tokens_for_hook(
1064            &mut buffer,
1065            0,     // top_byte
1066            80,    // estimated_line_length
1067            10,    // visible_count
1068            false, // is_binary
1069            LineEnding::CRLF,
1070        );
1071
1072        let offsets = extract_token_offsets(&tokens);
1073
1074        // Should have: Text("abc") at 0, Newline at 3
1075        // The \n at byte 4 should be skipped
1076        assert!(
1077            offsets
1078                .iter()
1079                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1080            "Expected Text(abc) at offset 0, got: {:?}",
1081            offsets
1082        );
1083        assert!(
1084            offsets
1085                .iter()
1086                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1087            "Expected Newline at offset 3 (\\r position), got: {:?}",
1088            offsets
1089        );
1090
1091        // Verify there's only one Newline token
1092        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1093        assert_eq!(
1094            newline_count, 1,
1095            "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1096            newline_count, offsets
1097        );
1098    }
1099
1100    /// Test tokenization of CRLF content with multiple lines.
1101    /// This verifies that source_offset correctly accumulates across lines.
1102    #[test]
1103    fn test_build_base_tokens_crlf_multiple_lines() {
1104        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
1105        // Line 1: a=0, b=1, c=2, \r=3, \n=4
1106        // Line 2: d=5, e=6, f=7, \r=8, \n=9
1107        // Line 3: g=10, h=11, i=12, \r=13, \n=14
1108        let content = b"abc\r\ndef\r\nghi\r\n";
1109        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1110        buffer.set_line_ending(LineEnding::CRLF);
1111
1112        let tokens = SplitRenderer::build_base_tokens_for_hook(
1113            &mut buffer,
1114            0,
1115            80,
1116            10,
1117            false,
1118            LineEnding::CRLF,
1119        );
1120
1121        let offsets = extract_token_offsets(&tokens);
1122
1123        // Expected tokens:
1124        // Text("abc") at 0, Newline at 3
1125        // Text("def") at 5, Newline at 8
1126        // Text("ghi") at 10, Newline at 13
1127
1128        // Verify line 1 tokens
1129        assert!(
1130            offsets
1131                .iter()
1132                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1133            "Line 1: Expected Text(abc) at 0, got: {:?}",
1134            offsets
1135        );
1136        assert!(
1137            offsets
1138                .iter()
1139                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1140            "Line 1: Expected Newline at 3, got: {:?}",
1141            offsets
1142        );
1143
1144        // Verify line 2 tokens - THIS IS WHERE OFFSET DRIFT WOULD APPEAR
1145        assert!(
1146            offsets
1147                .iter()
1148                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1149            "Line 2: Expected Text(def) at 5, got: {:?}",
1150            offsets
1151        );
1152        assert!(
1153            offsets
1154                .iter()
1155                .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1156            "Line 2: Expected Newline at 8, got: {:?}",
1157            offsets
1158        );
1159
1160        // Verify line 3 tokens - DRIFT ACCUMULATES HERE
1161        assert!(
1162            offsets
1163                .iter()
1164                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1165            "Line 3: Expected Text(ghi) at 10, got: {:?}",
1166            offsets
1167        );
1168        assert!(
1169            offsets
1170                .iter()
1171                .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
1172            "Line 3: Expected Newline at 13, got: {:?}",
1173            offsets
1174        );
1175
1176        // Verify exactly 3 Newline tokens
1177        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1178        assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
1179    }
1180
1181    /// Test tokenization of LF content to compare with CRLF.
1182    /// LF mode should NOT skip anything - each character gets its own offset.
1183    #[test]
1184    fn test_build_base_tokens_lf_mode_for_comparison() {
1185        // Content: "abc\ndef\n" (8 bytes)
1186        // Line 1: a=0, b=1, c=2, \n=3
1187        // Line 2: d=4, e=5, f=6, \n=7
1188        let content = b"abc\ndef\n";
1189        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1190        buffer.set_line_ending(LineEnding::LF);
1191
1192        let tokens = SplitRenderer::build_base_tokens_for_hook(
1193            &mut buffer,
1194            0,
1195            80,
1196            10,
1197            false,
1198            LineEnding::LF,
1199        );
1200
1201        let offsets = extract_token_offsets(&tokens);
1202
1203        // Verify LF offsets
1204        assert!(
1205            offsets
1206                .iter()
1207                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1208            "LF Line 1: Expected Text(abc) at 0"
1209        );
1210        assert!(
1211            offsets
1212                .iter()
1213                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1214            "LF Line 1: Expected Newline at 3"
1215        );
1216        assert!(
1217            offsets
1218                .iter()
1219                .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
1220            "LF Line 2: Expected Text(def) at 4"
1221        );
1222        assert!(
1223            offsets
1224                .iter()
1225                .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
1226            "LF Line 2: Expected Newline at 7"
1227        );
1228    }
1229
1230    /// Test that CRLF in LF-mode file shows \r as control character.
1231    /// This verifies that \r is rendered as <0D> in LF files.
1232    #[test]
1233    fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
1234        // Content: "abc\r\n" but buffer is in LF mode
1235        let content = b"abc\r\n";
1236        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1237        buffer.set_line_ending(LineEnding::LF); // Force LF mode
1238
1239        let tokens = SplitRenderer::build_base_tokens_for_hook(
1240            &mut buffer,
1241            0,
1242            80,
1243            10,
1244            false,
1245            LineEnding::LF,
1246        );
1247
1248        let offsets = extract_token_offsets(&tokens);
1249
1250        // In LF mode, \r should be rendered as BinaryByte(0x0d)
1251        assert!(
1252            offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
1253            "LF mode should render \\r as control char <0D>, got: {:?}",
1254            offsets
1255        );
1256    }
1257
1258    /// Test tokenization starting from middle of file (top_byte != 0).
1259    /// Verifies that source_offset is correct even when not starting from byte 0.
1260    #[test]
1261    fn test_build_base_tokens_crlf_from_middle() {
1262        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
1263        // Start from byte 5 (beginning of "def")
1264        let content = b"abc\r\ndef\r\nghi\r\n";
1265        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1266        buffer.set_line_ending(LineEnding::CRLF);
1267
1268        let tokens = SplitRenderer::build_base_tokens_for_hook(
1269            &mut buffer,
1270            5, // Start from line 2
1271            80,
1272            10,
1273            false,
1274            LineEnding::CRLF,
1275        );
1276
1277        let offsets = extract_token_offsets(&tokens);
1278
1279        // Should have:
1280        // Text("def") at 5, Newline at 8
1281        // Text("ghi") at 10, Newline at 13
1282        assert!(
1283            offsets
1284                .iter()
1285                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1286            "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
1287            offsets
1288        );
1289        assert!(
1290            offsets
1291                .iter()
1292                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1293            "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
1294            offsets
1295        );
1296    }
1297
1298    /// End-to-end test: verify full pipeline from CRLF buffer to ViewLine to highlighting lookup
1299    /// This test simulates the complete flow that would trigger the offset drift bug.
1300    #[test]
1301    fn test_crlf_highlight_span_lookup() {
1302        use crate::view::ui::view_pipeline::ViewLineIterator;
1303
1304        // Simulate Java-like CRLF content:
1305        // "int x;\r\nint y;\r\n"
1306        // Bytes: i=0, n=1, t=2, ' '=3, x=4, ;=5, \r=6, \n=7,
1307        //        i=8, n=9, t=10, ' '=11, y=12, ;=13, \r=14, \n=15
1308        let content = b"int x;\r\nint y;\r\n";
1309        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1310        buffer.set_line_ending(LineEnding::CRLF);
1311
1312        // Step 1: Generate tokens
1313        let tokens = SplitRenderer::build_base_tokens_for_hook(
1314            &mut buffer,
1315            0,
1316            80,
1317            10,
1318            false,
1319            LineEnding::CRLF,
1320        );
1321
1322        // Verify tokens have correct offsets
1323        let offsets = extract_token_offsets(&tokens);
1324        eprintln!("Tokens: {:?}", offsets);
1325
1326        // Step 2: Convert tokens to ViewLines
1327        let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1328        assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
1329
1330        // Step 3: Verify char_source_bytes mapping for each line
1331        // Line 1: "int x;\n" displayed, maps to bytes 0-6
1332        eprintln!(
1333            "Line 1 char_source_bytes: {:?}",
1334            view_lines[0].char_source_bytes
1335        );
1336        assert_eq!(
1337            view_lines[0].char_source_bytes.len(),
1338            7,
1339            "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
1340        );
1341        // Check specific mappings
1342        assert_eq!(
1343            view_lines[0].char_source_bytes[0],
1344            Some(0),
1345            "Line 1 'i' -> byte 0"
1346        );
1347        assert_eq!(
1348            view_lines[0].char_source_bytes[4],
1349            Some(4),
1350            "Line 1 'x' -> byte 4"
1351        );
1352        assert_eq!(
1353            view_lines[0].char_source_bytes[5],
1354            Some(5),
1355            "Line 1 ';' -> byte 5"
1356        );
1357        assert_eq!(
1358            view_lines[0].char_source_bytes[6],
1359            Some(6),
1360            "Line 1 newline -> byte 6 (\\r pos)"
1361        );
1362
1363        // Line 2: "int y;\n" displayed, maps to bytes 8-14
1364        eprintln!(
1365            "Line 2 char_source_bytes: {:?}",
1366            view_lines[1].char_source_bytes
1367        );
1368        assert_eq!(
1369            view_lines[1].char_source_bytes.len(),
1370            7,
1371            "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
1372        );
1373        // Check specific mappings - THIS IS WHERE DRIFT WOULD SHOW
1374        assert_eq!(
1375            view_lines[1].char_source_bytes[0],
1376            Some(8),
1377            "Line 2 'i' -> byte 8"
1378        );
1379        assert_eq!(
1380            view_lines[1].char_source_bytes[4],
1381            Some(12),
1382            "Line 2 'y' -> byte 12"
1383        );
1384        assert_eq!(
1385            view_lines[1].char_source_bytes[5],
1386            Some(13),
1387            "Line 2 ';' -> byte 13"
1388        );
1389        assert_eq!(
1390            view_lines[1].char_source_bytes[6],
1391            Some(14),
1392            "Line 2 newline -> byte 14 (\\r pos)"
1393        );
1394
1395        // Step 4: Simulate highlight span lookup
1396        // If TreeSitter highlights "int" as keyword (bytes 0-3 for line 1, bytes 8-11 for line 2),
1397        // the lookup should find these correctly.
1398        let simulated_highlight_spans = [
1399            // "int" on line 1: bytes 0-3
1400            (0usize..3usize, "keyword"),
1401            // "int" on line 2: bytes 8-11
1402            (8usize..11usize, "keyword"),
1403        ];
1404
1405        // Verify that looking up byte positions from char_source_bytes finds the right spans
1406        for (line_idx, view_line) in view_lines.iter().enumerate() {
1407            for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
1408                if let Some(bp) = byte_pos {
1409                    let in_span = simulated_highlight_spans
1410                        .iter()
1411                        .find(|(range, _)| range.contains(bp))
1412                        .map(|(_, name)| *name);
1413
1414                    // First 3 chars of each line should be in keyword span
1415                    let expected_in_keyword = char_idx < 3;
1416                    let actually_in_keyword = in_span == Some("keyword");
1417
1418                    if expected_in_keyword != actually_in_keyword {
1419                        panic!(
1420                            "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
1421                            line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
1422                        );
1423                    }
1424                }
1425            }
1426        }
1427    }
1428
1429    /// Test that apply_wrapping_transform correctly breaks long lines.
1430    /// This prevents memory exhaustion from extremely long single-line files (issue #481).
1431    #[test]
1432    fn test_apply_wrapping_transform_breaks_long_lines() {
1433        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1434
1435        // Create a token with 25,000 characters (longer than MAX_SAFE_LINE_WIDTH of 10,000)
1436        let long_text = "x".repeat(25_000);
1437        let tokens = vec![
1438            ViewTokenWire {
1439                kind: ViewTokenWireKind::Text(long_text),
1440                source_offset: Some(0),
1441                style: None,
1442            },
1443            ViewTokenWire {
1444                kind: ViewTokenWireKind::Newline,
1445                source_offset: Some(25_000),
1446                style: None,
1447            },
1448        ];
1449
1450        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
1451        let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1452
1453        // Count Break tokens - should have at least 2 breaks for 25K chars at 10K width
1454        let break_count = wrapped
1455            .iter()
1456            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1457            .count();
1458
1459        assert!(
1460            break_count >= 2,
1461            "25K char line should have at least 2 breaks at 10K width, got {}",
1462            break_count
1463        );
1464
1465        // Verify total content is preserved (excluding Break tokens)
1466        let total_chars: usize = wrapped
1467            .iter()
1468            .filter_map(|t| match &t.kind {
1469                ViewTokenWireKind::Text(s) => Some(s.len()),
1470                _ => None,
1471            })
1472            .sum();
1473
1474        assert_eq!(
1475            total_chars, 25_000,
1476            "Total character count should be preserved after wrapping"
1477        );
1478    }
1479
1480    /// Property test encoding the wrap-boundary invariant that the
1481    /// char-split path of [`apply_wrapping_transform`] must satisfy.
1482    ///
1483    /// The invariant is scoped to **char-split** row endings — rows
1484    /// whose last emitted grapheme falls strictly INSIDE a source Text
1485    /// token.  Word-wrap breaks (where the row ends at whitespace
1486    /// between tokens) are outside the scope of the char-split
1487    /// improvement and pass through unchecked; they land at a token
1488    /// boundary by construction.
1489    ///
1490    /// For every non-final visual row whose end is mid-Text-token:
1491    ///
1492    /// 1. **No overflow.** The row's visual width is at most
1493    ///    `content_width`.
1494    /// 2. **No loss.** Concatenating every emitted row in order yields
1495    ///    exactly the original input.
1496    /// 3. **Prefer UAX #29 word boundaries.** Let `hard_cap` be the
1497    ///    largest char position where the row could still fit, and
1498    ///    `floor = max(hard_cap - MAX_LOOKBACK, hard_cap / 2)`, both
1499    ///    measured in characters from the start of this row inside the
1500    ///    input.  If any `split_word_bound_indices()` boundary lies in
1501    ///    `[floor, hard_cap]`, the split must land at the LARGEST such
1502    ///    boundary.
1503    /// 4. **Fall back to hard cap.** If no word boundary lies in that
1504    ///    window, the split lands at `hard_cap` exactly (char split).
1505    #[cfg(test)]
1506    mod wrap_boundary_property {
1507        use super::apply_wrapping_transform;
1508        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1509        use proptest::prelude::*;
1510        use unicode_segmentation::UnicodeSegmentation;
1511
1512        /// Matches the constant used by the implementation.  Defined
1513        /// here as well so the property test can compute the same
1514        /// window without reaching into the module internals.
1515        const MAX_LOOKBACK: usize = 16;
1516
1517        fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
1518            let mut tokens: Vec<ViewTokenWire> = Vec::new();
1519            let mut buf = String::new();
1520            let mut buf_start = 0usize;
1521            for (i, c) in input.char_indices() {
1522                if c == ' ' {
1523                    if !buf.is_empty() {
1524                        tokens.push(ViewTokenWire {
1525                            kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1526                            source_offset: Some(buf_start),
1527                            style: None,
1528                        });
1529                    }
1530                    tokens.push(ViewTokenWire {
1531                        kind: ViewTokenWireKind::Space,
1532                        source_offset: Some(i),
1533                        style: None,
1534                    });
1535                    buf_start = i + 1;
1536                } else {
1537                    if buf.is_empty() {
1538                        buf_start = i;
1539                    }
1540                    buf.push(c);
1541                }
1542            }
1543            if !buf.is_empty() {
1544                tokens.push(ViewTokenWire {
1545                    kind: ViewTokenWireKind::Text(buf.clone()),
1546                    source_offset: Some(buf_start),
1547                    style: None,
1548                });
1549            }
1550            tokens.push(ViewTokenWire {
1551                kind: ViewTokenWireKind::Newline,
1552                source_offset: Some(input.len()),
1553                style: None,
1554            });
1555            tokens
1556        }
1557
1558        /// Reconstruct the sequence of visual rows from the wrapped
1559        /// token stream.  Each entry is the row's rendered content
1560        /// (Text + Space, with Break separating rows; Newline ends the
1561        /// last row).
1562        fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
1563            let mut rows: Vec<String> = vec![String::new()];
1564            for t in wrapped {
1565                match &t.kind {
1566                    ViewTokenWireKind::Text(s) => {
1567                        rows.last_mut().unwrap().push_str(s);
1568                    }
1569                    ViewTokenWireKind::Space => {
1570                        rows.last_mut().unwrap().push(' ');
1571                    }
1572                    ViewTokenWireKind::Break => {
1573                        rows.push(String::new());
1574                    }
1575                    ViewTokenWireKind::Newline => {
1576                        // End of logical line — ignore for wrap row
1577                        // purposes; we don't wrap across Newline here.
1578                    }
1579                    _ => {}
1580                }
1581            }
1582            rows
1583        }
1584
1585        proptest! {
1586            // A handful of cases per run is plenty — wrapping is
1587            // deterministic, but the input space is large and we want
1588            // shrinking to work.
1589            #![proptest_config(ProptestConfig {
1590                cases: 256,
1591                .. ProptestConfig::default()
1592            })]
1593
1594            /// Core property: the four invariants stated on the module
1595            /// docstring above.
1596            #[test]
1597            fn prop_wrap_respects_boundaries(
1598                input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
1599                content_width in 5usize..40,
1600            ) {
1601                // Hanging indent off and gutter 0 — we want to isolate
1602                // the Text char-split logic from the indent path.
1603                let tokens = tokens_from_input(&input);
1604                let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
1605                let rows = visual_rows(&wrapped);
1606
1607                // Invariant 1: no row exceeds content_width.
1608                for (i, row) in rows.iter().enumerate() {
1609                    prop_assert!(
1610                        row.chars().count() <= content_width,
1611                        "row {i} {:?} has width {} > content_width {content_width}",
1612                        row,
1613                        row.chars().count(),
1614                    );
1615                }
1616
1617                // Invariant 2: lossless reconstruction.
1618                let reconstructed: String = rows.concat();
1619                prop_assert_eq!(
1620                    &reconstructed,
1621                    &input,
1622                    "reconstruction differs from input"
1623                );
1624
1625                // Invariants 3 + 4: every non-final split lands at
1626                // either the largest word boundary in the lookback
1627                // window or the hard cap.
1628                let boundaries: std::collections::BTreeSet<usize> = input
1629                    .split_word_bound_indices()
1630                    .map(|(i, _)| i)
1631                    .chain(std::iter::once(input.len()))
1632                    .collect();
1633
1634                let mut cursor_bytes = 0usize;
1635                let mut cursor_chars = 0usize;
1636                for (i, row) in rows.iter().enumerate() {
1637                    let row_bytes = row.len();
1638                    let row_chars = row.chars().count();
1639                    let row_end_bytes = cursor_bytes + row_bytes;
1640                    let row_end_chars = cursor_chars + row_chars;
1641                    let is_last = i + 1 == rows.len();
1642
1643                    if !is_last {
1644                        // Only apply the boundary invariant to char-
1645                        // splits — row endings that fall strictly
1646                        // inside a Text token.  When the row ends at
1647                        // or adjacent to a space, it's a word-wrap
1648                        // break, which is outside this invariant.
1649                        let input_bytes = input.as_bytes();
1650                        let prev_is_space =
1651                            row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
1652                        let next_is_space = row_end_bytes < input_bytes.len()
1653                            && input_bytes[row_end_bytes] == b' ';
1654                        let is_mid_text = !prev_is_space && !next_is_space;
1655                        if !is_mid_text {
1656                            cursor_bytes = row_end_bytes;
1657                            cursor_chars = row_end_chars;
1658                            continue;
1659                        }
1660
1661                        // The hard cap is the last char position this row
1662                        // could have reached: current cursor + content_width.
1663                        let hard_cap_chars = cursor_chars + content_width;
1664                        let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
1665                        let floor_chars = cursor_chars
1666                            + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
1667                        let floor_bytes = char_index_to_byte(&input, floor_chars);
1668
1669                        // Invariant 3 + 4: either the chosen split is
1670                        // the largest word boundary in [floor,
1671                        // hard_cap] (when any such boundary exists) or
1672                        // it's the hard cap itself (char-split
1673                        // fallback).  Do not exempt "row is exactly
1674                        // content_width" from the check — that's the
1675                        // case the improvement is supposed to change.
1676                        let max_in_window = boundaries
1677                            .range(floor_bytes..=hard_cap_bytes)
1678                            .next_back()
1679                            .copied();
1680                        match max_in_window {
1681                            Some(max_b) => {
1682                                prop_assert_eq!(
1683                                    row_end_bytes,
1684                                    max_b,
1685                                    "split at byte {} but largest word boundary in \
1686                                     [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
1687                                    row_end_bytes,
1688                                    floor_bytes,
1689                                    hard_cap_bytes,
1690                                    max_b,
1691                                    row,
1692                                    input,
1693                                );
1694                            }
1695                            None => {
1696                                prop_assert_eq!(
1697                                    row_end_bytes,
1698                                    hard_cap_bytes,
1699                                    "no word boundary in [floor={}, hard_cap={}], so \
1700                                     char-split must land at hard_cap, but split is at \
1701                                     byte {}; row={:?}, input={:?}",
1702                                    floor_bytes,
1703                                    hard_cap_bytes,
1704                                    row_end_bytes,
1705                                    row,
1706                                    input,
1707                                );
1708                            }
1709                        }
1710                    }
1711
1712                    cursor_bytes = row_end_bytes;
1713                    cursor_chars = row_end_chars;
1714                }
1715            }
1716        }
1717
1718        /// Translate a char index into a byte index for ASCII-ish
1719        /// inputs; clamps to input length.
1720        fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1721            s.char_indices()
1722                .nth(char_idx)
1723                .map(|(b, _)| b)
1724                .unwrap_or(s.len())
1725        }
1726    }
1727
1728    /// Test that normal-length lines are not affected by safety wrapping.
1729    #[test]
1730    fn test_apply_wrapping_transform_preserves_short_lines() {
1731        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1732
1733        // Create a token with 100 characters (much shorter than MAX_SAFE_LINE_WIDTH)
1734        let short_text = "x".repeat(100);
1735        let tokens = vec![
1736            ViewTokenWire {
1737                kind: ViewTokenWireKind::Text(short_text.clone()),
1738                source_offset: Some(0),
1739                style: None,
1740            },
1741            ViewTokenWire {
1742                kind: ViewTokenWireKind::Newline,
1743                source_offset: Some(100),
1744                style: None,
1745            },
1746        ];
1747
1748        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
1749        let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1750
1751        // Should have no Break tokens for short lines
1752        let break_count = wrapped
1753            .iter()
1754            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1755            .count();
1756
1757        assert_eq!(
1758            break_count, 0,
1759            "Short lines should not have any breaks, got {}",
1760            break_count
1761        );
1762
1763        // Original text should be preserved exactly
1764        let text_tokens: Vec<_> = wrapped
1765            .iter()
1766            .filter_map(|t| match &t.kind {
1767                ViewTokenWireKind::Text(s) => Some(s.clone()),
1768                _ => None,
1769            })
1770            .collect();
1771
1772        assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
1773        assert_eq!(
1774            text_tokens[0], short_text,
1775            "Text content should be unchanged"
1776        );
1777    }
1778
1779    /// End-to-end test: verify large single-line content with sequential markers
1780    /// is correctly chunked, wrapped, and all data is preserved through the pipeline.
1781    #[test]
1782    fn test_large_single_line_sequential_data_preserved() {
1783        use crate::view::ui::view_pipeline::ViewLineIterator;
1784        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1785
1786        // Create content with sequential markers that span multiple chunks
1787        // Format: "[00001][00002]..." - each marker is 7 chars
1788        let num_markers = 5_000; // ~35KB, enough to test chunking at 10K char intervals
1789        let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
1790
1791        // Create tokens simulating what build_base_tokens would produce
1792        let tokens = vec![
1793            ViewTokenWire {
1794                kind: ViewTokenWireKind::Text(content.clone()),
1795                source_offset: Some(0),
1796                style: None,
1797            },
1798            ViewTokenWire {
1799                kind: ViewTokenWireKind::Newline,
1800                source_offset: Some(content.len()),
1801                style: None,
1802            },
1803        ];
1804
1805        // Apply safety wrapping (simulating line_wrap=false with MAX_SAFE_LINE_WIDTH)
1806        let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1807
1808        // Convert to ViewLines
1809        let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
1810
1811        // Reconstruct content from ViewLines
1812        let mut reconstructed = String::new();
1813        for line in &view_lines {
1814            // Skip the trailing newline character in each line's text
1815            let text = line.text.trim_end_matches('\n');
1816            reconstructed.push_str(text);
1817        }
1818
1819        // Verify all content is preserved
1820        assert_eq!(
1821            reconstructed.len(),
1822            content.len(),
1823            "Reconstructed content length should match original"
1824        );
1825
1826        // Verify sequential markers are all present
1827        for i in 1..=num_markers {
1828            let marker = format!("[{:05}]", i);
1829            assert!(
1830                reconstructed.contains(&marker),
1831                "Missing marker {} after pipeline",
1832                marker
1833            );
1834        }
1835
1836        // Verify order is preserved by checking sample positions
1837        let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
1838        let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
1839        let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
1840        assert!(
1841            pos_100 < pos_1000 && pos_1000 < pos_3000,
1842            "Markers should be in sequential order: {} < {} < {}",
1843            pos_100,
1844            pos_1000,
1845            pos_3000
1846        );
1847
1848        // Verify we got multiple visual lines (content was wrapped)
1849        assert!(
1850            view_lines.len() >= 3,
1851            "35KB content should produce multiple visual lines at 10K width, got {}",
1852            view_lines.len()
1853        );
1854
1855        // Verify each ViewLine is bounded in size (memory safety check)
1856        for (i, line) in view_lines.iter().enumerate() {
1857            assert!(
1858                line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, // +10 for newline and rounding
1859                "ViewLine {} exceeds safe width: {} chars",
1860                i,
1861                line.text.len()
1862            );
1863        }
1864    }
1865
1866    /// Helper: strip OSC 8 escape sequences from a string, returning plain text.
1867    fn strip_osc8(s: &str) -> String {
1868        let mut result = String::with_capacity(s.len());
1869        let bytes = s.as_bytes();
1870        let mut i = 0;
1871        while i < bytes.len() {
1872            if i + 3 < bytes.len()
1873                && bytes[i] == 0x1b
1874                && bytes[i + 1] == b']'
1875                && bytes[i + 2] == b'8'
1876                && bytes[i + 3] == b';'
1877            {
1878                i += 4;
1879                while i < bytes.len() && bytes[i] != 0x07 {
1880                    i += 1;
1881                }
1882                if i < bytes.len() {
1883                    i += 1;
1884                }
1885            } else {
1886                result.push(bytes[i] as char);
1887                i += 1;
1888            }
1889        }
1890        result
1891    }
1892
1893    /// Read a row from a ratatui buffer, skipping the second cell of 2-char
1894    /// OSC 8 chunks so we get clean text.
1895    fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
1896        let width = buf.area().width;
1897        let mut s = String::new();
1898        let mut col = 0u16;
1899        while col < width {
1900            let cell = &buf[(col, y)];
1901            let stripped = strip_osc8(cell.symbol());
1902            let chars = stripped.chars().count();
1903            if chars > 1 {
1904                s.push_str(&stripped);
1905                col += chars as u16;
1906            } else {
1907                s.push_str(&stripped);
1908                col += 1;
1909            }
1910        }
1911        s.trim_end().to_string()
1912    }
1913
1914    #[test]
1915    fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
1916        use ratatui::buffer::Buffer;
1917        use ratatui::layout::Rect;
1918
1919        // Simulate: "[Quick Install](#installation)" in a 40-wide buffer row 0
1920        let text = "[Quick Install](#installation)";
1921        let area = Rect::new(0, 0, 40, 1);
1922        let mut buf = Buffer::empty(area);
1923        for (i, ch) in text.chars().enumerate() {
1924            if (i as u16) < 40 {
1925                buf[(i as u16, 0)].set_symbol(&ch.to_string());
1926            }
1927        }
1928
1929        // Overlay covers "Quick Install" = cols 1..14 (bytes 9..22 mapped to screen)
1930        let url = "https://example.com";
1931
1932        // Apply with cursor at col 0 (not inside the overlay range)
1933        apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
1934
1935        let row = read_row(&buf, 0);
1936        assert_eq!(
1937            row, text,
1938            "After OSC 8 application, reading the row should reproduce the original text"
1939        );
1940
1941        // Cell 14 = ']' must not be touched
1942        let cell14 = strip_osc8(buf[(14, 0)].symbol());
1943        assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
1944
1945        // Cell 0 = '[' must not be touched
1946        let cell0 = strip_osc8(buf[(0, 0)].symbol());
1947        assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
1948    }
1949
1950    #[test]
1951    fn test_apply_osc8_stable_across_reapply() {
1952        use ratatui::buffer::Buffer;
1953        use ratatui::layout::Rect;
1954
1955        let text = "[Quick Install](#installation)";
1956        let area = Rect::new(0, 0, 40, 1);
1957
1958        // First render: apply OSC 8 with cursor at col 0
1959        let mut buf1 = Buffer::empty(area);
1960        for (i, ch) in text.chars().enumerate() {
1961            if (i as u16) < 40 {
1962                buf1[(i as u16, 0)].set_symbol(&ch.to_string());
1963            }
1964        }
1965        apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
1966        let row1 = read_row(&buf1, 0);
1967
1968        // Second render: fresh buffer, same text, apply OSC 8 with cursor at col 5
1969        let mut buf2 = Buffer::empty(area);
1970        for (i, ch) in text.chars().enumerate() {
1971            if (i as u16) < 40 {
1972                buf2[(i as u16, 0)].set_symbol(&ch.to_string());
1973            }
1974        }
1975        apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
1976        let row2 = read_row(&buf2, 0);
1977
1978        assert_eq!(row1, text);
1979        assert_eq!(row2, text);
1980    }
1981
1982    #[test]
1983    #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
1984    fn test_apply_osc8_diff_between_renders() {
1985        use ratatui::buffer::Buffer;
1986        use ratatui::layout::Rect;
1987
1988        // Simulate ratatui's diff-based update: a "concealed" render followed
1989        // by an "unconcealed" render. The backend buffer accumulates diffs.
1990        let area = Rect::new(0, 0, 40, 1);
1991
1992        // --- Render 1: concealed text "Quick Install" at cols 0..12, rest is space ---
1993        let concealed = "Quick Install";
1994        let mut frame1 = Buffer::empty(area);
1995        for (i, ch) in concealed.chars().enumerate() {
1996            frame1[(i as u16, 0)].set_symbol(&ch.to_string());
1997        }
1998        // OSC 8 covers cols 0..13 (concealed mapping)
1999        apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2000
2001        // Simulate backend: starts empty, apply diff from frame1
2002        let prev = Buffer::empty(area);
2003        let mut backend = Buffer::empty(area);
2004        let diff1 = prev.diff(&frame1);
2005        for (x, y, cell) in &diff1 {
2006            backend[(*x, *y)] = (*cell).clone();
2007        }
2008
2009        // --- Render 2: unconcealed "[Quick Install](#installation)" ---
2010        let full = "[Quick Install](#installation)";
2011        let mut frame2 = Buffer::empty(area);
2012        for (i, ch) in full.chars().enumerate() {
2013            if (i as u16) < 40 {
2014                frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2015            }
2016        }
2017        // OSC 8 covers cols 1..14 (unconcealed mapping)
2018        apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2019
2020        // Apply diff from frame1→frame2 to backend
2021        let diff2 = frame1.diff(&frame2);
2022        for (x, y, cell) in &diff2 {
2023            backend[(*x, *y)] = (*cell).clone();
2024        }
2025
2026        // Backend should now show the full text when read
2027        let row = read_row(&backend, 0);
2028        assert_eq!(
2029            row, full,
2030            "After diff-based update from concealed to unconcealed, \
2031             backend should show full text"
2032        );
2033
2034        // Specifically, cell 14 must be ']'
2035        let cell14 = strip_osc8(backend[(14, 0)].symbol());
2036        assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
2037    }
2038
2039    // --- Current line highlight tests ---
2040
2041    fn render_with_highlight_option(
2042        content: &str,
2043        cursor_pos: usize,
2044        highlight_current_line: bool,
2045    ) -> LineRenderOutput {
2046        let mut state = EditorState::new(20, 6, 1024, test_fs());
2047        state.buffer = Buffer::from_str(content, 1024, test_fs());
2048        let mut cursors = crate::model::cursor::Cursors::new();
2049        cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
2050        let viewport = Viewport::new(20, 4);
2051        state.margins.left_config.enabled = false;
2052
2053        let render_area = Rect::new(0, 0, 20, 4);
2054        let visible_count = viewport.visible_line_count();
2055        let gutter_width = state.margins.left_total_width();
2056        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
2057        let empty_folds = FoldManager::new();
2058
2059        let view_data = build_view_data(
2060            &mut state,
2061            &viewport,
2062            None,
2063            content.len().max(1),
2064            visible_count,
2065            false,
2066            render_area.width as usize,
2067            gutter_width,
2068            &ViewMode::Source,
2069            &empty_folds,
2070            &theme,
2071        );
2072        let view_anchor = calculate_view_anchor(&view_data.lines, 0);
2073
2074        let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
2075        state.margins.update_width_for_buffer(estimated_lines, true);
2076        let gutter_width = state.margins.left_total_width();
2077
2078        let selection = selection_context(&state, &cursors);
2079        let _ = state
2080            .buffer
2081            .populate_line_cache(viewport.top_byte, visible_count);
2082        let viewport_start = viewport.top_byte;
2083        let viewport_end = calculate_viewport_end(
2084            &mut state,
2085            viewport_start,
2086            content.len().max(1),
2087            visible_count,
2088        );
2089        let decorations = decoration_context(
2090            &mut state,
2091            viewport_start,
2092            viewport_end,
2093            selection.primary_cursor_position,
2094            &empty_folds,
2095            &theme,
2096            100_000,
2097            &ViewMode::Source,
2098            false,
2099            &[],
2100        );
2101
2102        render_view_lines(LineRenderInput {
2103            state: &state,
2104            theme: &theme,
2105            view_lines: &view_data.lines,
2106            view_anchor,
2107            render_area,
2108            gutter_width,
2109            selection: &selection,
2110            decorations: &decorations,
2111            visible_line_count: visible_count,
2112            lsp_waiting: false,
2113            is_active: true,
2114            line_wrap: viewport.line_wrap_enabled,
2115            estimated_lines,
2116            left_column: viewport.left_column,
2117            relative_line_numbers: false,
2118            session_mode: false,
2119            software_cursor_only: false,
2120            show_line_numbers: false,
2121            byte_offset_mode: false,
2122            show_tilde: true,
2123            highlight_current_line,
2124            cell_theme_map: &mut Vec::new(),
2125            screen_width: 0,
2126        })
2127    }
2128
2129    /// Check whether any span on a given line has `current_line_bg` as its background.
2130    fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
2131        let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
2132        if let Some(line) = output.lines.get(line_idx) {
2133            line.spans
2134                .iter()
2135                .any(|span| span.style.bg == Some(current_line_bg))
2136        } else {
2137            false
2138        }
2139    }
2140
2141    #[test]
2142    fn current_line_highlight_enabled_highlights_cursor_line() {
2143        let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
2144        // Cursor is on line 0 — it should have current_line_bg
2145        assert!(
2146            line_has_current_line_bg(&output, 0),
2147            "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
2148        );
2149        // Line 1 should NOT have current_line_bg
2150        assert!(
2151            !line_has_current_line_bg(&output, 1),
2152            "Non-cursor line (line 1) should NOT have current_line_bg"
2153        );
2154    }
2155
2156    #[test]
2157    fn current_line_highlight_disabled_no_highlight() {
2158        let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
2159        // No line should have current_line_bg when disabled
2160        assert!(
2161            !line_has_current_line_bg(&output, 0),
2162            "Cursor line should NOT have current_line_bg when highlighting is disabled"
2163        );
2164        assert!(
2165            !line_has_current_line_bg(&output, 1),
2166            "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
2167        );
2168    }
2169
2170    #[test]
2171    fn current_line_highlight_follows_cursor_position() {
2172        // Cursor on line 1 (byte 4 = start of "def")
2173        let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
2174        assert!(
2175            !line_has_current_line_bg(&output, 0),
2176            "Line 0 should NOT have current_line_bg when cursor is on line 1"
2177        );
2178        assert!(
2179            line_has_current_line_bg(&output, 1),
2180            "Line 1 should have current_line_bg when cursor is there"
2181        );
2182        assert!(
2183            !line_has_current_line_bg(&output, 2),
2184            "Line 2 should NOT have current_line_bg when cursor is on line 1"
2185        );
2186    }
2187}