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