Skip to main content

fresh/view/ui/split_rendering/
mod.rs

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