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