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, LeafId, 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::folding::FoldManager;
15use crate::view::split::SplitManager;
16use crate::view::theme::color_to_rgb;
17use crate::view::ui::tabs::TabsRenderer;
18use crate::view::ui::view_pipeline::{
19    should_show_line_number, LineStart, ViewLine, ViewLineIterator,
20};
21use crate::view::virtual_text::VirtualTextPosition;
22use fresh_core::api::{ViewTokenStyle, ViewTransformPayload};
23use ratatui::layout::Rect;
24use ratatui::style::{Color, Modifier, Style};
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Block, Borders, Clear, Paragraph};
27use ratatui::Frame;
28use std::collections::{HashMap, HashSet};
29use std::ops::Range;
30
31/// Maximum line width before forced wrapping is applied, even when line wrapping is disabled.
32/// This prevents memory exhaustion when opening files with extremely long lines (e.g., 10MB
33/// single-line JSON files). Lines exceeding this width are wrapped into multiple visual lines,
34/// each bounded to this width. 10,000 columns is far wider than any monitor while keeping
35/// memory usage reasonable (~80KB per ViewLine instead of hundreds of MB).
36const MAX_SAFE_LINE_WIDTH: usize = 10_000;
37
38/// Compute character-level diff between two strings, returning ranges of changed characters.
39/// Returns a tuple of (old_changed_ranges, new_changed_ranges) where each range indicates
40/// character indices that differ between the strings.
41fn compute_inline_diff(old_text: &str, new_text: &str) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
42    let old_chars: Vec<char> = old_text.chars().collect();
43    let new_chars: Vec<char> = new_text.chars().collect();
44
45    let mut old_ranges = Vec::new();
46    let mut new_ranges = Vec::new();
47
48    // Find common prefix
49    let prefix_len = old_chars
50        .iter()
51        .zip(new_chars.iter())
52        .take_while(|(a, b)| a == b)
53        .count();
54
55    // Find common suffix (from the non-prefix part)
56    let old_remaining = old_chars.len() - prefix_len;
57    let new_remaining = new_chars.len() - prefix_len;
58    let suffix_len = old_chars
59        .iter()
60        .rev()
61        .zip(new_chars.iter().rev())
62        .take(old_remaining.min(new_remaining))
63        .take_while(|(a, b)| a == b)
64        .count();
65
66    // The changed range is between prefix and suffix
67    let old_start = prefix_len;
68    let old_end = old_chars.len().saturating_sub(suffix_len);
69    let new_start = prefix_len;
70    let new_end = new_chars.len().saturating_sub(suffix_len);
71
72    if old_start < old_end {
73        old_ranges.push(old_start..old_end);
74    }
75    if new_start < new_end {
76        new_ranges.push(new_start..new_end);
77    }
78
79    (old_ranges, new_ranges)
80}
81
82fn push_span_with_map(
83    spans: &mut Vec<Span<'static>>,
84    map: &mut Vec<Option<usize>>,
85    text: String,
86    style: Style,
87    source: Option<usize>,
88) {
89    if text.is_empty() {
90        return;
91    }
92    // Push one map entry per visual column (not per character)
93    // Double-width characters (CJK, emoji) need 2 entries
94    // Zero-width characters (like \u{200b}) get 0 entries - they don't occupy screen space
95    for ch in text.chars() {
96        let width = char_width(ch);
97        for _ in 0..width {
98            map.push(source);
99        }
100    }
101    spans.push(Span::styled(text, style));
102}
103
104/// Debug tag style - dim/muted color to distinguish from actual content
105fn debug_tag_style() -> Style {
106    Style::default()
107        .fg(Color::DarkGray)
108        .add_modifier(Modifier::DIM)
109}
110
111fn fold_placeholder_style(theme: &crate::view::theme::Theme) -> ViewTokenStyle {
112    let fg = color_to_rgb(theme.line_number_fg).or_else(|| color_to_rgb(theme.editor_fg));
113    ViewTokenStyle {
114        fg,
115        bg: None,
116        bold: false,
117        italic: true,
118    }
119}
120
121/// Compute a dimmed version of a color for EOF tilde lines.
122/// This replaces using Modifier::DIM which can bleed through to overlays.
123fn dim_color_for_tilde(color: Color) -> Color {
124    match color {
125        Color::Rgb(r, g, b) => {
126            // Reduce brightness by ~50% (similar to DIM modifier effect)
127            Color::Rgb(r / 2, g / 2, b / 2)
128        }
129        Color::Indexed(idx) => {
130            // For indexed colors, map to a reasonable dim equivalent
131            // Standard colors 0-7: use corresponding bright versions dimmed
132            // Bright colors 8-15: dim them down
133            // Grayscale and cube colors: just use a dark gray
134            if idx < 16 {
135                Color::Rgb(50, 50, 50) // Dark gray for basic colors
136            } else {
137                Color::Rgb(40, 40, 40) // Slightly darker for extended colors
138            }
139        }
140        // Map named colors to dimmed RGB equivalents
141        Color::Black => Color::Rgb(15, 15, 15),
142        Color::White => Color::Rgb(128, 128, 128),
143        Color::Red => Color::Rgb(100, 30, 30),
144        Color::Green => Color::Rgb(30, 100, 30),
145        Color::Yellow => Color::Rgb(100, 100, 30),
146        Color::Blue => Color::Rgb(30, 30, 100),
147        Color::Magenta => Color::Rgb(100, 30, 100),
148        Color::Cyan => Color::Rgb(30, 100, 100),
149        Color::Gray => Color::Rgb(64, 64, 64),
150        Color::DarkGray => Color::Rgb(40, 40, 40),
151        Color::LightRed => Color::Rgb(128, 50, 50),
152        Color::LightGreen => Color::Rgb(50, 128, 50),
153        Color::LightYellow => Color::Rgb(128, 128, 50),
154        Color::LightBlue => Color::Rgb(50, 50, 128),
155        Color::LightMagenta => Color::Rgb(128, 50, 128),
156        Color::LightCyan => Color::Rgb(50, 128, 128),
157        Color::Reset => Color::Rgb(50, 50, 50),
158    }
159}
160
161/// Accumulator for building spans - collects characters with the same style
162/// into a single span, flushing when style changes. This is important for
163/// proper rendering of combining characters (like Thai diacritics) which
164/// must be in the same string as their base character.
165struct SpanAccumulator {
166    text: String,
167    style: Style,
168    first_source: Option<usize>,
169}
170
171impl SpanAccumulator {
172    fn new() -> Self {
173        Self {
174            text: String::new(),
175            style: Style::default(),
176            first_source: None,
177        }
178    }
179
180    /// Add a character to the accumulator. If the style matches, append to current span.
181    /// If style differs, flush the current span first and start a new one.
182    fn push(
183        &mut self,
184        ch: char,
185        style: Style,
186        source: Option<usize>,
187        spans: &mut Vec<Span<'static>>,
188        map: &mut Vec<Option<usize>>,
189    ) {
190        // If we have accumulated text and the style changed, flush first
191        if !self.text.is_empty() && style != self.style {
192            self.flush(spans, map);
193        }
194
195        // Start new accumulation if empty
196        if self.text.is_empty() {
197            self.style = style;
198            self.first_source = source;
199        }
200
201        self.text.push(ch);
202
203        // Update map for this character's visual width
204        let width = char_width(ch);
205        for _ in 0..width {
206            map.push(source);
207        }
208    }
209
210    /// Flush accumulated text as a span
211    fn flush(&mut self, spans: &mut Vec<Span<'static>>, _map: &mut Vec<Option<usize>>) {
212        if !self.text.is_empty() {
213            spans.push(Span::styled(std::mem::take(&mut self.text), self.style));
214            self.first_source = None;
215        }
216    }
217}
218
219/// Push a debug tag span (no map entries since these aren't real content)
220fn push_debug_tag(spans: &mut Vec<Span<'static>>, map: &mut Vec<Option<usize>>, text: String) {
221    if text.is_empty() {
222        return;
223    }
224    // Debug tags don't map to source positions - they're visual-only
225    for ch in text.chars() {
226        let width = char_width(ch);
227        for _ in 0..width {
228            map.push(None);
229        }
230    }
231    spans.push(Span::styled(text, debug_tag_style()));
232}
233
234/// Context for tracking active spans in debug mode
235#[derive(Default)]
236struct DebugSpanTracker {
237    /// Currently active highlight span (byte range)
238    active_highlight: Option<Range<usize>>,
239    /// Currently active overlay spans (byte ranges)
240    active_overlays: Vec<Range<usize>>,
241}
242
243impl DebugSpanTracker {
244    /// Get opening tags for spans that start at this byte position
245    fn get_opening_tags(
246        &mut self,
247        byte_pos: Option<usize>,
248        highlight_spans: &[crate::primitives::highlighter::HighlightSpan],
249        viewport_overlays: &[(crate::view::overlay::Overlay, Range<usize>)],
250    ) -> Vec<String> {
251        let mut tags = Vec::new();
252
253        if let Some(bp) = byte_pos {
254            // Check if we're entering a new highlight span
255            if let Some(span) = highlight_spans.iter().find(|s| s.range.start == bp) {
256                tags.push(format!("<hl:{}-{}>", span.range.start, span.range.end));
257                self.active_highlight = Some(span.range.clone());
258            }
259
260            // Check if we're entering new overlay spans
261            for (overlay, range) in viewport_overlays.iter() {
262                if range.start == bp {
263                    let overlay_type = match &overlay.face {
264                        crate::view::overlay::OverlayFace::Underline { .. } => "ul",
265                        crate::view::overlay::OverlayFace::Background { .. } => "bg",
266                        crate::view::overlay::OverlayFace::Foreground { .. } => "fg",
267                        crate::view::overlay::OverlayFace::Style { .. } => "st",
268                        crate::view::overlay::OverlayFace::ThemedStyle { .. } => "ts",
269                    };
270                    tags.push(format!("<{}:{}-{}>", overlay_type, range.start, range.end));
271                    self.active_overlays.push(range.clone());
272                }
273            }
274        }
275
276        tags
277    }
278
279    /// Get closing tags for spans that end at this byte position
280    fn get_closing_tags(&mut self, byte_pos: Option<usize>) -> Vec<String> {
281        let mut tags = Vec::new();
282
283        if let Some(bp) = byte_pos {
284            // Check if we're exiting the active highlight span
285            if let Some(ref range) = self.active_highlight {
286                if bp >= range.end {
287                    tags.push("</hl>".to_string());
288                    self.active_highlight = None;
289                }
290            }
291
292            // Check if we're exiting any overlay spans
293            let mut closed_indices = Vec::new();
294            for (i, range) in self.active_overlays.iter().enumerate() {
295                if bp >= range.end {
296                    tags.push("</ov>".to_string());
297                    closed_indices.push(i);
298                }
299            }
300            // Remove closed overlays (in reverse order to preserve indices)
301            for i in closed_indices.into_iter().rev() {
302                self.active_overlays.remove(i);
303            }
304        }
305
306        tags
307    }
308}
309
310/// Processed view data containing display lines from the view pipeline
311struct ViewData {
312    /// Display lines with all token information preserved
313    lines: Vec<ViewLine>,
314}
315
316struct ViewAnchor {
317    start_line_idx: usize,
318    start_line_skip: usize,
319}
320
321struct ComposeLayout {
322    render_area: Rect,
323    left_pad: u16,
324    right_pad: u16,
325}
326
327struct SelectionContext {
328    ranges: Vec<Range<usize>>,
329    block_rects: Vec<(usize, usize, usize, usize)>,
330    cursor_positions: Vec<usize>,
331    primary_cursor_position: usize,
332}
333
334struct DecorationContext {
335    highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
336    semantic_token_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
337    viewport_overlays: Vec<(crate::view::overlay::Overlay, Range<usize>)>,
338    virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>>,
339    /// Diagnostic lines indexed by line-start byte offset
340    diagnostic_lines: HashSet<usize>,
341    /// Inline diagnostic text per line (line_start_byte -> (message, style))
342    /// Derived from viewport overlays; highest severity wins per line.
343    diagnostic_inline_texts: HashMap<usize, (String, Style)>,
344    /// Line indicators indexed by line-start byte offset
345    line_indicators: BTreeMap<usize, crate::view::margin::LineIndicator>,
346    /// Fold indicators indexed by line-start byte offset
347    fold_indicators: BTreeMap<usize, FoldIndicator>,
348}
349
350#[derive(Clone, Copy, Debug)]
351struct FoldIndicator {
352    collapsed: bool,
353}
354
355struct LineRenderOutput {
356    lines: Vec<Line<'static>>,
357    cursor: Option<(u16, u16)>,
358    last_line_end: Option<LastLineEnd>,
359    content_lines_rendered: usize,
360    view_line_mappings: Vec<ViewLineMapping>,
361}
362
363#[derive(Clone, Copy, Debug, PartialEq, Eq)]
364struct LastLineEnd {
365    pos: (u16, u16),
366    terminated_with_newline: bool,
367}
368
369/// Output of the pure layout computation phase of buffer rendering.
370/// Contains everything the drawing phase needs to produce the final frame.
371struct BufferLayoutOutput {
372    view_line_mappings: Vec<ViewLineMapping>,
373    render_output: LineRenderOutput,
374    render_area: Rect,
375    compose_layout: ComposeLayout,
376    effective_editor_bg: Color,
377    view_mode: ViewMode,
378    left_column: usize,
379    gutter_width: usize,
380    buffer_ends_with_newline: bool,
381    selection: SelectionContext,
382}
383
384struct SplitLayout {
385    tabs_rect: Rect,
386    content_rect: Rect,
387    scrollbar_rect: Rect,
388    horizontal_scrollbar_rect: Rect,
389}
390
391struct ViewPreferences {
392    view_mode: ViewMode,
393    compose_width: Option<u16>,
394    compose_column_guides: Option<Vec<u16>>,
395    view_transform: Option<ViewTransformPayload>,
396    rulers: Vec<usize>,
397    /// Per-split line number visibility (from BufferViewState)
398    show_line_numbers: bool,
399    /// Per-split current line highlight visibility (from BufferViewState)
400    highlight_current_line: bool,
401}
402
403struct LineRenderInput<'a> {
404    state: &'a EditorState,
405    theme: &'a crate::view::theme::Theme,
406    /// Display lines from the view pipeline (each line has its own mappings, styles, etc.)
407    view_lines: &'a [ViewLine],
408    view_anchor: ViewAnchor,
409    render_area: Rect,
410    gutter_width: usize,
411    selection: &'a SelectionContext,
412    decorations: &'a DecorationContext,
413    visible_line_count: usize,
414    lsp_waiting: bool,
415    is_active: bool,
416    line_wrap: bool,
417    estimated_lines: usize,
418    /// Left column offset for horizontal scrolling
419    left_column: usize,
420    /// Whether to show relative line numbers (distance from cursor)
421    relative_line_numbers: bool,
422    /// Skip REVERSED style on the primary cursor (session mode or non-block cursor style)
423    session_mode: bool,
424    /// No hardware cursor: always render software cursor indicators
425    software_cursor_only: bool,
426    /// Whether to show line numbers in the gutter
427    show_line_numbers: bool,
428    /// Whether the gutter shows byte offsets instead of line numbers
429    /// (large file without line index scan)
430    byte_offset_mode: bool,
431    /// Whether to show tilde (~) markers on lines past end-of-file
432    show_tilde: bool,
433    /// Whether to highlight the line containing the cursor
434    highlight_current_line: bool,
435    /// Per-cell theme key map for the theme inspector (screen_width used for indexing)
436    cell_theme_map: &'a mut Vec<crate::app::types::CellThemeInfo>,
437    /// Screen width for cell_theme_map indexing
438    screen_width: u16,
439}
440
441/// Context for computing the style of a single character
442struct CharStyleContext<'a> {
443    byte_pos: Option<usize>,
444    token_style: Option<&'a fresh_core::api::ViewTokenStyle>,
445    ansi_style: Style,
446    is_cursor: bool,
447    is_selected: bool,
448    theme: &'a crate::view::theme::Theme,
449    /// Pre-resolved syntax highlight color for this byte position (from cursor-based lookup)
450    highlight_color: Option<Color>,
451    /// Theme key for the syntax highlight category (e.g. "syntax.keyword")
452    highlight_theme_key: Option<&'static str>,
453    /// Pre-resolved semantic token color for this byte position (from cursor-based lookup)
454    semantic_token_color: Option<Color>,
455    viewport_overlays: &'a [(crate::view::overlay::Overlay, Range<usize>)],
456    primary_cursor_position: usize,
457    is_active: bool,
458    /// Skip REVERSED style on the primary cursor cell.
459    /// True when a hardware cursor is available (not software_cursor_only),
460    /// or in session mode. Avoids double-inversion in terminal multiplexers
461    /// like zellij where the hardware block cursor inverts the cell too.
462    skip_primary_cursor_reverse: bool,
463    /// Whether this character is on the cursor line and current line highlighting is enabled
464    is_cursor_line_highlighted: bool,
465    /// Background color for the current line
466    current_line_bg: Color,
467}
468
469/// Output from compute_char_style
470struct CharStyleOutput {
471    style: Style,
472    is_secondary_cursor: bool,
473    /// Theme key for the foreground color used on this cell
474    fg_theme_key: Option<&'static str>,
475    /// Theme key for the background color used on this cell
476    bg_theme_key: Option<&'static str>,
477    /// Region label for this cell
478    region: &'static str,
479}
480
481/// Context for rendering the left margin (line numbers, indicators, separator)
482struct LeftMarginContext<'a> {
483    state: &'a EditorState,
484    theme: &'a crate::view::theme::Theme,
485    is_continuation: bool,
486    /// Line-start byte offset for fold/diagnostic/indicator lookups (None for continuations)
487    line_start_byte: Option<usize>,
488    /// Display line number or byte offset for the gutter
489    gutter_num: usize,
490    estimated_lines: usize,
491    diagnostic_lines: &'a HashSet<usize>,
492    /// Pre-computed line indicators (line_start_byte -> indicator)
493    line_indicators: &'a BTreeMap<usize, crate::view::margin::LineIndicator>,
494    /// Fold indicators (line_start_byte -> indicator)
495    fold_indicators: &'a BTreeMap<usize, FoldIndicator>,
496    /// Line-start byte of the cursor line (for relative line numbers and cursor highlight)
497    cursor_line_start_byte: usize,
498    /// 0-indexed line number of the cursor line (for relative line number calculation)
499    cursor_line_number: usize,
500    /// Whether to show relative line numbers
501    relative_line_numbers: bool,
502    /// Whether to show line numbers in the gutter
503    show_line_numbers: bool,
504    /// Whether the gutter shows byte offsets instead of line numbers
505    byte_offset_mode: bool,
506    /// Whether to highlight the current line in the gutter
507    highlight_current_line: bool,
508    /// Whether this split is the active (focused) one
509    is_active: bool,
510}
511
512/// Compute the inline diagnostic style from overlay priority (severity).
513/// Priority values: 100=error, 50=warning, 30=info, 10=hint.
514fn inline_diagnostic_style(priority: i32, theme: &crate::view::theme::Theme) -> Style {
515    match priority {
516        100 => Style::default().fg(theme.diagnostic_error_fg),
517        50 => Style::default().fg(theme.diagnostic_warning_fg),
518        30 => Style::default().fg(theme.diagnostic_info_fg),
519        _ => Style::default().fg(theme.diagnostic_hint_fg),
520    }
521}
522
523/// Render the left margin (indicators + line numbers + separator) to line_spans
524fn render_left_margin(
525    ctx: &LeftMarginContext,
526    line_spans: &mut Vec<Span<'static>>,
527    line_view_map: &mut Vec<Option<usize>>,
528) {
529    if !ctx.state.margins.left_config.enabled {
530        return;
531    }
532
533    let lookup_key = ctx.line_start_byte;
534    // Pre-compute indicator bg for cursor line highlighting
535    let indicator_is_cursor_line = lookup_key.is_some_and(|k| k == ctx.cursor_line_start_byte);
536    let indicator_bg = if indicator_is_cursor_line && ctx.highlight_current_line && ctx.is_active {
537        Some(ctx.theme.current_line_bg)
538    } else {
539        None
540    };
541
542    // For continuation lines, don't show any indicators
543    if ctx.is_continuation {
544        let mut style = Style::default();
545        if let Some(bg) = indicator_bg {
546            style = style.bg(bg);
547        }
548        push_span_with_map(line_spans, line_view_map, " ".to_string(), style, None);
549    } else if lookup_key.is_some_and(|k| ctx.diagnostic_lines.contains(&k)) {
550        // Diagnostic indicators have highest priority
551        let mut style = Style::default().fg(ratatui::style::Color::Red);
552        if let Some(bg) = indicator_bg {
553            style = style.bg(bg);
554        }
555        push_span_with_map(line_spans, line_view_map, "●".to_string(), style, None);
556    } else if lookup_key.is_some_and(|k| {
557        ctx.fold_indicators.contains_key(&k) && !ctx.line_indicators.contains_key(&k)
558    }) {
559        // Show fold indicator when no other indicator is present
560        let fold = ctx.fold_indicators.get(&lookup_key.unwrap()).unwrap();
561        let symbol = if fold.collapsed { "▸" } else { "▾" };
562        let mut style = Style::default().fg(ctx.theme.line_number_fg);
563        if let Some(bg) = indicator_bg {
564            style = style.bg(bg);
565        }
566        push_span_with_map(line_spans, line_view_map, symbol.to_string(), style, None);
567    } else if let Some(indicator) = lookup_key.and_then(|k| ctx.line_indicators.get(&k)) {
568        // Show line indicator (git gutter, breakpoints, etc.)
569        let mut style = Style::default().fg(indicator.color);
570        if let Some(bg) = indicator_bg {
571            style = style.bg(bg);
572        }
573        push_span_with_map(
574            line_spans,
575            line_view_map,
576            indicator.symbol.clone(),
577            style,
578            None,
579        );
580    } else {
581        // Show space (no indicator)
582        let mut style = Style::default();
583        if let Some(bg) = indicator_bg {
584            style = style.bg(bg);
585        }
586        push_span_with_map(line_spans, line_view_map, " ".to_string(), style, None);
587    }
588
589    let is_cursor_line = lookup_key.is_some_and(|k| k == ctx.cursor_line_start_byte);
590    let use_cursor_line_bg = is_cursor_line && ctx.highlight_current_line && ctx.is_active;
591
592    // Render line number (right-aligned) or blank for continuations
593    if ctx.is_continuation {
594        // For wrapped continuation lines, render blank space
595        let blank = " ".repeat(ctx.state.margins.left_config.width);
596        let mut style = Style::default().fg(ctx.theme.line_number_fg);
597        if use_cursor_line_bg {
598            style = style.bg(ctx.theme.current_line_bg);
599        }
600        push_span_with_map(line_spans, line_view_map, blank, style, None);
601    } else if ctx.byte_offset_mode && ctx.show_line_numbers {
602        // Byte offset mode: show the absolute byte offset at the start of each line
603        let rendered_text = format!(
604            "{:>width$}",
605            ctx.gutter_num,
606            width = ctx.state.margins.left_config.width
607        );
608        let mut margin_style = if is_cursor_line {
609            Style::default().fg(ctx.theme.editor_fg)
610        } else {
611            Style::default().fg(ctx.theme.line_number_fg)
612        };
613        if use_cursor_line_bg {
614            margin_style = margin_style.bg(ctx.theme.current_line_bg);
615        }
616        push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
617    } else if ctx.relative_line_numbers {
618        // Relative line numbers: show distance from cursor, or absolute for cursor line
619        let display_num = if is_cursor_line {
620            // Show absolute line number for the cursor line (1-indexed)
621            ctx.gutter_num + 1
622        } else {
623            // Show relative distance for other lines
624            ctx.gutter_num.abs_diff(ctx.cursor_line_number)
625        };
626        let rendered_text = format!(
627            "{:>width$}",
628            display_num,
629            width = ctx.state.margins.left_config.width
630        );
631        // Use brighter color for the cursor line
632        let mut margin_style = if is_cursor_line {
633            Style::default().fg(ctx.theme.editor_fg)
634        } else {
635            Style::default().fg(ctx.theme.line_number_fg)
636        };
637        if use_cursor_line_bg {
638            margin_style = margin_style.bg(ctx.theme.current_line_bg);
639        }
640        push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
641    } else {
642        let margin_content = ctx.state.margins.render_line(
643            ctx.gutter_num,
644            crate::view::margin::MarginPosition::Left,
645            ctx.estimated_lines,
646            ctx.show_line_numbers,
647        );
648        let (rendered_text, style_opt) = margin_content.render(ctx.state.margins.left_config.width);
649
650        // Use custom style if provided, otherwise use default theme color
651        let mut margin_style =
652            style_opt.unwrap_or_else(|| Style::default().fg(ctx.theme.line_number_fg));
653        if use_cursor_line_bg {
654            margin_style = margin_style.bg(ctx.theme.current_line_bg);
655        }
656
657        push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
658    }
659
660    // Render separator
661    if ctx.state.margins.left_config.show_separator {
662        let mut separator_style = Style::default().fg(ctx.theme.line_number_fg);
663        if use_cursor_line_bg {
664            separator_style = separator_style.bg(ctx.theme.current_line_bg);
665        }
666        push_span_with_map(
667            line_spans,
668            line_view_map,
669            ctx.state.margins.left_config.separator.clone(),
670            separator_style,
671            None,
672        );
673    }
674}
675
676/// Advance a cursor through sorted, non-overlapping spans to find the color at `byte_pos`.
677/// Returns the color if `byte_pos` falls inside a span, and advances `cursor` past any
678/// spans that end before `byte_pos` so subsequent calls are O(1) amortized.
679#[inline]
680fn span_color_at(
681    spans: &[crate::primitives::highlighter::HighlightSpan],
682    cursor: &mut usize,
683    byte_pos: usize,
684) -> Option<Color> {
685    while *cursor < spans.len() {
686        let span = &spans[*cursor];
687        if span.range.end <= byte_pos {
688            *cursor += 1;
689        } else if span.range.start > byte_pos {
690            return None;
691        } else {
692            return Some(span.color);
693        }
694    }
695    None
696}
697
698/// Like `span_color_at` but also returns the theme key for the highlight category.
699fn span_info_at(
700    spans: &[crate::primitives::highlighter::HighlightSpan],
701    cursor: &mut usize,
702    byte_pos: usize,
703) -> (Option<Color>, Option<&'static str>, Option<&'static str>) {
704    while *cursor < spans.len() {
705        let span = &spans[*cursor];
706        if span.range.end <= byte_pos {
707            *cursor += 1;
708        } else if span.range.start > byte_pos {
709            return (None, None, None);
710        } else {
711            let theme_key = span.category.as_ref().map(|c| c.theme_key());
712            let display_name = span.category.as_ref().map(|c| c.display_name());
713            return (Some(span.color), theme_key, display_name);
714        }
715    }
716    (None, None, None)
717}
718
719/// Compute the style for a character by layering: token -> ANSI -> syntax -> semantic -> overlays -> selection -> cursor
720/// Also tracks which theme keys produced the final fg/bg colors.
721fn compute_char_style(ctx: &CharStyleContext) -> CharStyleOutput {
722    use crate::view::overlay::OverlayFace;
723
724    let highlight_color = ctx.highlight_color;
725
726    // Track theme key provenance alongside style
727    let mut fg_theme_key: Option<&'static str> = None;
728    let mut bg_theme_key: Option<&'static str> = Some("editor.bg");
729    let mut region: &'static str = "Editor Content";
730
731    // Find overlays for this byte position
732    let overlays: Vec<&crate::view::overlay::Overlay> = if let Some(bp) = ctx.byte_pos {
733        ctx.viewport_overlays
734            .iter()
735            .filter(|(_, range)| range.contains(&bp))
736            .map(|(overlay, _)| overlay)
737            .collect()
738    } else {
739        Vec::new()
740    };
741
742    // Start with token style if present (for injected content like annotation headers)
743    // Otherwise use ANSI/syntax/theme default
744    let mut style = if let Some(ts) = ctx.token_style {
745        let mut s = Style::default();
746        if let Some((r, g, b)) = ts.fg {
747            s = s.fg(ratatui::style::Color::Rgb(r, g, b));
748        } else {
749            s = s.fg(ctx.theme.editor_fg);
750            fg_theme_key = Some("editor.fg");
751        }
752        if let Some((r, g, b)) = ts.bg {
753            s = s.bg(ratatui::style::Color::Rgb(r, g, b));
754        }
755        if ts.bold {
756            s = s.add_modifier(Modifier::BOLD);
757        }
758        if ts.italic {
759            s = s.add_modifier(Modifier::ITALIC);
760        }
761        region = "Plugin Token";
762        s
763    } else if ctx.ansi_style.fg.is_some()
764        || ctx.ansi_style.bg.is_some()
765        || !ctx.ansi_style.add_modifier.is_empty()
766    {
767        // Apply ANSI styling from escape codes
768        let mut s = Style::default();
769        if let Some(fg) = ctx.ansi_style.fg {
770            s = s.fg(fg);
771        } else {
772            s = s.fg(ctx.theme.editor_fg);
773            fg_theme_key = Some("editor.fg");
774        }
775        if let Some(bg) = ctx.ansi_style.bg {
776            s = s.bg(bg);
777            bg_theme_key = None; // ANSI bg, not from theme
778        }
779        s = s.add_modifier(ctx.ansi_style.add_modifier);
780        region = "ANSI Escape";
781        s
782    } else if let Some(color) = highlight_color {
783        // Apply syntax highlighting
784        fg_theme_key = ctx.highlight_theme_key;
785        Style::default().fg(color)
786    } else {
787        // Default color from theme
788        fg_theme_key = Some("editor.fg");
789        Style::default().fg(ctx.theme.editor_fg)
790    };
791
792    // If we have ANSI style but also syntax highlighting, syntax takes precedence for color
793    // (unless ANSI has explicit color which we already applied above)
794    if let Some(color) = highlight_color {
795        if ctx.ansi_style.fg.is_none()
796            && (ctx.ansi_style.bg.is_some() || !ctx.ansi_style.add_modifier.is_empty())
797        {
798            style = style.fg(color);
799            fg_theme_key = ctx.highlight_theme_key;
800        }
801    }
802
803    // Note: Reference highlighting (word under cursor) is now handled via overlays
804    // in the "Apply overlay styles" section below
805
806    // Apply LSP semantic token foreground color when no custom token style is set.
807    if ctx.token_style.is_none() {
808        if let Some(color) = ctx.semantic_token_color {
809            style = style.fg(color);
810            // Semantic tokens don't have a single static key; leave fg_theme_key as-is
811            // (the syntax highlight key is a reasonable approximation)
812        }
813    }
814
815    // Apply overlay styles — last overlay wins for each attribute
816    for overlay in &overlays {
817        match &overlay.face {
818            OverlayFace::Underline {
819                color,
820                style: _underline_style,
821            } => {
822                style = style.add_modifier(Modifier::UNDERLINED).fg(*color);
823                if let Some(key) = overlay.theme_key {
824                    fg_theme_key = Some(key);
825                }
826            }
827            OverlayFace::Background { color } => {
828                style = style.bg(*color);
829                if let Some(key) = overlay.theme_key {
830                    bg_theme_key = Some(key);
831                }
832            }
833            OverlayFace::Foreground { color } => {
834                style = style.fg(*color);
835                if let Some(key) = overlay.theme_key {
836                    fg_theme_key = Some(key);
837                }
838            }
839            OverlayFace::Style {
840                style: overlay_style,
841            } => {
842                style = style.patch(*overlay_style);
843                // Style overlays may set both fg and bg; use overlay's theme_key for bg
844                if let Some(key) = overlay.theme_key {
845                    if overlay_style.bg.is_some() {
846                        bg_theme_key = Some(key);
847                    }
848                    if overlay_style.fg.is_some() {
849                        fg_theme_key = Some(key);
850                    }
851                }
852            }
853            OverlayFace::ThemedStyle {
854                fallback_style,
855                fg_theme,
856                bg_theme,
857            } => {
858                let mut themed_style = *fallback_style;
859                if let Some(fg_key) = fg_theme {
860                    if let Some(color) = ctx.theme.resolve_theme_key(fg_key) {
861                        themed_style = themed_style.fg(color);
862                    }
863                }
864                if let Some(bg_key) = bg_theme {
865                    if let Some(color) = ctx.theme.resolve_theme_key(bg_key) {
866                        themed_style = themed_style.bg(color);
867                    }
868                }
869                style = style.patch(themed_style);
870                // ThemedStyle carries its own theme keys — no need for overlay.theme_key
871            }
872        }
873    }
874
875    // Apply current line background highlight (before selection, so selection overrides it)
876    if ctx.is_cursor_line_highlighted && !ctx.is_selected && style.bg.is_none() {
877        style = style.bg(ctx.current_line_bg);
878    }
879
880    // Apply selection highlighting (preserve fg/syntax colors, only change bg)
881    if ctx.is_selected {
882        style = style.bg(ctx.theme.selection_bg);
883        bg_theme_key = Some("editor.selection_bg");
884        region = "Selection";
885    }
886
887    // Apply cursor styling - make all cursors visible with reversed colors
888    // For active splits: apply REVERSED to ensure character under cursor is visible
889    // (especially important for block cursors where white-on-white would be invisible)
890    // For inactive splits: use a less pronounced background color (no hardware cursor)
891    let is_secondary_cursor = ctx.is_cursor && ctx.byte_pos != Some(ctx.primary_cursor_position);
892    if ctx.is_active {
893        if ctx.is_cursor {
894            if ctx.skip_primary_cursor_reverse {
895                if is_secondary_cursor {
896                    style = style.add_modifier(Modifier::REVERSED);
897                }
898            } else {
899                style = style.add_modifier(Modifier::REVERSED);
900            }
901            region = "Cursor";
902        }
903    } else if ctx.is_cursor {
904        style = style.fg(ctx.theme.editor_fg).bg(ctx.theme.inactive_cursor);
905        fg_theme_key = Some("editor.fg");
906        bg_theme_key = Some("editor.inactive_cursor");
907        region = "Inactive Cursor";
908    }
909
910    CharStyleOutput {
911        style,
912        is_secondary_cursor,
913        fg_theme_key,
914        bg_theme_key,
915        region,
916    }
917}
918
919/// Renders split panes and their content
920pub struct SplitRenderer;
921
922impl SplitRenderer {
923    /// Render the main content area with all splits
924    ///
925    /// # Arguments
926    /// * `frame` - The ratatui frame to render to
927    /// * `area` - The rectangular area to render in
928    /// * `split_manager` - The split manager
929    /// * `buffers` - All open buffers
930    /// * `buffer_metadata` - Metadata for buffers (contains display names)
931    /// * `event_logs` - Event logs for each buffer
932    /// * `theme` - The active theme for colors
933    /// * `lsp_waiting` - Whether LSP is waiting
934    /// * `large_file_threshold_bytes` - Threshold for using constant scrollbar thumb size
935    /// * `line_wrap` - Whether line wrapping is enabled
936    /// * `estimated_line_length` - Estimated average line length for large file line estimation
937    /// * `hide_cursor` - Whether to hide the hardware cursor (e.g., when menu is open)
938    ///
939    /// # Returns
940    /// * Vec of (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end) for mouse handling
941    #[allow(clippy::too_many_arguments)]
942    #[allow(clippy::type_complexity)]
943    pub fn render_content(
944        frame: &mut Frame,
945        area: Rect,
946        split_manager: &SplitManager,
947        buffers: &mut HashMap<BufferId, EditorState>,
948        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
949        event_logs: &mut HashMap<BufferId, EventLog>,
950        composite_buffers: &mut HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
951        composite_view_states: &mut HashMap<
952            (LeafId, BufferId),
953            crate::view::composite_view::CompositeViewState,
954        >,
955        theme: &crate::view::theme::Theme,
956        ansi_background: Option<&AnsiBackground>,
957        background_fade: f32,
958        lsp_waiting: bool,
959        large_file_threshold_bytes: u64,
960        _line_wrap: bool,
961        estimated_line_length: usize,
962        highlight_context_bytes: usize,
963        mut split_view_states: Option<&mut HashMap<LeafId, crate::view::split::SplitViewState>>,
964        grouped_subtrees: &HashMap<LeafId, crate::view::split::SplitNode>,
965        hide_cursor: bool,
966        hovered_tab: Option<(crate::view::split::TabTarget, LeafId, bool)>, // (target, split_id, is_close_button)
967        hovered_close_split: Option<LeafId>,
968        hovered_maximize_split: Option<LeafId>,
969        is_maximized: bool,
970        relative_line_numbers: bool,
971        tab_bar_visible: bool,
972        use_terminal_bg: bool,
973        session_mode: bool,
974        software_cursor_only: bool,
975        show_vertical_scrollbar: bool,
976        show_horizontal_scrollbar: bool,
977        diagnostics_inline_text: bool,
978        show_tilde: bool,
979        cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
980        screen_width: u16,
981    ) -> (
982        Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
983        HashMap<LeafId, crate::view::ui::tabs::TabLayout>, // tab layouts per split
984        Vec<(LeafId, u16, u16, u16)>,                      // close split button areas
985        Vec<(LeafId, u16, u16, u16)>,                      // maximize split button areas
986        HashMap<LeafId, Vec<ViewLineMapping>>,             // view line mappings for mouse clicks
987        Vec<(LeafId, BufferId, Rect, usize, usize, usize)>, // horizontal scrollbar areas (rect + max_content_width + thumb_start + thumb_end)
988        Vec<(
989            crate::model::event::ContainerId,
990            SplitDirection,
991            u16,
992            u16,
993            u16,
994        )>, // hit areas for separators inside active Grouped subtrees
995    ) {
996        let _span = tracing::trace_span!("render_content").entered();
997
998        // Get all visible splits with their areas.
999        //
1000        // Each entry in `visible_buffers` is
1001        //   (tab_bar_owner_split, effective_leaf_id, buffer_id, split_area, kind)
1002        //
1003        // where `kind` is:
1004        //   - `Normal`: regular split. Render tab bar + buffer content.
1005        //   - `GroupTabBarOnly`: main split where a group is active. Render
1006        //     the tab bar (to show the group tab) but skip buffer content
1007        //     (the group's inner leaves will fill it).
1008        //   - `InnerLeaf`: a leaf inside a Grouped subtree. `split_area` is
1009        //     the already-computed content rect for this inner leaf; no tab
1010        //     bar is rendered.
1011        #[derive(Copy, Clone, PartialEq, Eq)]
1012        enum RenderKind {
1013            Normal,
1014            GroupTabBarOnly,
1015            InnerLeaf,
1016        }
1017
1018        let base_visible = split_manager.get_visible_buffers(area);
1019        let active_split_id = split_manager.active_split();
1020        let has_multiple_splits = base_visible.len() > 1;
1021
1022        // Expand groups: for each main leaf, if its SplitViewState has an
1023        // active group tab, emit a tab-bar-only entry for the main split
1024        // followed by one InnerLeaf entry per panel.
1025        let mut visible_buffers: Vec<(LeafId, LeafId, BufferId, Rect, RenderKind)> = Vec::new();
1026        for (main_split_id, main_buffer_id, split_area) in &base_visible {
1027            let active_group = split_view_states
1028                .as_deref()
1029                .and_then(|svs| svs.get(main_split_id))
1030                .and_then(|vs| vs.active_group_tab);
1031
1032            if let Some(group_leaf) = active_group {
1033                if let Some(grouped) = grouped_subtrees.get(&group_leaf) {
1034                    // Compute the content rect for this main split (after tab bar).
1035                    let split_tab_bar_visible = tab_bar_visible
1036                        && !split_view_states
1037                            .as_deref()
1038                            .and_then(|svs| svs.get(main_split_id))
1039                            .is_some_and(|vs| vs.suppress_chrome);
1040                    let main_layout = Self::split_layout(
1041                        *split_area,
1042                        split_tab_bar_visible,
1043                        show_vertical_scrollbar,
1044                        show_horizontal_scrollbar,
1045                    );
1046                    let inner_leaves = grouped.get_leaves_with_rects(main_layout.content_rect);
1047                    visible_buffers.push((
1048                        *main_split_id,
1049                        *main_split_id,
1050                        *main_buffer_id,
1051                        *split_area,
1052                        RenderKind::GroupTabBarOnly,
1053                    ));
1054                    for (inner_leaf, inner_buffer, inner_rect) in &inner_leaves {
1055                        // Keep inner panel viewports in sync with their actual
1056                        // rendered dimensions. This ensures editor.getViewport()
1057                        // returns the correct panel size (not the terminal size)
1058                        // and fixes resize-timing issues since the viewport is
1059                        // updated synchronously during rendering.
1060                        if let Some(svs) = split_view_states.as_deref_mut() {
1061                            if let Some(vs) = svs.get_mut(inner_leaf) {
1062                                vs.viewport.resize(inner_rect.width, inner_rect.height);
1063                            }
1064                        }
1065                        visible_buffers.push((
1066                            *main_split_id,
1067                            *inner_leaf,
1068                            *inner_buffer,
1069                            *inner_rect,
1070                            RenderKind::InnerLeaf,
1071                        ));
1072                    }
1073                    continue;
1074                }
1075            }
1076
1077            visible_buffers.push((
1078                *main_split_id,
1079                *main_split_id,
1080                *main_buffer_id,
1081                *split_area,
1082                RenderKind::Normal,
1083            ));
1084        }
1085
1086        // Collect areas for mouse handling
1087        let mut split_areas = Vec::new();
1088        let mut horizontal_scrollbar_areas: Vec<(LeafId, BufferId, Rect, usize, usize, usize)> =
1089            Vec::new();
1090        let mut tab_layouts: HashMap<LeafId, crate::view::ui::tabs::TabLayout> = HashMap::new();
1091        let mut close_split_areas = Vec::new();
1092        let mut maximize_split_areas = Vec::new();
1093        let mut view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>> = HashMap::new();
1094
1095        // Render each split.
1096        for (main_split_id, split_id, buffer_id, split_area, kind) in visible_buffers {
1097            let is_active = split_id == active_split_id;
1098            let is_inner_group_leaf = kind == RenderKind::InnerLeaf;
1099            let skip_content = kind == RenderKind::GroupTabBarOnly;
1100            let _ = main_split_id; // no longer needed below, kept for clarity
1101
1102            // Suppress chrome (tab bar) for splits in buffer groups
1103            let split_tab_bar_visible = !is_inner_group_leaf
1104                && tab_bar_visible
1105                && !split_view_states
1106                    .as_deref()
1107                    .and_then(|svs| svs.get(&split_id))
1108                    .is_some_and(|vs| vs.suppress_chrome);
1109            // Hide tildes per-split (e.g., for buffer group panels)
1110            let split_show_tilde = show_tilde
1111                && !split_view_states
1112                    .as_deref()
1113                    .and_then(|svs| svs.get(&split_id))
1114                    .is_some_and(|vs| vs.hide_tilde);
1115
1116            let layout = if is_inner_group_leaf {
1117                // Inner leaf: split_area IS the content rect already.
1118                SplitLayout {
1119                    tabs_rect: Rect::new(split_area.x, split_area.y, 0, 0),
1120                    content_rect: Rect::new(
1121                        split_area.x,
1122                        split_area.y,
1123                        split_area.width.saturating_sub(if show_vertical_scrollbar {
1124                            1
1125                        } else {
1126                            0
1127                        }),
1128                        split_area.height,
1129                    ),
1130                    scrollbar_rect: Rect::new(
1131                        split_area.x + split_area.width.saturating_sub(1),
1132                        split_area.y,
1133                        if show_vertical_scrollbar { 1 } else { 0 },
1134                        split_area.height,
1135                    ),
1136                    horizontal_scrollbar_rect: Rect::new(0, 0, 0, 0),
1137                }
1138            } else {
1139                Self::split_layout(
1140                    split_area,
1141                    split_tab_bar_visible,
1142                    show_vertical_scrollbar,
1143                    show_horizontal_scrollbar,
1144                )
1145            };
1146            let (split_buffers, tab_scroll_offset) = if is_inner_group_leaf {
1147                (Vec::new(), 0)
1148            } else {
1149                Self::split_buffers_for_tabs(split_view_states.as_deref(), split_id, buffer_id)
1150            };
1151
1152            // Determine hover state for this split's tabs
1153            let tab_hover_for_split = hovered_tab.and_then(|(hover_buf, hover_split, is_close)| {
1154                if hover_split == split_id {
1155                    Some((hover_buf, is_close))
1156                } else {
1157                    None
1158                }
1159            });
1160
1161            // Only render tabs and split control buttons when tab bar is visible
1162            if split_tab_bar_visible {
1163                // Determine the active target for this split's tab bar.
1164                // If the split's SplitViewState marks a group tab as active,
1165                // that's the active target; otherwise the currently displayed
1166                // buffer.
1167                let active_target = split_view_states
1168                    .as_deref()
1169                    .and_then(|svs| svs.get(&split_id))
1170                    .map(|vs| vs.active_target())
1171                    .unwrap_or(crate::view::split::TabTarget::Buffer(buffer_id));
1172                // Collect group names from the stashed Grouped subtrees.
1173                let group_names: HashMap<LeafId, String> = grouped_subtrees
1174                    .iter()
1175                    .filter_map(|(leaf_id, node)| {
1176                        if let crate::view::split::SplitNode::Grouped { name, .. } = node {
1177                            Some((*leaf_id, name.clone()))
1178                        } else {
1179                            None
1180                        }
1181                    })
1182                    .collect();
1183                // Render tabs for this split and collect hit areas
1184                let tab_layout = TabsRenderer::render_for_split(
1185                    frame,
1186                    layout.tabs_rect,
1187                    &split_buffers,
1188                    buffers,
1189                    buffer_metadata,
1190                    composite_buffers,
1191                    active_target,
1192                    theme,
1193                    is_active,
1194                    tab_scroll_offset,
1195                    tab_hover_for_split,
1196                    &group_names,
1197                );
1198
1199                // Store the tab layout for this split
1200                tab_layouts.insert(split_id, tab_layout);
1201                let tab_row = layout.tabs_rect.y;
1202
1203                // Render split control buttons at the right side of tabs row
1204                // Show maximize/unmaximize button when: multiple splits exist OR we're currently maximized
1205                // Show close button when: multiple splits exist AND we're not maximized
1206                let show_maximize_btn = has_multiple_splits || is_maximized;
1207                let show_close_btn = has_multiple_splits && !is_maximized;
1208
1209                if show_maximize_btn || show_close_btn {
1210                    // Calculate button positions from right edge
1211                    // Layout: [maximize] [space] [close] |
1212                    let mut btn_x = layout.tabs_rect.x + layout.tabs_rect.width.saturating_sub(2);
1213
1214                    // Render close button first (rightmost) if visible
1215                    if show_close_btn {
1216                        let is_hovered = hovered_close_split == Some(split_id);
1217                        let close_fg = if is_hovered {
1218                            theme.tab_close_hover_fg
1219                        } else {
1220                            theme.line_number_fg
1221                        };
1222                        let close_button = Paragraph::new("×")
1223                            .style(Style::default().fg(close_fg).bg(theme.tab_separator_bg));
1224                        let close_area = Rect::new(btn_x, tab_row, 1, 1);
1225                        frame.render_widget(close_button, close_area);
1226                        close_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
1227                        btn_x = btn_x.saturating_sub(2); // Move left with 1 space for next button
1228                    }
1229
1230                    // Render maximize/unmaximize button
1231                    if show_maximize_btn {
1232                        let is_hovered = hovered_maximize_split == Some(split_id);
1233                        let max_fg = if is_hovered {
1234                            theme.tab_close_hover_fg
1235                        } else {
1236                            theme.line_number_fg
1237                        };
1238                        // Use □ for maximize, ⧉ for unmaximize (restore)
1239                        let icon = if is_maximized { "⧉" } else { "□" };
1240                        let max_button = Paragraph::new(icon)
1241                            .style(Style::default().fg(max_fg).bg(theme.tab_separator_bg));
1242                        let max_area = Rect::new(btn_x, tab_row, 1, 1);
1243                        frame.render_widget(max_button, max_area);
1244                        maximize_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
1245                    }
1246                }
1247            }
1248
1249            // For GroupTabBarOnly entries we've already rendered the tab bar;
1250            // skip buffer content rendering so the group's inner leaves can
1251            // draw into the content rect without being overwritten.
1252            if skip_content {
1253                view_line_mappings.insert(split_id, Vec::new());
1254                continue;
1255            }
1256
1257            // Get references separately to avoid double borrow
1258            let state_opt = buffers.get_mut(&buffer_id);
1259            let event_log_opt = event_logs.get_mut(&buffer_id);
1260
1261            if let Some(state) = state_opt {
1262                // Check if this is a composite buffer - render differently
1263                if state.is_composite_buffer {
1264                    // Take initial_focus_hunk before borrowing composite immutably
1265                    let initial_focus_hunk = composite_buffers
1266                        .get_mut(&buffer_id)
1267                        .and_then(|c| c.initial_focus_hunk.take());
1268                    if let Some(composite) = composite_buffers.get(&buffer_id) {
1269                        // Update SplitViewState viewport to match actual rendered area
1270                        // This ensures cursor movement uses correct viewport height after resize
1271                        if let Some(ref mut svs) = split_view_states {
1272                            if let Some(split_vs) = svs.get_mut(&split_id) {
1273                                if split_vs.viewport.width != layout.content_rect.width
1274                                    || split_vs.viewport.height != layout.content_rect.height
1275                                {
1276                                    split_vs.viewport.resize(
1277                                        layout.content_rect.width,
1278                                        layout.content_rect.height,
1279                                    );
1280                                }
1281                            }
1282                        }
1283
1284                        // Get or create composite view state
1285                        let pane_count = composite.pane_count();
1286                        let view_state = composite_view_states
1287                            .entry((split_id, buffer_id))
1288                            .or_insert_with(|| {
1289                                crate::view::composite_view::CompositeViewState::new(
1290                                    buffer_id, pane_count,
1291                                )
1292                            });
1293
1294                        // Apply deferred initial focus hunk (first render only).
1295                        // This runs here because it's the only place where both the
1296                        // CompositeViewState and the correct viewport height exist.
1297                        if let Some(hunk_index) = initial_focus_hunk {
1298                            let mut target_row = None;
1299                            // Walk hunk headers to find the Nth one
1300                            let mut hunk_count = 0usize;
1301                            for (row_idx, row) in composite.alignment.rows.iter().enumerate() {
1302                                if row.row_type
1303                                    == crate::model::composite_buffer::RowType::HunkHeader
1304                                {
1305                                    if hunk_count == hunk_index {
1306                                        target_row = Some(row_idx);
1307                                        break;
1308                                    }
1309                                    hunk_count += 1;
1310                                }
1311                            }
1312                            if let Some(row) = target_row {
1313                                let viewport_height =
1314                                    layout.content_rect.height.saturating_sub(1) as usize;
1315                                let context_above = viewport_height / 3;
1316                                view_state.cursor_row = row;
1317                                view_state.scroll_row = row.saturating_sub(context_above);
1318                            }
1319                        }
1320
1321                        // Render composite buffer with side-by-side panes
1322                        Self::render_composite_buffer(
1323                            frame,
1324                            layout.content_rect,
1325                            composite,
1326                            buffers,
1327                            theme,
1328                            is_active,
1329                            view_state,
1330                            use_terminal_bg,
1331                            split_show_tilde,
1332                        );
1333
1334                        // Render scrollbar for composite buffer
1335                        let total_rows = composite.row_count();
1336                        let content_height = layout.content_rect.height.saturating_sub(1) as usize; // -1 for header
1337                        let (thumb_start, thumb_end) = if show_vertical_scrollbar {
1338                            Self::render_composite_scrollbar(
1339                                frame,
1340                                layout.scrollbar_rect,
1341                                total_rows,
1342                                view_state.scroll_row,
1343                                content_height,
1344                                is_active,
1345                            )
1346                        } else {
1347                            (0, 0)
1348                        };
1349
1350                        // Store the areas for mouse handling
1351                        split_areas.push((
1352                            split_id,
1353                            buffer_id,
1354                            layout.content_rect,
1355                            layout.scrollbar_rect,
1356                            thumb_start,
1357                            thumb_end,
1358                        ));
1359                        if show_horizontal_scrollbar {
1360                            horizontal_scrollbar_areas.push((
1361                                split_id,
1362                                buffer_id,
1363                                layout.horizontal_scrollbar_rect,
1364                                0, // composite buffers don't horizontal-scroll
1365                                0,
1366                                0,
1367                            ));
1368                        }
1369                    }
1370                    view_line_mappings.insert(split_id, Vec::new());
1371                    continue;
1372                }
1373
1374                // Get viewport from SplitViewState (authoritative source)
1375                // We need to get it mutably for sync operations
1376                // Use as_deref() to get Option<&HashMap> for read-only operations
1377                let view_state_opt = split_view_states
1378                    .as_deref()
1379                    .and_then(|vs| vs.get(&split_id));
1380                let viewport_clone =
1381                    view_state_opt
1382                        .map(|vs| vs.viewport.clone())
1383                        .unwrap_or_else(|| {
1384                            crate::view::viewport::Viewport::new(
1385                                layout.content_rect.width,
1386                                layout.content_rect.height,
1387                            )
1388                        });
1389                let mut viewport = viewport_clone;
1390
1391                // Get cursors from the split's view state
1392                let split_cursors = split_view_states
1393                    .as_deref()
1394                    .and_then(|vs| vs.get(&split_id))
1395                    .map(|vs| vs.cursors.clone())
1396                    .unwrap_or_default();
1397                // Resolve hidden fold byte ranges so ensure_visible can skip
1398                // folded lines when counting distance to the cursor.
1399                let hidden_ranges: Vec<(usize, usize)> = split_view_states
1400                    .as_deref()
1401                    .and_then(|vs| vs.get(&split_id))
1402                    .map(|vs| {
1403                        vs.folds
1404                            .resolved_ranges(&state.buffer, &state.marker_list)
1405                            .into_iter()
1406                            .map(|r| (r.start_byte, r.end_byte))
1407                            .collect()
1408                    })
1409                    .unwrap_or_default();
1410
1411                {
1412                    let _span = tracing::trace_span!("sync_viewport_to_content").entered();
1413                    Self::sync_viewport_to_content(
1414                        &mut viewport,
1415                        &mut state.buffer,
1416                        &split_cursors,
1417                        layout.content_rect,
1418                        &hidden_ranges,
1419                    );
1420                }
1421                let view_prefs =
1422                    Self::resolve_view_preferences(state, split_view_states.as_deref(), split_id);
1423
1424                // When cursors are hidden, also suppress current-line highlighting
1425                // and selection rendering so the buffer appears fully non-interactive.
1426                let effective_highlight_current_line =
1427                    view_prefs.highlight_current_line && state.show_cursors;
1428
1429                let mut empty_folds = FoldManager::new();
1430                let folds = split_view_states
1431                    .as_deref_mut()
1432                    .and_then(|vs| vs.get_mut(&split_id))
1433                    .map(|vs| &mut vs.folds)
1434                    .unwrap_or(&mut empty_folds);
1435
1436                let _render_buf_span = tracing::trace_span!("render_buffer_in_split").entered();
1437                let split_view_mappings = Self::render_buffer_in_split(
1438                    frame,
1439                    state,
1440                    &split_cursors,
1441                    &mut viewport,
1442                    folds,
1443                    event_log_opt,
1444                    layout.content_rect,
1445                    is_active,
1446                    theme,
1447                    ansi_background,
1448                    background_fade,
1449                    lsp_waiting,
1450                    view_prefs.view_mode,
1451                    view_prefs.compose_width,
1452                    view_prefs.compose_column_guides,
1453                    view_prefs.view_transform,
1454                    estimated_line_length,
1455                    highlight_context_bytes,
1456                    buffer_id,
1457                    hide_cursor,
1458                    relative_line_numbers,
1459                    use_terminal_bg,
1460                    session_mode,
1461                    software_cursor_only,
1462                    &view_prefs.rulers,
1463                    view_prefs.show_line_numbers,
1464                    effective_highlight_current_line,
1465                    diagnostics_inline_text,
1466                    split_show_tilde,
1467                    cell_theme_map,
1468                    screen_width,
1469                );
1470
1471                drop(_render_buf_span);
1472
1473                // Store view line mappings for mouse click handling
1474                view_line_mappings.insert(split_id, split_view_mappings);
1475
1476                // For small files, count actual lines for accurate scrollbar
1477                // For large files, we'll use a constant thumb size
1478                let buffer_len = state.buffer.len();
1479                let (total_lines, top_line) = {
1480                    let _span = tracing::trace_span!("scrollbar_line_counts").entered();
1481                    Self::scrollbar_line_counts(
1482                        state,
1483                        &viewport,
1484                        large_file_threshold_bytes,
1485                        buffer_len,
1486                    )
1487                };
1488
1489                // Render vertical scrollbar for this split and get thumb position
1490                let (thumb_start, thumb_end) = if show_vertical_scrollbar {
1491                    Self::render_scrollbar(
1492                        frame,
1493                        state,
1494                        &viewport,
1495                        layout.scrollbar_rect,
1496                        is_active,
1497                        theme,
1498                        large_file_threshold_bytes,
1499                        total_lines,
1500                        top_line,
1501                    )
1502                } else {
1503                    (0, 0)
1504                };
1505
1506                // Compute the actual max line length for horizontal scrollbar
1507                let max_content_width = if show_horizontal_scrollbar && !viewport.line_wrap_enabled
1508                {
1509                    let mcw = Self::compute_max_line_length(state, &mut viewport);
1510                    // Clamp left_column so content can't scroll past the end of the longest line
1511                    let visible_width = viewport.width as usize;
1512                    let max_scroll = mcw.saturating_sub(visible_width);
1513                    if viewport.left_column > max_scroll {
1514                        viewport.left_column = max_scroll;
1515                    }
1516                    mcw
1517                } else {
1518                    0
1519                };
1520
1521                // Render horizontal scrollbar for this split
1522                let (hthumb_start, hthumb_end) = if show_horizontal_scrollbar {
1523                    Self::render_horizontal_scrollbar(
1524                        frame,
1525                        &viewport,
1526                        layout.horizontal_scrollbar_rect,
1527                        is_active,
1528                        max_content_width,
1529                    )
1530                } else {
1531                    (0, 0)
1532                };
1533
1534                // Write back updated viewport to SplitViewState
1535                // This is crucial for cursor visibility tracking (ensure_visible_in_layout updates)
1536                // NOTE: We do NOT clear skip_ensure_visible here - it should persist across
1537                // renders until something actually needs cursor visibility check
1538                if let Some(view_states) = split_view_states.as_deref_mut() {
1539                    if let Some(view_state) = view_states.get_mut(&split_id) {
1540                        tracing::trace!(
1541                            "Writing back viewport: top_byte={}, skip_ensure_visible={}",
1542                            viewport.top_byte,
1543                            viewport.should_skip_ensure_visible()
1544                        );
1545                        view_state.viewport = viewport.clone();
1546                    }
1547                }
1548
1549                // Store the areas for mouse handling
1550                split_areas.push((
1551                    split_id,
1552                    buffer_id,
1553                    layout.content_rect,
1554                    layout.scrollbar_rect,
1555                    thumb_start,
1556                    thumb_end,
1557                ));
1558                if show_horizontal_scrollbar {
1559                    horizontal_scrollbar_areas.push((
1560                        split_id,
1561                        buffer_id,
1562                        layout.horizontal_scrollbar_rect,
1563                        max_content_width,
1564                        hthumb_start,
1565                        hthumb_end,
1566                    ));
1567                }
1568            }
1569        }
1570
1571        // Render split separators — for both the main tree and any
1572        // active Grouped subtrees dispatched at render time.
1573        let separators = split_manager.get_separators(area);
1574        for (direction, x, y, length) in separators {
1575            Self::render_separator(frame, direction, x, y, length, theme);
1576        }
1577        // Walk base_visible again to render internal separators of active
1578        // groups (the group's Split nodes live in the side-map, not in the
1579        // main split tree, so split_manager doesn't know about them).
1580        // Collect these separators with their container IDs so the hit-test
1581        // path in `app::render` can wire up dragging.
1582        let mut grouped_separator_areas: Vec<(
1583            crate::model::event::ContainerId,
1584            SplitDirection,
1585            u16,
1586            u16,
1587            u16,
1588        )> = Vec::new();
1589        for (main_split_id, _main_buffer_id, split_area) in &base_visible {
1590            let active_group = split_view_states
1591                .as_deref()
1592                .and_then(|svs| svs.get(main_split_id))
1593                .and_then(|vs| vs.active_group_tab);
1594            if let Some(group_leaf) = active_group {
1595                if let Some(grouped) = grouped_subtrees.get(&group_leaf) {
1596                    let split_tab_bar_visible = tab_bar_visible
1597                        && !split_view_states
1598                            .as_deref()
1599                            .and_then(|svs| svs.get(main_split_id))
1600                            .is_some_and(|vs| vs.suppress_chrome);
1601                    let main_layout = Self::split_layout(
1602                        *split_area,
1603                        split_tab_bar_visible,
1604                        show_vertical_scrollbar,
1605                        show_horizontal_scrollbar,
1606                    );
1607                    if let crate::view::split::SplitNode::Grouped { layout, .. } = grouped {
1608                        for (id, direction, x, y, length) in
1609                            layout.get_separators_with_ids(main_layout.content_rect)
1610                        {
1611                            Self::render_separator(frame, direction, x, y, length, theme);
1612                            grouped_separator_areas.push((id, direction, x, y, length));
1613                        }
1614                    }
1615                }
1616            }
1617        }
1618
1619        (
1620            split_areas,
1621            tab_layouts,
1622            close_split_areas,
1623            maximize_split_areas,
1624            view_line_mappings,
1625            horizontal_scrollbar_areas,
1626            grouped_separator_areas,
1627        )
1628    }
1629
1630    /// Layout-only path: computes view_line_mappings for all visible splits
1631    /// without drawing anything. Used by macro replay to keep the cached layout
1632    /// fresh between actions without paying the cost of full rendering.
1633    #[allow(clippy::too_many_arguments)]
1634    pub fn compute_content_layout(
1635        area: Rect,
1636        split_manager: &SplitManager,
1637        buffers: &mut HashMap<BufferId, EditorState>,
1638        split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
1639        theme: &crate::view::theme::Theme,
1640        lsp_waiting: bool,
1641        estimated_line_length: usize,
1642        highlight_context_bytes: usize,
1643        relative_line_numbers: bool,
1644        use_terminal_bg: bool,
1645        session_mode: bool,
1646        software_cursor_only: bool,
1647        tab_bar_visible: bool,
1648        show_vertical_scrollbar: bool,
1649        show_horizontal_scrollbar: bool,
1650        diagnostics_inline_text: bool,
1651        show_tilde: bool,
1652    ) -> HashMap<LeafId, Vec<ViewLineMapping>> {
1653        let visible_buffers = split_manager.get_visible_buffers(area);
1654        let active_split_id = split_manager.active_split();
1655        let mut view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>> = HashMap::new();
1656
1657        for (split_id, buffer_id, split_area) in visible_buffers {
1658            let is_active = split_id == active_split_id;
1659
1660            // Suppress chrome (tab bar) for splits in buffer groups
1661            let split_tab_bar_visible = tab_bar_visible
1662                && !split_view_states
1663                    .get(&split_id)
1664                    .map_or(false, |vs| vs.suppress_chrome);
1665
1666            let layout = Self::split_layout(
1667                split_area,
1668                split_tab_bar_visible,
1669                show_vertical_scrollbar,
1670                show_horizontal_scrollbar,
1671            );
1672
1673            let state = match buffers.get_mut(&buffer_id) {
1674                Some(s) => s,
1675                None => continue,
1676            };
1677
1678            // Skip composite buffers — they don't produce view_line_mappings
1679            if state.is_composite_buffer {
1680                view_line_mappings.insert(split_id, Vec::new());
1681                continue;
1682            }
1683
1684            // Get viewport from SplitViewState (authoritative source)
1685            let viewport_clone = split_view_states
1686                .get(&split_id)
1687                .map(|vs| vs.viewport.clone())
1688                .unwrap_or_else(|| {
1689                    crate::view::viewport::Viewport::new(
1690                        layout.content_rect.width,
1691                        layout.content_rect.height,
1692                    )
1693                });
1694            let mut viewport = viewport_clone;
1695
1696            // Get cursors from the split's view state
1697            let split_cursors = split_view_states
1698                .get(&split_id)
1699                .map(|vs| vs.cursors.clone())
1700                .unwrap_or_default();
1701            // Resolve hidden fold byte ranges so ensure_visible can skip
1702            // folded lines when counting distance to the cursor.
1703            let hidden_ranges: Vec<(usize, usize)> = split_view_states
1704                .get(&split_id)
1705                .map(|vs| {
1706                    vs.folds
1707                        .resolved_ranges(&state.buffer, &state.marker_list)
1708                        .into_iter()
1709                        .map(|r| (r.start_byte, r.end_byte))
1710                        .collect()
1711                })
1712                .unwrap_or_default();
1713
1714            Self::sync_viewport_to_content(
1715                &mut viewport,
1716                &mut state.buffer,
1717                &split_cursors,
1718                layout.content_rect,
1719                &hidden_ranges,
1720            );
1721            let view_prefs =
1722                Self::resolve_view_preferences(state, Some(&*split_view_states), split_id);
1723
1724            let effective_highlight_current_line =
1725                view_prefs.highlight_current_line && state.show_cursors;
1726
1727            let mut empty_folds = FoldManager::new();
1728            let folds = split_view_states
1729                .get_mut(&split_id)
1730                .map(|vs| &mut vs.folds)
1731                .unwrap_or(&mut empty_folds);
1732
1733            let layout_output = Self::compute_buffer_layout(
1734                state,
1735                &split_cursors,
1736                &mut viewport,
1737                folds,
1738                layout.content_rect,
1739                is_active,
1740                theme,
1741                lsp_waiting,
1742                view_prefs.view_mode,
1743                view_prefs.compose_width,
1744                view_prefs.view_transform,
1745                estimated_line_length,
1746                highlight_context_bytes,
1747                relative_line_numbers,
1748                use_terminal_bg,
1749                session_mode,
1750                software_cursor_only,
1751                view_prefs.show_line_numbers,
1752                effective_highlight_current_line,
1753                diagnostics_inline_text,
1754                show_tilde,
1755                None, // No cell theme map for layout-only computation
1756            );
1757
1758            view_line_mappings.insert(split_id, layout_output.view_line_mappings);
1759
1760            // Write back updated viewport to SplitViewState
1761            if let Some(view_state) = split_view_states.get_mut(&split_id) {
1762                view_state.viewport = viewport;
1763            }
1764        }
1765
1766        view_line_mappings
1767    }
1768
1769    /// Render a split separator line
1770    fn render_separator(
1771        frame: &mut Frame,
1772        direction: SplitDirection,
1773        x: u16,
1774        y: u16,
1775        length: u16,
1776        theme: &crate::view::theme::Theme,
1777    ) {
1778        match direction {
1779            SplitDirection::Horizontal => {
1780                // Draw horizontal line
1781                let line_area = Rect::new(x, y, length, 1);
1782                let line_text = "─".repeat(length as usize);
1783                let paragraph =
1784                    Paragraph::new(line_text).style(Style::default().fg(theme.split_separator_fg));
1785                frame.render_widget(paragraph, line_area);
1786            }
1787            SplitDirection::Vertical => {
1788                // Draw vertical line
1789                for offset in 0..length {
1790                    let cell_area = Rect::new(x, y + offset, 1, 1);
1791                    let paragraph =
1792                        Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
1793                    frame.render_widget(paragraph, cell_area);
1794                }
1795            }
1796        }
1797    }
1798
1799    /// Render a composite buffer (side-by-side view of multiple source buffers)
1800    /// Uses ViewLines for proper syntax highlighting, ANSI handling, etc.
1801    #[allow(clippy::too_many_arguments)]
1802    fn render_composite_buffer(
1803        frame: &mut Frame,
1804        area: Rect,
1805        composite: &crate::model::composite_buffer::CompositeBuffer,
1806        buffers: &mut HashMap<BufferId, EditorState>,
1807        theme: &crate::view::theme::Theme,
1808        _is_active: bool,
1809        view_state: &mut crate::view::composite_view::CompositeViewState,
1810        use_terminal_bg: bool,
1811        show_tilde: bool,
1812    ) {
1813        use crate::model::composite_buffer::{CompositeLayout, RowType};
1814
1815        // Compute effective editor background: terminal default or theme-defined
1816        let effective_editor_bg = if use_terminal_bg {
1817            ratatui::style::Color::Reset
1818        } else {
1819            theme.editor_bg
1820        };
1821
1822        let scroll_row = view_state.scroll_row;
1823        let cursor_row = view_state.cursor_row;
1824
1825        // Clear the area first
1826        frame.render_widget(Clear, area);
1827
1828        // Calculate pane widths based on layout
1829        let pane_count = composite.sources.len();
1830        if pane_count == 0 {
1831            return;
1832        }
1833
1834        // Extract show_separator from layout
1835        let show_separator = match &composite.layout {
1836            CompositeLayout::SideBySide { show_separator, .. } => *show_separator,
1837            _ => false,
1838        };
1839
1840        // Calculate pane areas
1841        let separator_width = if show_separator { 1 } else { 0 };
1842        let total_separators = (pane_count.saturating_sub(1)) as u16 * separator_width;
1843        let available_width = area.width.saturating_sub(total_separators);
1844
1845        let pane_widths: Vec<u16> = match &composite.layout {
1846            CompositeLayout::SideBySide { ratios, .. } => {
1847                let default_ratio = 1.0 / pane_count as f32;
1848                ratios
1849                    .iter()
1850                    .chain(std::iter::repeat(&default_ratio))
1851                    .take(pane_count)
1852                    .map(|r| (available_width as f32 * r).round() as u16)
1853                    .collect()
1854            }
1855            _ => {
1856                // Equal widths for stacked/unified layouts
1857                let pane_width = available_width / pane_count as u16;
1858                vec![pane_width; pane_count]
1859            }
1860        };
1861
1862        // Store computed pane widths in view state for cursor movement calculations
1863        view_state.pane_widths = pane_widths.clone();
1864
1865        // Render headers first
1866        let header_height = 1u16;
1867        let mut x_offset = area.x;
1868        for (idx, (source, &width)) in composite.sources.iter().zip(&pane_widths).enumerate() {
1869            let header_area = Rect::new(x_offset, area.y, width, header_height);
1870            let is_focused = idx == view_state.focused_pane;
1871
1872            let header_style = if is_focused {
1873                Style::default()
1874                    .fg(theme.tab_active_fg)
1875                    .bg(theme.tab_active_bg)
1876            } else {
1877                Style::default()
1878                    .fg(theme.tab_inactive_fg)
1879                    .bg(theme.tab_inactive_bg)
1880            };
1881
1882            let header_text = format!(" {} ", source.label);
1883            let header = Paragraph::new(header_text).style(header_style);
1884            frame.render_widget(header, header_area);
1885
1886            x_offset += width + separator_width;
1887        }
1888
1889        // Content area (below headers)
1890        let content_y = area.y + header_height;
1891        let content_height = area.height.saturating_sub(header_height);
1892        let visible_rows = content_height as usize;
1893
1894        // Render aligned rows
1895        let alignment = &composite.alignment;
1896        let total_rows = alignment.rows.len();
1897
1898        // Build ViewData and get syntax highlighting for each pane
1899        // Store: (ViewLines, line->ViewLine mapping, highlight spans)
1900        struct PaneRenderData {
1901            lines: Vec<ViewLine>,
1902            line_to_view_line: HashMap<usize, usize>,
1903            highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
1904        }
1905
1906        let mut pane_render_data: Vec<Option<PaneRenderData>> = Vec::new();
1907
1908        for (pane_idx, source) in composite.sources.iter().enumerate() {
1909            if let Some(source_state) = buffers.get_mut(&source.buffer_id) {
1910                // Find the first and last source lines we need for this pane
1911                let visible_lines: Vec<usize> = alignment
1912                    .rows
1913                    .iter()
1914                    .skip(scroll_row)
1915                    .take(visible_rows)
1916                    .filter_map(|row| row.get_pane_line(pane_idx))
1917                    .map(|r| r.line)
1918                    .collect();
1919
1920                let first_line = visible_lines.iter().copied().min();
1921                let last_line = visible_lines.iter().copied().max();
1922
1923                if let (Some(first_line), Some(last_line)) = (first_line, last_line) {
1924                    // Get byte range for highlighting
1925                    let top_byte = source_state
1926                        .buffer
1927                        .line_start_offset(first_line)
1928                        .unwrap_or(0);
1929                    let end_byte = source_state
1930                        .buffer
1931                        .line_start_offset(last_line + 1)
1932                        .unwrap_or(source_state.buffer.len());
1933
1934                    // Get syntax highlighting spans from the highlighter
1935                    let highlight_spans = source_state.highlighter.highlight_viewport(
1936                        &source_state.buffer,
1937                        top_byte,
1938                        end_byte,
1939                        theme,
1940                        1024, // highlight_context_bytes
1941                    );
1942
1943                    // Create a temporary viewport for building view data
1944                    let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80);
1945                    let mut viewport =
1946                        crate::view::viewport::Viewport::new(pane_width, content_height);
1947                    viewport.top_byte = top_byte;
1948                    viewport.line_wrap_enabled = false;
1949
1950                    let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80) as usize;
1951                    let gutter_width = 4; // Line number width
1952                    let content_width = pane_width.saturating_sub(gutter_width);
1953
1954                    // Build ViewData for this pane
1955                    // Need enough lines to cover from first_line to last_line
1956                    let lines_needed = last_line - first_line + 10;
1957                    let empty_folds = FoldManager::new();
1958                    let view_data = Self::build_view_data(
1959                        source_state,
1960                        &viewport,
1961                        None,         // No view transform
1962                        80,           // estimated_line_length
1963                        lines_needed, // visible_count - enough to cover the range
1964                        false,        // line_wrap_enabled
1965                        content_width,
1966                        gutter_width,
1967                        &ViewMode::Source, // Composite view uses source mode
1968                        &empty_folds,
1969                        theme,
1970                    );
1971
1972                    // Build source_line -> ViewLine index mapping
1973                    let mut line_to_view_line: HashMap<usize, usize> = HashMap::new();
1974                    let mut current_line = first_line;
1975                    for (idx, view_line) in view_data.lines.iter().enumerate() {
1976                        if should_show_line_number(view_line) {
1977                            line_to_view_line.insert(current_line, idx);
1978                            current_line += 1;
1979                        }
1980                    }
1981
1982                    pane_render_data.push(Some(PaneRenderData {
1983                        lines: view_data.lines,
1984                        line_to_view_line,
1985                        highlight_spans,
1986                    }));
1987                } else {
1988                    pane_render_data.push(None);
1989                }
1990            } else {
1991                pane_render_data.push(None);
1992            }
1993        }
1994
1995        // Now render aligned rows using ViewLines
1996        for view_row in 0..visible_rows {
1997            let display_row = scroll_row + view_row;
1998            if display_row >= total_rows {
1999                if show_tilde {
2000                    // Fill with tildes for empty rows
2001                    let mut x = area.x;
2002                    for &width in &pane_widths {
2003                        let tilde_area = Rect::new(x, content_y + view_row as u16, width, 1);
2004                        let tilde =
2005                            Paragraph::new("~").style(Style::default().fg(theme.line_number_fg));
2006                        frame.render_widget(tilde, tilde_area);
2007                        x += width + separator_width;
2008                    }
2009                }
2010                continue;
2011            }
2012
2013            let aligned_row = &alignment.rows[display_row];
2014            let is_cursor_row = display_row == cursor_row;
2015            // Get selection column range for this row (if any)
2016            let selection_cols = view_state.selection_column_range(display_row);
2017
2018            // Determine row background based on type (selection is now character-level)
2019            let row_bg = match aligned_row.row_type {
2020                RowType::Addition => Some(theme.diff_add_bg),
2021                RowType::Deletion => Some(theme.diff_remove_bg),
2022                RowType::Modification => Some(theme.diff_modify_bg),
2023                RowType::HunkHeader => Some(theme.current_line_bg),
2024                RowType::Context => None,
2025            };
2026
2027            // Compute inline diff for modified rows (to highlight changed words/characters)
2028            let inline_diffs: Vec<Vec<Range<usize>>> = if aligned_row.row_type
2029                == RowType::Modification
2030            {
2031                // Get line content from both panes
2032                let mut line_contents: Vec<Option<String>> = Vec::new();
2033                for (pane_idx, source) in composite.sources.iter().enumerate() {
2034                    if let Some(line_ref) = aligned_row.get_pane_line(pane_idx) {
2035                        if let Some(source_state) = buffers.get(&source.buffer_id) {
2036                            line_contents.push(
2037                                source_state
2038                                    .buffer
2039                                    .get_line(line_ref.line)
2040                                    .map(|line| String::from_utf8_lossy(&line).to_string()),
2041                            );
2042                        } else {
2043                            line_contents.push(None);
2044                        }
2045                    } else {
2046                        line_contents.push(None);
2047                    }
2048                }
2049
2050                // Compute inline diff between panes (typically old vs new)
2051                if line_contents.len() >= 2 {
2052                    if let (Some(old_text), Some(new_text)) = (&line_contents[0], &line_contents[1])
2053                    {
2054                        let (old_ranges, new_ranges) = compute_inline_diff(old_text, new_text);
2055                        vec![old_ranges, new_ranges]
2056                    } else {
2057                        vec![Vec::new(); composite.sources.len()]
2058                    }
2059                } else {
2060                    vec![Vec::new(); composite.sources.len()]
2061                }
2062            } else {
2063                // For non-modification rows, no inline highlighting
2064                vec![Vec::new(); composite.sources.len()]
2065            };
2066
2067            // Render each pane for this row
2068            let mut x_offset = area.x;
2069            for (pane_idx, (_source, &width)) in
2070                composite.sources.iter().zip(&pane_widths).enumerate()
2071            {
2072                let pane_area = Rect::new(x_offset, content_y + view_row as u16, width, 1);
2073
2074                // Get horizontal scroll offset for this pane
2075                let left_column = view_state
2076                    .get_pane_viewport(pane_idx)
2077                    .map(|v| v.left_column)
2078                    .unwrap_or(0);
2079
2080                // Get source line for this pane
2081                let source_line_opt = aligned_row.get_pane_line(pane_idx);
2082
2083                if let Some(source_line_ref) = source_line_opt {
2084                    // Try to get ViewLine and highlight spans from pre-built data
2085                    let pane_data = pane_render_data.get(pane_idx).and_then(|opt| opt.as_ref());
2086                    let view_line_opt = pane_data.and_then(|data| {
2087                        data.line_to_view_line
2088                            .get(&source_line_ref.line)
2089                            .and_then(|&idx| data.lines.get(idx))
2090                    });
2091                    let highlight_spans = pane_data
2092                        .map(|data| data.highlight_spans.as_slice())
2093                        .unwrap_or(&[]);
2094
2095                    let gutter_width = 4usize;
2096                    let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
2097
2098                    let is_focused_pane = pane_idx == view_state.focused_pane;
2099
2100                    // Determine background - cursor row highlight only on focused pane
2101                    // Selection is now character-level, handled in render_view_line_content
2102                    let bg = if is_cursor_row && is_focused_pane {
2103                        theme.current_line_bg
2104                    } else {
2105                        row_bg.unwrap_or(effective_editor_bg)
2106                    };
2107
2108                    // Selection range for this row (only for focused pane)
2109                    let pane_selection_cols = if is_focused_pane {
2110                        selection_cols
2111                    } else {
2112                        None
2113                    };
2114
2115                    // Line number
2116                    let line_num = format!("{:>3} ", source_line_ref.line + 1);
2117                    let line_num_style = Style::default().fg(theme.line_number_fg).bg(bg);
2118
2119                    let is_cursor_pane = is_focused_pane;
2120                    let cursor_column = view_state.cursor_column;
2121
2122                    // Get inline diff ranges for this pane
2123                    let inline_ranges = inline_diffs.get(pane_idx).cloned().unwrap_or_default();
2124
2125                    // Determine highlight color for changed portions (brighter than line bg)
2126                    let highlight_bg = match aligned_row.row_type {
2127                        RowType::Deletion => Some(theme.diff_remove_highlight_bg),
2128                        RowType::Addition => Some(theme.diff_add_highlight_bg),
2129                        RowType::Modification => {
2130                            if pane_idx == 0 {
2131                                Some(theme.diff_remove_highlight_bg)
2132                            } else {
2133                                Some(theme.diff_add_highlight_bg)
2134                            }
2135                        }
2136                        _ => None,
2137                    };
2138
2139                    // Build spans using ViewLine if available (for syntax highlighting)
2140                    let mut spans = vec![Span::styled(line_num, line_num_style)];
2141
2142                    if let Some(view_line) = view_line_opt {
2143                        // Use ViewLine for syntax-highlighted content
2144                        Self::render_view_line_content(
2145                            &mut spans,
2146                            view_line,
2147                            highlight_spans,
2148                            left_column,
2149                            max_content_width,
2150                            bg,
2151                            theme,
2152                            is_cursor_row && is_cursor_pane,
2153                            cursor_column,
2154                            &inline_ranges,
2155                            highlight_bg,
2156                            pane_selection_cols,
2157                        );
2158                    } else {
2159                        // This branch should be unreachable:
2160                        // - visible_lines is collected from the same range we iterate over
2161                        // - If source_line_ref exists, that line was in visible_lines
2162                        // - So pane_render_data exists and the line should be in the mapping
2163                        // - With line_wrap disabled, each source line = one ViewLine
2164                        tracing::warn!(
2165                            "ViewLine missing for composite buffer: pane={}, line={}, pane_data={}",
2166                            pane_idx,
2167                            source_line_ref.line,
2168                            pane_data.is_some()
2169                        );
2170                        // Graceful degradation: render empty content with background
2171                        let base_style = Style::default().fg(theme.editor_fg).bg(bg);
2172                        let padding = " ".repeat(max_content_width);
2173                        spans.push(Span::styled(padding, base_style));
2174                    }
2175
2176                    let line = Line::from(spans);
2177                    let para = Paragraph::new(line);
2178                    frame.render_widget(para, pane_area);
2179                } else {
2180                    // No content for this pane (padding/gap line)
2181                    let is_focused_pane = pane_idx == view_state.focused_pane;
2182                    // For empty lines in focused pane, show selection if entire line is selected
2183                    let pane_has_selection = is_focused_pane
2184                        && selection_cols
2185                            .map(|(start, end)| start == 0 && end == usize::MAX)
2186                            .unwrap_or(false);
2187
2188                    let bg = if pane_has_selection {
2189                        theme.selection_bg
2190                    } else if is_cursor_row && is_focused_pane {
2191                        theme.current_line_bg
2192                    } else {
2193                        row_bg.unwrap_or(effective_editor_bg)
2194                    };
2195                    let style = Style::default().fg(theme.line_number_fg).bg(bg);
2196
2197                    // Check if cursor should be shown on this empty line
2198                    let is_cursor_pane = pane_idx == view_state.focused_pane;
2199                    if is_cursor_row && is_cursor_pane && view_state.cursor_column == 0 {
2200                        // Show cursor on empty line
2201                        let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
2202                        let gutter_width = 4usize;
2203                        let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
2204                        let padding = " ".repeat(max_content_width.saturating_sub(1));
2205                        let line = Line::from(vec![
2206                            Span::styled("    ", style),
2207                            Span::styled(" ", cursor_style),
2208                            Span::styled(padding, Style::default().bg(bg)),
2209                        ]);
2210                        let para = Paragraph::new(line);
2211                        frame.render_widget(para, pane_area);
2212                    } else {
2213                        // Empty gap line with diff background
2214                        let gap_style = Style::default().bg(bg);
2215                        let empty_content = " ".repeat(width as usize);
2216                        let para = Paragraph::new(empty_content).style(gap_style);
2217                        frame.render_widget(para, pane_area);
2218                    }
2219                }
2220
2221                x_offset += width;
2222
2223                // Render separator
2224                if show_separator && pane_idx < pane_count - 1 {
2225                    let sep_area =
2226                        Rect::new(x_offset, content_y + view_row as u16, separator_width, 1);
2227                    let sep =
2228                        Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
2229                    frame.render_widget(sep, sep_area);
2230                    x_offset += separator_width;
2231                }
2232            }
2233        }
2234    }
2235
2236    /// Render ViewLine content with syntax highlighting to spans
2237    #[allow(clippy::too_many_arguments)]
2238    fn render_view_line_content(
2239        spans: &mut Vec<Span<'static>>,
2240        view_line: &ViewLine,
2241        highlight_spans: &[crate::primitives::highlighter::HighlightSpan],
2242        left_column: usize,
2243        max_width: usize,
2244        bg: Color,
2245        theme: &crate::view::theme::Theme,
2246        show_cursor: bool,
2247        cursor_column: usize,
2248        inline_ranges: &[Range<usize>],
2249        highlight_bg: Option<Color>,
2250        selection_cols: Option<(usize, usize)>, // (start_col, end_col) for selection
2251    ) {
2252        let text = &view_line.text;
2253        let char_source_bytes = &view_line.char_source_bytes;
2254
2255        // Apply horizontal scroll and collect visible characters with styles
2256        let chars: Vec<char> = text.chars().collect();
2257        let mut col = 0usize;
2258        let mut rendered = 0usize;
2259        let mut current_span_text = String::new();
2260        let mut current_style: Option<Style> = None;
2261        let mut hl_cursor = 0usize;
2262
2263        for (char_idx, ch) in chars.iter().enumerate() {
2264            let char_width = char_width(*ch);
2265
2266            // Skip characters before left_column
2267            if col < left_column {
2268                col += char_width;
2269                continue;
2270            }
2271
2272            // Stop if we've rendered enough
2273            if rendered >= max_width {
2274                break;
2275            }
2276
2277            // Get source byte position for this character
2278            let byte_pos = char_source_bytes.get(char_idx).and_then(|b| *b);
2279
2280            // Get syntax highlight color via cursor-based O(1) lookup
2281            let highlight_color =
2282                byte_pos.and_then(|bp| span_color_at(highlight_spans, &mut hl_cursor, bp));
2283
2284            // Check if this character is in an inline diff range
2285            let in_inline_range = inline_ranges.iter().any(|r| r.contains(&char_idx));
2286
2287            // Check if this character is in selection range
2288            let in_selection = selection_cols
2289                .map(|(start, end)| col >= start && col < end)
2290                .unwrap_or(false);
2291
2292            // Determine background: selection > inline diff > normal
2293            let char_bg = if in_selection {
2294                theme.selection_bg
2295            } else if in_inline_range {
2296                highlight_bg.unwrap_or(bg)
2297            } else {
2298                bg
2299            };
2300
2301            // Build character style
2302            let char_style = if let Some(color) = highlight_color {
2303                Style::default().fg(color).bg(char_bg)
2304            } else {
2305                Style::default().fg(theme.editor_fg).bg(char_bg)
2306            };
2307
2308            // Handle cursor - cursor_column is absolute position, compare directly with col
2309            let final_style = if show_cursor && col == cursor_column {
2310                // Invert colors for cursor
2311                Style::default().fg(theme.editor_bg).bg(theme.editor_fg)
2312            } else {
2313                char_style
2314            };
2315
2316            // Accumulate or flush spans based on style changes
2317            if let Some(style) = current_style {
2318                if style != final_style && !current_span_text.is_empty() {
2319                    spans.push(Span::styled(std::mem::take(&mut current_span_text), style));
2320                }
2321            }
2322
2323            current_style = Some(final_style);
2324            current_span_text.push(*ch);
2325            col += char_width;
2326            rendered += char_width;
2327        }
2328
2329        // Flush remaining span
2330        if !current_span_text.is_empty() {
2331            if let Some(style) = current_style {
2332                spans.push(Span::styled(current_span_text, style));
2333            }
2334        }
2335
2336        // Pad to fill width
2337        if rendered < max_width {
2338            let padding_len = max_width - rendered;
2339            // cursor_column is absolute, convert to visual position for padding check
2340            let cursor_visual = cursor_column.saturating_sub(left_column);
2341
2342            // Check if cursor is in the padding area (past end of line content)
2343            if show_cursor && cursor_visual >= rendered && cursor_visual < max_width {
2344                // Cursor is in padding area - render cursor as single char
2345                let cursor_offset = cursor_visual - rendered;
2346                let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
2347                let normal_style = Style::default().bg(bg);
2348
2349                // Pre-cursor padding (if cursor is not at start of padding)
2350                if cursor_offset > 0 {
2351                    spans.push(Span::styled(" ".repeat(cursor_offset), normal_style));
2352                }
2353                // Single-char cursor
2354                spans.push(Span::styled(" ", cursor_style));
2355                // Post-cursor padding
2356                let remaining = padding_len.saturating_sub(cursor_offset + 1);
2357                if remaining > 0 {
2358                    spans.push(Span::styled(" ".repeat(remaining), normal_style));
2359                }
2360            } else {
2361                // No cursor in padding - just fill with background
2362                spans.push(Span::styled(
2363                    " ".repeat(padding_len),
2364                    Style::default().bg(bg),
2365                ));
2366            }
2367        }
2368    }
2369
2370    /// Render a scrollbar for composite buffer views
2371    fn render_composite_scrollbar(
2372        frame: &mut Frame,
2373        scrollbar_rect: Rect,
2374        total_rows: usize,
2375        scroll_row: usize,
2376        viewport_height: usize,
2377        is_active: bool,
2378    ) -> (usize, usize) {
2379        let height = scrollbar_rect.height as usize;
2380        if height == 0 || total_rows == 0 {
2381            return (0, 0);
2382        }
2383
2384        // Calculate thumb size based on viewport ratio to total document
2385        let thumb_size_raw = if total_rows > 0 {
2386            ((viewport_height as f64 / total_rows as f64) * height as f64).ceil() as usize
2387        } else {
2388            1
2389        };
2390
2391        // Maximum scroll position
2392        let max_scroll = total_rows.saturating_sub(viewport_height);
2393
2394        // When content fits in viewport, fill entire scrollbar
2395        let thumb_size = if max_scroll == 0 {
2396            height
2397        } else {
2398            // Cap thumb size: minimum 1, maximum 80% of scrollbar height
2399            let max_thumb_size = (height as f64 * 0.8).floor() as usize;
2400            thumb_size_raw.max(1).min(max_thumb_size).min(height)
2401        };
2402
2403        // Calculate thumb position
2404        let thumb_start = if max_scroll > 0 {
2405            let scroll_ratio = scroll_row.min(max_scroll) as f64 / max_scroll as f64;
2406            let max_thumb_start = height.saturating_sub(thumb_size);
2407            (scroll_ratio * max_thumb_start as f64) as usize
2408        } else {
2409            0
2410        };
2411
2412        let thumb_end = thumb_start + thumb_size;
2413
2414        // Choose colors based on whether split is active
2415        let track_color = if is_active {
2416            Color::DarkGray
2417        } else {
2418            Color::Black
2419        };
2420        let thumb_color = if is_active {
2421            Color::Gray
2422        } else {
2423            Color::DarkGray
2424        };
2425
2426        // Render as background fills
2427        for row in 0..height {
2428            let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
2429
2430            let style = if row >= thumb_start && row < thumb_end {
2431                Style::default().bg(thumb_color)
2432            } else {
2433                Style::default().bg(track_color)
2434            };
2435
2436            let paragraph = Paragraph::new(" ").style(style);
2437            frame.render_widget(paragraph, cell_area);
2438        }
2439
2440        (thumb_start, thumb_end)
2441    }
2442
2443    fn split_layout(
2444        split_area: Rect,
2445        tab_bar_visible: bool,
2446        show_vertical_scrollbar: bool,
2447        show_horizontal_scrollbar: bool,
2448    ) -> SplitLayout {
2449        let tabs_height = if tab_bar_visible { 1u16 } else { 0u16 };
2450        let scrollbar_width = if show_vertical_scrollbar { 1u16 } else { 0u16 };
2451        let hscrollbar_height = if show_horizontal_scrollbar {
2452            1u16
2453        } else {
2454            0u16
2455        };
2456
2457        let tabs_rect = Rect::new(split_area.x, split_area.y, split_area.width, tabs_height);
2458        let content_rect = Rect::new(
2459            split_area.x,
2460            split_area.y + tabs_height,
2461            split_area.width.saturating_sub(scrollbar_width),
2462            split_area
2463                .height
2464                .saturating_sub(tabs_height)
2465                .saturating_sub(hscrollbar_height),
2466        );
2467        let scrollbar_rect = Rect::new(
2468            split_area.x + split_area.width.saturating_sub(scrollbar_width),
2469            split_area.y + tabs_height,
2470            scrollbar_width,
2471            split_area
2472                .height
2473                .saturating_sub(tabs_height)
2474                .saturating_sub(hscrollbar_height),
2475        );
2476        let horizontal_scrollbar_rect = Rect::new(
2477            split_area.x,
2478            split_area.y + split_area.height.saturating_sub(hscrollbar_height),
2479            split_area.width.saturating_sub(scrollbar_width),
2480            hscrollbar_height,
2481        );
2482
2483        SplitLayout {
2484            tabs_rect,
2485            content_rect,
2486            scrollbar_rect,
2487            horizontal_scrollbar_rect,
2488        }
2489    }
2490
2491    fn split_buffers_for_tabs(
2492        split_view_states: Option<&HashMap<LeafId, crate::view::split::SplitViewState>>,
2493        split_id: LeafId,
2494        buffer_id: BufferId,
2495    ) -> (Vec<crate::view::split::TabTarget>, usize) {
2496        if let Some(view_states) = split_view_states {
2497            if let Some(view_state) = view_states.get(&split_id) {
2498                return (
2499                    view_state.open_buffers.clone(),
2500                    view_state.tab_scroll_offset,
2501                );
2502            }
2503        }
2504        (vec![crate::view::split::TabTarget::Buffer(buffer_id)], 0)
2505    }
2506
2507    fn sync_viewport_to_content(
2508        viewport: &mut crate::view::viewport::Viewport,
2509        buffer: &mut crate::model::buffer::Buffer,
2510        cursors: &crate::model::cursor::Cursors,
2511        content_rect: Rect,
2512        hidden_ranges: &[(usize, usize)],
2513    ) {
2514        let size_changed =
2515            viewport.width != content_rect.width || viewport.height != content_rect.height;
2516
2517        if size_changed {
2518            viewport.resize(content_rect.width, content_rect.height);
2519        }
2520
2521        // Always sync viewport with cursor to ensure visibility after cursor movements
2522        // The sync_with_cursor method internally checks needs_sync and skip_resize_sync
2523        // so this is safe to call unconditionally. Previously needs_sync was set by
2524        // EditorState.apply() but now viewport is owned by SplitViewState.
2525        let primary = *cursors.primary();
2526        viewport.ensure_visible(buffer, &primary, hidden_ranges);
2527    }
2528
2529    fn resolve_view_preferences(
2530        _state: &EditorState,
2531        split_view_states: Option<&HashMap<LeafId, crate::view::split::SplitViewState>>,
2532        split_id: LeafId,
2533    ) -> ViewPreferences {
2534        if let Some(view_states) = split_view_states {
2535            if let Some(view_state) = view_states.get(&split_id) {
2536                return ViewPreferences {
2537                    view_mode: view_state.view_mode.clone(),
2538                    compose_width: view_state.compose_width,
2539                    compose_column_guides: view_state.compose_column_guides.clone(),
2540                    view_transform: view_state.view_transform.clone(),
2541                    rulers: view_state.rulers.clone(),
2542                    show_line_numbers: view_state.show_line_numbers,
2543                    highlight_current_line: view_state.highlight_current_line,
2544                };
2545            }
2546        }
2547
2548        // Fallback when no SplitViewState is available (shouldn't happen in practice)
2549        ViewPreferences {
2550            view_mode: ViewMode::Source,
2551            compose_width: None,
2552            compose_column_guides: None,
2553            view_transform: None,
2554            rulers: Vec::new(),
2555            show_line_numbers: true,
2556            highlight_current_line: true,
2557        }
2558    }
2559
2560    fn scrollbar_line_counts(
2561        state: &mut EditorState,
2562        viewport: &crate::view::viewport::Viewport,
2563        large_file_threshold_bytes: u64,
2564        buffer_len: usize,
2565    ) -> (usize, usize) {
2566        if buffer_len > large_file_threshold_bytes as usize {
2567            return (0, 0);
2568        }
2569
2570        // When line wrapping is enabled, count visual rows instead of logical lines
2571        if viewport.line_wrap_enabled {
2572            return Self::scrollbar_visual_row_counts(state, viewport, buffer_len);
2573        }
2574
2575        let total_lines = if buffer_len > 0 {
2576            state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
2577        } else {
2578            1
2579        };
2580
2581        let top_line = if viewport.top_byte < buffer_len {
2582            state.buffer.get_line_number(viewport.top_byte)
2583        } else {
2584            0
2585        };
2586
2587        (total_lines, top_line)
2588    }
2589
2590    /// Calculate scrollbar position based on visual rows (for line-wrapped content)
2591    /// Returns (total_visual_rows, top_visual_row)
2592    ///
2593    /// Uses a cache to avoid re-wrapping every line on each frame. The cache is
2594    /// invalidated when the buffer version, viewport width, or wrap settings change.
2595    /// When only top_byte changes (scrolling), the cached total_visual_rows is reused
2596    /// and only the top_visual_row is recomputed.
2597    fn scrollbar_visual_row_counts(
2598        state: &mut EditorState,
2599        viewport: &crate::view::viewport::Viewport,
2600        buffer_len: usize,
2601    ) -> (usize, usize) {
2602        use crate::primitives::line_wrapping::{wrap_line, WrapConfig};
2603
2604        if buffer_len == 0 {
2605            return (1, 0);
2606        }
2607
2608        let buf_version = state.buffer.version();
2609        let cache = &state.scrollbar_row_cache;
2610
2611        // Check if the cache is valid: same buffer version, viewport width, and wrap settings
2612        let cache_fully_valid = cache.valid
2613            && cache.buffer_version == buf_version
2614            && cache.viewport_width == viewport.width
2615            && cache.wrap_indent == viewport.wrap_indent
2616            && cache.top_byte == viewport.top_byte
2617            && cache.top_view_line_offset == viewport.top_view_line_offset;
2618
2619        if cache_fully_valid {
2620            return (cache.total_visual_rows, cache.top_visual_row);
2621        }
2622
2623        // Check if we can reuse the total_visual_rows (only top_byte changed)
2624        let total_rows_valid = cache.valid
2625            && cache.buffer_version == buf_version
2626            && cache.viewport_width == viewport.width
2627            && cache.wrap_indent == viewport.wrap_indent;
2628
2629        let gutter_width = viewport.gutter_width(&state.buffer);
2630        let wrap_config = WrapConfig::new(
2631            viewport.width as usize,
2632            gutter_width,
2633            true,
2634            viewport.wrap_indent,
2635        );
2636
2637        let line_count = state
2638            .buffer
2639            .line_count()
2640            .unwrap_or_else(|| (buffer_len / state.buffer.estimated_line_length()).max(1));
2641
2642        let mut total_visual_rows = 0;
2643        let mut top_visual_row = 0;
2644        let mut found_top = false;
2645
2646        if total_rows_valid {
2647            // Buffer hasn't changed — only need to find the new top_visual_row
2648            total_visual_rows = cache.total_visual_rows;
2649            for line_idx in 0..line_count {
2650                let line_start = state
2651                    .buffer
2652                    .line_start_offset(line_idx)
2653                    .unwrap_or(buffer_len);
2654
2655                if line_start >= viewport.top_byte {
2656                    top_visual_row = total_visual_rows.min(
2657                        // We actually need to count rows up to this line
2658                        // so fall through to full computation below
2659                        0,
2660                    );
2661                    // Can't shortcut top_visual_row without counting rows up to here.
2662                    // Fall through to full computation.
2663                    break;
2664                }
2665            }
2666            // We need the row count up to top_byte, so do a partial scan
2667            let mut rows_before_top = 0;
2668            for line_idx in 0..line_count {
2669                let line_start = state
2670                    .buffer
2671                    .line_start_offset(line_idx)
2672                    .unwrap_or(buffer_len);
2673
2674                if line_start >= viewport.top_byte {
2675                    top_visual_row = rows_before_top + viewport.top_view_line_offset;
2676                    found_top = true;
2677                    break;
2678                }
2679
2680                let line_content = if let Some(bytes) = state.buffer.get_line(line_idx) {
2681                    String::from_utf8_lossy(&bytes)
2682                        .trim_end_matches('\n')
2683                        .trim_end_matches('\r')
2684                        .to_string()
2685                } else {
2686                    break;
2687                };
2688
2689                let segments = wrap_line(&line_content, &wrap_config);
2690                rows_before_top += segments.len().max(1);
2691            }
2692
2693            if !found_top {
2694                top_visual_row = total_visual_rows.saturating_sub(1);
2695            }
2696        } else {
2697            // Full recomputation needed
2698            for line_idx in 0..line_count {
2699                let line_start = state
2700                    .buffer
2701                    .line_start_offset(line_idx)
2702                    .unwrap_or(buffer_len);
2703
2704                if !found_top && line_start >= viewport.top_byte {
2705                    top_visual_row = total_visual_rows + viewport.top_view_line_offset;
2706                    found_top = true;
2707                }
2708
2709                let line_content = if let Some(bytes) = state.buffer.get_line(line_idx) {
2710                    String::from_utf8_lossy(&bytes)
2711                        .trim_end_matches('\n')
2712                        .trim_end_matches('\r')
2713                        .to_string()
2714                } else {
2715                    break;
2716                };
2717
2718                let segments = wrap_line(&line_content, &wrap_config);
2719                total_visual_rows += segments.len().max(1);
2720            }
2721
2722            if !found_top {
2723                top_visual_row = total_visual_rows.saturating_sub(1);
2724            }
2725
2726            total_visual_rows = total_visual_rows.max(1);
2727        }
2728
2729        // Update cache
2730        state.scrollbar_row_cache = crate::state::ScrollbarRowCache {
2731            buffer_version: buf_version,
2732            viewport_width: viewport.width,
2733            wrap_indent: viewport.wrap_indent,
2734            total_visual_rows,
2735            top_byte: viewport.top_byte,
2736            top_visual_row,
2737            top_view_line_offset: viewport.top_view_line_offset,
2738            valid: true,
2739        };
2740
2741        (total_visual_rows, top_visual_row)
2742    }
2743
2744    /// Render a scrollbar for a split
2745    /// Returns (thumb_start, thumb_end) positions for mouse hit testing
2746    #[allow(clippy::too_many_arguments)]
2747    fn render_scrollbar(
2748        frame: &mut Frame,
2749        state: &EditorState,
2750        viewport: &crate::view::viewport::Viewport,
2751        scrollbar_rect: Rect,
2752        is_active: bool,
2753        _theme: &crate::view::theme::Theme,
2754        large_file_threshold_bytes: u64,
2755        total_lines: usize,
2756        top_line: usize,
2757    ) -> (usize, usize) {
2758        let height = scrollbar_rect.height as usize;
2759        if height == 0 {
2760            return (0, 0);
2761        }
2762
2763        let buffer_len = state.buffer.len();
2764        let viewport_top = viewport.top_byte;
2765        // Use the scrollbar height as the viewport height for line count calculations.
2766        // This represents the actual number of visible content rows, which matches
2767        // what the user sees. When line wrapping is enabled, total_lines represents
2768        // visual rows, so we need to compare against actual visible rows (height),
2769        // not the full terminal height (viewport.height includes menu, status bar, etc.)
2770        let viewport_height_lines = height;
2771
2772        // Calculate scrollbar thumb position and size
2773        let (thumb_start, thumb_size) = if buffer_len > large_file_threshold_bytes as usize {
2774            // Large file: use constant 1-character thumb for performance
2775            let thumb_start = if buffer_len > 0 {
2776                ((viewport_top as f64 / buffer_len as f64) * height as f64) as usize
2777            } else {
2778                0
2779            };
2780            (thumb_start, 1)
2781        } else {
2782            // Small file: use actual line count for accurate scrollbar
2783            // total_lines and top_line are passed in (already calculated with mutable access)
2784
2785            // Calculate thumb size based on viewport ratio to total document
2786            let thumb_size_raw = if total_lines > 0 {
2787                ((viewport_height_lines as f64 / total_lines as f64) * height as f64).ceil()
2788                    as usize
2789            } else {
2790                1
2791            };
2792
2793            // Calculate the maximum scroll position first to determine if buffer fits in viewport
2794            // The maximum scroll position is when the last line of the file is at
2795            // the bottom of the viewport, i.e., max_scroll_line = total_lines - viewport_height
2796            let max_scroll_line = total_lines.saturating_sub(viewport_height_lines);
2797
2798            // When buffer fits entirely in viewport (no scrolling possible),
2799            // fill the entire scrollbar to make it obvious to the user
2800            let thumb_size = if max_scroll_line == 0 {
2801                height
2802            } else {
2803                // Cap thumb size: minimum 1, maximum 80% of scrollbar height
2804                let max_thumb_size = (height as f64 * 0.8).floor() as usize;
2805                thumb_size_raw.max(1).min(max_thumb_size).min(height)
2806            };
2807
2808            // Calculate thumb position using proper linear mapping:
2809            // - At line 0: thumb_start = 0
2810            // - At max scroll position: thumb_start = height - thumb_size
2811            let thumb_start = if max_scroll_line > 0 {
2812                // Linear interpolation from 0 to (height - thumb_size)
2813                let scroll_ratio = top_line.min(max_scroll_line) as f64 / max_scroll_line as f64;
2814                let max_thumb_start = height.saturating_sub(thumb_size);
2815                (scroll_ratio * max_thumb_start as f64) as usize
2816            } else {
2817                // File fits in viewport, thumb fills entire height starting at top
2818                0
2819            };
2820
2821            (thumb_start, thumb_size)
2822        };
2823
2824        let thumb_end = thumb_start + thumb_size;
2825
2826        // Choose colors based on whether split is active
2827        let track_color = if is_active {
2828            Color::DarkGray
2829        } else {
2830            Color::Black
2831        };
2832        let thumb_color = if is_active {
2833            Color::Gray
2834        } else {
2835            Color::DarkGray
2836        };
2837
2838        // Render as background fills to avoid glyph gaps in terminals like Apple Terminal.
2839        for row in 0..height {
2840            let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
2841
2842            let style = if row >= thumb_start && row < thumb_end {
2843                // Thumb
2844                Style::default().bg(thumb_color)
2845            } else {
2846                // Track
2847                Style::default().bg(track_color)
2848            };
2849
2850            let paragraph = Paragraph::new(" ").style(style);
2851            frame.render_widget(paragraph, cell_area);
2852        }
2853
2854        // Return thumb position for mouse hit testing
2855        (thumb_start, thumb_end)
2856    }
2857
2858    /// Compute the maximum line length encountered so far (in display columns).
2859    /// Only scans the currently visible lines (plus a small margin) and updates
2860    /// the running maximum stored in the viewport. This avoids scanning the
2861    /// entire file, which would break large file support.
2862    fn compute_max_line_length(
2863        state: &mut EditorState,
2864        viewport: &mut crate::view::viewport::Viewport,
2865    ) -> usize {
2866        let buffer_len = state.buffer.len();
2867        let visible_width = viewport.width as usize;
2868
2869        if buffer_len == 0 {
2870            return viewport.max_line_length_seen.max(visible_width);
2871        }
2872
2873        // Scan only the visible lines (with a small margin) to update the running maximum
2874        let visible_lines = viewport.height as usize + 5; // small margin beyond visible area
2875        let mut lines_scanned = 0usize;
2876        let mut iter = state.buffer.line_iterator(viewport.top_byte, 80);
2877        loop {
2878            if lines_scanned >= visible_lines {
2879                break;
2880            }
2881            match iter.next_line() {
2882                Some((_byte_offset, content)) => {
2883                    let display_len = content.len();
2884                    if display_len > viewport.max_line_length_seen {
2885                        viewport.max_line_length_seen = display_len;
2886                    }
2887                    lines_scanned += 1;
2888                }
2889                None => break,
2890            }
2891        }
2892
2893        // Return at least visible_width (not left_column + visible_width) to avoid
2894        // a feedback loop where scrolling right increases the limit further
2895        viewport.max_line_length_seen.max(visible_width)
2896    }
2897
2898    /// Render a horizontal scrollbar for a split.
2899    /// `max_content_width` should be the actual max line length (from compute_max_line_length).
2900    /// Returns (thumb_start_col, thumb_end_col) for mouse hit testing.
2901    fn render_horizontal_scrollbar(
2902        frame: &mut Frame,
2903        viewport: &crate::view::viewport::Viewport,
2904        hscrollbar_rect: Rect,
2905        is_active: bool,
2906        max_content_width: usize,
2907    ) -> (usize, usize) {
2908        let width = hscrollbar_rect.width as usize;
2909        if width == 0 || hscrollbar_rect.height == 0 {
2910            return (0, 0);
2911        }
2912
2913        let track_color = if is_active {
2914            Color::DarkGray
2915        } else {
2916            Color::Black
2917        };
2918
2919        // When line wrapping is enabled, render empty track
2920        if viewport.line_wrap_enabled {
2921            for col in 0..width {
2922                let cell_area = Rect::new(hscrollbar_rect.x + col as u16, hscrollbar_rect.y, 1, 1);
2923                let paragraph = Paragraph::new(" ").style(Style::default().bg(track_color));
2924                frame.render_widget(paragraph, cell_area);
2925            }
2926            return (0, width);
2927        }
2928
2929        let visible_width = viewport.width as usize;
2930        let left_column = viewport.left_column;
2931
2932        // If content fits entirely in viewport, fill the entire scrollbar with thumb
2933        let max_scroll = max_content_width.saturating_sub(visible_width);
2934
2935        let (thumb_start, thumb_size) = if max_scroll == 0 {
2936            (0, width)
2937        } else {
2938            // Calculate thumb size proportional to visible/total ratio
2939            let thumb_size_raw =
2940                ((visible_width as f64 / max_content_width as f64) * width as f64).ceil() as usize;
2941            let thumb_size = thumb_size_raw.max(2).min(width); // min 2 cols for visibility
2942
2943            // Calculate thumb position
2944            let scroll_ratio = left_column.min(max_scroll) as f64 / max_scroll as f64;
2945            let max_thumb_start = width.saturating_sub(thumb_size);
2946            let thumb_start = (scroll_ratio * max_thumb_start as f64).round() as usize;
2947
2948            (thumb_start, thumb_size)
2949        };
2950
2951        let thumb_end = thumb_start + thumb_size;
2952
2953        let thumb_color = if is_active {
2954            Color::Gray
2955        } else {
2956            Color::DarkGray
2957        };
2958
2959        // Render as background fills (horizontal)
2960        for col in 0..width {
2961            let cell_area = Rect::new(hscrollbar_rect.x + col as u16, hscrollbar_rect.y, 1, 1);
2962
2963            let style = if col >= thumb_start && col < thumb_end {
2964                Style::default().bg(thumb_color)
2965            } else {
2966                Style::default().bg(track_color)
2967            };
2968
2969            let paragraph = Paragraph::new(" ").style(style);
2970            frame.render_widget(paragraph, cell_area);
2971        }
2972
2973        (thumb_start, thumb_end)
2974    }
2975
2976    #[allow(clippy::too_many_arguments)]
2977    fn build_view_data(
2978        state: &mut EditorState,
2979        viewport: &crate::view::viewport::Viewport,
2980        view_transform: Option<ViewTransformPayload>,
2981        estimated_line_length: usize,
2982        visible_count: usize,
2983        line_wrap_enabled: bool,
2984        content_width: usize,
2985        gutter_width: usize,
2986        view_mode: &ViewMode,
2987        folds: &FoldManager,
2988        theme: &crate::view::theme::Theme,
2989    ) -> ViewData {
2990        let adjusted_visible_count = Self::fold_adjusted_visible_count(
2991            &state.buffer,
2992            &state.marker_list,
2993            folds,
2994            viewport.top_byte,
2995            visible_count,
2996        );
2997
2998        // Check if buffer is binary before building tokens
2999        let is_binary = state.buffer.is_binary();
3000        let line_ending = state.buffer.line_ending();
3001
3002        // Build base token stream from source
3003        let base_tokens = Self::build_base_tokens(
3004            &mut state.buffer,
3005            viewport.top_byte,
3006            estimated_line_length,
3007            adjusted_visible_count,
3008            is_binary,
3009            line_ending,
3010        );
3011
3012        // Use plugin transform if available, otherwise use base tokens
3013        let has_view_transform = view_transform.is_some();
3014        let mut tokens = view_transform.map(|vt| vt.tokens).unwrap_or(base_tokens);
3015
3016        // Apply soft breaks — marker-based line wrapping that survives edits without flicker.
3017        // Only apply in Compose mode; Source mode shows the raw unwrapped text.
3018        let is_compose = matches!(view_mode, ViewMode::PageView);
3019        if is_compose && !state.soft_breaks.is_empty() {
3020            let viewport_end = tokens
3021                .iter()
3022                .filter_map(|t| t.source_offset)
3023                .next_back()
3024                .unwrap_or(viewport.top_byte)
3025                + 1;
3026            let soft_breaks = state.soft_breaks.query_viewport(
3027                viewport.top_byte,
3028                viewport_end,
3029                &state.marker_list,
3030            );
3031            if !soft_breaks.is_empty() {
3032                tokens = Self::apply_soft_breaks(tokens, &soft_breaks);
3033            }
3034        }
3035
3036        // Apply conceal ranges - filter/replace tokens that fall within concealed byte ranges.
3037        // Only apply in Compose mode; Source mode shows the raw markdown syntax.
3038        if is_compose && !state.conceals.is_empty() {
3039            let viewport_end = tokens
3040                .iter()
3041                .filter_map(|t| t.source_offset)
3042                .next_back()
3043                .unwrap_or(viewport.top_byte)
3044                + 1;
3045            let conceal_ranges =
3046                state
3047                    .conceals
3048                    .query_viewport(viewport.top_byte, viewport_end, &state.marker_list);
3049            if !conceal_ranges.is_empty() {
3050                tokens = Self::apply_conceal_ranges(tokens, &conceal_ranges);
3051            }
3052        }
3053
3054        // Apply wrapping transform - always enabled for safety, but with different thresholds.
3055        // When line_wrap is on: wrap at viewport width (or wrap_column if set) for normal text flow.
3056        // When line_wrap is off: wrap at MAX_SAFE_LINE_WIDTH to prevent memory exhaustion
3057        // from extremely long lines (e.g., 10MB single-line JSON files).
3058        let effective_width = if line_wrap_enabled {
3059            if let Some(col) = viewport.wrap_column {
3060                // Wrap at the configured column, but never beyond viewport width
3061                col.min(content_width)
3062            } else {
3063                content_width
3064            }
3065        } else {
3066            MAX_SAFE_LINE_WIDTH
3067        };
3068        let hanging_indent = line_wrap_enabled && viewport.wrap_indent;
3069        tokens =
3070            Self::apply_wrapping_transform(tokens, effective_width, gutter_width, hanging_indent);
3071
3072        // Convert tokens to display lines using the view pipeline
3073        // Each ViewLine preserves LineStart info for correct line number rendering
3074        // Use binary mode if the buffer contains binary content
3075        // Enable ANSI awareness for non-binary content to handle escape sequences correctly
3076        let is_binary = state.buffer.is_binary();
3077        let ansi_aware = !is_binary; // ANSI parsing for normal text files
3078        let at_buffer_end = if has_view_transform {
3079            // View transforms supply their own token streams; the trailing
3080            // empty line logic doesn't apply to them.
3081            false
3082        } else {
3083            let max_source_offset = tokens
3084                .iter()
3085                .filter_map(|t| t.source_offset)
3086                .max()
3087                .unwrap_or(0);
3088            max_source_offset + 2 >= state.buffer.len()
3089        };
3090        let source_lines: Vec<ViewLine> = ViewLineIterator::new(
3091            &tokens,
3092            is_binary,
3093            ansi_aware,
3094            state.buffer_settings.tab_size,
3095            at_buffer_end,
3096        )
3097        .collect();
3098
3099        // Inject virtual lines (LineAbove/LineBelow) from VirtualTextManager
3100        let lines = Self::inject_virtual_lines(source_lines, state);
3101        let placeholder_style = fold_placeholder_style(theme);
3102        let lines = Self::apply_folding(
3103            lines,
3104            &state.buffer,
3105            &state.marker_list,
3106            folds,
3107            &placeholder_style,
3108        );
3109
3110        ViewData { lines }
3111    }
3112
3113    fn fold_adjusted_visible_count(
3114        buffer: &Buffer,
3115        marker_list: &crate::model::marker::MarkerList,
3116        folds: &FoldManager,
3117        top_byte: usize,
3118        visible_count: usize,
3119    ) -> usize {
3120        if folds.is_empty() {
3121            return visible_count;
3122        }
3123
3124        let start_line = buffer.get_line_number(top_byte);
3125        let mut total = visible_count;
3126
3127        let mut ranges = folds.resolved_ranges(buffer, marker_list);
3128        if ranges.is_empty() {
3129            return visible_count;
3130        }
3131        ranges.sort_by_key(|range| range.header_line);
3132
3133        let mut min_header_line = start_line;
3134        if let Some(containing_end) = ranges
3135            .iter()
3136            .filter(|range| start_line >= range.start_line && start_line <= range.end_line)
3137            .map(|range| range.end_line)
3138            .max()
3139        {
3140            let hidden_remaining = containing_end.saturating_sub(start_line).saturating_add(1);
3141            total = total.saturating_add(hidden_remaining);
3142            min_header_line = containing_end.saturating_add(1);
3143        }
3144
3145        let mut end_line = start_line.saturating_add(total);
3146
3147        for range in ranges {
3148            if range.header_line < min_header_line {
3149                continue;
3150            }
3151            if range.header_line > end_line {
3152                break;
3153            }
3154            let hidden = range
3155                .end_line
3156                .saturating_sub(range.start_line)
3157                .saturating_add(1);
3158            total = total.saturating_add(hidden);
3159            end_line = start_line.saturating_add(total);
3160        }
3161
3162        total
3163    }
3164
3165    fn apply_folding(
3166        lines: Vec<ViewLine>,
3167        buffer: &Buffer,
3168        marker_list: &crate::model::marker::MarkerList,
3169        folds: &FoldManager,
3170        placeholder_style: &ViewTokenStyle,
3171    ) -> Vec<ViewLine> {
3172        if folds.is_empty() {
3173            return lines;
3174        }
3175
3176        let collapsed_ranges = folds.resolved_ranges(buffer, marker_list);
3177        if collapsed_ranges.is_empty() {
3178            return lines;
3179        }
3180
3181        let collapsed_header_bytes = folds.collapsed_header_bytes(buffer, marker_list);
3182
3183        // Pre-compute: for each line, what is the source byte of the next line?
3184        let mut next_source_byte: Vec<Option<usize>> = vec![None; lines.len()];
3185        let mut next_byte: Option<usize> = None;
3186        for (idx, line) in lines.iter().enumerate().rev() {
3187            next_source_byte[idx] = next_byte;
3188            if let Some(byte) = Self::view_line_source_byte(line) {
3189                next_byte = Some(byte);
3190            }
3191        }
3192
3193        let mut filtered = Vec::with_capacity(lines.len());
3194        for (idx, mut line) in lines.into_iter().enumerate() {
3195            let source_byte = Self::view_line_source_byte(&line);
3196
3197            if let Some(byte) = source_byte {
3198                if Self::is_hidden_byte(byte, &collapsed_ranges) {
3199                    continue;
3200                }
3201
3202                if let Some(placeholder) = collapsed_header_bytes.get(&byte) {
3203                    // Only append placeholder on the last visual segment of the line
3204                    if next_source_byte[idx] != Some(byte) {
3205                        let raw_text = placeholder
3206                            .as_deref()
3207                            .filter(|s| !s.trim().is_empty())
3208                            .unwrap_or("...");
3209                        let text = if raw_text.starts_with(' ') {
3210                            raw_text.to_string()
3211                        } else {
3212                            format!(" {}", raw_text)
3213                        };
3214                        Self::append_fold_placeholder(&mut line, &text, placeholder_style);
3215                    }
3216                }
3217            } else if let Some(next_byte) = next_source_byte[idx] {
3218                if Self::is_hidden_byte(next_byte, &collapsed_ranges) {
3219                    continue;
3220                }
3221            }
3222
3223            filtered.push(line);
3224        }
3225
3226        filtered
3227    }
3228
3229    /// Get the source byte offset of a view line (first `Some` in char_source_bytes).
3230    fn view_line_source_byte(line: &ViewLine) -> Option<usize> {
3231        line.char_source_bytes.iter().find_map(|m| *m)
3232    }
3233
3234    /// Check if a byte offset falls within any collapsed fold range.
3235    fn is_hidden_byte(byte: usize, ranges: &[crate::view::folding::ResolvedFoldRange]) -> bool {
3236        ranges
3237            .iter()
3238            .any(|range| byte >= range.start_byte && byte < range.end_byte)
3239    }
3240
3241    fn append_fold_placeholder(line: &mut ViewLine, text: &str, style: &ViewTokenStyle) {
3242        if text.is_empty() {
3243            return;
3244        }
3245
3246        // If this line ends with a newline, temporarily remove it so we can insert
3247        // the placeholder before the newline.
3248        let mut removed_newline: Option<(char, Option<usize>, Option<ViewTokenStyle>)> = None;
3249        if line.ends_with_newline {
3250            if let Some(last_char) = line.text.chars().last() {
3251                if last_char == '\n' {
3252                    let removed = line.text.pop();
3253                    if removed.is_some() {
3254                        let removed_source = line.char_source_bytes.pop().unwrap_or(None);
3255                        let removed_style = line.char_styles.pop().unwrap_or(None);
3256                        line.char_visual_cols.pop();
3257                        let width = char_width(last_char);
3258                        for _ in 0..width {
3259                            line.visual_to_char.pop();
3260                        }
3261                        removed_newline = Some((last_char, removed_source, removed_style));
3262                    }
3263                }
3264            }
3265        }
3266
3267        let mut col = line.visual_to_char.len();
3268        for ch in text.chars() {
3269            let char_idx = line.char_source_bytes.len();
3270            let width = char_width(ch);
3271            line.text.push(ch);
3272            line.char_source_bytes.push(None);
3273            line.char_styles.push(Some(style.clone()));
3274            line.char_visual_cols.push(col);
3275            for _ in 0..width {
3276                line.visual_to_char.push(char_idx);
3277            }
3278            col += width;
3279        }
3280
3281        if let Some((ch, source, style)) = removed_newline {
3282            let char_idx = line.char_source_bytes.len();
3283            let width = char_width(ch);
3284            line.text.push(ch);
3285            line.char_source_bytes.push(source);
3286            line.char_styles.push(style);
3287            line.char_visual_cols.push(col);
3288            for _ in 0..width {
3289                line.visual_to_char.push(char_idx);
3290            }
3291        }
3292    }
3293
3294    /// Create a ViewLine from virtual text content (for LineAbove/LineBelow)
3295    fn create_virtual_line(text: &str, style: ratatui::style::Style) -> ViewLine {
3296        use fresh_core::api::ViewTokenStyle;
3297
3298        let text = text.to_string();
3299        let len = text.chars().count();
3300
3301        // Convert ratatui Style to ViewTokenStyle
3302        let token_style = ViewTokenStyle {
3303            fg: style.fg.and_then(|c| match c {
3304                ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
3305                _ => None,
3306            }),
3307            bg: style.bg.and_then(|c| match c {
3308                ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
3309                _ => None,
3310            }),
3311            bold: style.add_modifier.contains(ratatui::style::Modifier::BOLD),
3312            italic: style
3313                .add_modifier
3314                .contains(ratatui::style::Modifier::ITALIC),
3315        };
3316
3317        ViewLine {
3318            text,
3319            // Per-character data: all None - no source mapping (this is injected content)
3320            char_source_bytes: vec![None; len],
3321            // All have the virtual text's style
3322            char_styles: vec![Some(token_style); len],
3323            // Visual column positions for each character (0, 1, 2, ...)
3324            char_visual_cols: (0..len).collect(),
3325            // Per-visual-column: each column maps to its corresponding character
3326            visual_to_char: (0..len).collect(),
3327            tab_starts: HashSet::new(),
3328            // AfterInjectedNewline means no line number will be shown
3329            line_start: LineStart::AfterInjectedNewline,
3330            ends_with_newline: true,
3331        }
3332    }
3333
3334    /// Inject virtual lines (LineAbove/LineBelow) into the ViewLine stream
3335    fn inject_virtual_lines(source_lines: Vec<ViewLine>, state: &EditorState) -> Vec<ViewLine> {
3336        use crate::view::virtual_text::VirtualTextPosition;
3337
3338        // Get viewport byte range from source lines.
3339        // Use the last line that has source bytes (not a trailing empty line
3340        // which the iterator may emit at the buffer end).
3341        let viewport_start = source_lines
3342            .first()
3343            .and_then(|l| l.char_source_bytes.iter().find_map(|m| *m))
3344            .unwrap_or(0);
3345        let viewport_end = source_lines
3346            .iter()
3347            .rev()
3348            .find_map(|l| l.char_source_bytes.iter().rev().find_map(|m| *m))
3349            .map(|b| b + 1)
3350            .unwrap_or(viewport_start);
3351
3352        // Query virtual lines in viewport range
3353        let virtual_lines = state.virtual_texts.query_lines_in_range(
3354            &state.marker_list,
3355            viewport_start,
3356            viewport_end,
3357        );
3358
3359        // If no virtual lines, return source lines unchanged
3360        if virtual_lines.is_empty() {
3361            return source_lines;
3362        }
3363
3364        // Build result with virtual lines injected
3365        let mut result = Vec::with_capacity(source_lines.len() + virtual_lines.len());
3366
3367        for source_line in source_lines {
3368            // Get this line's byte range
3369            let line_start_byte = source_line.char_source_bytes.iter().find_map(|m| *m);
3370            let line_end_byte = source_line
3371                .char_source_bytes
3372                .iter()
3373                .rev()
3374                .find_map(|m| *m)
3375                .map(|b| b + 1);
3376
3377            // Find LineAbove virtual texts anchored to this line
3378            if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
3379                for (anchor_pos, vtext) in &virtual_lines {
3380                    if *anchor_pos >= start
3381                        && *anchor_pos < end
3382                        && vtext.position == VirtualTextPosition::LineAbove
3383                    {
3384                        result.push(Self::create_virtual_line(&vtext.text, vtext.style));
3385                    }
3386                }
3387            }
3388
3389            // Add the source line
3390            result.push(source_line.clone());
3391
3392            // Find LineBelow virtual texts anchored to this line
3393            if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
3394                for (anchor_pos, vtext) in &virtual_lines {
3395                    if *anchor_pos >= start
3396                        && *anchor_pos < end
3397                        && vtext.position == VirtualTextPosition::LineBelow
3398                    {
3399                        result.push(Self::create_virtual_line(&vtext.text, vtext.style));
3400                    }
3401                }
3402            }
3403        }
3404
3405        result
3406    }
3407
3408    /// Apply soft breaks to a token stream.
3409    ///
3410    /// Walks tokens with a sorted break list `[(position, indent)]`.
3411    /// When a token's `source_offset` matches a break position:
3412    /// - For Space tokens: replace with Newline + indent Spaces
3413    /// - For other tokens: insert Newline + indent Spaces before the token
3414    ///
3415    /// Tokens without source_offset (injected/virtual) pass through unchanged.
3416    fn apply_soft_breaks(
3417        tokens: Vec<fresh_core::api::ViewTokenWire>,
3418        soft_breaks: &[(usize, u16)],
3419    ) -> Vec<fresh_core::api::ViewTokenWire> {
3420        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3421
3422        if soft_breaks.is_empty() {
3423            return tokens;
3424        }
3425
3426        let mut output = Vec::with_capacity(tokens.len() + soft_breaks.len() * 2);
3427        let mut break_idx = 0;
3428
3429        for token in tokens {
3430            let offset = match token.source_offset {
3431                Some(o) => o,
3432                None => {
3433                    // Injected tokens pass through
3434                    output.push(token);
3435                    continue;
3436                }
3437            };
3438
3439            // Check if any break points match this token's position
3440            // Advance past any break positions that are before this token
3441            while break_idx < soft_breaks.len() && soft_breaks[break_idx].0 < offset {
3442                break_idx += 1;
3443            }
3444
3445            if break_idx < soft_breaks.len() && soft_breaks[break_idx].0 == offset {
3446                let indent = soft_breaks[break_idx].1;
3447                break_idx += 1;
3448
3449                match &token.kind {
3450                    ViewTokenWireKind::Space => {
3451                        // Replace the Space with Newline + indent Spaces
3452                        output.push(ViewTokenWire {
3453                            source_offset: None,
3454                            kind: ViewTokenWireKind::Newline,
3455                            style: None,
3456                        });
3457                        for _ in 0..indent {
3458                            output.push(ViewTokenWire {
3459                                source_offset: None,
3460                                kind: ViewTokenWireKind::Space,
3461                                style: None,
3462                            });
3463                        }
3464                    }
3465                    _ => {
3466                        // Insert Newline + indent Spaces before the token
3467                        output.push(ViewTokenWire {
3468                            source_offset: None,
3469                            kind: ViewTokenWireKind::Newline,
3470                            style: None,
3471                        });
3472                        for _ in 0..indent {
3473                            output.push(ViewTokenWire {
3474                                source_offset: None,
3475                                kind: ViewTokenWireKind::Space,
3476                                style: None,
3477                            });
3478                        }
3479                        output.push(token);
3480                    }
3481                }
3482            } else {
3483                output.push(token);
3484            }
3485        }
3486
3487        output
3488    }
3489
3490    /// Apply conceal ranges to a token stream.
3491    ///
3492    /// Handles partial token overlap: if a Text token spans bytes that are
3493    /// partially concealed, the token is split at conceal boundaries.
3494    /// Non-text tokens (Space, Newline) are treated as single-byte.
3495    ///
3496    /// Tokens without source_offset (injected/virtual) always pass through.
3497    fn apply_conceal_ranges(
3498        tokens: Vec<fresh_core::api::ViewTokenWire>,
3499        conceal_ranges: &[(std::ops::Range<usize>, Option<&str>)],
3500    ) -> Vec<fresh_core::api::ViewTokenWire> {
3501        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3502        use std::collections::HashSet;
3503
3504        if conceal_ranges.is_empty() {
3505            return tokens;
3506        }
3507
3508        let mut output = Vec::with_capacity(tokens.len());
3509        let mut emitted_replacements: HashSet<usize> = HashSet::new();
3510
3511        // Helper: check if a byte offset is concealed, returns (is_concealed, conceal_index)
3512        let is_concealed = |byte_offset: usize| -> Option<usize> {
3513            for (idx, (range, _)) in conceal_ranges.iter().enumerate() {
3514                if byte_offset >= range.start && byte_offset < range.end {
3515                    return Some(idx);
3516                }
3517            }
3518            None
3519        };
3520
3521        for token in tokens {
3522            let offset = match token.source_offset {
3523                Some(o) => o,
3524                None => {
3525                    // Injected tokens always pass through
3526                    output.push(token);
3527                    continue;
3528                }
3529            };
3530
3531            match &token.kind {
3532                ViewTokenWireKind::Text(text) => {
3533                    // Text tokens may span multiple bytes. We need to check
3534                    // each character's byte offset against conceal ranges and
3535                    // split the token at conceal boundaries.
3536                    let mut current_byte = offset;
3537                    let mut visible_start: Option<usize> = None; // byte offset of visible run start
3538                    let mut visible_chars = String::new();
3539
3540                    for ch in text.chars() {
3541                        let ch_len = ch.len_utf8();
3542
3543                        if let Some(cidx) = is_concealed(current_byte) {
3544                            // This char is concealed - flush any visible run first
3545                            if !visible_chars.is_empty() {
3546                                output.push(ViewTokenWire {
3547                                    source_offset: visible_start,
3548                                    kind: ViewTokenWireKind::Text(std::mem::take(
3549                                        &mut visible_chars,
3550                                    )),
3551                                    style: token.style.clone(),
3552                                });
3553                                visible_start = None;
3554                            }
3555
3556                            // Emit replacement text once per conceal range.
3557                            // Split into first-char (with source_offset for cursor/click
3558                            // positioning) and remaining chars (with None source_offset).
3559                            // Without this split, the view pipeline's byte-advancing logic
3560                            // (`source + byte_idx`) causes multi-character replacements to
3561                            // "claim" buffer byte positions beyond the concealed range,
3562                            // leading to ghost cursors and mispositioned hardware cursors.
3563                            if let Some(repl) = conceal_ranges[cidx].1 {
3564                                if !emitted_replacements.contains(&cidx) {
3565                                    emitted_replacements.insert(cidx);
3566                                    if !repl.is_empty() {
3567                                        let mut chars = repl.chars();
3568                                        if let Some(first_ch) = chars.next() {
3569                                            // First character maps to the concealed range start
3570                                            output.push(ViewTokenWire {
3571                                                source_offset: Some(conceal_ranges[cidx].0.start),
3572                                                kind: ViewTokenWireKind::Text(first_ch.to_string()),
3573                                                style: None,
3574                                            });
3575                                            let rest: String = chars.collect();
3576                                            if !rest.is_empty() {
3577                                                // Remaining characters are synthetic — no source mapping
3578                                                output.push(ViewTokenWire {
3579                                                    source_offset: None,
3580                                                    kind: ViewTokenWireKind::Text(rest),
3581                                                    style: None,
3582                                                });
3583                                            }
3584                                        }
3585                                    }
3586                                }
3587                            }
3588                        } else {
3589                            // Visible char - accumulate
3590                            if visible_start.is_none() {
3591                                visible_start = Some(current_byte);
3592                            }
3593                            visible_chars.push(ch);
3594                        }
3595
3596                        current_byte += ch_len;
3597                    }
3598
3599                    // Flush remaining visible chars
3600                    if !visible_chars.is_empty() {
3601                        output.push(ViewTokenWire {
3602                            source_offset: visible_start,
3603                            kind: ViewTokenWireKind::Text(visible_chars),
3604                            style: token.style.clone(),
3605                        });
3606                    }
3607                }
3608                ViewTokenWireKind::Space
3609                | ViewTokenWireKind::Newline
3610                | ViewTokenWireKind::Break => {
3611                    // Single-byte tokens: conceal or pass through
3612                    if is_concealed(offset).is_some() {
3613                        // Skip concealed space/newline
3614                    } else {
3615                        output.push(token);
3616                    }
3617                }
3618                ViewTokenWireKind::BinaryByte(_) => {
3619                    if is_concealed(offset).is_some() {
3620                        // Skip concealed binary byte
3621                    } else {
3622                        output.push(token);
3623                    }
3624                }
3625            }
3626        }
3627
3628        output
3629    }
3630
3631    fn build_base_tokens(
3632        buffer: &mut Buffer,
3633        top_byte: usize,
3634        estimated_line_length: usize,
3635        visible_count: usize,
3636        is_binary: bool,
3637        line_ending: crate::model::buffer::LineEnding,
3638    ) -> Vec<fresh_core::api::ViewTokenWire> {
3639        use crate::model::buffer::LineEnding;
3640        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3641
3642        let mut tokens = Vec::new();
3643
3644        // For binary files, read raw bytes directly to preserve byte values
3645        // (LineIterator uses String::from_utf8_lossy which loses high bytes)
3646        if is_binary {
3647            return Self::build_base_tokens_binary(
3648                buffer,
3649                top_byte,
3650                estimated_line_length,
3651                visible_count,
3652            );
3653        }
3654
3655        let mut iter = buffer.line_iterator(top_byte, estimated_line_length);
3656        let mut lines_seen = 0usize;
3657        let max_lines = visible_count.saturating_add(4);
3658
3659        while lines_seen < max_lines {
3660            if let Some((line_start, line_content)) = iter.next_line() {
3661                let mut byte_offset = 0usize;
3662                let content_bytes = line_content.as_bytes();
3663                let mut skip_next_lf = false; // Track if we should skip \n after \r in CRLF
3664                let mut chars_this_line = 0usize; // Track chars to enforce MAX_SAFE_LINE_WIDTH
3665                for ch in line_content.chars() {
3666                    // Limit characters per line to prevent memory exhaustion from huge lines.
3667                    // Insert a Break token to force wrapping at safe intervals.
3668                    if chars_this_line >= MAX_SAFE_LINE_WIDTH {
3669                        tokens.push(ViewTokenWire {
3670                            source_offset: None,
3671                            kind: ViewTokenWireKind::Break,
3672                            style: None,
3673                        });
3674                        chars_this_line = 0;
3675                        // Count this as a new visual line for the max_lines limit
3676                        lines_seen += 1;
3677                        if lines_seen >= max_lines {
3678                            break;
3679                        }
3680                    }
3681                    chars_this_line += 1;
3682
3683                    let ch_len = ch.len_utf8();
3684                    let source_offset = Some(line_start + byte_offset);
3685
3686                    match ch {
3687                        '\r' => {
3688                            // In CRLF mode with \r\n: emit Newline at \r position, skip the \n
3689                            // This allows cursor at \r (end of line) to be visible
3690                            // In LF/Unix files, ANY \r is unusual and should be shown as <0D>
3691                            let is_crlf_file = line_ending == LineEnding::CRLF;
3692                            let next_byte = content_bytes.get(byte_offset + 1);
3693                            if is_crlf_file && next_byte == Some(&b'\n') {
3694                                // CRLF: emit Newline token at \r position for cursor visibility
3695                                tokens.push(ViewTokenWire {
3696                                    source_offset,
3697                                    kind: ViewTokenWireKind::Newline,
3698                                    style: None,
3699                                });
3700                                // Mark to skip the following \n in the char iterator
3701                                skip_next_lf = true;
3702                                byte_offset += ch_len;
3703                                continue;
3704                            }
3705                            // LF file or standalone \r - show as control character
3706                            tokens.push(ViewTokenWire {
3707                                source_offset,
3708                                kind: ViewTokenWireKind::BinaryByte(ch as u8),
3709                                style: None,
3710                            });
3711                        }
3712                        '\n' if skip_next_lf => {
3713                            // Skip \n that follows \r in CRLF mode (already emitted Newline at \r)
3714                            skip_next_lf = false;
3715                            byte_offset += ch_len;
3716                            continue;
3717                        }
3718                        '\n' => {
3719                            tokens.push(ViewTokenWire {
3720                                source_offset,
3721                                kind: ViewTokenWireKind::Newline,
3722                                style: None,
3723                            });
3724                        }
3725                        ' ' => {
3726                            tokens.push(ViewTokenWire {
3727                                source_offset,
3728                                kind: ViewTokenWireKind::Space,
3729                                style: None,
3730                            });
3731                        }
3732                        '\t' => {
3733                            // Tab is safe, emit as Text
3734                            tokens.push(ViewTokenWire {
3735                                source_offset,
3736                                kind: ViewTokenWireKind::Text(ch.to_string()),
3737                                style: None,
3738                            });
3739                        }
3740                        _ if Self::is_control_char(ch) => {
3741                            // Control character - emit as BinaryByte to render as <XX>
3742                            tokens.push(ViewTokenWire {
3743                                source_offset,
3744                                kind: ViewTokenWireKind::BinaryByte(ch as u8),
3745                                style: None,
3746                            });
3747                        }
3748                        _ => {
3749                            // Accumulate consecutive non-space/non-newline chars into Text tokens
3750                            if let Some(last) = tokens.last_mut() {
3751                                if let ViewTokenWireKind::Text(ref mut s) = last.kind {
3752                                    // Extend existing Text token if contiguous
3753                                    let expected_offset = last.source_offset.map(|o| o + s.len());
3754                                    if expected_offset == Some(line_start + byte_offset) {
3755                                        s.push(ch);
3756                                        byte_offset += ch_len;
3757                                        continue;
3758                                    }
3759                                }
3760                            }
3761                            tokens.push(ViewTokenWire {
3762                                source_offset,
3763                                kind: ViewTokenWireKind::Text(ch.to_string()),
3764                                style: None,
3765                            });
3766                        }
3767                    }
3768                    byte_offset += ch_len;
3769                }
3770                lines_seen += 1;
3771            } else {
3772                break;
3773            }
3774        }
3775
3776        // Handle empty buffer
3777        if tokens.is_empty() {
3778            tokens.push(ViewTokenWire {
3779                source_offset: Some(top_byte),
3780                kind: ViewTokenWireKind::Text(String::new()),
3781                style: None,
3782            });
3783        }
3784
3785        tokens
3786    }
3787
3788    /// Build tokens for binary files by reading raw bytes directly
3789    /// This preserves byte values >= 0x80 that would be lost by String::from_utf8_lossy
3790    fn build_base_tokens_binary(
3791        buffer: &mut Buffer,
3792        top_byte: usize,
3793        estimated_line_length: usize,
3794        visible_count: usize,
3795    ) -> Vec<fresh_core::api::ViewTokenWire> {
3796        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3797
3798        let mut tokens = Vec::new();
3799        let max_lines = visible_count.saturating_add(4);
3800        let buffer_len = buffer.len();
3801
3802        if top_byte >= buffer_len {
3803            tokens.push(ViewTokenWire {
3804                source_offset: Some(top_byte),
3805                kind: ViewTokenWireKind::Text(String::new()),
3806                style: None,
3807            });
3808            return tokens;
3809        }
3810
3811        // Estimate how many bytes we need to read
3812        let estimated_bytes = estimated_line_length * max_lines * 2;
3813        let bytes_to_read = estimated_bytes.min(buffer_len - top_byte);
3814
3815        // Read raw bytes directly from buffer
3816        let raw_bytes = buffer.slice_bytes(top_byte..top_byte + bytes_to_read);
3817
3818        let mut byte_offset = 0usize;
3819        let mut lines_seen = 0usize;
3820        let mut current_text = String::new();
3821        let mut current_text_start: Option<usize> = None;
3822
3823        // Helper to flush accumulated text to tokens
3824        let flush_text =
3825            |tokens: &mut Vec<ViewTokenWire>, text: &mut String, start: &mut Option<usize>| {
3826                if !text.is_empty() {
3827                    tokens.push(ViewTokenWire {
3828                        source_offset: *start,
3829                        kind: ViewTokenWireKind::Text(std::mem::take(text)),
3830                        style: None,
3831                    });
3832                    *start = None;
3833                }
3834            };
3835
3836        while byte_offset < raw_bytes.len() && lines_seen < max_lines {
3837            let b = raw_bytes[byte_offset];
3838            let source_offset = top_byte + byte_offset;
3839
3840            match b {
3841                b'\n' => {
3842                    flush_text(&mut tokens, &mut current_text, &mut current_text_start);
3843                    tokens.push(ViewTokenWire {
3844                        source_offset: Some(source_offset),
3845                        kind: ViewTokenWireKind::Newline,
3846                        style: None,
3847                    });
3848                    lines_seen += 1;
3849                }
3850                b' ' => {
3851                    flush_text(&mut tokens, &mut current_text, &mut current_text_start);
3852                    tokens.push(ViewTokenWire {
3853                        source_offset: Some(source_offset),
3854                        kind: ViewTokenWireKind::Space,
3855                        style: None,
3856                    });
3857                }
3858                _ => {
3859                    // For binary files, emit unprintable bytes as BinaryByte tokens
3860                    // This ensures view_pipeline.rs can map all 4 chars of <XX> to the same source byte
3861                    if Self::is_binary_unprintable(b) {
3862                        // Flush any accumulated printable text first
3863                        flush_text(&mut tokens, &mut current_text, &mut current_text_start);
3864                        // Emit as BinaryByte so cursor positioning works correctly
3865                        tokens.push(ViewTokenWire {
3866                            source_offset: Some(source_offset),
3867                            kind: ViewTokenWireKind::BinaryByte(b),
3868                            style: None,
3869                        });
3870                    } else {
3871                        // Printable ASCII - accumulate into text token
3872                        // Each printable char is 1 byte so accumulation works correctly
3873                        if current_text_start.is_none() {
3874                            current_text_start = Some(source_offset);
3875                        }
3876                        current_text.push(b as char);
3877                    }
3878                }
3879            }
3880            byte_offset += 1;
3881        }
3882
3883        // Flush any remaining text
3884        flush_text(&mut tokens, &mut current_text, &mut current_text_start);
3885
3886        // Handle empty buffer
3887        if tokens.is_empty() {
3888            tokens.push(ViewTokenWire {
3889                source_offset: Some(top_byte),
3890                kind: ViewTokenWireKind::Text(String::new()),
3891                style: None,
3892            });
3893        }
3894
3895        tokens
3896    }
3897
3898    /// Check if a byte should be displayed as <XX> in binary mode
3899    /// Returns true for:
3900    /// - Control characters (0x00-0x1F) except tab and newline
3901    /// - DEL (0x7F)
3902    /// - High bytes (0x80-0xFF) which are not valid single-byte UTF-8
3903    ///
3904    /// Note: In binary mode, we must be very strict about what characters we allow through,
3905    /// because control characters can move the terminal cursor and corrupt the display:
3906    /// - CR (0x0D) moves cursor to column 0, overwriting the gutter
3907    /// - VT (0x0B) and FF (0x0C) move cursor vertically
3908    /// - ESC (0x1B) starts ANSI escape sequences
3909    fn is_binary_unprintable(b: u8) -> bool {
3910        // Only allow: tab (0x09) and newline (0x0A)
3911        // These are the only safe whitespace characters in binary mode
3912        // All other control characters can corrupt terminal output
3913        if b == 0x09 || b == 0x0A {
3914            return false;
3915        }
3916        // All other control characters (0x00-0x1F) are unprintable in binary mode
3917        // This includes CR, VT, FF, ESC which can move the cursor
3918        if b < 0x20 {
3919            return true;
3920        }
3921        // DEL character (0x7F) is unprintable
3922        if b == 0x7F {
3923            return true;
3924        }
3925        // High bytes (0x80-0xFF) are unprintable in binary mode
3926        // (they're not valid single-byte UTF-8 and would be converted to replacement char)
3927        if b >= 0x80 {
3928            return true;
3929        }
3930        false
3931    }
3932
3933    /// Check if a character is a control character that should be rendered as <XX>
3934    /// This applies to ALL files (binary and non-binary) to prevent terminal corruption
3935    fn is_control_char(ch: char) -> bool {
3936        let code = ch as u32;
3937        // Only check ASCII range
3938        if code >= 128 {
3939            return false;
3940        }
3941        let b = code as u8;
3942        // Allow: tab (0x09), newline (0x0A), ESC (0x1B - for ANSI sequences)
3943        if b == 0x09 || b == 0x0A || b == 0x1B {
3944            return false;
3945        }
3946        // Other control characters (0x00-0x1F) and DEL (0x7F) are dangerous
3947        // This includes CR (0x0D), VT (0x0B), FF (0x0C) which move the cursor
3948        b < 0x20 || b == 0x7F
3949    }
3950
3951    /// Public wrapper for building base tokens - used by render.rs for the view_transform_request hook
3952    pub fn build_base_tokens_for_hook(
3953        buffer: &mut Buffer,
3954        top_byte: usize,
3955        estimated_line_length: usize,
3956        visible_count: usize,
3957        is_binary: bool,
3958        line_ending: crate::model::buffer::LineEnding,
3959    ) -> Vec<fresh_core::api::ViewTokenWire> {
3960        Self::build_base_tokens(
3961            buffer,
3962            top_byte,
3963            estimated_line_length,
3964            visible_count,
3965            is_binary,
3966            line_ending,
3967        )
3968    }
3969
3970    fn apply_wrapping_transform(
3971        tokens: Vec<fresh_core::api::ViewTokenWire>,
3972        content_width: usize,
3973        gutter_width: usize,
3974        hanging_indent: bool,
3975    ) -> Vec<fresh_core::api::ViewTokenWire> {
3976        use crate::primitives::visual_layout::visual_width;
3977        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3978
3979        /// Minimum content width for continuation lines when hanging indent is active.
3980        const MIN_CONTINUATION_CONTENT_WIDTH: usize = 10;
3981
3982        // Calculate available width (accounting for gutter on first line only)
3983        let available_width = content_width.saturating_sub(gutter_width);
3984
3985        // Guard against zero or very small available width which would produce
3986        // one Break per character, causing pathological memory usage.
3987        // Return tokens unwrapped instead of exploding memory.
3988        if available_width < 2 {
3989            return tokens;
3990        }
3991
3992        let mut wrapped = Vec::new();
3993        let mut current_line_width: usize = 0;
3994
3995        // Hanging indent state: the visual indent width for the current logical line.
3996        // Reset to 0 on each Newline, measured from leading whitespace.
3997        let mut line_indent: usize = 0;
3998        // Whether we're still measuring leading whitespace for the current line
3999        let mut measuring_indent = hanging_indent;
4000        // Whether we've emitted a Break for the current logical line (i.e., we're on a continuation)
4001        let mut on_continuation = false;
4002
4003        /// Effective width for the current segment.
4004        ///
4005        /// This always returns `available_width` because hanging indent is
4006        /// already accounted for by the indent text emitted into
4007        /// `current_line_width` via `emit_break_with_indent`.  Subtracting
4008        /// `line_indent` here would double-count it, causing "squished"
4009        /// wrapping on narrow terminals (issue #1502).
4010        #[inline]
4011        fn effective_width(
4012            available_width: usize,
4013            _line_indent: usize,
4014            _on_continuation: bool,
4015        ) -> usize {
4016            available_width
4017        }
4018
4019        /// Emit a Break token followed by hanging indent spaces.
4020        /// Updates current_line_width to reflect the indent.
4021        /// Uses a pre-computed indent string to avoid repeated allocations.
4022        fn emit_break_with_indent(
4023            wrapped: &mut Vec<ViewTokenWire>,
4024            current_line_width: &mut usize,
4025            indent_string: &str,
4026        ) {
4027            wrapped.push(ViewTokenWire {
4028                source_offset: None,
4029                kind: ViewTokenWireKind::Break,
4030                style: None,
4031            });
4032            *current_line_width = 0;
4033            if !indent_string.is_empty() {
4034                wrapped.push(ViewTokenWire {
4035                    source_offset: None,
4036                    kind: ViewTokenWireKind::Text(indent_string.to_string()),
4037                    style: None,
4038                });
4039                *current_line_width = indent_string.len();
4040            }
4041        }
4042
4043        // Pre-computed indent string, updated only when line_indent changes.
4044        // Avoids allocating " ".repeat(n) on every Break emission.
4045        let mut cached_indent_string = String::new();
4046        let mut cached_indent_len: usize = 0;
4047
4048        for token in tokens {
4049            match &token.kind {
4050                ViewTokenWireKind::Newline => {
4051                    // Real newlines always break the line
4052                    wrapped.push(token);
4053                    current_line_width = 0;
4054                    line_indent = 0;
4055                    cached_indent_string.clear();
4056                    cached_indent_len = 0;
4057                    measuring_indent = hanging_indent;
4058                    on_continuation = false;
4059                }
4060                ViewTokenWireKind::Text(text) => {
4061                    // Measure leading whitespace at the start of a logical line
4062                    if measuring_indent {
4063                        let mut ws_char_count = 0usize;
4064                        let mut ws_visual_width = 0usize;
4065                        for c in text.chars() {
4066                            if c == ' ' {
4067                                ws_visual_width += 1;
4068                                ws_char_count += 1;
4069                            } else if c == '\t' {
4070                                // Expand tab to next 4-column tab stop, matching detect_indent()
4071                                let tab_stop = 4;
4072                                let col = line_indent + ws_visual_width;
4073                                ws_visual_width += tab_stop - (col % tab_stop);
4074                                ws_char_count += 1;
4075                            } else {
4076                                break;
4077                            }
4078                        }
4079                        if ws_char_count == text.chars().count() {
4080                            // Entire token is whitespace — accumulate and continue measuring
4081                            line_indent += ws_visual_width;
4082                        } else {
4083                            // Token has non-whitespace: finalize indent measurement
4084                            line_indent += ws_visual_width;
4085                            measuring_indent = false;
4086                        }
4087                        // Clamp indent to ensure continuation lines have room for content
4088                        if line_indent + MIN_CONTINUATION_CONTENT_WIDTH > available_width {
4089                            line_indent = 0;
4090                        }
4091                        // Rebuild cached indent string if indent changed
4092                        if line_indent != cached_indent_len {
4093                            cached_indent_string = " ".repeat(line_indent);
4094                            cached_indent_len = line_indent;
4095                        }
4096                    }
4097
4098                    let eff_width = effective_width(available_width, line_indent, on_continuation);
4099
4100                    // Use visual_width which properly handles tabs and ANSI codes
4101                    let text_visual_width = visual_width(text, current_line_width);
4102
4103                    // If this token would exceed line width, insert Break before it
4104                    if current_line_width > 0 && current_line_width + text_visual_width > eff_width
4105                    {
4106                        on_continuation = true;
4107                        emit_break_with_indent(
4108                            &mut wrapped,
4109                            &mut current_line_width,
4110                            &cached_indent_string,
4111                        );
4112                    }
4113
4114                    let eff_width = effective_width(available_width, line_indent, on_continuation);
4115
4116                    // Recalculate visual width after potential line break (tabs depend on column)
4117                    let text_visual_width = visual_width(text, current_line_width);
4118
4119                    // If visible text is longer than line width, we need to split
4120                    // However, we don't split tokens containing ANSI codes to avoid
4121                    // breaking escape sequences. ANSI-heavy content may exceed line width.
4122                    if text_visual_width > eff_width
4123                        && !crate::primitives::ansi::contains_ansi_codes(text)
4124                    {
4125                        use unicode_segmentation::UnicodeSegmentation;
4126
4127                        // Collect graphemes with their byte offsets for proper Unicode handling
4128                        let graphemes: Vec<(usize, &str)> = text.grapheme_indices(true).collect();
4129                        let mut grapheme_idx = 0;
4130                        let source_base = token.source_offset;
4131
4132                        while grapheme_idx < graphemes.len() {
4133                            let eff_width =
4134                                effective_width(available_width, line_indent, on_continuation);
4135                            // Calculate how many graphemes fit in remaining space
4136                            // by summing visual widths until we exceed available width
4137                            let remaining_width = eff_width.saturating_sub(current_line_width);
4138                            if remaining_width == 0 {
4139                                // Need to break to next line.
4140                                // If we already are on a continuation and line_indent
4141                                // fills half or more of available_width, breaking again
4142                                // would leave remaining_width == 0 again — infinite loop.
4143                                // Force-emit at least one grapheme to guarantee progress.
4144                                if on_continuation && current_line_width >= eff_width {
4145                                    // Force one grapheme onto this line to avoid infinite loop
4146                                } else {
4147                                    on_continuation = true;
4148                                    emit_break_with_indent(
4149                                        &mut wrapped,
4150                                        &mut current_line_width,
4151                                        &cached_indent_string,
4152                                    );
4153                                    continue;
4154                                }
4155                            }
4156
4157                            let mut chunk_visual_width = 0;
4158                            let mut chunk_grapheme_count = 0;
4159                            let mut col = current_line_width;
4160
4161                            for &(_byte_offset, grapheme) in &graphemes[grapheme_idx..] {
4162                                let g_width = if grapheme == "\t" {
4163                                    crate::primitives::visual_layout::tab_expansion_width(col)
4164                                } else {
4165                                    crate::primitives::display_width::str_width(grapheme)
4166                                };
4167
4168                                if chunk_visual_width + g_width > remaining_width
4169                                    && chunk_grapheme_count > 0
4170                                {
4171                                    break;
4172                                }
4173
4174                                chunk_visual_width += g_width;
4175                                chunk_grapheme_count += 1;
4176                                col += g_width;
4177                            }
4178
4179                            if chunk_grapheme_count == 0 {
4180                                // Single grapheme is wider than available width, force it
4181                                chunk_grapheme_count = 1;
4182                                let grapheme = graphemes[grapheme_idx].1;
4183                                chunk_visual_width = if grapheme == "\t" {
4184                                    crate::primitives::visual_layout::tab_expansion_width(
4185                                        current_line_width,
4186                                    )
4187                                } else {
4188                                    crate::primitives::display_width::str_width(grapheme)
4189                                };
4190                            }
4191
4192                            // Build chunk from graphemes and calculate source offset
4193                            let chunk_start_byte = graphemes[grapheme_idx].0;
4194                            let chunk_end_byte =
4195                                if grapheme_idx + chunk_grapheme_count < graphemes.len() {
4196                                    graphemes[grapheme_idx + chunk_grapheme_count].0
4197                                } else {
4198                                    text.len()
4199                                };
4200                            let chunk = text[chunk_start_byte..chunk_end_byte].to_string();
4201                            let chunk_source = source_base.map(|b| b + chunk_start_byte);
4202
4203                            wrapped.push(ViewTokenWire {
4204                                source_offset: chunk_source,
4205                                kind: ViewTokenWireKind::Text(chunk),
4206                                style: token.style.clone(),
4207                            });
4208
4209                            current_line_width += chunk_visual_width;
4210                            grapheme_idx += chunk_grapheme_count;
4211
4212                            let eff_width =
4213                                effective_width(available_width, line_indent, on_continuation);
4214                            // If we filled the line, break
4215                            if current_line_width >= eff_width {
4216                                on_continuation = true;
4217                                emit_break_with_indent(
4218                                    &mut wrapped,
4219                                    &mut current_line_width,
4220                                    &cached_indent_string,
4221                                );
4222                            }
4223                        }
4224                    } else {
4225                        wrapped.push(token);
4226                        current_line_width += text_visual_width;
4227                    }
4228                }
4229                ViewTokenWireKind::Space => {
4230                    // Measure leading whitespace (spaces)
4231                    if measuring_indent {
4232                        line_indent += 1;
4233                        // Clamp indent
4234                        if line_indent + MIN_CONTINUATION_CONTENT_WIDTH > available_width {
4235                            line_indent = 0;
4236                        }
4237                    }
4238
4239                    let eff_width = effective_width(available_width, line_indent, on_continuation);
4240                    // Spaces count toward line width
4241                    if current_line_width + 1 > eff_width {
4242                        on_continuation = true;
4243                        emit_break_with_indent(
4244                            &mut wrapped,
4245                            &mut current_line_width,
4246                            &cached_indent_string,
4247                        );
4248                    }
4249                    wrapped.push(token);
4250                    current_line_width += 1;
4251                }
4252                ViewTokenWireKind::Break => {
4253                    // Pass through existing breaks
4254                    wrapped.push(token);
4255                    current_line_width = 0;
4256                    on_continuation = true;
4257                    // Inject indent for pre-existing breaks too
4258                    if line_indent > 0 {
4259                        wrapped.push(ViewTokenWire {
4260                            source_offset: None,
4261                            kind: ViewTokenWireKind::Text(" ".repeat(line_indent)),
4262                            style: None,
4263                        });
4264                        current_line_width = line_indent;
4265                    }
4266                }
4267                ViewTokenWireKind::BinaryByte(_) => {
4268                    // Stop measuring indent on non-whitespace content
4269                    if measuring_indent {
4270                        measuring_indent = false;
4271                    }
4272
4273                    let eff_width = effective_width(available_width, line_indent, on_continuation);
4274                    // Binary bytes render as <XX> which is 4 characters
4275                    let byte_display_width = 4;
4276                    if current_line_width + byte_display_width > eff_width {
4277                        on_continuation = true;
4278                        emit_break_with_indent(
4279                            &mut wrapped,
4280                            &mut current_line_width,
4281                            &cached_indent_string,
4282                        );
4283                    }
4284                    wrapped.push(token);
4285                    current_line_width += byte_display_width;
4286                }
4287            }
4288        }
4289
4290        wrapped
4291    }
4292
4293    fn calculate_view_anchor(view_lines: &[ViewLine], top_byte: usize) -> ViewAnchor {
4294        // Find the first line that contains source content at or after top_byte
4295        // Walk backwards to include any injected content (headers) that precede it
4296        for (idx, line) in view_lines.iter().enumerate() {
4297            // Check if this line has source content at or after top_byte
4298            if let Some(first_source) = line.char_source_bytes.iter().find_map(|m| *m) {
4299                if first_source >= top_byte {
4300                    // Found a line with source >= top_byte
4301                    // But we may need to include previous lines if they're injected headers
4302                    let mut start_idx = idx;
4303                    while start_idx > 0 {
4304                        let prev_line = &view_lines[start_idx - 1];
4305                        // If previous line is all injected (no source mappings), include it
4306                        let prev_has_source =
4307                            prev_line.char_source_bytes.iter().any(|m| m.is_some());
4308                        if !prev_has_source {
4309                            start_idx -= 1;
4310                        } else {
4311                            break;
4312                        }
4313                    }
4314                    return ViewAnchor {
4315                        start_line_idx: start_idx,
4316                        start_line_skip: 0,
4317                    };
4318                }
4319            }
4320        }
4321
4322        // No matching source found, start from beginning
4323        ViewAnchor {
4324            start_line_idx: 0,
4325            start_line_skip: 0,
4326        }
4327    }
4328
4329    fn calculate_compose_layout(
4330        area: Rect,
4331        view_mode: &ViewMode,
4332        compose_width: Option<u16>,
4333    ) -> ComposeLayout {
4334        // Enable centering/margins if:
4335        // 1. View mode is explicitly Compose, OR
4336        // 2. compose_width is set (plugin-driven compose mode)
4337        let should_compose = view_mode == &ViewMode::PageView || compose_width.is_some();
4338
4339        if !should_compose {
4340            return ComposeLayout {
4341                render_area: area,
4342                left_pad: 0,
4343                right_pad: 0,
4344            };
4345        }
4346
4347        let target_width = compose_width.unwrap_or(area.width);
4348        let clamped_width = target_width.min(area.width).max(1);
4349        if clamped_width >= area.width {
4350            return ComposeLayout {
4351                render_area: area,
4352                left_pad: 0,
4353                right_pad: 0,
4354            };
4355        }
4356
4357        let pad_total = area.width - clamped_width;
4358        let left_pad = pad_total / 2;
4359        let right_pad = pad_total - left_pad;
4360
4361        ComposeLayout {
4362            render_area: Rect::new(area.x + left_pad, area.y, clamped_width, area.height),
4363            left_pad,
4364            right_pad,
4365        }
4366    }
4367
4368    fn render_compose_margins(
4369        frame: &mut Frame,
4370        area: Rect,
4371        layout: &ComposeLayout,
4372        _view_mode: &ViewMode,
4373        theme: &crate::view::theme::Theme,
4374        effective_editor_bg: ratatui::style::Color,
4375    ) {
4376        // Render margins if there are any pads (indicates compose layout is active)
4377        if layout.left_pad == 0 && layout.right_pad == 0 {
4378            return;
4379        }
4380
4381        // Paper-on-desk effect: outer "desk" margin with inner "paper edge"
4382        // Layout: [desk][paper edge][content][paper edge][desk]
4383        const PAPER_EDGE_WIDTH: u16 = 1;
4384
4385        let desk_style = Style::default().bg(theme.compose_margin_bg);
4386        let paper_style = Style::default().bg(effective_editor_bg);
4387
4388        if layout.left_pad > 0 {
4389            let paper_edge = PAPER_EDGE_WIDTH.min(layout.left_pad);
4390            let desk_width = layout.left_pad.saturating_sub(paper_edge);
4391
4392            // Desk area (outer)
4393            if desk_width > 0 {
4394                let desk_rect = Rect::new(area.x, area.y, desk_width, area.height);
4395                frame.render_widget(Block::default().style(desk_style), desk_rect);
4396            }
4397
4398            // Paper edge (inner, adjacent to content)
4399            if paper_edge > 0 {
4400                let paper_rect = Rect::new(area.x + desk_width, area.y, paper_edge, area.height);
4401                frame.render_widget(Block::default().style(paper_style), paper_rect);
4402            }
4403        }
4404
4405        if layout.right_pad > 0 {
4406            let paper_edge = PAPER_EDGE_WIDTH.min(layout.right_pad);
4407            let desk_width = layout.right_pad.saturating_sub(paper_edge);
4408            let right_start = area.x + layout.left_pad + layout.render_area.width;
4409
4410            // Paper edge (inner, adjacent to content)
4411            if paper_edge > 0 {
4412                let paper_rect = Rect::new(right_start, area.y, paper_edge, area.height);
4413                frame.render_widget(Block::default().style(paper_style), paper_rect);
4414            }
4415
4416            // Desk area (outer)
4417            if desk_width > 0 {
4418                let desk_rect =
4419                    Rect::new(right_start + paper_edge, area.y, desk_width, area.height);
4420                frame.render_widget(Block::default().style(desk_style), desk_rect);
4421            }
4422        }
4423    }
4424
4425    fn selection_context(
4426        state: &EditorState,
4427        cursors: &crate::model::cursor::Cursors,
4428    ) -> SelectionContext {
4429        // When cursors are hidden, suppress all visual selection feedback
4430        // (no selection highlight, no block rects, no cursor positions)
4431        if !state.show_cursors {
4432            return SelectionContext {
4433                ranges: Vec::new(),
4434                block_rects: Vec::new(),
4435                cursor_positions: Vec::new(),
4436                primary_cursor_position: cursors.primary().position,
4437            };
4438        }
4439
4440        let ranges: Vec<Range<usize>> = cursors
4441            .iter()
4442            .filter_map(|(_, cursor)| {
4443                // Don't include normal selection for cursors in block selection mode
4444                // Block selections are rendered separately via block_rects
4445                if cursor.selection_mode == SelectionMode::Block {
4446                    None
4447                } else {
4448                    cursor.selection_range()
4449                }
4450            })
4451            .collect();
4452
4453        let block_rects: Vec<(usize, usize, usize, usize)> = cursors
4454            .iter()
4455            .filter_map(|(_, cursor)| {
4456                if cursor.selection_mode == SelectionMode::Block {
4457                    if let Some(anchor) = cursor.block_anchor {
4458                        // Convert cursor position to 2D coords
4459                        let cur_line = state.buffer.get_line_number(cursor.position);
4460                        let cur_line_start = state.buffer.line_start_offset(cur_line).unwrap_or(0);
4461                        let cur_col = cursor.position.saturating_sub(cur_line_start);
4462
4463                        // Return normalized rectangle (min values first)
4464                        Some((
4465                            anchor.line.min(cur_line),
4466                            anchor.column.min(cur_col),
4467                            anchor.line.max(cur_line),
4468                            anchor.column.max(cur_col),
4469                        ))
4470                    } else {
4471                        None
4472                    }
4473                } else {
4474                    None
4475                }
4476            })
4477            .collect();
4478
4479        let cursor_positions: Vec<usize> =
4480            cursors.iter().map(|(_, cursor)| cursor.position).collect();
4481
4482        SelectionContext {
4483            ranges,
4484            block_rects,
4485            cursor_positions,
4486            primary_cursor_position: cursors.primary().position,
4487        }
4488    }
4489
4490    #[allow(clippy::too_many_arguments)]
4491    fn decoration_context(
4492        state: &mut EditorState,
4493        viewport_start: usize,
4494        viewport_end: usize,
4495        primary_cursor_position: usize,
4496        folds: &FoldManager,
4497        theme: &crate::view::theme::Theme,
4498        highlight_context_bytes: usize,
4499        view_mode: &ViewMode,
4500        diagnostics_inline_text: bool,
4501    ) -> DecorationContext {
4502        use crate::view::folding::indent_folding;
4503
4504        // Extend highlighting range by ~1 viewport size before/after for better context.
4505        // This helps tree-sitter parse multi-line constructs that span viewport boundaries.
4506        let viewport_size = viewport_end.saturating_sub(viewport_start);
4507        let highlight_start = viewport_start.saturating_sub(viewport_size);
4508        let highlight_end = viewport_end
4509            .saturating_add(viewport_size)
4510            .min(state.buffer.len());
4511
4512        let highlight_spans = state.highlighter.highlight_viewport(
4513            &state.buffer,
4514            highlight_start,
4515            highlight_end,
4516            theme,
4517            highlight_context_bytes,
4518        );
4519
4520        // Update reference highlight overlays (debounced, creates overlays that auto-adjust)
4521        state.reference_highlight_overlay.update(
4522            &state.buffer,
4523            &mut state.overlays,
4524            &mut state.marker_list,
4525            &mut state.reference_highlighter,
4526            primary_cursor_position,
4527            viewport_start,
4528            viewport_end,
4529            highlight_context_bytes,
4530            theme.semantic_highlight_bg,
4531        );
4532
4533        // Update bracket highlight overlays
4534        state.bracket_highlight_overlay.update(
4535            &state.buffer,
4536            &mut state.overlays,
4537            &mut state.marker_list,
4538            primary_cursor_position,
4539        );
4540
4541        // Semantic tokens are stored as overlays so their ranges track edits.
4542        // Convert them into highlight spans for the render pipeline.
4543        let is_compose = matches!(view_mode, ViewMode::PageView);
4544        let md_emphasis_ns =
4545            fresh_core::overlay::OverlayNamespace::from_string("md-emphasis".to_string());
4546        let mut semantic_token_spans = Vec::new();
4547        let mut viewport_overlays = Vec::new();
4548        for (overlay, range) in
4549            state
4550                .overlays
4551                .query_viewport(viewport_start, viewport_end, &state.marker_list)
4552        {
4553            if crate::services::lsp::semantic_tokens::is_semantic_token_overlay(overlay) {
4554                if let crate::view::overlay::OverlayFace::Foreground { color } = &overlay.face {
4555                    semantic_token_spans.push(crate::primitives::highlighter::HighlightSpan {
4556                        range,
4557                        color: *color,
4558                        category: None,
4559                    });
4560                }
4561                continue;
4562            }
4563
4564            // Skip markdown compose overlays in Source mode — they should only
4565            // render in the Compose-mode split.
4566            if !is_compose && overlay.namespace.as_ref() == Some(&md_emphasis_ns) {
4567                continue;
4568            }
4569
4570            viewport_overlays.push((overlay.clone(), range));
4571        }
4572
4573        // Sort overlays by priority (ascending) so higher priority overlays
4574        // are applied last in the rendering loop and their styles take effect.
4575        // This ensures e.g. an error overlay (priority 100) renders its background
4576        // on top of a hint overlay (priority 10) at the same range.
4577        viewport_overlays.sort_by_key(|(overlay, _)| overlay.priority);
4578
4579        // Use the lsp-diagnostic namespace to identify diagnostic overlays
4580        // Key by line-start byte so lookups match line_start_byte in render loop
4581        let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
4582        let diagnostic_lines: HashSet<usize> = viewport_overlays
4583            .iter()
4584            .filter_map(|(overlay, range)| {
4585                if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
4586                    return Some(indent_folding::find_line_start_byte(
4587                        &state.buffer,
4588                        range.start,
4589                    ));
4590                }
4591                None
4592            })
4593            .collect();
4594
4595        // Build inline diagnostic text map from the same viewport overlays.
4596        // For each line with diagnostics, keep only the highest-priority (severity) message.
4597        let diagnostic_inline_texts: HashMap<usize, (String, Style)> = if diagnostics_inline_text {
4598            let mut by_line: HashMap<usize, (String, Style, i32)> = HashMap::new();
4599            for (overlay, range) in &viewport_overlays {
4600                if overlay.namespace.as_ref() != Some(&diagnostic_ns) {
4601                    continue;
4602                }
4603                if let Some(ref message) = overlay.message {
4604                    let line_start =
4605                        indent_folding::find_line_start_byte(&state.buffer, range.start);
4606                    let priority = overlay.priority;
4607                    let dominated = by_line
4608                        .get(&line_start)
4609                        .is_some_and(|(_, _, existing_pri)| *existing_pri >= priority);
4610                    if !dominated {
4611                        let style = inline_diagnostic_style(priority, theme);
4612                        // Take first line of multi-line messages
4613                        let first_line = message.lines().next().unwrap_or(message);
4614                        by_line.insert(line_start, (first_line.to_string(), style, priority));
4615                    }
4616                }
4617            }
4618            by_line
4619                .into_iter()
4620                .map(|(k, (msg, style, _))| (k, (msg, style)))
4621                .collect()
4622        } else {
4623            HashMap::new()
4624        };
4625
4626        let virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>> =
4627            state
4628                .virtual_texts
4629                .build_lookup(&state.marker_list, viewport_start, viewport_end)
4630                .into_iter()
4631                .map(|(position, texts)| (position, texts.into_iter().cloned().collect()))
4632                .collect();
4633
4634        // Pre-compute line indicators for the viewport (only query markers in visible range)
4635        // Key by line-start byte so lookups match line_start_byte in render loop
4636        let mut line_indicators = state.margins.get_indicators_for_viewport(
4637            viewport_start,
4638            viewport_end,
4639            |byte_offset| indent_folding::find_line_start_byte(&state.buffer, byte_offset),
4640        );
4641
4642        // Merge native diff-since-saved indicators (cornflower blue │ for unsaved edits).
4643        // These have priority 5, lower than git gutter (10), so existing indicators win.
4644        let diff_indicators =
4645            Self::diff_indicators_for_viewport(state, viewport_start, viewport_end);
4646        for (key, diff_ind) in diff_indicators {
4647            line_indicators.entry(key).or_insert(diff_ind);
4648        }
4649
4650        let fold_indicators =
4651            Self::fold_indicators_for_viewport(state, folds, viewport_start, viewport_end);
4652
4653        DecorationContext {
4654            highlight_spans,
4655            semantic_token_spans,
4656            viewport_overlays,
4657            virtual_text_lookup,
4658            diagnostic_lines,
4659            diagnostic_inline_texts,
4660            line_indicators,
4661            fold_indicators,
4662        }
4663    }
4664
4665    fn fold_indicators_for_viewport(
4666        state: &EditorState,
4667        folds: &FoldManager,
4668        viewport_start: usize,
4669        viewport_end: usize,
4670    ) -> BTreeMap<usize, FoldIndicator> {
4671        let mut indicators = BTreeMap::new();
4672
4673        // Collapsed headers from marker-based folds — always keyed by header_byte
4674        for range in folds.resolved_ranges(&state.buffer, &state.marker_list) {
4675            indicators.insert(range.header_byte, FoldIndicator { collapsed: true });
4676        }
4677
4678        if !state.folding_ranges.is_empty() {
4679            // Use LSP-provided folding ranges — key by line-start byte
4680            for range in &state.folding_ranges {
4681                let start_line = range.start_line as usize;
4682                let end_line = range.end_line as usize;
4683                if end_line <= start_line {
4684                    continue;
4685                }
4686                if let Some(line_byte) = state.buffer.line_start_offset(start_line) {
4687                    indicators
4688                        .entry(line_byte)
4689                        .or_insert(FoldIndicator { collapsed: false });
4690                }
4691            }
4692        } else {
4693            // Indent-based fold detection on viewport bytes — key by absolute byte offset
4694            use crate::view::folding::indent_folding;
4695            let tab_size = state.buffer_settings.tab_size;
4696            let max_lookahead = crate::config::INDENT_FOLD_INDICATOR_MAX_SCAN;
4697            let bytes = state.buffer.slice_bytes(viewport_start..viewport_end);
4698            if !bytes.is_empty() {
4699                let foldable =
4700                    indent_folding::foldable_lines_in_bytes(&bytes, tab_size, max_lookahead);
4701                for line_idx in foldable {
4702                    let byte_off = Self::byte_offset_of_line_in_bytes(&bytes, line_idx);
4703                    indicators
4704                        .entry(viewport_start + byte_off)
4705                        .or_insert(FoldIndicator { collapsed: false });
4706                }
4707            }
4708        }
4709
4710        indicators
4711    }
4712
4713    /// Compute diff-since-saved indicators for the viewport.
4714    ///
4715    /// Calls `diff_since_saved()` to get byte ranges that differ from the saved
4716    /// version, intersects them with the viewport, and scans for line starts to
4717    /// produce per-line indicators. Works identically with and without line
4718    /// number metadata (byte-offset mode for large files).
4719    fn diff_indicators_for_viewport(
4720        state: &EditorState,
4721        viewport_start: usize,
4722        viewport_end: usize,
4723    ) -> BTreeMap<usize, crate::view::margin::LineIndicator> {
4724        use crate::view::folding::indent_folding;
4725        let diff = state.buffer.diff_since_saved();
4726        if diff.equal || diff.byte_ranges.is_empty() {
4727            return BTreeMap::new();
4728        }
4729
4730        let mut indicators = BTreeMap::new();
4731        let indicator = crate::view::margin::LineIndicator::new(
4732            "│",
4733            Color::Rgb(100, 149, 237), // Cornflower blue
4734            5,                         // Lower priority than git gutter (10)
4735        );
4736
4737        let bytes = state.buffer.slice_bytes(viewport_start..viewport_end);
4738        if bytes.is_empty() {
4739            return indicators;
4740        }
4741
4742        for range in &diff.byte_ranges {
4743            // Intersect diff range with viewport
4744            let lo = range.start.max(viewport_start);
4745            let hi = range.end.min(viewport_end);
4746            if lo >= hi {
4747                continue;
4748            }
4749
4750            // Mark the line containing the start of this diff range
4751            let line_start = indent_folding::find_line_start_byte(&state.buffer, lo);
4752            if line_start >= viewport_start && line_start < viewport_end {
4753                indicators
4754                    .entry(line_start)
4755                    .or_insert_with(|| indicator.clone());
4756            }
4757
4758            // Scan forward for \n within [lo..hi] to find subsequent line starts
4759            let rel_lo = lo - viewport_start;
4760            let rel_hi = (hi - viewport_start).min(bytes.len());
4761            for (i, &byte) in bytes[rel_lo..rel_hi].iter().enumerate() {
4762                if byte == b'\n' {
4763                    let next_line_start = viewport_start + rel_lo + i + 1;
4764                    if next_line_start < viewport_end {
4765                        indicators
4766                            .entry(next_line_start)
4767                            .or_insert_with(|| indicator.clone());
4768                    }
4769                }
4770            }
4771        }
4772
4773        indicators
4774    }
4775
4776    /// Given a byte slice, return the byte offset of line N (0-indexed)
4777    /// within that slice.
4778    fn byte_offset_of_line_in_bytes(bytes: &[u8], line_idx: usize) -> usize {
4779        let mut current_line = 0;
4780        for (i, &b) in bytes.iter().enumerate() {
4781            if current_line == line_idx {
4782                return i;
4783            }
4784            if b == b'\n' {
4785                current_line += 1;
4786            }
4787        }
4788        // If we exhausted the bytes without reaching the line, return end
4789        bytes.len()
4790    }
4791
4792    // semantic token colors are mapped when overlays are created
4793
4794    fn calculate_viewport_end(
4795        state: &mut EditorState,
4796        viewport_start: usize,
4797        estimated_line_length: usize,
4798        visible_count: usize,
4799    ) -> usize {
4800        let mut iter_temp = state
4801            .buffer
4802            .line_iterator(viewport_start, estimated_line_length);
4803        let mut viewport_end = viewport_start;
4804        for _ in 0..visible_count {
4805            if let Some((line_start, line_content)) = iter_temp.next_line() {
4806                viewport_end = line_start + line_content.len();
4807            } else {
4808                break;
4809            }
4810        }
4811        viewport_end
4812    }
4813
4814    fn render_view_lines(input: LineRenderInput<'_>) -> LineRenderOutput {
4815        use crate::view::folding::indent_folding;
4816
4817        let LineRenderInput {
4818            state,
4819            theme,
4820            view_lines,
4821            view_anchor,
4822            render_area,
4823            gutter_width,
4824            selection,
4825            decorations,
4826            visible_line_count,
4827            lsp_waiting,
4828            is_active,
4829            line_wrap,
4830            estimated_lines,
4831            left_column,
4832            relative_line_numbers,
4833            session_mode,
4834            software_cursor_only,
4835            show_line_numbers,
4836            byte_offset_mode,
4837            show_tilde,
4838            highlight_current_line,
4839            cell_theme_map,
4840            screen_width,
4841        } = input;
4842
4843        // Fill the entire content area with default editor bg/gutter theme info
4844        if screen_width > 0 {
4845            let gutter_info = crate::app::types::CellThemeInfo {
4846                fg_key: Some("editor.line_number_fg"),
4847                bg_key: Some("editor.line_number_bg"),
4848                region: "Line Numbers",
4849                syntax_category: None,
4850            };
4851            let content_info = crate::app::types::CellThemeInfo {
4852                fg_key: Some("editor.fg"),
4853                bg_key: Some("editor.bg"),
4854                region: "Editor Content",
4855                syntax_category: None,
4856            };
4857            let sw = screen_width as usize;
4858            for row in render_area.y..render_area.y + render_area.height {
4859                for col in render_area.x..render_area.x + render_area.width {
4860                    let idx = row as usize * sw + col as usize;
4861                    if let Some(cell) = cell_theme_map.get_mut(idx) {
4862                        *cell = if col < render_area.x + gutter_width as u16 {
4863                            gutter_info.clone()
4864                        } else {
4865                            content_info.clone()
4866                        };
4867                    }
4868                }
4869            }
4870        }
4871
4872        let selection_ranges = &selection.ranges;
4873        let block_selections = &selection.block_rects;
4874        let cursor_positions = &selection.cursor_positions;
4875        let primary_cursor_position = selection.primary_cursor_position;
4876
4877        // Compute cursor line start byte — universal key for cursor line highlight
4878        let cursor_line_start_byte =
4879            indent_folding::find_line_start_byte(&state.buffer, primary_cursor_position);
4880
4881        let highlight_spans = &decorations.highlight_spans;
4882        let semantic_token_spans = &decorations.semantic_token_spans;
4883        let viewport_overlays = &decorations.viewport_overlays;
4884        let virtual_text_lookup = &decorations.virtual_text_lookup;
4885        let diagnostic_lines = &decorations.diagnostic_lines;
4886        let line_indicators = &decorations.line_indicators;
4887
4888        // Cursors for O(1) amortized span lookups (spans are sorted by byte range)
4889        let mut hl_cursor = 0usize;
4890        let mut sem_cursor = 0usize;
4891
4892        let mut lines = Vec::new();
4893        let mut view_line_mappings = Vec::new();
4894        let mut lines_rendered = 0usize;
4895        let mut view_iter_idx = view_anchor.start_line_idx;
4896        let mut cursor_screen_x = 0u16;
4897        let mut cursor_screen_y = 0u16;
4898        let mut have_cursor = false;
4899        let mut last_line_end: Option<LastLineEnd> = None;
4900        let mut last_gutter_num: Option<usize> = None;
4901        let mut trailing_empty_line_rendered = false;
4902        let mut is_on_cursor_line = false;
4903
4904        let is_empty_buffer = state.buffer.is_empty();
4905
4906        // Track cursor position during rendering (eliminates duplicate line iteration)
4907        let mut last_visible_x: u16 = 0;
4908        let _view_start_line_skip = view_anchor.start_line_skip; // Currently unused
4909
4910        loop {
4911            // Get the current ViewLine from the pipeline
4912            let current_view_line = if let Some(vl) = view_lines.get(view_iter_idx) {
4913                vl
4914            } else if is_empty_buffer && lines_rendered == 0 {
4915                // Handle empty buffer case - create a minimal line
4916                static EMPTY_LINE: std::sync::OnceLock<ViewLine> = std::sync::OnceLock::new();
4917                EMPTY_LINE.get_or_init(|| ViewLine {
4918                    text: String::new(),
4919                    char_source_bytes: Vec::new(),
4920                    char_styles: Vec::new(),
4921                    char_visual_cols: Vec::new(),
4922                    visual_to_char: Vec::new(),
4923                    tab_starts: HashSet::new(),
4924                    line_start: LineStart::Beginning,
4925                    ends_with_newline: false,
4926                })
4927            } else {
4928                break;
4929            };
4930
4931            // Extract line data
4932            let line_content = current_view_line.text.clone();
4933            let line_has_newline = current_view_line.ends_with_newline;
4934            let line_char_source_bytes = &current_view_line.char_source_bytes;
4935            let line_char_styles = &current_view_line.char_styles;
4936            let line_visual_to_char = &current_view_line.visual_to_char;
4937            let line_tab_starts = &current_view_line.tab_starts;
4938            let _line_start_type = current_view_line.line_start;
4939
4940            // Pre-compute whitespace position boundaries for this view line.
4941            // first_non_ws: index of first non-whitespace char (None if all whitespace)
4942            // last_non_ws: index of last non-whitespace char (None if all whitespace)
4943            let line_chars_for_ws: Vec<char> = line_content.chars().collect();
4944            let first_non_ws_idx = line_chars_for_ws
4945                .iter()
4946                .position(|&c| c != ' ' && c != '\n' && c != '\r');
4947            let last_non_ws_idx = line_chars_for_ws
4948                .iter()
4949                .rposition(|&c| c != ' ' && c != '\n' && c != '\r');
4950
4951            // Helper to get source byte at a visual column using the new O(1) lookup
4952            let _source_byte_at_col = |vis_col: usize| -> Option<usize> {
4953                let char_idx = line_visual_to_char.get(vis_col).copied()?;
4954                line_char_source_bytes.get(char_idx).copied().flatten()
4955            };
4956
4957            view_iter_idx += 1;
4958
4959            if lines_rendered >= visible_line_count {
4960                break;
4961            }
4962
4963            // Use the elegant pipeline's should_show_line_number function
4964            // This correctly handles: injected content, wrapped continuations, and source lines
4965            let show_line_number = should_show_line_number(current_view_line);
4966
4967            // is_continuation means "don't show line number" for rendering purposes
4968            let is_continuation = !show_line_number;
4969
4970            // Per-line byte offset — universal key for all fold/diagnostic/indicator lookups
4971            let line_start_byte: Option<usize> = if !is_continuation {
4972                line_char_source_bytes
4973                    .iter()
4974                    .find_map(|opt| *opt)
4975                    .or_else(|| {
4976                        // Trailing empty line (after final newline) has no source bytes,
4977                        // but its logical position is buffer.len() — needed for diagnostic
4978                        // gutter markers placed at the end of the file.
4979                        if line_content.is_empty()
4980                            && _line_start_type == LineStart::AfterSourceNewline
4981                        {
4982                            Some(state.buffer.len())
4983                        } else {
4984                            None
4985                        }
4986                    })
4987            } else {
4988                None
4989            };
4990
4991            // Track whether this line is the cursor line (for current line highlighting).
4992            // Non-continuation lines check their start byte; continuation lines inherit.
4993            if !is_continuation {
4994                is_on_cursor_line = line_start_byte.is_some_and(|b| b == cursor_line_start_byte);
4995            }
4996
4997            // Gutter display number — line number for small files, byte offset for large files
4998            let gutter_num = if let Some(byte) = line_start_byte {
4999                let n = if byte_offset_mode {
5000                    byte
5001                } else {
5002                    state.buffer.get_line_number(byte)
5003                };
5004                last_gutter_num = Some(n);
5005                n
5006            } else if !is_continuation {
5007                // Non-continuation line with no source bytes (trailing empty line
5008                // produced by ViewLineIterator after final newline).
5009                // For empty buffers (last_gutter_num is None), show line 0 (displays as "1").
5010                last_gutter_num.map_or(0, |n| n + 1)
5011            } else {
5012                0
5013            };
5014
5015            lines_rendered += 1;
5016
5017            // Apply horizontal scrolling - skip characters before left_column
5018            let left_col = left_column;
5019
5020            // Build line with selection highlighting
5021            let mut line_spans = Vec::new();
5022            let mut line_view_map: Vec<Option<usize>> = Vec::new();
5023            let mut last_seg_y: Option<u16> = None;
5024            let mut _last_seg_width: usize = 0;
5025
5026            // Accumulator for merging consecutive characters with the same style
5027            // This is critical for proper rendering of combining characters (Thai, etc.)
5028            let mut span_acc = SpanAccumulator::new();
5029
5030            // Render left margin (indicators + line numbers + separator)
5031            render_left_margin(
5032                &LeftMarginContext {
5033                    state,
5034                    theme,
5035                    is_continuation,
5036                    line_start_byte,
5037                    gutter_num,
5038                    estimated_lines,
5039                    diagnostic_lines,
5040                    line_indicators,
5041                    fold_indicators: &decorations.fold_indicators,
5042                    cursor_line_start_byte,
5043                    cursor_line_number: state.primary_cursor_line_number.value(),
5044                    relative_line_numbers,
5045                    show_line_numbers,
5046                    byte_offset_mode,
5047                    highlight_current_line,
5048                    is_active,
5049                },
5050                &mut line_spans,
5051                &mut line_view_map,
5052            );
5053
5054            // Check if this line has any selected text
5055            let mut byte_index = 0; // Byte offset in line_content string
5056            let mut display_char_idx = 0usize; // Character index in text (for char_source_bytes)
5057            let mut col_offset = 0usize; // Visual column position
5058
5059            // Performance optimization: For very long lines, only process visible characters
5060            // Calculate the maximum characters we might need to render based on screen width
5061            // For wrapped lines, we need enough characters to fill the visible viewport
5062            // For non-wrapped lines, we only need one screen width worth
5063            let visible_lines_remaining = visible_line_count.saturating_sub(lines_rendered);
5064            let max_visible_chars = if line_wrap {
5065                // With wrapping: might need chars for multiple wrapped lines
5066                // Be generous to avoid cutting off wrapped content
5067                (render_area.width as usize)
5068                    .saturating_mul(visible_lines_remaining.max(1))
5069                    .saturating_add(200)
5070            } else {
5071                // Without wrapping: only need one line worth of characters
5072                (render_area.width as usize).saturating_add(100)
5073            };
5074            let max_chars_to_process = left_col.saturating_add(max_visible_chars);
5075
5076            // ANSI parser for this line to handle escape sequences
5077            // Optimization: only create parser if line contains ESC byte
5078            let line_has_ansi = line_content.contains('\x1b');
5079            let mut ansi_parser = if line_has_ansi {
5080                Some(AnsiParser::new())
5081            } else {
5082                None
5083            };
5084            // Track visible characters separately from byte position for ANSI handling
5085            let mut visible_char_count = 0usize;
5086
5087            // Debug mode: track active highlight/overlay spans for WordPerfect-style reveal codes
5088            let mut debug_tracker = if state.debug_highlight_mode {
5089                Some(DebugSpanTracker::default())
5090            } else {
5091                None
5092            };
5093
5094            // Track byte positions for extend_to_line_end feature
5095            let mut first_line_byte_pos: Option<usize> = None;
5096            let mut last_line_byte_pos: Option<usize> = None;
5097
5098            let chars_iterator = line_content.chars().peekable();
5099            for ch in chars_iterator {
5100                // Get source byte for this character using character index
5101                // (char_source_bytes is indexed by character position, not visual column)
5102                let byte_pos = line_char_source_bytes
5103                    .get(display_char_idx)
5104                    .copied()
5105                    .flatten();
5106
5107                // Track byte positions for extend_to_line_end
5108                if let Some(bp) = byte_pos {
5109                    if first_line_byte_pos.is_none() {
5110                        first_line_byte_pos = Some(bp);
5111                    }
5112                    last_line_byte_pos = Some(bp);
5113                }
5114
5115                // Process character through ANSI parser first (if line has ANSI)
5116                // If parser returns None, the character is part of an escape sequence and should be skipped
5117                let ansi_style = if let Some(ref mut parser) = ansi_parser {
5118                    match parser.parse_char(ch) {
5119                        Some(style) => style,
5120                        None => {
5121                            // This character is part of an ANSI escape sequence, skip it
5122                            // ANSI escape chars have zero visual width, so don't increment col_offset
5123                            // IMPORTANT: If the cursor is on this ANSI byte, track it
5124                            if let Some(bp) = byte_pos {
5125                                if bp == primary_cursor_position && !have_cursor {
5126                                    // Account for horizontal scrolling by using col_offset - left_col
5127                                    cursor_screen_x = gutter_width as u16
5128                                        + col_offset.saturating_sub(left_col) as u16;
5129                                    cursor_screen_y = lines_rendered.saturating_sub(1) as u16;
5130                                    have_cursor = true;
5131                                }
5132                            }
5133                            byte_index += ch.len_utf8();
5134                            display_char_idx += 1;
5135                            // Note: col_offset not incremented - ANSI chars have 0 visual width
5136                            continue;
5137                        }
5138                    }
5139                } else {
5140                    // No ANSI in this line - use default style (fast path)
5141                    Style::default()
5142                };
5143
5144                // Performance: skip expensive style calculations for characters beyond visible range
5145                // Use visible_char_count (not byte_index) since ANSI codes don't take up visible space
5146                if visible_char_count > max_chars_to_process {
5147                    // Fast path: skip remaining characters without processing
5148                    // This is critical for performance with very long lines (e.g., 100KB single line)
5149                    break;
5150                }
5151
5152                // Skip characters before left_column
5153                if col_offset >= left_col {
5154                    // Check if this view position is the START of a tab expansion
5155                    let is_tab_start = line_tab_starts.contains(&col_offset);
5156
5157                    // Check if this character is at a cursor position
5158                    // For tab expansions: only show cursor on the FIRST space (the tab_start position)
5159                    // This prevents cursor from appearing on all 8 expanded spaces
5160                    let is_cursor = byte_pos
5161                        .map(|bp| {
5162                            if !cursor_positions.contains(&bp) || bp >= state.buffer.len() {
5163                                return false;
5164                            }
5165                            // If this byte maps to a tab character, only show cursor at tab_start
5166                            // Check if this is part of a tab expansion by looking at previous char
5167                            let prev_char_idx = display_char_idx.saturating_sub(1);
5168                            let prev_byte_pos =
5169                                line_char_source_bytes.get(prev_char_idx).copied().flatten();
5170                            // Show cursor if: this is start of line, OR previous char had different byte pos
5171                            display_char_idx == 0 || prev_byte_pos != Some(bp)
5172                        })
5173                        .unwrap_or(false);
5174
5175                    // Check if this character is in any selection range (but not at cursor position)
5176                    // Also check for block/rectangular selections (uses gutter_num which is
5177                    // the line number for small files — block_rects stores line numbers)
5178                    let is_in_block_selection = block_selections.iter().any(
5179                        |(start_line, start_col, end_line, end_col)| {
5180                            gutter_num >= *start_line
5181                                && gutter_num <= *end_line
5182                                && byte_index >= *start_col
5183                                && byte_index <= *end_col
5184                        },
5185                    );
5186
5187                    // For primary cursor in active split, terminal hardware cursor provides
5188                    // visual indication, so we can still show selection background.
5189                    // Only exclude secondary cursors from selection (they use REVERSED styling).
5190                    // Bug #614: Previously excluded all cursor positions, causing first char
5191                    // of selection to display with wrong background for bar/underline cursors.
5192                    let is_primary_cursor = is_cursor && byte_pos == Some(primary_cursor_position);
5193                    let exclude_from_selection = is_cursor && !(is_active && is_primary_cursor);
5194
5195                    let is_selected = !exclude_from_selection
5196                        && (byte_pos.is_some_and(|bp| {
5197                            selection_ranges.iter().any(|range| range.contains(&bp))
5198                        }) || is_in_block_selection);
5199
5200                    // Compute character style using helper function
5201                    // char_styles is indexed by character position, not visual column
5202                    let token_style = line_char_styles
5203                        .get(display_char_idx)
5204                        .and_then(|s| s.as_ref());
5205
5206                    // Resolve highlight/semantic colors via cursor-based O(1) lookup
5207                    let (highlight_color, highlight_theme_key, highlight_display_name) =
5208                        match byte_pos {
5209                            Some(bp) => span_info_at(highlight_spans, &mut hl_cursor, bp),
5210                            None => (None, None, None),
5211                        };
5212                    let semantic_token_color = match byte_pos {
5213                        Some(bp) => span_color_at(semantic_token_spans, &mut sem_cursor, bp),
5214                        None => None,
5215                    };
5216
5217                    let CharStyleOutput {
5218                        mut style,
5219                        is_secondary_cursor,
5220                        fg_theme_key,
5221                        bg_theme_key,
5222                        region: cell_region,
5223                    } = compute_char_style(&CharStyleContext {
5224                        byte_pos,
5225                        token_style,
5226                        ansi_style,
5227                        is_cursor,
5228                        is_selected,
5229                        theme,
5230                        highlight_color,
5231                        highlight_theme_key,
5232                        semantic_token_color,
5233                        viewport_overlays,
5234                        primary_cursor_position,
5235                        is_active,
5236                        skip_primary_cursor_reverse: session_mode,
5237                        is_cursor_line_highlighted: is_on_cursor_line
5238                            && highlight_current_line
5239                            && is_active,
5240                        current_line_bg: theme.current_line_bg,
5241                    });
5242
5243                    // Record cell theme info for the theme inspector popup
5244                    if screen_width > 0 {
5245                        let screen_col = render_area.x
5246                            + gutter_width as u16
5247                            + col_offset.saturating_sub(left_col) as u16;
5248                        let screen_row = render_area.y + lines.len() as u16;
5249                        let idx = screen_row as usize * screen_width as usize + screen_col as usize;
5250                        if let Some(cell) = cell_theme_map.get_mut(idx) {
5251                            *cell = crate::app::types::CellThemeInfo {
5252                                fg_key: fg_theme_key,
5253                                bg_key: bg_theme_key,
5254                                region: cell_region,
5255                                syntax_category: highlight_display_name,
5256                            };
5257                        }
5258                    }
5259
5260                    // Determine display character (tabs already expanded in ViewLineIterator)
5261                    // Show tab indicator (→) or space indicator (·) based on granular
5262                    // whitespace visibility settings (leading/inner/trailing positions)
5263                    let indicator_buf: String;
5264                    let mut is_whitespace_indicator = false;
5265
5266                    // Classify whitespace position: leading, inner, or trailing
5267                    // Leading = before first non-ws char, Trailing = after last non-ws char
5268                    // All-whitespace lines match both leading and trailing
5269                    let ws_show_tab = is_tab_start && {
5270                        let ws = &state.buffer_settings.whitespace;
5271                        match (first_non_ws_idx, last_non_ws_idx) {
5272                            (None, _) | (_, None) => ws.tabs_leading || ws.tabs_trailing,
5273                            (Some(first), Some(last)) => {
5274                                if display_char_idx < first {
5275                                    ws.tabs_leading
5276                                } else if display_char_idx > last {
5277                                    ws.tabs_trailing
5278                                } else {
5279                                    ws.tabs_inner
5280                                }
5281                            }
5282                        }
5283                    };
5284                    let ws_show_space = ch == ' ' && !is_tab_start && {
5285                        let ws = &state.buffer_settings.whitespace;
5286                        match (first_non_ws_idx, last_non_ws_idx) {
5287                            (None, _) | (_, None) => ws.spaces_leading || ws.spaces_trailing,
5288                            (Some(first), Some(last)) => {
5289                                if display_char_idx < first {
5290                                    ws.spaces_leading
5291                                } else if display_char_idx > last {
5292                                    ws.spaces_trailing
5293                                } else {
5294                                    ws.spaces_inner
5295                                }
5296                            }
5297                        }
5298                    };
5299
5300                    let display_char: &str = if is_cursor && lsp_waiting && is_active {
5301                        "⋯"
5302                    } else if debug_tracker.is_some() && ch == '\r' {
5303                        // Debug mode: show CR explicitly
5304                        "\\r"
5305                    } else if debug_tracker.is_some() && ch == '\n' {
5306                        // Debug mode: show LF explicitly
5307                        "\\n"
5308                    } else if ch == '\n' {
5309                        ""
5310                    } else if ws_show_tab {
5311                        // Visual indicator for tab: show → at the first position
5312                        is_whitespace_indicator = true;
5313                        indicator_buf = "→".to_string();
5314                        &indicator_buf
5315                    } else if ws_show_space {
5316                        // Visual indicator for space: show · when enabled
5317                        is_whitespace_indicator = true;
5318                        indicator_buf = "·".to_string();
5319                        &indicator_buf
5320                    } else {
5321                        indicator_buf = ch.to_string();
5322                        &indicator_buf
5323                    };
5324
5325                    // Apply subdued whitespace indicator color from theme
5326                    if is_whitespace_indicator && !is_cursor && !is_selected {
5327                        style = style.fg(theme.whitespace_indicator_fg);
5328                    }
5329
5330                    if let Some(bp) = byte_pos {
5331                        if let Some(vtexts) = virtual_text_lookup.get(&bp) {
5332                            for vtext in vtexts
5333                                .iter()
5334                                .filter(|v| v.position == VirtualTextPosition::BeforeChar)
5335                            {
5336                                // Flush accumulated text before inserting virtual text
5337                                span_acc.flush(&mut line_spans, &mut line_view_map);
5338                                // Add extra space if at end of line (before newline)
5339                                let extra_space = if ch == '\n' { " " } else { "" };
5340                                let text_with_space = format!("{}{} ", extra_space, vtext.text);
5341                                push_span_with_map(
5342                                    &mut line_spans,
5343                                    &mut line_view_map,
5344                                    text_with_space,
5345                                    vtext.style,
5346                                    None,
5347                                );
5348                            }
5349                        }
5350                    }
5351
5352                    if !display_char.is_empty() {
5353                        // Debug mode: insert opening tags for spans starting at this position
5354                        if let Some(ref mut tracker) = debug_tracker {
5355                            // Flush before debug tags
5356                            span_acc.flush(&mut line_spans, &mut line_view_map);
5357                            let opening_tags = tracker.get_opening_tags(
5358                                byte_pos,
5359                                highlight_spans,
5360                                viewport_overlays,
5361                            );
5362                            for tag in opening_tags {
5363                                push_debug_tag(&mut line_spans, &mut line_view_map, tag);
5364                            }
5365                        }
5366
5367                        // Debug mode: show byte position before each character
5368                        if debug_tracker.is_some() {
5369                            if let Some(bp) = byte_pos {
5370                                push_debug_tag(
5371                                    &mut line_spans,
5372                                    &mut line_view_map,
5373                                    format!("[{}]", bp),
5374                                );
5375                            }
5376                        }
5377
5378                        // Use accumulator to merge consecutive chars with same style
5379                        // This is critical for combining characters (Thai diacritics, etc.)
5380                        for c in display_char.chars() {
5381                            span_acc.push(c, style, byte_pos, &mut line_spans, &mut line_view_map);
5382                        }
5383
5384                        // Debug mode: insert closing tags for spans ending at this position
5385                        // Check using the NEXT byte position to see if we're leaving a span
5386                        if let Some(ref mut tracker) = debug_tracker {
5387                            // Flush before debug tags
5388                            span_acc.flush(&mut line_spans, &mut line_view_map);
5389                            // Look ahead to next byte position to determine closing tags
5390                            let next_byte_pos = byte_pos.map(|bp| bp + ch.len_utf8());
5391                            let closing_tags = tracker.get_closing_tags(next_byte_pos);
5392                            for tag in closing_tags {
5393                                push_debug_tag(&mut line_spans, &mut line_view_map, tag);
5394                            }
5395                        }
5396                    }
5397
5398                    // Track cursor position for zero-width characters
5399                    // Zero-width chars don't get map entries, so we need to explicitly record cursor pos
5400                    if !have_cursor {
5401                        if let Some(bp) = byte_pos {
5402                            if bp == primary_cursor_position && char_width(ch) == 0 {
5403                                // Account for horizontal scrolling by subtracting left_col
5404                                cursor_screen_x = gutter_width as u16
5405                                    + col_offset.saturating_sub(left_col) as u16;
5406                                cursor_screen_y = lines.len() as u16;
5407                                have_cursor = true;
5408                            }
5409                        }
5410                    }
5411
5412                    if let Some(bp) = byte_pos {
5413                        if let Some(vtexts) = virtual_text_lookup.get(&bp) {
5414                            for vtext in vtexts
5415                                .iter()
5416                                .filter(|v| v.position == VirtualTextPosition::AfterChar)
5417                            {
5418                                let text_with_space = format!(" {}", vtext.text);
5419                                push_span_with_map(
5420                                    &mut line_spans,
5421                                    &mut line_view_map,
5422                                    text_with_space,
5423                                    vtext.style,
5424                                    None,
5425                                );
5426                            }
5427                        }
5428                    }
5429
5430                    if is_cursor && ch == '\n' {
5431                        let should_add_indicator =
5432                            if is_active { is_secondary_cursor } else { true };
5433                        if should_add_indicator {
5434                            // Flush accumulated text before adding cursor indicator
5435                            // so the indicator appears after the line content, not before
5436                            span_acc.flush(&mut line_spans, &mut line_view_map);
5437                            let cursor_style = if is_active {
5438                                Style::default()
5439                                    .fg(theme.editor_fg)
5440                                    .bg(theme.editor_bg)
5441                                    .add_modifier(Modifier::REVERSED)
5442                            } else {
5443                                Style::default()
5444                                    .fg(theme.editor_fg)
5445                                    .bg(theme.inactive_cursor)
5446                            };
5447                            push_span_with_map(
5448                                &mut line_spans,
5449                                &mut line_view_map,
5450                                " ".to_string(),
5451                                cursor_style,
5452                                byte_pos,
5453                            );
5454                        }
5455                    }
5456                }
5457
5458                byte_index += ch.len_utf8();
5459                display_char_idx += 1; // Increment character index for next lookup
5460                                       // col_offset tracks visual column position (for indexing into visual_to_char)
5461                                       // visual_to_char has one entry per visual column, not per character
5462                let ch_width = char_width(ch);
5463                col_offset += ch_width;
5464                visible_char_count += ch_width;
5465            }
5466
5467            // Flush any remaining accumulated text at end of line
5468            span_acc.flush(&mut line_spans, &mut line_view_map);
5469
5470            // Set last_seg_y early so cursor detection works for both empty and non-empty lines
5471            // For lines without wrapping, this will be the final y position
5472            // Also set for empty content lines (regardless of line_wrap) so cursor at EOF can be positioned
5473            let content_is_empty = line_content.is_empty();
5474            if line_spans.is_empty() || !line_wrap || content_is_empty {
5475                last_seg_y = Some(lines.len() as u16);
5476            }
5477
5478            if !line_has_newline {
5479                let line_len_chars = line_content.chars().count();
5480
5481                // Map view positions to buffer positions using per-line char_source_bytes
5482                let last_char_idx = line_len_chars.saturating_sub(1);
5483                let after_last_char_idx = line_len_chars;
5484
5485                let last_char_buf_pos =
5486                    line_char_source_bytes.get(last_char_idx).copied().flatten();
5487                let after_last_char_buf_pos = line_char_source_bytes
5488                    .get(after_last_char_idx)
5489                    .copied()
5490                    .flatten();
5491
5492                let cursor_at_end = cursor_positions.iter().any(|&pos| {
5493                    // Cursor is "at end" only if it's AFTER the last character, not ON it.
5494                    // A cursor ON the last character should render on that character (handled in main loop).
5495                    let matches_after = after_last_char_buf_pos.is_some_and(|bp| pos == bp);
5496                    // Fallback: when there's no mapping after last char (EOF), check if cursor is after last char
5497                    // The fallback should match the position that would be "after" if there was a mapping.
5498                    // For empty lines with no source mappings (e.g. trailing empty line after final '\n'),
5499                    // the expected position is buffer.len() (EOF), not 0.
5500                    let expected_after_pos = last_char_buf_pos
5501                        .map(|p| p + 1)
5502                        .unwrap_or(state.buffer.len());
5503                    let matches_fallback =
5504                        after_last_char_buf_pos.is_none() && pos == expected_after_pos;
5505
5506                    matches_after || matches_fallback
5507                });
5508
5509                if cursor_at_end {
5510                    // Primary cursor is at end only if AFTER the last char, not ON it
5511                    let is_primary_at_end = after_last_char_buf_pos
5512                        .is_some_and(|bp| bp == primary_cursor_position)
5513                        || (after_last_char_buf_pos.is_none()
5514                            && primary_cursor_position >= state.buffer.len());
5515
5516                    // Track cursor position for primary cursor
5517                    if let Some(seg_y) = last_seg_y {
5518                        if is_primary_at_end {
5519                            // Cursor position now includes gutter width (consistent with main cursor tracking)
5520                            // For empty lines, cursor is at gutter width (right after gutter)
5521                            // For non-empty lines without newline, cursor is after the last visible character
5522                            // Account for horizontal scrolling by using col_offset - left_col
5523                            cursor_screen_x = if line_len_chars == 0 {
5524                                gutter_width as u16
5525                            } else {
5526                                // col_offset is the visual column after the last character
5527                                // Subtract left_col to get the screen position after horizontal scroll
5528                                gutter_width as u16 + col_offset.saturating_sub(left_col) as u16
5529                            };
5530                            cursor_screen_y = seg_y;
5531                            have_cursor = true;
5532                        }
5533                    }
5534
5535                    // When software_cursor_only, always add the indicator space because
5536                    // the backend does not render a hardware cursor.  In terminal mode,
5537                    // the primary cursor at end-of-line relies on the hardware cursor.
5538                    let should_add_indicator = if is_active {
5539                        software_cursor_only || !is_primary_at_end
5540                    } else {
5541                        true
5542                    };
5543                    if should_add_indicator {
5544                        let cursor_style = if is_active {
5545                            Style::default()
5546                                .fg(theme.editor_fg)
5547                                .bg(theme.editor_bg)
5548                                .add_modifier(Modifier::REVERSED)
5549                        } else {
5550                            Style::default()
5551                                .fg(theme.editor_fg)
5552                                .bg(theme.inactive_cursor)
5553                        };
5554                        push_span_with_map(
5555                            &mut line_spans,
5556                            &mut line_view_map,
5557                            " ".to_string(),
5558                            cursor_style,
5559                            None,
5560                        );
5561                    }
5562                }
5563            }
5564
5565            // ViewLines are already wrapped (Break tokens became newlines in ViewLineIterator)
5566            // so each line is one visual line - no need to wrap again
5567            let current_y = lines.len() as u16;
5568            last_seg_y = Some(current_y);
5569
5570            if !line_spans.is_empty() {
5571                // Find cursor position and track last visible x by iterating through line_view_map
5572                // Note: line_view_map includes both gutter and content character mappings
5573                //
5574                // When the cursor byte falls inside a concealed range (e.g. syntax markers
5575                // hidden by compose-mode plugins), no view_map entry will exactly match
5576                // primary_cursor_position.  In that case we fall back to the nearest
5577                // visible byte that is >= the cursor byte on the same line — this keeps
5578                // the cursor visible for the one frame between cursor movement and the
5579                // plugin's conceal-refresh response.
5580                let mut nearest_fallback: Option<(u16, usize)> = None; // (screen_x, byte_distance)
5581                for (screen_x, source_offset) in line_view_map.iter().enumerate() {
5582                    if let Some(src) = source_offset {
5583                        // Exact match: cursor byte is visible
5584                        if *src == primary_cursor_position && !have_cursor {
5585                            cursor_screen_x = screen_x as u16;
5586                            cursor_screen_y = current_y;
5587                            have_cursor = true;
5588                        }
5589                        // Track nearest visible byte >= cursor position for fallback
5590                        if !have_cursor && *src >= primary_cursor_position {
5591                            let dist = *src - primary_cursor_position;
5592                            if nearest_fallback.is_none() || dist < nearest_fallback.unwrap().1 {
5593                                nearest_fallback = Some((screen_x as u16, dist));
5594                            }
5595                        }
5596                        last_visible_x = screen_x as u16;
5597                    }
5598                }
5599                // Fallback: cursor byte was concealed — snap to nearest visible byte
5600                if !have_cursor {
5601                    if let Some((fallback_x, _)) = nearest_fallback {
5602                        cursor_screen_x = fallback_x;
5603                        cursor_screen_y = current_y;
5604                        have_cursor = true;
5605                    }
5606                }
5607            }
5608
5609            // Inline diagnostic text: render after line content (before extend_to_line_end fill).
5610            // Only for non-continuation lines that have a diagnostic overlay.
5611            if let Some(lsb) = line_start_byte {
5612                if let Some((message, diag_style)) = decorations.diagnostic_inline_texts.get(&lsb) {
5613                    let content_width =
5614                        render_area.width.saturating_sub(gutter_width as u16) as usize;
5615                    let used = visible_char_count;
5616                    let available = content_width.saturating_sub(used);
5617                    let gap = 2usize;
5618                    let min_text = 10usize;
5619
5620                    if available > gap + min_text {
5621                        // Truncate message to fit
5622                        let max_chars = available - gap;
5623                        let display: String = if message.chars().count() > max_chars {
5624                            let truncated: String =
5625                                message.chars().take(max_chars.saturating_sub(1)).collect();
5626                            format!("{}…", truncated)
5627                        } else {
5628                            message.clone()
5629                        };
5630                        let display_width = display.chars().count();
5631
5632                        // Right-align: fill gap between code and diagnostic text
5633                        let padding = available.saturating_sub(display_width);
5634                        let cursor_line_active =
5635                            is_on_cursor_line && highlight_current_line && is_active;
5636                        if padding > 0 {
5637                            let pad_style = if cursor_line_active {
5638                                Style::default().bg(theme.current_line_bg)
5639                            } else {
5640                                Style::default()
5641                            };
5642                            push_span_with_map(
5643                                &mut line_spans,
5644                                &mut line_view_map,
5645                                " ".repeat(padding),
5646                                pad_style,
5647                                None,
5648                            );
5649                            visible_char_count += padding;
5650                        }
5651
5652                        // Apply current line background to diagnostic text when on cursor line
5653                        let effective_diag_style = if cursor_line_active && diag_style.bg.is_none()
5654                        {
5655                            diag_style.bg(theme.current_line_bg)
5656                        } else {
5657                            *diag_style
5658                        };
5659                        push_span_with_map(
5660                            &mut line_spans,
5661                            &mut line_view_map,
5662                            display,
5663                            effective_diag_style,
5664                            None,
5665                        );
5666                        visible_char_count += display_width;
5667                    }
5668                }
5669            }
5670
5671            // Fill remaining width for overlays with extend_to_line_end
5672            // Only when line wrapping is disabled (side-by-side diff typically disables wrapping)
5673            if !line_wrap {
5674                // Calculate the content area width (total width minus gutter)
5675                let content_width = render_area.width.saturating_sub(gutter_width as u16) as usize;
5676                let remaining_cols = content_width.saturating_sub(visible_char_count);
5677
5678                if remaining_cols > 0 {
5679                    // Find the highest priority background color from overlays with extend_to_line_end
5680                    // that overlap with this line's byte range. Overlay ranges
5681                    // are half-open `[start, end)`, so an overlay whose end
5682                    // equals this line's first byte ends *before* the line
5683                    // begins and must NOT match — `range.end > start` (strict),
5684                    // not `>=`. With `>=`, an overlay covering the previous
5685                    // line's content + trailing newline would bleed its bg
5686                    // onto this line's trailing fill.
5687                    let fill_style: Option<Style> = if let (Some(start), Some(end)) =
5688                        (first_line_byte_pos, last_line_byte_pos)
5689                    {
5690                        viewport_overlays
5691                            .iter()
5692                            .filter(|(overlay, range)| {
5693                                overlay.extend_to_line_end
5694                                    && range.start <= end
5695                                    && range.end > start
5696                            })
5697                            .max_by_key(|(o, _)| o.priority)
5698                            .and_then(|(overlay, _)| {
5699                                match &overlay.face {
5700                                    crate::view::overlay::OverlayFace::Background { color } => {
5701                                        // Set both fg and bg to ensure ANSI codes are output
5702                                        Some(Style::default().fg(*color).bg(*color))
5703                                    }
5704                                    crate::view::overlay::OverlayFace::Style { style } => {
5705                                        // Extract background from style if present
5706                                        // Set fg to same as bg for invisible text
5707                                        style.bg.map(|bg| Style::default().fg(bg).bg(bg))
5708                                    }
5709                                    crate::view::overlay::OverlayFace::ThemedStyle {
5710                                        fallback_style,
5711                                        bg_theme,
5712                                        ..
5713                                    } => {
5714                                        // Try theme key first, fall back to style's bg
5715                                        let bg = bg_theme
5716                                            .as_ref()
5717                                            .and_then(|key| theme.resolve_theme_key(key))
5718                                            .or(fallback_style.bg);
5719                                        bg.map(|bg| Style::default().fg(bg).bg(bg))
5720                                    }
5721                                    _ => None,
5722                                }
5723                            })
5724                    } else {
5725                        None
5726                    };
5727
5728                    if let Some(fill_bg) = fill_style {
5729                        let fill_text = " ".repeat(remaining_cols);
5730                        push_span_with_map(
5731                            &mut line_spans,
5732                            &mut line_view_map,
5733                            fill_text,
5734                            fill_bg,
5735                            None,
5736                        );
5737                    }
5738                }
5739            }
5740
5741            // Fill remaining width with current_line_bg for cursor line highlighting.
5742            // Add the span directly (not via push_span_with_map) to avoid extending
5743            // line_view_map, which would break mouse click byte mapping.
5744            if is_on_cursor_line && highlight_current_line && is_active {
5745                let content_width = render_area.width.saturating_sub(gutter_width as u16) as usize;
5746                let remaining_cols = content_width.saturating_sub(visible_char_count);
5747                if remaining_cols > 0 {
5748                    span_acc.flush(&mut line_spans, &mut line_view_map);
5749                    line_spans.push(Span::styled(
5750                        " ".repeat(remaining_cols),
5751                        Style::default().bg(theme.current_line_bg),
5752                    ));
5753                }
5754            }
5755
5756            // For virtual rows (no source bytes), inherit from previous row
5757            let prev_line_end_byte = view_line_mappings
5758                .last()
5759                .map(|prev: &ViewLineMapping| prev.line_end_byte)
5760                .unwrap_or(0);
5761
5762            // Calculate line_end_byte for this line
5763            let line_end_byte = if current_view_line.ends_with_newline {
5764                // Position ON the newline - find the last source byte (the newline's position)
5765                current_view_line
5766                    .char_source_bytes
5767                    .iter()
5768                    .rev()
5769                    .find_map(|m| *m)
5770                    .unwrap_or(prev_line_end_byte)
5771            } else {
5772                // Position AFTER the last character - find last source byte and add char length
5773                if let Some((char_idx, &Some(last_byte_start))) = current_view_line
5774                    .char_source_bytes
5775                    .iter()
5776                    .enumerate()
5777                    .rev()
5778                    .find(|(_, m)| m.is_some())
5779                {
5780                    // Get the character at this index to find its UTF-8 byte length
5781                    if let Some(last_char) = current_view_line.text.chars().nth(char_idx) {
5782                        last_byte_start + last_char.len_utf8()
5783                    } else {
5784                        last_byte_start
5785                    }
5786                } else if matches!(current_view_line.line_start, LineStart::AfterSourceNewline)
5787                    && prev_line_end_byte + 2 >= state.buffer.len()
5788                {
5789                    // Trailing empty line after the final source newline.
5790                    // The cursor on this line lives at buffer_len.
5791                    state.buffer.len()
5792                } else {
5793                    // Virtual row with no source bytes (e.g. table border from conceals).
5794                    // Inherit line_end_byte from the previous row so cursor movement
5795                    // through virtual rows lands at a valid source position.
5796                    prev_line_end_byte
5797                }
5798            };
5799
5800            // Capture accurate view line mapping for mouse clicks
5801            // Content mapping starts after the gutter
5802            let content_map = if line_view_map.len() >= gutter_width {
5803                line_view_map[gutter_width..].to_vec()
5804            } else {
5805                Vec::new()
5806            };
5807            view_line_mappings.push(ViewLineMapping {
5808                char_source_bytes: content_map.clone(),
5809                visual_to_char: (0..content_map.len()).collect(),
5810                line_end_byte,
5811            });
5812
5813            // Track if line was empty before moving line_spans
5814            let line_was_empty = line_spans.is_empty();
5815            lines.push(Line::from(line_spans));
5816
5817            // Detect the trailing empty ViewLine produced by ViewLineIterator
5818            // when at_buffer_end is true: empty content, no newline,
5819            // line_start == AfterSourceNewline.  This is a visual display aid,
5820            // not an actual content line — don't update last_line_end for it
5821            // (same policy as the implicit empty line rendered below).
5822            let is_iterator_trailing_empty = line_content.is_empty()
5823                && !line_has_newline
5824                && _line_start_type == LineStart::AfterSourceNewline;
5825            if is_iterator_trailing_empty {
5826                trailing_empty_line_rendered = true;
5827            }
5828
5829            // Update last_line_end and check for cursor on newline BEFORE the break check
5830            // This ensures the last visible line's metadata is captured
5831            if let Some(y) = last_seg_y {
5832                // end_x is the cursor position after the last visible character.
5833                // For empty lines, last_visible_x stays at 0, so we need to ensure end_x is
5834                // at least gutter_width to place the cursor after the gutter, not in it.
5835                let end_x = if line_was_empty {
5836                    gutter_width as u16
5837                } else {
5838                    last_visible_x.saturating_add(1)
5839                };
5840                let line_len_chars = line_content.chars().count();
5841
5842                // Don't update last_line_end for the iterator's trailing empty
5843                // line — it's a display aid, not actual content.
5844                if !is_iterator_trailing_empty {
5845                    last_line_end = Some(LastLineEnd {
5846                        pos: (end_x, y),
5847                        terminated_with_newline: line_has_newline,
5848                    });
5849                }
5850
5851                if line_has_newline && line_len_chars > 0 {
5852                    let newline_idx = line_len_chars.saturating_sub(1);
5853                    if let Some(Some(src_newline)) = line_char_source_bytes.get(newline_idx) {
5854                        if *src_newline == primary_cursor_position {
5855                            // Cursor position now includes gutter width (consistent with main cursor tracking)
5856                            // For empty lines (just newline), cursor should be at gutter width (after gutter)
5857                            // For lines with content, cursor on newline should be after the content
5858                            if line_len_chars == 1 {
5859                                // Empty line - just the newline character
5860                                cursor_screen_x = gutter_width as u16;
5861                                cursor_screen_y = y;
5862                            } else {
5863                                // Line has content before the newline - cursor after last char
5864                                // end_x already includes gutter (from last_visible_x)
5865                                cursor_screen_x = end_x;
5866                                cursor_screen_y = y;
5867                            }
5868                            have_cursor = true;
5869                        }
5870                    }
5871                }
5872            }
5873
5874            if lines_rendered >= visible_line_count {
5875                break;
5876            }
5877        }
5878
5879        // If the last line ended with a newline, render an implicit empty line after it.
5880        // This shows the line number for the cursor position after the final newline.
5881        // Skip this if the ViewLineIterator already produced the trailing empty line.
5882        if let Some(ref end) = last_line_end {
5883            if end.terminated_with_newline
5884                && lines_rendered < visible_line_count
5885                && !trailing_empty_line_rendered
5886            {
5887                // Render the implicit line after the newline
5888                let mut implicit_line_spans = Vec::new();
5889                // The implicit trailing line is at buffer.len()
5890                let implicit_line_byte = state.buffer.len();
5891                let implicit_gutter_num = if byte_offset_mode {
5892                    implicit_line_byte
5893                } else {
5894                    last_gutter_num.map_or(0, |n| n + 1)
5895                };
5896
5897                let implicit_is_cursor_line = implicit_line_byte == cursor_line_start_byte;
5898                let implicit_cursor_bg =
5899                    if implicit_is_cursor_line && highlight_current_line && is_active {
5900                        Some(theme.current_line_bg)
5901                    } else {
5902                        None
5903                    };
5904
5905                if state.margins.left_config.enabled {
5906                    // Indicator column: check for diagnostic markers on this implicit line
5907                    if decorations.diagnostic_lines.contains(&implicit_line_byte) {
5908                        let mut style = Style::default().fg(ratatui::style::Color::Red);
5909                        if let Some(bg) = implicit_cursor_bg {
5910                            style = style.bg(bg);
5911                        }
5912                        implicit_line_spans.push(Span::styled("●", style));
5913                    } else {
5914                        let mut style = Style::default();
5915                        if let Some(bg) = implicit_cursor_bg {
5916                            style = style.bg(bg);
5917                        }
5918                        implicit_line_spans.push(Span::styled(" ", style));
5919                    }
5920
5921                    // Line number (or byte offset in byte_offset_mode)
5922                    let rendered_text = if byte_offset_mode && show_line_numbers {
5923                        format!(
5924                            "{:>width$}",
5925                            implicit_gutter_num,
5926                            width = state.margins.left_config.width
5927                        )
5928                    } else {
5929                        let estimated_lines = state.buffer.line_count().unwrap_or(
5930                            (state.buffer.len() / state.buffer.estimated_line_length()).max(1),
5931                        );
5932                        let margin_content = state.margins.render_line(
5933                            implicit_gutter_num,
5934                            crate::view::margin::MarginPosition::Left,
5935                            estimated_lines,
5936                            show_line_numbers,
5937                        );
5938                        margin_content.render(state.margins.left_config.width).0
5939                    };
5940                    let mut margin_style = Style::default().fg(theme.line_number_fg);
5941                    if let Some(bg) = implicit_cursor_bg {
5942                        margin_style = margin_style.bg(bg);
5943                    }
5944                    implicit_line_spans.push(Span::styled(rendered_text, margin_style));
5945
5946                    // Separator
5947                    if state.margins.left_config.show_separator {
5948                        let mut sep_style = Style::default().fg(theme.line_number_fg);
5949                        if let Some(bg) = implicit_cursor_bg {
5950                            sep_style = sep_style.bg(bg);
5951                        }
5952                        implicit_line_spans.push(Span::styled(
5953                            state.margins.left_config.separator.to_string(),
5954                            sep_style,
5955                        ));
5956                    }
5957                }
5958
5959                // Fill remaining width with current_line_bg for cursor line
5960                if let Some(bg) = implicit_cursor_bg {
5961                    let gutter_w = if state.margins.left_config.enabled {
5962                        state.margins.left_total_width()
5963                    } else {
5964                        0
5965                    };
5966                    let content_width = render_area.width.saturating_sub(gutter_w as u16) as usize;
5967                    if content_width > 0 {
5968                        implicit_line_spans.push(Span::styled(
5969                            " ".repeat(content_width),
5970                            Style::default().bg(bg),
5971                        ));
5972                    }
5973                }
5974
5975                let implicit_y = lines.len() as u16;
5976                lines.push(Line::from(implicit_line_spans));
5977                lines_rendered += 1;
5978
5979                // Add mapping for implicit line
5980                // It has no content, so map is empty (gutter is handled by offset in screen_to_buffer_position)
5981                let buffer_len = state.buffer.len();
5982
5983                view_line_mappings.push(ViewLineMapping {
5984                    char_source_bytes: Vec::new(),
5985                    visual_to_char: Vec::new(),
5986                    line_end_byte: buffer_len,
5987                });
5988
5989                // NOTE: We intentionally do NOT update last_line_end here.
5990                // The implicit empty line is a visual display aid, not an actual content line.
5991                // last_line_end should track the last actual content line for cursor placement logic.
5992
5993                // If primary cursor is at EOF (after the newline), set cursor on this line
5994                if primary_cursor_position == state.buffer.len() && !have_cursor {
5995                    cursor_screen_x = gutter_width as u16;
5996                    cursor_screen_y = implicit_y;
5997                    have_cursor = true;
5998                }
5999            }
6000        }
6001
6002        // Even when there was no screen room to render the implicit trailing
6003        // empty line, we must still add a ViewLineMapping for it.  Without
6004        // the mapping, move_visual_line (Down key) thinks the last rendered
6005        // row is the boundary and returns None — preventing the cursor from
6006        // reaching the trailing empty line (which would trigger a viewport
6007        // scroll on the next render).
6008        if let Some(ref end) = last_line_end {
6009            if end.terminated_with_newline {
6010                let last_mapped_byte = view_line_mappings
6011                    .last()
6012                    .map(|m| m.line_end_byte)
6013                    .unwrap_or(0);
6014                let near_buffer_end = last_mapped_byte + 2 >= state.buffer.len();
6015                let already_mapped = view_line_mappings.last().is_some_and(|m| {
6016                    m.char_source_bytes.is_empty() && m.line_end_byte == state.buffer.len()
6017                });
6018                if near_buffer_end && !already_mapped {
6019                    view_line_mappings.push(ViewLineMapping {
6020                        char_source_bytes: Vec::new(),
6021                        visual_to_char: Vec::new(),
6022                        line_end_byte: state.buffer.len(),
6023                    });
6024                }
6025            }
6026        }
6027
6028        // Fill remaining rows with tilde characters to indicate EOF (like vim/neovim).
6029        // This also ensures proper clearing in differential rendering because tildes
6030        // are guaranteed to differ from previous content, forcing ratatui to update.
6031        // See: https://github.com/ratatui/ratatui/issues/1606
6032        //
6033        // NOTE: We use a computed darker color instead of Modifier::DIM because the DIM
6034        // modifier can bleed through to overlays (like menus) rendered on top of these
6035        // lines due to how terminal escape sequences are output.
6036        // See: https://github.com/sinelaw/fresh/issues/458
6037        if show_tilde {
6038            let eof_fg = dim_color_for_tilde(theme.line_number_fg);
6039            let eof_style = Style::default().fg(eof_fg);
6040            while lines.len() < render_area.height as usize {
6041                // Show tilde with dim styling, padded with spaces to fill the line
6042                let tilde_line = format!(
6043                    "~{}",
6044                    " ".repeat(render_area.width.saturating_sub(1) as usize)
6045                );
6046                lines.push(Line::styled(tilde_line, eof_style));
6047            }
6048        }
6049
6050        LineRenderOutput {
6051            lines,
6052            cursor: have_cursor.then_some((cursor_screen_x, cursor_screen_y)),
6053            last_line_end,
6054            content_lines_rendered: lines_rendered,
6055            view_line_mappings,
6056        }
6057    }
6058
6059    fn resolve_cursor_fallback(
6060        current_cursor: Option<(u16, u16)>,
6061        primary_cursor_position: usize,
6062        buffer_len: usize,
6063        buffer_ends_with_newline: bool,
6064        last_line_end: Option<LastLineEnd>,
6065        lines_rendered: usize,
6066        gutter_width: usize,
6067    ) -> Option<(u16, u16)> {
6068        if current_cursor.is_some() || primary_cursor_position != buffer_len {
6069            return current_cursor;
6070        }
6071
6072        if buffer_ends_with_newline {
6073            if let Some(end) = last_line_end {
6074                // When the last rendered line was the newline-terminated content
6075                // line, the cursor belongs on the implicit empty line one row
6076                // below.  But when the trailing empty line was already emitted
6077                // by the ViewLineIterator (terminated_with_newline == false),
6078                // the cursor belongs on that rendered row itself.
6079                let y = if end.terminated_with_newline {
6080                    end.pos.1.saturating_add(1)
6081                } else {
6082                    end.pos.1
6083                };
6084                return Some((gutter_width as u16, y));
6085            }
6086            return Some((gutter_width as u16, lines_rendered as u16));
6087        }
6088
6089        last_line_end.map(|end| end.pos)
6090    }
6091
6092    /// Pure layout computation for a buffer in a split pane.
6093    /// No frame/drawing involved — produces a BufferLayoutOutput that the
6094    /// drawing phase can consume.
6095    #[allow(clippy::too_many_arguments)]
6096    fn compute_buffer_layout(
6097        state: &mut EditorState,
6098        cursors: &crate::model::cursor::Cursors,
6099        viewport: &mut crate::view::viewport::Viewport,
6100        folds: &mut FoldManager,
6101        area: Rect,
6102        is_active: bool,
6103        theme: &crate::view::theme::Theme,
6104        lsp_waiting: bool,
6105        view_mode: ViewMode,
6106        compose_width: Option<u16>,
6107        view_transform: Option<ViewTransformPayload>,
6108        estimated_line_length: usize,
6109        highlight_context_bytes: usize,
6110        relative_line_numbers: bool,
6111        use_terminal_bg: bool,
6112        session_mode: bool,
6113        software_cursor_only: bool,
6114        show_line_numbers: bool,
6115        highlight_current_line: bool,
6116        diagnostics_inline_text: bool,
6117        show_tilde: bool,
6118        cell_theme_map: Option<(&mut Vec<crate::app::types::CellThemeInfo>, u16)>,
6119    ) -> BufferLayoutOutput {
6120        let _span = tracing::trace_span!("compute_buffer_layout").entered();
6121
6122        // Configure shared margin layout for this split's line number setting.
6123        state.margins.configure_for_line_numbers(show_line_numbers);
6124
6125        // Compute effective editor background: terminal default or theme-defined
6126        let effective_editor_bg = if use_terminal_bg {
6127            ratatui::style::Color::Reset
6128        } else {
6129            theme.editor_bg
6130        };
6131
6132        let line_wrap = viewport.line_wrap_enabled;
6133
6134        let overlay_count = state.overlays.all().len();
6135        if overlay_count > 0 {
6136            tracing::trace!("render_content: {} overlays present", overlay_count);
6137        }
6138
6139        let visible_count = viewport.visible_line_count();
6140
6141        let buffer_len = state.buffer.len();
6142        let byte_offset_mode = state.buffer.line_count().is_none();
6143        let estimated_lines = if byte_offset_mode {
6144            // In byte offset mode, gutter shows byte offsets, so size the gutter
6145            // for the largest byte offset (file size)
6146            buffer_len.max(1)
6147        } else {
6148            state.buffer.line_count().unwrap_or(1)
6149        };
6150        state
6151            .margins
6152            .update_width_for_buffer(estimated_lines, show_line_numbers);
6153        let gutter_width = state.margins.left_total_width();
6154
6155        let compose_layout = Self::calculate_compose_layout(area, &view_mode, compose_width);
6156        let render_area = compose_layout.render_area;
6157
6158        // Clone view_transform so we can reuse it if scrolling triggers a rebuild
6159        let view_transform_for_rebuild = view_transform.clone();
6160
6161        let view_data = {
6162            let _span = tracing::trace_span!("build_view_data").entered();
6163            Self::build_view_data(
6164                state,
6165                viewport,
6166                view_transform,
6167                estimated_line_length,
6168                visible_count,
6169                line_wrap,
6170                render_area.width as usize,
6171                gutter_width,
6172                &view_mode,
6173                folds,
6174                theme,
6175            )
6176        };
6177
6178        // Same-buffer scroll sync: if the sync code flagged this viewport to
6179        // scroll to the end, apply it now using the view lines we just built.
6180        let sync_scrolled = if viewport.sync_scroll_to_end {
6181            viewport.sync_scroll_to_end = false;
6182            viewport.scroll_to_end_of_view(&view_data.lines)
6183        } else {
6184            false
6185        };
6186
6187        // If the sync adjustment changed top_byte, rebuild view_data before
6188        // ensure_visible_in_layout runs (so it sees the correct view lines).
6189        let (view_data, view_transform_for_rebuild) = if sync_scrolled {
6190            viewport.top_view_line_offset = 0;
6191            let rebuilt = Self::build_view_data(
6192                state,
6193                viewport,
6194                view_transform_for_rebuild,
6195                estimated_line_length,
6196                visible_count,
6197                line_wrap,
6198                render_area.width as usize,
6199                gutter_width,
6200                &view_mode,
6201                folds,
6202                theme,
6203            );
6204            viewport.scroll_to_end_of_view(&rebuilt.lines);
6205            (rebuilt, None)
6206        } else {
6207            (view_data, Some(view_transform_for_rebuild))
6208        };
6209
6210        // Ensure cursor is visible using Layout-aware check (handles virtual lines)
6211        let primary = *cursors.primary();
6212        let scrolled = viewport.ensure_visible_in_layout(&view_data.lines, &primary, gutter_width);
6213
6214        // If we scrolled, rebuild view_data from the new top_byte and then re-run the
6215        // layout-aware check so that top_view_line_offset is correct for the rebuilt data.
6216        let view_data = if scrolled {
6217            if let Some(vt) = view_transform_for_rebuild {
6218                viewport.top_view_line_offset = 0;
6219                let rebuilt = Self::build_view_data(
6220                    state,
6221                    viewport,
6222                    vt,
6223                    estimated_line_length,
6224                    visible_count,
6225                    line_wrap,
6226                    render_area.width as usize,
6227                    gutter_width,
6228                    &view_mode,
6229                    folds,
6230                    theme,
6231                );
6232                let _ = viewport.ensure_visible_in_layout(&rebuilt.lines, &primary, gutter_width);
6233                rebuilt
6234            } else {
6235                view_data
6236            }
6237        } else {
6238            view_data
6239        };
6240
6241        let view_anchor = Self::calculate_view_anchor(&view_data.lines, viewport.top_byte);
6242
6243        let selection = Self::selection_context(state, cursors);
6244
6245        tracing::trace!(
6246            "Rendering buffer with {} cursors at positions: {:?}, primary at {}, is_active: {}, buffer_len: {}",
6247            selection.cursor_positions.len(),
6248            selection.cursor_positions,
6249            selection.primary_cursor_position,
6250            is_active,
6251            state.buffer.len()
6252        );
6253
6254        if !selection.cursor_positions.is_empty()
6255            && !selection
6256                .cursor_positions
6257                .contains(&selection.primary_cursor_position)
6258        {
6259            tracing::warn!(
6260                "Primary cursor position {} not found in cursor_positions list: {:?}",
6261                selection.primary_cursor_position,
6262                selection.cursor_positions
6263            );
6264        }
6265
6266        let adjusted_visible_count = Self::fold_adjusted_visible_count(
6267            &state.buffer,
6268            &state.marker_list,
6269            folds,
6270            viewport.top_byte,
6271            visible_count,
6272        );
6273
6274        // Populate line cache to ensure chunks are loaded for rendering.
6275        // For small files this also builds the line index; for large files
6276        // it just loads the needed chunks from disk.
6277        let _ = state
6278            .buffer
6279            .populate_line_cache(viewport.top_byte, adjusted_visible_count);
6280
6281        let viewport_start = viewport.top_byte;
6282        let viewport_end = Self::calculate_viewport_end(
6283            state,
6284            viewport_start,
6285            estimated_line_length,
6286            adjusted_visible_count,
6287        );
6288
6289        let decorations = Self::decoration_context(
6290            state,
6291            viewport_start,
6292            viewport_end,
6293            selection.primary_cursor_position,
6294            folds,
6295            theme,
6296            highlight_context_bytes,
6297            &view_mode,
6298            diagnostics_inline_text,
6299        );
6300
6301        let calculated_offset = viewport.top_view_line_offset;
6302
6303        tracing::trace!(
6304            top_byte = viewport.top_byte,
6305            top_view_line_offset = viewport.top_view_line_offset,
6306            calculated_offset,
6307            view_data_lines = view_data.lines.len(),
6308            "view line offset calculation"
6309        );
6310        let (view_lines_to_render, adjusted_view_anchor) =
6311            if calculated_offset > 0 && calculated_offset < view_data.lines.len() {
6312                let sliced = &view_data.lines[calculated_offset..];
6313                let adjusted_anchor = Self::calculate_view_anchor(sliced, viewport.top_byte);
6314                (sliced, adjusted_anchor)
6315            } else {
6316                (&view_data.lines[..], view_anchor)
6317            };
6318
6319        // Use provided cell theme map or a temporary dummy
6320        let mut dummy_map = Vec::new();
6321        let (map_ref, sw) = match cell_theme_map {
6322            Some((map, w)) => (map, w),
6323            None => (&mut dummy_map, 0u16),
6324        };
6325
6326        let render_output = Self::render_view_lines(LineRenderInput {
6327            state,
6328            theme,
6329            view_lines: view_lines_to_render,
6330            view_anchor: adjusted_view_anchor,
6331            render_area,
6332            gutter_width,
6333            selection: &selection,
6334            decorations: &decorations,
6335            visible_line_count: visible_count,
6336            lsp_waiting,
6337            is_active,
6338            line_wrap,
6339            estimated_lines,
6340            left_column: viewport.left_column,
6341            relative_line_numbers,
6342            session_mode,
6343            software_cursor_only,
6344            show_line_numbers,
6345            byte_offset_mode,
6346            show_tilde,
6347            highlight_current_line,
6348            cell_theme_map: map_ref,
6349            screen_width: sw,
6350        });
6351
6352        let view_line_mappings = render_output.view_line_mappings.clone();
6353
6354        let buffer_ends_with_newline = if !state.buffer.is_empty() {
6355            let last_char = state.get_text_range(state.buffer.len() - 1, state.buffer.len());
6356            last_char == "\n"
6357        } else {
6358            false
6359        };
6360
6361        BufferLayoutOutput {
6362            view_line_mappings,
6363            render_output,
6364            render_area,
6365            compose_layout,
6366            effective_editor_bg,
6367            view_mode,
6368            left_column: viewport.left_column,
6369            gutter_width,
6370            buffer_ends_with_newline,
6371            selection,
6372        }
6373    }
6374
6375    /// Draw a buffer into a frame using pre-computed layout output.
6376    #[allow(clippy::too_many_arguments)]
6377    fn draw_buffer_in_split(
6378        frame: &mut Frame,
6379        state: &EditorState,
6380        cursors: &crate::model::cursor::Cursors,
6381        layout_output: BufferLayoutOutput,
6382        event_log: Option<&mut EventLog>,
6383        area: Rect,
6384        is_active: bool,
6385        theme: &crate::view::theme::Theme,
6386        ansi_background: Option<&AnsiBackground>,
6387        background_fade: f32,
6388        hide_cursor: bool,
6389        software_cursor_only: bool,
6390        rulers: &[usize],
6391        compose_column_guides: Option<Vec<u16>>,
6392    ) {
6393        let render_area = layout_output.render_area;
6394        let effective_editor_bg = layout_output.effective_editor_bg;
6395        let gutter_width = layout_output.gutter_width;
6396        let starting_line_num = 0; // used only for background offset
6397
6398        Self::render_compose_margins(
6399            frame,
6400            area,
6401            &layout_output.compose_layout,
6402            &layout_output.view_mode,
6403            theme,
6404            effective_editor_bg,
6405        );
6406
6407        let mut lines = layout_output.render_output.lines;
6408        let background_x_offset = layout_output.left_column;
6409
6410        if let Some(bg) = ansi_background {
6411            Self::apply_background_to_lines(
6412                &mut lines,
6413                render_area.width,
6414                bg,
6415                effective_editor_bg,
6416                theme.editor_fg,
6417                background_fade,
6418                background_x_offset,
6419                starting_line_num,
6420            );
6421        }
6422
6423        frame.render_widget(Clear, render_area);
6424        let editor_block = Block::default()
6425            .borders(Borders::NONE)
6426            .style(Style::default().bg(effective_editor_bg));
6427        frame.render_widget(Paragraph::new(lines).block(editor_block), render_area);
6428
6429        let cursor = Self::resolve_cursor_fallback(
6430            layout_output.render_output.cursor,
6431            layout_output.selection.primary_cursor_position,
6432            state.buffer.len(),
6433            layout_output.buffer_ends_with_newline,
6434            layout_output.render_output.last_line_end,
6435            layout_output.render_output.content_lines_rendered,
6436            gutter_width,
6437        );
6438
6439        let cursor_screen_pos = if is_active && state.show_cursors && !hide_cursor {
6440            cursor.map(|(cx, cy)| {
6441                let screen_x = render_area.x.saturating_add(cx);
6442                let max_y = render_area.height.saturating_sub(1);
6443                let screen_y = render_area.y.saturating_add(cy.min(max_y));
6444                (screen_x, screen_y)
6445            })
6446        } else {
6447            None
6448        };
6449
6450        // Render config-based vertical rulers
6451        if !rulers.is_empty() {
6452            let ruler_cols: Vec<u16> = rulers.iter().map(|&r| r as u16).collect();
6453            Self::render_ruler_bg(
6454                frame,
6455                &ruler_cols,
6456                theme.ruler_bg,
6457                render_area,
6458                gutter_width,
6459                layout_output.render_output.content_lines_rendered,
6460                layout_output.left_column,
6461            );
6462        }
6463
6464        // Render compose column guides
6465        if let Some(guides) = compose_column_guides {
6466            let guide_style = Style::default()
6467                .fg(theme.line_number_fg)
6468                .add_modifier(Modifier::DIM);
6469            Self::render_column_guides(
6470                frame,
6471                &guides,
6472                guide_style,
6473                render_area,
6474                gutter_width,
6475                layout_output.render_output.content_lines_rendered,
6476                0,
6477            );
6478        }
6479
6480        if let Some((screen_x, screen_y)) = cursor_screen_pos {
6481            frame.set_cursor_position((screen_x, screen_y));
6482
6483            // When software_cursor_only the backend has no hardware cursor, so
6484            // ensure the cell at the cursor position always has REVERSED style.
6485            // This covers all edge cases (end-of-line, empty buffer, newline
6486            // positions) where the per-character REVERSED styling from
6487            // compute_char_style may not have been applied.
6488            if software_cursor_only {
6489                let buf = frame.buffer_mut();
6490                let area = buf.area;
6491                if screen_x < area.x + area.width && screen_y < area.y + area.height {
6492                    let cell = &mut buf[(screen_x, screen_y)];
6493                    // Only override empty / default-background cells to avoid
6494                    // double-reversing cells that already got software cursor
6495                    // styling in render_view_lines.
6496                    if !cell.modifier.contains(Modifier::REVERSED) {
6497                        cell.set_char(' ');
6498                        cell.fg = theme.editor_fg;
6499                        cell.bg = theme.editor_bg;
6500                        cell.modifier.insert(Modifier::REVERSED);
6501                    }
6502                }
6503            }
6504
6505            if let Some(event_log) = event_log {
6506                let cursor_pos = cursors.primary().position;
6507                let buffer_len = state.buffer.len();
6508                event_log.log_render_state(cursor_pos, screen_x, screen_y, buffer_len);
6509            }
6510        }
6511    }
6512
6513    /// Render a single buffer in a split pane (convenience wrapper).
6514    /// Calls compute_buffer_layout then draw_buffer_in_split.
6515    /// Returns the view line mappings for mouse click handling.
6516    #[allow(clippy::too_many_arguments)]
6517    fn render_buffer_in_split(
6518        frame: &mut Frame,
6519        state: &mut EditorState,
6520        cursors: &crate::model::cursor::Cursors,
6521        viewport: &mut crate::view::viewport::Viewport,
6522        folds: &mut FoldManager,
6523        event_log: Option<&mut EventLog>,
6524        area: Rect,
6525        is_active: bool,
6526        theme: &crate::view::theme::Theme,
6527        ansi_background: Option<&AnsiBackground>,
6528        background_fade: f32,
6529        lsp_waiting: bool,
6530        view_mode: ViewMode,
6531        compose_width: Option<u16>,
6532        compose_column_guides: Option<Vec<u16>>,
6533        view_transform: Option<ViewTransformPayload>,
6534        estimated_line_length: usize,
6535        highlight_context_bytes: usize,
6536        _buffer_id: BufferId,
6537        hide_cursor: bool,
6538        relative_line_numbers: bool,
6539        use_terminal_bg: bool,
6540        session_mode: bool,
6541        software_cursor_only: bool,
6542        rulers: &[usize],
6543        show_line_numbers: bool,
6544        highlight_current_line: bool,
6545        diagnostics_inline_text: bool,
6546        show_tilde: bool,
6547        cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
6548        screen_width: u16,
6549    ) -> Vec<ViewLineMapping> {
6550        let layout_output = Self::compute_buffer_layout(
6551            state,
6552            cursors,
6553            viewport,
6554            folds,
6555            area,
6556            is_active,
6557            theme,
6558            lsp_waiting,
6559            view_mode.clone(),
6560            compose_width,
6561            view_transform,
6562            estimated_line_length,
6563            highlight_context_bytes,
6564            relative_line_numbers,
6565            use_terminal_bg,
6566            session_mode,
6567            software_cursor_only,
6568            show_line_numbers,
6569            highlight_current_line,
6570            diagnostics_inline_text,
6571            show_tilde,
6572            Some((cell_theme_map, screen_width)),
6573        );
6574
6575        let view_line_mappings = layout_output.view_line_mappings.clone();
6576
6577        Self::draw_buffer_in_split(
6578            frame,
6579            state,
6580            cursors,
6581            layout_output,
6582            event_log,
6583            area,
6584            is_active,
6585            theme,
6586            ansi_background,
6587            background_fade,
6588            hide_cursor,
6589            software_cursor_only,
6590            rulers,
6591            compose_column_guides,
6592        );
6593
6594        view_line_mappings
6595    }
6596
6597    /// Render vertical column guide lines in the editor content area.
6598    /// Used for both config-based vertical rulers and compose-mode column guides.
6599    fn render_column_guides(
6600        frame: &mut Frame,
6601        columns: &[u16],
6602        style: Style,
6603        render_area: Rect,
6604        gutter_width: usize,
6605        content_height: usize,
6606        left_column: usize,
6607    ) {
6608        let guide_height = content_height.min(render_area.height as usize);
6609        for &col in columns {
6610            // Account for horizontal scroll
6611            let Some(scrolled_col) = (col as usize).checked_sub(left_column) else {
6612                continue;
6613            };
6614            let guide_x = render_area.x + gutter_width as u16 + scrolled_col as u16;
6615            if guide_x < render_area.x + render_area.width {
6616                for row in 0..guide_height {
6617                    let cell = &mut frame.buffer_mut()[(guide_x, render_area.y + row as u16)];
6618                    cell.set_symbol("│");
6619                    if let Some(fg) = style.fg {
6620                        cell.set_fg(fg);
6621                    }
6622                    if !style.add_modifier.is_empty() {
6623                        cell.set_style(Style::default().add_modifier(style.add_modifier));
6624                    }
6625                }
6626            }
6627        }
6628    }
6629
6630    /// Render vertical rulers as a subtle background color tint.
6631    /// Unlike `render_column_guides` which draws │ characters (for compose guides),
6632    /// this preserves the existing text content and only adjusts the background color.
6633    fn render_ruler_bg(
6634        frame: &mut Frame,
6635        columns: &[u16],
6636        color: Color,
6637        render_area: Rect,
6638        gutter_width: usize,
6639        content_height: usize,
6640        left_column: usize,
6641    ) {
6642        let guide_height = content_height.min(render_area.height as usize);
6643        for &col in columns {
6644            let Some(scrolled_col) = (col as usize).checked_sub(left_column) else {
6645                continue;
6646            };
6647            let guide_x = render_area.x + gutter_width as u16 + scrolled_col as u16;
6648            if guide_x < render_area.x + render_area.width {
6649                for row in 0..guide_height {
6650                    let cell = &mut frame.buffer_mut()[(guide_x, render_area.y + row as u16)];
6651                    cell.set_bg(color);
6652                }
6653            }
6654        }
6655    }
6656
6657    /// Post-process the rendered frame to apply OSC 8 hyperlink escape sequences
6658    /// for any overlays that have a URL set.
6659    ///
6660    /// Uses view_line_mappings to translate overlay byte ranges into screen
6661    /// positions, then wraps the corresponding cells with OSC 8 sequences so
6662    /// they become clickable in terminals that support the protocol.
6663    #[allow(dead_code)]
6664    fn apply_hyperlink_overlays(
6665        frame: &mut Frame,
6666        viewport_overlays: &[(crate::view::overlay::Overlay, Range<usize>)],
6667        view_line_mappings: &[ViewLineMapping],
6668        render_area: Rect,
6669        gutter_width: usize,
6670        cursor_screen_pos: Option<(u16, u16)>,
6671    ) {
6672        let hyperlink_overlays: Vec<_> = viewport_overlays
6673            .iter()
6674            .filter(|(overlay, _)| overlay.url.is_some())
6675            .collect();
6676
6677        if hyperlink_overlays.is_empty() {
6678            return;
6679        }
6680
6681        let buf = frame.buffer_mut();
6682        for (screen_row, mapping) in view_line_mappings.iter().enumerate() {
6683            let y = render_area.y + screen_row as u16;
6684            if y >= render_area.y + render_area.height {
6685                break;
6686            }
6687            for (overlay, range) in &hyperlink_overlays {
6688                let url = overlay.url.as_ref().unwrap();
6689                // Find screen columns in this row whose source byte falls in range
6690                let mut run_start: Option<u16> = None;
6691                let content_x_offset = render_area.x + gutter_width as u16;
6692                for (char_idx, maybe_byte) in mapping.char_source_bytes.iter().enumerate() {
6693                    let in_range = maybe_byte
6694                        .map(|b| b >= range.start && b < range.end)
6695                        .unwrap_or(false);
6696                    let screen_x = content_x_offset + char_idx as u16;
6697                    if in_range && screen_x < render_area.x + render_area.width {
6698                        if run_start.is_none() {
6699                            run_start = Some(screen_x);
6700                        }
6701                    } else if let Some(start_x) = run_start.take() {
6702                        Self::apply_osc8_to_cells(
6703                            buf,
6704                            start_x,
6705                            screen_x,
6706                            y,
6707                            url,
6708                            cursor_screen_pos,
6709                        );
6710                    }
6711                }
6712                // Flush trailing run
6713                if let Some(start_x) = run_start {
6714                    let end_x = content_x_offset + mapping.char_source_bytes.len() as u16;
6715                    let end_x = end_x.min(render_area.x + render_area.width);
6716                    Self::apply_osc8_to_cells(buf, start_x, end_x, y, url, cursor_screen_pos);
6717                }
6718            }
6719        }
6720    }
6721
6722    /// Apply OSC 8 hyperlink escape sequences to a run of buffer cells.
6723    ///
6724    /// Uses 2-character chunking to work around Crossterm width accounting
6725    /// issues with OSC sequences (same approach as popup hyperlinks).
6726    /// When the cursor falls on the second character of a 2-char chunk, the
6727    /// chunk is split into two 1-char chunks so the terminal cursor remains
6728    /// visible on the correct cell.
6729    #[allow(dead_code)]
6730    fn apply_osc8_to_cells(
6731        buf: &mut ratatui::buffer::Buffer,
6732        start_x: u16,
6733        end_x: u16,
6734        y: u16,
6735        url: &str,
6736        cursor_pos: Option<(u16, u16)>,
6737    ) {
6738        let area = *buf.area();
6739        if y < area.y || y >= area.y + area.height {
6740            return;
6741        }
6742        let max_x = area.x + area.width;
6743        // If the cursor is on this row, note its x so we can avoid
6744        // swallowing it into the second half of a 2-char chunk.
6745        let cursor_x = cursor_pos.and_then(|(cx, cy)| if cy == y { Some(cx) } else { None });
6746        let mut x = start_x;
6747        while x < end_x {
6748            if x >= max_x {
6749                break;
6750            }
6751            // Determine chunk size: normally 2, but use 1 if the cursor
6752            // sits on the second cell of the pair so it stays addressable.
6753            let chunk_size = if cursor_x == Some(x + 1) { 1 } else { 2 };
6754
6755            let mut chunk = String::new();
6756            let chunk_start = x;
6757            for _ in 0..chunk_size {
6758                if x >= end_x || x >= max_x {
6759                    break;
6760                }
6761                let sym = buf[(x, y)].symbol().to_string();
6762                chunk.push_str(&sym);
6763                x += 1;
6764            }
6765            if !chunk.is_empty() {
6766                let actual_chunk_len = x - chunk_start;
6767                let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
6768                buf[(chunk_start, y)].set_symbol(&hyperlink);
6769                // Clear trailing cells that were folded into this chunk so
6770                // ratatui's diff picks up the change when chunking shifts.
6771                for cx in (chunk_start + 1)..chunk_start + actual_chunk_len {
6772                    buf[(cx, y)].set_symbol("");
6773                }
6774            }
6775        }
6776    }
6777
6778    /// Apply styles from original line_spans to a wrapped segment
6779    ///
6780    /// Maps each character in the segment text back to its original span to preserve
6781    /// syntax highlighting, selections, and other styling across wrapped lines.
6782    ///
6783    /// # Arguments
6784    /// * `segment_text` - The text content of this wrapped segment
6785    /// * `line_spans` - The original styled spans for the entire line
6786    /// * `segment_start_offset` - Character offset where this segment starts in the original line
6787    /// * `scroll_offset` - Additional offset for horizontal scrolling (non-wrap mode)
6788    #[allow(clippy::too_many_arguments)]
6789    fn apply_background_to_lines(
6790        lines: &mut Vec<Line<'static>>,
6791        area_width: u16,
6792        background: &AnsiBackground,
6793        theme_bg: Color,
6794        default_fg: Color,
6795        fade: f32,
6796        x_offset: usize,
6797        y_offset: usize,
6798    ) {
6799        if area_width == 0 {
6800            return;
6801        }
6802
6803        let width = area_width as usize;
6804
6805        for (y, line) in lines.iter_mut().enumerate() {
6806            // Flatten existing spans into per-character styles
6807            let mut existing: Vec<(char, Style)> = Vec::new();
6808            let spans = std::mem::take(&mut line.spans);
6809            for span in spans {
6810                let style = span.style;
6811                for ch in span.content.chars() {
6812                    existing.push((ch, style));
6813                }
6814            }
6815
6816            let mut chars_with_style = Vec::with_capacity(width);
6817            for x in 0..width {
6818                let sample_x = x_offset + x;
6819                let sample_y = y_offset + y;
6820
6821                let (ch, mut style) = if x < existing.len() {
6822                    existing[x]
6823                } else {
6824                    (' ', Style::default().fg(default_fg))
6825                };
6826
6827                if let Some(bg_color) = background.faded_color(sample_x, sample_y, theme_bg, fade) {
6828                    if style.bg.is_none() || matches!(style.bg, Some(Color::Reset)) {
6829                        style = style.bg(bg_color);
6830                    }
6831                }
6832
6833                chars_with_style.push((ch, style));
6834            }
6835
6836            line.spans = Self::compress_chars(chars_with_style);
6837        }
6838    }
6839
6840    fn compress_chars(chars: Vec<(char, Style)>) -> Vec<Span<'static>> {
6841        if chars.is_empty() {
6842            return vec![];
6843        }
6844
6845        let mut spans = Vec::new();
6846        let mut current_style = chars[0].1;
6847        let mut current_text = String::new();
6848        current_text.push(chars[0].0);
6849
6850        for (ch, style) in chars.into_iter().skip(1) {
6851            if style == current_style {
6852                current_text.push(ch);
6853            } else {
6854                spans.push(Span::styled(current_text.clone(), current_style));
6855                current_text.clear();
6856                current_text.push(ch);
6857                current_style = style;
6858            }
6859        }
6860
6861        spans.push(Span::styled(current_text, current_style));
6862        spans
6863    }
6864}
6865
6866#[cfg(test)]
6867mod tests {
6868    use crate::model::filesystem::StdFileSystem;
6869    use std::sync::Arc;
6870
6871    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
6872        Arc::new(StdFileSystem)
6873    }
6874    use super::*;
6875    use crate::model::buffer::Buffer;
6876    use crate::primitives::display_width::str_width;
6877    use crate::view::theme;
6878    use crate::view::theme::Theme;
6879    use crate::view::viewport::Viewport;
6880    use lsp_types::FoldingRange;
6881
6882    fn render_output_for(
6883        content: &str,
6884        cursor_pos: usize,
6885    ) -> (LineRenderOutput, usize, bool, usize) {
6886        render_output_for_with_gutters(content, cursor_pos, false)
6887    }
6888
6889    fn render_output_for_with_gutters(
6890        content: &str,
6891        cursor_pos: usize,
6892        gutters_enabled: bool,
6893    ) -> (LineRenderOutput, usize, bool, usize) {
6894        let mut state = EditorState::new(20, 6, 1024, test_fs());
6895        state.buffer = Buffer::from_str(content, 1024, test_fs());
6896        let mut cursors = crate::model::cursor::Cursors::new();
6897        cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
6898        // Create a standalone viewport (no longer part of EditorState)
6899        let viewport = Viewport::new(20, 4);
6900        // Enable/disable line numbers/gutters based on parameter
6901        state.margins.left_config.enabled = gutters_enabled;
6902
6903        let render_area = Rect::new(0, 0, 20, 4);
6904        let visible_count = viewport.visible_line_count();
6905        let gutter_width = state.margins.left_total_width();
6906        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
6907        let empty_folds = FoldManager::new();
6908
6909        let view_data = SplitRenderer::build_view_data(
6910            &mut state,
6911            &viewport,
6912            None,
6913            content.len().max(1),
6914            visible_count,
6915            false, // line wrap disabled for tests
6916            render_area.width as usize,
6917            gutter_width,
6918            &ViewMode::Source, // Tests use source mode
6919            &empty_folds,
6920            &theme,
6921        );
6922        let view_anchor = SplitRenderer::calculate_view_anchor(&view_data.lines, 0);
6923
6924        let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
6925        state.margins.update_width_for_buffer(estimated_lines, true);
6926        let gutter_width = state.margins.left_total_width();
6927
6928        let selection = SplitRenderer::selection_context(&state, &cursors);
6929        let _ = state
6930            .buffer
6931            .populate_line_cache(viewport.top_byte, visible_count);
6932        let viewport_start = viewport.top_byte;
6933        let viewport_end = SplitRenderer::calculate_viewport_end(
6934            &mut state,
6935            viewport_start,
6936            content.len().max(1),
6937            visible_count,
6938        );
6939        let decorations = SplitRenderer::decoration_context(
6940            &mut state,
6941            viewport_start,
6942            viewport_end,
6943            selection.primary_cursor_position,
6944            &empty_folds,
6945            &theme,
6946            100_000,           // default highlight context bytes
6947            &ViewMode::Source, // Tests use source mode
6948            false,             // inline diagnostics off for test
6949        );
6950
6951        let mut dummy_theme_map = Vec::new();
6952        let output = SplitRenderer::render_view_lines(LineRenderInput {
6953            state: &state,
6954            theme: &theme,
6955            view_lines: &view_data.lines,
6956            view_anchor,
6957            render_area,
6958            gutter_width,
6959            selection: &selection,
6960            decorations: &decorations,
6961            visible_line_count: visible_count,
6962            lsp_waiting: false,
6963            is_active: true,
6964            line_wrap: viewport.line_wrap_enabled,
6965            estimated_lines,
6966            left_column: viewport.left_column,
6967            relative_line_numbers: false,
6968            session_mode: false,
6969            software_cursor_only: false,
6970            show_line_numbers: true, // Tests show line numbers
6971            byte_offset_mode: false, // Tests use exact line numbers
6972            show_tilde: true,
6973            highlight_current_line: true,
6974            cell_theme_map: &mut dummy_theme_map,
6975            screen_width: 0,
6976        });
6977
6978        (
6979            output,
6980            state.buffer.len(),
6981            content.ends_with('\n'),
6982            selection.primary_cursor_position,
6983        )
6984    }
6985
6986    #[test]
6987    fn test_folding_hides_lines_and_adds_placeholder() {
6988        let content = "header\nline1\nline2\ntail\n";
6989        let mut state = EditorState::new(40, 6, 1024, test_fs());
6990        state.buffer = Buffer::from_str(content, 1024, test_fs());
6991
6992        let start = state.buffer.line_start_offset(1).unwrap();
6993        let end = state.buffer.line_start_offset(3).unwrap();
6994        let mut folds = FoldManager::new();
6995        folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
6996
6997        let viewport = Viewport::new(40, 6);
6998        let gutter_width = state.margins.left_total_width();
6999        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
7000        let view_data = SplitRenderer::build_view_data(
7001            &mut state,
7002            &viewport,
7003            None,
7004            content.len().max(1),
7005            viewport.visible_line_count(),
7006            false,
7007            40,
7008            gutter_width,
7009            &ViewMode::Source,
7010            &folds,
7011            &theme,
7012        );
7013
7014        let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
7015        assert!(lines.iter().any(|l| l.contains("header")));
7016        assert!(lines.iter().any(|l| l.contains("tail")));
7017        assert!(!lines.iter().any(|l| l.contains("line1")));
7018        assert!(!lines.iter().any(|l| l.contains("line2")));
7019        assert!(lines
7020            .iter()
7021            .any(|l| l.contains("header") && l.contains("...")));
7022    }
7023
7024    #[test]
7025    fn test_fold_indicators_collapsed_and_expanded() {
7026        let content = "a\nb\nc\nd\n";
7027        let mut state = EditorState::new(40, 6, 1024, test_fs());
7028        state.buffer = Buffer::from_str(content, 1024, test_fs());
7029
7030        state.folding_ranges = vec![
7031            FoldingRange {
7032                start_line: 0,
7033                end_line: 1,
7034                start_character: None,
7035                end_character: None,
7036                kind: None,
7037                collapsed_text: None,
7038            },
7039            FoldingRange {
7040                start_line: 1,
7041                end_line: 2,
7042                start_character: None,
7043                end_character: None,
7044                kind: None,
7045                collapsed_text: None,
7046            },
7047        ];
7048
7049        let start = state.buffer.line_start_offset(1).unwrap();
7050        let end = state.buffer.line_start_offset(2).unwrap();
7051        let mut folds = FoldManager::new();
7052        folds.add(&mut state.marker_list, start, end, None);
7053
7054        let indicators =
7055            SplitRenderer::fold_indicators_for_viewport(&state, &folds, 0, state.buffer.len());
7056
7057        // Collapsed fold: header is line 0 (byte 0)
7058        assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
7059        // LSP range starting at line 1 (byte 2, since "a\n" is 2 bytes)
7060        let line1_byte = state.buffer.line_start_offset(1).unwrap();
7061        assert_eq!(
7062            indicators.get(&line1_byte).map(|i| i.collapsed),
7063            Some(false)
7064        );
7065    }
7066
7067    #[test]
7068    fn last_line_end_tracks_trailing_newline() {
7069        let output = render_output_for("abc\n", 4);
7070        assert_eq!(
7071            output.0.last_line_end,
7072            Some(LastLineEnd {
7073                pos: (3, 0),
7074                terminated_with_newline: true
7075            })
7076        );
7077    }
7078
7079    #[test]
7080    fn last_line_end_tracks_no_trailing_newline() {
7081        let output = render_output_for("abc", 3);
7082        assert_eq!(
7083            output.0.last_line_end,
7084            Some(LastLineEnd {
7085                pos: (3, 0),
7086                terminated_with_newline: false
7087            })
7088        );
7089    }
7090
7091    #[test]
7092    fn cursor_after_newline_places_on_next_line() {
7093        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
7094        let cursor = SplitRenderer::resolve_cursor_fallback(
7095            output.cursor,
7096            cursor_pos,
7097            buffer_len,
7098            buffer_newline,
7099            output.last_line_end,
7100            output.content_lines_rendered,
7101            0, // gutter_width (gutters disabled in tests)
7102        );
7103        assert_eq!(cursor, Some((0, 1)));
7104    }
7105
7106    #[test]
7107    fn cursor_at_end_without_newline_stays_on_line() {
7108        let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
7109        let cursor = SplitRenderer::resolve_cursor_fallback(
7110            output.cursor,
7111            cursor_pos,
7112            buffer_len,
7113            buffer_newline,
7114            output.last_line_end,
7115            output.content_lines_rendered,
7116            0, // gutter_width (gutters disabled in tests)
7117        );
7118        assert_eq!(cursor, Some((3, 0)));
7119    }
7120
7121    // Helper to count all cursor positions in rendered output
7122    // Cursors can appear as:
7123    // 1. Primary cursor in output.cursor (hardware cursor position)
7124    // 2. Visual spans with REVERSED modifier (secondary cursors, or primary cursor with contrast fix)
7125    // 3. Visual spans with special background color (inactive cursors)
7126    fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
7127        let mut cursor_positions = Vec::new();
7128
7129        // Check for primary cursor in output.cursor field
7130        let primary_cursor = output.cursor;
7131        if let Some(cursor_pos) = primary_cursor {
7132            cursor_positions.push(cursor_pos);
7133        }
7134
7135        // Check for visual cursor indicators in rendered spans (secondary/inactive cursors)
7136        for (line_idx, line) in output.lines.iter().enumerate() {
7137            let mut col = 0u16;
7138            for span in line.spans.iter() {
7139                // Check if this span has the REVERSED modifier (secondary cursor)
7140                if span
7141                    .style
7142                    .add_modifier
7143                    .contains(ratatui::style::Modifier::REVERSED)
7144                {
7145                    let pos = (col, line_idx as u16);
7146                    // Only add if this is not the primary cursor position
7147                    // (primary cursor may also have REVERSED for contrast)
7148                    if primary_cursor != Some(pos) {
7149                        cursor_positions.push(pos);
7150                    }
7151                }
7152                // Count the visual width of this span's content
7153                col += str_width(&span.content) as u16;
7154            }
7155        }
7156
7157        cursor_positions
7158    }
7159
7160    // Helper to dump rendered output for debugging
7161    fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
7162        eprintln!("\n=== RENDER DEBUG ===");
7163        eprintln!("Content: {:?}", content);
7164        eprintln!("Cursor position: {}", cursor_pos);
7165        eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
7166        eprintln!("Last line end: {:?}", output.last_line_end);
7167        eprintln!("Content lines rendered: {}", output.content_lines_rendered);
7168        eprintln!("\nRendered lines:");
7169        for (line_idx, line) in output.lines.iter().enumerate() {
7170            eprintln!("  Line {}: {} spans", line_idx, line.spans.len());
7171            for (span_idx, span) in line.spans.iter().enumerate() {
7172                let has_reversed = span
7173                    .style
7174                    .add_modifier
7175                    .contains(ratatui::style::Modifier::REVERSED);
7176                let bg_color = format!("{:?}", span.style.bg);
7177                eprintln!(
7178                    "    Span {}: {:?} (REVERSED: {}, BG: {})",
7179                    span_idx, span.content, has_reversed, bg_color
7180                );
7181            }
7182        }
7183        eprintln!("===================\n");
7184    }
7185
7186    // Helper to get final cursor position after fallback resolution
7187    // Also validates that exactly one cursor is present
7188    fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
7189        let (output, buffer_len, buffer_newline, cursor_pos) =
7190            render_output_for(content, cursor_pos);
7191
7192        // Count all cursors (hardware + visual) in the rendered output
7193        let all_cursors = count_all_cursors(&output);
7194
7195        // Validate that at most one cursor is present in rendered output
7196        // (Some cursors are added by fallback logic, not during rendering)
7197        assert!(
7198            all_cursors.len() <= 1,
7199            "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
7200            all_cursors.len(),
7201            all_cursors
7202        );
7203
7204        let final_cursor = SplitRenderer::resolve_cursor_fallback(
7205            output.cursor,
7206            cursor_pos,
7207            buffer_len,
7208            buffer_newline,
7209            output.last_line_end,
7210            output.content_lines_rendered,
7211            0, // gutter_width (gutters disabled in tests)
7212        );
7213
7214        // Debug dump if we find unexpected results
7215        if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
7216        {
7217            dump_render_output(content, cursor_pos, &output);
7218        }
7219
7220        // If a cursor was rendered, it should match the final cursor position
7221        if let Some(rendered_cursor) = all_cursors.first() {
7222            assert_eq!(
7223                Some(*rendered_cursor),
7224                final_cursor,
7225                "Rendered cursor at {:?} doesn't match final cursor {:?}",
7226                rendered_cursor,
7227                final_cursor
7228            );
7229        }
7230
7231        // Validate that we have a final cursor position (either rendered or from fallback)
7232        assert!(
7233            final_cursor.is_some(),
7234            "Expected a final cursor position, but got None. Rendered cursors: {:?}",
7235            all_cursors
7236        );
7237
7238        final_cursor
7239    }
7240
7241    // Helper to simulate typing a character and check if it appears at cursor position
7242    fn check_typing_at_cursor(
7243        content: &str,
7244        cursor_pos: usize,
7245        char_to_type: char,
7246    ) -> (Option<(u16, u16)>, String) {
7247        // Get cursor position before typing
7248        let cursor_before = get_final_cursor(content, cursor_pos);
7249
7250        // Simulate inserting the character at cursor position
7251        let mut new_content = content.to_string();
7252        if cursor_pos <= content.len() {
7253            new_content.insert(cursor_pos, char_to_type);
7254        }
7255
7256        (cursor_before, new_content)
7257    }
7258
7259    #[test]
7260    fn e2e_cursor_at_start_of_nonempty_line() {
7261        // "abc" with cursor at position 0 (before 'a')
7262        let cursor = get_final_cursor("abc", 0);
7263        assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
7264
7265        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
7266        assert_eq!(
7267            new_content, "Xabc",
7268            "Typing should insert at cursor position"
7269        );
7270        assert_eq!(cursor_pos, Some((0, 0)));
7271    }
7272
7273    #[test]
7274    fn e2e_cursor_in_middle_of_line() {
7275        // "abc" with cursor at position 1 (on 'b')
7276        let cursor = get_final_cursor("abc", 1);
7277        assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
7278
7279        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
7280        assert_eq!(
7281            new_content, "aXbc",
7282            "Typing should insert at cursor position"
7283        );
7284        assert_eq!(cursor_pos, Some((1, 0)));
7285    }
7286
7287    #[test]
7288    fn e2e_cursor_at_end_of_line_no_newline() {
7289        // "abc" with cursor at position 3 (after 'c', at EOF)
7290        let cursor = get_final_cursor("abc", 3);
7291        assert_eq!(
7292            cursor,
7293            Some((3, 0)),
7294            "Cursor should be at column 3, line 0 (after last char)"
7295        );
7296
7297        let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
7298        assert_eq!(new_content, "abcX", "Typing should append at end");
7299        assert_eq!(cursor_pos, Some((3, 0)));
7300    }
7301
7302    #[test]
7303    fn e2e_cursor_at_empty_line() {
7304        // "\n" with cursor at position 0 (on the newline itself)
7305        let cursor = get_final_cursor("\n", 0);
7306        assert_eq!(
7307            cursor,
7308            Some((0, 0)),
7309            "Cursor on empty line should be at column 0"
7310        );
7311
7312        let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
7313        assert_eq!(new_content, "X\n", "Typing should insert before newline");
7314        assert_eq!(cursor_pos, Some((0, 0)));
7315    }
7316
7317    #[test]
7318    fn e2e_cursor_after_newline_at_eof() {
7319        // "abc\n" with cursor at position 4 (after newline, at EOF)
7320        let cursor = get_final_cursor("abc\n", 4);
7321        assert_eq!(
7322            cursor,
7323            Some((0, 1)),
7324            "Cursor after newline at EOF should be on next line"
7325        );
7326
7327        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
7328        assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
7329        assert_eq!(cursor_pos, Some((0, 1)));
7330    }
7331
7332    #[test]
7333    fn e2e_cursor_on_newline_with_content() {
7334        // "abc\n" with cursor at position 3 (on the newline character)
7335        let cursor = get_final_cursor("abc\n", 3);
7336        assert_eq!(
7337            cursor,
7338            Some((3, 0)),
7339            "Cursor on newline after content should be after last char"
7340        );
7341
7342        let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
7343        assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
7344        assert_eq!(cursor_pos, Some((3, 0)));
7345    }
7346
7347    #[test]
7348    fn e2e_cursor_multiline_start_of_second_line() {
7349        // "abc\ndef" with cursor at position 4 (start of second line, on 'd')
7350        let cursor = get_final_cursor("abc\ndef", 4);
7351        assert_eq!(
7352            cursor,
7353            Some((0, 1)),
7354            "Cursor at start of second line should be at column 0, line 1"
7355        );
7356
7357        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
7358        assert_eq!(
7359            new_content, "abc\nXdef",
7360            "Typing should insert at start of second line"
7361        );
7362        assert_eq!(cursor_pos, Some((0, 1)));
7363    }
7364
7365    #[test]
7366    fn e2e_cursor_multiline_end_of_first_line() {
7367        // "abc\ndef" with cursor at position 3 (on newline of first line)
7368        let cursor = get_final_cursor("abc\ndef", 3);
7369        assert_eq!(
7370            cursor,
7371            Some((3, 0)),
7372            "Cursor on newline of first line should be after content"
7373        );
7374
7375        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
7376        assert_eq!(
7377            new_content, "abcX\ndef",
7378            "Typing should insert before newline"
7379        );
7380        assert_eq!(cursor_pos, Some((3, 0)));
7381    }
7382
7383    #[test]
7384    fn e2e_cursor_empty_buffer() {
7385        // Empty buffer with cursor at position 0
7386        let cursor = get_final_cursor("", 0);
7387        assert_eq!(
7388            cursor,
7389            Some((0, 0)),
7390            "Cursor in empty buffer should be at origin"
7391        );
7392
7393        let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
7394        assert_eq!(
7395            new_content, "X",
7396            "Typing in empty buffer should insert character"
7397        );
7398        assert_eq!(cursor_pos, Some((0, 0)));
7399    }
7400
7401    #[test]
7402    fn e2e_cursor_empty_buffer_with_gutters() {
7403        // Empty buffer with cursor at position 0, with gutters enabled
7404        // The cursor should be positioned at the gutter width (right after the gutter),
7405        // NOT at column 0 (which would be in the gutter area)
7406        let (output, buffer_len, buffer_newline, cursor_pos) =
7407            render_output_for_with_gutters("", 0, true);
7408
7409        // With gutters enabled, the gutter width should be > 0
7410        // Default gutter includes: 1 char indicator + line number width + separator
7411        // For a 1-line buffer, line number width is typically 1 digit + padding
7412        let gutter_width = {
7413            let mut state = EditorState::new(20, 6, 1024, test_fs());
7414            state.margins.left_config.enabled = true;
7415            state.margins.update_width_for_buffer(1, true);
7416            state.margins.left_total_width()
7417        };
7418        assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
7419
7420        // CRITICAL: Check the RENDERED cursor position directly from output.cursor
7421        // This is what the terminal will actually use for cursor positioning
7422        // The cursor should be rendered at gutter_width, not at 0
7423        assert_eq!(
7424            output.cursor,
7425            Some((gutter_width as u16, 0)),
7426            "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
7427            gutter_width,
7428            output.cursor
7429        );
7430
7431        let final_cursor = SplitRenderer::resolve_cursor_fallback(
7432            output.cursor,
7433            cursor_pos,
7434            buffer_len,
7435            buffer_newline,
7436            output.last_line_end,
7437            output.content_lines_rendered,
7438            gutter_width,
7439        );
7440
7441        // Cursor should be at (gutter_width, 0) - right after the gutter on line 0
7442        assert_eq!(
7443            final_cursor,
7444            Some((gutter_width as u16, 0)),
7445            "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
7446        );
7447    }
7448
7449    #[test]
7450    fn e2e_cursor_between_empty_lines() {
7451        // "\n\n" with cursor at position 1 (on second newline)
7452        let cursor = get_final_cursor("\n\n", 1);
7453        assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
7454
7455        let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
7456        assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
7457        assert_eq!(cursor_pos, Some((0, 1)));
7458    }
7459
7460    #[test]
7461    fn e2e_cursor_at_eof_after_multiple_lines() {
7462        // "abc\ndef\nghi" with cursor at position 11 (at EOF, no trailing newline)
7463        let cursor = get_final_cursor("abc\ndef\nghi", 11);
7464        assert_eq!(
7465            cursor,
7466            Some((3, 2)),
7467            "Cursor at EOF after 'i' should be at column 3, line 2"
7468        );
7469
7470        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
7471        assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
7472        assert_eq!(cursor_pos, Some((3, 2)));
7473    }
7474
7475    #[test]
7476    fn e2e_cursor_at_eof_with_trailing_newline() {
7477        // "abc\ndef\nghi\n" with cursor at position 12 (after trailing newline)
7478        let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
7479        assert_eq!(
7480            cursor,
7481            Some((0, 3)),
7482            "Cursor after trailing newline should be on line 3"
7483        );
7484
7485        let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
7486        assert_eq!(
7487            new_content, "abc\ndef\nghi\nX",
7488            "Typing should insert on new line"
7489        );
7490        assert_eq!(cursor_pos, Some((0, 3)));
7491    }
7492
7493    #[test]
7494    fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
7495        // Simulate Ctrl+End: jump from start to end of buffer without trailing newline
7496        let content = "abc\ndef\nghi";
7497
7498        // Start at position 0
7499        let cursor_at_start = get_final_cursor(content, 0);
7500        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
7501
7502        // Jump to EOF (position 11, after 'i')
7503        let cursor_at_eof = get_final_cursor(content, 11);
7504        assert_eq!(
7505            cursor_at_eof,
7506            Some((3, 2)),
7507            "After Ctrl+End, cursor at column 3, line 2"
7508        );
7509
7510        // Type a character at EOF
7511        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
7512        assert_eq!(cursor_before_typing, Some((3, 2)));
7513        assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
7514
7515        // Verify cursor position in the new content
7516        let cursor_after_typing = get_final_cursor(&new_content, 12);
7517        assert_eq!(
7518            cursor_after_typing,
7519            Some((4, 2)),
7520            "After typing, cursor moved to column 4"
7521        );
7522
7523        // Move cursor to start of buffer - verify cursor is no longer at end
7524        let cursor_moved_away = get_final_cursor(&new_content, 0);
7525        assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
7526        // The cursor should NOT be at the end anymore - verify by rendering without cursor at end
7527        // This implicitly tests that only one cursor is rendered
7528    }
7529
7530    #[test]
7531    fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
7532        // Simulate Ctrl+End: jump from start to end of buffer WITH trailing newline
7533        let content = "abc\ndef\nghi\n";
7534
7535        // Start at position 0
7536        let cursor_at_start = get_final_cursor(content, 0);
7537        assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
7538
7539        // Jump to EOF (position 12, after trailing newline)
7540        let cursor_at_eof = get_final_cursor(content, 12);
7541        assert_eq!(
7542            cursor_at_eof,
7543            Some((0, 3)),
7544            "After Ctrl+End, cursor at column 0, line 3 (new line)"
7545        );
7546
7547        // Type a character at EOF
7548        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
7549        assert_eq!(cursor_before_typing, Some((0, 3)));
7550        assert_eq!(
7551            new_content, "abc\ndef\nghi\nX",
7552            "Character inserted on new line"
7553        );
7554
7555        // After typing, the cursor should move forward
7556        let cursor_after_typing = get_final_cursor(&new_content, 13);
7557        assert_eq!(
7558            cursor_after_typing,
7559            Some((1, 3)),
7560            "After typing, cursor should be at column 1, line 3"
7561        );
7562
7563        // Move cursor to middle of buffer - verify cursor is no longer at end
7564        let cursor_moved_away = get_final_cursor(&new_content, 4);
7565        assert_eq!(
7566            cursor_moved_away,
7567            Some((0, 1)),
7568            "Cursor moved to start of line 1 (position 4 = start of 'def')"
7569        );
7570    }
7571
7572    #[test]
7573    fn e2e_jump_to_end_of_empty_buffer() {
7574        // Edge case: Ctrl+End in empty buffer should stay at (0,0)
7575        let content = "";
7576
7577        let cursor_at_eof = get_final_cursor(content, 0);
7578        assert_eq!(
7579            cursor_at_eof,
7580            Some((0, 0)),
7581            "Empty buffer: cursor at origin"
7582        );
7583
7584        // Type a character
7585        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
7586        assert_eq!(cursor_before_typing, Some((0, 0)));
7587        assert_eq!(new_content, "X", "Character inserted");
7588
7589        // Verify cursor after typing
7590        let cursor_after_typing = get_final_cursor(&new_content, 1);
7591        assert_eq!(
7592            cursor_after_typing,
7593            Some((1, 0)),
7594            "After typing, cursor at column 1"
7595        );
7596
7597        // Move cursor back to start - verify cursor is no longer at end
7598        let cursor_moved_away = get_final_cursor(&new_content, 0);
7599        assert_eq!(
7600            cursor_moved_away,
7601            Some((0, 0)),
7602            "Cursor moved back to start"
7603        );
7604    }
7605
7606    #[test]
7607    fn e2e_jump_to_end_of_single_empty_line() {
7608        // Edge case: buffer with just a newline
7609        let content = "\n";
7610
7611        // Position 0 is ON the newline
7612        let cursor_on_newline = get_final_cursor(content, 0);
7613        assert_eq!(
7614            cursor_on_newline,
7615            Some((0, 0)),
7616            "Cursor on the newline character"
7617        );
7618
7619        // Position 1 is AFTER the newline (EOF)
7620        let cursor_at_eof = get_final_cursor(content, 1);
7621        assert_eq!(
7622            cursor_at_eof,
7623            Some((0, 1)),
7624            "After Ctrl+End, cursor on line 1"
7625        );
7626
7627        // Type at EOF
7628        let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
7629        assert_eq!(cursor_before_typing, Some((0, 1)));
7630        assert_eq!(new_content, "\nX", "Character on second line");
7631
7632        let cursor_after_typing = get_final_cursor(&new_content, 2);
7633        assert_eq!(
7634            cursor_after_typing,
7635            Some((1, 1)),
7636            "After typing, cursor at column 1, line 1"
7637        );
7638
7639        // Move cursor to the newline - verify cursor is no longer at end
7640        let cursor_moved_away = get_final_cursor(&new_content, 0);
7641        assert_eq!(
7642            cursor_moved_away,
7643            Some((0, 0)),
7644            "Cursor moved to the newline on line 0"
7645        );
7646    }
7647    // NOTE: Tests for view transform header handling have been moved to src/ui/view_pipeline.rs
7648    // where the elegant token-based pipeline properly handles these cases.
7649    // The view_pipeline tests cover:
7650    // - test_simple_source_lines
7651    // - test_wrapped_continuation
7652    // - test_injected_header_then_source
7653    // - test_mixed_scenario
7654
7655    // ==================== CRLF Tokenization Tests ====================
7656
7657    use crate::model::buffer::LineEnding;
7658    use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
7659
7660    /// Helper to extract source_offset from tokens for easier assertion
7661    fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
7662        tokens
7663            .iter()
7664            .map(|t| {
7665                let kind_str = match &t.kind {
7666                    ViewTokenWireKind::Text(s) => format!("Text({})", s),
7667                    ViewTokenWireKind::Newline => "Newline".to_string(),
7668                    ViewTokenWireKind::Space => "Space".to_string(),
7669                    ViewTokenWireKind::Break => "Break".to_string(),
7670                    ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
7671                };
7672                (kind_str, t.source_offset)
7673            })
7674            .collect()
7675    }
7676
7677    /// Test tokenization of CRLF content with a single line.
7678    /// Verifies that Newline token is at \r position and \n is skipped.
7679    #[test]
7680    fn test_build_base_tokens_crlf_single_line() {
7681        // Content: "abc\r\n" (5 bytes: a=0, b=1, c=2, \r=3, \n=4)
7682        let content = b"abc\r\n";
7683        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
7684        buffer.set_line_ending(LineEnding::CRLF);
7685
7686        let tokens = SplitRenderer::build_base_tokens_for_hook(
7687            &mut buffer,
7688            0,     // top_byte
7689            80,    // estimated_line_length
7690            10,    // visible_count
7691            false, // is_binary
7692            LineEnding::CRLF,
7693        );
7694
7695        let offsets = extract_token_offsets(&tokens);
7696
7697        // Should have: Text("abc") at 0, Newline at 3
7698        // The \n at byte 4 should be skipped
7699        assert!(
7700            offsets
7701                .iter()
7702                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
7703            "Expected Text(abc) at offset 0, got: {:?}",
7704            offsets
7705        );
7706        assert!(
7707            offsets
7708                .iter()
7709                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
7710            "Expected Newline at offset 3 (\\r position), got: {:?}",
7711            offsets
7712        );
7713
7714        // Verify there's only one Newline token
7715        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
7716        assert_eq!(
7717            newline_count, 1,
7718            "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
7719            newline_count, offsets
7720        );
7721    }
7722
7723    /// Test tokenization of CRLF content with multiple lines.
7724    /// This verifies that source_offset correctly accumulates across lines.
7725    #[test]
7726    fn test_build_base_tokens_crlf_multiple_lines() {
7727        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
7728        // Line 1: a=0, b=1, c=2, \r=3, \n=4
7729        // Line 2: d=5, e=6, f=7, \r=8, \n=9
7730        // Line 3: g=10, h=11, i=12, \r=13, \n=14
7731        let content = b"abc\r\ndef\r\nghi\r\n";
7732        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
7733        buffer.set_line_ending(LineEnding::CRLF);
7734
7735        let tokens = SplitRenderer::build_base_tokens_for_hook(
7736            &mut buffer,
7737            0,
7738            80,
7739            10,
7740            false,
7741            LineEnding::CRLF,
7742        );
7743
7744        let offsets = extract_token_offsets(&tokens);
7745
7746        // Expected tokens:
7747        // Text("abc") at 0, Newline at 3
7748        // Text("def") at 5, Newline at 8
7749        // Text("ghi") at 10, Newline at 13
7750
7751        // Verify line 1 tokens
7752        assert!(
7753            offsets
7754                .iter()
7755                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
7756            "Line 1: Expected Text(abc) at 0, got: {:?}",
7757            offsets
7758        );
7759        assert!(
7760            offsets
7761                .iter()
7762                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
7763            "Line 1: Expected Newline at 3, got: {:?}",
7764            offsets
7765        );
7766
7767        // Verify line 2 tokens - THIS IS WHERE OFFSET DRIFT WOULD APPEAR
7768        assert!(
7769            offsets
7770                .iter()
7771                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
7772            "Line 2: Expected Text(def) at 5, got: {:?}",
7773            offsets
7774        );
7775        assert!(
7776            offsets
7777                .iter()
7778                .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
7779            "Line 2: Expected Newline at 8, got: {:?}",
7780            offsets
7781        );
7782
7783        // Verify line 3 tokens - DRIFT ACCUMULATES HERE
7784        assert!(
7785            offsets
7786                .iter()
7787                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
7788            "Line 3: Expected Text(ghi) at 10, got: {:?}",
7789            offsets
7790        );
7791        assert!(
7792            offsets
7793                .iter()
7794                .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
7795            "Line 3: Expected Newline at 13, got: {:?}",
7796            offsets
7797        );
7798
7799        // Verify exactly 3 Newline tokens
7800        let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
7801        assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
7802    }
7803
7804    /// Test tokenization of LF content to compare with CRLF.
7805    /// LF mode should NOT skip anything - each character gets its own offset.
7806    #[test]
7807    fn test_build_base_tokens_lf_mode_for_comparison() {
7808        // Content: "abc\ndef\n" (8 bytes)
7809        // Line 1: a=0, b=1, c=2, \n=3
7810        // Line 2: d=4, e=5, f=6, \n=7
7811        let content = b"abc\ndef\n";
7812        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
7813        buffer.set_line_ending(LineEnding::LF);
7814
7815        let tokens = SplitRenderer::build_base_tokens_for_hook(
7816            &mut buffer,
7817            0,
7818            80,
7819            10,
7820            false,
7821            LineEnding::LF,
7822        );
7823
7824        let offsets = extract_token_offsets(&tokens);
7825
7826        // Verify LF offsets
7827        assert!(
7828            offsets
7829                .iter()
7830                .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
7831            "LF Line 1: Expected Text(abc) at 0"
7832        );
7833        assert!(
7834            offsets
7835                .iter()
7836                .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
7837            "LF Line 1: Expected Newline at 3"
7838        );
7839        assert!(
7840            offsets
7841                .iter()
7842                .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
7843            "LF Line 2: Expected Text(def) at 4"
7844        );
7845        assert!(
7846            offsets
7847                .iter()
7848                .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
7849            "LF Line 2: Expected Newline at 7"
7850        );
7851    }
7852
7853    /// Test that CRLF in LF-mode file shows \r as control character.
7854    /// This verifies that \r is rendered as <0D> in LF files.
7855    #[test]
7856    fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
7857        // Content: "abc\r\n" but buffer is in LF mode
7858        let content = b"abc\r\n";
7859        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
7860        buffer.set_line_ending(LineEnding::LF); // Force LF mode
7861
7862        let tokens = SplitRenderer::build_base_tokens_for_hook(
7863            &mut buffer,
7864            0,
7865            80,
7866            10,
7867            false,
7868            LineEnding::LF,
7869        );
7870
7871        let offsets = extract_token_offsets(&tokens);
7872
7873        // In LF mode, \r should be rendered as BinaryByte(0x0d)
7874        assert!(
7875            offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
7876            "LF mode should render \\r as control char <0D>, got: {:?}",
7877            offsets
7878        );
7879    }
7880
7881    /// Test tokenization starting from middle of file (top_byte != 0).
7882    /// Verifies that source_offset is correct even when not starting from byte 0.
7883    #[test]
7884    fn test_build_base_tokens_crlf_from_middle() {
7885        // Content: "abc\r\ndef\r\nghi\r\n" (15 bytes)
7886        // Start from byte 5 (beginning of "def")
7887        let content = b"abc\r\ndef\r\nghi\r\n";
7888        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
7889        buffer.set_line_ending(LineEnding::CRLF);
7890
7891        let tokens = SplitRenderer::build_base_tokens_for_hook(
7892            &mut buffer,
7893            5, // Start from line 2
7894            80,
7895            10,
7896            false,
7897            LineEnding::CRLF,
7898        );
7899
7900        let offsets = extract_token_offsets(&tokens);
7901
7902        // Should have:
7903        // Text("def") at 5, Newline at 8
7904        // Text("ghi") at 10, Newline at 13
7905        assert!(
7906            offsets
7907                .iter()
7908                .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
7909            "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
7910            offsets
7911        );
7912        assert!(
7913            offsets
7914                .iter()
7915                .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
7916            "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
7917            offsets
7918        );
7919    }
7920
7921    /// End-to-end test: verify full pipeline from CRLF buffer to ViewLine to highlighting lookup
7922    /// This test simulates the complete flow that would trigger the offset drift bug.
7923    #[test]
7924    fn test_crlf_highlight_span_lookup() {
7925        use crate::view::ui::view_pipeline::ViewLineIterator;
7926
7927        // Simulate Java-like CRLF content:
7928        // "int x;\r\nint y;\r\n"
7929        // Bytes: i=0, n=1, t=2, ' '=3, x=4, ;=5, \r=6, \n=7,
7930        //        i=8, n=9, t=10, ' '=11, y=12, ;=13, \r=14, \n=15
7931        let content = b"int x;\r\nint y;\r\n";
7932        let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
7933        buffer.set_line_ending(LineEnding::CRLF);
7934
7935        // Step 1: Generate tokens
7936        let tokens = SplitRenderer::build_base_tokens_for_hook(
7937            &mut buffer,
7938            0,
7939            80,
7940            10,
7941            false,
7942            LineEnding::CRLF,
7943        );
7944
7945        // Verify tokens have correct offsets
7946        let offsets = extract_token_offsets(&tokens);
7947        eprintln!("Tokens: {:?}", offsets);
7948
7949        // Step 2: Convert tokens to ViewLines
7950        let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
7951        assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
7952
7953        // Step 3: Verify char_source_bytes mapping for each line
7954        // Line 1: "int x;\n" displayed, maps to bytes 0-6
7955        eprintln!(
7956            "Line 1 char_source_bytes: {:?}",
7957            view_lines[0].char_source_bytes
7958        );
7959        assert_eq!(
7960            view_lines[0].char_source_bytes.len(),
7961            7,
7962            "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
7963        );
7964        // Check specific mappings
7965        assert_eq!(
7966            view_lines[0].char_source_bytes[0],
7967            Some(0),
7968            "Line 1 'i' -> byte 0"
7969        );
7970        assert_eq!(
7971            view_lines[0].char_source_bytes[4],
7972            Some(4),
7973            "Line 1 'x' -> byte 4"
7974        );
7975        assert_eq!(
7976            view_lines[0].char_source_bytes[5],
7977            Some(5),
7978            "Line 1 ';' -> byte 5"
7979        );
7980        assert_eq!(
7981            view_lines[0].char_source_bytes[6],
7982            Some(6),
7983            "Line 1 newline -> byte 6 (\\r pos)"
7984        );
7985
7986        // Line 2: "int y;\n" displayed, maps to bytes 8-14
7987        eprintln!(
7988            "Line 2 char_source_bytes: {:?}",
7989            view_lines[1].char_source_bytes
7990        );
7991        assert_eq!(
7992            view_lines[1].char_source_bytes.len(),
7993            7,
7994            "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
7995        );
7996        // Check specific mappings - THIS IS WHERE DRIFT WOULD SHOW
7997        assert_eq!(
7998            view_lines[1].char_source_bytes[0],
7999            Some(8),
8000            "Line 2 'i' -> byte 8"
8001        );
8002        assert_eq!(
8003            view_lines[1].char_source_bytes[4],
8004            Some(12),
8005            "Line 2 'y' -> byte 12"
8006        );
8007        assert_eq!(
8008            view_lines[1].char_source_bytes[5],
8009            Some(13),
8010            "Line 2 ';' -> byte 13"
8011        );
8012        assert_eq!(
8013            view_lines[1].char_source_bytes[6],
8014            Some(14),
8015            "Line 2 newline -> byte 14 (\\r pos)"
8016        );
8017
8018        // Step 4: Simulate highlight span lookup
8019        // If TreeSitter highlights "int" as keyword (bytes 0-3 for line 1, bytes 8-11 for line 2),
8020        // the lookup should find these correctly.
8021        let simulated_highlight_spans = [
8022            // "int" on line 1: bytes 0-3
8023            (0usize..3usize, "keyword"),
8024            // "int" on line 2: bytes 8-11
8025            (8usize..11usize, "keyword"),
8026        ];
8027
8028        // Verify that looking up byte positions from char_source_bytes finds the right spans
8029        for (line_idx, view_line) in view_lines.iter().enumerate() {
8030            for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
8031                if let Some(bp) = byte_pos {
8032                    let in_span = simulated_highlight_spans
8033                        .iter()
8034                        .find(|(range, _)| range.contains(bp))
8035                        .map(|(_, name)| *name);
8036
8037                    // First 3 chars of each line should be in keyword span
8038                    let expected_in_keyword = char_idx < 3;
8039                    let actually_in_keyword = in_span == Some("keyword");
8040
8041                    if expected_in_keyword != actually_in_keyword {
8042                        panic!(
8043                            "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
8044                            line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
8045                        );
8046                    }
8047                }
8048            }
8049        }
8050    }
8051
8052    /// Test that apply_wrapping_transform correctly breaks long lines.
8053    /// This prevents memory exhaustion from extremely long single-line files (issue #481).
8054    #[test]
8055    fn test_apply_wrapping_transform_breaks_long_lines() {
8056        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
8057
8058        // Create a token with 25,000 characters (longer than MAX_SAFE_LINE_WIDTH of 10,000)
8059        let long_text = "x".repeat(25_000);
8060        let tokens = vec![
8061            ViewTokenWire {
8062                kind: ViewTokenWireKind::Text(long_text),
8063                source_offset: Some(0),
8064                style: None,
8065            },
8066            ViewTokenWire {
8067                kind: ViewTokenWireKind::Newline,
8068                source_offset: Some(25_000),
8069                style: None,
8070            },
8071        ];
8072
8073        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
8074        let wrapped =
8075            SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
8076
8077        // Count Break tokens - should have at least 2 breaks for 25K chars at 10K width
8078        let break_count = wrapped
8079            .iter()
8080            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
8081            .count();
8082
8083        assert!(
8084            break_count >= 2,
8085            "25K char line should have at least 2 breaks at 10K width, got {}",
8086            break_count
8087        );
8088
8089        // Verify total content is preserved (excluding Break tokens)
8090        let total_chars: usize = wrapped
8091            .iter()
8092            .filter_map(|t| match &t.kind {
8093                ViewTokenWireKind::Text(s) => Some(s.len()),
8094                _ => None,
8095            })
8096            .sum();
8097
8098        assert_eq!(
8099            total_chars, 25_000,
8100            "Total character count should be preserved after wrapping"
8101        );
8102    }
8103
8104    /// Test that normal-length lines are not affected by safety wrapping.
8105    #[test]
8106    fn test_apply_wrapping_transform_preserves_short_lines() {
8107        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
8108
8109        // Create a token with 100 characters (much shorter than MAX_SAFE_LINE_WIDTH)
8110        let short_text = "x".repeat(100);
8111        let tokens = vec![
8112            ViewTokenWire {
8113                kind: ViewTokenWireKind::Text(short_text.clone()),
8114                source_offset: Some(0),
8115                style: None,
8116            },
8117            ViewTokenWire {
8118                kind: ViewTokenWireKind::Newline,
8119                source_offset: Some(100),
8120                style: None,
8121            },
8122        ];
8123
8124        // Apply wrapping with MAX_SAFE_LINE_WIDTH (simulating line_wrap disabled)
8125        let wrapped =
8126            SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
8127
8128        // Should have no Break tokens for short lines
8129        let break_count = wrapped
8130            .iter()
8131            .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
8132            .count();
8133
8134        assert_eq!(
8135            break_count, 0,
8136            "Short lines should not have any breaks, got {}",
8137            break_count
8138        );
8139
8140        // Original text should be preserved exactly
8141        let text_tokens: Vec<_> = wrapped
8142            .iter()
8143            .filter_map(|t| match &t.kind {
8144                ViewTokenWireKind::Text(s) => Some(s.clone()),
8145                _ => None,
8146            })
8147            .collect();
8148
8149        assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
8150        assert_eq!(
8151            text_tokens[0], short_text,
8152            "Text content should be unchanged"
8153        );
8154    }
8155
8156    /// End-to-end test: verify large single-line content with sequential markers
8157    /// is correctly chunked, wrapped, and all data is preserved through the pipeline.
8158    #[test]
8159    fn test_large_single_line_sequential_data_preserved() {
8160        use crate::view::ui::view_pipeline::ViewLineIterator;
8161        use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
8162
8163        // Create content with sequential markers that span multiple chunks
8164        // Format: "[00001][00002]..." - each marker is 7 chars
8165        let num_markers = 5_000; // ~35KB, enough to test chunking at 10K char intervals
8166        let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
8167
8168        // Create tokens simulating what build_base_tokens would produce
8169        let tokens = vec![
8170            ViewTokenWire {
8171                kind: ViewTokenWireKind::Text(content.clone()),
8172                source_offset: Some(0),
8173                style: None,
8174            },
8175            ViewTokenWire {
8176                kind: ViewTokenWireKind::Newline,
8177                source_offset: Some(content.len()),
8178                style: None,
8179            },
8180        ];
8181
8182        // Apply safety wrapping (simulating line_wrap=false with MAX_SAFE_LINE_WIDTH)
8183        let wrapped =
8184            SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
8185
8186        // Convert to ViewLines
8187        let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
8188
8189        // Reconstruct content from ViewLines
8190        let mut reconstructed = String::new();
8191        for line in &view_lines {
8192            // Skip the trailing newline character in each line's text
8193            let text = line.text.trim_end_matches('\n');
8194            reconstructed.push_str(text);
8195        }
8196
8197        // Verify all content is preserved
8198        assert_eq!(
8199            reconstructed.len(),
8200            content.len(),
8201            "Reconstructed content length should match original"
8202        );
8203
8204        // Verify sequential markers are all present
8205        for i in 1..=num_markers {
8206            let marker = format!("[{:05}]", i);
8207            assert!(
8208                reconstructed.contains(&marker),
8209                "Missing marker {} after pipeline",
8210                marker
8211            );
8212        }
8213
8214        // Verify order is preserved by checking sample positions
8215        let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
8216        let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
8217        let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
8218        assert!(
8219            pos_100 < pos_1000 && pos_1000 < pos_3000,
8220            "Markers should be in sequential order: {} < {} < {}",
8221            pos_100,
8222            pos_1000,
8223            pos_3000
8224        );
8225
8226        // Verify we got multiple visual lines (content was wrapped)
8227        assert!(
8228            view_lines.len() >= 3,
8229            "35KB content should produce multiple visual lines at 10K width, got {}",
8230            view_lines.len()
8231        );
8232
8233        // Verify each ViewLine is bounded in size (memory safety check)
8234        for (i, line) in view_lines.iter().enumerate() {
8235            assert!(
8236                line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, // +10 for newline and rounding
8237                "ViewLine {} exceeds safe width: {} chars",
8238                i,
8239                line.text.len()
8240            );
8241        }
8242    }
8243
8244    /// Helper: strip OSC 8 escape sequences from a string, returning plain text.
8245    fn strip_osc8(s: &str) -> String {
8246        let mut result = String::with_capacity(s.len());
8247        let bytes = s.as_bytes();
8248        let mut i = 0;
8249        while i < bytes.len() {
8250            if i + 3 < bytes.len()
8251                && bytes[i] == 0x1b
8252                && bytes[i + 1] == b']'
8253                && bytes[i + 2] == b'8'
8254                && bytes[i + 3] == b';'
8255            {
8256                i += 4;
8257                while i < bytes.len() && bytes[i] != 0x07 {
8258                    i += 1;
8259                }
8260                if i < bytes.len() {
8261                    i += 1;
8262                }
8263            } else {
8264                result.push(bytes[i] as char);
8265                i += 1;
8266            }
8267        }
8268        result
8269    }
8270
8271    /// Read a row from a ratatui buffer, skipping the second cell of 2-char
8272    /// OSC 8 chunks so we get clean text.
8273    fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
8274        let width = buf.area().width;
8275        let mut s = String::new();
8276        let mut col = 0u16;
8277        while col < width {
8278            let cell = &buf[(col, y)];
8279            let stripped = strip_osc8(cell.symbol());
8280            let chars = stripped.chars().count();
8281            if chars > 1 {
8282                s.push_str(&stripped);
8283                col += chars as u16;
8284            } else {
8285                s.push_str(&stripped);
8286                col += 1;
8287            }
8288        }
8289        s.trim_end().to_string()
8290    }
8291
8292    #[test]
8293    fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
8294        use ratatui::buffer::Buffer;
8295        use ratatui::layout::Rect;
8296
8297        // Simulate: "[Quick Install](#installation)" in a 40-wide buffer row 0
8298        let text = "[Quick Install](#installation)";
8299        let area = Rect::new(0, 0, 40, 1);
8300        let mut buf = Buffer::empty(area);
8301        for (i, ch) in text.chars().enumerate() {
8302            if (i as u16) < 40 {
8303                buf[(i as u16, 0)].set_symbol(&ch.to_string());
8304            }
8305        }
8306
8307        // Overlay covers "Quick Install" = cols 1..14 (bytes 9..22 mapped to screen)
8308        let url = "https://example.com";
8309
8310        // Apply with cursor at col 0 (not inside the overlay range)
8311        SplitRenderer::apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
8312
8313        let row = read_row(&buf, 0);
8314        assert_eq!(
8315            row, text,
8316            "After OSC 8 application, reading the row should reproduce the original text"
8317        );
8318
8319        // Cell 14 = ']' must not be touched
8320        let cell14 = strip_osc8(buf[(14, 0)].symbol());
8321        assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
8322
8323        // Cell 0 = '[' must not be touched
8324        let cell0 = strip_osc8(buf[(0, 0)].symbol());
8325        assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
8326    }
8327
8328    #[test]
8329    fn test_apply_osc8_stable_across_reapply() {
8330        use ratatui::buffer::Buffer;
8331        use ratatui::layout::Rect;
8332
8333        let text = "[Quick Install](#installation)";
8334        let area = Rect::new(0, 0, 40, 1);
8335
8336        // First render: apply OSC 8 with cursor at col 0
8337        let mut buf1 = Buffer::empty(area);
8338        for (i, ch) in text.chars().enumerate() {
8339            if (i as u16) < 40 {
8340                buf1[(i as u16, 0)].set_symbol(&ch.to_string());
8341            }
8342        }
8343        SplitRenderer::apply_osc8_to_cells(
8344            &mut buf1,
8345            1,
8346            14,
8347            0,
8348            "https://example.com",
8349            Some((0, 0)),
8350        );
8351        let row1 = read_row(&buf1, 0);
8352
8353        // Second render: fresh buffer, same text, apply OSC 8 with cursor at col 5
8354        let mut buf2 = Buffer::empty(area);
8355        for (i, ch) in text.chars().enumerate() {
8356            if (i as u16) < 40 {
8357                buf2[(i as u16, 0)].set_symbol(&ch.to_string());
8358            }
8359        }
8360        SplitRenderer::apply_osc8_to_cells(
8361            &mut buf2,
8362            1,
8363            14,
8364            0,
8365            "https://example.com",
8366            Some((5, 0)),
8367        );
8368        let row2 = read_row(&buf2, 0);
8369
8370        assert_eq!(row1, text);
8371        assert_eq!(row2, text);
8372    }
8373
8374    #[test]
8375    #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
8376    fn test_apply_osc8_diff_between_renders() {
8377        use ratatui::buffer::Buffer;
8378        use ratatui::layout::Rect;
8379
8380        // Simulate ratatui's diff-based update: a "concealed" render followed
8381        // by an "unconcealed" render. The backend buffer accumulates diffs.
8382        let area = Rect::new(0, 0, 40, 1);
8383
8384        // --- Render 1: concealed text "Quick Install" at cols 0..12, rest is space ---
8385        let concealed = "Quick Install";
8386        let mut frame1 = Buffer::empty(area);
8387        for (i, ch) in concealed.chars().enumerate() {
8388            frame1[(i as u16, 0)].set_symbol(&ch.to_string());
8389        }
8390        // OSC 8 covers cols 0..13 (concealed mapping)
8391        SplitRenderer::apply_osc8_to_cells(
8392            &mut frame1,
8393            0,
8394            13,
8395            0,
8396            "https://example.com",
8397            Some((0, 5)),
8398        );
8399
8400        // Simulate backend: starts empty, apply diff from frame1
8401        let prev = Buffer::empty(area);
8402        let mut backend = Buffer::empty(area);
8403        let diff1 = prev.diff(&frame1);
8404        for (x, y, cell) in &diff1 {
8405            backend[(*x, *y)] = (*cell).clone();
8406        }
8407
8408        // --- Render 2: unconcealed "[Quick Install](#installation)" ---
8409        let full = "[Quick Install](#installation)";
8410        let mut frame2 = Buffer::empty(area);
8411        for (i, ch) in full.chars().enumerate() {
8412            if (i as u16) < 40 {
8413                frame2[(i as u16, 0)].set_symbol(&ch.to_string());
8414            }
8415        }
8416        // OSC 8 covers cols 1..14 (unconcealed mapping)
8417        SplitRenderer::apply_osc8_to_cells(
8418            &mut frame2,
8419            1,
8420            14,
8421            0,
8422            "https://example.com",
8423            Some((0, 0)),
8424        );
8425
8426        // Apply diff from frame1→frame2 to backend
8427        let diff2 = frame1.diff(&frame2);
8428        for (x, y, cell) in &diff2 {
8429            backend[(*x, *y)] = (*cell).clone();
8430        }
8431
8432        // Backend should now show the full text when read
8433        let row = read_row(&backend, 0);
8434        assert_eq!(
8435            row, full,
8436            "After diff-based update from concealed to unconcealed, \
8437             backend should show full text"
8438        );
8439
8440        // Specifically, cell 14 must be ']'
8441        let cell14 = strip_osc8(backend[(14, 0)].symbol());
8442        assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
8443    }
8444
8445    // --- Current line highlight tests ---
8446
8447    fn render_with_highlight_option(
8448        content: &str,
8449        cursor_pos: usize,
8450        highlight_current_line: bool,
8451    ) -> LineRenderOutput {
8452        let mut state = EditorState::new(20, 6, 1024, test_fs());
8453        state.buffer = Buffer::from_str(content, 1024, test_fs());
8454        let mut cursors = crate::model::cursor::Cursors::new();
8455        cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
8456        let viewport = Viewport::new(20, 4);
8457        state.margins.left_config.enabled = false;
8458
8459        let render_area = Rect::new(0, 0, 20, 4);
8460        let visible_count = viewport.visible_line_count();
8461        let gutter_width = state.margins.left_total_width();
8462        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
8463        let empty_folds = FoldManager::new();
8464
8465        let view_data = SplitRenderer::build_view_data(
8466            &mut state,
8467            &viewport,
8468            None,
8469            content.len().max(1),
8470            visible_count,
8471            false,
8472            render_area.width as usize,
8473            gutter_width,
8474            &ViewMode::Source,
8475            &empty_folds,
8476            &theme,
8477        );
8478        let view_anchor = SplitRenderer::calculate_view_anchor(&view_data.lines, 0);
8479
8480        let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
8481        state.margins.update_width_for_buffer(estimated_lines, true);
8482        let gutter_width = state.margins.left_total_width();
8483
8484        let selection = SplitRenderer::selection_context(&state, &cursors);
8485        let _ = state
8486            .buffer
8487            .populate_line_cache(viewport.top_byte, visible_count);
8488        let viewport_start = viewport.top_byte;
8489        let viewport_end = SplitRenderer::calculate_viewport_end(
8490            &mut state,
8491            viewport_start,
8492            content.len().max(1),
8493            visible_count,
8494        );
8495        let decorations = SplitRenderer::decoration_context(
8496            &mut state,
8497            viewport_start,
8498            viewport_end,
8499            selection.primary_cursor_position,
8500            &empty_folds,
8501            &theme,
8502            100_000,
8503            &ViewMode::Source,
8504            false,
8505        );
8506
8507        SplitRenderer::render_view_lines(LineRenderInput {
8508            state: &state,
8509            theme: &theme,
8510            view_lines: &view_data.lines,
8511            view_anchor,
8512            render_area,
8513            gutter_width,
8514            selection: &selection,
8515            decorations: &decorations,
8516            visible_line_count: visible_count,
8517            lsp_waiting: false,
8518            is_active: true,
8519            line_wrap: viewport.line_wrap_enabled,
8520            estimated_lines,
8521            left_column: viewport.left_column,
8522            relative_line_numbers: false,
8523            session_mode: false,
8524            software_cursor_only: false,
8525            show_line_numbers: false,
8526            byte_offset_mode: false,
8527            show_tilde: true,
8528            highlight_current_line,
8529            cell_theme_map: &mut Vec::new(),
8530            screen_width: 0,
8531        })
8532    }
8533
8534    /// Check whether any span on a given line has `current_line_bg` as its background.
8535    fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
8536        let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
8537        if let Some(line) = output.lines.get(line_idx) {
8538            line.spans
8539                .iter()
8540                .any(|span| span.style.bg == Some(current_line_bg))
8541        } else {
8542            false
8543        }
8544    }
8545
8546    #[test]
8547    fn current_line_highlight_enabled_highlights_cursor_line() {
8548        let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
8549        // Cursor is on line 0 — it should have current_line_bg
8550        assert!(
8551            line_has_current_line_bg(&output, 0),
8552            "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
8553        );
8554        // Line 1 should NOT have current_line_bg
8555        assert!(
8556            !line_has_current_line_bg(&output, 1),
8557            "Non-cursor line (line 1) should NOT have current_line_bg"
8558        );
8559    }
8560
8561    #[test]
8562    fn current_line_highlight_disabled_no_highlight() {
8563        let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
8564        // No line should have current_line_bg when disabled
8565        assert!(
8566            !line_has_current_line_bg(&output, 0),
8567            "Cursor line should NOT have current_line_bg when highlighting is disabled"
8568        );
8569        assert!(
8570            !line_has_current_line_bg(&output, 1),
8571            "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
8572        );
8573    }
8574
8575    #[test]
8576    fn current_line_highlight_follows_cursor_position() {
8577        // Cursor on line 1 (byte 4 = start of "def")
8578        let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
8579        assert!(
8580            !line_has_current_line_bg(&output, 0),
8581            "Line 0 should NOT have current_line_bg when cursor is on line 1"
8582        );
8583        assert!(
8584            line_has_current_line_bg(&output, 1),
8585            "Line 1 should have current_line_bg when cursor is there"
8586        );
8587        assert!(
8588            !line_has_current_line_bg(&output, 2),
8589            "Line 2 should NOT have current_line_bg when cursor is on line 1"
8590        );
8591    }
8592}