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