1use 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
31const MAX_SAFE_LINE_WIDTH: usize = 10_000;
37
38fn 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 let prefix_len = old_chars
50 .iter()
51 .zip(new_chars.iter())
52 .take_while(|(a, b)| a == b)
53 .count();
54
55 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 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 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
104fn 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
121fn dim_color_for_tilde(color: Color) -> Color {
124 match color {
125 Color::Rgb(r, g, b) => {
126 Color::Rgb(r / 2, g / 2, b / 2)
128 }
129 Color::Indexed(idx) => {
130 if idx < 16 {
135 Color::Rgb(50, 50, 50) } else {
137 Color::Rgb(40, 40, 40) }
139 }
140 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
161struct 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 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 !self.text.is_empty() && style != self.style {
192 self.flush(spans, map);
193 }
194
195 if self.text.is_empty() {
197 self.style = style;
198 self.first_source = source;
199 }
200
201 self.text.push(ch);
202
203 let width = char_width(ch);
205 for _ in 0..width {
206 map.push(source);
207 }
208 }
209
210 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
219fn push_debug_tag(spans: &mut Vec<Span<'static>>, map: &mut Vec<Option<usize>>, text: String) {
221 if text.is_empty() {
222 return;
223 }
224 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#[derive(Default)]
236struct DebugSpanTracker {
237 active_highlight: Option<Range<usize>>,
239 active_overlays: Vec<Range<usize>>,
241}
242
243impl DebugSpanTracker {
244 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 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 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 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 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 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 for i in closed_indices.into_iter().rev() {
302 self.active_overlays.remove(i);
303 }
304 }
305
306 tags
307 }
308}
309
310struct ViewData {
312 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: HashSet<usize>,
341 diagnostic_inline_texts: HashMap<usize, (String, Style)>,
344 line_indicators: BTreeMap<usize, crate::view::margin::LineIndicator>,
346 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
369struct 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 show_line_numbers: bool,
399}
400
401struct LineRenderInput<'a> {
402 state: &'a EditorState,
403 theme: &'a crate::view::theme::Theme,
404 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: usize,
418 relative_line_numbers: bool,
420 session_mode: bool,
422 software_cursor_only: bool,
424 show_line_numbers: bool,
426 byte_offset_mode: bool,
429}
430
431struct 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 highlight_color: Option<Color>,
441 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_primary_cursor_reverse: bool,
449}
450
451struct CharStyleOutput {
453 style: Style,
454 is_secondary_cursor: bool,
455}
456
457struct LeftMarginContext<'a> {
459 state: &'a EditorState,
460 theme: &'a crate::view::theme::Theme,
461 is_continuation: bool,
462 line_start_byte: Option<usize>,
464 gutter_num: usize,
466 estimated_lines: usize,
467 diagnostic_lines: &'a HashSet<usize>,
468 line_indicators: &'a BTreeMap<usize, crate::view::margin::LineIndicator>,
470 fold_indicators: &'a BTreeMap<usize, FoldIndicator>,
472 cursor_line_start_byte: usize,
474 relative_line_numbers: bool,
476 show_line_numbers: bool,
478 byte_offset_mode: bool,
480}
481
482fn 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
493fn 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 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 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 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 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 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 if ctx.is_continuation {
560 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 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 let display_num = if is_cursor_line {
585 ctx.gutter_num + 1
587 } else {
588 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 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 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 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#[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
654fn compute_char_style(ctx: &CharStyleContext) -> CharStyleOutput {
656 use crate::view::overlay::OverlayFace;
657
658 let highlight_color = ctx.highlight_color;
659
660 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 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 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 Style::default().fg(color)
709 } else {
710 Style::default().fg(ctx.theme.editor_fg)
712 };
713
714 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 if ctx.token_style.is_none() {
729 if let Some(color) = ctx.semantic_token_color {
730 style = style.fg(color);
731 }
732 }
733
734 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 if ctx.is_selected {
777 style = Style::default()
778 .fg(ctx.theme.editor_fg)
779 .bg(ctx.theme.selection_bg);
780 }
781
782 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 if is_secondary_cursor {
795 style = style.add_modifier(Modifier::REVERSED);
796 }
797 } else {
798 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
814pub struct SplitRenderer;
816
817impl SplitRenderer {
818 #[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)>, 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>, Vec<(LeafId, u16, u16, u16)>, Vec<(LeafId, u16, u16, u16)>, HashMap<LeafId, Vec<ViewLineMapping>>, Vec<(LeafId, BufferId, Rect, usize, usize, usize)>, ) {
880 let _span = tracing::trace_span!("render_content").entered();
881
882 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 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 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 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 if tab_bar_visible {
920 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, theme,
930 is_active,
931 tab_scroll_offset,
932 tab_hover_for_split,
933 );
934
935 tab_layouts.insert(split_id, tab_layout);
937 let tab_row = layout.tabs_rect.y;
938
939 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 let mut btn_x = layout.tabs_rect.x + layout.tabs_rect.width.saturating_sub(2);
949
950 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); }
965
966 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 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 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 if state.is_composite_buffer {
992 if let Some(composite) = composite_buffers.get(&buffer_id) {
993 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 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 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 let total_rows = composite.row_count();
1031 let content_height = layout.content_rect.height.saturating_sub(1) as usize; 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 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, 0,
1061 0,
1062 ));
1063 }
1064 }
1065 view_line_mappings.insert(split_id, Vec::new());
1066 continue;
1067 }
1068
1069 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 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 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 view_line_mappings.insert(split_id, split_view_mappings);
1161
1162 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 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 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 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 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 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 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 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 #[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 if state.is_composite_buffer {
1316 view_line_mappings.insert(split_id, Vec::new());
1317 continue;
1318 }
1319
1320 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 let split_cursors = split_view_states
1334 .get(&split_id)
1335 .map(|vs| vs.cursors.clone())
1336 .unwrap_or_default();
1337 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 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 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 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 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 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 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 frame.render_widget(Clear, area);
1455
1456 let pane_count = composite.sources.len();
1458 if pane_count == 0 {
1459 return;
1460 }
1461
1462 let show_separator = match &composite.layout {
1464 CompositeLayout::SideBySide { show_separator, .. } => *show_separator,
1465 _ => false,
1466 };
1467
1468 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 let pane_width = available_width / pane_count as u16;
1486 vec![pane_width; pane_count]
1487 }
1488 };
1489
1490 view_state.pane_widths = pane_widths.clone();
1492
1493 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 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 let alignment = &composite.alignment;
1524 let total_rows = alignment.rows.len();
1525
1526 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 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 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 let highlight_spans = source_state.highlighter.highlight_viewport(
1564 &source_state.buffer,
1565 top_byte,
1566 end_byte,
1567 theme,
1568 1024, );
1570
1571 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; let content_width = pane_width.saturating_sub(gutter_width);
1581
1582 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, 80, lines_needed, false, content_width,
1594 gutter_width,
1595 &ViewMode::Source, &empty_folds,
1597 theme,
1598 );
1599
1600 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 for view_row in 0..visible_rows {
1625 let display_row = scroll_row + view_row;
1626 if display_row >= total_rows {
1627 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 let selection_cols = view_state.selection_column_range(display_row);
1643
1644 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 let inline_diffs: Vec<Vec<Range<usize>>> = if aligned_row.row_type
1655 == RowType::Modification
1656 {
1657 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 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 vec![Vec::new(); composite.sources.len()]
1691 };
1692
1693 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 let left_column = view_state
1702 .get_pane_viewport(pane_idx)
1703 .map(|v| v.left_column)
1704 .unwrap_or(0);
1705
1706 let source_line_opt = aligned_row.get_pane_line(pane_idx);
1708
1709 if let Some(source_line_ref) = source_line_opt {
1710 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 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 let pane_selection_cols = if is_focused_pane {
1736 selection_cols
1737 } else {
1738 None
1739 };
1740
1741 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 let inline_ranges = inline_diffs.get(pane_idx).cloned().unwrap_or_default();
1750
1751 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 let mut spans = vec![Span::styled(line_num, line_num_style)];
1767
1768 if let Some(view_line) = view_line_opt {
1769 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 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 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 let is_focused_pane = pane_idx == view_state.focused_pane;
1808 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 let is_cursor_pane = pane_idx == view_state.focused_pane;
1825 if is_cursor_row && is_cursor_pane && view_state.cursor_column == 0 {
1826 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 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 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 #[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)>, ) {
1878 let text = &view_line.text;
1879 let char_source_bytes = &view_line.char_source_bytes;
1880
1881 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 if col < left_column {
1894 col += char_width;
1895 continue;
1896 }
1897
1898 if rendered >= max_width {
1900 break;
1901 }
1902
1903 let byte_pos = char_source_bytes.get(char_idx).and_then(|b| *b);
1905
1906 let highlight_color =
1908 byte_pos.and_then(|bp| span_color_at(highlight_spans, &mut hl_cursor, bp));
1909
1910 let in_inline_range = inline_ranges.iter().any(|r| r.contains(&char_idx));
1912
1913 let in_selection = selection_cols
1915 .map(|(start, end)| col >= start && col < end)
1916 .unwrap_or(false);
1917
1918 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 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 let final_style = if show_cursor && col == cursor_column {
1936 Style::default().fg(theme.editor_bg).bg(theme.editor_fg)
1938 } else {
1939 char_style
1940 };
1941
1942 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 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 if rendered < max_width {
1964 let padding_len = max_width - rendered;
1965 let cursor_visual = cursor_column.saturating_sub(left_column);
1967
1968 if show_cursor && cursor_visual >= rendered && cursor_visual < max_width {
1970 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 if cursor_offset > 0 {
1977 spans.push(Span::styled(" ".repeat(cursor_offset), normal_style));
1978 }
1979 spans.push(Span::styled(" ", cursor_style));
1981 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 spans.push(Span::styled(
1989 " ".repeat(padding_len),
1990 Style::default().bg(bg),
1991 ));
1992 }
1993 }
1994 }
1995
1996 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 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 let max_scroll = total_rows.saturating_sub(viewport_height);
2019
2020 let thumb_size = if max_scroll == 0 {
2022 height
2023 } else {
2024 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 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 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 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 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 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 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 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 let mut total_visual_rows = 0;
2238 let mut top_visual_row = 0;
2239 let mut found_top = false;
2240
2241 let line_count = state.buffer.line_count().unwrap_or_else(|| {
2243 (buffer_len / state.buffer.estimated_line_length()).max(1)
2245 });
2246
2247 for line_idx in 0..line_count {
2248 let line_start = state
2250 .buffer
2251 .line_start_offset(line_idx)
2252 .unwrap_or(buffer_len);
2253
2254 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 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 if !found_top {
2277 top_visual_row = total_visual_rows.saturating_sub(1);
2278 }
2279
2280 total_visual_rows = total_visual_rows.max(1);
2282
2283 (total_visual_rows, top_visual_row)
2284 }
2285
2286 #[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 let viewport_height_lines = height;
2313
2314 let (thumb_start, thumb_size) = if buffer_len > large_file_threshold_bytes as usize {
2316 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 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 let max_scroll_line = total_lines.saturating_sub(viewport_height_lines);
2339
2340 let thumb_size = if max_scroll_line == 0 {
2343 height
2344 } else {
2345 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 let thumb_start = if max_scroll_line > 0 {
2354 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 0
2361 };
2362
2363 (thumb_start, thumb_size)
2364 };
2365
2366 let thumb_end = thumb_start + thumb_size;
2367
2368 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 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 Style::default().bg(thumb_color)
2387 } else {
2388 Style::default().bg(track_color)
2390 };
2391
2392 let paragraph = Paragraph::new(" ").style(style);
2393 frame.render_widget(paragraph, cell_area);
2394 }
2395
2396 (thumb_start, thumb_end)
2398 }
2399
2400 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 let visible_lines = viewport.height as usize + 5; 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 viewport.max_line_length_seen.max(visible_width)
2438 }
2439
2440 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 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 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 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); 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 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 let is_binary = state.buffer.is_binary();
2542 let line_ending = state.buffer.line_ending();
2543
2544 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 let has_view_transform = view_transform.is_some();
2556 let mut tokens = view_transform.map(|vt| vt.tokens).unwrap_or(base_tokens);
2557
2558 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 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 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 let is_binary = state.buffer.is_binary();
2614 let ansi_aware = !is_binary; let at_buffer_end = if has_view_transform {
2616 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 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 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 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 fn view_line_source_byte(line: &ViewLine) -> Option<usize> {
2768 line.char_source_bytes.iter().find_map(|m| *m)
2769 }
2770
2771 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 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 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 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 char_source_bytes: vec![None; len],
2858 char_styles: vec![Some(token_style); len],
2860 char_visual_cols: (0..len).collect(),
2862 visual_to_char: (0..len).collect(),
2864 tab_starts: HashSet::new(),
2865 line_start: LineStart::AfterInjectedNewline,
2867 ends_with_newline: true,
2868 }
2869 }
2870
2871 fn inject_virtual_lines(source_lines: Vec<ViewLine>, state: &EditorState) -> Vec<ViewLine> {
2873 use crate::view::virtual_text::VirtualTextPosition;
2874
2875 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 let virtual_lines = state.virtual_texts.query_lines_in_range(
2891 &state.marker_list,
2892 viewport_start,
2893 viewport_end,
2894 );
2895
2896 if virtual_lines.is_empty() {
2898 return source_lines;
2899 }
2900
2901 let mut result = Vec::with_capacity(source_lines.len() + virtual_lines.len());
2903
2904 for source_line in source_lines {
2905 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 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 result.push(source_line.clone());
2928
2929 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 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 output.push(token);
2971 continue;
2972 }
2973 };
2974
2975 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 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 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 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 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 output.push(token);
3063 continue;
3064 }
3065 };
3066
3067 match &token.kind {
3068 ViewTokenWireKind::Text(text) => {
3069 let mut current_byte = offset;
3073 let mut visible_start: Option<usize> = None; 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 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 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 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 output.push(ViewTokenWire {
3115 source_offset: None,
3116 kind: ViewTokenWireKind::Text(rest),
3117 style: None,
3118 });
3119 }
3120 }
3121 }
3122 }
3123 }
3124 } else {
3125 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 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 if is_concealed(offset).is_some() {
3149 } else {
3151 output.push(token);
3152 }
3153 }
3154 ViewTokenWireKind::BinaryByte(_) => {
3155 if is_concealed(offset).is_some() {
3156 } 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 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; let mut chars_this_line = 0usize; for ch in line_content.chars() {
3202 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 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 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 tokens.push(ViewTokenWire {
3232 source_offset,
3233 kind: ViewTokenWireKind::Newline,
3234 style: None,
3235 });
3236 skip_next_lf = true;
3238 byte_offset += ch_len;
3239 continue;
3240 }
3241 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_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 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 tokens.push(ViewTokenWire {
3279 source_offset,
3280 kind: ViewTokenWireKind::BinaryByte(ch as u8),
3281 style: None,
3282 });
3283 }
3284 _ => {
3285 if let Some(last) = tokens.last_mut() {
3287 if let ViewTokenWireKind::Text(ref mut s) = last.kind {
3288 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 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 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 let estimated_bytes = estimated_line_length * max_lines * 2;
3349 let bytes_to_read = estimated_bytes.min(buffer_len - top_byte);
3350
3351 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 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 if Self::is_binary_unprintable(b) {
3398 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
3400 tokens.push(ViewTokenWire {
3402 source_offset: Some(source_offset),
3403 kind: ViewTokenWireKind::BinaryByte(b),
3404 style: None,
3405 });
3406 } else {
3407 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_text(&mut tokens, &mut current_text, &mut current_text_start);
3421
3422 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 fn is_binary_unprintable(b: u8) -> bool {
3446 if b == 0x09 || b == 0x0A {
3450 return false;
3451 }
3452 if b < 0x20 {
3455 return true;
3456 }
3457 if b == 0x7F {
3459 return true;
3460 }
3461 if b >= 0x80 {
3464 return true;
3465 }
3466 false
3467 }
3468
3469 fn is_control_char(ch: char) -> bool {
3472 let code = ch as u32;
3473 if code >= 128 {
3475 return false;
3476 }
3477 let b = code as u8;
3478 if b == 0x09 || b == 0x0A || b == 0x1B {
3480 return false;
3481 }
3482 b < 0x20 || b == 0x7F
3485 }
3486
3487 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 const MIN_CONTINUATION_CONTENT_WIDTH: usize = 10;
3518
3519 let mut wrapped = Vec::new();
3520 let mut current_line_width: usize = 0;
3521
3522 let available_width = content_width.saturating_sub(gutter_width);
3524
3525 let mut line_indent: usize = 0;
3528 let mut measuring_indent = hanging_indent;
3530 let mut on_continuation = false;
3532
3533 #[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 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 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 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 line_indent += str_width(text);
3598 } else {
3599 line_indent += leading_ws;
3601 measuring_indent = false;
3602 }
3603 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 let text_visual_width = visual_width(text, current_line_width);
3613
3614 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 let text_visual_width = visual_width(text, current_line_width);
3625
3626 if text_visual_width > eff_width
3630 && !crate::primitives::ansi::contains_ansi_codes(text)
3631 {
3632 use unicode_segmentation::UnicodeSegmentation;
3633
3634 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 let remaining_width = eff_width.saturating_sub(current_line_width);
3645 if remaining_width == 0 {
3646 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 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 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 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 if measuring_indent {
3731 line_indent += 1;
3732 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 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 wrapped.push(token);
3750 current_line_width = 0;
3751 on_continuation = true;
3752 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 if measuring_indent {
3765 measuring_indent = false;
3766 }
3767
3768 let eff_width = effective_width(available_width, line_indent, on_continuation);
3769 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 for (idx, line) in view_lines.iter().enumerate() {
3788 if let Some(first_source) = line.char_source_bytes.iter().find_map(|m| *m) {
3790 if first_source >= top_byte {
3791 let mut start_idx = idx;
3794 while start_idx > 0 {
3795 let prev_line = &view_lines[start_idx - 1];
3796 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 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 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 if layout.left_pad == 0 && layout.right_pad == 0 {
3869 return;
3870 }
3871
3872 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 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 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 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 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 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 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 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 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 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 state.bracket_highlight_overlay.update(
4017 &state.buffer,
4018 &mut state.overlays,
4019 &mut state.marker_list,
4020 primary_cursor_position,
4021 );
4022
4023 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 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 viewport_overlays.sort_by_key(|(overlay, _)| overlay.priority);
4060
4061 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 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 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 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 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 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 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 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 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), 5, );
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 let lo = range.start.max(viewport_start);
4227 let hi = range.end.min(viewport_end);
4228 if lo >= hi {
4229 continue;
4230 }
4231
4232 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 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 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 bytes.len()
4272 }
4273
4274 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 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 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 let mut last_visible_x: u16 = 0;
4356 let _view_start_line_skip = view_anchor.start_line_skip; loop {
4359 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 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 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 = ¤t_view_line.char_source_bytes;
4383 let line_char_styles = ¤t_view_line.char_styles;
4384 let line_visual_to_char = ¤t_view_line.visual_to_char;
4385 let line_tab_starts = ¤t_view_line.tab_starts;
4386 let _line_start_type = current_view_line.line_start;
4387
4388 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 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 let show_line_number = should_show_line_number(current_view_line);
4414
4415 let is_continuation = !show_line_number;
4417
4418 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 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 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 last_gutter_num.map_or(0, |n| n + 1)
4453 } else {
4454 0
4455 };
4456
4457 lines_rendered += 1;
4458
4459 let left_col = left_column;
4461
4462 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 let mut span_acc = SpanAccumulator::new();
4471
4472 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 let mut byte_index = 0; let mut display_char_idx = 0usize; let mut col_offset = 0usize; let visible_lines_remaining = visible_line_count.saturating_sub(lines_rendered);
4503 let max_visible_chars = if line_wrap {
4504 (render_area.width as usize)
4507 .saturating_mul(visible_lines_remaining.max(1))
4508 .saturating_add(200)
4509 } else {
4510 (render_area.width as usize).saturating_add(100)
4512 };
4513 let max_chars_to_process = left_col.saturating_add(max_visible_chars);
4514
4515 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 let mut visible_char_count = 0usize;
4525
4526 let mut debug_tracker = if state.debug_highlight_mode {
4528 Some(DebugSpanTracker::default())
4529 } else {
4530 None
4531 };
4532
4533 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 let byte_pos = line_char_source_bytes
4542 .get(display_char_idx)
4543 .copied()
4544 .flatten();
4545
4546 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 let ansi_style = if let Some(ref mut parser) = ansi_parser {
4557 match parser.parse_char(ch) {
4558 Some(style) => style,
4559 None => {
4560 if let Some(bp) = byte_pos {
4564 if bp == primary_cursor_position && !have_cursor {
4565 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 continue;
4576 }
4577 }
4578 } else {
4579 Style::default()
4581 };
4582
4583 if visible_char_count > max_chars_to_process {
4586 break;
4589 }
4590
4591 if col_offset >= left_col {
4593 let is_tab_start = line_tab_starts.contains(&col_offset);
4595
4596 let is_cursor = byte_pos
4600 .map(|bp| {
4601 if !cursor_positions.contains(&bp) || bp >= state.buffer.len() {
4602 return false;
4603 }
4604 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 display_char_idx == 0 || prev_byte_pos != Some(bp)
4611 })
4612 .unwrap_or(false);
4613
4614 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 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 let token_style = line_char_styles
4642 .get(display_char_idx)
4643 .and_then(|s| s.as_ref());
4644
4645 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 let indicator_buf: String;
4676 let mut is_whitespace_indicator = false;
4677
4678 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 "\\r"
4717 } else if debug_tracker.is_some() && ch == '\n' {
4718 "\\n"
4720 } else if ch == '\n' {
4721 ""
4722 } else if ws_show_tab {
4723 is_whitespace_indicator = true;
4725 indicator_buf = "→".to_string();
4726 &indicator_buf
4727 } else if ws_show_space {
4728 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 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 span_acc.flush(&mut line_spans, &mut line_view_map);
4750 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 if let Some(ref mut tracker) = debug_tracker {
4767 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 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 for c in display_char.chars() {
4793 span_acc.push(c, style, byte_pos, &mut line_spans, &mut line_view_map);
4794 }
4795
4796 if let Some(ref mut tracker) = debug_tracker {
4799 span_acc.flush(&mut line_spans, &mut line_view_map);
4801 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 if !have_cursor {
4813 if let Some(bp) = byte_pos {
4814 if bp == primary_cursor_position && char_width(ch) == 0 {
4815 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 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; let ch_width = char_width(ch);
4875 col_offset += ch_width;
4876 visible_char_count += ch_width;
4877 }
4878
4879 span_acc.flush(&mut line_spans, &mut line_view_map);
4881
4882 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 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 let matches_after = after_last_char_buf_pos.is_some_and(|bp| pos == bp);
4908 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 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 if let Some(seg_y) = last_seg_y {
4930 if is_primary_at_end {
4931 cursor_screen_x = if line_len_chars == 0 {
4936 gutter_width as u16
4937 } else {
4938 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 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 let current_y = lines.len() as u16;
4980 last_seg_y = Some(current_y);
4981
4982 if !line_spans.is_empty() {
4983 let mut nearest_fallback: Option<(u16, usize)> = None; for (screen_x, source_offset) in line_view_map.iter().enumerate() {
4994 if let Some(src) = source_offset {
4995 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 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 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 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 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 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 if !line_wrap {
5072 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 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 Some(Style::default().fg(*color).bg(*color))
5095 }
5096 crate::view::overlay::OverlayFace::Style { style } => {
5097 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 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 let prev_line_end_byte = view_line_mappings
5135 .last()
5136 .map(|prev: &ViewLineMapping| prev.line_end_byte)
5137 .unwrap_or(0);
5138
5139 let line_end_byte = if current_view_line.ends_with_newline {
5141 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 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 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 state.buffer.len()
5169 } else {
5170 prev_line_end_byte
5174 }
5175 };
5176
5177 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 let line_was_empty = line_spans.is_empty();
5192 lines.push(Line::from(line_spans));
5193
5194 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 if let Some(y) = last_seg_y {
5209 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 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 if line_len_chars == 1 {
5236 cursor_screen_x = gutter_width as u16;
5238 cursor_screen_y = y;
5239 } else {
5240 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 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 let mut implicit_line_spans = Vec::new();
5266 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 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 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 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 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 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 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 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 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 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 #[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 state.margins.configure_for_line_numbers(show_line_numbers);
5460
5461 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 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 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 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 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 let primary = *cursors.primary();
5548 let scrolled = viewport.ensure_visible_in_layout(&view_data.lines, &primary, gutter_width);
5549
5550 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 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 #[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; 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 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 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 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 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 #[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 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 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 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 #[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 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 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 #[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 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 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 for cx in (chunk_start + 1)..chunk_start + actual_chunk_len {
6090 buf[(cx, y)].set_symbol("");
6091 }
6092 }
6093 }
6094 }
6095
6096 #[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 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 let viewport = Viewport::new(20, 4);
6218 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, render_area.width as usize,
6235 gutter_width,
6236 &ViewMode::Source, &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, &ViewMode::Source, false, );
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, byte_offset_mode: false, });
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 assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
6372 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, );
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, );
6431 assert_eq!(cursor, Some((3, 0)));
6432 }
6433
6434 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
6440 let mut cursor_positions = Vec::new();
6441
6442 let primary_cursor = output.cursor;
6444 if let Some(cursor_pos) = primary_cursor {
6445 cursor_positions.push(cursor_pos);
6446 }
6447
6448 for (line_idx, line) in output.lines.iter().enumerate() {
6450 let mut col = 0u16;
6451 for span in line.spans.iter() {
6452 if span
6454 .style
6455 .add_modifier
6456 .contains(ratatui::style::Modifier::REVERSED)
6457 {
6458 let pos = (col, line_idx as u16);
6459 if primary_cursor != Some(pos) {
6462 cursor_positions.push(pos);
6463 }
6464 }
6465 col += str_width(&span.content) as u16;
6467 }
6468 }
6469
6470 cursor_positions
6471 }
6472
6473 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 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 let all_cursors = count_all_cursors(&output);
6507
6508 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, );
6526
6527 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 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 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 fn check_typing_at_cursor(
6556 content: &str,
6557 cursor_pos: usize,
6558 char_to_type: char,
6559 ) -> (Option<(u16, u16)>, String) {
6560 let cursor_before = get_final_cursor(content, cursor_pos);
6562
6563 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 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 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 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 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 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 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 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 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 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 let (output, buffer_len, buffer_newline, cursor_pos) =
6720 render_output_for_with_gutters("", 0, true);
6721
6722 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 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 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 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 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 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 let content = "abc\ndef\nghi";
6810
6811 let cursor_at_start = get_final_cursor(content, 0);
6813 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
6814
6815 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 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 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 let cursor_moved_away = get_final_cursor(&new_content, 0);
6838 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
6839 }
6842
6843 #[test]
6844 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
6845 let content = "abc\ndef\nghi\n";
6847
6848 let cursor_at_start = get_final_cursor(content, 0);
6850 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
6851
6852 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 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 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 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 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 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 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 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 let content = "\n";
6923
6924 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 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 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 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 use crate::model::buffer::LineEnding;
6971 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
6972
6973 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]
6993 fn test_build_base_tokens_crlf_single_line() {
6994 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, 80, 10, false, LineEnding::CRLF,
7006 );
7007
7008 let offsets = extract_token_offsets(&tokens);
7009
7010 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 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]
7039 fn test_build_base_tokens_crlf_multiple_lines() {
7040 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 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 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 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 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]
7120 fn test_build_base_tokens_lf_mode_for_comparison() {
7121 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 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]
7169 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
7170 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); 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 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]
7197 fn test_build_base_tokens_crlf_from_middle() {
7198 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, 80,
7208 10,
7209 false,
7210 LineEnding::CRLF,
7211 );
7212
7213 let offsets = extract_token_offsets(&tokens);
7214
7215 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 #[test]
7237 fn test_crlf_highlight_span_lookup() {
7238 use crate::view::ui::view_pipeline::ViewLineIterator;
7239
7240 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 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 let offsets = extract_token_offsets(&tokens);
7260 eprintln!("Tokens: {:?}", offsets);
7261
7262 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 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 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 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 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 let simulated_highlight_spans = [
7335 (0usize..3usize, "keyword"),
7337 (8usize..11usize, "keyword"),
7339 ];
7340
7341 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 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]
7368 fn test_apply_wrapping_transform_breaks_long_lines() {
7369 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
7370
7371 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 let wrapped =
7388 SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
7389
7390 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 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]
7419 fn test_apply_wrapping_transform_preserves_short_lines() {
7420 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
7421
7422 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 let wrapped =
7439 SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
7440
7441 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 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 #[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 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
7480
7481 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 let wrapped =
7497 SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
7498
7499 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
7501
7502 let mut reconstructed = String::new();
7504 for line in &view_lines {
7505 let text = line.text.trim_end_matches('\n');
7507 reconstructed.push_str(text);
7508 }
7509
7510 assert_eq!(
7512 reconstructed.len(),
7513 content.len(),
7514 "Reconstructed content length should match original"
7515 );
7516
7517 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 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 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 for (i, line) in view_lines.iter().enumerate() {
7548 assert!(
7549 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
7551 i,
7552 line.text.len()
7553 );
7554 }
7555 }
7556
7557 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 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 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 let url = "https://example.com";
7622
7623 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 let cell14 = strip_osc8(buf[(14, 0)].symbol());
7634 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
7635
7636 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 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 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 let area = Rect::new(0, 0, 40, 1);
7696
7697 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 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 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 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 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 let diff2 = frame1.diff(&frame2);
7741 for (x, y, cell) in &diff2 {
7742 backend[(*x, *y)] = (*cell).clone();
7743 }
7744
7745 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 let cell14 = strip_osc8(backend[(14, 0)].symbol());
7755 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
7756 }
7757}