fresh/view/ui/
split_rendering.rs

1//! Split pane layout and buffer rendering
2
3use std::collections::BTreeMap;
4
5use crate::app::types::ViewLineMapping;
6use crate::app::BufferMetadata;
7use crate::model::buffer::Buffer;
8use crate::model::cursor::SelectionMode;
9use crate::model::event::{BufferId, EventLog, SplitDirection};
10use crate::primitives::ansi::AnsiParser;
11use crate::primitives::ansi_background::AnsiBackground;
12use crate::primitives::display_width::char_width;
13use crate::state::{EditorState, ViewMode};
14use crate::view::split::SplitManager;
15use crate::view::ui::tabs::TabsRenderer;
16use crate::view::ui::view_pipeline::{
17    should_show_line_number, LineStart, ViewLine, ViewLineIterator,
18};
19use crate::view::virtual_text::VirtualTextPosition;
20use fresh_core::api::ViewTransformPayload;
21use ratatui::layout::Rect;
22use ratatui::style::{Color, Modifier, Style};
23use ratatui::text::{Line, Span};
24use ratatui::widgets::{Block, Borders, Clear, Paragraph};
25use ratatui::Frame;
26use std::collections::{HashMap, HashSet};
27use std::ops::Range;
28
29/// Maximum line width before forced wrapping is applied, even when line wrapping is disabled.
30/// This prevents memory exhaustion when opening files with extremely long lines (e.g., 10MB
31/// single-line JSON files). Lines exceeding this width are wrapped into multiple visual lines,
32/// each bounded to this width. 10,000 columns is far wider than any monitor while keeping
33/// memory usage reasonable (~80KB per ViewLine instead of hundreds of MB).
34const MAX_SAFE_LINE_WIDTH: usize = 10_000;
35
36/// Compute character-level diff between two strings, returning ranges of changed characters.
37/// Returns a tuple of (old_changed_ranges, new_changed_ranges) where each range indicates
38/// character indices that differ between the strings.
39fn compute_inline_diff(old_text: &str, new_text: &str) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
40    let old_chars: Vec<char> = old_text.chars().collect();
41    let new_chars: Vec<char> = new_text.chars().collect();
42
43    let mut old_ranges = Vec::new();
44    let mut new_ranges = Vec::new();
45
46    // Find common prefix
47    let prefix_len = old_chars
48        .iter()
49        .zip(new_chars.iter())
50        .take_while(|(a, b)| a == b)
51        .count();
52
53    // Find common suffix (from the non-prefix part)
54    let old_remaining = old_chars.len() - prefix_len;
55    let new_remaining = new_chars.len() - prefix_len;
56    let suffix_len = old_chars
57        .iter()
58        .rev()
59        .zip(new_chars.iter().rev())
60        .take(old_remaining.min(new_remaining))
61        .take_while(|(a, b)| a == b)
62        .count();
63
64    // The changed range is between prefix and suffix
65    let old_start = prefix_len;
66    let old_end = old_chars.len().saturating_sub(suffix_len);
67    let new_start = prefix_len;
68    let new_end = new_chars.len().saturating_sub(suffix_len);
69
70    if old_start < old_end {
71        old_ranges.push(old_start..old_end);
72    }
73    if new_start < new_end {
74        new_ranges.push(new_start..new_end);
75    }
76
77    (old_ranges, new_ranges)
78}
79
80fn push_span_with_map(
81    spans: &mut Vec<Span<'static>>,
82    map: &mut Vec<Option<usize>>,
83    text: String,
84    style: Style,
85    source: Option<usize>,
86) {
87    if text.is_empty() {
88        return;
89    }
90    // Push one map entry per visual column (not per character)
91    // Double-width characters (CJK, emoji) need 2 entries
92    // Zero-width characters (like \u{200b}) get 0 entries - they don't occupy screen space
93    for ch in text.chars() {
94        let width = char_width(ch);
95        for _ in 0..width {
96            map.push(source);
97        }
98    }
99    spans.push(Span::styled(text, style));
100}
101
102/// Debug tag style - dim/muted color to distinguish from actual content
103fn debug_tag_style() -> Style {
104    Style::default()
105        .fg(Color::DarkGray)
106        .add_modifier(Modifier::DIM)
107}
108
109/// Compute a dimmed version of a color for EOF tilde lines.
110/// This replaces using Modifier::DIM which can bleed through to overlays.
111fn dim_color_for_tilde(color: Color) -> Color {
112    match color {
113        Color::Rgb(r, g, b) => {
114            // Reduce brightness by ~50% (similar to DIM modifier effect)
115            Color::Rgb(r / 2, g / 2, b / 2)
116        }
117        Color::Indexed(idx) => {
118            // For indexed colors, map to a reasonable dim equivalent
119            // Standard colors 0-7: use corresponding bright versions dimmed
120            // Bright colors 8-15: dim them down
121            // Grayscale and cube colors: just use a dark gray
122            if idx < 16 {
123                Color::Rgb(50, 50, 50) // Dark gray for basic colors
124            } else {
125                Color::Rgb(40, 40, 40) // Slightly darker for extended colors
126            }
127        }
128        // Map named colors to dimmed RGB equivalents
129        Color::Black => Color::Rgb(15, 15, 15),
130        Color::White => Color::Rgb(128, 128, 128),
131        Color::Red => Color::Rgb(100, 30, 30),
132        Color::Green => Color::Rgb(30, 100, 30),
133        Color::Yellow => Color::Rgb(100, 100, 30),
134        Color::Blue => Color::Rgb(30, 30, 100),
135        Color::Magenta => Color::Rgb(100, 30, 100),
136        Color::Cyan => Color::Rgb(30, 100, 100),
137        Color::Gray => Color::Rgb(64, 64, 64),
138        Color::DarkGray => Color::Rgb(40, 40, 40),
139        Color::LightRed => Color::Rgb(128, 50, 50),
140        Color::LightGreen => Color::Rgb(50, 128, 50),
141        Color::LightYellow => Color::Rgb(128, 128, 50),
142        Color::LightBlue => Color::Rgb(50, 50, 128),
143        Color::LightMagenta => Color::Rgb(128, 50, 128),
144        Color::LightCyan => Color::Rgb(50, 128, 128),
145        Color::Reset => Color::Rgb(50, 50, 50),
146    }
147}
148
149/// Accumulator for building spans - collects characters with the same style
150/// into a single span, flushing when style changes. This is important for
151/// proper rendering of combining characters (like Thai diacritics) which
152/// must be in the same string as their base character.
153struct SpanAccumulator {
154    text: String,
155    style: Style,
156    first_source: Option<usize>,
157}
158
159impl SpanAccumulator {
160    fn new() -> Self {
161        Self {
162            text: String::new(),
163            style: Style::default(),
164            first_source: None,
165        }
166    }
167
168    /// Add a character to the accumulator. If the style matches, append to current span.
169    /// If style differs, flush the current span first and start a new one.
170    fn push(
171        &mut self,
172        ch: char,
173        style: Style,
174        source: Option<usize>,
175        spans: &mut Vec<Span<'static>>,
176        map: &mut Vec<Option<usize>>,
177    ) {
178        // If we have accumulated text and the style changed, flush first
179        if !self.text.is_empty() && style != self.style {
180            self.flush(spans, map);
181        }
182
183        // Start new accumulation if empty
184        if self.text.is_empty() {
185            self.style = style;
186            self.first_source = source;
187        }
188
189        self.text.push(ch);
190
191        // Update map for this character's visual width
192        let width = char_width(ch);
193        for _ in 0..width {
194            map.push(source);
195        }
196    }
197
198    /// Flush accumulated text as a span
199    fn flush(&mut self, spans: &mut Vec<Span<'static>>, _map: &mut Vec<Option<usize>>) {
200        if !self.text.is_empty() {
201            spans.push(Span::styled(std::mem::take(&mut self.text), self.style));
202            self.first_source = None;
203        }
204    }
205}
206
207/// Push a debug tag span (no map entries since these aren't real content)
208fn push_debug_tag(spans: &mut Vec<Span<'static>>, map: &mut Vec<Option<usize>>, text: String) {
209    if text.is_empty() {
210        return;
211    }
212    // Debug tags don't map to source positions - they're visual-only
213    for ch in text.chars() {
214        let width = char_width(ch);
215        for _ in 0..width {
216            map.push(None);
217        }
218    }
219    spans.push(Span::styled(text, debug_tag_style()));
220}
221
222/// Context for tracking active spans in debug mode
223#[derive(Default)]
224struct DebugSpanTracker {
225    /// Currently active highlight span (byte range)
226    active_highlight: Option<Range<usize>>,
227    /// Currently active overlay spans (byte ranges)
228    active_overlays: Vec<Range<usize>>,
229}
230
231impl DebugSpanTracker {
232    /// Get opening tags for spans that start at this byte position
233    fn get_opening_tags(
234        &mut self,
235        byte_pos: Option<usize>,
236        highlight_spans: &[crate::primitives::highlighter::HighlightSpan],
237        viewport_overlays: &[(crate::view::overlay::Overlay, Range<usize>)],
238    ) -> Vec<String> {
239        let mut tags = Vec::new();
240
241        if let Some(bp) = byte_pos {
242            // Check if we're entering a new highlight span
243            if let Some(span) = highlight_spans.iter().find(|s| s.range.start == bp) {
244                tags.push(format!("<hl:{}-{}>", span.range.start, span.range.end));
245                self.active_highlight = Some(span.range.clone());
246            }
247
248            // Check if we're entering new overlay spans
249            for (overlay, range) in viewport_overlays.iter() {
250                if range.start == bp {
251                    let overlay_type = match &overlay.face {
252                        crate::view::overlay::OverlayFace::Underline { .. } => "ul",
253                        crate::view::overlay::OverlayFace::Background { .. } => "bg",
254                        crate::view::overlay::OverlayFace::Foreground { .. } => "fg",
255                        crate::view::overlay::OverlayFace::Style { .. } => "st",
256                    };
257                    tags.push(format!("<{}:{}-{}>", overlay_type, range.start, range.end));
258                    self.active_overlays.push(range.clone());
259                }
260            }
261        }
262
263        tags
264    }
265
266    /// Get closing tags for spans that end at this byte position
267    fn get_closing_tags(&mut self, byte_pos: Option<usize>) -> Vec<String> {
268        let mut tags = Vec::new();
269
270        if let Some(bp) = byte_pos {
271            // Check if we're exiting the active highlight span
272            if let Some(ref range) = self.active_highlight {
273                if bp >= range.end {
274                    tags.push("</hl>".to_string());
275                    self.active_highlight = None;
276                }
277            }
278
279            // Check if we're exiting any overlay spans
280            let mut closed_indices = Vec::new();
281            for (i, range) in self.active_overlays.iter().enumerate() {
282                if bp >= range.end {
283                    tags.push("</ov>".to_string());
284                    closed_indices.push(i);
285                }
286            }
287            // Remove closed overlays (in reverse order to preserve indices)
288            for i in closed_indices.into_iter().rev() {
289                self.active_overlays.remove(i);
290            }
291        }
292
293        tags
294    }
295}
296
297/// Processed view data containing display lines from the view pipeline
298struct ViewData {
299    /// Display lines with all token information preserved
300    lines: Vec<ViewLine>,
301}
302
303struct ViewAnchor {
304    start_line_idx: usize,
305    start_line_skip: usize,
306}
307
308struct ComposeLayout {
309    render_area: Rect,
310    left_pad: u16,
311    right_pad: u16,
312}
313
314struct SelectionContext {
315    ranges: Vec<Range<usize>>,
316    block_rects: Vec<(usize, usize, usize, usize)>,
317    cursor_positions: Vec<usize>,
318    primary_cursor_position: usize,
319}
320
321struct DecorationContext {
322    highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
323    semantic_token_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
324    viewport_overlays: Vec<(crate::view::overlay::Overlay, Range<usize>)>,
325    virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>>,
326    diagnostic_lines: HashSet<usize>,
327    /// Line indicators indexed by line number (highest priority indicator per line)
328    line_indicators: BTreeMap<usize, crate::view::margin::LineIndicator>,
329}
330
331struct LineRenderOutput {
332    lines: Vec<Line<'static>>,
333    cursor: Option<(u16, u16)>,
334    last_line_end: Option<LastLineEnd>,
335    content_lines_rendered: usize,
336    view_line_mappings: Vec<ViewLineMapping>,
337}
338
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
340struct LastLineEnd {
341    pos: (u16, u16),
342    terminated_with_newline: bool,
343}
344
345struct SplitLayout {
346    tabs_rect: Rect,
347    content_rect: Rect,
348    scrollbar_rect: Rect,
349}
350
351struct ViewPreferences {
352    view_mode: ViewMode,
353    compose_width: Option<u16>,
354    compose_column_guides: Option<Vec<u16>>,
355    view_transform: Option<ViewTransformPayload>,
356}
357
358struct LineRenderInput<'a> {
359    state: &'a EditorState,
360    theme: &'a crate::view::theme::Theme,
361    /// Display lines from the view pipeline (each line has its own mappings, styles, etc.)
362    view_lines: &'a [ViewLine],
363    view_anchor: ViewAnchor,
364    render_area: Rect,
365    gutter_width: usize,
366    selection: &'a SelectionContext,
367    decorations: &'a DecorationContext,
368    starting_line_num: usize,
369    visible_line_count: usize,
370    lsp_waiting: bool,
371    is_active: bool,
372    line_wrap: bool,
373    estimated_lines: usize,
374    /// Left column offset for horizontal scrolling
375    left_column: usize,
376    /// Whether to show relative line numbers (distance from cursor)
377    relative_line_numbers: bool,
378}
379
380/// Context for computing the style of a single character
381struct CharStyleContext<'a> {
382    byte_pos: Option<usize>,
383    token_style: Option<&'a fresh_core::api::ViewTokenStyle>,
384    ansi_style: Style,
385    is_cursor: bool,
386    is_selected: bool,
387    theme: &'a crate::view::theme::Theme,
388    highlight_spans: &'a [crate::primitives::highlighter::HighlightSpan],
389    semantic_token_spans: &'a [crate::primitives::highlighter::HighlightSpan],
390    viewport_overlays: &'a [(crate::view::overlay::Overlay, Range<usize>)],
391    primary_cursor_position: usize,
392    is_active: bool,
393}
394
395/// Output from compute_char_style
396struct CharStyleOutput {
397    style: Style,
398    is_secondary_cursor: bool,
399}
400
401/// Context for rendering the left margin (line numbers, indicators, separator)
402struct LeftMarginContext<'a> {
403    state: &'a EditorState,
404    theme: &'a crate::view::theme::Theme,
405    is_continuation: bool,
406    current_source_line_num: usize,
407    estimated_lines: usize,
408    diagnostic_lines: &'a HashSet<usize>,
409    /// Pre-computed line indicators (line_num -> indicator)
410    line_indicators: &'a BTreeMap<usize, crate::view::margin::LineIndicator>,
411    /// Line number where the primary cursor is located (for relative line numbers)
412    cursor_line: usize,
413    /// Whether to show relative line numbers
414    relative_line_numbers: bool,
415}
416
417/// Render the left margin (indicators + line numbers + separator) to line_spans
418fn render_left_margin(
419    ctx: &LeftMarginContext,
420    line_spans: &mut Vec<Span<'static>>,
421    line_view_map: &mut Vec<Option<usize>>,
422) {
423    if !ctx.state.margins.left_config.enabled {
424        return;
425    }
426
427    // For continuation lines, don't show any indicators
428    if ctx.is_continuation {
429        push_span_with_map(
430            line_spans,
431            line_view_map,
432            " ".to_string(),
433            Style::default(),
434            None,
435        );
436    } else if ctx.diagnostic_lines.contains(&ctx.current_source_line_num) {
437        // Diagnostic indicators have highest priority
438        push_span_with_map(
439            line_spans,
440            line_view_map,
441            "●".to_string(),
442            Style::default().fg(ratatui::style::Color::Red),
443            None,
444        );
445    } else if let Some(indicator) = ctx.line_indicators.get(&ctx.current_source_line_num) {
446        // Show line indicator (git gutter, breakpoints, etc.)
447        push_span_with_map(
448            line_spans,
449            line_view_map,
450            indicator.symbol.clone(),
451            Style::default().fg(indicator.color),
452            None,
453        );
454    } else {
455        // Show space (no indicator)
456        push_span_with_map(
457            line_spans,
458            line_view_map,
459            " ".to_string(),
460            Style::default(),
461            None,
462        );
463    }
464
465    // Render line number (right-aligned) or blank for continuations
466    if ctx.is_continuation {
467        // For wrapped continuation lines, render blank space
468        let blank = " ".repeat(ctx.state.margins.left_config.width);
469        push_span_with_map(
470            line_spans,
471            line_view_map,
472            blank,
473            Style::default().fg(ctx.theme.line_number_fg),
474            None,
475        );
476    } else if ctx.relative_line_numbers {
477        // Relative line numbers: show distance from cursor, or absolute for cursor line
478        let display_num = if ctx.current_source_line_num == ctx.cursor_line {
479            // Show absolute line number for the cursor line (1-indexed)
480            ctx.current_source_line_num + 1
481        } else {
482            // Show relative distance for other lines
483            ctx.current_source_line_num.abs_diff(ctx.cursor_line)
484        };
485        let rendered_text = format!(
486            "{:>width$}",
487            display_num,
488            width = ctx.state.margins.left_config.width
489        );
490        // Use brighter color for the cursor line
491        let margin_style = if ctx.current_source_line_num == ctx.cursor_line {
492            Style::default().fg(ctx.theme.editor_fg)
493        } else {
494            Style::default().fg(ctx.theme.line_number_fg)
495        };
496        push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
497    } else {
498        let margin_content = ctx.state.margins.render_line(
499            ctx.current_source_line_num,
500            crate::view::margin::MarginPosition::Left,
501            ctx.estimated_lines,
502        );
503        let (rendered_text, style_opt) = margin_content.render(ctx.state.margins.left_config.width);
504
505        // Use custom style if provided, otherwise use default theme color
506        let margin_style =
507            style_opt.unwrap_or_else(|| Style::default().fg(ctx.theme.line_number_fg));
508
509        push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
510    }
511
512    // Render separator
513    if ctx.state.margins.left_config.show_separator {
514        let separator_style = Style::default().fg(ctx.theme.line_number_fg);
515        push_span_with_map(
516            line_spans,
517            line_view_map,
518            ctx.state.margins.left_config.separator.clone(),
519            separator_style,
520            None,
521        );
522    }
523}
524
525/// Compute the style for a character by layering: token -> ANSI -> syntax -> semantic -> overlays -> selection -> cursor
526fn compute_char_style(ctx: &CharStyleContext) -> CharStyleOutput {
527    use crate::view::overlay::OverlayFace;
528
529    // Find highlight color for this byte position
530    let highlight_color = ctx.byte_pos.and_then(|bp| {
531        ctx.highlight_spans
532            .iter()
533            .find(|span| span.range.contains(&bp))
534            .map(|span| span.color)
535    });
536
537    // Find overlays for this byte position
538    let overlays: Vec<&crate::view::overlay::Overlay> = if let Some(bp) = ctx.byte_pos {
539        ctx.viewport_overlays
540            .iter()
541            .filter(|(_, range)| range.contains(&bp))
542            .map(|(overlay, _)| overlay)
543            .collect()
544    } else {
545        Vec::new()
546    };
547
548    // Start with token style if present (for injected content like annotation headers)
549    // Otherwise use ANSI/syntax/theme default
550    let mut style = if let Some(ts) = ctx.token_style {
551        let mut s = Style::default();
552        if let Some((r, g, b)) = ts.fg {
553            s = s.fg(ratatui::style::Color::Rgb(r, g, b));
554        } else {
555            s = s.fg(ctx.theme.editor_fg);
556        }
557        if let Some((r, g, b)) = ts.bg {
558            s = s.bg(ratatui::style::Color::Rgb(r, g, b));
559        }
560        if ts.bold {
561            s = s.add_modifier(Modifier::BOLD);
562        }
563        if ts.italic {
564            s = s.add_modifier(Modifier::ITALIC);
565        }
566        s
567    } else if ctx.ansi_style.fg.is_some()
568        || ctx.ansi_style.bg.is_some()
569        || !ctx.ansi_style.add_modifier.is_empty()
570    {
571        // Apply ANSI styling from escape codes
572        let mut s = Style::default();
573        if let Some(fg) = ctx.ansi_style.fg {
574            s = s.fg(fg);
575        } else {
576            s = s.fg(ctx.theme.editor_fg);
577        }
578        if let Some(bg) = ctx.ansi_style.bg {
579            s = s.bg(bg);
580        }
581        s = s.add_modifier(ctx.ansi_style.add_modifier);
582        s
583    } else if let Some(color) = highlight_color {
584        // Apply syntax highlighting
585        Style::default().fg(color)
586    } else {
587        // Default color from theme
588        Style::default().fg(ctx.theme.editor_fg)
589    };
590
591    // If we have ANSI style but also syntax highlighting, syntax takes precedence for color
592    // (unless ANSI has explicit color which we already applied above)
593    if let Some(color) = highlight_color {
594        if ctx.ansi_style.fg.is_none()
595            && (ctx.ansi_style.bg.is_some() || !ctx.ansi_style.add_modifier.is_empty())
596        {
597            style = style.fg(color);
598        }
599    }
600
601    // Note: Reference highlighting (word under cursor) is now handled via overlays
602    // in the "Apply overlay styles" section below
603
604    // Apply LSP semantic token foreground color when no custom token style is set.
605    if ctx.token_style.is_none() {
606        if let Some(bp) = ctx.byte_pos {
607            if let Some(token_span) = ctx
608                .semantic_token_spans
609                .iter()
610                .find(|span| span.range.contains(&bp))
611            {
612                style = style.fg(token_span.color);
613            }
614        }
615    }
616
617    // Apply overlay styles
618    for overlay in &overlays {
619        match &overlay.face {
620            OverlayFace::Underline {
621                color,
622                style: _underline_style,
623            } => {
624                style = style.add_modifier(Modifier::UNDERLINED).fg(*color);
625            }
626            OverlayFace::Background { color } => {
627                style = style.bg(*color);
628            }
629            OverlayFace::Foreground { color } => {
630                style = style.fg(*color);
631            }
632            OverlayFace::Style {
633                style: overlay_style,
634            } => {
635                style = style.patch(*overlay_style);
636            }
637        }
638    }
639
640    // Apply selection highlighting
641    if ctx.is_selected {
642        style = Style::default()
643            .fg(ctx.theme.editor_fg)
644            .bg(ctx.theme.selection_bg);
645    }
646
647    // Apply cursor styling - make all cursors visible with reversed colors
648    // For active splits: apply REVERSED to ensure character under cursor is visible
649    // (especially important for block cursors where white-on-white would be invisible)
650    // For inactive splits: use a less pronounced background color (no hardware cursor)
651    let is_secondary_cursor = ctx.is_cursor && ctx.byte_pos != Some(ctx.primary_cursor_position);
652    if ctx.is_active {
653        if ctx.is_cursor {
654            // Apply REVERSED to all cursor positions (primary and secondary)
655            // This ensures the character under the cursor is always visible
656            style = style.add_modifier(Modifier::REVERSED);
657        }
658    } else if ctx.is_cursor {
659        style = style.fg(ctx.theme.editor_fg).bg(ctx.theme.inactive_cursor);
660    }
661
662    CharStyleOutput {
663        style,
664        is_secondary_cursor,
665    }
666}
667
668/// Renders split panes and their content
669pub struct SplitRenderer;
670
671impl SplitRenderer {
672    /// Render the main content area with all splits
673    ///
674    /// # Arguments
675    /// * `frame` - The ratatui frame to render to
676    /// * `area` - The rectangular area to render in
677    /// * `split_manager` - The split manager
678    /// * `buffers` - All open buffers
679    /// * `buffer_metadata` - Metadata for buffers (contains display names)
680    /// * `event_logs` - Event logs for each buffer
681    /// * `theme` - The active theme for colors
682    /// * `lsp_waiting` - Whether LSP is waiting
683    /// * `large_file_threshold_bytes` - Threshold for using constant scrollbar thumb size
684    /// * `line_wrap` - Whether line wrapping is enabled
685    /// * `estimated_line_length` - Estimated average line length for large file line estimation
686    /// * `hide_cursor` - Whether to hide the hardware cursor (e.g., when menu is open)
687    ///
688    /// # Returns
689    /// * Vec of (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end) for mouse handling
690    #[allow(clippy::too_many_arguments)]
691    #[allow(clippy::type_complexity)]
692    pub fn render_content(
693        frame: &mut Frame,
694        area: Rect,
695        split_manager: &SplitManager,
696        buffers: &mut HashMap<BufferId, EditorState>,
697        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
698        event_logs: &mut HashMap<BufferId, EventLog>,
699        composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
700        composite_view_states: &mut HashMap<
701            (crate::model::event::SplitId, BufferId),
702            crate::view::composite_view::CompositeViewState,
703        >,
704        theme: &crate::view::theme::Theme,
705        ansi_background: Option<&AnsiBackground>,
706        background_fade: f32,
707        lsp_waiting: bool,
708        large_file_threshold_bytes: u64,
709        _line_wrap: bool,
710        estimated_line_length: usize,
711        highlight_context_bytes: usize,
712        mut split_view_states: Option<
713            &mut HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
714        >,
715        hide_cursor: bool,
716        hovered_tab: Option<(BufferId, crate::model::event::SplitId, bool)>, // (buffer_id, split_id, is_close_button)
717        hovered_close_split: Option<crate::model::event::SplitId>,
718        hovered_maximize_split: Option<crate::model::event::SplitId>,
719        is_maximized: bool,
720        relative_line_numbers: bool,
721        tab_bar_visible: bool,
722        use_terminal_bg: bool,
723    ) -> (
724        Vec<(
725            crate::model::event::SplitId,
726            BufferId,
727            Rect,
728            Rect,
729            usize,
730            usize,
731        )>,
732        Vec<(crate::model::event::SplitId, BufferId, u16, u16, u16, u16)>,
733        Vec<(crate::model::event::SplitId, u16, u16, u16)>, // close split button areas
734        Vec<(crate::model::event::SplitId, u16, u16, u16)>, // maximize split button areas
735        HashMap<crate::model::event::SplitId, Vec<ViewLineMapping>>, // view line mappings for mouse clicks
736    ) {
737        let _span = tracing::trace_span!("render_content").entered();
738
739        // Get all visible splits with their areas
740        let visible_buffers = split_manager.get_visible_buffers(area);
741        let active_split_id = split_manager.active_split();
742        let has_multiple_splits = visible_buffers.len() > 1;
743
744        // Collect areas for mouse handling
745        let mut split_areas = Vec::new();
746        let mut all_tab_areas = Vec::new();
747        let mut close_split_areas = Vec::new();
748        let mut maximize_split_areas = Vec::new();
749        let mut view_line_mappings: HashMap<crate::model::event::SplitId, Vec<ViewLineMapping>> =
750            HashMap::new();
751
752        // Render each split
753        for (split_id, buffer_id, split_area) in visible_buffers {
754            let is_active = split_id == active_split_id;
755
756            let layout = Self::split_layout(split_area, tab_bar_visible);
757            let (split_buffers, tab_scroll_offset) =
758                Self::split_buffers_for_tabs(split_view_states.as_deref(), split_id, buffer_id);
759
760            // Determine hover state for this split's tabs
761            let tab_hover_for_split = hovered_tab.and_then(|(hover_buf, hover_split, is_close)| {
762                if hover_split == split_id {
763                    Some((hover_buf, is_close))
764                } else {
765                    None
766                }
767            });
768
769            // Only render tabs and split control buttons when tab bar is visible
770            if tab_bar_visible {
771                // Render tabs for this split and collect hit areas
772                let tab_hit_areas = TabsRenderer::render_for_split(
773                    frame,
774                    layout.tabs_rect,
775                    &split_buffers,
776                    buffers,
777                    buffer_metadata,
778                    composite_buffers,
779                    buffer_id, // The currently displayed buffer in this split
780                    theme,
781                    is_active,
782                    tab_scroll_offset,
783                    tab_hover_for_split,
784                );
785
786                // Add tab row to hit areas (all tabs share the same row)
787                let tab_row = layout.tabs_rect.y;
788                for (buf_id, start_col, end_col, close_start) in tab_hit_areas {
789                    all_tab_areas.push((
790                        split_id,
791                        buf_id,
792                        tab_row,
793                        start_col,
794                        end_col,
795                        close_start,
796                    ));
797                }
798
799                // Render split control buttons at the right side of tabs row
800                // Show maximize/unmaximize button when: multiple splits exist OR we're currently maximized
801                // Show close button when: multiple splits exist AND we're not maximized
802                let show_maximize_btn = has_multiple_splits || is_maximized;
803                let show_close_btn = has_multiple_splits && !is_maximized;
804
805                if show_maximize_btn || show_close_btn {
806                    // Calculate button positions from right edge
807                    // Layout: [maximize] [space] [close] |
808                    let mut btn_x = layout.tabs_rect.x + layout.tabs_rect.width.saturating_sub(2);
809
810                    // Render close button first (rightmost) if visible
811                    if show_close_btn {
812                        let is_hovered = hovered_close_split == Some(split_id);
813                        let close_fg = if is_hovered {
814                            theme.tab_close_hover_fg
815                        } else {
816                            theme.line_number_fg
817                        };
818                        let close_button = Paragraph::new("×")
819                            .style(Style::default().fg(close_fg).bg(theme.tab_separator_bg));
820                        let close_area = Rect::new(btn_x, tab_row, 1, 1);
821                        frame.render_widget(close_button, close_area);
822                        close_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
823                        btn_x = btn_x.saturating_sub(2); // Move left with 1 space for next button
824                    }
825
826                    // Render maximize/unmaximize button
827                    if show_maximize_btn {
828                        let is_hovered = hovered_maximize_split == Some(split_id);
829                        let max_fg = if is_hovered {
830                            theme.tab_close_hover_fg
831                        } else {
832                            theme.line_number_fg
833                        };
834                        // Use □ for maximize, ⧉ for unmaximize (restore)
835                        let icon = if is_maximized { "⧉" } else { "□" };
836                        let max_button = Paragraph::new(icon)
837                            .style(Style::default().fg(max_fg).bg(theme.tab_separator_bg));
838                        let max_area = Rect::new(btn_x, tab_row, 1, 1);
839                        frame.render_widget(max_button, max_area);
840                        maximize_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
841                    }
842                }
843            }
844
845            // Get references separately to avoid double borrow
846            let state_opt = buffers.get_mut(&buffer_id);
847            let event_log_opt = event_logs.get_mut(&buffer_id);
848
849            if let Some(state) = state_opt {
850                // Check if this is a composite buffer - render differently
851                if state.is_composite_buffer {
852                    if let Some(composite) = composite_buffers.get(&buffer_id) {
853                        // Update SplitViewState viewport to match actual rendered area
854                        // This ensures cursor movement uses correct viewport height after resize
855                        if let Some(ref mut svs) = split_view_states {
856                            if let Some(split_vs) = svs.get_mut(&split_id) {
857                                if split_vs.viewport.width != layout.content_rect.width
858                                    || split_vs.viewport.height != layout.content_rect.height
859                                {
860                                    split_vs.viewport.resize(
861                                        layout.content_rect.width,
862                                        layout.content_rect.height,
863                                    );
864                                }
865                            }
866                        }
867
868                        // Get or create composite view state
869                        let pane_count = composite.pane_count();
870                        let view_state = composite_view_states
871                            .entry((split_id, buffer_id))
872                            .or_insert_with(|| {
873                                crate::view::composite_view::CompositeViewState::new(
874                                    buffer_id, pane_count,
875                                )
876                            });
877                        // Render composite buffer with side-by-side panes
878                        Self::render_composite_buffer(
879                            frame,
880                            layout.content_rect,
881                            composite,
882                            buffers,
883                            theme,
884                            is_active,
885                            view_state,
886                            use_terminal_bg,
887                        );
888
889                        // Render scrollbar for composite buffer
890                        let total_rows = composite.row_count();
891                        let content_height = layout.content_rect.height.saturating_sub(1) as usize; // -1 for header
892                        let (thumb_start, thumb_end) = Self::render_composite_scrollbar(
893                            frame,
894                            layout.scrollbar_rect,
895                            total_rows,
896                            view_state.scroll_row,
897                            content_height,
898                            is_active,
899                        );
900
901                        // Store the areas for mouse handling
902                        split_areas.push((
903                            split_id,
904                            buffer_id,
905                            layout.content_rect,
906                            layout.scrollbar_rect,
907                            thumb_start,
908                            thumb_end,
909                        ));
910                    }
911                    view_line_mappings.insert(split_id, Vec::new());
912                    continue;
913                }
914
915                // Get viewport from SplitViewState (authoritative source)
916                // We need to get it mutably for sync operations
917                // Use as_deref() to get Option<&HashMap> for read-only operations
918                let view_state_opt = split_view_states
919                    .as_deref()
920                    .and_then(|vs| vs.get(&split_id));
921                let viewport_clone =
922                    view_state_opt
923                        .map(|vs| vs.viewport.clone())
924                        .unwrap_or_else(|| {
925                            crate::view::viewport::Viewport::new(
926                                layout.content_rect.width,
927                                layout.content_rect.height,
928                            )
929                        });
930                let mut viewport = viewport_clone;
931
932                let saved_cursors = Self::temporary_split_state(
933                    state,
934                    split_view_states.as_deref(),
935                    split_id,
936                    is_active,
937                );
938                Self::sync_viewport_to_content(
939                    &mut viewport,
940                    &mut state.buffer,
941                    &state.cursors,
942                    layout.content_rect,
943                );
944                let view_prefs =
945                    Self::resolve_view_preferences(state, split_view_states.as_deref(), split_id);
946
947                let split_view_mappings = Self::render_buffer_in_split(
948                    frame,
949                    state,
950                    &mut viewport,
951                    event_log_opt,
952                    layout.content_rect,
953                    is_active,
954                    theme,
955                    ansi_background,
956                    background_fade,
957                    lsp_waiting,
958                    view_prefs.view_mode,
959                    view_prefs.compose_width,
960                    view_prefs.compose_column_guides,
961                    view_prefs.view_transform,
962                    estimated_line_length,
963                    highlight_context_bytes,
964                    buffer_id,
965                    hide_cursor,
966                    relative_line_numbers,
967                    use_terminal_bg,
968                );
969
970                // Store view line mappings for mouse click handling
971                view_line_mappings.insert(split_id, split_view_mappings);
972
973                // For small files, count actual lines for accurate scrollbar
974                // For large files, we'll use a constant thumb size
975                let buffer_len = state.buffer.len();
976                let (total_lines, top_line) = Self::scrollbar_line_counts(
977                    state,
978                    &viewport,
979                    large_file_threshold_bytes,
980                    buffer_len,
981                );
982
983                // Render scrollbar for this split and get thumb position
984                let (thumb_start, thumb_end) = Self::render_scrollbar(
985                    frame,
986                    state,
987                    &viewport,
988                    layout.scrollbar_rect,
989                    is_active,
990                    theme,
991                    large_file_threshold_bytes,
992                    total_lines,
993                    top_line,
994                );
995
996                // Restore the original cursors after rendering content and scrollbar
997                Self::restore_split_state(state, saved_cursors);
998
999                // Write back updated viewport to SplitViewState
1000                // This is crucial for cursor visibility tracking (ensure_visible_in_layout updates)
1001                // NOTE: We do NOT clear skip_ensure_visible here - it should persist across
1002                // renders until something actually needs cursor visibility check
1003                if let Some(view_states) = split_view_states.as_deref_mut() {
1004                    if let Some(view_state) = view_states.get_mut(&split_id) {
1005                        tracing::trace!(
1006                            "Writing back viewport: top_byte={}, skip_ensure_visible={}",
1007                            viewport.top_byte,
1008                            viewport.should_skip_ensure_visible()
1009                        );
1010                        view_state.viewport = viewport.clone();
1011                    }
1012                }
1013
1014                // Store the areas for mouse handling
1015                split_areas.push((
1016                    split_id,
1017                    buffer_id,
1018                    layout.content_rect,
1019                    layout.scrollbar_rect,
1020                    thumb_start,
1021                    thumb_end,
1022                ));
1023            }
1024        }
1025
1026        // Render split separators
1027        let separators = split_manager.get_separators(area);
1028        for (direction, x, y, length) in separators {
1029            Self::render_separator(frame, direction, x, y, length, theme);
1030        }
1031
1032        (
1033            split_areas,
1034            all_tab_areas,
1035            close_split_areas,
1036            maximize_split_areas,
1037            view_line_mappings,
1038        )
1039    }
1040
1041    /// Render a split separator line
1042    fn render_separator(
1043        frame: &mut Frame,
1044        direction: SplitDirection,
1045        x: u16,
1046        y: u16,
1047        length: u16,
1048        theme: &crate::view::theme::Theme,
1049    ) {
1050        match direction {
1051            SplitDirection::Horizontal => {
1052                // Draw horizontal line
1053                let line_area = Rect::new(x, y, length, 1);
1054                let line_text = "─".repeat(length as usize);
1055                let paragraph =
1056                    Paragraph::new(line_text).style(Style::default().fg(theme.split_separator_fg));
1057                frame.render_widget(paragraph, line_area);
1058            }
1059            SplitDirection::Vertical => {
1060                // Draw vertical line
1061                for offset in 0..length {
1062                    let cell_area = Rect::new(x, y + offset, 1, 1);
1063                    let paragraph =
1064                        Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
1065                    frame.render_widget(paragraph, cell_area);
1066                }
1067            }
1068        }
1069    }
1070
1071    /// Render a composite buffer (side-by-side view of multiple source buffers)
1072    /// Uses ViewLines for proper syntax highlighting, ANSI handling, etc.
1073    fn render_composite_buffer(
1074        frame: &mut Frame,
1075        area: Rect,
1076        composite: &crate::model::composite_buffer::CompositeBuffer,
1077        buffers: &mut HashMap<BufferId, EditorState>,
1078        theme: &crate::view::theme::Theme,
1079        _is_active: bool,
1080        view_state: &mut crate::view::composite_view::CompositeViewState,
1081        use_terminal_bg: bool,
1082    ) {
1083        use crate::model::composite_buffer::{CompositeLayout, RowType};
1084
1085        // Compute effective editor background: terminal default or theme-defined
1086        let effective_editor_bg = if use_terminal_bg {
1087            ratatui::style::Color::Reset
1088        } else {
1089            theme.editor_bg
1090        };
1091
1092        let scroll_row = view_state.scroll_row;
1093        let cursor_row = view_state.cursor_row;
1094
1095        // Clear the area first
1096        frame.render_widget(Clear, area);
1097
1098        // Calculate pane widths based on layout
1099        let pane_count = composite.sources.len();
1100        if pane_count == 0 {
1101            return;
1102        }
1103
1104        // Extract show_separator from layout
1105        let show_separator = match &composite.layout {
1106            CompositeLayout::SideBySide { show_separator, .. } => *show_separator,
1107            _ => false,
1108        };
1109
1110        // Calculate pane areas
1111        let separator_width = if show_separator { 1 } else { 0 };
1112        let total_separators = (pane_count.saturating_sub(1)) as u16 * separator_width;
1113        let available_width = area.width.saturating_sub(total_separators);
1114
1115        let pane_widths: Vec<u16> = match &composite.layout {
1116            CompositeLayout::SideBySide { ratios, .. } => {
1117                let default_ratio = 1.0 / pane_count as f32;
1118                ratios
1119                    .iter()
1120                    .chain(std::iter::repeat(&default_ratio))
1121                    .take(pane_count)
1122                    .map(|r| (available_width as f32 * r).round() as u16)
1123                    .collect()
1124            }
1125            _ => {
1126                // Equal widths for stacked/unified layouts
1127                let pane_width = available_width / pane_count as u16;
1128                vec![pane_width; pane_count]
1129            }
1130        };
1131
1132        // Store computed pane widths in view state for cursor movement calculations
1133        view_state.pane_widths = pane_widths.clone();
1134
1135        // Render headers first
1136        let header_height = 1u16;
1137        let mut x_offset = area.x;
1138        for (idx, (source, &width)) in composite.sources.iter().zip(&pane_widths).enumerate() {
1139            let header_area = Rect::new(x_offset, area.y, width, header_height);
1140            let is_focused = idx == view_state.focused_pane;
1141
1142            let header_style = if is_focused {
1143                Style::default()
1144                    .fg(theme.tab_active_fg)
1145                    .bg(theme.tab_active_bg)
1146            } else {
1147                Style::default()
1148                    .fg(theme.tab_inactive_fg)
1149                    .bg(theme.tab_inactive_bg)
1150            };
1151
1152            let header_text = format!(" {} ", source.label);
1153            let header = Paragraph::new(header_text).style(header_style);
1154            frame.render_widget(header, header_area);
1155
1156            x_offset += width + separator_width;
1157        }
1158
1159        // Content area (below headers)
1160        let content_y = area.y + header_height;
1161        let content_height = area.height.saturating_sub(header_height);
1162        let visible_rows = content_height as usize;
1163
1164        // Render aligned rows
1165        let alignment = &composite.alignment;
1166        let total_rows = alignment.rows.len();
1167
1168        // Build ViewData and get syntax highlighting for each pane
1169        // Store: (ViewLines, line->ViewLine mapping, highlight spans)
1170        struct PaneRenderData {
1171            lines: Vec<ViewLine>,
1172            line_to_view_line: HashMap<usize, usize>,
1173            highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
1174        }
1175
1176        let mut pane_render_data: Vec<Option<PaneRenderData>> = Vec::new();
1177
1178        for (pane_idx, source) in composite.sources.iter().enumerate() {
1179            if let Some(source_state) = buffers.get_mut(&source.buffer_id) {
1180                // Find the first and last source lines we need for this pane
1181                let visible_lines: Vec<usize> = alignment
1182                    .rows
1183                    .iter()
1184                    .skip(scroll_row)
1185                    .take(visible_rows)
1186                    .filter_map(|row| row.get_pane_line(pane_idx))
1187                    .map(|r| r.line)
1188                    .collect();
1189
1190                let first_line = visible_lines.iter().copied().min();
1191                let last_line = visible_lines.iter().copied().max();
1192
1193                if let (Some(first_line), Some(last_line)) = (first_line, last_line) {
1194                    // Get byte range for highlighting
1195                    let top_byte = source_state
1196                        .buffer
1197                        .line_start_offset(first_line)
1198                        .unwrap_or(0);
1199                    let end_byte = source_state
1200                        .buffer
1201                        .line_start_offset(last_line + 1)
1202                        .unwrap_or(source_state.buffer.len());
1203
1204                    // Get syntax highlighting spans from the highlighter
1205                    let highlight_spans = source_state.highlighter.highlight_viewport(
1206                        &source_state.buffer,
1207                        top_byte,
1208                        end_byte,
1209                        theme,
1210                        1024, // highlight_context_bytes
1211                    );
1212
1213                    // Create a temporary viewport for building view data
1214                    let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80);
1215                    let mut viewport =
1216                        crate::view::viewport::Viewport::new(pane_width, content_height);
1217                    viewport.top_byte = top_byte;
1218                    viewport.line_wrap_enabled = false;
1219
1220                    let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80) as usize;
1221                    let gutter_width = 4; // Line number width
1222                    let content_width = pane_width.saturating_sub(gutter_width);
1223
1224                    // Build ViewData for this pane
1225                    // Need enough lines to cover from first_line to last_line
1226                    let lines_needed = last_line - first_line + 10;
1227                    let view_data = Self::build_view_data(
1228                        source_state,
1229                        &viewport,
1230                        None,         // No view transform
1231                        80,           // estimated_line_length
1232                        lines_needed, // visible_count - enough to cover the range
1233                        false,        // line_wrap_enabled
1234                        content_width,
1235                        gutter_width,
1236                    );
1237
1238                    // Build source_line -> ViewLine index mapping
1239                    let mut line_to_view_line: HashMap<usize, usize> = HashMap::new();
1240                    let mut current_line = first_line;
1241                    for (idx, view_line) in view_data.lines.iter().enumerate() {
1242                        if should_show_line_number(view_line) {
1243                            line_to_view_line.insert(current_line, idx);
1244                            current_line += 1;
1245                        }
1246                    }
1247
1248                    pane_render_data.push(Some(PaneRenderData {
1249                        lines: view_data.lines,
1250                        line_to_view_line,
1251                        highlight_spans,
1252                    }));
1253                } else {
1254                    pane_render_data.push(None);
1255                }
1256            } else {
1257                pane_render_data.push(None);
1258            }
1259        }
1260
1261        // Now render aligned rows using ViewLines
1262        for view_row in 0..visible_rows {
1263            let display_row = scroll_row + view_row;
1264            if display_row >= total_rows {
1265                // Fill with tildes for empty rows
1266                let mut x = area.x;
1267                for &width in &pane_widths {
1268                    let tilde_area = Rect::new(x, content_y + view_row as u16, width, 1);
1269                    let tilde =
1270                        Paragraph::new("~").style(Style::default().fg(theme.line_number_fg));
1271                    frame.render_widget(tilde, tilde_area);
1272                    x += width + separator_width;
1273                }
1274                continue;
1275            }
1276
1277            let aligned_row = &alignment.rows[display_row];
1278            let is_cursor_row = display_row == cursor_row;
1279            // Get selection column range for this row (if any)
1280            let selection_cols = view_state.selection_column_range(display_row);
1281
1282            // Determine row background based on type (selection is now character-level)
1283            let row_bg = match aligned_row.row_type {
1284                RowType::Addition => Some(theme.diff_add_bg),
1285                RowType::Deletion => Some(theme.diff_remove_bg),
1286                RowType::Modification => Some(theme.diff_modify_bg),
1287                RowType::HunkHeader => Some(theme.current_line_bg),
1288                RowType::Context => None,
1289            };
1290
1291            // Compute inline diff for modified rows (to highlight changed words/characters)
1292            let inline_diffs: Vec<Vec<Range<usize>>> = if aligned_row.row_type
1293                == RowType::Modification
1294            {
1295                // Get line content from both panes
1296                let mut line_contents: Vec<Option<String>> = Vec::new();
1297                for (pane_idx, source) in composite.sources.iter().enumerate() {
1298                    if let Some(line_ref) = aligned_row.get_pane_line(pane_idx) {
1299                        if let Some(source_state) = buffers.get(&source.buffer_id) {
1300                            line_contents.push(
1301                                source_state
1302                                    .buffer
1303                                    .get_line(line_ref.line)
1304                                    .map(|line| String::from_utf8_lossy(&line).to_string()),
1305                            );
1306                        } else {
1307                            line_contents.push(None);
1308                        }
1309                    } else {
1310                        line_contents.push(None);
1311                    }
1312                }
1313
1314                // Compute inline diff between panes (typically old vs new)
1315                if line_contents.len() >= 2 {
1316                    if let (Some(old_text), Some(new_text)) = (&line_contents[0], &line_contents[1])
1317                    {
1318                        let (old_ranges, new_ranges) = compute_inline_diff(old_text, new_text);
1319                        vec![old_ranges, new_ranges]
1320                    } else {
1321                        vec![Vec::new(); composite.sources.len()]
1322                    }
1323                } else {
1324                    vec![Vec::new(); composite.sources.len()]
1325                }
1326            } else {
1327                // For non-modification rows, no inline highlighting
1328                vec![Vec::new(); composite.sources.len()]
1329            };
1330
1331            // Render each pane for this row
1332            let mut x_offset = area.x;
1333            for (pane_idx, (_source, &width)) in
1334                composite.sources.iter().zip(&pane_widths).enumerate()
1335            {
1336                let pane_area = Rect::new(x_offset, content_y + view_row as u16, width, 1);
1337
1338                // Get horizontal scroll offset for this pane
1339                let left_column = view_state
1340                    .get_pane_viewport(pane_idx)
1341                    .map(|v| v.left_column)
1342                    .unwrap_or(0);
1343
1344                // Get source line for this pane
1345                let source_line_opt = aligned_row.get_pane_line(pane_idx);
1346
1347                if let Some(source_line_ref) = source_line_opt {
1348                    // Try to get ViewLine and highlight spans from pre-built data
1349                    let pane_data = pane_render_data.get(pane_idx).and_then(|opt| opt.as_ref());
1350                    let view_line_opt = pane_data.and_then(|data| {
1351                        data.line_to_view_line
1352                            .get(&source_line_ref.line)
1353                            .and_then(|&idx| data.lines.get(idx))
1354                    });
1355                    let highlight_spans = pane_data
1356                        .map(|data| data.highlight_spans.as_slice())
1357                        .unwrap_or(&[]);
1358
1359                    let gutter_width = 4usize;
1360                    let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
1361
1362                    let is_focused_pane = pane_idx == view_state.focused_pane;
1363
1364                    // Determine background - cursor row highlight only on focused pane
1365                    // Selection is now character-level, handled in render_view_line_content
1366                    let bg = if is_cursor_row && is_focused_pane {
1367                        theme.current_line_bg
1368                    } else {
1369                        row_bg.unwrap_or(effective_editor_bg)
1370                    };
1371
1372                    // Selection range for this row (only for focused pane)
1373                    let pane_selection_cols = if is_focused_pane {
1374                        selection_cols
1375                    } else {
1376                        None
1377                    };
1378
1379                    // Line number
1380                    let line_num = format!("{:>3} ", source_line_ref.line + 1);
1381                    let line_num_style = Style::default().fg(theme.line_number_fg).bg(bg);
1382
1383                    let is_cursor_pane = is_focused_pane;
1384                    let cursor_column = view_state.cursor_column;
1385
1386                    // Get inline diff ranges for this pane
1387                    let inline_ranges = inline_diffs.get(pane_idx).cloned().unwrap_or_default();
1388
1389                    // Determine highlight color for changed portions (brighter than line bg)
1390                    let highlight_bg = match aligned_row.row_type {
1391                        RowType::Deletion => Some(theme.diff_remove_highlight_bg),
1392                        RowType::Addition => Some(theme.diff_add_highlight_bg),
1393                        RowType::Modification => {
1394                            if pane_idx == 0 {
1395                                Some(theme.diff_remove_highlight_bg)
1396                            } else {
1397                                Some(theme.diff_add_highlight_bg)
1398                            }
1399                        }
1400                        _ => None,
1401                    };
1402
1403                    // Build spans using ViewLine if available (for syntax highlighting)
1404                    let mut spans = vec![Span::styled(line_num, line_num_style)];
1405
1406                    if let Some(view_line) = view_line_opt {
1407                        // Use ViewLine for syntax-highlighted content
1408                        Self::render_view_line_content(
1409                            &mut spans,
1410                            view_line,
1411                            highlight_spans,
1412                            left_column,
1413                            max_content_width,
1414                            bg,
1415                            theme,
1416                            is_cursor_row && is_cursor_pane,
1417                            cursor_column,
1418                            &inline_ranges,
1419                            highlight_bg,
1420                            pane_selection_cols,
1421                        );
1422                    } else {
1423                        // This branch should be unreachable:
1424                        // - visible_lines is collected from the same range we iterate over
1425                        // - If source_line_ref exists, that line was in visible_lines
1426                        // - So pane_render_data exists and the line should be in the mapping
1427                        // - With line_wrap disabled, each source line = one ViewLine
1428                        tracing::warn!(
1429                            "ViewLine missing for composite buffer: pane={}, line={}, pane_data={}",
1430                            pane_idx,
1431                            source_line_ref.line,
1432                            pane_data.is_some()
1433                        );
1434                        // Graceful degradation: render empty content with background
1435                        let base_style = Style::default().fg(theme.editor_fg).bg(bg);
1436                        let padding = " ".repeat(max_content_width);
1437                        spans.push(Span::styled(padding, base_style));
1438                    }
1439
1440                    let line = Line::from(spans);
1441                    let para = Paragraph::new(line);
1442                    frame.render_widget(para, pane_area);
1443                } else {
1444                    // No content for this pane (padding/gap line)
1445                    let is_focused_pane = pane_idx == view_state.focused_pane;
1446                    // For empty lines in focused pane, show selection if entire line is selected
1447                    let pane_has_selection = is_focused_pane
1448                        && selection_cols
1449                            .map(|(start, end)| start == 0 && end == usize::MAX)
1450                            .unwrap_or(false);
1451
1452                    let bg = if pane_has_selection {
1453                        theme.selection_bg
1454                    } else if is_cursor_row && is_focused_pane {
1455                        theme.current_line_bg
1456                    } else {
1457                        row_bg.unwrap_or(effective_editor_bg)
1458                    };
1459                    let style = Style::default().fg(theme.line_number_fg).bg(bg);
1460
1461                    // Check if cursor should be shown on this empty line
1462                    let is_cursor_pane = pane_idx == view_state.focused_pane;
1463                    if is_cursor_row && is_cursor_pane && view_state.cursor_column == 0 {
1464                        // Show cursor on empty line
1465                        let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
1466                        let gutter_width = 4usize;
1467                        let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
1468                        let padding = " ".repeat(max_content_width.saturating_sub(1));
1469                        let line = Line::from(vec![
1470                            Span::styled("    ", style),
1471                            Span::styled(" ", cursor_style),
1472                            Span::styled(padding, Style::default().bg(bg)),
1473                        ]);
1474                        let para = Paragraph::new(line);
1475                        frame.render_widget(para, pane_area);
1476                    } else {
1477                        // Empty gap line with diff background
1478                        let gap_style = Style::default().bg(bg);
1479                        let empty_content = " ".repeat(width as usize);
1480                        let para = Paragraph::new(empty_content).style(gap_style);
1481                        frame.render_widget(para, pane_area);
1482                    }
1483                }
1484
1485                x_offset += width;
1486
1487                // Render separator
1488                if show_separator && pane_idx < pane_count - 1 {
1489                    let sep_area =
1490                        Rect::new(x_offset, content_y + view_row as u16, separator_width, 1);
1491                    let sep =
1492                        Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
1493                    frame.render_widget(sep, sep_area);
1494                    x_offset += separator_width;
1495                }
1496            }
1497        }
1498    }
1499
1500    /// Render ViewLine content with syntax highlighting to spans
1501    #[allow(clippy::too_many_arguments)]
1502    fn render_view_line_content(
1503        spans: &mut Vec<Span<'static>>,
1504        view_line: &ViewLine,
1505        highlight_spans: &[crate::primitives::highlighter::HighlightSpan],
1506        left_column: usize,
1507        max_width: usize,
1508        bg: Color,
1509        theme: &crate::view::theme::Theme,
1510        show_cursor: bool,
1511        cursor_column: usize,
1512        inline_ranges: &[Range<usize>],
1513        highlight_bg: Option<Color>,
1514        selection_cols: Option<(usize, usize)>, // (start_col, end_col) for selection
1515    ) {
1516        let text = &view_line.text;
1517        let char_source_bytes = &view_line.char_source_bytes;
1518
1519        // Apply horizontal scroll and collect visible characters with styles
1520        let chars: Vec<char> = text.chars().collect();
1521        let mut col = 0usize;
1522        let mut rendered = 0usize;
1523        let mut current_span_text = String::new();
1524        let mut current_style: Option<Style> = None;
1525
1526        for (char_idx, ch) in chars.iter().enumerate() {
1527            let char_width = char_width(*ch);
1528
1529            // Skip characters before left_column
1530            if col < left_column {
1531                col += char_width;
1532                continue;
1533            }
1534
1535            // Stop if we've rendered enough
1536            if rendered >= max_width {
1537                break;
1538            }
1539
1540            // Get source byte position for this character
1541            let byte_pos = char_source_bytes.get(char_idx).and_then(|b| *b);
1542
1543            // Get syntax highlight color from highlight_spans
1544            let highlight_color = byte_pos.and_then(|bp| {
1545                highlight_spans
1546                    .iter()
1547                    .find(|span| span.range.contains(&bp))
1548                    .map(|span| span.color)
1549            });
1550
1551            // Check if this character is in an inline diff range
1552            let in_inline_range = inline_ranges.iter().any(|r| r.contains(&char_idx));
1553
1554            // Check if this character is in selection range
1555            let in_selection = selection_cols
1556                .map(|(start, end)| col >= start && col < end)
1557                .unwrap_or(false);
1558
1559            // Determine background: selection > inline diff > normal
1560            let char_bg = if in_selection {
1561                theme.selection_bg
1562            } else if in_inline_range {
1563                highlight_bg.unwrap_or(bg)
1564            } else {
1565                bg
1566            };
1567
1568            // Build character style
1569            let char_style = if let Some(color) = highlight_color {
1570                Style::default().fg(color).bg(char_bg)
1571            } else {
1572                Style::default().fg(theme.editor_fg).bg(char_bg)
1573            };
1574
1575            // Handle cursor - cursor_column is absolute position, compare directly with col
1576            let final_style = if show_cursor && col == cursor_column {
1577                // Invert colors for cursor
1578                Style::default().fg(theme.editor_bg).bg(theme.editor_fg)
1579            } else {
1580                char_style
1581            };
1582
1583            // Accumulate or flush spans based on style changes
1584            if let Some(style) = current_style {
1585                if style != final_style && !current_span_text.is_empty() {
1586                    spans.push(Span::styled(std::mem::take(&mut current_span_text), style));
1587                }
1588            }
1589
1590            current_style = Some(final_style);
1591            current_span_text.push(*ch);
1592            col += char_width;
1593            rendered += char_width;
1594        }
1595
1596        // Flush remaining span
1597        if !current_span_text.is_empty() {
1598            if let Some(style) = current_style {
1599                spans.push(Span::styled(current_span_text, style));
1600            }
1601        }
1602
1603        // Pad to fill width
1604        if rendered < max_width {
1605            let padding_len = max_width - rendered;
1606            // cursor_column is absolute, convert to visual position for padding check
1607            let cursor_visual = cursor_column.saturating_sub(left_column);
1608
1609            // Check if cursor is in the padding area (past end of line content)
1610            if show_cursor && cursor_visual >= rendered && cursor_visual < max_width {
1611                // Cursor is in padding area - render cursor as single char
1612                let cursor_offset = cursor_visual - rendered;
1613                let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
1614                let normal_style = Style::default().bg(bg);
1615
1616                // Pre-cursor padding (if cursor is not at start of padding)
1617                if cursor_offset > 0 {
1618                    spans.push(Span::styled(" ".repeat(cursor_offset), normal_style));
1619                }
1620                // Single-char cursor
1621                spans.push(Span::styled(" ", cursor_style));
1622                // Post-cursor padding
1623                let remaining = padding_len.saturating_sub(cursor_offset + 1);
1624                if remaining > 0 {
1625                    spans.push(Span::styled(" ".repeat(remaining), normal_style));
1626                }
1627            } else {
1628                // No cursor in padding - just fill with background
1629                spans.push(Span::styled(
1630                    " ".repeat(padding_len),
1631                    Style::default().bg(bg),
1632                ));
1633            }
1634        }
1635    }
1636
1637    /// Render a scrollbar for composite buffer views
1638    fn render_composite_scrollbar(
1639        frame: &mut Frame,
1640        scrollbar_rect: Rect,
1641        total_rows: usize,
1642        scroll_row: usize,
1643        viewport_height: usize,
1644        is_active: bool,
1645    ) -> (usize, usize) {
1646        let height = scrollbar_rect.height as usize;
1647        if height == 0 || total_rows == 0 {
1648            return (0, 0);
1649        }
1650
1651        // Calculate thumb size based on viewport ratio to total document
1652        let thumb_size_raw = if total_rows > 0 {
1653            ((viewport_height as f64 / total_rows as f64) * height as f64).ceil() as usize
1654        } else {
1655            1
1656        };
1657
1658        // Maximum scroll position
1659        let max_scroll = total_rows.saturating_sub(viewport_height);
1660
1661        // When content fits in viewport, fill entire scrollbar
1662        let thumb_size = if max_scroll == 0 {
1663            height
1664        } else {
1665            // Cap thumb size: minimum 1, maximum 80% of scrollbar height
1666            let max_thumb_size = (height as f64 * 0.8).floor() as usize;
1667            thumb_size_raw.max(1).min(max_thumb_size).min(height)
1668        };
1669
1670        // Calculate thumb position
1671        let thumb_start = if max_scroll > 0 {
1672            let scroll_ratio = scroll_row.min(max_scroll) as f64 / max_scroll as f64;
1673            let max_thumb_start = height.saturating_sub(thumb_size);
1674            (scroll_ratio * max_thumb_start as f64) as usize
1675        } else {
1676            0
1677        };
1678
1679        let thumb_end = thumb_start + thumb_size;
1680
1681        // Choose colors based on whether split is active
1682        let track_color = if is_active {
1683            Color::DarkGray
1684        } else {
1685            Color::Black
1686        };
1687        let thumb_color = if is_active {
1688            Color::Gray
1689        } else {
1690            Color::DarkGray
1691        };
1692
1693        // Render as background fills
1694        for row in 0..height {
1695            let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
1696
1697            let style = if row >= thumb_start && row < thumb_end {
1698                Style::default().bg(thumb_color)
1699            } else {
1700                Style::default().bg(track_color)
1701            };
1702
1703            let paragraph = Paragraph::new(" ").style(style);
1704            frame.render_widget(paragraph, cell_area);
1705        }
1706
1707        (thumb_start, thumb_end)
1708    }
1709
1710    fn split_layout(split_area: Rect, tab_bar_visible: bool) -> SplitLayout {
1711        let tabs_height = if tab_bar_visible { 1u16 } else { 0u16 };
1712        let scrollbar_width = 1u16;
1713
1714        let tabs_rect = Rect::new(split_area.x, split_area.y, split_area.width, tabs_height);
1715        let content_rect = Rect::new(
1716            split_area.x,
1717            split_area.y + tabs_height,
1718            split_area.width.saturating_sub(scrollbar_width),
1719            split_area.height.saturating_sub(tabs_height),
1720        );
1721        let scrollbar_rect = Rect::new(
1722            split_area.x + split_area.width.saturating_sub(scrollbar_width),
1723            split_area.y + tabs_height,
1724            scrollbar_width,
1725            split_area.height.saturating_sub(tabs_height),
1726        );
1727
1728        SplitLayout {
1729            tabs_rect,
1730            content_rect,
1731            scrollbar_rect,
1732        }
1733    }
1734
1735    fn split_buffers_for_tabs(
1736        split_view_states: Option<
1737            &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1738        >,
1739        split_id: crate::model::event::SplitId,
1740        buffer_id: BufferId,
1741    ) -> (Vec<BufferId>, usize) {
1742        if let Some(view_states) = split_view_states {
1743            if let Some(view_state) = view_states.get(&split_id) {
1744                return (
1745                    view_state.open_buffers.clone(),
1746                    view_state.tab_scroll_offset,
1747                );
1748            }
1749        }
1750        (vec![buffer_id], 0)
1751    }
1752
1753    fn temporary_split_state(
1754        state: &mut EditorState,
1755        split_view_states: Option<
1756            &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1757        >,
1758        split_id: crate::model::event::SplitId,
1759        is_active: bool,
1760    ) -> Option<crate::model::cursor::Cursors> {
1761        if is_active {
1762            return None;
1763        }
1764
1765        if let Some(view_states) = split_view_states {
1766            if let Some(view_state) = view_states.get(&split_id) {
1767                // Only save/restore cursors - viewport is now owned by SplitViewState
1768                let saved_cursors = Some(std::mem::replace(
1769                    &mut state.cursors,
1770                    view_state.cursors.clone(),
1771                ));
1772                return saved_cursors;
1773            }
1774        }
1775
1776        None
1777    }
1778
1779    fn restore_split_state(
1780        state: &mut EditorState,
1781        saved_cursors: Option<crate::model::cursor::Cursors>,
1782    ) {
1783        if let Some(cursors) = saved_cursors {
1784            state.cursors = cursors;
1785        }
1786    }
1787
1788    fn sync_viewport_to_content(
1789        viewport: &mut crate::view::viewport::Viewport,
1790        buffer: &mut crate::model::buffer::Buffer,
1791        cursors: &crate::model::cursor::Cursors,
1792        content_rect: Rect,
1793    ) {
1794        let size_changed =
1795            viewport.width != content_rect.width || viewport.height != content_rect.height;
1796
1797        if size_changed {
1798            viewport.resize(content_rect.width, content_rect.height);
1799        }
1800
1801        // Always sync viewport with cursor to ensure visibility after cursor movements
1802        // The sync_with_cursor method internally checks needs_sync and skip_resize_sync
1803        // so this is safe to call unconditionally. Previously needs_sync was set by
1804        // EditorState.apply() but now viewport is owned by SplitViewState.
1805        let primary = *cursors.primary();
1806        viewport.ensure_visible(buffer, &primary);
1807    }
1808
1809    fn resolve_view_preferences(
1810        state: &EditorState,
1811        split_view_states: Option<
1812            &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1813        >,
1814        split_id: crate::model::event::SplitId,
1815    ) -> ViewPreferences {
1816        if let Some(view_states) = split_view_states {
1817            if let Some(view_state) = view_states.get(&split_id) {
1818                return ViewPreferences {
1819                    view_mode: view_state.view_mode.clone(),
1820                    compose_width: view_state.compose_width,
1821                    compose_column_guides: view_state.compose_column_guides.clone(),
1822                    view_transform: view_state.view_transform.clone(),
1823                };
1824            }
1825        }
1826
1827        ViewPreferences {
1828            view_mode: state.view_mode.clone(),
1829            compose_width: state.compose_width,
1830            compose_column_guides: state.compose_column_guides.clone(),
1831            view_transform: state.view_transform.clone(),
1832        }
1833    }
1834
1835    fn scrollbar_line_counts(
1836        state: &EditorState,
1837        viewport: &crate::view::viewport::Viewport,
1838        large_file_threshold_bytes: u64,
1839        buffer_len: usize,
1840    ) -> (usize, usize) {
1841        if buffer_len > large_file_threshold_bytes as usize {
1842            return (0, 0);
1843        }
1844
1845        let total_lines = if buffer_len > 0 {
1846            state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1847        } else {
1848            1
1849        };
1850
1851        let top_line = if viewport.top_byte < buffer_len {
1852            state.buffer.get_line_number(viewport.top_byte)
1853        } else {
1854            0
1855        };
1856
1857        (total_lines, top_line)
1858    }
1859
1860    /// Render a scrollbar for a split
1861    /// Returns (thumb_start, thumb_end) positions for mouse hit testing
1862    #[allow(clippy::too_many_arguments)]
1863    fn render_scrollbar(
1864        frame: &mut Frame,
1865        state: &EditorState,
1866        viewport: &crate::view::viewport::Viewport,
1867        scrollbar_rect: Rect,
1868        is_active: bool,
1869        _theme: &crate::view::theme::Theme,
1870        large_file_threshold_bytes: u64,
1871        total_lines: usize,
1872        top_line: usize,
1873    ) -> (usize, usize) {
1874        let height = scrollbar_rect.height as usize;
1875        if height == 0 {
1876            return (0, 0);
1877        }
1878
1879        let buffer_len = state.buffer.len();
1880        let viewport_top = viewport.top_byte;
1881        // Use the constant viewport height (allocated terminal rows), not visible_line_count()
1882        // which varies based on content. The scrollbar should represent the ratio of the
1883        // viewport AREA to total document size, remaining constant throughout scrolling.
1884        let viewport_height_lines = viewport.height as usize;
1885
1886        // Calculate scrollbar thumb position and size
1887        let (thumb_start, thumb_size) = if buffer_len > large_file_threshold_bytes as usize {
1888            // Large file: use constant 1-character thumb for performance
1889            let thumb_start = if buffer_len > 0 {
1890                ((viewport_top as f64 / buffer_len as f64) * height as f64) as usize
1891            } else {
1892                0
1893            };
1894            (thumb_start, 1)
1895        } else {
1896            // Small file: use actual line count for accurate scrollbar
1897            // total_lines and top_line are passed in (already calculated with mutable access)
1898
1899            // Calculate thumb size based on viewport ratio to total document
1900            let thumb_size_raw = if total_lines > 0 {
1901                ((viewport_height_lines as f64 / total_lines as f64) * height as f64).ceil()
1902                    as usize
1903            } else {
1904                1
1905            };
1906
1907            // Calculate the maximum scroll position first to determine if buffer fits in viewport
1908            // The maximum scroll position is when the last line of the file is at
1909            // the bottom of the viewport, i.e., max_scroll_line = total_lines - viewport_height
1910            let max_scroll_line = total_lines.saturating_sub(viewport_height_lines);
1911
1912            // When buffer fits entirely in viewport (no scrolling possible),
1913            // fill the entire scrollbar to make it obvious to the user
1914            let thumb_size = if max_scroll_line == 0 {
1915                height
1916            } else {
1917                // Cap thumb size: minimum 1, maximum 80% of scrollbar height
1918                let max_thumb_size = (height as f64 * 0.8).floor() as usize;
1919                thumb_size_raw.max(1).min(max_thumb_size).min(height)
1920            };
1921
1922            // Calculate thumb position using proper linear mapping:
1923            // - At line 0: thumb_start = 0
1924            // - At max scroll position: thumb_start = height - thumb_size
1925            let thumb_start = if max_scroll_line > 0 {
1926                // Linear interpolation from 0 to (height - thumb_size)
1927                let scroll_ratio = top_line.min(max_scroll_line) as f64 / max_scroll_line as f64;
1928                let max_thumb_start = height.saturating_sub(thumb_size);
1929                (scroll_ratio * max_thumb_start as f64) as usize
1930            } else {
1931                // File fits in viewport, thumb fills entire height starting at top
1932                0
1933            };
1934
1935            (thumb_start, thumb_size)
1936        };
1937
1938        let thumb_end = thumb_start + thumb_size;
1939
1940        // Choose colors based on whether split is active
1941        let track_color = if is_active {
1942            Color::DarkGray
1943        } else {
1944            Color::Black
1945        };
1946        let thumb_color = if is_active {
1947            Color::Gray
1948        } else {
1949            Color::DarkGray
1950        };
1951
1952        // Render as background fills to avoid glyph gaps in terminals like Apple Terminal.
1953        for row in 0..height {
1954            let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
1955
1956            let style = if row >= thumb_start && row < thumb_end {
1957                // Thumb
1958                Style::default().bg(thumb_color)
1959            } else {
1960                // Track
1961                Style::default().bg(track_color)
1962            };
1963
1964            let paragraph = Paragraph::new(" ").style(style);
1965            frame.render_widget(paragraph, cell_area);
1966        }
1967
1968        // Return thumb position for mouse hit testing
1969        (thumb_start, thumb_end)
1970    }
1971
1972    #[allow(clippy::too_many_arguments)]
1973    fn build_view_data(
1974        state: &mut EditorState,
1975        viewport: &crate::view::viewport::Viewport,
1976        view_transform: Option<ViewTransformPayload>,
1977        estimated_line_length: usize,
1978        visible_count: usize,
1979        line_wrap_enabled: bool,
1980        content_width: usize,
1981        gutter_width: usize,
1982    ) -> ViewData {
1983        // Check if buffer is binary before building tokens
1984        let is_binary = state.buffer.is_binary();
1985        let line_ending = state.buffer.line_ending();
1986
1987        // Build base token stream from source
1988        let base_tokens = Self::build_base_tokens(
1989            &mut state.buffer,
1990            viewport.top_byte,
1991            estimated_line_length,
1992            visible_count,
1993            is_binary,
1994            line_ending,
1995        );
1996
1997        // Use plugin transform if available, otherwise use base tokens
1998        let mut tokens = view_transform.map(|vt| vt.tokens).unwrap_or(base_tokens);
1999
2000        // Apply wrapping transform - always enabled for safety, but with different thresholds.
2001        // When line_wrap is on: wrap at viewport width for normal text flow.
2002        // When line_wrap is off: wrap at MAX_SAFE_LINE_WIDTH to prevent memory exhaustion
2003        // from extremely long lines (e.g., 10MB single-line JSON files).
2004        let effective_width = if line_wrap_enabled {
2005            content_width
2006        } else {
2007            MAX_SAFE_LINE_WIDTH
2008        };
2009        tokens = Self::apply_wrapping_transform(tokens, effective_width, gutter_width);
2010
2011        // Convert tokens to display lines using the view pipeline
2012        // Each ViewLine preserves LineStart info for correct line number rendering
2013        // Use binary mode if the buffer contains binary content
2014        // Enable ANSI awareness for non-binary content to handle escape sequences correctly
2015        let is_binary = state.buffer.is_binary();
2016        let ansi_aware = !is_binary; // ANSI parsing for normal text files
2017        let source_lines: Vec<ViewLine> =
2018            ViewLineIterator::new(&tokens, is_binary, ansi_aware, state.tab_size).collect();
2019
2020        // Inject virtual lines (LineAbove/LineBelow) from VirtualTextManager
2021        let lines = Self::inject_virtual_lines(source_lines, state);
2022
2023        ViewData { lines }
2024    }
2025
2026    /// Create a ViewLine from virtual text content (for LineAbove/LineBelow)
2027    fn create_virtual_line(text: &str, style: ratatui::style::Style) -> ViewLine {
2028        use fresh_core::api::ViewTokenStyle;
2029
2030        let text = text.to_string();
2031        let len = text.chars().count();
2032
2033        // Convert ratatui Style to ViewTokenStyle
2034        let token_style = ViewTokenStyle {
2035            fg: style.fg.and_then(|c| match c {
2036                ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
2037                _ => None,
2038            }),
2039            bg: style.bg.and_then(|c| match c {
2040                ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
2041                _ => None,
2042            }),
2043            bold: style.add_modifier.contains(ratatui::style::Modifier::BOLD),
2044            italic: style
2045                .add_modifier
2046                .contains(ratatui::style::Modifier::ITALIC),
2047        };
2048
2049        ViewLine {
2050            text,
2051            // Per-character data: all None - no source mapping (this is injected content)
2052            char_source_bytes: vec![None; len],
2053            // All have the virtual text's style
2054            char_styles: vec![Some(token_style); len],
2055            // Visual column positions for each character (0, 1, 2, ...)
2056            char_visual_cols: (0..len).collect(),
2057            // Per-visual-column: each column maps to its corresponding character
2058            visual_to_char: (0..len).collect(),
2059            tab_starts: HashSet::new(),
2060            // AfterInjectedNewline means no line number will be shown
2061            line_start: LineStart::AfterInjectedNewline,
2062            ends_with_newline: true,
2063        }
2064    }
2065
2066    /// Inject virtual lines (LineAbove/LineBelow) into the ViewLine stream
2067    fn inject_virtual_lines(source_lines: Vec<ViewLine>, state: &EditorState) -> Vec<ViewLine> {
2068        use crate::view::virtual_text::VirtualTextPosition;
2069
2070        // Get viewport byte range from source lines
2071        let viewport_start = source_lines
2072            .first()
2073            .and_then(|l| l.char_source_bytes.iter().find_map(|m| *m))
2074            .unwrap_or(0);
2075        let viewport_end = source_lines
2076            .last()
2077            .and_then(|l| l.char_source_bytes.iter().rev().find_map(|m| *m))
2078            .map(|b| b + 1)
2079            .unwrap_or(viewport_start);
2080
2081        // Query virtual lines in viewport range
2082        let virtual_lines = state.virtual_texts.query_lines_in_range(
2083            &state.marker_list,
2084            viewport_start,
2085            viewport_end,
2086        );
2087
2088        // If no virtual lines, return source lines unchanged
2089        if virtual_lines.is_empty() {
2090            return source_lines;
2091        }
2092
2093        // Build result with virtual lines injected
2094        let mut result = Vec::with_capacity(source_lines.len() + virtual_lines.len());
2095
2096        for source_line in source_lines {
2097            // Get this line's byte range
2098            let line_start_byte = source_line.char_source_bytes.iter().find_map(|m| *m);
2099            let line_end_byte = source_line
2100                .char_source_bytes
2101                .iter()
2102                .rev()
2103                .find_map(|m| *m)
2104                .map(|b| b + 1);
2105
2106            // Find LineAbove virtual texts anchored to this line
2107            if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
2108                for (anchor_pos, vtext) in &virtual_lines {
2109                    if *anchor_pos >= start
2110                        && *anchor_pos < end
2111                        && vtext.position == VirtualTextPosition::LineAbove
2112                    {
2113                        result.push(Self::create_virtual_line(&vtext.text, vtext.style));
2114                    }
2115                }
2116            }
2117
2118            // Add the source line
2119            result.push(source_line.clone());
2120
2121            // Find LineBelow virtual texts anchored to this line
2122            if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
2123                for (anchor_pos, vtext) in &virtual_lines {
2124                    if *anchor_pos >= start
2125                        && *anchor_pos < end
2126                        && vtext.position == VirtualTextPosition::LineBelow
2127                    {
2128                        result.push(Self::create_virtual_line(&vtext.text, vtext.style));
2129                    }
2130                }
2131            }
2132        }
2133
2134        result
2135    }
2136
2137    fn build_base_tokens(
2138        buffer: &mut Buffer,
2139        top_byte: usize,
2140        estimated_line_length: usize,
2141        visible_count: usize,
2142        is_binary: bool,
2143        line_ending: crate::model::buffer::LineEnding,
2144    ) -> Vec<fresh_core::api::ViewTokenWire> {
2145        use crate::model::buffer::LineEnding;
2146        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2147
2148        let mut tokens = Vec::new();
2149
2150        // For binary files, read raw bytes directly to preserve byte values
2151        // (LineIterator uses String::from_utf8_lossy which loses high bytes)
2152        if is_binary {
2153            return Self::build_base_tokens_binary(
2154                buffer,
2155                top_byte,
2156                estimated_line_length,
2157                visible_count,
2158            );
2159        }
2160
2161        let mut iter = buffer.line_iterator(top_byte, estimated_line_length);
2162        let mut lines_seen = 0usize;
2163        let max_lines = visible_count.saturating_add(4);
2164
2165        while lines_seen < max_lines {
2166            if let Some((line_start, line_content)) = iter.next_line() {
2167                let mut byte_offset = 0usize;
2168                let content_bytes = line_content.as_bytes();
2169                let mut skip_next_lf = false; // Track if we should skip \n after \r in CRLF
2170                let mut chars_this_line = 0usize; // Track chars to enforce MAX_SAFE_LINE_WIDTH
2171                for ch in line_content.chars() {
2172                    // Limit characters per line to prevent memory exhaustion from huge lines.
2173                    // Insert a Break token to force wrapping at safe intervals.
2174                    if chars_this_line >= MAX_SAFE_LINE_WIDTH {
2175                        tokens.push(ViewTokenWire {
2176                            source_offset: None,
2177                            kind: ViewTokenWireKind::Break,
2178                            style: None,
2179                        });
2180                        chars_this_line = 0;
2181                        // Count this as a new visual line for the max_lines limit
2182                        lines_seen += 1;
2183                        if lines_seen >= max_lines {
2184                            break;
2185                        }
2186                    }
2187                    chars_this_line += 1;
2188
2189                    let ch_len = ch.len_utf8();
2190                    let source_offset = Some(line_start + byte_offset);
2191
2192                    match ch {
2193                        '\r' => {
2194                            // In CRLF mode with \r\n: emit Newline at \r position, skip the \n
2195                            // This allows cursor at \r (end of line) to be visible
2196                            // In LF/Unix files, ANY \r is unusual and should be shown as <0D>
2197                            let is_crlf_file = line_ending == LineEnding::CRLF;
2198                            let next_byte = content_bytes.get(byte_offset + 1);
2199                            if is_crlf_file && next_byte == Some(&b'\n') {
2200                                // CRLF: emit Newline token at \r position for cursor visibility
2201                                tokens.push(ViewTokenWire {
2202                                    source_offset,
2203                                    kind: ViewTokenWireKind::Newline,
2204                                    style: None,
2205                                });
2206                                // Mark to skip the following \n in the char iterator
2207                                skip_next_lf = true;
2208                                byte_offset += ch_len;
2209                                continue;
2210                            }
2211                            // LF file or standalone \r - show as control character
2212                            tokens.push(ViewTokenWire {
2213                                source_offset,
2214                                kind: ViewTokenWireKind::BinaryByte(ch as u8),
2215                                style: None,
2216                            });
2217                        }
2218                        '\n' if skip_next_lf => {
2219                            // Skip \n that follows \r in CRLF mode (already emitted Newline at \r)
2220                            skip_next_lf = false;
2221                            byte_offset += ch_len;
2222                            continue;
2223                        }
2224                        '\n' => {
2225                            tokens.push(ViewTokenWire {
2226                                source_offset,
2227                                kind: ViewTokenWireKind::Newline,
2228                                style: None,
2229                            });
2230                        }
2231                        ' ' => {
2232                            tokens.push(ViewTokenWire {
2233                                source_offset,
2234                                kind: ViewTokenWireKind::Space,
2235                                style: None,
2236                            });
2237                        }
2238                        '\t' => {
2239                            // Tab is safe, emit as Text
2240                            tokens.push(ViewTokenWire {
2241                                source_offset,
2242                                kind: ViewTokenWireKind::Text(ch.to_string()),
2243                                style: None,
2244                            });
2245                        }
2246                        _ if Self::is_control_char(ch) => {
2247                            // Control character - emit as BinaryByte to render as <XX>
2248                            tokens.push(ViewTokenWire {
2249                                source_offset,
2250                                kind: ViewTokenWireKind::BinaryByte(ch as u8),
2251                                style: None,
2252                            });
2253                        }
2254                        _ => {
2255                            // Accumulate consecutive non-space/non-newline chars into Text tokens
2256                            if let Some(last) = tokens.last_mut() {
2257                                if let ViewTokenWireKind::Text(ref mut s) = last.kind {
2258                                    // Extend existing Text token if contiguous
2259                                    let expected_offset = last.source_offset.map(|o| o + s.len());
2260                                    if expected_offset == Some(line_start + byte_offset) {
2261                                        s.push(ch);
2262                                        byte_offset += ch_len;
2263                                        continue;
2264                                    }
2265                                }
2266                            }
2267                            tokens.push(ViewTokenWire {
2268                                source_offset,
2269                                kind: ViewTokenWireKind::Text(ch.to_string()),
2270                                style: None,
2271                            });
2272                        }
2273                    }
2274                    byte_offset += ch_len;
2275                }
2276                lines_seen += 1;
2277            } else {
2278                break;
2279            }
2280        }
2281
2282        // Handle empty buffer
2283        if tokens.is_empty() {
2284            tokens.push(ViewTokenWire {
2285                source_offset: Some(top_byte),
2286                kind: ViewTokenWireKind::Text(String::new()),
2287                style: None,
2288            });
2289        }
2290
2291        tokens
2292    }
2293
2294    /// Build tokens for binary files by reading raw bytes directly
2295    /// This preserves byte values >= 0x80 that would be lost by String::from_utf8_lossy
2296    fn build_base_tokens_binary(
2297        buffer: &mut Buffer,
2298        top_byte: usize,
2299        estimated_line_length: usize,
2300        visible_count: usize,
2301    ) -> Vec<fresh_core::api::ViewTokenWire> {
2302        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2303
2304        let mut tokens = Vec::new();
2305        let max_lines = visible_count.saturating_add(4);
2306        let buffer_len = buffer.len();
2307
2308        if top_byte >= buffer_len {
2309            tokens.push(ViewTokenWire {
2310                source_offset: Some(top_byte),
2311                kind: ViewTokenWireKind::Text(String::new()),
2312                style: None,
2313            });
2314            return tokens;
2315        }
2316
2317        // Estimate how many bytes we need to read
2318        let estimated_bytes = estimated_line_length * max_lines * 2;
2319        let bytes_to_read = estimated_bytes.min(buffer_len - top_byte);
2320
2321        // Read raw bytes directly from buffer
2322        let raw_bytes = buffer.slice_bytes(top_byte..top_byte + bytes_to_read);
2323
2324        let mut byte_offset = 0usize;
2325        let mut lines_seen = 0usize;
2326        let mut current_text = String::new();
2327        let mut current_text_start: Option<usize> = None;
2328
2329        // Helper to flush accumulated text to tokens
2330        let flush_text =
2331            |tokens: &mut Vec<ViewTokenWire>, text: &mut String, start: &mut Option<usize>| {
2332                if !text.is_empty() {
2333                    tokens.push(ViewTokenWire {
2334                        source_offset: *start,
2335                        kind: ViewTokenWireKind::Text(std::mem::take(text)),
2336                        style: None,
2337                    });
2338                    *start = None;
2339                }
2340            };
2341
2342        while byte_offset < raw_bytes.len() && lines_seen < max_lines {
2343            let b = raw_bytes[byte_offset];
2344            let source_offset = top_byte + byte_offset;
2345
2346            match b {
2347                b'\n' => {
2348                    flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2349                    tokens.push(ViewTokenWire {
2350                        source_offset: Some(source_offset),
2351                        kind: ViewTokenWireKind::Newline,
2352                        style: None,
2353                    });
2354                    lines_seen += 1;
2355                }
2356                b' ' => {
2357                    flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2358                    tokens.push(ViewTokenWire {
2359                        source_offset: Some(source_offset),
2360                        kind: ViewTokenWireKind::Space,
2361                        style: None,
2362                    });
2363                }
2364                _ => {
2365                    // For binary files, emit unprintable bytes as BinaryByte tokens
2366                    // This ensures view_pipeline.rs can map all 4 chars of <XX> to the same source byte
2367                    if Self::is_binary_unprintable(b) {
2368                        // Flush any accumulated printable text first
2369                        flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2370                        // Emit as BinaryByte so cursor positioning works correctly
2371                        tokens.push(ViewTokenWire {
2372                            source_offset: Some(source_offset),
2373                            kind: ViewTokenWireKind::BinaryByte(b),
2374                            style: None,
2375                        });
2376                    } else {
2377                        // Printable ASCII - accumulate into text token
2378                        // Each printable char is 1 byte so accumulation works correctly
2379                        if current_text_start.is_none() {
2380                            current_text_start = Some(source_offset);
2381                        }
2382                        current_text.push(b as char);
2383                    }
2384                }
2385            }
2386            byte_offset += 1;
2387        }
2388
2389        // Flush any remaining text
2390        flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2391
2392        // Handle empty buffer
2393        if tokens.is_empty() {
2394            tokens.push(ViewTokenWire {
2395                source_offset: Some(top_byte),
2396                kind: ViewTokenWireKind::Text(String::new()),
2397                style: None,
2398            });
2399        }
2400
2401        tokens
2402    }
2403
2404    /// Check if a byte should be displayed as <XX> in binary mode
2405    /// Returns true for:
2406    /// - Control characters (0x00-0x1F) except tab and newline
2407    /// - DEL (0x7F)
2408    /// - High bytes (0x80-0xFF) which are not valid single-byte UTF-8
2409    ///
2410    /// Note: In binary mode, we must be very strict about what characters we allow through,
2411    /// because control characters can move the terminal cursor and corrupt the display:
2412    /// - CR (0x0D) moves cursor to column 0, overwriting the gutter
2413    /// - VT (0x0B) and FF (0x0C) move cursor vertically
2414    /// - ESC (0x1B) starts ANSI escape sequences
2415    fn is_binary_unprintable(b: u8) -> bool {
2416        // Only allow: tab (0x09) and newline (0x0A)
2417        // These are the only safe whitespace characters in binary mode
2418        // All other control characters can corrupt terminal output
2419        if b == 0x09 || b == 0x0A {
2420            return false;
2421        }
2422        // All other control characters (0x00-0x1F) are unprintable in binary mode
2423        // This includes CR, VT, FF, ESC which can move the cursor
2424        if b < 0x20 {
2425            return true;
2426        }
2427        // DEL character (0x7F) is unprintable
2428        if b == 0x7F {
2429            return true;
2430        }
2431        // High bytes (0x80-0xFF) are unprintable in binary mode
2432        // (they're not valid single-byte UTF-8 and would be converted to replacement char)
2433        if b >= 0x80 {
2434            return true;
2435        }
2436        false
2437    }
2438
2439    /// Check if a character is a control character that should be rendered as <XX>
2440    /// This applies to ALL files (binary and non-binary) to prevent terminal corruption
2441    fn is_control_char(ch: char) -> bool {
2442        let code = ch as u32;
2443        // Only check ASCII range
2444        if code >= 128 {
2445            return false;
2446        }
2447        let b = code as u8;
2448        // Allow: tab (0x09), newline (0x0A), ESC (0x1B - for ANSI sequences)
2449        if b == 0x09 || b == 0x0A || b == 0x1B {
2450            return false;
2451        }
2452        // Other control characters (0x00-0x1F) and DEL (0x7F) are dangerous
2453        // This includes CR (0x0D), VT (0x0B), FF (0x0C) which move the cursor
2454        b < 0x20 || b == 0x7F
2455    }
2456
2457    /// Public wrapper for building base tokens - used by render.rs for the view_transform_request hook
2458    pub fn build_base_tokens_for_hook(
2459        buffer: &mut Buffer,
2460        top_byte: usize,
2461        estimated_line_length: usize,
2462        visible_count: usize,
2463        is_binary: bool,
2464        line_ending: crate::model::buffer::LineEnding,
2465    ) -> Vec<fresh_core::api::ViewTokenWire> {
2466        Self::build_base_tokens(
2467            buffer,
2468            top_byte,
2469            estimated_line_length,
2470            visible_count,
2471            is_binary,
2472            line_ending,
2473        )
2474    }
2475
2476    fn apply_wrapping_transform(
2477        tokens: Vec<fresh_core::api::ViewTokenWire>,
2478        content_width: usize,
2479        gutter_width: usize,
2480    ) -> Vec<fresh_core::api::ViewTokenWire> {
2481        use crate::primitives::visual_layout::visual_width;
2482        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2483
2484        let mut wrapped = Vec::new();
2485        let mut current_line_width = 0;
2486
2487        // Calculate available width (accounting for gutter on first line only)
2488        let available_width = content_width.saturating_sub(gutter_width);
2489
2490        for token in tokens {
2491            match &token.kind {
2492                ViewTokenWireKind::Newline => {
2493                    // Real newlines always break the line
2494                    wrapped.push(token);
2495                    current_line_width = 0;
2496                }
2497                ViewTokenWireKind::Text(text) => {
2498                    // Use visual_width which properly handles tabs and ANSI codes
2499                    let text_visual_width = visual_width(text, current_line_width);
2500
2501                    // If this token would exceed line width, insert Break before it
2502                    if current_line_width > 0
2503                        && current_line_width + text_visual_width > available_width
2504                    {
2505                        wrapped.push(ViewTokenWire {
2506                            source_offset: None,
2507                            kind: ViewTokenWireKind::Break,
2508                            style: None,
2509                        });
2510                        current_line_width = 0;
2511                    }
2512
2513                    // Recalculate visual width after potential line break (tabs depend on column)
2514                    let text_visual_width = visual_width(text, current_line_width);
2515
2516                    // If visible text is longer than line width, we need to split
2517                    // However, we don't split tokens containing ANSI codes to avoid
2518                    // breaking escape sequences. ANSI-heavy content may exceed line width.
2519                    if text_visual_width > available_width
2520                        && !crate::primitives::ansi::contains_ansi_codes(text)
2521                    {
2522                        use unicode_segmentation::UnicodeSegmentation;
2523
2524                        // Collect graphemes with their byte offsets for proper Unicode handling
2525                        let graphemes: Vec<(usize, &str)> = text.grapheme_indices(true).collect();
2526                        let mut grapheme_idx = 0;
2527                        let source_base = token.source_offset;
2528
2529                        while grapheme_idx < graphemes.len() {
2530                            // Calculate how many graphemes fit in remaining space
2531                            // by summing visual widths until we exceed available width
2532                            let remaining_width =
2533                                available_width.saturating_sub(current_line_width);
2534                            if remaining_width == 0 {
2535                                // Need to break to next line
2536                                wrapped.push(ViewTokenWire {
2537                                    source_offset: None,
2538                                    kind: ViewTokenWireKind::Break,
2539                                    style: None,
2540                                });
2541                                current_line_width = 0;
2542                                continue;
2543                            }
2544
2545                            let mut chunk_visual_width = 0;
2546                            let mut chunk_grapheme_count = 0;
2547                            let mut col = current_line_width;
2548
2549                            for &(_byte_offset, grapheme) in &graphemes[grapheme_idx..] {
2550                                let g_width = if grapheme == "\t" {
2551                                    crate::primitives::visual_layout::tab_expansion_width(col)
2552                                } else {
2553                                    crate::primitives::display_width::str_width(grapheme)
2554                                };
2555
2556                                if chunk_visual_width + g_width > remaining_width
2557                                    && chunk_grapheme_count > 0
2558                                {
2559                                    break;
2560                                }
2561
2562                                chunk_visual_width += g_width;
2563                                chunk_grapheme_count += 1;
2564                                col += g_width;
2565                            }
2566
2567                            if chunk_grapheme_count == 0 {
2568                                // Single grapheme is wider than available width, force it
2569                                chunk_grapheme_count = 1;
2570                                let grapheme = graphemes[grapheme_idx].1;
2571                                chunk_visual_width = if grapheme == "\t" {
2572                                    crate::primitives::visual_layout::tab_expansion_width(
2573                                        current_line_width,
2574                                    )
2575                                } else {
2576                                    crate::primitives::display_width::str_width(grapheme)
2577                                };
2578                            }
2579
2580                            // Build chunk from graphemes and calculate source offset
2581                            let chunk_start_byte = graphemes[grapheme_idx].0;
2582                            let chunk_end_byte =
2583                                if grapheme_idx + chunk_grapheme_count < graphemes.len() {
2584                                    graphemes[grapheme_idx + chunk_grapheme_count].0
2585                                } else {
2586                                    text.len()
2587                                };
2588                            let chunk = text[chunk_start_byte..chunk_end_byte].to_string();
2589                            let chunk_source = source_base.map(|b| b + chunk_start_byte);
2590
2591                            wrapped.push(ViewTokenWire {
2592                                source_offset: chunk_source,
2593                                kind: ViewTokenWireKind::Text(chunk),
2594                                style: token.style.clone(),
2595                            });
2596
2597                            current_line_width += chunk_visual_width;
2598                            grapheme_idx += chunk_grapheme_count;
2599
2600                            // If we filled the line, break
2601                            if current_line_width >= available_width {
2602                                wrapped.push(ViewTokenWire {
2603                                    source_offset: None,
2604                                    kind: ViewTokenWireKind::Break,
2605                                    style: None,
2606                                });
2607                                current_line_width = 0;
2608                            }
2609                        }
2610                    } else {
2611                        wrapped.push(token);
2612                        current_line_width += text_visual_width;
2613                    }
2614                }
2615                ViewTokenWireKind::Space => {
2616                    // Spaces count toward line width
2617                    if current_line_width + 1 > available_width {
2618                        wrapped.push(ViewTokenWire {
2619                            source_offset: None,
2620                            kind: ViewTokenWireKind::Break,
2621                            style: None,
2622                        });
2623                        current_line_width = 0;
2624                    }
2625                    wrapped.push(token);
2626                    current_line_width += 1;
2627                }
2628                ViewTokenWireKind::Break => {
2629                    // Pass through existing breaks
2630                    wrapped.push(token);
2631                    current_line_width = 0;
2632                }
2633                ViewTokenWireKind::BinaryByte(_) => {
2634                    // Binary bytes render as <XX> which is 4 characters
2635                    let byte_display_width = 4;
2636                    if current_line_width + byte_display_width > available_width {
2637                        wrapped.push(ViewTokenWire {
2638                            source_offset: None,
2639                            kind: ViewTokenWireKind::Break,
2640                            style: None,
2641                        });
2642                        current_line_width = 0;
2643                    }
2644                    wrapped.push(token);
2645                    current_line_width += byte_display_width;
2646                }
2647            }
2648        }
2649
2650        wrapped
2651    }
2652
2653    fn calculate_view_anchor(view_lines: &[ViewLine], top_byte: usize) -> ViewAnchor {
2654        // Find the first line that contains source content at or after top_byte
2655        // Walk backwards to include any injected content (headers) that precede it
2656        for (idx, line) in view_lines.iter().enumerate() {
2657            // Check if this line has source content at or after top_byte
2658            if let Some(first_source) = line.char_source_bytes.iter().find_map(|m| *m) {
2659                if first_source >= top_byte {
2660                    // Found a line with source >= top_byte
2661                    // But we may need to include previous lines if they're injected headers
2662                    let mut start_idx = idx;
2663                    while start_idx > 0 {
2664                        let prev_line = &view_lines[start_idx - 1];
2665                        // If previous line is all injected (no source mappings), include it
2666                        let prev_has_source =
2667                            prev_line.char_source_bytes.iter().any(|m| m.is_some());
2668                        if !prev_has_source {
2669                            start_idx -= 1;
2670                        } else {
2671                            break;
2672                        }
2673                    }
2674                    return ViewAnchor {
2675                        start_line_idx: start_idx,
2676                        start_line_skip: 0,
2677                    };
2678                }
2679            }
2680        }
2681
2682        // No matching source found, start from beginning
2683        ViewAnchor {
2684            start_line_idx: 0,
2685            start_line_skip: 0,
2686        }
2687    }
2688
2689    fn calculate_compose_layout(
2690        area: Rect,
2691        view_mode: &ViewMode,
2692        compose_width: Option<u16>,
2693    ) -> ComposeLayout {
2694        // Enable centering/margins if:
2695        // 1. View mode is explicitly Compose, OR
2696        // 2. compose_width is set (plugin-driven compose mode)
2697        let should_compose = view_mode == &ViewMode::Compose || compose_width.is_some();
2698
2699        if !should_compose {
2700            return ComposeLayout {
2701                render_area: area,
2702                left_pad: 0,
2703                right_pad: 0,
2704            };
2705        }
2706
2707        let target_width = compose_width.unwrap_or(area.width);
2708        let clamped_width = target_width.min(area.width).max(1);
2709        if clamped_width >= area.width {
2710            return ComposeLayout {
2711                render_area: area,
2712                left_pad: 0,
2713                right_pad: 0,
2714            };
2715        }
2716
2717        let pad_total = area.width - clamped_width;
2718        let left_pad = pad_total / 2;
2719        let right_pad = pad_total - left_pad;
2720
2721        ComposeLayout {
2722            render_area: Rect::new(area.x + left_pad, area.y, clamped_width, area.height),
2723            left_pad,
2724            right_pad,
2725        }
2726    }
2727
2728    fn render_compose_margins(
2729        frame: &mut Frame,
2730        area: Rect,
2731        layout: &ComposeLayout,
2732        _view_mode: &ViewMode,
2733        theme: &crate::view::theme::Theme,
2734        effective_editor_bg: ratatui::style::Color,
2735    ) {
2736        // Render margins if there are any pads (indicates compose layout is active)
2737        if layout.left_pad == 0 && layout.right_pad == 0 {
2738            return;
2739        }
2740
2741        // Paper-on-desk effect: outer "desk" margin with inner "paper edge"
2742        // Layout: [desk][paper edge][content][paper edge][desk]
2743        const PAPER_EDGE_WIDTH: u16 = 1;
2744
2745        let desk_style = Style::default().bg(theme.compose_margin_bg);
2746        let paper_style = Style::default().bg(effective_editor_bg);
2747
2748        if layout.left_pad > 0 {
2749            let paper_edge = PAPER_EDGE_WIDTH.min(layout.left_pad);
2750            let desk_width = layout.left_pad.saturating_sub(paper_edge);
2751
2752            // Desk area (outer)
2753            if desk_width > 0 {
2754                let desk_rect = Rect::new(area.x, area.y, desk_width, area.height);
2755                frame.render_widget(Block::default().style(desk_style), desk_rect);
2756            }
2757
2758            // Paper edge (inner, adjacent to content)
2759            if paper_edge > 0 {
2760                let paper_rect = Rect::new(area.x + desk_width, area.y, paper_edge, area.height);
2761                frame.render_widget(Block::default().style(paper_style), paper_rect);
2762            }
2763        }
2764
2765        if layout.right_pad > 0 {
2766            let paper_edge = PAPER_EDGE_WIDTH.min(layout.right_pad);
2767            let desk_width = layout.right_pad.saturating_sub(paper_edge);
2768            let right_start = area.x + layout.left_pad + layout.render_area.width;
2769
2770            // Paper edge (inner, adjacent to content)
2771            if paper_edge > 0 {
2772                let paper_rect = Rect::new(right_start, area.y, paper_edge, area.height);
2773                frame.render_widget(Block::default().style(paper_style), paper_rect);
2774            }
2775
2776            // Desk area (outer)
2777            if desk_width > 0 {
2778                let desk_rect =
2779                    Rect::new(right_start + paper_edge, area.y, desk_width, area.height);
2780                frame.render_widget(Block::default().style(desk_style), desk_rect);
2781            }
2782        }
2783    }
2784
2785    fn selection_context(state: &EditorState) -> SelectionContext {
2786        let ranges: Vec<Range<usize>> = state
2787            .cursors
2788            .iter()
2789            .filter_map(|(_, cursor)| cursor.selection_range())
2790            .collect();
2791
2792        let block_rects: Vec<(usize, usize, usize, usize)> = state
2793            .cursors
2794            .iter()
2795            .filter_map(|(_, cursor)| {
2796                if cursor.selection_mode == SelectionMode::Block {
2797                    if let Some(anchor) = cursor.block_anchor {
2798                        // Convert cursor position to 2D coords
2799                        let cur_line = state.buffer.get_line_number(cursor.position);
2800                        let cur_line_start = state.buffer.line_start_offset(cur_line).unwrap_or(0);
2801                        let cur_col = cursor.position.saturating_sub(cur_line_start);
2802
2803                        // Return normalized rectangle (min values first)
2804                        Some((
2805                            anchor.line.min(cur_line),
2806                            anchor.column.min(cur_col),
2807                            anchor.line.max(cur_line),
2808                            anchor.column.max(cur_col),
2809                        ))
2810                    } else {
2811                        None
2812                    }
2813                } else {
2814                    None
2815                }
2816            })
2817            .collect();
2818
2819        let cursor_positions: Vec<usize> = if state.show_cursors {
2820            state
2821                .cursors
2822                .iter()
2823                .map(|(_, cursor)| cursor.position)
2824                .collect()
2825        } else {
2826            Vec::new()
2827        };
2828
2829        SelectionContext {
2830            ranges,
2831            block_rects,
2832            cursor_positions,
2833            primary_cursor_position: state.cursors.primary().position,
2834        }
2835    }
2836
2837    fn decoration_context(
2838        state: &mut EditorState,
2839        viewport_start: usize,
2840        viewport_end: usize,
2841        primary_cursor_position: usize,
2842        theme: &crate::view::theme::Theme,
2843        highlight_context_bytes: usize,
2844    ) -> DecorationContext {
2845        // Extend highlighting range by ~1 viewport size before/after for better context.
2846        // This helps tree-sitter parse multi-line constructs that span viewport boundaries.
2847        let viewport_size = viewport_end.saturating_sub(viewport_start);
2848        let highlight_start = viewport_start.saturating_sub(viewport_size);
2849        let highlight_end = viewport_end
2850            .saturating_add(viewport_size)
2851            .min(state.buffer.len());
2852
2853        let highlight_spans = state.highlighter.highlight_viewport(
2854            &state.buffer,
2855            highlight_start,
2856            highlight_end,
2857            theme,
2858            highlight_context_bytes,
2859        );
2860
2861        // Update reference highlight overlays (debounced, creates overlays that auto-adjust)
2862        state.reference_highlight_overlay.update(
2863            &state.buffer,
2864            &mut state.overlays,
2865            &mut state.marker_list,
2866            &mut state.reference_highlighter,
2867            primary_cursor_position,
2868            viewport_start,
2869            viewport_end,
2870            highlight_context_bytes,
2871            theme.semantic_highlight_bg,
2872        );
2873
2874        // Semantic tokens are stored as overlays so their ranges track edits.
2875        // Convert them into highlight spans for the render pipeline.
2876        let mut semantic_token_spans = Vec::new();
2877        let mut viewport_overlays = Vec::new();
2878        for (overlay, range) in
2879            state
2880                .overlays
2881                .query_viewport(viewport_start, viewport_end, &state.marker_list)
2882        {
2883            if crate::services::lsp::semantic_tokens::is_semantic_token_overlay(overlay) {
2884                if let crate::view::overlay::OverlayFace::Foreground { color } = &overlay.face {
2885                    semantic_token_spans.push(crate::primitives::highlighter::HighlightSpan {
2886                        range,
2887                        color: *color,
2888                    });
2889                }
2890                continue;
2891            }
2892
2893            viewport_overlays.push((overlay.clone(), range));
2894        }
2895
2896        // Use the lsp-diagnostic namespace to identify diagnostic overlays
2897        let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
2898        let diagnostic_lines: HashSet<usize> = viewport_overlays
2899            .iter()
2900            .filter_map(|(overlay, range)| {
2901                if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
2902                    return Some(state.buffer.get_line_number(range.start));
2903                }
2904                None
2905            })
2906            .collect();
2907
2908        let virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>> =
2909            state
2910                .virtual_texts
2911                .build_lookup(&state.marker_list, viewport_start, viewport_end)
2912                .into_iter()
2913                .map(|(position, texts)| (position, texts.into_iter().cloned().collect()))
2914                .collect();
2915
2916        // Pre-compute line indicators for the viewport (only query markers in visible range)
2917        let line_indicators = state.margins.get_indicators_for_viewport(
2918            viewport_start,
2919            viewport_end,
2920            |byte_offset| state.buffer.get_line_number(byte_offset),
2921        );
2922
2923        DecorationContext {
2924            highlight_spans,
2925            semantic_token_spans,
2926            viewport_overlays,
2927            virtual_text_lookup,
2928            diagnostic_lines,
2929            line_indicators,
2930        }
2931    }
2932
2933    // semantic token colors are mapped when overlays are created
2934
2935    fn calculate_viewport_end(
2936        state: &mut EditorState,
2937        viewport_start: usize,
2938        estimated_line_length: usize,
2939        visible_count: usize,
2940    ) -> usize {
2941        let mut iter_temp = state
2942            .buffer
2943            .line_iterator(viewport_start, estimated_line_length);
2944        let mut viewport_end = viewport_start;
2945        for _ in 0..visible_count {
2946            if let Some((line_start, line_content)) = iter_temp.next_line() {
2947                viewport_end = line_start + line_content.len();
2948            } else {
2949                break;
2950            }
2951        }
2952        viewport_end
2953    }
2954
2955    fn render_view_lines(input: LineRenderInput<'_>) -> LineRenderOutput {
2956        let LineRenderInput {
2957            state,
2958            theme,
2959            view_lines,
2960            view_anchor,
2961            render_area,
2962            gutter_width,
2963            selection,
2964            decorations,
2965            starting_line_num,
2966            visible_line_count,
2967            lsp_waiting,
2968            is_active,
2969            line_wrap,
2970            estimated_lines,
2971            left_column,
2972            relative_line_numbers,
2973        } = input;
2974
2975        let selection_ranges = &selection.ranges;
2976        let block_selections = &selection.block_rects;
2977        let cursor_positions = &selection.cursor_positions;
2978        let primary_cursor_position = selection.primary_cursor_position;
2979
2980        // Compute cursor line number for relative line numbers display
2981        let cursor_line = state.buffer.get_line_number(primary_cursor_position);
2982
2983        let highlight_spans = &decorations.highlight_spans;
2984        let semantic_token_spans = &decorations.semantic_token_spans;
2985        let viewport_overlays = &decorations.viewport_overlays;
2986        let virtual_text_lookup = &decorations.virtual_text_lookup;
2987        let diagnostic_lines = &decorations.diagnostic_lines;
2988        let line_indicators = &decorations.line_indicators;
2989
2990        let mut lines = Vec::new();
2991        let mut view_line_mappings = Vec::new();
2992        let mut lines_rendered = 0usize;
2993        let mut view_iter_idx = view_anchor.start_line_idx;
2994        let mut cursor_screen_x = 0u16;
2995        let mut cursor_screen_y = 0u16;
2996        let mut have_cursor = false;
2997        let mut last_line_end: Option<LastLineEnd> = None;
2998
2999        let is_empty_buffer = state.buffer.is_empty();
3000
3001        // Track cursor position during rendering (eliminates duplicate line iteration)
3002        let mut last_visible_x: u16 = 0;
3003        let _view_start_line_skip = view_anchor.start_line_skip; // Currently unused
3004
3005        // Track the current source line number separately from display lines
3006        let mut current_source_line_num = starting_line_num;
3007        // Track whether the previous line was a source line (showed a line number)
3008        // Used to determine when to increment the line counter
3009        let mut prev_was_source_line = false;
3010
3011        loop {
3012            // Get the current ViewLine from the pipeline
3013            let current_view_line = if let Some(vl) = view_lines.get(view_iter_idx) {
3014                vl
3015            } else if is_empty_buffer && lines_rendered == 0 {
3016                // Handle empty buffer case - create a minimal line
3017                static EMPTY_LINE: std::sync::OnceLock<ViewLine> = std::sync::OnceLock::new();
3018                EMPTY_LINE.get_or_init(|| ViewLine {
3019                    text: String::new(),
3020                    char_source_bytes: Vec::new(),
3021                    char_styles: Vec::new(),
3022                    char_visual_cols: Vec::new(),
3023                    visual_to_char: Vec::new(),
3024                    tab_starts: HashSet::new(),
3025                    line_start: LineStart::Beginning,
3026                    ends_with_newline: false,
3027                })
3028            } else {
3029                break;
3030            };
3031
3032            // Extract line data
3033            let line_content = current_view_line.text.clone();
3034            let line_has_newline = current_view_line.ends_with_newline;
3035            let line_char_source_bytes = &current_view_line.char_source_bytes;
3036            let line_char_styles = &current_view_line.char_styles;
3037            let line_visual_to_char = &current_view_line.visual_to_char;
3038            let line_tab_starts = &current_view_line.tab_starts;
3039            let _line_start_type = current_view_line.line_start; // Available for future use
3040
3041            // Helper to get source byte at a visual column using the new O(1) lookup
3042            let _source_byte_at_col = |vis_col: usize| -> Option<usize> {
3043                let char_idx = line_visual_to_char.get(vis_col).copied()?;
3044                line_char_source_bytes.get(char_idx).copied().flatten()
3045            };
3046
3047            view_iter_idx += 1;
3048
3049            if lines_rendered >= visible_line_count {
3050                break;
3051            }
3052
3053            // Use the elegant pipeline's should_show_line_number function
3054            // This correctly handles: injected content, wrapped continuations, and source lines
3055            let show_line_number = should_show_line_number(current_view_line);
3056
3057            // Only increment source line number when BOTH:
3058            // 1. We've already rendered at least one source line (prev_was_source_line)
3059            // 2. The CURRENT line is also a source line
3060            // This ensures virtual/injected lines don't cause line numbers to skip
3061            if show_line_number && prev_was_source_line {
3062                current_source_line_num += 1;
3063            }
3064            // Only update the flag when we see a source line - virtual lines
3065            // between source lines shouldn't reset the tracking
3066            if show_line_number {
3067                prev_was_source_line = true;
3068            }
3069
3070            // is_continuation means "don't show line number" for rendering purposes
3071            let is_continuation = !show_line_number;
3072
3073            lines_rendered += 1;
3074
3075            // Apply horizontal scrolling - skip characters before left_column
3076            let left_col = left_column;
3077
3078            // Build line with selection highlighting
3079            let mut line_spans = Vec::new();
3080            let mut line_view_map: Vec<Option<usize>> = Vec::new();
3081            let mut last_seg_y: Option<u16> = None;
3082            let mut _last_seg_width: usize = 0;
3083
3084            // Accumulator for merging consecutive characters with the same style
3085            // This is critical for proper rendering of combining characters (Thai, etc.)
3086            let mut span_acc = SpanAccumulator::new();
3087
3088            // Render left margin (indicators + line numbers + separator)
3089            render_left_margin(
3090                &LeftMarginContext {
3091                    state,
3092                    theme,
3093                    is_continuation,
3094                    current_source_line_num,
3095                    estimated_lines,
3096                    diagnostic_lines,
3097                    line_indicators,
3098                    cursor_line,
3099                    relative_line_numbers,
3100                },
3101                &mut line_spans,
3102                &mut line_view_map,
3103            );
3104
3105            // Check if this line has any selected text
3106            let mut byte_index = 0; // Byte offset in line_content string
3107            let mut display_char_idx = 0usize; // Character index in text (for char_source_bytes)
3108            let mut col_offset = 0usize; // Visual column position
3109
3110            // Performance optimization: For very long lines, only process visible characters
3111            // Calculate the maximum characters we might need to render based on screen width
3112            // For wrapped lines, we need enough characters to fill the visible viewport
3113            // For non-wrapped lines, we only need one screen width worth
3114            let visible_lines_remaining = visible_line_count.saturating_sub(lines_rendered);
3115            let max_visible_chars = if line_wrap {
3116                // With wrapping: might need chars for multiple wrapped lines
3117                // Be generous to avoid cutting off wrapped content
3118                (render_area.width as usize)
3119                    .saturating_mul(visible_lines_remaining.max(1))
3120                    .saturating_add(200)
3121            } else {
3122                // Without wrapping: only need one line worth of characters
3123                (render_area.width as usize).saturating_add(100)
3124            };
3125            let max_chars_to_process = left_col.saturating_add(max_visible_chars);
3126
3127            // ANSI parser for this line to handle escape sequences
3128            // Optimization: only create parser if line contains ESC byte
3129            let line_has_ansi = line_content.contains('\x1b');
3130            let mut ansi_parser = if line_has_ansi {
3131                Some(AnsiParser::new())
3132            } else {
3133                None
3134            };
3135            // Track visible characters separately from byte position for ANSI handling
3136            let mut visible_char_count = 0usize;
3137
3138            // Debug mode: track active highlight/overlay spans for WordPerfect-style reveal codes
3139            let mut debug_tracker = if state.debug_highlight_mode {
3140                Some(DebugSpanTracker::default())
3141            } else {
3142                None
3143            };
3144
3145            // Track byte positions for extend_to_line_end feature
3146            let mut first_line_byte_pos: Option<usize> = None;
3147            let mut last_line_byte_pos: Option<usize> = None;
3148
3149            let chars_iterator = line_content.chars().peekable();
3150            for ch in chars_iterator {
3151                // Get source byte for this character using character index
3152                // (char_source_bytes is indexed by character position, not visual column)
3153                let byte_pos = line_char_source_bytes
3154                    .get(display_char_idx)
3155                    .copied()
3156                    .flatten();
3157
3158                // Track byte positions for extend_to_line_end
3159                if let Some(bp) = byte_pos {
3160                    if first_line_byte_pos.is_none() {
3161                        first_line_byte_pos = Some(bp);
3162                    }
3163                    last_line_byte_pos = Some(bp);
3164                }
3165
3166                // Process character through ANSI parser first (if line has ANSI)
3167                // If parser returns None, the character is part of an escape sequence and should be skipped
3168                let ansi_style = if let Some(ref mut parser) = ansi_parser {
3169                    match parser.parse_char(ch) {
3170                        Some(style) => style,
3171                        None => {
3172                            // This character is part of an ANSI escape sequence, skip it
3173                            // ANSI escape chars have zero visual width, so don't increment col_offset
3174                            // IMPORTANT: If the cursor is on this ANSI byte, track it
3175                            if let Some(bp) = byte_pos {
3176                                if bp == primary_cursor_position && !have_cursor {
3177                                    // Account for horizontal scrolling by using col_offset - left_col
3178                                    cursor_screen_x = gutter_width as u16
3179                                        + col_offset.saturating_sub(left_col) as u16;
3180                                    cursor_screen_y = lines_rendered.saturating_sub(1) as u16;
3181                                    have_cursor = true;
3182                                }
3183                            }
3184                            byte_index += ch.len_utf8();
3185                            display_char_idx += 1;
3186                            // Note: col_offset not incremented - ANSI chars have 0 visual width
3187                            continue;
3188                        }
3189                    }
3190                } else {
3191                    // No ANSI in this line - use default style (fast path)
3192                    Style::default()
3193                };
3194
3195                // Performance: skip expensive style calculations for characters beyond visible range
3196                // Use visible_char_count (not byte_index) since ANSI codes don't take up visible space
3197                if visible_char_count > max_chars_to_process {
3198                    // Fast path: skip remaining characters without processing
3199                    // This is critical for performance with very long lines (e.g., 100KB single line)
3200                    break;
3201                }
3202
3203                // Skip characters before left_column
3204                if col_offset >= left_col {
3205                    // Check if this view position is the START of a tab expansion
3206                    let is_tab_start = line_tab_starts.contains(&col_offset);
3207
3208                    // Check if this character is at a cursor position
3209                    // For tab expansions: only show cursor on the FIRST space (the tab_start position)
3210                    // This prevents cursor from appearing on all 8 expanded spaces
3211                    let is_cursor = byte_pos
3212                        .map(|bp| {
3213                            if !cursor_positions.contains(&bp) || bp >= state.buffer.len() {
3214                                return false;
3215                            }
3216                            // If this byte maps to a tab character, only show cursor at tab_start
3217                            // Check if this is part of a tab expansion by looking at previous char
3218                            let prev_char_idx = display_char_idx.saturating_sub(1);
3219                            let prev_byte_pos =
3220                                line_char_source_bytes.get(prev_char_idx).copied().flatten();
3221                            // Show cursor if: this is start of line, OR previous char had different byte pos
3222                            display_char_idx == 0 || prev_byte_pos != Some(bp)
3223                        })
3224                        .unwrap_or(false);
3225
3226                    // Check if this character is in any selection range (but not at cursor position)
3227                    // Also check for block/rectangular selections
3228                    let is_in_block_selection = block_selections.iter().any(
3229                        |(start_line, start_col, end_line, end_col)| {
3230                            current_source_line_num >= *start_line
3231                                && current_source_line_num <= *end_line
3232                                && byte_index >= *start_col
3233                                && byte_index <= *end_col
3234                        },
3235                    );
3236
3237                    // For primary cursor in active split, terminal hardware cursor provides
3238                    // visual indication, so we can still show selection background.
3239                    // Only exclude secondary cursors from selection (they use REVERSED styling).
3240                    // Bug #614: Previously excluded all cursor positions, causing first char
3241                    // of selection to display with wrong background for bar/underline cursors.
3242                    let is_primary_cursor = is_cursor && byte_pos == Some(primary_cursor_position);
3243                    let exclude_from_selection = is_cursor && !(is_active && is_primary_cursor);
3244
3245                    let is_selected = !exclude_from_selection
3246                        && (byte_pos.is_some_and(|bp| {
3247                            selection_ranges.iter().any(|range| range.contains(&bp))
3248                        }) || is_in_block_selection);
3249
3250                    // Compute character style using helper function
3251                    // char_styles is indexed by character position, not visual column
3252                    let token_style = line_char_styles
3253                        .get(display_char_idx)
3254                        .and_then(|s| s.as_ref());
3255                    let CharStyleOutput {
3256                        style,
3257                        is_secondary_cursor,
3258                    } = compute_char_style(&CharStyleContext {
3259                        byte_pos,
3260                        token_style,
3261                        ansi_style,
3262                        is_cursor,
3263                        is_selected,
3264                        theme,
3265                        highlight_spans,
3266                        semantic_token_spans,
3267                        viewport_overlays,
3268                        primary_cursor_position,
3269                        is_active,
3270                    });
3271
3272                    // Determine display character (tabs already expanded in ViewLineIterator)
3273                    // Show tab indicator (→) at the start of tab expansions (if enabled for this language)
3274                    let tab_indicator: String;
3275                    let display_char: &str = if is_cursor && lsp_waiting && is_active {
3276                        "⋯"
3277                    } else if debug_tracker.is_some() && ch == '\r' {
3278                        // Debug mode: show CR explicitly
3279                        "\\r"
3280                    } else if debug_tracker.is_some() && ch == '\n' {
3281                        // Debug mode: show LF explicitly
3282                        "\\n"
3283                    } else if ch == '\n' {
3284                        ""
3285                    } else if is_tab_start && state.show_whitespace_tabs {
3286                        // Visual indicator for tab: show → at the first position
3287                        tab_indicator = "→".to_string();
3288                        &tab_indicator
3289                    } else {
3290                        tab_indicator = ch.to_string();
3291                        &tab_indicator
3292                    };
3293
3294                    if let Some(bp) = byte_pos {
3295                        if let Some(vtexts) = virtual_text_lookup.get(&bp) {
3296                            for vtext in vtexts
3297                                .iter()
3298                                .filter(|v| v.position == VirtualTextPosition::BeforeChar)
3299                            {
3300                                // Flush accumulated text before inserting virtual text
3301                                span_acc.flush(&mut line_spans, &mut line_view_map);
3302                                // Add extra space if at end of line (before newline)
3303                                let extra_space = if ch == '\n' { " " } else { "" };
3304                                let text_with_space = format!("{}{} ", extra_space, vtext.text);
3305                                push_span_with_map(
3306                                    &mut line_spans,
3307                                    &mut line_view_map,
3308                                    text_with_space,
3309                                    vtext.style,
3310                                    None,
3311                                );
3312                            }
3313                        }
3314                    }
3315
3316                    if !display_char.is_empty() {
3317                        // Debug mode: insert opening tags for spans starting at this position
3318                        if let Some(ref mut tracker) = debug_tracker {
3319                            // Flush before debug tags
3320                            span_acc.flush(&mut line_spans, &mut line_view_map);
3321                            let opening_tags = tracker.get_opening_tags(
3322                                byte_pos,
3323                                highlight_spans,
3324                                viewport_overlays,
3325                            );
3326                            for tag in opening_tags {
3327                                push_debug_tag(&mut line_spans, &mut line_view_map, tag);
3328                            }
3329                        }
3330
3331                        // Debug mode: show byte position before each character
3332                        if debug_tracker.is_some() {
3333                            if let Some(bp) = byte_pos {
3334                                push_debug_tag(
3335                                    &mut line_spans,
3336                                    &mut line_view_map,
3337                                    format!("[{}]", bp),
3338                                );
3339                            }
3340                        }
3341
3342                        // Use accumulator to merge consecutive chars with same style
3343                        // This is critical for combining characters (Thai diacritics, etc.)
3344                        for c in display_char.chars() {
3345                            span_acc.push(c, style, byte_pos, &mut line_spans, &mut line_view_map);
3346                        }
3347
3348                        // Debug mode: insert closing tags for spans ending at this position
3349                        // Check using the NEXT byte position to see if we're leaving a span
3350                        if let Some(ref mut tracker) = debug_tracker {
3351                            // Flush before debug tags
3352                            span_acc.flush(&mut line_spans, &mut line_view_map);
3353                            // Look ahead to next byte position to determine closing tags
3354                            let next_byte_pos = byte_pos.map(|bp| bp + ch.len_utf8());
3355                            let closing_tags = tracker.get_closing_tags(next_byte_pos);
3356                            for tag in closing_tags {
3357                                push_debug_tag(&mut line_spans, &mut line_view_map, tag);
3358                            }
3359                        }
3360                    }
3361
3362                    // Track cursor position for zero-width characters
3363                    // Zero-width chars don't get map entries, so we need to explicitly record cursor pos
3364                    if !have_cursor {
3365                        if let Some(bp) = byte_pos {
3366                            if bp == primary_cursor_position && char_width(ch) == 0 {
3367                                // Account for horizontal scrolling by subtracting left_col
3368                                cursor_screen_x = gutter_width as u16
3369                                    + col_offset.saturating_sub(left_col) as u16;
3370                                cursor_screen_y = lines.len() as u16;
3371                                have_cursor = true;
3372                            }
3373                        }
3374                    }
3375
3376                    if let Some(bp) = byte_pos {
3377                        if let Some(vtexts) = virtual_text_lookup.get(&bp) {
3378                            for vtext in vtexts
3379                                .iter()
3380                                .filter(|v| v.position == VirtualTextPosition::AfterChar)
3381                            {
3382                                let text_with_space = format!(" {}", vtext.text);
3383                                push_span_with_map(
3384                                    &mut line_spans,
3385                                    &mut line_view_map,
3386                                    text_with_space,
3387                                    vtext.style,
3388                                    None,
3389                                );
3390                            }
3391                        }
3392                    }
3393
3394                    if is_cursor && ch == '\n' {
3395                        let should_add_indicator =
3396                            if is_active { is_secondary_cursor } else { true };
3397                        if should_add_indicator {
3398                            // Flush accumulated text before adding cursor indicator
3399                            // so the indicator appears after the line content, not before
3400                            span_acc.flush(&mut line_spans, &mut line_view_map);
3401                            let cursor_style = if is_active {
3402                                Style::default()
3403                                    .fg(theme.editor_fg)
3404                                    .bg(theme.editor_bg)
3405                                    .add_modifier(Modifier::REVERSED)
3406                            } else {
3407                                Style::default()
3408                                    .fg(theme.editor_fg)
3409                                    .bg(theme.inactive_cursor)
3410                            };
3411                            push_span_with_map(
3412                                &mut line_spans,
3413                                &mut line_view_map,
3414                                " ".to_string(),
3415                                cursor_style,
3416                                byte_pos,
3417                            );
3418                        }
3419                    }
3420                }
3421
3422                byte_index += ch.len_utf8();
3423                display_char_idx += 1; // Increment character index for next lookup
3424                                       // col_offset tracks visual column position (for indexing into visual_to_char)
3425                                       // visual_to_char has one entry per visual column, not per character
3426                let ch_width = char_width(ch);
3427                col_offset += ch_width;
3428                visible_char_count += ch_width;
3429            }
3430
3431            // Flush any remaining accumulated text at end of line
3432            span_acc.flush(&mut line_spans, &mut line_view_map);
3433
3434            // Set last_seg_y early so cursor detection works for both empty and non-empty lines
3435            // For lines without wrapping, this will be the final y position
3436            // Also set for empty content lines (regardless of line_wrap) so cursor at EOF can be positioned
3437            let content_is_empty = line_content.is_empty();
3438            if line_spans.is_empty() || !line_wrap || content_is_empty {
3439                last_seg_y = Some(lines.len() as u16);
3440            }
3441
3442            if !line_has_newline {
3443                let line_len_chars = line_content.chars().count();
3444
3445                // Map view positions to buffer positions using per-line char_source_bytes
3446                let last_char_idx = line_len_chars.saturating_sub(1);
3447                let after_last_char_idx = line_len_chars;
3448
3449                let last_char_buf_pos =
3450                    line_char_source_bytes.get(last_char_idx).copied().flatten();
3451                let after_last_char_buf_pos = line_char_source_bytes
3452                    .get(after_last_char_idx)
3453                    .copied()
3454                    .flatten();
3455
3456                let cursor_at_end = cursor_positions.iter().any(|&pos| {
3457                    // Cursor is "at end" only if it's AFTER the last character, not ON it.
3458                    // A cursor ON the last character should render on that character (handled in main loop).
3459                    let matches_after = after_last_char_buf_pos.is_some_and(|bp| pos == bp);
3460                    // Fallback: when there's no mapping after last char (EOF), check if cursor is after last char
3461                    // The fallback should match the position that would be "after" if there was a mapping
3462                    let expected_after_pos = last_char_buf_pos.map(|p| p + 1).unwrap_or(0);
3463                    let matches_fallback =
3464                        after_last_char_buf_pos.is_none() && pos == expected_after_pos;
3465
3466                    matches_after || matches_fallback
3467                });
3468
3469                if cursor_at_end {
3470                    // Primary cursor is at end only if AFTER the last char, not ON it
3471                    let is_primary_at_end = after_last_char_buf_pos
3472                        .is_some_and(|bp| bp == primary_cursor_position)
3473                        || (after_last_char_buf_pos.is_none()
3474                            && primary_cursor_position >= state.buffer.len());
3475
3476                    // Track cursor position for primary cursor
3477                    if let Some(seg_y) = last_seg_y {
3478                        if is_primary_at_end {
3479                            // Cursor position now includes gutter width (consistent with main cursor tracking)
3480                            // For empty lines, cursor is at gutter width (right after gutter)
3481                            // For non-empty lines without newline, cursor is after the last visible character
3482                            // Account for horizontal scrolling by using col_offset - left_col
3483                            cursor_screen_x = if line_len_chars == 0 {
3484                                gutter_width as u16
3485                            } else {
3486                                // col_offset is the visual column after the last character
3487                                // Subtract left_col to get the screen position after horizontal scroll
3488                                gutter_width as u16 + col_offset.saturating_sub(left_col) as u16
3489                            };
3490                            cursor_screen_y = seg_y;
3491                            have_cursor = true;
3492                        }
3493                    }
3494
3495                    let should_add_indicator = if is_active { !is_primary_at_end } else { true };
3496                    if should_add_indicator {
3497                        let cursor_style = if is_active {
3498                            Style::default()
3499                                .fg(theme.editor_fg)
3500                                .bg(theme.editor_bg)
3501                                .add_modifier(Modifier::REVERSED)
3502                        } else {
3503                            Style::default()
3504                                .fg(theme.editor_fg)
3505                                .bg(theme.inactive_cursor)
3506                        };
3507                        push_span_with_map(
3508                            &mut line_spans,
3509                            &mut line_view_map,
3510                            " ".to_string(),
3511                            cursor_style,
3512                            None,
3513                        );
3514                    }
3515                }
3516            }
3517
3518            // ViewLines are already wrapped (Break tokens became newlines in ViewLineIterator)
3519            // so each line is one visual line - no need to wrap again
3520            let current_y = lines.len() as u16;
3521            last_seg_y = Some(current_y);
3522
3523            if !line_spans.is_empty() {
3524                // Find cursor position and track last visible x by iterating through line_view_map
3525                // Note: line_view_map includes both gutter and content character mappings
3526                for (screen_x, source_offset) in line_view_map.iter().enumerate() {
3527                    if let Some(src) = source_offset {
3528                        // Check if this is the primary cursor position
3529                        // Only set cursor on the FIRST screen position that maps to cursor byte
3530                        // (important for tabs where multiple spaces map to same byte)
3531                        if *src == primary_cursor_position && !have_cursor {
3532                            cursor_screen_x = screen_x as u16;
3533                            cursor_screen_y = current_y;
3534                            have_cursor = true;
3535                        }
3536                        // Track the last visible position (rightmost character with a source mapping)
3537                        // This is used for EOF cursor placement
3538                        last_visible_x = screen_x as u16;
3539                    }
3540                }
3541            }
3542
3543            // Fill remaining width for overlays with extend_to_line_end
3544            // Only when line wrapping is disabled (side-by-side diff typically disables wrapping)
3545            if !line_wrap {
3546                // Calculate the content area width (total width minus gutter)
3547                let content_width = render_area.width.saturating_sub(gutter_width as u16) as usize;
3548                let remaining_cols = content_width.saturating_sub(visible_char_count);
3549
3550                if remaining_cols > 0 {
3551                    // Find the highest priority background color from overlays with extend_to_line_end
3552                    // that overlap with this line's byte range
3553                    let fill_style: Option<Style> = if let (Some(start), Some(end)) =
3554                        (first_line_byte_pos, last_line_byte_pos)
3555                    {
3556                        viewport_overlays
3557                            .iter()
3558                            .filter(|(overlay, range)| {
3559                                overlay.extend_to_line_end
3560                                    && range.start <= end
3561                                    && range.end >= start
3562                            })
3563                            .max_by_key(|(o, _)| o.priority)
3564                            .and_then(|(overlay, _)| {
3565                                match &overlay.face {
3566                                    crate::view::overlay::OverlayFace::Background { color } => {
3567                                        // Set both fg and bg to ensure ANSI codes are output
3568                                        Some(Style::default().fg(*color).bg(*color))
3569                                    }
3570                                    crate::view::overlay::OverlayFace::Style { style } => {
3571                                        // Extract background from style if present
3572                                        // Set fg to same as bg for invisible text
3573                                        style.bg.map(|bg| Style::default().fg(bg).bg(bg))
3574                                    }
3575                                    _ => None,
3576                                }
3577                            })
3578                    } else {
3579                        None
3580                    };
3581
3582                    if let Some(fill_bg) = fill_style {
3583                        let fill_text = " ".repeat(remaining_cols);
3584                        push_span_with_map(
3585                            &mut line_spans,
3586                            &mut line_view_map,
3587                            fill_text,
3588                            fill_bg,
3589                            None,
3590                        );
3591                    }
3592                }
3593            }
3594
3595            // Calculate line_end_byte for this line
3596            let line_end_byte = if current_view_line.ends_with_newline {
3597                // Position ON the newline - find the last source byte (the newline's position)
3598                current_view_line
3599                    .char_source_bytes
3600                    .iter()
3601                    .rev()
3602                    .find_map(|m| *m)
3603                    .unwrap_or(0)
3604            } else {
3605                // Position AFTER the last character - find last source byte and add char length
3606                if let Some((char_idx, &Some(last_byte_start))) = current_view_line
3607                    .char_source_bytes
3608                    .iter()
3609                    .enumerate()
3610                    .rev()
3611                    .find(|(_, m)| m.is_some())
3612                {
3613                    // Get the character at this index to find its UTF-8 byte length
3614                    if let Some(last_char) = current_view_line.text.chars().nth(char_idx) {
3615                        last_byte_start + last_char.len_utf8()
3616                    } else {
3617                        last_byte_start
3618                    }
3619                } else {
3620                    0
3621                }
3622            };
3623
3624            // Capture accurate view line mapping for mouse clicks
3625            // Content mapping starts after the gutter
3626            let content_map = if line_view_map.len() >= gutter_width {
3627                line_view_map[gutter_width..].to_vec()
3628            } else {
3629                Vec::new()
3630            };
3631            view_line_mappings.push(ViewLineMapping {
3632                char_source_bytes: content_map.clone(),
3633                visual_to_char: (0..content_map.len()).collect(),
3634                line_end_byte,
3635            });
3636
3637            // Track if line was empty before moving line_spans
3638            let line_was_empty = line_spans.is_empty();
3639            lines.push(Line::from(line_spans));
3640
3641            // Update last_line_end and check for cursor on newline BEFORE the break check
3642            // This ensures the last visible line's metadata is captured
3643            if let Some(y) = last_seg_y {
3644                // end_x is the cursor position after the last visible character.
3645                // For empty lines, last_visible_x stays at 0, so we need to ensure end_x is
3646                // at least gutter_width to place the cursor after the gutter, not in it.
3647                let end_x = if line_was_empty {
3648                    gutter_width as u16
3649                } else {
3650                    last_visible_x.saturating_add(1)
3651                };
3652                let line_len_chars = line_content.chars().count();
3653
3654                last_line_end = Some(LastLineEnd {
3655                    pos: (end_x, y),
3656                    terminated_with_newline: line_has_newline,
3657                });
3658
3659                if line_has_newline && line_len_chars > 0 {
3660                    let newline_idx = line_len_chars.saturating_sub(1);
3661                    if let Some(Some(src_newline)) = line_char_source_bytes.get(newline_idx) {
3662                        if *src_newline == primary_cursor_position {
3663                            // Cursor position now includes gutter width (consistent with main cursor tracking)
3664                            // For empty lines (just newline), cursor should be at gutter width (after gutter)
3665                            // For lines with content, cursor on newline should be after the content
3666                            if line_len_chars == 1 {
3667                                // Empty line - just the newline character
3668                                cursor_screen_x = gutter_width as u16;
3669                                cursor_screen_y = y;
3670                            } else {
3671                                // Line has content before the newline - cursor after last char
3672                                // end_x already includes gutter (from last_visible_x)
3673                                cursor_screen_x = end_x;
3674                                cursor_screen_y = y;
3675                            }
3676                            have_cursor = true;
3677                        }
3678                    }
3679                }
3680            }
3681
3682            if lines_rendered >= visible_line_count {
3683                break;
3684            }
3685        }
3686
3687        // If the last line ended with a newline, render an implicit empty line after it.
3688        // This shows the line number for the cursor position after the final newline.
3689        if let Some(ref end) = last_line_end {
3690            if end.terminated_with_newline && lines_rendered < visible_line_count {
3691                // Render the implicit line after the newline
3692                let mut implicit_line_spans = Vec::new();
3693                let implicit_line_num = current_source_line_num + 1;
3694
3695                if state.margins.left_config.enabled {
3696                    // Indicator column (space)
3697                    implicit_line_spans.push(Span::styled(" ", Style::default()));
3698
3699                    // Line number
3700                    let estimated_lines = (state.buffer.len() / 80).max(1);
3701                    let margin_content = state.margins.render_line(
3702                        implicit_line_num,
3703                        crate::view::margin::MarginPosition::Left,
3704                        estimated_lines,
3705                    );
3706                    let (rendered_text, style_opt) =
3707                        margin_content.render(state.margins.left_config.width);
3708                    let margin_style =
3709                        style_opt.unwrap_or_else(|| Style::default().fg(theme.line_number_fg));
3710                    implicit_line_spans.push(Span::styled(rendered_text, margin_style));
3711
3712                    // Separator
3713                    if state.margins.left_config.show_separator {
3714                        implicit_line_spans.push(Span::styled(
3715                            state.margins.left_config.separator.to_string(),
3716                            Style::default().fg(theme.line_number_fg),
3717                        ));
3718                    }
3719                }
3720
3721                let implicit_y = lines.len() as u16;
3722                lines.push(Line::from(implicit_line_spans));
3723                lines_rendered += 1;
3724
3725                // Add mapping for implicit line
3726                // It has no content, so map is empty (gutter is handled by offset in screen_to_buffer_position)
3727                let buffer_len = state.buffer.len();
3728
3729                view_line_mappings.push(ViewLineMapping {
3730                    char_source_bytes: Vec::new(),
3731                    visual_to_char: Vec::new(),
3732                    line_end_byte: buffer_len,
3733                });
3734
3735                // NOTE: We intentionally do NOT update last_line_end here.
3736                // The implicit empty line is a visual display aid, not an actual content line.
3737                // last_line_end should track the last actual content line for cursor placement logic.
3738
3739                // If primary cursor is at EOF (after the newline), set cursor on this line
3740                if primary_cursor_position == state.buffer.len() && !have_cursor {
3741                    cursor_screen_x = gutter_width as u16;
3742                    cursor_screen_y = implicit_y;
3743                    have_cursor = true;
3744                }
3745            }
3746        }
3747
3748        // Fill remaining rows with tilde characters to indicate EOF (like vim/neovim).
3749        // This also ensures proper clearing in differential rendering because tildes
3750        // are guaranteed to differ from previous content, forcing ratatui to update.
3751        // See: https://github.com/ratatui/ratatui/issues/1606
3752        //
3753        // NOTE: We use a computed darker color instead of Modifier::DIM because the DIM
3754        // modifier can bleed through to overlays (like menus) rendered on top of these
3755        // lines due to how terminal escape sequences are output.
3756        // See: https://github.com/sinelaw/fresh/issues/458
3757        let eof_fg = dim_color_for_tilde(theme.line_number_fg);
3758        let eof_style = Style::default().fg(eof_fg);
3759        while lines.len() < render_area.height as usize {
3760            // Show tilde with dim styling, padded with spaces to fill the line
3761            let tilde_line = format!(
3762                "~{}",
3763                " ".repeat(render_area.width.saturating_sub(1) as usize)
3764            );
3765            lines.push(Line::styled(tilde_line, eof_style));
3766        }
3767
3768        LineRenderOutput {
3769            lines,
3770            cursor: have_cursor.then_some((cursor_screen_x, cursor_screen_y)),
3771            last_line_end,
3772            content_lines_rendered: lines_rendered,
3773            view_line_mappings,
3774        }
3775    }
3776
3777    fn resolve_cursor_fallback(
3778        current_cursor: Option<(u16, u16)>,
3779        primary_cursor_position: usize,
3780        buffer_len: usize,
3781        buffer_ends_with_newline: bool,
3782        last_line_end: Option<LastLineEnd>,
3783        lines_rendered: usize,
3784        gutter_width: usize,
3785    ) -> Option<(u16, u16)> {
3786        if current_cursor.is_some() || primary_cursor_position != buffer_len {
3787            return current_cursor;
3788        }
3789
3790        if buffer_ends_with_newline {
3791            if let Some(end) = last_line_end {
3792                // Cursor should appear on the implicit empty line after the newline
3793                // Include gutter width in x coordinate
3794                return Some((gutter_width as u16, end.pos.1.saturating_add(1)));
3795            }
3796            return Some((gutter_width as u16, lines_rendered as u16));
3797        }
3798
3799        last_line_end.map(|end| end.pos)
3800    }
3801
3802    /// Render a single buffer in a split pane
3803    /// Returns the view line mappings for mouse click handling
3804    #[allow(clippy::too_many_arguments)]
3805    fn render_buffer_in_split(
3806        frame: &mut Frame,
3807        state: &mut EditorState,
3808        viewport: &mut crate::view::viewport::Viewport,
3809        event_log: Option<&mut EventLog>,
3810        area: Rect,
3811        is_active: bool,
3812        theme: &crate::view::theme::Theme,
3813        ansi_background: Option<&AnsiBackground>,
3814        background_fade: f32,
3815        lsp_waiting: bool,
3816        view_mode: ViewMode,
3817        compose_width: Option<u16>,
3818        compose_column_guides: Option<Vec<u16>>,
3819        view_transform: Option<ViewTransformPayload>,
3820        estimated_line_length: usize,
3821        highlight_context_bytes: usize,
3822        _buffer_id: BufferId,
3823        hide_cursor: bool,
3824        relative_line_numbers: bool,
3825        use_terminal_bg: bool,
3826    ) -> Vec<ViewLineMapping> {
3827        let _span = tracing::trace_span!("render_buffer_in_split").entered();
3828
3829        // Compute effective editor background: terminal default or theme-defined
3830        let effective_editor_bg = if use_terminal_bg {
3831            ratatui::style::Color::Reset
3832        } else {
3833            theme.editor_bg
3834        };
3835
3836        let line_wrap = viewport.line_wrap_enabled;
3837
3838        let overlay_count = state.overlays.all().len();
3839        if overlay_count > 0 {
3840            tracing::trace!("render_content: {} overlays present", overlay_count);
3841        }
3842
3843        let visible_count = viewport.visible_line_count();
3844
3845        let buffer_len = state.buffer.len();
3846        let estimated_lines = (buffer_len / 80).max(1);
3847        state.margins.update_width_for_buffer(estimated_lines);
3848        let gutter_width = state.margins.left_total_width();
3849
3850        let compose_layout = Self::calculate_compose_layout(area, &view_mode, compose_width);
3851        let render_area = compose_layout.render_area;
3852
3853        // Clone view_transform so we can reuse it if scrolling triggers a rebuild
3854        let view_transform_for_rebuild = view_transform.clone();
3855
3856        let view_data = Self::build_view_data(
3857            state,
3858            viewport,
3859            view_transform,
3860            estimated_line_length,
3861            visible_count,
3862            line_wrap,
3863            render_area.width as usize,
3864            gutter_width,
3865        );
3866
3867        // Ensure cursor is visible using Layout-aware check (handles virtual lines)
3868        // This detects when cursor is beyond the rendered view_lines and scrolls
3869        let primary = *state.cursors.primary();
3870        let scrolled = viewport.ensure_visible_in_layout(&view_data.lines, &primary, gutter_width);
3871
3872        // If we scrolled, rebuild view_data from new position WITH the view_transform
3873        // This ensures virtual lines are included in the rebuilt view
3874        let view_data = if scrolled {
3875            Self::build_view_data(
3876                state,
3877                viewport,
3878                view_transform_for_rebuild,
3879                estimated_line_length,
3880                visible_count,
3881                line_wrap,
3882                render_area.width as usize,
3883                gutter_width,
3884            )
3885        } else {
3886            view_data
3887        };
3888
3889        let view_anchor = Self::calculate_view_anchor(&view_data.lines, viewport.top_byte);
3890        Self::render_compose_margins(
3891            frame,
3892            area,
3893            &compose_layout,
3894            &view_mode,
3895            theme,
3896            effective_editor_bg,
3897        );
3898
3899        let selection = Self::selection_context(state);
3900
3901        tracing::trace!(
3902            "Rendering buffer with {} cursors at positions: {:?}, primary at {}, is_active: {}, buffer_len: {}",
3903            selection.cursor_positions.len(),
3904            selection.cursor_positions,
3905            selection.primary_cursor_position,
3906            is_active,
3907            state.buffer.len()
3908        );
3909
3910        if !selection.cursor_positions.is_empty()
3911            && !selection
3912                .cursor_positions
3913                .contains(&selection.primary_cursor_position)
3914        {
3915            tracing::warn!(
3916                "Primary cursor position {} not found in cursor_positions list: {:?}",
3917                selection.primary_cursor_position,
3918                selection.cursor_positions
3919            );
3920        }
3921
3922        let starting_line_num = state
3923            .buffer
3924            .populate_line_cache(viewport.top_byte, visible_count);
3925
3926        let viewport_start = viewport.top_byte;
3927        let viewport_end = Self::calculate_viewport_end(
3928            state,
3929            viewport_start,
3930            estimated_line_length,
3931            visible_count,
3932        );
3933
3934        let decorations = Self::decoration_context(
3935            state,
3936            viewport_start,
3937            viewport_end,
3938            selection.primary_cursor_position,
3939            theme,
3940            highlight_context_bytes,
3941        );
3942
3943        // Use top_view_line_offset to handle scrolling through virtual lines.
3944        // The viewport code (ensure_visible_in_layout) updates this when scrolling
3945        // to keep the cursor visible, including special handling for virtual lines.
3946        //
3947        // We recalculate starting_line_num below to ensure line numbers stay in sync
3948        // even if view_data was rebuilt from a different starting position.
3949        let calculated_offset = viewport.top_view_line_offset;
3950
3951        tracing::trace!(
3952            top_byte = viewport.top_byte,
3953            top_view_line_offset = viewport.top_view_line_offset,
3954            calculated_offset,
3955            view_data_lines = view_data.lines.len(),
3956            "view line offset calculation"
3957        );
3958        let (view_lines_to_render, adjusted_starting_line_num, adjusted_view_anchor) =
3959            if calculated_offset > 0 && calculated_offset < view_data.lines.len() {
3960                let sliced = &view_data.lines[calculated_offset..];
3961
3962                // Count how many source lines were in the skipped portion
3963                // A view line is a "source line" if it shows a line number (not a continuation)
3964                let skipped_lines = &view_data.lines[..calculated_offset];
3965                let skipped_source_lines = skipped_lines
3966                    .iter()
3967                    .filter(|vl| should_show_line_number(vl))
3968                    .count();
3969
3970                let adjusted_line_num = starting_line_num + skipped_source_lines;
3971
3972                // Recalculate view_anchor on the sliced array
3973                let adjusted_anchor = Self::calculate_view_anchor(sliced, viewport.top_byte);
3974
3975                (sliced, adjusted_line_num, adjusted_anchor)
3976            } else {
3977                (&view_data.lines[..], starting_line_num, view_anchor)
3978            };
3979
3980        let render_output = Self::render_view_lines(LineRenderInput {
3981            state,
3982            theme,
3983            view_lines: view_lines_to_render,
3984            view_anchor: adjusted_view_anchor,
3985            render_area,
3986            gutter_width,
3987            selection: &selection,
3988            decorations: &decorations,
3989            starting_line_num: adjusted_starting_line_num,
3990            visible_line_count: visible_count,
3991            lsp_waiting,
3992            is_active,
3993            line_wrap,
3994            estimated_lines,
3995            left_column: viewport.left_column,
3996            relative_line_numbers,
3997        });
3998
3999        let mut lines = render_output.lines;
4000        let background_x_offset = viewport.left_column;
4001
4002        if let Some(bg) = ansi_background {
4003            Self::apply_background_to_lines(
4004                &mut lines,
4005                render_area.width,
4006                bg,
4007                effective_editor_bg,
4008                theme.editor_fg,
4009                background_fade,
4010                background_x_offset,
4011                starting_line_num,
4012            );
4013        }
4014
4015        frame.render_widget(Clear, render_area);
4016        let editor_block = Block::default()
4017            .borders(Borders::NONE)
4018            .style(Style::default().bg(effective_editor_bg));
4019        frame.render_widget(Paragraph::new(lines).block(editor_block), render_area);
4020
4021        // Render column guides if present (for tables, etc.)
4022        if let Some(guides) = compose_column_guides {
4023            let guide_style = Style::default()
4024                .fg(theme.line_number_fg)
4025                .add_modifier(Modifier::DIM);
4026            let guide_height = render_output
4027                .content_lines_rendered
4028                .min(render_area.height as usize);
4029
4030            for col in guides {
4031                // Column guides are relative to content area (after gutter)
4032                let guide_x = render_area.x + gutter_width as u16 + col;
4033
4034                // Only draw if the guide is within the visible area
4035                if guide_x >= render_area.x && guide_x < render_area.x + render_area.width {
4036                    for row in 0..guide_height {
4037                        let cell_area = Rect::new(guide_x, render_area.y + row as u16, 1, 1);
4038                        let guide_char = Paragraph::new("│").style(guide_style);
4039                        frame.render_widget(guide_char, cell_area);
4040                    }
4041                }
4042            }
4043        }
4044
4045        let buffer_ends_with_newline = if !state.buffer.is_empty() {
4046            let last_char = state.get_text_range(state.buffer.len() - 1, state.buffer.len());
4047            last_char == "\n"
4048        } else {
4049            false
4050        };
4051
4052        let cursor = Self::resolve_cursor_fallback(
4053            render_output.cursor,
4054            selection.primary_cursor_position,
4055            state.buffer.len(),
4056            buffer_ends_with_newline,
4057            render_output.last_line_end,
4058            render_output.content_lines_rendered,
4059            gutter_width,
4060        );
4061
4062        if is_active && state.show_cursors && !hide_cursor {
4063            if let Some((cursor_screen_x, cursor_screen_y)) = cursor {
4064                // cursor_screen_x already includes gutter width from line_view_map
4065                let screen_x = render_area.x.saturating_add(cursor_screen_x);
4066
4067                // Clamp cursor_screen_y to stay within the render area bounds.
4068                // This prevents the cursor from jumping to the status bar when
4069                // the cursor is at EOF and the buffer ends with a newline.
4070                // Issue #468: "Cursor is jumping on statusbar"
4071                let max_y = render_area.height.saturating_sub(1);
4072                let clamped_cursor_y = cursor_screen_y.min(max_y);
4073                let screen_y = render_area.y.saturating_add(clamped_cursor_y);
4074
4075                frame.set_cursor_position((screen_x, screen_y));
4076
4077                if let Some(event_log) = event_log {
4078                    let cursor_pos = state.cursors.primary().position;
4079                    let buffer_len = state.buffer.len();
4080                    event_log.log_render_state(cursor_pos, screen_x, screen_y, buffer_len);
4081                }
4082            }
4083        }
4084
4085        // Extract view line mappings for mouse click handling
4086        // This maps screen coordinates to buffer byte positions
4087        render_output.view_line_mappings
4088    }
4089
4090    /// Apply styles from original line_spans to a wrapped segment
4091    ///
4092    /// Maps each character in the segment text back to its original span to preserve
4093    /// syntax highlighting, selections, and other styling across wrapped lines.
4094    ///
4095    /// # Arguments
4096    /// * `segment_text` - The text content of this wrapped segment
4097    /// * `line_spans` - The original styled spans for the entire line
4098    /// * `segment_start_offset` - Character offset where this segment starts in the original line
4099    /// * `scroll_offset` - Additional offset for horizontal scrolling (non-wrap mode)
4100    #[allow(clippy::too_many_arguments)]
4101    fn apply_background_to_lines(
4102        lines: &mut Vec<Line<'static>>,
4103        area_width: u16,
4104        background: &AnsiBackground,
4105        theme_bg: Color,
4106        default_fg: Color,
4107        fade: f32,
4108        x_offset: usize,
4109        y_offset: usize,
4110    ) {
4111        if area_width == 0 {
4112            return;
4113        }
4114
4115        let width = area_width as usize;
4116
4117        for (y, line) in lines.iter_mut().enumerate() {
4118            // Flatten existing spans into per-character styles
4119            let mut existing: Vec<(char, Style)> = Vec::new();
4120            let spans = std::mem::take(&mut line.spans);
4121            for span in spans {
4122                let style = span.style;
4123                for ch in span.content.chars() {
4124                    existing.push((ch, style));
4125                }
4126            }
4127
4128            let mut chars_with_style = Vec::with_capacity(width);
4129            for x in 0..width {
4130                let sample_x = x_offset + x;
4131                let sample_y = y_offset + y;
4132
4133                let (ch, mut style) = if x < existing.len() {
4134                    existing[x]
4135                } else {
4136                    (' ', Style::default().fg(default_fg))
4137                };
4138
4139                if let Some(bg_color) = background.faded_color(sample_x, sample_y, theme_bg, fade) {
4140                    if style.bg.is_none() || matches!(style.bg, Some(Color::Reset)) {
4141                        style = style.bg(bg_color);
4142                    }
4143                }
4144
4145                chars_with_style.push((ch, style));
4146            }
4147
4148            line.spans = Self::compress_chars(chars_with_style);
4149        }
4150    }
4151
4152    fn compress_chars(chars: Vec<(char, Style)>) -> Vec<Span<'static>> {
4153        if chars.is_empty() {
4154            return vec![];
4155        }
4156
4157        let mut spans = Vec::new();
4158        let mut current_style = chars[0].1;
4159        let mut current_text = String::new();
4160        current_text.push(chars[0].0);
4161
4162        for (ch, style) in chars.into_iter().skip(1) {
4163            if style == current_style {
4164                current_text.push(ch);
4165            } else {
4166                spans.push(Span::styled(current_text.clone(), current_style));
4167                current_text.clear();
4168                current_text.push(ch);
4169                current_style = style;
4170            }
4171        }
4172
4173        spans.push(Span::styled(current_text, current_style));
4174        spans
4175    }
4176}
4177
4178#[cfg(test)]
4179mod tests {
4180    use super::*;
4181    use crate::model::buffer::Buffer;
4182    use crate::primitives::display_width::str_width;
4183    use crate::view::theme;
4184    use crate::view::theme::Theme;
4185    use crate::view::viewport::Viewport;
4186
4187    fn render_output_for(
4188        content: &str,
4189        cursor_pos: usize,
4190    ) -> (LineRenderOutput, usize, bool, usize) {
4191        render_output_for_with_gutters(content, cursor_pos, false)
4192    }
4193
4194    fn render_output_for_with_gutters(
4195        content: &str,
4196        cursor_pos: usize,
4197        gutters_enabled: bool,
4198    ) -> (LineRenderOutput, usize, bool, usize) {
4199        let mut state = EditorState::new(20, 6, 1024);
4200        state.buffer = Buffer::from_str(content, 1024);
4201        state.cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
4202        // Create a standalone viewport (no longer part of EditorState)
4203        let viewport = Viewport::new(20, 4);
4204        // Enable/disable line numbers/gutters based on parameter
4205        state.margins.left_config.enabled = gutters_enabled;
4206
4207        let render_area = Rect::new(0, 0, 20, 4);
4208        let visible_count = viewport.visible_line_count();
4209        let gutter_width = state.margins.left_total_width();
4210
4211        let view_data = SplitRenderer::build_view_data(
4212            &mut state,
4213            &viewport,
4214            None,
4215            content.len().max(1),
4216            visible_count,
4217            false, // line wrap disabled for tests
4218            render_area.width as usize,
4219            gutter_width,
4220        );
4221        let view_anchor = SplitRenderer::calculate_view_anchor(&view_data.lines, 0);
4222
4223        let estimated_lines = (state.buffer.len() / 80).max(1);
4224        state.margins.update_width_for_buffer(estimated_lines);
4225        let gutter_width = state.margins.left_total_width();
4226
4227        let selection = SplitRenderer::selection_context(&state);
4228        let starting_line_num = state
4229            .buffer
4230            .populate_line_cache(viewport.top_byte, visible_count);
4231        let viewport_start = viewport.top_byte;
4232        let viewport_end = SplitRenderer::calculate_viewport_end(
4233            &mut state,
4234            viewport_start,
4235            content.len().max(1),
4236            visible_count,
4237        );
4238        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
4239        let decorations = SplitRenderer::decoration_context(
4240            &mut state,
4241            viewport_start,
4242            viewport_end,
4243            selection.primary_cursor_position,
4244            &theme,
4245            100_000, // default highlight context bytes
4246        );
4247
4248        let output = SplitRenderer::render_view_lines(LineRenderInput {
4249            state: &state,
4250            theme: &theme,
4251            view_lines: &view_data.lines,
4252            view_anchor,
4253            render_area,
4254            gutter_width,
4255            selection: &selection,
4256            decorations: &decorations,
4257            starting_line_num,
4258            visible_line_count: visible_count,
4259            lsp_waiting: false,
4260            is_active: true,
4261            line_wrap: viewport.line_wrap_enabled,
4262            estimated_lines,
4263            left_column: viewport.left_column,
4264            relative_line_numbers: false,
4265        });
4266
4267        (
4268            output,
4269            state.buffer.len(),
4270            content.ends_with('\n'),
4271            selection.primary_cursor_position,
4272        )
4273    }
4274
4275    #[test]
4276    fn last_line_end_tracks_trailing_newline() {
4277        let output = render_output_for("abc\n", 4);
4278        assert_eq!(
4279            output.0.last_line_end,
4280            Some(LastLineEnd {
4281                pos: (3, 0),
4282                terminated_with_newline: true
4283            })
4284        );
4285    }
4286
4287    #[test]
4288    fn last_line_end_tracks_no_trailing_newline() {
4289        let output = render_output_for("abc", 3);
4290        assert_eq!(
4291            output.0.last_line_end,
4292            Some(LastLineEnd {
4293                pos: (3, 0),
4294                terminated_with_newline: false
4295            })
4296        );
4297    }
4298
4299    #[test]
4300    fn cursor_after_newline_places_on_next_line() {
4301        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
4302        let cursor = SplitRenderer::resolve_cursor_fallback(
4303            output.cursor,
4304            cursor_pos,
4305            buffer_len,
4306            buffer_newline,
4307            output.last_line_end,
4308            output.content_lines_rendered,
4309            0, // gutter_width (gutters disabled in tests)
4310        );
4311        assert_eq!(cursor, Some((0, 1)));
4312    }
4313
4314    #[test]
4315    fn cursor_at_end_without_newline_stays_on_line() {
4316        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
4317        let cursor = SplitRenderer::resolve_cursor_fallback(
4318            output.cursor,
4319            cursor_pos,
4320            buffer_len,
4321            buffer_newline,
4322            output.last_line_end,
4323            output.content_lines_rendered,
4324            0, // gutter_width (gutters disabled in tests)
4325        );
4326        assert_eq!(cursor, Some((3, 0)));
4327    }
4328
4329    // Helper to count all cursor positions in rendered output
4330    // Cursors can appear as:
4331    // 1. Primary cursor in output.cursor (hardware cursor position)
4332    // 2. Visual spans with REVERSED modifier (secondary cursors, or primary cursor with contrast fix)
4333    // 3. Visual spans with special background color (inactive cursors)
4334    fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
4335        let mut cursor_positions = Vec::new();
4336
4337        // Check for primary cursor in output.cursor field
4338        let primary_cursor = output.cursor;
4339        if let Some(cursor_pos) = primary_cursor {
4340            cursor_positions.push(cursor_pos);
4341        }
4342
4343        // Check for visual cursor indicators in rendered spans (secondary/inactive cursors)
4344        for (line_idx, line) in output.lines.iter().enumerate() {
4345            let mut col = 0u16;
4346            for span in line.spans.iter() {
4347                // Check if this span has the REVERSED modifier (secondary cursor)
4348                if span
4349                    .style
4350                    .add_modifier
4351                    .contains(ratatui::style::Modifier::REVERSED)
4352                {
4353                    let pos = (col, line_idx as u16);
4354                    // Only add if this is not the primary cursor position
4355                    // (primary cursor may also have REVERSED for contrast)
4356                    if primary_cursor != Some(pos) {
4357                        cursor_positions.push(pos);
4358                    }
4359                }
4360                // Count the visual width of this span's content
4361                col += str_width(&span.content) as u16;
4362            }
4363        }
4364
4365        cursor_positions
4366    }
4367
4368    // Helper to dump rendered output for debugging
4369    fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
4370        eprintln!("\n=== RENDER DEBUG ===");
4371        eprintln!("Content: {:?}", content);
4372        eprintln!("Cursor position: {}", cursor_pos);
4373        eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
4374        eprintln!("Last line end: {:?}", output.last_line_end);
4375        eprintln!("Content lines rendered: {}", output.content_lines_rendered);
4376        eprintln!("\nRendered lines:");
4377        for (line_idx, line) in output.lines.iter().enumerate() {
4378            eprintln!("  Line {}: {} spans", line_idx, line.spans.len());
4379            for (span_idx, span) in line.spans.iter().enumerate() {
4380                let has_reversed = span
4381                    .style
4382                    .add_modifier
4383                    .contains(ratatui::style::Modifier::REVERSED);
4384                let bg_color = format!("{:?}", span.style.bg);
4385                eprintln!(
4386                    "    Span {}: {:?} (REVERSED: {}, BG: {})",
4387                    span_idx, span.content, has_reversed, bg_color
4388                );
4389            }
4390        }
4391        eprintln!("===================\n");
4392    }
4393
4394    // Helper to get final cursor position after fallback resolution
4395    // Also validates that exactly one cursor is present
4396    fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
4397        let (output, buffer_len, buffer_newline, cursor_pos) =
4398            render_output_for(content, cursor_pos);
4399
4400        // Count all cursors (hardware + visual) in the rendered output
4401        let all_cursors = count_all_cursors(&output);
4402
4403        // Validate that at most one cursor is present in rendered output
4404        // (Some cursors are added by fallback logic, not during rendering)
4405        assert!(
4406            all_cursors.len() <= 1,
4407            "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
4408            all_cursors.len(),
4409            all_cursors
4410        );
4411
4412        let final_cursor = SplitRenderer::resolve_cursor_fallback(
4413            output.cursor,
4414            cursor_pos,
4415            buffer_len,
4416            buffer_newline,
4417            output.last_line_end,
4418            output.content_lines_rendered,
4419            0, // gutter_width (gutters disabled in tests)
4420        );
4421
4422        // Debug dump if we find unexpected results
4423        if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
4424        {
4425            dump_render_output(content, cursor_pos, &output);
4426        }
4427
4428        // If a cursor was rendered, it should match the final cursor position
4429        if let Some(rendered_cursor) = all_cursors.first() {
4430            assert_eq!(
4431                Some(*rendered_cursor),
4432                final_cursor,
4433                "Rendered cursor at {:?} doesn't match final cursor {:?}",
4434                rendered_cursor,
4435                final_cursor
4436            );
4437        }
4438
4439        // Validate that we have a final cursor position (either rendered or from fallback)
4440        assert!(
4441            final_cursor.is_some(),
4442            "Expected a final cursor position, but got None. Rendered cursors: {:?}",
4443            all_cursors
4444        );
4445
4446        final_cursor
4447    }
4448
4449    // Helper to simulate typing a character and check if it appears at cursor position
4450    fn check_typing_at_cursor(
4451        content: &str,
4452        cursor_pos: usize,
4453        char_to_type: char,
4454    ) -> (Option<(u16, u16)>, String) {
4455        // Get cursor position before typing
4456        let cursor_before = get_final_cursor(content, cursor_pos);
4457
4458        // Simulate inserting the character at cursor position
4459        let mut new_content = content.to_string();
4460        if cursor_pos <= content.len() {
4461            new_content.insert(cursor_pos, char_to_type);
4462        }
4463
4464        (cursor_before, new_content)
4465    }
4466
4467    #[test]
4468    fn e2e_cursor_at_start_of_nonempty_line() {
4469        // "abc" with cursor at position 0 (before 'a')
4470        let cursor = get_final_cursor("abc", 0);
4471        assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
4472
4473        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
4474        assert_eq!(
4475            new_content, "Xabc",
4476            "Typing should insert at cursor position"
4477        );
4478        assert_eq!(cursor_pos, Some((0, 0)));
4479    }
4480
4481    #[test]
4482    fn e2e_cursor_in_middle_of_line() {
4483        // "abc" with cursor at position 1 (on 'b')
4484        let cursor = get_final_cursor("abc", 1);
4485        assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
4486
4487        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
4488        assert_eq!(
4489            new_content, "aXbc",
4490            "Typing should insert at cursor position"
4491        );
4492        assert_eq!(cursor_pos, Some((1, 0)));
4493    }
4494
4495    #[test]
4496    fn e2e_cursor_at_end_of_line_no_newline() {
4497        // "abc" with cursor at position 3 (after 'c', at EOF)
4498        let cursor = get_final_cursor("abc", 3);
4499        assert_eq!(
4500            cursor,
4501            Some((3, 0)),
4502            "Cursor should be at column 3, line 0 (after last char)"
4503        );
4504
4505        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
4506        assert_eq!(new_content, "abcX", "Typing should append at end");
4507        assert_eq!(cursor_pos, Some((3, 0)));
4508    }
4509
4510    #[test]
4511    fn e2e_cursor_at_empty_line() {
4512        // "\n" with cursor at position 0 (on the newline itself)
4513        let cursor = get_final_cursor("\n", 0);
4514        assert_eq!(
4515            cursor,
4516            Some((0, 0)),
4517            "Cursor on empty line should be at column 0"
4518        );
4519
4520        let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
4521        assert_eq!(new_content, "X\n", "Typing should insert before newline");
4522        assert_eq!(cursor_pos, Some((0, 0)));
4523    }
4524
4525    #[test]
4526    fn e2e_cursor_after_newline_at_eof() {
4527        // "abc\n" with cursor at position 4 (after newline, at EOF)
4528        let cursor = get_final_cursor("abc\n", 4);
4529        assert_eq!(
4530            cursor,
4531            Some((0, 1)),
4532            "Cursor after newline at EOF should be on next line"
4533        );
4534
4535        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
4536        assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
4537        assert_eq!(cursor_pos, Some((0, 1)));
4538    }
4539
4540    #[test]
4541    fn e2e_cursor_on_newline_with_content() {
4542        // "abc\n" with cursor at position 3 (on the newline character)
4543        let cursor = get_final_cursor("abc\n", 3);
4544        assert_eq!(
4545            cursor,
4546            Some((3, 0)),
4547            "Cursor on newline after content should be after last char"
4548        );
4549
4550        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
4551        assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
4552        assert_eq!(cursor_pos, Some((3, 0)));
4553    }
4554
4555    #[test]
4556    fn e2e_cursor_multiline_start_of_second_line() {
4557        // "abc\ndef" with cursor at position 4 (start of second line, on 'd')
4558        let cursor = get_final_cursor("abc\ndef", 4);
4559        assert_eq!(
4560            cursor,
4561            Some((0, 1)),
4562            "Cursor at start of second line should be at column 0, line 1"
4563        );
4564
4565        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
4566        assert_eq!(
4567            new_content, "abc\nXdef",
4568            "Typing should insert at start of second line"
4569        );
4570        assert_eq!(cursor_pos, Some((0, 1)));
4571    }
4572
4573    #[test]
4574    fn e2e_cursor_multiline_end_of_first_line() {
4575        // "abc\ndef" with cursor at position 3 (on newline of first line)
4576        let cursor = get_final_cursor("abc\ndef", 3);
4577        assert_eq!(
4578            cursor,
4579            Some((3, 0)),
4580            "Cursor on newline of first line should be after content"
4581        );
4582
4583        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
4584        assert_eq!(
4585            new_content, "abcX\ndef",
4586            "Typing should insert before newline"
4587        );
4588        assert_eq!(cursor_pos, Some((3, 0)));
4589    }
4590
4591    #[test]
4592    fn e2e_cursor_empty_buffer() {
4593        // Empty buffer with cursor at position 0
4594        let cursor = get_final_cursor("", 0);
4595        assert_eq!(
4596            cursor,
4597            Some((0, 0)),
4598            "Cursor in empty buffer should be at origin"
4599        );
4600
4601        let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
4602        assert_eq!(
4603            new_content, "X",
4604            "Typing in empty buffer should insert character"
4605        );
4606        assert_eq!(cursor_pos, Some((0, 0)));
4607    }
4608
4609    #[test]
4610    fn e2e_cursor_empty_buffer_with_gutters() {
4611        // Empty buffer with cursor at position 0, with gutters enabled
4612        // The cursor should be positioned at the gutter width (right after the gutter),
4613        // NOT at column 0 (which would be in the gutter area)
4614        let (output, buffer_len, buffer_newline, cursor_pos) =
4615            render_output_for_with_gutters("", 0, true);
4616
4617        // With gutters enabled, the gutter width should be > 0
4618        // Default gutter includes: 1 char indicator + line number width + separator
4619        // For a 1-line buffer, line number width is typically 1 digit + padding
4620        let gutter_width = {
4621            let mut state = EditorState::new(20, 6, 1024);
4622            state.margins.left_config.enabled = true;
4623            state.margins.update_width_for_buffer(1);
4624            state.margins.left_total_width()
4625        };
4626        assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
4627
4628        // CRITICAL: Check the RENDERED cursor position directly from output.cursor
4629        // This is what the terminal will actually use for cursor positioning
4630        // The cursor should be rendered at gutter_width, not at 0
4631        assert_eq!(
4632            output.cursor,
4633            Some((gutter_width as u16, 0)),
4634            "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
4635            gutter_width,
4636            output.cursor
4637        );
4638
4639        let final_cursor = SplitRenderer::resolve_cursor_fallback(
4640            output.cursor,
4641            cursor_pos,
4642            buffer_len,
4643            buffer_newline,
4644            output.last_line_end,
4645            output.content_lines_rendered,
4646            gutter_width,
4647        );
4648
4649        // Cursor should be at (gutter_width, 0) - right after the gutter on line 0
4650        assert_eq!(
4651            final_cursor,
4652            Some((gutter_width as u16, 0)),
4653            "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
4654        );
4655    }
4656
4657    #[test]
4658    fn e2e_cursor_between_empty_lines() {
4659        // "\n\n" with cursor at position 1 (on second newline)
4660        let cursor = get_final_cursor("\n\n", 1);
4661        assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
4662
4663        let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
4664        assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
4665        assert_eq!(cursor_pos, Some((0, 1)));
4666    }
4667
4668    #[test]
4669    fn e2e_cursor_at_eof_after_multiple_lines() {
4670        // "abc\ndef\nghi" with cursor at position 11 (at EOF, no trailing newline)
4671        let cursor = get_final_cursor("abc\ndef\nghi", 11);
4672        assert_eq!(
4673            cursor,
4674            Some((3, 2)),
4675            "Cursor at EOF after 'i' should be at column 3, line 2"
4676        );
4677
4678        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
4679        assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
4680        assert_eq!(cursor_pos, Some((3, 2)));
4681    }
4682
4683    #[test]
4684    fn e2e_cursor_at_eof_with_trailing_newline() {
4685        // "abc\ndef\nghi\n" with cursor at position 12 (after trailing newline)
4686        let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
4687        assert_eq!(
4688            cursor,
4689            Some((0, 3)),
4690            "Cursor after trailing newline should be on line 3"
4691        );
4692
4693        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
4694        assert_eq!(
4695            new_content, "abc\ndef\nghi\nX",
4696            "Typing should insert on new line"
4697        );
4698        assert_eq!(cursor_pos, Some((0, 3)));
4699    }
4700
4701    #[test]
4702    fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
4703        // Simulate Ctrl+End: jump from start to end of buffer without trailing newline
4704        let content = "abc\ndef\nghi";
4705
4706        // Start at position 0
4707        let cursor_at_start = get_final_cursor(content, 0);
4708        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
4709
4710        // Jump to EOF (position 11, after 'i')
4711        let cursor_at_eof = get_final_cursor(content, 11);
4712        assert_eq!(
4713            cursor_at_eof,
4714            Some((3, 2)),
4715            "After Ctrl+End, cursor at column 3, line 2"
4716        );
4717
4718        // Type a character at EOF
4719        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
4720        assert_eq!(cursor_before_typing, Some((3, 2)));
4721        assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
4722
4723        // Verify cursor position in the new content
4724        let cursor_after_typing = get_final_cursor(&new_content, 12);
4725        assert_eq!(
4726            cursor_after_typing,
4727            Some((4, 2)),
4728            "After typing, cursor moved to column 4"
4729        );
4730
4731        // Move cursor to start of buffer - verify cursor is no longer at end
4732        let cursor_moved_away = get_final_cursor(&new_content, 0);
4733        assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
4734        // The cursor should NOT be at the end anymore - verify by rendering without cursor at end
4735        // This implicitly tests that only one cursor is rendered
4736    }
4737
4738    #[test]
4739    fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
4740        // Simulate Ctrl+End: jump from start to end of buffer WITH trailing newline
4741        let content = "abc\ndef\nghi\n";
4742
4743        // Start at position 0
4744        let cursor_at_start = get_final_cursor(content, 0);
4745        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
4746
4747        // Jump to EOF (position 12, after trailing newline)
4748        let cursor_at_eof = get_final_cursor(content, 12);
4749        assert_eq!(
4750            cursor_at_eof,
4751            Some((0, 3)),
4752            "After Ctrl+End, cursor at column 0, line 3 (new line)"
4753        );
4754
4755        // Type a character at EOF
4756        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
4757        assert_eq!(cursor_before_typing, Some((0, 3)));
4758        assert_eq!(
4759            new_content, "abc\ndef\nghi\nX",
4760            "Character inserted on new line"
4761        );
4762
4763        // After typing, the cursor should move forward
4764        let cursor_after_typing = get_final_cursor(&new_content, 13);
4765        assert_eq!(
4766            cursor_after_typing,
4767            Some((1, 3)),
4768            "After typing, cursor should be at column 1, line 3"
4769        );
4770
4771        // Move cursor to middle of buffer - verify cursor is no longer at end
4772        let cursor_moved_away = get_final_cursor(&new_content, 4);
4773        assert_eq!(
4774            cursor_moved_away,
4775            Some((0, 1)),
4776            "Cursor moved to start of line 1 (position 4 = start of 'def')"
4777        );
4778    }
4779
4780    #[test]
4781    fn e2e_jump_to_end_of_empty_buffer() {
4782        // Edge case: Ctrl+End in empty buffer should stay at (0,0)
4783        let content = "";
4784
4785        let cursor_at_eof = get_final_cursor(content, 0);
4786        assert_eq!(
4787            cursor_at_eof,
4788            Some((0, 0)),
4789            "Empty buffer: cursor at origin"
4790        );
4791
4792        // Type a character
4793        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
4794        assert_eq!(cursor_before_typing, Some((0, 0)));
4795        assert_eq!(new_content, "X", "Character inserted");
4796
4797        // Verify cursor after typing
4798        let cursor_after_typing = get_final_cursor(&new_content, 1);
4799        assert_eq!(
4800            cursor_after_typing,
4801            Some((1, 0)),
4802            "After typing, cursor at column 1"
4803        );
4804
4805        // Move cursor back to start - verify cursor is no longer at end
4806        let cursor_moved_away = get_final_cursor(&new_content, 0);
4807        assert_eq!(
4808            cursor_moved_away,
4809            Some((0, 0)),
4810            "Cursor moved back to start"
4811        );
4812    }
4813
4814    #[test]
4815    fn e2e_jump_to_end_of_single_empty_line() {
4816        // Edge case: buffer with just a newline
4817        let content = "\n";
4818
4819        // Position 0 is ON the newline
4820        let cursor_on_newline = get_final_cursor(content, 0);
4821        assert_eq!(
4822            cursor_on_newline,
4823            Some((0, 0)),
4824            "Cursor on the newline character"
4825        );
4826
4827        // Position 1 is AFTER the newline (EOF)
4828        let cursor_at_eof = get_final_cursor(content, 1);
4829        assert_eq!(
4830            cursor_at_eof,
4831            Some((0, 1)),
4832            "After Ctrl+End, cursor on line 1"
4833        );
4834
4835        // Type at EOF
4836        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
4837        assert_eq!(cursor_before_typing, Some((0, 1)));
4838        assert_eq!(new_content, "\nX", "Character on second line");
4839
4840        let cursor_after_typing = get_final_cursor(&new_content, 2);
4841        assert_eq!(
4842            cursor_after_typing,
4843            Some((1, 1)),
4844            "After typing, cursor at column 1, line 1"
4845        );
4846
4847        // Move cursor to the newline - verify cursor is no longer at end
4848        let cursor_moved_away = get_final_cursor(&new_content, 0);
4849        assert_eq!(
4850            cursor_moved_away,
4851            Some((0, 0)),
4852            "Cursor moved to the newline on line 0"
4853        );
4854    }
4855    // NOTE: Tests for view transform header handling have been moved to src/ui/view_pipeline.rs
4856    // where the elegant token-based pipeline properly handles these cases.
4857    // The view_pipeline tests cover:
4858    // - test_simple_source_lines
4859    // - test_wrapped_continuation
4860    // - test_injected_header_then_source
4861    // - test_mixed_scenario
4862
4863    // ==================== CRLF Tokenization Tests ====================
4864
4865    use crate::model::buffer::LineEnding;
4866    use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4867
4868    /// Helper to extract source_offset from tokens for easier assertion
4869    fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
4870        tokens
4871            .iter()
4872            .map(|t| {
4873                let kind_str = match &t.kind {
4874                    ViewTokenWireKind::Text(s) => format!("Text({})", s),
4875                    ViewTokenWireKind::Newline => "Newline".to_string(),
4876                    ViewTokenWireKind::Space => "Space".to_string(),
4877                    ViewTokenWireKind::Break => "Break".to_string(),
4878                    ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
4879                };
4880                (kind_str, t.source_offset)
4881            })
4882            .collect()
4883    }
4884
4885    /// Test tokenization of CRLF content with a single line.
4886    /// Verifies that Newline token is at \r position and \n is skipped.
4887    #[test]
4888    fn test_build_base_tokens_crlf_single_line() {
4889        // Content: "abc\r\n" (5 bytes: a=0, b=1, c=2, \r=3, \n=4)
4890        let content = b"abc\r\n";
4891        let mut buffer = Buffer::from_bytes(content.to_vec());
4892        buffer.set_line_ending(LineEnding::CRLF);
4893
4894        let tokens = SplitRenderer::build_base_tokens_for_hook(
4895            &mut buffer,
4896            0,     // top_byte
4897            80,    // estimated_line_length
4898            10,    // visible_count
4899            false, // is_binary
4900            LineEnding::CRLF,
4901        );
4902
4903        let offsets = extract_token_offsets(&tokens);
4904
4905        // Should have: Text("abc") at 0, Newline at 3
4906        // The \n at byte 4 should be skipped
4907        assert!(
4908            offsets
4909                .iter()
4910                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
4911            "Expected Text(abc) at offset 0, got: {:?}",
4912            offsets
4913        );
4914        assert!(
4915            offsets
4916                .iter()
4917                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
4918            "Expected Newline at offset 3 (\\r position), got: {:?}",
4919            offsets
4920        );
4921
4922        // Verify there's only one Newline token
4923        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
4924        assert_eq!(
4925            newline_count, 1,
4926            "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
4927            newline_count, offsets
4928        );
4929    }
4930
4931    /// Test tokenization of CRLF content with multiple lines.
4932    /// This verifies that source_offset correctly accumulates across lines.
4933    #[test]
4934    fn test_build_base_tokens_crlf_multiple_lines() {
4935        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
4936        // Line 1: a=0, b=1, c=2, \r=3, \n=4
4937        // Line 2: d=5, e=6, f=7, \r=8, \n=9
4938        // Line 3: g=10, h=11, i=12, \r=13, \n=14
4939        let content = b"abc\r\ndef\r\nghi\r\n";
4940        let mut buffer = Buffer::from_bytes(content.to_vec());
4941        buffer.set_line_ending(LineEnding::CRLF);
4942
4943        let tokens = SplitRenderer::build_base_tokens_for_hook(
4944            &mut buffer,
4945            0,
4946            80,
4947            10,
4948            false,
4949            LineEnding::CRLF,
4950        );
4951
4952        let offsets = extract_token_offsets(&tokens);
4953
4954        // Expected tokens:
4955        // Text("abc") at 0, Newline at 3
4956        // Text("def") at 5, Newline at 8
4957        // Text("ghi") at 10, Newline at 13
4958
4959        // Verify line 1 tokens
4960        assert!(
4961            offsets
4962                .iter()
4963                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
4964            "Line 1: Expected Text(abc) at 0, got: {:?}",
4965            offsets
4966        );
4967        assert!(
4968            offsets
4969                .iter()
4970                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
4971            "Line 1: Expected Newline at 3, got: {:?}",
4972            offsets
4973        );
4974
4975        // Verify line 2 tokens - THIS IS WHERE OFFSET DRIFT WOULD APPEAR
4976        assert!(
4977            offsets
4978                .iter()
4979                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
4980            "Line 2: Expected Text(def) at 5, got: {:?}",
4981            offsets
4982        );
4983        assert!(
4984            offsets
4985                .iter()
4986                .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
4987            "Line 2: Expected Newline at 8, got: {:?}",
4988            offsets
4989        );
4990
4991        // Verify line 3 tokens - DRIFT ACCUMULATES HERE
4992        assert!(
4993            offsets
4994                .iter()
4995                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
4996            "Line 3: Expected Text(ghi) at 10, got: {:?}",
4997            offsets
4998        );
4999        assert!(
5000            offsets
5001                .iter()
5002                .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
5003            "Line 3: Expected Newline at 13, got: {:?}",
5004            offsets
5005        );
5006
5007        // Verify exactly 3 Newline tokens
5008        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
5009        assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
5010    }
5011
5012    /// Test tokenization of LF content to compare with CRLF.
5013    /// LF mode should NOT skip anything - each character gets its own offset.
5014    #[test]
5015    fn test_build_base_tokens_lf_mode_for_comparison() {
5016        // Content: "abc\ndef\n" (8 bytes)
5017        // Line 1: a=0, b=1, c=2, \n=3
5018        // Line 2: d=4, e=5, f=6, \n=7
5019        let content = b"abc\ndef\n";
5020        let mut buffer = Buffer::from_bytes(content.to_vec());
5021        buffer.set_line_ending(LineEnding::LF);
5022
5023        let tokens = SplitRenderer::build_base_tokens_for_hook(
5024            &mut buffer,
5025            0,
5026            80,
5027            10,
5028            false,
5029            LineEnding::LF,
5030        );
5031
5032        let offsets = extract_token_offsets(&tokens);
5033
5034        // Verify LF offsets
5035        assert!(
5036            offsets
5037                .iter()
5038                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
5039            "LF Line 1: Expected Text(abc) at 0"
5040        );
5041        assert!(
5042            offsets
5043                .iter()
5044                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
5045            "LF Line 1: Expected Newline at 3"
5046        );
5047        assert!(
5048            offsets
5049                .iter()
5050                .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
5051            "LF Line 2: Expected Text(def) at 4"
5052        );
5053        assert!(
5054            offsets
5055                .iter()
5056                .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
5057            "LF Line 2: Expected Newline at 7"
5058        );
5059    }
5060
5061    /// Test that CRLF in LF-mode file shows \r as control character.
5062    /// This verifies that \r is rendered as <0D> in LF files.
5063    #[test]
5064    fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
5065        // Content: "abc\r\n" but buffer is in LF mode
5066        let content = b"abc\r\n";
5067        let mut buffer = Buffer::from_bytes(content.to_vec());
5068        buffer.set_line_ending(LineEnding::LF); // Force LF mode
5069
5070        let tokens = SplitRenderer::build_base_tokens_for_hook(
5071            &mut buffer,
5072            0,
5073            80,
5074            10,
5075            false,
5076            LineEnding::LF,
5077        );
5078
5079        let offsets = extract_token_offsets(&tokens);
5080
5081        // In LF mode, \r should be rendered as BinaryByte(0x0d)
5082        assert!(
5083            offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
5084            "LF mode should render \\r as control char <0D>, got: {:?}",
5085            offsets
5086        );
5087    }
5088
5089    /// Test tokenization starting from middle of file (top_byte != 0).
5090    /// Verifies that source_offset is correct even when not starting from byte 0.
5091    #[test]
5092    fn test_build_base_tokens_crlf_from_middle() {
5093        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
5094        // Start from byte 5 (beginning of "def")
5095        let content = b"abc\r\ndef\r\nghi\r\n";
5096        let mut buffer = Buffer::from_bytes(content.to_vec());
5097        buffer.set_line_ending(LineEnding::CRLF);
5098
5099        let tokens = SplitRenderer::build_base_tokens_for_hook(
5100            &mut buffer,
5101            5, // Start from line 2
5102            80,
5103            10,
5104            false,
5105            LineEnding::CRLF,
5106        );
5107
5108        let offsets = extract_token_offsets(&tokens);
5109
5110        // Should have:
5111        // Text("def") at 5, Newline at 8
5112        // Text("ghi") at 10, Newline at 13
5113        assert!(
5114            offsets
5115                .iter()
5116                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
5117            "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
5118            offsets
5119        );
5120        assert!(
5121            offsets
5122                .iter()
5123                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
5124            "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
5125            offsets
5126        );
5127    }
5128
5129    /// End-to-end test: verify full pipeline from CRLF buffer to ViewLine to highlighting lookup
5130    /// This test simulates the complete flow that would trigger the offset drift bug.
5131    #[test]
5132    fn test_crlf_highlight_span_lookup() {
5133        use crate::view::ui::view_pipeline::ViewLineIterator;
5134
5135        // Simulate Java-like CRLF content:
5136        // "int x;\r\nint y;\r\n"
5137        // Bytes: i=0, n=1, t=2, ' '=3, x=4, ;=5, \r=6, \n=7,
5138        //        i=8, n=9, t=10, ' '=11, y=12, ;=13, \r=14, \n=15
5139        let content = b"int x;\r\nint y;\r\n";
5140        let mut buffer = Buffer::from_bytes(content.to_vec());
5141        buffer.set_line_ending(LineEnding::CRLF);
5142
5143        // Step 1: Generate tokens
5144        let tokens = SplitRenderer::build_base_tokens_for_hook(
5145            &mut buffer,
5146            0,
5147            80,
5148            10,
5149            false,
5150            LineEnding::CRLF,
5151        );
5152
5153        // Verify tokens have correct offsets
5154        let offsets = extract_token_offsets(&tokens);
5155        eprintln!("Tokens: {:?}", offsets);
5156
5157        // Step 2: Convert tokens to ViewLines
5158        let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
5159        assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
5160
5161        // Step 3: Verify char_source_bytes mapping for each line
5162        // Line 1: "int x;\n" displayed, maps to bytes 0-6
5163        eprintln!(
5164            "Line 1 char_source_bytes: {:?}",
5165            view_lines[0].char_source_bytes
5166        );
5167        assert_eq!(
5168            view_lines[0].char_source_bytes.len(),
5169            7,
5170            "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
5171        );
5172        // Check specific mappings
5173        assert_eq!(
5174            view_lines[0].char_source_bytes[0],
5175            Some(0),
5176            "Line 1 'i' -> byte 0"
5177        );
5178        assert_eq!(
5179            view_lines[0].char_source_bytes[4],
5180            Some(4),
5181            "Line 1 'x' -> byte 4"
5182        );
5183        assert_eq!(
5184            view_lines[0].char_source_bytes[5],
5185            Some(5),
5186            "Line 1 ';' -> byte 5"
5187        );
5188        assert_eq!(
5189            view_lines[0].char_source_bytes[6],
5190            Some(6),
5191            "Line 1 newline -> byte 6 (\\r pos)"
5192        );
5193
5194        // Line 2: "int y;\n" displayed, maps to bytes 8-14
5195        eprintln!(
5196            "Line 2 char_source_bytes: {:?}",
5197            view_lines[1].char_source_bytes
5198        );
5199        assert_eq!(
5200            view_lines[1].char_source_bytes.len(),
5201            7,
5202            "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
5203        );
5204        // Check specific mappings - THIS IS WHERE DRIFT WOULD SHOW
5205        assert_eq!(
5206            view_lines[1].char_source_bytes[0],
5207            Some(8),
5208            "Line 2 'i' -> byte 8"
5209        );
5210        assert_eq!(
5211            view_lines[1].char_source_bytes[4],
5212            Some(12),
5213            "Line 2 'y' -> byte 12"
5214        );
5215        assert_eq!(
5216            view_lines[1].char_source_bytes[5],
5217            Some(13),
5218            "Line 2 ';' -> byte 13"
5219        );
5220        assert_eq!(
5221            view_lines[1].char_source_bytes[6],
5222            Some(14),
5223            "Line 2 newline -> byte 14 (\\r pos)"
5224        );
5225
5226        // Step 4: Simulate highlight span lookup
5227        // If TreeSitter highlights "int" as keyword (bytes 0-3 for line 1, bytes 8-11 for line 2),
5228        // the lookup should find these correctly.
5229        let simulated_highlight_spans = [
5230            // "int" on line 1: bytes 0-3
5231            (0usize..3usize, "keyword"),
5232            // "int" on line 2: bytes 8-11
5233            (8usize..11usize, "keyword"),
5234        ];
5235
5236        // Verify that looking up byte positions from char_source_bytes finds the right spans
5237        for (line_idx, view_line) in view_lines.iter().enumerate() {
5238            for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
5239                if let Some(bp) = byte_pos {
5240                    let in_span = simulated_highlight_spans
5241                        .iter()
5242                        .find(|(range, _)| range.contains(bp))
5243                        .map(|(_, name)| *name);
5244
5245                    // First 3 chars of each line should be in keyword span
5246                    let expected_in_keyword = char_idx < 3;
5247                    let actually_in_keyword = in_span == Some("keyword");
5248
5249                    if expected_in_keyword != actually_in_keyword {
5250                        panic!(
5251                            "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
5252                            line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
5253                        );
5254                    }
5255                }
5256            }
5257        }
5258    }
5259
5260    /// Test that apply_wrapping_transform correctly breaks long lines.
5261    /// This prevents memory exhaustion from extremely long single-line files (issue #481).
5262    #[test]
5263    fn test_apply_wrapping_transform_breaks_long_lines() {
5264        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5265
5266        // Create a token with 25,000 characters (longer than MAX_SAFE_LINE_WIDTH of 10,000)
5267        let long_text = "x".repeat(25_000);
5268        let tokens = vec![
5269            ViewTokenWire {
5270                kind: ViewTokenWireKind::Text(long_text),
5271                source_offset: Some(0),
5272                style: None,
5273            },
5274            ViewTokenWire {
5275                kind: ViewTokenWireKind::Newline,
5276                source_offset: Some(25_000),
5277                style: None,
5278            },
5279        ];
5280
5281        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
5282        let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5283
5284        // Count Break tokens - should have at least 2 breaks for 25K chars at 10K width
5285        let break_count = wrapped
5286            .iter()
5287            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
5288            .count();
5289
5290        assert!(
5291            break_count >= 2,
5292            "25K char line should have at least 2 breaks at 10K width, got {}",
5293            break_count
5294        );
5295
5296        // Verify total content is preserved (excluding Break tokens)
5297        let total_chars: usize = wrapped
5298            .iter()
5299            .filter_map(|t| match &t.kind {
5300                ViewTokenWireKind::Text(s) => Some(s.len()),
5301                _ => None,
5302            })
5303            .sum();
5304
5305        assert_eq!(
5306            total_chars, 25_000,
5307            "Total character count should be preserved after wrapping"
5308        );
5309    }
5310
5311    /// Test that normal-length lines are not affected by safety wrapping.
5312    #[test]
5313    fn test_apply_wrapping_transform_preserves_short_lines() {
5314        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5315
5316        // Create a token with 100 characters (much shorter than MAX_SAFE_LINE_WIDTH)
5317        let short_text = "x".repeat(100);
5318        let tokens = vec![
5319            ViewTokenWire {
5320                kind: ViewTokenWireKind::Text(short_text.clone()),
5321                source_offset: Some(0),
5322                style: None,
5323            },
5324            ViewTokenWire {
5325                kind: ViewTokenWireKind::Newline,
5326                source_offset: Some(100),
5327                style: None,
5328            },
5329        ];
5330
5331        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
5332        let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5333
5334        // Should have no Break tokens for short lines
5335        let break_count = wrapped
5336            .iter()
5337            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
5338            .count();
5339
5340        assert_eq!(
5341            break_count, 0,
5342            "Short lines should not have any breaks, got {}",
5343            break_count
5344        );
5345
5346        // Original text should be preserved exactly
5347        let text_tokens: Vec<_> = wrapped
5348            .iter()
5349            .filter_map(|t| match &t.kind {
5350                ViewTokenWireKind::Text(s) => Some(s.clone()),
5351                _ => None,
5352            })
5353            .collect();
5354
5355        assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
5356        assert_eq!(
5357            text_tokens[0], short_text,
5358            "Text content should be unchanged"
5359        );
5360    }
5361
5362    /// End-to-end test: verify large single-line content with sequential markers
5363    /// is correctly chunked, wrapped, and all data is preserved through the pipeline.
5364    #[test]
5365    fn test_large_single_line_sequential_data_preserved() {
5366        use crate::view::ui::view_pipeline::ViewLineIterator;
5367        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5368
5369        // Create content with sequential markers that span multiple chunks
5370        // Format: "[00001][00002]..." - each marker is 7 chars
5371        let num_markers = 5_000; // ~35KB, enough to test chunking at 10K char intervals
5372        let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
5373
5374        // Create tokens simulating what build_base_tokens would produce
5375        let tokens = vec![
5376            ViewTokenWire {
5377                kind: ViewTokenWireKind::Text(content.clone()),
5378                source_offset: Some(0),
5379                style: None,
5380            },
5381            ViewTokenWire {
5382                kind: ViewTokenWireKind::Newline,
5383                source_offset: Some(content.len()),
5384                style: None,
5385            },
5386        ];
5387
5388        // Apply safety wrapping (simulating line_wrap=false with MAX_SAFE_LINE_WIDTH)
5389        let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5390
5391        // Convert to ViewLines
5392        let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4).collect();
5393
5394        // Reconstruct content from ViewLines
5395        let mut reconstructed = String::new();
5396        for line in &view_lines {
5397            // Skip the trailing newline character in each line's text
5398            let text = line.text.trim_end_matches('\n');
5399            reconstructed.push_str(text);
5400        }
5401
5402        // Verify all content is preserved
5403        assert_eq!(
5404            reconstructed.len(),
5405            content.len(),
5406            "Reconstructed content length should match original"
5407        );
5408
5409        // Verify sequential markers are all present
5410        for i in 1..=num_markers {
5411            let marker = format!("[{:05}]", i);
5412            assert!(
5413                reconstructed.contains(&marker),
5414                "Missing marker {} after pipeline",
5415                marker
5416            );
5417        }
5418
5419        // Verify order is preserved by checking sample positions
5420        let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
5421        let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
5422        let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
5423        assert!(
5424            pos_100 < pos_1000 && pos_1000 < pos_3000,
5425            "Markers should be in sequential order: {} < {} < {}",
5426            pos_100,
5427            pos_1000,
5428            pos_3000
5429        );
5430
5431        // Verify we got multiple visual lines (content was wrapped)
5432        assert!(
5433            view_lines.len() >= 3,
5434            "35KB content should produce multiple visual lines at 10K width, got {}",
5435            view_lines.len()
5436        );
5437
5438        // Verify each ViewLine is bounded in size (memory safety check)
5439        for (i, line) in view_lines.iter().enumerate() {
5440            assert!(
5441                line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, // +10 for newline and rounding
5442                "ViewLine {} exceeds safe width: {} chars",
5443                i,
5444                line.text.len()
5445            );
5446        }
5447    }
5448}