Skip to main content

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