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