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::config::IndentationGuideMode;
31use crate::model::buffer::Buffer;
32use crate::model::event::{BufferId, EventLog, LeafId, SplitDirection};
33use crate::primitives::ansi_background::AnsiBackground;
34use crate::state::EditorState;
35use crate::view::split::SplitManager;
36use ratatui::layout::Rect;
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/// Immutable editor render settings for one frame.
47///
48/// Bundles the static `config.editor.*` flags (plus a couple of stable
49/// `Editor` fields) that `render_content` and its callees only ever read.
50/// Deliberately holds *only* settings — no buffers, view-states, theme,
51/// geometry, output sinks, or per-frame computed flags (hover, cursor,
52/// mode) — so it can be built once and shared without entangling borrows.
53#[derive(Debug, Clone, Copy)]
54pub struct EditorRenderConfig<'a> {
55    pub large_file_threshold_bytes: u64,
56    pub line_wrap: bool,
57    pub estimated_line_length: usize,
58    pub highlight_context_bytes: usize,
59    pub relative_line_numbers: bool,
60    pub use_terminal_bg: bool,
61    pub show_vertical_scrollbar: bool,
62    pub show_horizontal_scrollbar: bool,
63    pub diagnostics_inline_text: bool,
64    pub show_tilde: bool,
65    pub highlight_current_column: bool,
66    pub indentation_guide: IndentationGuideMode,
67    pub indentation_guide_glyph: &'a str,
68    pub hide_current_line_on_selection: bool,
69    pub background_fade: f32,
70    pub software_cursor_only: bool,
71}
72
73impl<'a> EditorRenderConfig<'a> {
74    /// Build from the static editor config plus the two stable `Editor`
75    /// flags that don't live under `config.editor`. Borrows only
76    /// `config.editor`, so it composes with a disjoint `&mut windows`
77    /// borrow at the call site.
78    pub fn new(
79        editor: &'a crate::config::EditorConfig,
80        background_fade: f32,
81        software_cursor_only: bool,
82    ) -> Self {
83        Self {
84            large_file_threshold_bytes: editor.large_file_threshold_bytes,
85            line_wrap: editor.line_wrap,
86            estimated_line_length: editor.estimated_line_length,
87            highlight_context_bytes: editor.highlight_context_bytes,
88            relative_line_numbers: editor.relative_line_numbers,
89            use_terminal_bg: editor.use_terminal_bg,
90            show_vertical_scrollbar: editor.show_vertical_scrollbar,
91            show_horizontal_scrollbar: editor.show_horizontal_scrollbar,
92            diagnostics_inline_text: editor.diagnostics_inline_text,
93            show_tilde: editor.show_tilde,
94            highlight_current_column: editor.highlight_current_column,
95            indentation_guide: editor.indentation_guide,
96            indentation_guide_glyph: &editor.indentation_guide_glyph,
97            hide_current_line_on_selection: editor.hide_current_line_on_selection,
98            background_fade,
99            software_cursor_only,
100        }
101    }
102}
103
104/// "How to render" — the appearance/policy inputs that are identical for
105/// every split in a frame: the theme, the ANSI backdrop, and the editor
106/// render config. Built once at the top of the render pass and threaded by
107/// reference through the whole painter chain (`render_content` →
108/// `render_buffer_in_split` → …), so each layer forwards one `RenderStyle`
109/// instead of re-listing ~16 style parameters. Distinct from per-split state
110/// and the draw target, which vary or are mutated.
111//
112// No `Debug` derive — `AnsiBackground` isn't `Debug`.
113#[derive(Clone, Copy)]
114pub struct RenderStyle<'a> {
115    pub theme: &'a crate::view::theme::Theme,
116    pub ansi_background: Option<&'a AnsiBackground>,
117    pub cfg: EditorRenderConfig<'a>,
118}
119
120/// Public façade for split-pane rendering.
121///
122/// All logic lives in `orchestration::*`. This struct exists only to
123/// preserve the `SplitRenderer::…` call sites in the rest of the crate;
124/// nothing inside the `split_rendering` module references it.
125pub struct SplitRenderer;
126
127impl SplitRenderer {
128    #[allow(clippy::too_many_arguments)]
129    #[allow(clippy::type_complexity)]
130    pub fn render_content(
131        buf: &mut ratatui::buffer::Buffer,
132        area: Rect,
133        split_manager: &SplitManager,
134        buffers: &mut HashMap<BufferId, EditorState>,
135        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
136        preview_buffer: Option<BufferId>,
137        event_logs: &mut HashMap<BufferId, EventLog>,
138        composite_buffers: &mut HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
139        composite_view_states: &mut HashMap<
140            (LeafId, BufferId),
141            crate::view::composite_view::CompositeViewState,
142        >,
143        style: RenderStyle<'_>,
144        lsp_waiting: bool,
145        split_view_states: Option<&mut HashMap<LeafId, crate::view::split::SplitViewState>>,
146        grouped_subtrees: &HashMap<LeafId, crate::view::split::SplitNode>,
147        hide_cursor: bool,
148        hovered_tab: Option<(crate::view::split::TabTarget, LeafId, bool)>,
149        hovered_close_split: Option<LeafId>,
150        hovered_maximize_split: Option<LeafId>,
151        is_maximized: bool,
152        tab_bar_visible: bool,
153        session_mode: bool,
154        terminal_mode: bool,
155        cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
156        screen_width: u16,
157        pending_hardware_cursor: &mut Option<(u16, u16)>,
158        // Forwarded to the tab-bar renderer: when false the tab bar lays out but
159        // paints no cells (web renders tabs natively); panes always draw.
160        draw_tab_bar: bool,
161    ) -> (
162        Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
163        HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
164        Vec<(LeafId, u16, u16, u16)>,
165        Vec<(LeafId, u16, u16, u16)>,
166        HashMap<LeafId, Vec<ViewLineMapping>>,
167        Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
168        Vec<(
169            crate::model::event::ContainerId,
170            SplitDirection,
171            u16,
172            u16,
173            u16,
174        )>,
175    ) {
176        orchestration::render_content(
177            buf,
178            area,
179            split_manager,
180            buffers,
181            buffer_metadata,
182            preview_buffer,
183            event_logs,
184            composite_buffers,
185            composite_view_states,
186            style,
187            lsp_waiting,
188            split_view_states,
189            grouped_subtrees,
190            hide_cursor,
191            hovered_tab,
192            hovered_close_split,
193            hovered_maximize_split,
194            is_maximized,
195            tab_bar_visible,
196            session_mode,
197            terminal_mode,
198            cell_theme_map,
199            screen_width,
200            pending_hardware_cursor,
201            draw_tab_bar,
202        )
203    }
204
205    #[allow(clippy::too_many_arguments)]
206    pub fn compute_content_layout(
207        area: Rect,
208        split_manager: &SplitManager,
209        buffers: &mut HashMap<BufferId, EditorState>,
210        split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
211        theme: &crate::view::theme::Theme,
212        lsp_waiting: bool,
213        estimated_line_length: usize,
214        highlight_context_bytes: usize,
215        relative_line_numbers: bool,
216        use_terminal_bg: bool,
217        session_mode: bool,
218        software_cursor_only: bool,
219        tab_bar_visible: bool,
220        show_vertical_scrollbar: bool,
221        show_horizontal_scrollbar: bool,
222        diagnostics_inline_text: bool,
223        show_tilde: bool,
224    ) -> HashMap<LeafId, Vec<ViewLineMapping>> {
225        orchestration::compute_content_layout(
226            area,
227            split_manager,
228            buffers,
229            split_view_states,
230            theme,
231            lsp_waiting,
232            estimated_line_length,
233            highlight_context_bytes,
234            relative_line_numbers,
235            use_terminal_bg,
236            session_mode,
237            software_cursor_only,
238            tab_bar_visible,
239            show_vertical_scrollbar,
240            show_horizontal_scrollbar,
241            diagnostics_inline_text,
242            show_tilde,
243        )
244    }
245
246    /// Render a single buffer into an arbitrary screen rect.
247    ///
248    /// Public façade over the per-leaf renderer for callers that
249    /// drive layout outside of the split tree (e.g. the Live Grep
250    /// floating overlay's preview pane — see render.rs). The leaf is
251    /// not registered in `SplitManager`; the caller owns the
252    /// `SplitViewState` and is responsible for cursor, viewport, and
253    /// fold state. Returns the per-line mappings used for hit
254    /// testing — overlay callers may discard them.
255    #[allow(clippy::too_many_arguments)]
256    pub fn render_phantom_leaf(
257        buf: &mut ratatui::buffer::Buffer,
258        state: &mut EditorState,
259        cursors: &crate::model::cursor::Cursors,
260        viewport: &mut crate::view::viewport::Viewport,
261        folds: &mut crate::view::folding::FoldManager,
262        event_log: Option<&mut EventLog>,
263        area: Rect,
264        style: RenderStyle<'_>,
265        view_mode: crate::state::ViewMode,
266        compose_width: Option<u16>,
267        compose_column_guides: Option<Vec<u16>>,
268        view_transform: Option<crate::services::plugins::api::ViewTransformPayload>,
269        buffer_id: BufferId,
270        session_mode: bool,
271        rulers: &[usize],
272        show_line_numbers: bool,
273        highlight_current_line: bool,
274        show_tilde: bool,
275        highlight_current_column: bool,
276        cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
277        screen_width: u16,
278    ) -> Vec<crate::app::types::ViewLineMapping> {
279        // Phantom leaves are never the focused split, so:
280        // - is_active = false (no current-line emphasis chrome owned
281        //   by the focus split)
282        // - hide_cursor = true (the user's cursor lives in the
283        //   overlay's prompt input, not the preview)
284        // - lsp_waiting = false (preview never owns LSP requests)
285        // - pending_hardware_cursor: the preview must not move the
286        //   terminal's hardware cursor away from the prompt input.
287        let mut sink: Option<(u16, u16)> = None;
288        orchestration::render_buffer_in_split(
289            buf,
290            state,
291            cursors,
292            viewport,
293            folds,
294            event_log,
295            area,
296            /* is_active */ false,
297            style,
298            /* lsp_waiting */ false,
299            view_mode,
300            compose_width,
301            compose_column_guides,
302            view_transform,
303            buffer_id,
304            /* hide_cursor */ true,
305            session_mode,
306            rulers,
307            show_line_numbers,
308            highlight_current_line,
309            show_tilde,
310            highlight_current_column,
311            cell_theme_map,
312            screen_width,
313            &mut sink,
314        )
315    }
316
317    /// Public wrapper for building base tokens - used by render.rs for the
318    /// view_transform_request hook.
319    pub fn build_base_tokens_for_hook(
320        buffer: &mut Buffer,
321        top_byte: usize,
322        estimated_line_length: usize,
323        visible_count: usize,
324        is_binary: bool,
325        line_ending: crate::model::buffer::LineEnding,
326    ) -> Vec<fresh_core::api::ViewTokenWire> {
327        orchestration::build_base_tokens_for_hook(
328            buffer,
329            top_byte,
330            estimated_line_length,
331            visible_count,
332            is_binary,
333            line_ending,
334        )
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::folding::fold_indicators_for_viewport;
341    use super::layout::{calculate_view_anchor, calculate_viewport_end};
342    use super::orchestration::overlays::{decoration_context, selection_context};
343    use super::orchestration::render_buffer::resolve_cursor_fallback;
344    use super::orchestration::render_line::{
345        render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
346    };
347    use super::post_pass::apply_osc8_to_cells;
348    use super::transforms::apply_wrapping_transform;
349    use super::view_data::build_view_data;
350    use super::*;
351
352    use crate::model::buffer::{Buffer, LineEnding};
353    use crate::model::filesystem::StdFileSystem;
354    use crate::primitives::display_width::str_width;
355    use crate::state::{EditorState, ViewMode};
356    use crate::view::folding::FoldManager;
357    use crate::view::theme;
358    use crate::view::theme::Theme;
359    use crate::view::ui::view_pipeline::{LineStart, ViewLine};
360    use crate::view::viewport::Viewport;
361    use fresh_core::api::ViewTokenWire;
362    use lsp_types::FoldingRange;
363    use std::collections::HashSet;
364    use std::sync::Arc;
365
366    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
367        Arc::new(StdFileSystem)
368    }
369
370    fn render_output_for(
371        content: &str,
372        cursor_pos: usize,
373    ) -> (LineRenderOutput, usize, bool, usize) {
374        render_output_for_with_gutters(content, cursor_pos, false)
375    }
376
377    fn render_output_for_with_gutters(
378        content: &str,
379        cursor_pos: usize,
380        gutters_enabled: bool,
381    ) -> (LineRenderOutput, usize, bool, usize) {
382        render_output_for_with_options(
383            content,
384            cursor_pos,
385            gutters_enabled,
386            IndentationGuideMode::None,
387            crate::config::default_indentation_guide_glyph(),
388            0,
389            None,
390        )
391    }
392
393    fn render_output_for_with_indentation_guide(
394        content: &str,
395        cursor_pos: usize,
396        left_column: usize,
397    ) -> (LineRenderOutput, usize, bool, usize) {
398        render_output_for_with_indentation_guide_mode(
399            content,
400            cursor_pos,
401            left_column,
402            IndentationGuideMode::All,
403        )
404    }
405
406    fn render_output_for_with_indentation_guide_mode(
407        content: &str,
408        cursor_pos: usize,
409        left_column: usize,
410        indentation_guide: IndentationGuideMode,
411    ) -> (LineRenderOutput, usize, bool, usize) {
412        render_output_for_with_options(
413            content,
414            cursor_pos,
415            false,
416            indentation_guide,
417            crate::config::default_indentation_guide_glyph(),
418            left_column,
419            None,
420        )
421    }
422
423    fn render_output_for_with_indentation_guide_mode_and_tab_size(
424        content: &str,
425        cursor_pos: usize,
426        left_column: usize,
427        indentation_guide: IndentationGuideMode,
428        tab_size: usize,
429    ) -> (LineRenderOutput, usize, bool, usize) {
430        render_output_for_with_options(
431            content,
432            cursor_pos,
433            false,
434            indentation_guide,
435            crate::config::default_indentation_guide_glyph(),
436            left_column,
437            Some(tab_size),
438        )
439    }
440
441    fn render_output_for_with_options(
442        content: &str,
443        cursor_pos: usize,
444        gutters_enabled: bool,
445        indentation_guide: IndentationGuideMode,
446        indentation_guide_glyph: String,
447        left_column: usize,
448        tab_size: Option<usize>,
449    ) -> (LineRenderOutput, usize, bool, usize) {
450        let mut state = EditorState::new(20, 6, 1024, test_fs());
451        state.buffer = Buffer::from_str(content, 1024, test_fs());
452        if let Some(tab_size) = tab_size {
453            state.buffer_settings.tab_size = tab_size;
454        }
455        let mut cursors = crate::model::cursor::Cursors::new();
456        cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
457        // Create a standalone viewport (no longer part of EditorState)
458        let mut viewport = Viewport::new(20, 4);
459        viewport.left_column = left_column;
460        // Enable/disable line numbers/gutters based on parameter
461        state.margins.left_config.enabled = gutters_enabled;
462
463        let render_area = Rect::new(0, 0, 20, 4);
464        let visible_count = viewport.visible_line_count();
465        let gutter_width = state.margins.left_total_width();
466        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
467        let empty_folds = FoldManager::new();
468
469        let view_data = build_view_data(
470            &mut state,
471            &viewport,
472            None,
473            content.len().max(1),
474            visible_count,
475            false, // line wrap disabled for tests
476            render_area.width as usize,
477            gutter_width,
478            &ViewMode::Source, // Tests use source mode
479            &empty_folds,
480            &theme,
481        );
482        let view_anchor = calculate_view_anchor(&view_data.lines, 0);
483
484        let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
485        state.margins.update_width_for_buffer(estimated_lines, true);
486        let gutter_width = state.margins.left_total_width();
487
488        let selection = selection_context(&state, &cursors);
489        let _ = state
490            .buffer
491            .populate_line_cache(viewport.top_byte, visible_count);
492        let viewport_start = viewport.top_byte;
493        let viewport_end = calculate_viewport_end(
494            &mut state,
495            viewport_start,
496            content.len().max(1),
497            visible_count,
498        );
499        let decorations = decoration_context(
500            &mut state,
501            viewport_start,
502            viewport_end,
503            selection.primary_cursor_position,
504            &empty_folds,
505            &theme,
506            100_000,           // default highlight context bytes
507            &ViewMode::Source, // Tests use source mode
508            false,             // inline diagnostics off for test
509            &[],
510        );
511
512        let mut dummy_theme_map = Vec::new();
513        let output = render_view_lines(LineRenderInput {
514            state: &state,
515            theme: &theme,
516            view_lines: &view_data.lines,
517            view_anchor,
518            render_area,
519            gutter_width,
520            selection: &selection,
521            decorations: &decorations,
522            visible_line_count: visible_count,
523            lsp_waiting: false,
524            is_active: true,
525            line_wrap: viewport.line_wrap_enabled,
526            estimated_lines,
527            left_column: viewport.left_column,
528            relative_line_numbers: false,
529            session_mode: false,
530            software_cursor_only: false,
531            show_line_numbers: true, // Tests show line numbers
532            byte_offset_mode: false, // Tests use exact line numbers
533            show_tilde: true,
534            highlight_current_line: true,
535            indentation_guide,
536            indentation_guide_glyph: &indentation_guide_glyph,
537            cell_theme_map: &mut dummy_theme_map,
538            screen_width: 0,
539        });
540
541        (
542            output,
543            state.buffer.len(),
544            content.ends_with('\n'),
545            selection.primary_cursor_position,
546        )
547    }
548
549    /// Render `content` with the viewport scrolled so the buffer byte
550    /// `top_byte` is the first visible row. Used to exercise indentation-guide
551    /// rendering when a block-opening line has scrolled above the viewport.
552    fn render_output_scrolled_with_indentation_guide(
553        content: &str,
554        top_byte: usize,
555    ) -> LineRenderOutput {
556        let mut state = EditorState::new(20, 6, 1024, test_fs());
557        state.buffer = Buffer::from_str(content, 1024, test_fs());
558        let mut cursors = crate::model::cursor::Cursors::new();
559        cursors.primary_mut().position = 0;
560        let mut viewport = Viewport::new(20, 10);
561        viewport.top_byte = top_byte;
562        state.margins.left_config.enabled = false;
563
564        let render_area = Rect::new(0, 0, 20, 10);
565        let visible_count = viewport.visible_line_count();
566        let gutter_width = state.margins.left_total_width();
567        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
568        let empty_folds = FoldManager::new();
569
570        let view_data = build_view_data(
571            &mut state,
572            &viewport,
573            None,
574            content.len().max(1),
575            visible_count,
576            false,
577            render_area.width as usize,
578            gutter_width,
579            &ViewMode::Source,
580            &empty_folds,
581            &theme,
582        );
583        let view_anchor = calculate_view_anchor(&view_data.lines, viewport.top_byte);
584
585        let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
586        state.margins.update_width_for_buffer(estimated_lines, true);
587        let gutter_width = state.margins.left_total_width();
588
589        let selection = selection_context(&state, &cursors);
590        let _ = state
591            .buffer
592            .populate_line_cache(viewport.top_byte, visible_count);
593        let viewport_start = viewport.top_byte;
594        let viewport_end = calculate_viewport_end(
595            &mut state,
596            viewport_start,
597            content.len().max(1),
598            visible_count,
599        );
600        let decorations = decoration_context(
601            &mut state,
602            viewport_start,
603            viewport_end,
604            selection.primary_cursor_position,
605            &empty_folds,
606            &theme,
607            100_000,
608            &ViewMode::Source,
609            false,
610            &[],
611        );
612
613        let glyph = crate::config::default_indentation_guide_glyph();
614        let mut dummy_theme_map = Vec::new();
615        render_view_lines(LineRenderInput {
616            state: &state,
617            theme: &theme,
618            view_lines: &view_data.lines,
619            view_anchor,
620            render_area,
621            gutter_width,
622            selection: &selection,
623            decorations: &decorations,
624            visible_line_count: visible_count,
625            lsp_waiting: false,
626            is_active: true,
627            line_wrap: viewport.line_wrap_enabled,
628            estimated_lines,
629            left_column: viewport.left_column,
630            relative_line_numbers: false,
631            session_mode: false,
632            software_cursor_only: false,
633            show_line_numbers: true,
634            byte_offset_mode: false,
635            show_tilde: true,
636            highlight_current_line: true,
637            indentation_guide: IndentationGuideMode::All,
638            indentation_guide_glyph: &glyph,
639            cell_theme_map: &mut dummy_theme_map,
640            screen_width: 0,
641        })
642    }
643
644    fn rendered_line_text(output: &LineRenderOutput, line_idx: usize) -> String {
645        output.lines[line_idx]
646            .spans
647            .iter()
648            .map(|span| span.content.as_ref())
649            .collect::<String>()
650            .trim_end()
651            .to_string()
652    }
653
654    #[test]
655    fn indentation_guide_disabled_preserves_leading_spaces() {
656        let (output, _, _, _) = render_output_for("    let x = 1;\n", 0);
657
658        assert_eq!(rendered_line_text(&output, 0), "    let x = 1;");
659        assert!(!rendered_line_text(&output, 0).contains('▏'));
660    }
661
662    #[test]
663    fn indentation_guide_render_for_space_indents_but_not_root_lines() {
664        let (output, _, _, _) = render_output_for_with_indentation_guide(
665            "fn main()\n    let child = 1;\n        let grandchild = 2;\nroot\n",
666            0,
667            0,
668        );
669
670        assert_eq!(rendered_line_text(&output, 0), "fn main()");
671        assert_eq!(rendered_line_text(&output, 1), "▏   let child = 1;");
672        assert_eq!(
673            rendered_line_text(&output, 2),
674            "▏   ▏   let grandchild = 2;"
675        );
676        assert_eq!(rendered_line_text(&output, 3), "root");
677    }
678
679    #[test]
680    fn indentation_guide_follow_four_space_file_indents_when_tab_size_is_two() {
681        let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
682            "fn main() {\n    child();\n        grandchild();\n}\n",
683            0,
684            0,
685            IndentationGuideMode::All,
686            2,
687        );
688
689        assert_eq!(rendered_line_text(&output, 0), "fn main() {");
690        assert_eq!(rendered_line_text(&output, 1), "▏   child();");
691        assert_eq!(rendered_line_text(&output, 2), "▏   ▏   grandchild();");
692        assert_eq!(rendered_line_text(&output, 3), "}");
693    }
694
695    #[test]
696    fn indentation_guide_active_mode_uses_file_indent_width_when_tab_size_is_two() {
697        let content = "function test() {\n    if (1) {\n        // test\n    }\n}\n";
698        let cursor_pos = content.find("// test").unwrap();
699        let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
700            content,
701            cursor_pos,
702            0,
703            IndentationGuideMode::Active,
704            2,
705        );
706
707        assert_eq!(rendered_line_text(&output, 0), "function test() {");
708        assert_eq!(rendered_line_text(&output, 1), "    if (1) {");
709        assert_eq!(rendered_line_text(&output, 2), "    ▏   // test");
710        assert_eq!(rendered_line_text(&output, 3), "    }");
711    }
712
713    #[test]
714    fn indentation_guide_follow_nearest_earlier_parent_indent_when_widths_vary() {
715        let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
716            "root\n  child\n      grand\n  sibling\n",
717            0,
718            0,
719            IndentationGuideMode::All,
720            4,
721        );
722
723        assert_eq!(rendered_line_text(&output, 0), "root");
724        assert_eq!(rendered_line_text(&output, 1), "▏ child");
725        assert_eq!(rendered_line_text(&output, 2), "▏ ▏   grand");
726        assert_eq!(rendered_line_text(&output, 3), "▏ sibling");
727    }
728
729    #[test]
730    fn indentation_guide_active_mode_uses_nearest_earlier_parent_indent() {
731        let content = "root\n  child\n      grand\n  sibling\n";
732        let cursor_pos = content.find("grand").unwrap();
733        let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
734            content,
735            cursor_pos,
736            0,
737            IndentationGuideMode::Active,
738            4,
739        );
740
741        assert_eq!(rendered_line_text(&output, 0), "root");
742        assert_eq!(rendered_line_text(&output, 1), "  child");
743        assert_eq!(rendered_line_text(&output, 2), "  ▏   grand");
744        assert_eq!(rendered_line_text(&output, 3), "  sibling");
745    }
746
747    #[test]
748    fn indentation_guide_resume_after_lower_indent_line() {
749        let (output, _, _, _) = render_output_for_with_indentation_guide(
750            "        before\n    lower\n        after\n",
751            0,
752            0,
753        );
754
755        assert_eq!(rendered_line_text(&output, 0), "▏   ▏   before");
756        assert_eq!(rendered_line_text(&output, 1), "▏   lower");
757        assert_eq!(rendered_line_text(&output, 2), "▏   ▏   after");
758    }
759
760    #[test]
761    fn indentation_guide_all_mode_draws_through_blank_lines() {
762        // A whitespace-only line inside a nested block continues *every*
763        // enclosing block's guides straight through it, rather than leaving a
764        // one-row gap. With the block openers (`fn f() {` at col 0, `if x {` at
765        // col 4) on screen, the blank row must still draw both the col-0 and
766        // col-4 guides (it has eight trailing spaces, so those guide cells
767        // exist), and the staircase resumes unchanged on the row below.
768        let blank = " ".repeat(8);
769        let content =
770            format!("fn f() {{\n    if x {{\n        a;\n{blank}\n        b;\n    }}\n}}\n");
771        let output = render_output_scrolled_with_indentation_guide(&content, 0);
772
773        assert_eq!(rendered_line_text(&output, 0), "fn f() {");
774        assert_eq!(rendered_line_text(&output, 1), "▏   if x {");
775        assert_eq!(rendered_line_text(&output, 2), "▏   ▏   a;");
776        assert_eq!(rendered_line_text(&output, 3), "▏   ▏");
777        assert_eq!(rendered_line_text(&output, 4), "▏   ▏   b;");
778        assert_eq!(rendered_line_text(&output, 5), "▏   }");
779        assert_eq!(rendered_line_text(&output, 6), "}");
780    }
781
782    #[test]
783    fn indentation_guide_empty_line_does_not_collapse_staircase() {
784        // A *completely empty* line (a bare `\n`) inside a nested block must not
785        // reset the indent staircase: the code row below it keeps its full set of
786        // guides. (Before `slice_indent` treated `\n` as a terminator, a bare
787        // "\n" read as indent-0 content, popping the whole stack — so `b;` lost
788        // its col-4 guide and rendered "▏   b;".) The empty row's own rendering is
789        // covered by the draw-through test below.
790        let content = "fn f() {\n    if x {\n        a;\n\n        b;\n    }\n}\n";
791        let output = render_output_scrolled_with_indentation_guide(content, 0);
792
793        assert_eq!(rendered_line_text(&output, 0), "fn f() {");
794        assert_eq!(rendered_line_text(&output, 1), "▏   if x {");
795        assert_eq!(rendered_line_text(&output, 2), "▏   ▏   a;");
796        // row 3 is the empty line — owned by the draw-through test.
797        assert_eq!(rendered_line_text(&output, 4), "▏   ▏   b;");
798        assert_eq!(rendered_line_text(&output, 5), "▏   }");
799    }
800
801    #[test]
802    fn indentation_guide_survives_scroll_past_block_opener() {
803        // A block whose opening lines (`mod m {` at col 0, `fn f() {` at col 4)
804        // have scrolled above the viewport must still draw their guides on the
805        // interior rows below. Previously the all-mode scanner derived its
806        // staircase only from the visible rows, so the off-screen openers'
807        // levels were missing — the col-4 guide vanished on the deeper interior
808        // rows and reappeared only when scrolling the opener back into view.
809        let content = concat!(
810            "mod m {\n",             // col 0  (scrolled off-screen)
811            "    fn f() {\n",        // col 4  (scrolled off-screen)
812            "        let arr = [\n", // col 8  <- first visible row
813            "            a,\n",      // col 12
814            "            b,\n",      // col 12
815            "        ];\n",          // col 8
816            "        let c = 1;\n",  // col 8
817            "    }\n",
818            "}\n",
819        );
820        let top_byte = content.find("        let arr = [").unwrap();
821        let output = render_output_scrolled_with_indentation_guide(content, top_byte);
822
823        // First visible row keeps both ancestor guides (col 0 and col 4) even
824        // though both owning lines are off-screen.
825        assert_eq!(rendered_line_text(&output, 0), "▏   ▏   let arr = [");
826        // Interior rows show the full staircase, including the col-4 guide that
827        // the scroll regression used to drop.
828        assert_eq!(rendered_line_text(&output, 1), "▏   ▏   ▏   a,");
829        assert_eq!(rendered_line_text(&output, 2), "▏   ▏   ▏   b,");
830        assert_eq!(rendered_line_text(&output, 3), "▏   ▏   ];");
831    }
832
833    #[test]
834    fn indentation_guide_draws_through_blank_line_when_opener_scrolled_off() {
835        // Combines the two cases that interact here: a whitespace-only line in
836        // the middle of a block (guides must be drawn straight through it) while
837        // the block's openers (`mod m {` at col 0, `fn f() {` at col 4) have
838        // scrolled above the viewport. The primer must skip the blank line as it
839        // walks up to reconstruct the staircase, and the drawn-through guides
840        // must use that primed staircase — otherwise the col-4 guide drops on the
841        // blank row exactly as it did on the textual interior rows.
842        // 12 spaces: a whitespace-only line wide enough to carry guide cells at
843        // columns 0/4/8 (the renderer only replaces existing leading-space cells).
844        let blank = " ".repeat(12);
845        let content = format!(
846            "mod m {{\n    fn f() {{\n        let arr = [\n            alpha_value,\n{blank}\n            beta_value,\n        ];\n        let after = compute();\n    }}\n}}\n"
847        );
848        let top_byte = content.find("        let arr = [").unwrap();
849        let output = render_output_scrolled_with_indentation_guide(&content, top_byte);
850
851        assert_eq!(rendered_line_text(&output, 0), "▏   ▏   let arr = [");
852        assert_eq!(rendered_line_text(&output, 1), "▏   ▏   ▏   alpha_value,");
853        // The whitespace-only row continues the enclosing block's guides — the
854        // col-4 guide (owned by the off-screen `fn f() {`) must be drawn through.
855        assert_eq!(rendered_line_text(&output, 2), "▏   ▏   ▏");
856        assert_eq!(rendered_line_text(&output, 3), "▏   ▏   ▏   beta_value,");
857    }
858
859    #[test]
860    fn indentation_guide_draws_through_completely_empty_lines() {
861        // A bare `\n` line has no cells for the per-cell pass to restyle, so its
862        // guides are synthesised. At root depth the empty line carries the single
863        // col-0 guide; nested, it carries every ancestor guide of the surrounding
864        // body — keeping the vertical guides continuous through the gap.
865        let root = "int main() {\n\n    greet();\n    \n    return 0;\n}\n";
866        let out = render_output_scrolled_with_indentation_guide(root, 0);
867        assert_eq!(rendered_line_text(&out, 0), "int main() {");
868        assert_eq!(rendered_line_text(&out, 1), "▏"); // empty line, drawn through
869        assert_eq!(rendered_line_text(&out, 2), "▏   greet();");
870        assert_eq!(rendered_line_text(&out, 3), "▏"); // whitespace-only line
871        assert_eq!(rendered_line_text(&out, 4), "▏   return 0;");
872        assert_eq!(rendered_line_text(&out, 5), "}");
873
874        let nested = "fn f() {\n    if x {\n        a;\n\n        b;\n    }\n}\n";
875        let out = render_output_scrolled_with_indentation_guide(nested, 0);
876        assert_eq!(rendered_line_text(&out, 2), "▏   ▏   a;");
877        // The empty interior line carries both the col-0 and col-4 guides.
878        assert_eq!(rendered_line_text(&out, 3), "▏   ▏");
879        assert_eq!(rendered_line_text(&out, 4), "▏   ▏   b;");
880    }
881
882    #[test]
883    fn indentation_guide_empty_line_after_opener_flows_into_body() {
884        // The empty line sits directly under the opener, before any body row —
885        // so the staircase alone (just the opener's level) would under-draw. The
886        // look-ahead to the next content row pulls the body's guide onto the
887        // empty line, so the guide is continuous from the opener down.
888        let content = "if outer {\n\n    inner();\n}\n";
889        let out = render_output_scrolled_with_indentation_guide(content, 0);
890        assert_eq!(rendered_line_text(&out, 0), "if outer {");
891        assert_eq!(rendered_line_text(&out, 1), "▏"); // flows into the body below
892        assert_eq!(rendered_line_text(&out, 2), "▏   inner();");
893    }
894
895    #[test]
896    fn indentation_guide_render_for_tabs() {
897        let (output, _, _, _) =
898            render_output_for_with_indentation_guide("\tchild\n\t\tgrand\n", 0, 0);
899
900        assert_eq!(rendered_line_text(&output, 0), "▏   child");
901        assert_eq!(rendered_line_text(&output, 1), "▏   ▏   grand");
902    }
903
904    #[test]
905    fn indentation_guide_respect_horizontal_scroll() {
906        let (output, _, _, _) = render_output_for_with_indentation_guide("        grand\n", 0, 4);
907
908        assert_eq!(rendered_line_text(&output, 0), "▏   grand");
909    }
910
911    #[test]
912    fn indentation_guide_use_configured_glyph() {
913        let (output, _, _, _) = render_output_for_with_options(
914            "        grand\n",
915            0,
916            false,
917            IndentationGuideMode::All,
918            "┊".to_string(),
919            0,
920            None,
921        );
922
923        assert_eq!(rendered_line_text(&output, 0), "┊   ┊   grand");
924    }
925
926    #[test]
927    fn indentation_guide_renderer_normalizes_blank_and_padded_glyphs() {
928        let (output, _, _, _) = render_output_for_with_options(
929            "    child\n",
930            0,
931            false,
932            IndentationGuideMode::All,
933            "  ┊  ".to_string(),
934            0,
935            None,
936        );
937        assert_eq!(rendered_line_text(&output, 0), "┊   child");
938
939        let (output, _, _, _) = render_output_for_with_options(
940            "    child\n",
941            0,
942            false,
943            IndentationGuideMode::All,
944            "   ".to_string(),
945            0,
946            None,
947        );
948        assert_eq!(rendered_line_text(&output, 0), "▏   child");
949    }
950
951    #[test]
952    fn indentation_guide_renderer_uses_one_character_from_glyph_setting() {
953        let (output, _, _, _) = render_output_for_with_options(
954            "    child\n",
955            0,
956            false,
957            IndentationGuideMode::All,
958            "  ABC  ".to_string(),
959            0,
960            None,
961        );
962
963        assert_eq!(rendered_line_text(&output, 0), "A   child");
964    }
965
966    #[test]
967    fn indentation_guide_renderer_rejects_double_width_glyphs() {
968        let (output, _, _, _) = render_output_for_with_options(
969            "    child\n",
970            0,
971            false,
972            IndentationGuideMode::All,
973            "😀".to_string(),
974            0,
975            None,
976        );
977
978        assert_eq!(rendered_line_text(&output, 0), "▏   child");
979    }
980
981    #[test]
982    fn indentation_guide_active_mode_renders_only_innermost_active_block() {
983        let content = "    if ready {\n        inner\n        sibling\n    }\n";
984        let cursor_pos = content.find("inner").unwrap();
985        let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
986            content,
987            cursor_pos,
988            0,
989            IndentationGuideMode::Active,
990        );
991
992        assert_eq!(rendered_line_text(&output, 0), "    if ready {");
993        assert_eq!(rendered_line_text(&output, 1), "    ▏   inner");
994        assert_eq!(rendered_line_text(&output, 2), "    ▏   sibling");
995        assert_eq!(rendered_line_text(&output, 3), "    }");
996    }
997
998    #[test]
999    fn indentation_guide_active_mode_updates_when_cursor_changes_blocks() {
1000        // Two sibling blocks: the active guide follows whichever block encloses
1001        // the cursor.
1002        let content = "    a\n        x\n    b\n        y\n";
1003
1004        let first_cursor = content.find('x').unwrap();
1005        let (first_output, _, _, _) = render_output_for_with_indentation_guide_mode(
1006            content,
1007            first_cursor,
1008            0,
1009            IndentationGuideMode::Active,
1010        );
1011        assert_eq!(rendered_line_text(&first_output, 1), "    ▏   x");
1012        assert_eq!(rendered_line_text(&first_output, 3), "        y");
1013
1014        let second_cursor = content.find('y').unwrap();
1015        let (second_output, _, _, _) = render_output_for_with_indentation_guide_mode(
1016            content,
1017            second_cursor,
1018            0,
1019            IndentationGuideMode::Active,
1020        );
1021        assert_eq!(rendered_line_text(&second_output, 1), "        x");
1022        assert_eq!(rendered_line_text(&second_output, 3), "    ▏   y");
1023    }
1024
1025    #[test]
1026    fn indentation_guide_active_mode_supports_tabs() {
1027        let content = "\tchild\n\t\tgrand\n";
1028        let cursor_pos = content.find("grand").unwrap();
1029        let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1030            content,
1031            cursor_pos,
1032            0,
1033            IndentationGuideMode::Active,
1034        );
1035
1036        // Tab cells that are not replaced by the active guide retain the
1037        // existing leading-tab whitespace indicator.
1038        assert_eq!(rendered_line_text(&output, 0), "→   child");
1039        assert_eq!(rendered_line_text(&output, 1), "→   ▏   grand");
1040    }
1041
1042    #[test]
1043    fn indentation_guide_active_mode_cursor_on_block_header_uses_child_block() {
1044        // The cursor's line heads a more-indented block, so the active guide is
1045        // the child block's guide (one level in), not the header's own level.
1046        // Block detection is purely indentation-based, so this holds regardless
1047        // of the trailing delimiter (`{`, `(`, `[`, `:`, …).
1048        let content = "    if (1) {\n        // test\n    }\n";
1049        let cursor_pos = content.find('{').unwrap();
1050        let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1051            content,
1052            cursor_pos,
1053            0,
1054            IndentationGuideMode::Active,
1055        );
1056
1057        assert_eq!(rendered_line_text(&output, 0), "    if (1) {");
1058        assert_eq!(rendered_line_text(&output, 1), "    ▏   // test");
1059        assert_eq!(rendered_line_text(&output, 2), "    }");
1060    }
1061
1062    #[test]
1063    fn indentation_guide_active_mode_cursor_in_body_uses_enclosing_block() {
1064        let content = "    if (1) {\n        // test\n    }\n";
1065        let cursor_pos = content.find("// test").unwrap();
1066        let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1067            content,
1068            cursor_pos,
1069            0,
1070            IndentationGuideMode::Active,
1071        );
1072
1073        assert_eq!(rendered_line_text(&output, 0), "    if (1) {");
1074        assert_eq!(rendered_line_text(&output, 1), "    ▏   // test");
1075        assert_eq!(rendered_line_text(&output, 2), "    }");
1076    }
1077
1078    #[test]
1079    fn indentation_guide_active_mode_dedent_line_with_no_parent_has_no_guide() {
1080        // The cursor sits on a dedent line (`}`) at the outermost indent here,
1081        // so it belongs to no enclosing block and draws no guide.
1082        let content = "    if (1) {\n        // test\n    }\n";
1083        let cursor_pos = content.find("    }").unwrap() + 4;
1084        let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1085            content,
1086            cursor_pos,
1087            0,
1088            IndentationGuideMode::Active,
1089        );
1090
1091        assert_eq!(rendered_line_text(&output, 0), "    if (1) {");
1092        assert_eq!(rendered_line_text(&output, 1), "        // test");
1093        assert_eq!(rendered_line_text(&output, 2), "    }");
1094    }
1095
1096    #[test]
1097    fn indentation_guide_active_mode_dedent_line_uses_enclosing_block_when_nested() {
1098        // A dedent line that still sits inside an outer block draws that outer
1099        // block's guide.
1100        let content = "fn () {\n    if (1) {\n        // test\n    }\n}\n";
1101        let cursor_pos = content.find("    }").unwrap() + 4;
1102        let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1103            content,
1104            cursor_pos,
1105            0,
1106            IndentationGuideMode::Active,
1107        );
1108
1109        // Viewport is 4 rows tall in this harness, so the trailing `}` (line 4)
1110        // is off-screen; the dedent line `}` (line 3) carries the outer guide.
1111        assert_eq!(rendered_line_text(&output, 0), "fn () {");
1112        assert_eq!(rendered_line_text(&output, 1), "▏   if (1) {");
1113        assert_eq!(rendered_line_text(&output, 2), "▏       // test");
1114        assert_eq!(rendered_line_text(&output, 3), "▏   }");
1115    }
1116
1117    #[test]
1118    fn indentation_guide_all_mode_is_cursor_independent() {
1119        // `all` mode draws every level regardless of cursor position.
1120        let content = "    if (1) {\n        // test\n    }\n";
1121        let cursor_pos = content.find('{').unwrap() + 1;
1122        let (output, _, _, _) = render_output_for_with_indentation_guide(content, cursor_pos, 0);
1123
1124        assert_eq!(rendered_line_text(&output, 0), "▏   if (1) {");
1125        assert_eq!(rendered_line_text(&output, 1), "▏   ▏   // test");
1126        assert_eq!(rendered_line_text(&output, 2), "▏   }");
1127    }
1128
1129    #[test]
1130    fn indentation_guide_active_mode_root_header_draws_column_zero_guide() {
1131        // A cursor on a root-level header still gets the child block's guide,
1132        // which lands at column 0 — consistent with `all` mode.
1133        let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1134            "root\n    child\n",
1135            0,
1136            0,
1137            IndentationGuideMode::Active,
1138        );
1139
1140        assert_eq!(rendered_line_text(&output, 0), "root");
1141        assert_eq!(rendered_line_text(&output, 1), "▏   child");
1142    }
1143
1144    #[test]
1145    fn test_folding_hides_lines_and_adds_placeholder() {
1146        let content = "header\nline1\nline2\ntail\n";
1147        let mut state = EditorState::new(40, 6, 1024, test_fs());
1148        state.buffer = Buffer::from_str(content, 1024, test_fs());
1149
1150        let start = state.buffer.line_start_offset(1).unwrap();
1151        let end = state.buffer.line_start_offset(3).unwrap();
1152        let mut folds = FoldManager::new();
1153        folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
1154
1155        let viewport = Viewport::new(40, 6);
1156        let gutter_width = state.margins.left_total_width();
1157        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1158        let view_data = build_view_data(
1159            &mut state,
1160            &viewport,
1161            None,
1162            content.len().max(1),
1163            viewport.visible_line_count(),
1164            false,
1165            40,
1166            gutter_width,
1167            &ViewMode::Source,
1168            &folds,
1169            &theme,
1170        );
1171
1172        let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
1173        assert!(lines.iter().any(|l| l.contains("header")));
1174        assert!(lines.iter().any(|l| l.contains("tail")));
1175        assert!(!lines.iter().any(|l| l.contains("line1")));
1176        assert!(!lines.iter().any(|l| l.contains("line2")));
1177        assert!(lines
1178            .iter()
1179            .any(|l| l.contains("header") && l.contains("...")));
1180    }
1181
1182    #[test]
1183    fn fold_indicator_lands_on_header_not_blank_line_after_it() {
1184        // A blank line immediately after a foldable header must not steal the
1185        // fold marker. The indent-based detector consumes `ViewLine::text`,
1186        // which keeps the trailing `\n`; before `slice_indent` treated `\n` as a
1187        // terminator, a bare "\n" read as indent-0 *content*, so `int main() {`
1188        // looked unfoldable (its next "non-blank" line was the blank one) and the
1189        // blank line itself looked like the indent-0 header of the body below.
1190        let content = "int main() {\n\n    body();\n    \n    more();\n}\n";
1191        let mut state = EditorState::new(40, 10, 1024, test_fs());
1192        state.buffer = Buffer::from_str(content, 1024, test_fs());
1193        let viewport = Viewport::new(40, 10);
1194        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1195        let folds = FoldManager::new();
1196        let view_data = build_view_data(
1197            &mut state,
1198            &viewport,
1199            None,
1200            content.len().max(1),
1201            viewport.visible_line_count(),
1202            false,
1203            40,
1204            0,
1205            &ViewMode::Source,
1206            &folds,
1207            &theme,
1208        );
1209
1210        let indicators = fold_indicators_for_viewport(&state, &folds, &view_data.lines);
1211
1212        let header_byte = 0; // `int main() {`
1213        let blank_byte = content.find("\n\n").unwrap() + 1; // the empty line
1214        assert!(
1215            indicators.contains_key(&header_byte),
1216            "fold marker should be on the function header"
1217        );
1218        assert!(
1219            !indicators.contains_key(&blank_byte),
1220            "fold marker must not be on the blank line"
1221        );
1222    }
1223
1224    #[test]
1225    fn test_fold_indicators_collapsed_and_expanded() {
1226        let content = "a\nb\nc\nd\n";
1227        let mut state = EditorState::new(40, 6, 1024, test_fs());
1228        state.buffer = Buffer::from_str(content, 1024, test_fs());
1229
1230        let lsp_ranges = vec![
1231            FoldingRange {
1232                start_line: 0,
1233                end_line: 1,
1234                start_character: None,
1235                end_character: None,
1236                kind: None,
1237                collapsed_text: None,
1238            },
1239            FoldingRange {
1240                start_line: 1,
1241                end_line: 2,
1242                start_character: None,
1243                end_character: None,
1244                kind: None,
1245                collapsed_text: None,
1246            },
1247        ];
1248        state
1249            .folding_ranges
1250            .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
1251
1252        let start = state.buffer.line_start_offset(1).unwrap();
1253        let end = state.buffer.line_start_offset(2).unwrap();
1254        let mut folds = FoldManager::new();
1255        folds.add(&mut state.marker_list, start, end, None);
1256
1257        let line1_byte = state.buffer.line_start_offset(1).unwrap();
1258        let view_lines = vec![ViewLine {
1259            text: "b\n".to_string(),
1260            source_start_byte: Some(line1_byte),
1261            char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
1262            char_styles: vec![None, None],
1263            char_visual_cols: vec![0, 1],
1264            visual_to_char: vec![0, 1],
1265            tab_starts: HashSet::new(),
1266            line_start: LineStart::AfterSourceNewline,
1267            ends_with_newline: true,
1268            virtual_gutter_glyph: None,
1269            virtual_line_style: None,
1270        }];
1271
1272        let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
1273
1274        // Collapsed fold: header is line 0 (byte 0)
1275        assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
1276        // LSP range starting at line 1 (byte 2, since "a\n" is 2 bytes)
1277        assert_eq!(
1278            indicators.get(&line1_byte).map(|i| i.collapsed),
1279            Some(false)
1280        );
1281    }
1282
1283    #[test]
1284    fn last_line_end_tracks_trailing_newline() {
1285        let output = render_output_for("abc\n", 4);
1286        assert_eq!(
1287            output.0.last_line_end,
1288            Some(LastLineEnd {
1289                pos: (3, 0),
1290                terminated_with_newline: true
1291            })
1292        );
1293    }
1294
1295    #[test]
1296    fn last_line_end_tracks_no_trailing_newline() {
1297        let output = render_output_for("abc", 3);
1298        assert_eq!(
1299            output.0.last_line_end,
1300            Some(LastLineEnd {
1301                pos: (3, 0),
1302                terminated_with_newline: false
1303            })
1304        );
1305    }
1306
1307    #[test]
1308    fn cursor_after_newline_places_on_next_line() {
1309        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
1310        let cursor = resolve_cursor_fallback(
1311            output.cursor,
1312            cursor_pos,
1313            buffer_len,
1314            buffer_newline,
1315            output.last_line_end,
1316            output.content_lines_rendered,
1317            0, // gutter_width (gutters disabled in tests)
1318        );
1319        assert_eq!(cursor, Some((0, 1)));
1320    }
1321
1322    #[test]
1323    fn cursor_at_end_without_newline_stays_on_line() {
1324        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
1325        let cursor = resolve_cursor_fallback(
1326            output.cursor,
1327            cursor_pos,
1328            buffer_len,
1329            buffer_newline,
1330            output.last_line_end,
1331            output.content_lines_rendered,
1332            0, // gutter_width (gutters disabled in tests)
1333        );
1334        assert_eq!(cursor, Some((3, 0)));
1335    }
1336
1337    // Helper to count all cursor positions in rendered output
1338    // Cursors can appear as:
1339    // 1. Primary cursor in output.cursor (hardware cursor position)
1340    // 2. Visual spans with REVERSED modifier (secondary cursors, or primary cursor with contrast fix)
1341    // 3. Visual spans with special background color (inactive cursors)
1342    fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
1343        let mut cursor_positions = Vec::new();
1344
1345        // Check for primary cursor in output.cursor field
1346        let primary_cursor = output.cursor;
1347        if let Some(cursor_pos) = primary_cursor {
1348            cursor_positions.push(cursor_pos);
1349        }
1350
1351        // Check for visual cursor indicators in rendered spans (secondary/inactive cursors)
1352        for (line_idx, line) in output.lines.iter().enumerate() {
1353            let mut col = 0u16;
1354            for span in line.spans.iter() {
1355                // Check if this span has the REVERSED modifier (secondary cursor)
1356                if span
1357                    .style
1358                    .add_modifier
1359                    .contains(ratatui::style::Modifier::REVERSED)
1360                {
1361                    let pos = (col, line_idx as u16);
1362                    // Only add if this is not the primary cursor position
1363                    // (primary cursor may also have REVERSED for contrast)
1364                    if primary_cursor != Some(pos) {
1365                        cursor_positions.push(pos);
1366                    }
1367                }
1368                // Count the visual width of this span's content
1369                col += str_width(&span.content) as u16;
1370            }
1371        }
1372
1373        cursor_positions
1374    }
1375
1376    // Helper to dump rendered output for debugging
1377    fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
1378        eprintln!("\n=== RENDER DEBUG ===");
1379        eprintln!("Content: {:?}", content);
1380        eprintln!("Cursor position: {}", cursor_pos);
1381        eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
1382        eprintln!("Last line end: {:?}", output.last_line_end);
1383        eprintln!("Content lines rendered: {}", output.content_lines_rendered);
1384        eprintln!("\nRendered lines:");
1385        for (line_idx, line) in output.lines.iter().enumerate() {
1386            eprintln!("  Line {}: {} spans", line_idx, line.spans.len());
1387            for (span_idx, span) in line.spans.iter().enumerate() {
1388                let has_reversed = span
1389                    .style
1390                    .add_modifier
1391                    .contains(ratatui::style::Modifier::REVERSED);
1392                let bg_color = format!("{:?}", span.style.bg);
1393                eprintln!(
1394                    "    Span {}: {:?} (REVERSED: {}, BG: {})",
1395                    span_idx, span.content, has_reversed, bg_color
1396                );
1397            }
1398        }
1399        eprintln!("===================\n");
1400    }
1401
1402    // Helper to get final cursor position after fallback resolution
1403    // Also validates that exactly one cursor is present
1404    fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
1405        let (output, buffer_len, buffer_newline, cursor_pos) =
1406            render_output_for(content, cursor_pos);
1407
1408        // Count all cursors (hardware + visual) in the rendered output
1409        let all_cursors = count_all_cursors(&output);
1410
1411        // Validate that at most one cursor is present in rendered output
1412        // (Some cursors are added by fallback logic, not during rendering)
1413        assert!(
1414            all_cursors.len() <= 1,
1415            "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
1416            all_cursors.len(),
1417            all_cursors
1418        );
1419
1420        let final_cursor = resolve_cursor_fallback(
1421            output.cursor,
1422            cursor_pos,
1423            buffer_len,
1424            buffer_newline,
1425            output.last_line_end,
1426            output.content_lines_rendered,
1427            0, // gutter_width (gutters disabled in tests)
1428        );
1429
1430        // Debug dump if we find unexpected results
1431        if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
1432        {
1433            dump_render_output(content, cursor_pos, &output);
1434        }
1435
1436        // If a cursor was rendered, it should match the final cursor position
1437        if let Some(rendered_cursor) = all_cursors.first() {
1438            assert_eq!(
1439                Some(*rendered_cursor),
1440                final_cursor,
1441                "Rendered cursor at {:?} doesn't match final cursor {:?}",
1442                rendered_cursor,
1443                final_cursor
1444            );
1445        }
1446
1447        // Validate that we have a final cursor position (either rendered or from fallback)
1448        assert!(
1449            final_cursor.is_some(),
1450            "Expected a final cursor position, but got None. Rendered cursors: {:?}",
1451            all_cursors
1452        );
1453
1454        final_cursor
1455    }
1456
1457    // Helper to simulate typing a character and check if it appears at cursor position
1458    fn check_typing_at_cursor(
1459        content: &str,
1460        cursor_pos: usize,
1461        char_to_type: char,
1462    ) -> (Option<(u16, u16)>, String) {
1463        // Get cursor position before typing
1464        let cursor_before = get_final_cursor(content, cursor_pos);
1465
1466        // Simulate inserting the character at cursor position
1467        let mut new_content = content.to_string();
1468        if cursor_pos <= content.len() {
1469            new_content.insert(cursor_pos, char_to_type);
1470        }
1471
1472        (cursor_before, new_content)
1473    }
1474
1475    #[test]
1476    fn e2e_cursor_at_start_of_nonempty_line() {
1477        // "abc" with cursor at position 0 (before 'a')
1478        let cursor = get_final_cursor("abc", 0);
1479        assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
1480
1481        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
1482        assert_eq!(
1483            new_content, "Xabc",
1484            "Typing should insert at cursor position"
1485        );
1486        assert_eq!(cursor_pos, Some((0, 0)));
1487    }
1488
1489    #[test]
1490    fn e2e_cursor_in_middle_of_line() {
1491        // "abc" with cursor at position 1 (on 'b')
1492        let cursor = get_final_cursor("abc", 1);
1493        assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
1494
1495        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
1496        assert_eq!(
1497            new_content, "aXbc",
1498            "Typing should insert at cursor position"
1499        );
1500        assert_eq!(cursor_pos, Some((1, 0)));
1501    }
1502
1503    #[test]
1504    fn e2e_cursor_at_end_of_line_no_newline() {
1505        // "abc" with cursor at position 3 (after 'c', at EOF)
1506        let cursor = get_final_cursor("abc", 3);
1507        assert_eq!(
1508            cursor,
1509            Some((3, 0)),
1510            "Cursor should be at column 3, line 0 (after last char)"
1511        );
1512
1513        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
1514        assert_eq!(new_content, "abcX", "Typing should append at end");
1515        assert_eq!(cursor_pos, Some((3, 0)));
1516    }
1517
1518    #[test]
1519    fn e2e_cursor_at_empty_line() {
1520        // "\n" with cursor at position 0 (on the newline itself)
1521        let cursor = get_final_cursor("\n", 0);
1522        assert_eq!(
1523            cursor,
1524            Some((0, 0)),
1525            "Cursor on empty line should be at column 0"
1526        );
1527
1528        let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
1529        assert_eq!(new_content, "X\n", "Typing should insert before newline");
1530        assert_eq!(cursor_pos, Some((0, 0)));
1531    }
1532
1533    #[test]
1534    fn e2e_cursor_after_newline_at_eof() {
1535        // "abc\n" with cursor at position 4 (after newline, at EOF)
1536        let cursor = get_final_cursor("abc\n", 4);
1537        assert_eq!(
1538            cursor,
1539            Some((0, 1)),
1540            "Cursor after newline at EOF should be on next line"
1541        );
1542
1543        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
1544        assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
1545        assert_eq!(cursor_pos, Some((0, 1)));
1546    }
1547
1548    #[test]
1549    fn e2e_cursor_on_newline_with_content() {
1550        // "abc\n" with cursor at position 3 (on the newline character)
1551        let cursor = get_final_cursor("abc\n", 3);
1552        assert_eq!(
1553            cursor,
1554            Some((3, 0)),
1555            "Cursor on newline after content should be after last char"
1556        );
1557
1558        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
1559        assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
1560        assert_eq!(cursor_pos, Some((3, 0)));
1561    }
1562
1563    #[test]
1564    fn e2e_cursor_multiline_start_of_second_line() {
1565        // "abc\ndef" with cursor at position 4 (start of second line, on 'd')
1566        let cursor = get_final_cursor("abc\ndef", 4);
1567        assert_eq!(
1568            cursor,
1569            Some((0, 1)),
1570            "Cursor at start of second line should be at column 0, line 1"
1571        );
1572
1573        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
1574        assert_eq!(
1575            new_content, "abc\nXdef",
1576            "Typing should insert at start of second line"
1577        );
1578        assert_eq!(cursor_pos, Some((0, 1)));
1579    }
1580
1581    #[test]
1582    fn e2e_cursor_multiline_end_of_first_line() {
1583        // "abc\ndef" with cursor at position 3 (on newline of first line)
1584        let cursor = get_final_cursor("abc\ndef", 3);
1585        assert_eq!(
1586            cursor,
1587            Some((3, 0)),
1588            "Cursor on newline of first line should be after content"
1589        );
1590
1591        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
1592        assert_eq!(
1593            new_content, "abcX\ndef",
1594            "Typing should insert before newline"
1595        );
1596        assert_eq!(cursor_pos, Some((3, 0)));
1597    }
1598
1599    #[test]
1600    fn e2e_cursor_empty_buffer() {
1601        // Empty buffer with cursor at position 0
1602        let cursor = get_final_cursor("", 0);
1603        assert_eq!(
1604            cursor,
1605            Some((0, 0)),
1606            "Cursor in empty buffer should be at origin"
1607        );
1608
1609        let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
1610        assert_eq!(
1611            new_content, "X",
1612            "Typing in empty buffer should insert character"
1613        );
1614        assert_eq!(cursor_pos, Some((0, 0)));
1615    }
1616
1617    #[test]
1618    fn e2e_cursor_empty_buffer_with_gutters() {
1619        // Empty buffer with cursor at position 0, with gutters enabled
1620        // The cursor should be positioned at the gutter width (right after the gutter),
1621        // NOT at column 0 (which would be in the gutter area)
1622        let (output, buffer_len, buffer_newline, cursor_pos) =
1623            render_output_for_with_gutters("", 0, true);
1624
1625        // With gutters enabled, the gutter width should be > 0
1626        // Default gutter includes: 1 char indicator + line number width + separator
1627        // For a 1-line buffer, line number width is typically 1 digit + padding
1628        let gutter_width = {
1629            let mut state = EditorState::new(20, 6, 1024, test_fs());
1630            state.margins.left_config.enabled = true;
1631            state.margins.update_width_for_buffer(1, true);
1632            state.margins.left_total_width()
1633        };
1634        assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
1635
1636        // CRITICAL: Check the RENDERED cursor position directly from output.cursor
1637        // This is what the terminal will actually use for cursor positioning
1638        // The cursor should be rendered at gutter_width, not at 0
1639        assert_eq!(
1640            output.cursor,
1641            Some((gutter_width as u16, 0)),
1642            "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
1643            gutter_width,
1644            output.cursor
1645        );
1646
1647        let final_cursor = resolve_cursor_fallback(
1648            output.cursor,
1649            cursor_pos,
1650            buffer_len,
1651            buffer_newline,
1652            output.last_line_end,
1653            output.content_lines_rendered,
1654            gutter_width,
1655        );
1656
1657        // Cursor should be at (gutter_width, 0) - right after the gutter on line 0
1658        assert_eq!(
1659            final_cursor,
1660            Some((gutter_width as u16, 0)),
1661            "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
1662        );
1663    }
1664
1665    #[test]
1666    fn e2e_cursor_between_empty_lines() {
1667        // "\n\n" with cursor at position 1 (on second newline)
1668        let cursor = get_final_cursor("\n\n", 1);
1669        assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
1670
1671        let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
1672        assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
1673        assert_eq!(cursor_pos, Some((0, 1)));
1674    }
1675
1676    #[test]
1677    fn e2e_cursor_at_eof_after_multiple_lines() {
1678        // "abc\ndef\nghi" with cursor at position 11 (at EOF, no trailing newline)
1679        let cursor = get_final_cursor("abc\ndef\nghi", 11);
1680        assert_eq!(
1681            cursor,
1682            Some((3, 2)),
1683            "Cursor at EOF after 'i' should be at column 3, line 2"
1684        );
1685
1686        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
1687        assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
1688        assert_eq!(cursor_pos, Some((3, 2)));
1689    }
1690
1691    #[test]
1692    fn e2e_cursor_at_eof_with_trailing_newline() {
1693        // "abc\ndef\nghi\n" with cursor at position 12 (after trailing newline)
1694        let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
1695        assert_eq!(
1696            cursor,
1697            Some((0, 3)),
1698            "Cursor after trailing newline should be on line 3"
1699        );
1700
1701        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
1702        assert_eq!(
1703            new_content, "abc\ndef\nghi\nX",
1704            "Typing should insert on new line"
1705        );
1706        assert_eq!(cursor_pos, Some((0, 3)));
1707    }
1708
1709    #[test]
1710    fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
1711        // Simulate Ctrl+End: jump from start to end of buffer without trailing newline
1712        let content = "abc\ndef\nghi";
1713
1714        // Start at position 0
1715        let cursor_at_start = get_final_cursor(content, 0);
1716        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
1717
1718        // Jump to EOF (position 11, after 'i')
1719        let cursor_at_eof = get_final_cursor(content, 11);
1720        assert_eq!(
1721            cursor_at_eof,
1722            Some((3, 2)),
1723            "After Ctrl+End, cursor at column 3, line 2"
1724        );
1725
1726        // Type a character at EOF
1727        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
1728        assert_eq!(cursor_before_typing, Some((3, 2)));
1729        assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
1730
1731        // Verify cursor position in the new content
1732        let cursor_after_typing = get_final_cursor(&new_content, 12);
1733        assert_eq!(
1734            cursor_after_typing,
1735            Some((4, 2)),
1736            "After typing, cursor moved to column 4"
1737        );
1738
1739        // Move cursor to start of buffer - verify cursor is no longer at end
1740        let cursor_moved_away = get_final_cursor(&new_content, 0);
1741        assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
1742        // The cursor should NOT be at the end anymore - verify by rendering without cursor at end
1743        // This implicitly tests that only one cursor is rendered
1744    }
1745
1746    #[test]
1747    fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
1748        // Simulate Ctrl+End: jump from start to end of buffer WITH trailing newline
1749        let content = "abc\ndef\nghi\n";
1750
1751        // Start at position 0
1752        let cursor_at_start = get_final_cursor(content, 0);
1753        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
1754
1755        // Jump to EOF (position 12, after trailing newline)
1756        let cursor_at_eof = get_final_cursor(content, 12);
1757        assert_eq!(
1758            cursor_at_eof,
1759            Some((0, 3)),
1760            "After Ctrl+End, cursor at column 0, line 3 (new line)"
1761        );
1762
1763        // Type a character at EOF
1764        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
1765        assert_eq!(cursor_before_typing, Some((0, 3)));
1766        assert_eq!(
1767            new_content, "abc\ndef\nghi\nX",
1768            "Character inserted on new line"
1769        );
1770
1771        // After typing, the cursor should move forward
1772        let cursor_after_typing = get_final_cursor(&new_content, 13);
1773        assert_eq!(
1774            cursor_after_typing,
1775            Some((1, 3)),
1776            "After typing, cursor should be at column 1, line 3"
1777        );
1778
1779        // Move cursor to middle of buffer - verify cursor is no longer at end
1780        let cursor_moved_away = get_final_cursor(&new_content, 4);
1781        assert_eq!(
1782            cursor_moved_away,
1783            Some((0, 1)),
1784            "Cursor moved to start of line 1 (position 4 = start of 'def')"
1785        );
1786    }
1787
1788    #[test]
1789    fn e2e_jump_to_end_of_empty_buffer() {
1790        // Edge case: Ctrl+End in empty buffer should stay at (0,0)
1791        let content = "";
1792
1793        let cursor_at_eof = get_final_cursor(content, 0);
1794        assert_eq!(
1795            cursor_at_eof,
1796            Some((0, 0)),
1797            "Empty buffer: cursor at origin"
1798        );
1799
1800        // Type a character
1801        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
1802        assert_eq!(cursor_before_typing, Some((0, 0)));
1803        assert_eq!(new_content, "X", "Character inserted");
1804
1805        // Verify cursor after typing
1806        let cursor_after_typing = get_final_cursor(&new_content, 1);
1807        assert_eq!(
1808            cursor_after_typing,
1809            Some((1, 0)),
1810            "After typing, cursor at column 1"
1811        );
1812
1813        // Move cursor back to start - verify cursor is no longer at end
1814        let cursor_moved_away = get_final_cursor(&new_content, 0);
1815        assert_eq!(
1816            cursor_moved_away,
1817            Some((0, 0)),
1818            "Cursor moved back to start"
1819        );
1820    }
1821
1822    #[test]
1823    fn e2e_jump_to_end_of_single_empty_line() {
1824        // Edge case: buffer with just a newline
1825        let content = "\n";
1826
1827        // Position 0 is ON the newline
1828        let cursor_on_newline = get_final_cursor(content, 0);
1829        assert_eq!(
1830            cursor_on_newline,
1831            Some((0, 0)),
1832            "Cursor on the newline character"
1833        );
1834
1835        // Position 1 is AFTER the newline (EOF)
1836        let cursor_at_eof = get_final_cursor(content, 1);
1837        assert_eq!(
1838            cursor_at_eof,
1839            Some((0, 1)),
1840            "After Ctrl+End, cursor on line 1"
1841        );
1842
1843        // Type at EOF
1844        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1845        assert_eq!(cursor_before_typing, Some((0, 1)));
1846        assert_eq!(new_content, "\nX", "Character on second line");
1847
1848        let cursor_after_typing = get_final_cursor(&new_content, 2);
1849        assert_eq!(
1850            cursor_after_typing,
1851            Some((1, 1)),
1852            "After typing, cursor at column 1, line 1"
1853        );
1854
1855        // Move cursor to the newline - verify cursor is no longer at end
1856        let cursor_moved_away = get_final_cursor(&new_content, 0);
1857        assert_eq!(
1858            cursor_moved_away,
1859            Some((0, 0)),
1860            "Cursor moved to the newline on line 0"
1861        );
1862    }
1863    // NOTE: Tests for view transform header handling have been moved to src/ui/view_pipeline.rs
1864    // where the elegant token-based pipeline properly handles these cases.
1865    // The view_pipeline tests cover:
1866    // - test_simple_source_lines
1867    // - test_wrapped_continuation
1868    // - test_injected_header_then_source
1869    // - test_mixed_scenario
1870
1871    // ==================== CRLF Tokenization Tests ====================
1872
1873    use fresh_core::api::ViewTokenWireKind;
1874
1875    /// Helper to extract source_offset from tokens for easier assertion
1876    fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1877        tokens
1878            .iter()
1879            .map(|t| {
1880                let kind_str = match &t.kind {
1881                    ViewTokenWireKind::Text(s) => format!("Text({})", s),
1882                    ViewTokenWireKind::Newline => "Newline".to_string(),
1883                    ViewTokenWireKind::Space => "Space".to_string(),
1884                    ViewTokenWireKind::Break => "Break".to_string(),
1885                    ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1886                };
1887                (kind_str, t.source_offset)
1888            })
1889            .collect()
1890    }
1891
1892    /// Test tokenization of CRLF content with a single line.
1893    /// Verifies that Newline token is at \r position and \n is skipped.
1894    #[test]
1895    fn test_build_base_tokens_crlf_single_line() {
1896        // Content: "abc\r\n" (5 bytes: a=0, b=1, c=2, \r=3, \n=4)
1897        let content = b"abc\r\n";
1898        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1899        buffer.set_line_ending(LineEnding::CRLF);
1900
1901        let tokens = SplitRenderer::build_base_tokens_for_hook(
1902            &mut buffer,
1903            0,     // top_byte
1904            80,    // estimated_line_length
1905            10,    // visible_count
1906            false, // is_binary
1907            LineEnding::CRLF,
1908        );
1909
1910        let offsets = extract_token_offsets(&tokens);
1911
1912        // Should have: Text("abc") at 0, Newline at 3
1913        // The \n at byte 4 should be skipped
1914        assert!(
1915            offsets
1916                .iter()
1917                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1918            "Expected Text(abc) at offset 0, got: {:?}",
1919            offsets
1920        );
1921        assert!(
1922            offsets
1923                .iter()
1924                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1925            "Expected Newline at offset 3 (\\r position), got: {:?}",
1926            offsets
1927        );
1928
1929        // Verify there's only one Newline token
1930        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1931        assert_eq!(
1932            newline_count, 1,
1933            "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1934            newline_count, offsets
1935        );
1936    }
1937
1938    /// Test tokenization of CRLF content with multiple lines.
1939    /// This verifies that source_offset correctly accumulates across lines.
1940    #[test]
1941    fn test_build_base_tokens_crlf_multiple_lines() {
1942        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
1943        // Line 1: a=0, b=1, c=2, \r=3, \n=4
1944        // Line 2: d=5, e=6, f=7, \r=8, \n=9
1945        // Line 3: g=10, h=11, i=12, \r=13, \n=14
1946        let content = b"abc\r\ndef\r\nghi\r\n";
1947        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1948        buffer.set_line_ending(LineEnding::CRLF);
1949
1950        let tokens = SplitRenderer::build_base_tokens_for_hook(
1951            &mut buffer,
1952            0,
1953            80,
1954            10,
1955            false,
1956            LineEnding::CRLF,
1957        );
1958
1959        let offsets = extract_token_offsets(&tokens);
1960
1961        // Expected tokens:
1962        // Text("abc") at 0, Newline at 3
1963        // Text("def") at 5, Newline at 8
1964        // Text("ghi") at 10, Newline at 13
1965
1966        // Verify line 1 tokens
1967        assert!(
1968            offsets
1969                .iter()
1970                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1971            "Line 1: Expected Text(abc) at 0, got: {:?}",
1972            offsets
1973        );
1974        assert!(
1975            offsets
1976                .iter()
1977                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1978            "Line 1: Expected Newline at 3, got: {:?}",
1979            offsets
1980        );
1981
1982        // Verify line 2 tokens - THIS IS WHERE OFFSET DRIFT WOULD APPEAR
1983        assert!(
1984            offsets
1985                .iter()
1986                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1987            "Line 2: Expected Text(def) at 5, got: {:?}",
1988            offsets
1989        );
1990        assert!(
1991            offsets
1992                .iter()
1993                .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1994            "Line 2: Expected Newline at 8, got: {:?}",
1995            offsets
1996        );
1997
1998        // Verify line 3 tokens - DRIFT ACCUMULATES HERE
1999        assert!(
2000            offsets
2001                .iter()
2002                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
2003            "Line 3: Expected Text(ghi) at 10, got: {:?}",
2004            offsets
2005        );
2006        assert!(
2007            offsets
2008                .iter()
2009                .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
2010            "Line 3: Expected Newline at 13, got: {:?}",
2011            offsets
2012        );
2013
2014        // Verify exactly 3 Newline tokens
2015        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
2016        assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
2017    }
2018
2019    /// Test tokenization of LF content to compare with CRLF.
2020    /// LF mode should NOT skip anything - each character gets its own offset.
2021    #[test]
2022    fn test_build_base_tokens_lf_mode_for_comparison() {
2023        // Content: "abc\ndef\n" (8 bytes)
2024        // Line 1: a=0, b=1, c=2, \n=3
2025        // Line 2: d=4, e=5, f=6, \n=7
2026        let content = b"abc\ndef\n";
2027        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2028        buffer.set_line_ending(LineEnding::LF);
2029
2030        let tokens = SplitRenderer::build_base_tokens_for_hook(
2031            &mut buffer,
2032            0,
2033            80,
2034            10,
2035            false,
2036            LineEnding::LF,
2037        );
2038
2039        let offsets = extract_token_offsets(&tokens);
2040
2041        // Verify LF offsets
2042        assert!(
2043            offsets
2044                .iter()
2045                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
2046            "LF Line 1: Expected Text(abc) at 0"
2047        );
2048        assert!(
2049            offsets
2050                .iter()
2051                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
2052            "LF Line 1: Expected Newline at 3"
2053        );
2054        assert!(
2055            offsets
2056                .iter()
2057                .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
2058            "LF Line 2: Expected Text(def) at 4"
2059        );
2060        assert!(
2061            offsets
2062                .iter()
2063                .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
2064            "LF Line 2: Expected Newline at 7"
2065        );
2066    }
2067
2068    /// Test that CRLF in LF-mode file shows \r as control character.
2069    /// This verifies that \r is rendered as <0D> in LF files.
2070    #[test]
2071    fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
2072        // Content: "abc\r\n" but buffer is in LF mode
2073        let content = b"abc\r\n";
2074        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2075        buffer.set_line_ending(LineEnding::LF); // Force LF mode
2076
2077        let tokens = SplitRenderer::build_base_tokens_for_hook(
2078            &mut buffer,
2079            0,
2080            80,
2081            10,
2082            false,
2083            LineEnding::LF,
2084        );
2085
2086        let offsets = extract_token_offsets(&tokens);
2087
2088        // In LF mode, \r should be rendered as BinaryByte(0x0d)
2089        assert!(
2090            offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
2091            "LF mode should render \\r as control char <0D>, got: {:?}",
2092            offsets
2093        );
2094    }
2095
2096    /// Test tokenization starting from middle of file (top_byte != 0).
2097    /// Verifies that source_offset is correct even when not starting from byte 0.
2098    #[test]
2099    fn test_build_base_tokens_crlf_from_middle() {
2100        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
2101        // Start from byte 5 (beginning of "def")
2102        let content = b"abc\r\ndef\r\nghi\r\n";
2103        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2104        buffer.set_line_ending(LineEnding::CRLF);
2105
2106        let tokens = SplitRenderer::build_base_tokens_for_hook(
2107            &mut buffer,
2108            5, // Start from line 2
2109            80,
2110            10,
2111            false,
2112            LineEnding::CRLF,
2113        );
2114
2115        let offsets = extract_token_offsets(&tokens);
2116
2117        // Should have:
2118        // Text("def") at 5, Newline at 8
2119        // Text("ghi") at 10, Newline at 13
2120        assert!(
2121            offsets
2122                .iter()
2123                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
2124            "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
2125            offsets
2126        );
2127        assert!(
2128            offsets
2129                .iter()
2130                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
2131            "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
2132            offsets
2133        );
2134    }
2135
2136    /// End-to-end test: verify full pipeline from CRLF buffer to ViewLine to highlighting lookup
2137    /// This test simulates the complete flow that would trigger the offset drift bug.
2138    #[test]
2139    fn test_crlf_highlight_span_lookup() {
2140        use crate::view::ui::view_pipeline::ViewLineIterator;
2141
2142        // Simulate Java-like CRLF content:
2143        // "int x;\r\nint y;\r\n"
2144        // Bytes: i=0, n=1, t=2, ' '=3, x=4, ;=5, \r=6, \n=7,
2145        //        i=8, n=9, t=10, ' '=11, y=12, ;=13, \r=14, \n=15
2146        let content = b"int x;\r\nint y;\r\n";
2147        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2148        buffer.set_line_ending(LineEnding::CRLF);
2149
2150        // Step 1: Generate tokens
2151        let tokens = SplitRenderer::build_base_tokens_for_hook(
2152            &mut buffer,
2153            0,
2154            80,
2155            10,
2156            false,
2157            LineEnding::CRLF,
2158        );
2159
2160        // Verify tokens have correct offsets
2161        let offsets = extract_token_offsets(&tokens);
2162        eprintln!("Tokens: {:?}", offsets);
2163
2164        // Step 2: Convert tokens to ViewLines
2165        let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
2166        assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
2167
2168        // Step 3: Verify char_source_bytes mapping for each line
2169        // Line 1: "int x;\n" displayed, maps to bytes 0-6
2170        eprintln!(
2171            "Line 1 char_source_bytes: {:?}",
2172            view_lines[0].char_source_bytes
2173        );
2174        assert_eq!(
2175            view_lines[0].char_source_bytes.len(),
2176            7,
2177            "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
2178        );
2179        // Check specific mappings
2180        assert_eq!(
2181            view_lines[0].char_source_bytes[0],
2182            Some(0),
2183            "Line 1 'i' -> byte 0"
2184        );
2185        assert_eq!(
2186            view_lines[0].char_source_bytes[4],
2187            Some(4),
2188            "Line 1 'x' -> byte 4"
2189        );
2190        assert_eq!(
2191            view_lines[0].char_source_bytes[5],
2192            Some(5),
2193            "Line 1 ';' -> byte 5"
2194        );
2195        assert_eq!(
2196            view_lines[0].char_source_bytes[6],
2197            Some(6),
2198            "Line 1 newline -> byte 6 (\\r pos)"
2199        );
2200
2201        // Line 2: "int y;\n" displayed, maps to bytes 8-14
2202        eprintln!(
2203            "Line 2 char_source_bytes: {:?}",
2204            view_lines[1].char_source_bytes
2205        );
2206        assert_eq!(
2207            view_lines[1].char_source_bytes.len(),
2208            7,
2209            "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
2210        );
2211        // Check specific mappings - THIS IS WHERE DRIFT WOULD SHOW
2212        assert_eq!(
2213            view_lines[1].char_source_bytes[0],
2214            Some(8),
2215            "Line 2 'i' -> byte 8"
2216        );
2217        assert_eq!(
2218            view_lines[1].char_source_bytes[4],
2219            Some(12),
2220            "Line 2 'y' -> byte 12"
2221        );
2222        assert_eq!(
2223            view_lines[1].char_source_bytes[5],
2224            Some(13),
2225            "Line 2 ';' -> byte 13"
2226        );
2227        assert_eq!(
2228            view_lines[1].char_source_bytes[6],
2229            Some(14),
2230            "Line 2 newline -> byte 14 (\\r pos)"
2231        );
2232
2233        // Step 4: Simulate highlight span lookup
2234        // If TreeSitter highlights "int" as keyword (bytes 0-3 for line 1, bytes 8-11 for line 2),
2235        // the lookup should find these correctly.
2236        let simulated_highlight_spans = [
2237            // "int" on line 1: bytes 0-3
2238            (0usize..3usize, "keyword"),
2239            // "int" on line 2: bytes 8-11
2240            (8usize..11usize, "keyword"),
2241        ];
2242
2243        // Verify that looking up byte positions from char_source_bytes finds the right spans
2244        for (line_idx, view_line) in view_lines.iter().enumerate() {
2245            for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
2246                if let Some(bp) = byte_pos {
2247                    let in_span = simulated_highlight_spans
2248                        .iter()
2249                        .find(|(range, _)| range.contains(bp))
2250                        .map(|(_, name)| *name);
2251
2252                    // First 3 chars of each line should be in keyword span
2253                    let expected_in_keyword = char_idx < 3;
2254                    let actually_in_keyword = in_span == Some("keyword");
2255
2256                    if expected_in_keyword != actually_in_keyword {
2257                        panic!(
2258                            "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
2259                            line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
2260                        );
2261                    }
2262                }
2263            }
2264        }
2265    }
2266
2267    /// Test that apply_wrapping_transform correctly breaks long lines.
2268    /// This prevents memory exhaustion from extremely long single-line files (issue #481).
2269    #[test]
2270    fn test_apply_wrapping_transform_breaks_long_lines() {
2271        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2272
2273        // Create a token with 25,000 characters (longer than MAX_SAFE_LINE_WIDTH of 10,000)
2274        let long_text = "x".repeat(25_000);
2275        let tokens = vec![
2276            ViewTokenWire {
2277                kind: ViewTokenWireKind::Text(long_text),
2278                source_offset: Some(0),
2279                style: None,
2280            },
2281            ViewTokenWire {
2282                kind: ViewTokenWireKind::Newline,
2283                source_offset: Some(25_000),
2284                style: None,
2285            },
2286        ];
2287
2288        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
2289        let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2290
2291        // Count Break tokens - should have at least 2 breaks for 25K chars at 10K width
2292        let break_count = wrapped
2293            .iter()
2294            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
2295            .count();
2296
2297        assert!(
2298            break_count >= 2,
2299            "25K char line should have at least 2 breaks at 10K width, got {}",
2300            break_count
2301        );
2302
2303        // Verify total content is preserved (excluding Break tokens)
2304        let total_chars: usize = wrapped
2305            .iter()
2306            .filter_map(|t| match &t.kind {
2307                ViewTokenWireKind::Text(s) => Some(s.len()),
2308                _ => None,
2309            })
2310            .sum();
2311
2312        assert_eq!(
2313            total_chars, 25_000,
2314            "Total character count should be preserved after wrapping"
2315        );
2316    }
2317
2318    /// Property test encoding the wrap-boundary invariant that the
2319    /// char-split path of [`apply_wrapping_transform`] must satisfy.
2320    ///
2321    /// The invariant is scoped to **char-split** row endings — rows
2322    /// whose last emitted grapheme falls strictly INSIDE a source Text
2323    /// token.  Word-wrap breaks (where the row ends at whitespace
2324    /// between tokens) are outside the scope of the char-split
2325    /// improvement and pass through unchecked; they land at a token
2326    /// boundary by construction.
2327    ///
2328    /// For every non-final visual row whose end is mid-Text-token:
2329    ///
2330    /// 1. **No overflow.** The row's visual width is at most
2331    ///    `content_width`.
2332    /// 2. **No loss.** Concatenating every emitted row in order yields
2333    ///    exactly the original input.
2334    /// 3. **Prefer UAX #29 word boundaries.** Let `hard_cap` be the
2335    ///    largest char position where the row could still fit, and
2336    ///    `floor = max(hard_cap - MAX_LOOKBACK, hard_cap / 2)`, both
2337    ///    measured in characters from the start of this row inside the
2338    ///    input.  If any `split_word_bound_indices()` boundary lies in
2339    ///    `[floor, hard_cap]`, the split must land at the LARGEST such
2340    ///    boundary.
2341    /// 4. **Fall back to hard cap.** If no word boundary lies in that
2342    ///    window, the split lands at `hard_cap` exactly (char split).
2343    #[cfg(test)]
2344    mod wrap_boundary_property {
2345        use super::apply_wrapping_transform;
2346        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2347        use proptest::prelude::*;
2348        use unicode_segmentation::UnicodeSegmentation;
2349
2350        /// Matches the constant used by the implementation.  Defined
2351        /// here as well so the property test can compute the same
2352        /// window without reaching into the module internals.
2353        const MAX_LOOKBACK: usize = 16;
2354
2355        fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
2356            let mut tokens: Vec<ViewTokenWire> = Vec::new();
2357            let mut buf = String::new();
2358            let mut buf_start = 0usize;
2359            for (i, c) in input.char_indices() {
2360                if c == ' ' {
2361                    if !buf.is_empty() {
2362                        tokens.push(ViewTokenWire {
2363                            kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
2364                            source_offset: Some(buf_start),
2365                            style: None,
2366                        });
2367                    }
2368                    tokens.push(ViewTokenWire {
2369                        kind: ViewTokenWireKind::Space,
2370                        source_offset: Some(i),
2371                        style: None,
2372                    });
2373                    buf_start = i + 1;
2374                } else {
2375                    if buf.is_empty() {
2376                        buf_start = i;
2377                    }
2378                    buf.push(c);
2379                }
2380            }
2381            if !buf.is_empty() {
2382                tokens.push(ViewTokenWire {
2383                    kind: ViewTokenWireKind::Text(buf.clone()),
2384                    source_offset: Some(buf_start),
2385                    style: None,
2386                });
2387            }
2388            tokens.push(ViewTokenWire {
2389                kind: ViewTokenWireKind::Newline,
2390                source_offset: Some(input.len()),
2391                style: None,
2392            });
2393            tokens
2394        }
2395
2396        /// Reconstruct the sequence of visual rows from the wrapped
2397        /// token stream.  Each entry is the row's rendered content
2398        /// (Text + Space, with Break separating rows; Newline ends the
2399        /// last row).
2400        fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
2401            let mut rows: Vec<String> = vec![String::new()];
2402            for t in wrapped {
2403                match &t.kind {
2404                    ViewTokenWireKind::Text(s) => {
2405                        rows.last_mut().unwrap().push_str(s);
2406                    }
2407                    ViewTokenWireKind::Space => {
2408                        rows.last_mut().unwrap().push(' ');
2409                    }
2410                    ViewTokenWireKind::Break => {
2411                        rows.push(String::new());
2412                    }
2413                    ViewTokenWireKind::Newline => {
2414                        // End of logical line — ignore for wrap row
2415                        // purposes; we don't wrap across Newline here.
2416                    }
2417                    _ => {}
2418                }
2419            }
2420            rows
2421        }
2422
2423        proptest! {
2424            // A handful of cases per run is plenty — wrapping is
2425            // deterministic, but the input space is large and we want
2426            // shrinking to work.
2427            #![proptest_config(ProptestConfig {
2428                cases: 256,
2429                .. ProptestConfig::default()
2430            })]
2431
2432            /// Core property: the four invariants stated on the module
2433            /// docstring above.
2434            #[test]
2435            fn prop_wrap_respects_boundaries(
2436                input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
2437                content_width in 5usize..40,
2438            ) {
2439                // Hanging indent off and gutter 0 — we want to isolate
2440                // the Text char-split logic from the indent path.
2441                let tokens = tokens_from_input(&input);
2442                let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
2443                let rows = visual_rows(&wrapped);
2444
2445                // Invariant 1: no row exceeds content_width.
2446                for (i, row) in rows.iter().enumerate() {
2447                    prop_assert!(
2448                        row.chars().count() <= content_width,
2449                        "row {i} {:?} has width {} > content_width {content_width}",
2450                        row,
2451                        row.chars().count(),
2452                    );
2453                }
2454
2455                // Invariant 2: lossless reconstruction.
2456                let reconstructed: String = rows.concat();
2457                prop_assert_eq!(
2458                    &reconstructed,
2459                    &input,
2460                    "reconstruction differs from input"
2461                );
2462
2463                // Invariants 3 + 4: every non-final split lands at
2464                // either the largest word boundary in the lookback
2465                // window or the hard cap.
2466                let boundaries: std::collections::BTreeSet<usize> = input
2467                    .split_word_bound_indices()
2468                    .map(|(i, _)| i)
2469                    .chain(std::iter::once(input.len()))
2470                    .collect();
2471
2472                let mut cursor_bytes = 0usize;
2473                let mut cursor_chars = 0usize;
2474                for (i, row) in rows.iter().enumerate() {
2475                    let row_bytes = row.len();
2476                    let row_chars = row.chars().count();
2477                    let row_end_bytes = cursor_bytes + row_bytes;
2478                    let row_end_chars = cursor_chars + row_chars;
2479                    let is_last = i + 1 == rows.len();
2480
2481                    if !is_last {
2482                        // Only apply the boundary invariant to char-
2483                        // splits — row endings that fall strictly
2484                        // inside a Text token.  When the row ends at
2485                        // or adjacent to a space, it's a word-wrap
2486                        // break, which is outside this invariant.
2487                        let input_bytes = input.as_bytes();
2488                        let prev_is_space =
2489                            row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
2490                        let next_is_space = row_end_bytes < input_bytes.len()
2491                            && input_bytes[row_end_bytes] == b' ';
2492                        let is_mid_text = !prev_is_space && !next_is_space;
2493                        if !is_mid_text {
2494                            cursor_bytes = row_end_bytes;
2495                            cursor_chars = row_end_chars;
2496                            continue;
2497                        }
2498
2499                        // The hard cap is the last char position this row
2500                        // could have reached: current cursor + content_width.
2501                        let hard_cap_chars = cursor_chars + content_width;
2502                        let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
2503                        let floor_chars = cursor_chars
2504                            + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
2505                        let floor_bytes = char_index_to_byte(&input, floor_chars);
2506
2507                        // Invariant 3 + 4: either the chosen split is
2508                        // the largest word boundary in [floor,
2509                        // hard_cap] (when any such boundary exists) or
2510                        // it's the hard cap itself (char-split
2511                        // fallback).  Do not exempt "row is exactly
2512                        // content_width" from the check — that's the
2513                        // case the improvement is supposed to change.
2514                        let max_in_window = boundaries
2515                            .range(floor_bytes..=hard_cap_bytes)
2516                            .next_back()
2517                            .copied();
2518                        match max_in_window {
2519                            Some(max_b) => {
2520                                prop_assert_eq!(
2521                                    row_end_bytes,
2522                                    max_b,
2523                                    "split at byte {} but largest word boundary in \
2524                                     [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
2525                                    row_end_bytes,
2526                                    floor_bytes,
2527                                    hard_cap_bytes,
2528                                    max_b,
2529                                    row,
2530                                    input,
2531                                );
2532                            }
2533                            None => {
2534                                prop_assert_eq!(
2535                                    row_end_bytes,
2536                                    hard_cap_bytes,
2537                                    "no word boundary in [floor={}, hard_cap={}], so \
2538                                     char-split must land at hard_cap, but split is at \
2539                                     byte {}; row={:?}, input={:?}",
2540                                    floor_bytes,
2541                                    hard_cap_bytes,
2542                                    row_end_bytes,
2543                                    row,
2544                                    input,
2545                                );
2546                            }
2547                        }
2548                    }
2549
2550                    cursor_bytes = row_end_bytes;
2551                    cursor_chars = row_end_chars;
2552                }
2553            }
2554        }
2555
2556        /// Translate a char index into a byte index for ASCII-ish
2557        /// inputs; clamps to input length.
2558        fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
2559            s.char_indices()
2560                .nth(char_idx)
2561                .map(|(b, _)| b)
2562                .unwrap_or(s.len())
2563        }
2564    }
2565
2566    /// Helper for issue-1363 tests: tokenize a plain ASCII string into
2567    /// `Text` / `Space` tokens the same way `build_base_tokens` would
2568    /// (one `Space` per literal ' '; runs of non-space chars coalesce
2569    /// into a single `Text`).
2570    fn tokenize_for_wrap(text: &str) -> Vec<fresh_core::api::ViewTokenWire> {
2571        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2572        let mut tokens = Vec::new();
2573        let mut buf = String::new();
2574        let mut buf_start: Option<usize> = None;
2575        for (i, ch) in text.char_indices() {
2576            if ch == ' ' {
2577                if !buf.is_empty() {
2578                    tokens.push(ViewTokenWire {
2579                        source_offset: buf_start,
2580                        kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
2581                        style: None,
2582                    });
2583                    buf_start = None;
2584                }
2585                tokens.push(ViewTokenWire {
2586                    source_offset: Some(i),
2587                    kind: ViewTokenWireKind::Space,
2588                    style: None,
2589                });
2590            } else {
2591                if buf.is_empty() {
2592                    buf_start = Some(i);
2593                }
2594                buf.push(ch);
2595            }
2596        }
2597        if !buf.is_empty() {
2598            tokens.push(ViewTokenWire {
2599                source_offset: buf_start,
2600                kind: ViewTokenWireKind::Text(buf),
2601                style: None,
2602            });
2603        }
2604        tokens
2605    }
2606
2607    /// Materialise the row strings emitted by `apply_wrapping_transform`
2608    /// by walking its token output and splitting on `Break`.
2609    fn rows_from_wrapped(wrapped: &[fresh_core::api::ViewTokenWire]) -> Vec<String> {
2610        use fresh_core::api::ViewTokenWireKind;
2611        let mut rows: Vec<String> = vec![String::new()];
2612        for tok in wrapped {
2613            match &tok.kind {
2614                ViewTokenWireKind::Text(s) => rows.last_mut().unwrap().push_str(s),
2615                ViewTokenWireKind::Space => rows.last_mut().unwrap().push(' '),
2616                ViewTokenWireKind::Newline => {}
2617                ViewTokenWireKind::Break => rows.push(String::new()),
2618                ViewTokenWireKind::BinaryByte(_) => {}
2619            }
2620        }
2621        if rows.last().map(|r| r.is_empty()).unwrap_or(false) {
2622            rows.pop();
2623        }
2624        rows
2625    }
2626
2627    /// Issue #1363: wrap should not leak a leading space onto the
2628    /// continuation row.  The fix moves the last word on the prior
2629    /// row to the continuation row when a trailing Space would
2630    /// otherwise overflow — `["AAAAA BBBBBB", " CCCC"]` becomes
2631    /// `["AAAAA ", "BBBBBB CCCC"]`.
2632    #[test]
2633    fn issue_1363_no_leading_space_on_continuation_row() {
2634        let tokens = tokenize_for_wrap("AAAAA BBBBBB CCCC");
2635        let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
2636        let rows = rows_from_wrapped(&wrapped);
2637        assert_eq!(rows.len(), 2, "expected 2 rows, got {:?}", rows);
2638        for (i, row) in rows.iter().enumerate() {
2639            assert!(
2640                !row.starts_with(' '),
2641                "row {i} {:?} starts with whitespace (issue #1363): rows = {:?}",
2642                row,
2643                rows,
2644            );
2645            assert!(
2646                row.chars().count() <= 12,
2647                "row {i} {:?} width {} exceeds eff_width 12 (issue #1363): no char may overflow",
2648                row,
2649                row.chars().count(),
2650            );
2651        }
2652    }
2653
2654    /// Issue #1363: source content is preserved across the wrap —
2655    /// concatenating the rows recovers the original text.  Guards
2656    /// against the back-up logic dropping or duplicating tokens.
2657    #[test]
2658    fn issue_1363_back_up_preserves_content() {
2659        let input = "AAAAA BBBBBB CCCC";
2660        let tokens = tokenize_for_wrap(input);
2661        let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
2662        let rows = rows_from_wrapped(&wrapped);
2663        let reconstructed: String = rows.concat();
2664        assert_eq!(reconstructed, input, "rows = {:?}", rows);
2665    }
2666
2667    /// Issue #1363: when the row contains only one word (e.g. a
2668    /// char-split chunk), there's no prior Space to back up to.  The
2669    /// fix degrades gracefully to the old behaviour — the residual
2670    /// leading-space case is accepted as out of scope.  The invariant
2671    /// we DO maintain is that no row's visible content exceeds the
2672    /// effective width.
2673    #[test]
2674    fn issue_1363_single_word_row_falls_back() {
2675        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2676        let tokens = vec![
2677            ViewTokenWire {
2678                source_offset: Some(0),
2679                kind: ViewTokenWireKind::Text("XXXXXXXX".to_string()),
2680                style: None,
2681            },
2682            ViewTokenWire {
2683                source_offset: Some(8),
2684                kind: ViewTokenWireKind::Space,
2685                style: None,
2686            },
2687            ViewTokenWire {
2688                source_offset: Some(9),
2689                kind: ViewTokenWireKind::Text("YYYY".to_string()),
2690                style: None,
2691            },
2692        ];
2693        let wrapped = apply_wrapping_transform(tokens, 8, 0, false);
2694        let rows = rows_from_wrapped(&wrapped);
2695        for row in &rows {
2696            assert!(
2697                row.chars().count() <= 8,
2698                "row {:?} exceeds eff_width 8 in fallback case",
2699                row,
2700            );
2701        }
2702    }
2703
2704    /// Test that normal-length lines are not affected by safety wrapping.
2705    #[test]
2706    fn test_apply_wrapping_transform_preserves_short_lines() {
2707        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2708
2709        // Create a token with 100 characters (much shorter than MAX_SAFE_LINE_WIDTH)
2710        let short_text = "x".repeat(100);
2711        let tokens = vec![
2712            ViewTokenWire {
2713                kind: ViewTokenWireKind::Text(short_text.clone()),
2714                source_offset: Some(0),
2715                style: None,
2716            },
2717            ViewTokenWire {
2718                kind: ViewTokenWireKind::Newline,
2719                source_offset: Some(100),
2720                style: None,
2721            },
2722        ];
2723
2724        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
2725        let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2726
2727        // Should have no Break tokens for short lines
2728        let break_count = wrapped
2729            .iter()
2730            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
2731            .count();
2732
2733        assert_eq!(
2734            break_count, 0,
2735            "Short lines should not have any breaks, got {}",
2736            break_count
2737        );
2738
2739        // Original text should be preserved exactly
2740        let text_tokens: Vec<_> = wrapped
2741            .iter()
2742            .filter_map(|t| match &t.kind {
2743                ViewTokenWireKind::Text(s) => Some(s.clone()),
2744                _ => None,
2745            })
2746            .collect();
2747
2748        assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
2749        assert_eq!(
2750            text_tokens[0], short_text,
2751            "Text content should be unchanged"
2752        );
2753    }
2754
2755    /// End-to-end test: verify large single-line content with sequential markers
2756    /// is correctly chunked, wrapped, and all data is preserved through the pipeline.
2757    #[test]
2758    fn test_large_single_line_sequential_data_preserved() {
2759        use crate::view::ui::view_pipeline::ViewLineIterator;
2760        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2761
2762        // Create content with sequential markers that span multiple chunks
2763        // Format: "[00001][00002]..." - each marker is 7 chars
2764        let num_markers = 5_000; // ~35KB, enough to test chunking at 10K char intervals
2765        let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
2766
2767        // Create tokens simulating what build_base_tokens would produce
2768        let tokens = vec![
2769            ViewTokenWire {
2770                kind: ViewTokenWireKind::Text(content.clone()),
2771                source_offset: Some(0),
2772                style: None,
2773            },
2774            ViewTokenWire {
2775                kind: ViewTokenWireKind::Newline,
2776                source_offset: Some(content.len()),
2777                style: None,
2778            },
2779        ];
2780
2781        // Apply safety wrapping (simulating line_wrap=false with MAX_SAFE_LINE_WIDTH)
2782        let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2783
2784        // Convert to ViewLines
2785        let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
2786
2787        // Reconstruct content from ViewLines
2788        let mut reconstructed = String::new();
2789        for line in &view_lines {
2790            // Skip the trailing newline character in each line's text
2791            let text = line.text.trim_end_matches('\n');
2792            reconstructed.push_str(text);
2793        }
2794
2795        // Verify all content is preserved
2796        assert_eq!(
2797            reconstructed.len(),
2798            content.len(),
2799            "Reconstructed content length should match original"
2800        );
2801
2802        // Verify sequential markers are all present
2803        for i in 1..=num_markers {
2804            let marker = format!("[{:05}]", i);
2805            assert!(
2806                reconstructed.contains(&marker),
2807                "Missing marker {} after pipeline",
2808                marker
2809            );
2810        }
2811
2812        // Verify order is preserved by checking sample positions
2813        let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
2814        let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
2815        let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
2816        assert!(
2817            pos_100 < pos_1000 && pos_1000 < pos_3000,
2818            "Markers should be in sequential order: {} < {} < {}",
2819            pos_100,
2820            pos_1000,
2821            pos_3000
2822        );
2823
2824        // Verify we got multiple visual lines (content was wrapped)
2825        assert!(
2826            view_lines.len() >= 3,
2827            "35KB content should produce multiple visual lines at 10K width, got {}",
2828            view_lines.len()
2829        );
2830
2831        // Verify each ViewLine is bounded in size (memory safety check)
2832        for (i, line) in view_lines.iter().enumerate() {
2833            assert!(
2834                line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, // +10 for newline and rounding
2835                "ViewLine {} exceeds safe width: {} chars",
2836                i,
2837                line.text.len()
2838            );
2839        }
2840    }
2841
2842    /// Helper: strip OSC 8 escape sequences from a string, returning plain text.
2843    fn strip_osc8(s: &str) -> String {
2844        let mut result = String::with_capacity(s.len());
2845        let bytes = s.as_bytes();
2846        let mut i = 0;
2847        while i < bytes.len() {
2848            if i + 3 < bytes.len()
2849                && bytes[i] == 0x1b
2850                && bytes[i + 1] == b']'
2851                && bytes[i + 2] == b'8'
2852                && bytes[i + 3] == b';'
2853            {
2854                i += 4;
2855                while i < bytes.len() && bytes[i] != 0x07 {
2856                    i += 1;
2857                }
2858                if i < bytes.len() {
2859                    i += 1;
2860                }
2861            } else {
2862                result.push(bytes[i] as char);
2863                i += 1;
2864            }
2865        }
2866        result
2867    }
2868
2869    /// Read a row from a ratatui buffer, skipping the second cell of 2-char
2870    /// OSC 8 chunks so we get clean text.
2871    fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
2872        let width = buf.area().width;
2873        let mut s = String::new();
2874        let mut col = 0u16;
2875        while col < width {
2876            let cell = &buf[(col, y)];
2877            let stripped = strip_osc8(cell.symbol());
2878            let chars = stripped.chars().count();
2879            if chars > 1 {
2880                s.push_str(&stripped);
2881                col += chars as u16;
2882            } else {
2883                s.push_str(&stripped);
2884                col += 1;
2885            }
2886        }
2887        s.trim_end().to_string()
2888    }
2889
2890    #[test]
2891    fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
2892        use ratatui::buffer::Buffer;
2893        use ratatui::layout::Rect;
2894
2895        // Simulate: "[Quick Install](#installation)" in a 40-wide buffer row 0
2896        let text = "[Quick Install](#installation)";
2897        let area = Rect::new(0, 0, 40, 1);
2898        let mut buf = Buffer::empty(area);
2899        for (i, ch) in text.chars().enumerate() {
2900            if (i as u16) < 40 {
2901                buf[(i as u16, 0)].set_symbol(&ch.to_string());
2902            }
2903        }
2904
2905        // Overlay covers "Quick Install" = cols 1..14 (bytes 9..22 mapped to screen)
2906        let url = "https://example.com";
2907
2908        // Apply with cursor at col 0 (not inside the overlay range)
2909        apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
2910
2911        let row = read_row(&buf, 0);
2912        assert_eq!(
2913            row, text,
2914            "After OSC 8 application, reading the row should reproduce the original text"
2915        );
2916
2917        // Cell 14 = ']' must not be touched
2918        let cell14 = strip_osc8(buf[(14, 0)].symbol());
2919        assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
2920
2921        // Cell 0 = '[' must not be touched
2922        let cell0 = strip_osc8(buf[(0, 0)].symbol());
2923        assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
2924    }
2925
2926    #[test]
2927    fn test_apply_osc8_stable_across_reapply() {
2928        use ratatui::buffer::Buffer;
2929        use ratatui::layout::Rect;
2930
2931        let text = "[Quick Install](#installation)";
2932        let area = Rect::new(0, 0, 40, 1);
2933
2934        // First render: apply OSC 8 with cursor at col 0
2935        let mut buf1 = Buffer::empty(area);
2936        for (i, ch) in text.chars().enumerate() {
2937            if (i as u16) < 40 {
2938                buf1[(i as u16, 0)].set_symbol(&ch.to_string());
2939            }
2940        }
2941        apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
2942        let row1 = read_row(&buf1, 0);
2943
2944        // Second render: fresh buffer, same text, apply OSC 8 with cursor at col 5
2945        let mut buf2 = Buffer::empty(area);
2946        for (i, ch) in text.chars().enumerate() {
2947            if (i as u16) < 40 {
2948                buf2[(i as u16, 0)].set_symbol(&ch.to_string());
2949            }
2950        }
2951        apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
2952        let row2 = read_row(&buf2, 0);
2953
2954        assert_eq!(row1, text);
2955        assert_eq!(row2, text);
2956    }
2957
2958    #[test]
2959    #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
2960    fn test_apply_osc8_diff_between_renders() {
2961        use ratatui::buffer::Buffer;
2962        use ratatui::layout::Rect;
2963
2964        // Simulate ratatui's diff-based update: a "concealed" render followed
2965        // by an "unconcealed" render. The backend buffer accumulates diffs.
2966        let area = Rect::new(0, 0, 40, 1);
2967
2968        // --- Render 1: concealed text "Quick Install" at cols 0..12, rest is space ---
2969        let concealed = "Quick Install";
2970        let mut frame1 = Buffer::empty(area);
2971        for (i, ch) in concealed.chars().enumerate() {
2972            frame1[(i as u16, 0)].set_symbol(&ch.to_string());
2973        }
2974        // OSC 8 covers cols 0..13 (concealed mapping)
2975        apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2976
2977        // Simulate backend: starts empty, apply diff from frame1
2978        let prev = Buffer::empty(area);
2979        let mut backend = Buffer::empty(area);
2980        let diff1 = prev.diff(&frame1);
2981        for (x, y, cell) in &diff1 {
2982            backend[(*x, *y)] = (*cell).clone();
2983        }
2984
2985        // --- Render 2: unconcealed "[Quick Install](#installation)" ---
2986        let full = "[Quick Install](#installation)";
2987        let mut frame2 = Buffer::empty(area);
2988        for (i, ch) in full.chars().enumerate() {
2989            if (i as u16) < 40 {
2990                frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2991            }
2992        }
2993        // OSC 8 covers cols 1..14 (unconcealed mapping)
2994        apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2995
2996        // Apply diff from frame1→frame2 to backend
2997        let diff2 = frame1.diff(&frame2);
2998        for (x, y, cell) in &diff2 {
2999            backend[(*x, *y)] = (*cell).clone();
3000        }
3001
3002        // Backend should now show the full text when read
3003        let row = read_row(&backend, 0);
3004        assert_eq!(
3005            row, full,
3006            "After diff-based update from concealed to unconcealed, \
3007             backend should show full text"
3008        );
3009
3010        // Specifically, cell 14 must be ']'
3011        let cell14 = strip_osc8(backend[(14, 0)].symbol());
3012        assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
3013    }
3014
3015    // --- Current line highlight tests ---
3016
3017    fn render_with_highlight_option(
3018        content: &str,
3019        cursor_pos: usize,
3020        highlight_current_line: bool,
3021    ) -> LineRenderOutput {
3022        let mut state = EditorState::new(20, 6, 1024, test_fs());
3023        state.buffer = Buffer::from_str(content, 1024, test_fs());
3024        let mut cursors = crate::model::cursor::Cursors::new();
3025        cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
3026        let viewport = Viewport::new(20, 4);
3027        state.margins.left_config.enabled = false;
3028
3029        let render_area = Rect::new(0, 0, 20, 4);
3030        let visible_count = viewport.visible_line_count();
3031        let gutter_width = state.margins.left_total_width();
3032        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
3033        let empty_folds = FoldManager::new();
3034
3035        let view_data = build_view_data(
3036            &mut state,
3037            &viewport,
3038            None,
3039            content.len().max(1),
3040            visible_count,
3041            false,
3042            render_area.width as usize,
3043            gutter_width,
3044            &ViewMode::Source,
3045            &empty_folds,
3046            &theme,
3047        );
3048        let view_anchor = calculate_view_anchor(&view_data.lines, 0);
3049
3050        let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
3051        state.margins.update_width_for_buffer(estimated_lines, true);
3052        let gutter_width = state.margins.left_total_width();
3053
3054        let selection = selection_context(&state, &cursors);
3055        let _ = state
3056            .buffer
3057            .populate_line_cache(viewport.top_byte, visible_count);
3058        let viewport_start = viewport.top_byte;
3059        let viewport_end = calculate_viewport_end(
3060            &mut state,
3061            viewport_start,
3062            content.len().max(1),
3063            visible_count,
3064        );
3065        let decorations = decoration_context(
3066            &mut state,
3067            viewport_start,
3068            viewport_end,
3069            selection.primary_cursor_position,
3070            &empty_folds,
3071            &theme,
3072            100_000,
3073            &ViewMode::Source,
3074            false,
3075            &[],
3076        );
3077
3078        render_view_lines(LineRenderInput {
3079            state: &state,
3080            theme: &theme,
3081            view_lines: &view_data.lines,
3082            view_anchor,
3083            render_area,
3084            gutter_width,
3085            selection: &selection,
3086            decorations: &decorations,
3087            visible_line_count: visible_count,
3088            lsp_waiting: false,
3089            is_active: true,
3090            line_wrap: viewport.line_wrap_enabled,
3091            estimated_lines,
3092            left_column: viewport.left_column,
3093            relative_line_numbers: false,
3094            session_mode: false,
3095            software_cursor_only: false,
3096            show_line_numbers: false,
3097            byte_offset_mode: false,
3098            show_tilde: true,
3099            highlight_current_line,
3100            indentation_guide: IndentationGuideMode::None,
3101            indentation_guide_glyph: "▏",
3102            cell_theme_map: &mut Vec::new(),
3103            screen_width: 0,
3104        })
3105    }
3106
3107    /// Check whether any span on a given line has `current_line_bg` as its background.
3108    fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
3109        let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
3110        if let Some(line) = output.lines.get(line_idx) {
3111            line.spans
3112                .iter()
3113                .any(|span| span.style.bg == Some(current_line_bg))
3114        } else {
3115            false
3116        }
3117    }
3118
3119    #[test]
3120    fn current_line_highlight_enabled_highlights_cursor_line() {
3121        let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
3122        // Cursor is on line 0 — it should have current_line_bg
3123        assert!(
3124            line_has_current_line_bg(&output, 0),
3125            "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
3126        );
3127        // Line 1 should NOT have current_line_bg
3128        assert!(
3129            !line_has_current_line_bg(&output, 1),
3130            "Non-cursor line (line 1) should NOT have current_line_bg"
3131        );
3132    }
3133
3134    #[test]
3135    fn current_line_highlight_disabled_no_highlight() {
3136        let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
3137        // No line should have current_line_bg when disabled
3138        assert!(
3139            !line_has_current_line_bg(&output, 0),
3140            "Cursor line should NOT have current_line_bg when highlighting is disabled"
3141        );
3142        assert!(
3143            !line_has_current_line_bg(&output, 1),
3144            "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
3145        );
3146    }
3147
3148    #[test]
3149    fn current_line_highlight_follows_cursor_position() {
3150        // Cursor on line 1 (byte 4 = start of "def")
3151        let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
3152        assert!(
3153            !line_has_current_line_bg(&output, 0),
3154            "Line 0 should NOT have current_line_bg when cursor is on line 1"
3155        );
3156        assert!(
3157            line_has_current_line_bg(&output, 1),
3158            "Line 1 should have current_line_bg when cursor is there"
3159        );
3160        assert!(
3161            !line_has_current_line_bg(&output, 2),
3162            "Line 2 should NOT have current_line_bg when cursor is on line 1"
3163        );
3164    }
3165
3166    /// Agreement test: the standalone `wrap_str_to_width` helper used by
3167    /// the virtual-line path must produce the same chunk boundaries as
3168    /// `apply_wrapping_transform` does for a single Text token starting
3169    /// on a fresh row (no tabs, no ANSI, no hanging indent).  This
3170    /// pins the two implementations together so the doc-comment claim
3171    /// "virtual lines wrap like source lines" stays honest.
3172    #[test]
3173    fn wrap_str_to_width_matches_apply_wrapping_transform() {
3174        use crate::primitives::visual_layout::wrap_str_to_width;
3175        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3176
3177        // A range of inputs that exercise both the word-boundary and
3178        // hard-cap fallback paths.  Each (text, wrap_width) pair must
3179        // produce identical chunk byte boundaries on both code paths.
3180        let cases: &[(&str, usize)] = &[
3181            ("hello world how are you today friend", 12),
3182            ("the quick brown fox jumps over the lazy dog", 18),
3183            ("https://example.com/very-long-path/file", 24),
3184            (&"x".repeat(120), 32),
3185            (&"abc ".repeat(40), 25),
3186            ("dialog.getButton(...).setOnClickListener", 24),
3187        ];
3188
3189        for &(text, wrap_width) in cases {
3190            // Direct helper output.
3191            let helper_chunks = wrap_str_to_width(text, wrap_width);
3192            let helper_strings: Vec<&str> =
3193                helper_chunks.iter().map(|r| &text[r.clone()]).collect();
3194
3195            // Run the full transform on a single Text token.  Use
3196            // `gutter_width = 0` so `available_width == content_width`
3197            // and the transform's effective wrap width matches what
3198            // we pass to `wrap_str_to_width`.
3199            let tokens = vec![ViewTokenWire {
3200                kind: ViewTokenWireKind::Text(text.to_string()),
3201                source_offset: Some(0),
3202                style: None,
3203            }];
3204            let wrapped = apply_wrapping_transform(tokens, wrap_width, 0, false);
3205
3206            // Reconstruct the chunks the transform emitted by walking
3207            // its output: each Text token is one chunk; Break tokens
3208            // delimit chunks.  Skip standalone Spaces/etc. — they
3209            // don't appear in our pure-text inputs.
3210            let mut transform_strings: Vec<String> = Vec::new();
3211            for tok in &wrapped {
3212                match &tok.kind {
3213                    ViewTokenWireKind::Text(t) => transform_strings.push(t.clone()),
3214                    ViewTokenWireKind::Break => {}
3215                    other => panic!("unexpected token kind in agreement test: {:?}", other),
3216                }
3217            }
3218
3219            assert_eq!(
3220                transform_strings
3221                    .iter()
3222                    .map(String::as_str)
3223                    .collect::<Vec<_>>(),
3224                helper_strings,
3225                "wrap mismatch for text={text:?} wrap_width={wrap_width}",
3226            );
3227        }
3228    }
3229}