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