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, SplitDirection};
10use crate::primitives::ansi::AnsiParser;
11use crate::primitives::ansi_background::AnsiBackground;
12use crate::primitives::display_width::char_width;
13use crate::state::{EditorState, ViewMode};
14use crate::view::split::SplitManager;
15use crate::view::ui::tabs::TabsRenderer;
16use crate::view::ui::view_pipeline::{
17 should_show_line_number, LineStart, ViewLine, ViewLineIterator,
18};
19use crate::view::virtual_text::VirtualTextPosition;
20use fresh_core::api::ViewTransformPayload;
21use ratatui::layout::Rect;
22use ratatui::style::{Color, Modifier, Style};
23use ratatui::text::{Line, Span};
24use ratatui::widgets::{Block, Borders, Clear, Paragraph};
25use ratatui::Frame;
26use std::collections::{HashMap, HashSet};
27use std::ops::Range;
28
29const MAX_SAFE_LINE_WIDTH: usize = 10_000;
35
36fn compute_inline_diff(old_text: &str, new_text: &str) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
40 let old_chars: Vec<char> = old_text.chars().collect();
41 let new_chars: Vec<char> = new_text.chars().collect();
42
43 let mut old_ranges = Vec::new();
44 let mut new_ranges = Vec::new();
45
46 let prefix_len = old_chars
48 .iter()
49 .zip(new_chars.iter())
50 .take_while(|(a, b)| a == b)
51 .count();
52
53 let old_remaining = old_chars.len() - prefix_len;
55 let new_remaining = new_chars.len() - prefix_len;
56 let suffix_len = old_chars
57 .iter()
58 .rev()
59 .zip(new_chars.iter().rev())
60 .take(old_remaining.min(new_remaining))
61 .take_while(|(a, b)| a == b)
62 .count();
63
64 let old_start = prefix_len;
66 let old_end = old_chars.len().saturating_sub(suffix_len);
67 let new_start = prefix_len;
68 let new_end = new_chars.len().saturating_sub(suffix_len);
69
70 if old_start < old_end {
71 old_ranges.push(old_start..old_end);
72 }
73 if new_start < new_end {
74 new_ranges.push(new_start..new_end);
75 }
76
77 (old_ranges, new_ranges)
78}
79
80fn push_span_with_map(
81 spans: &mut Vec<Span<'static>>,
82 map: &mut Vec<Option<usize>>,
83 text: String,
84 style: Style,
85 source: Option<usize>,
86) {
87 if text.is_empty() {
88 return;
89 }
90 for ch in text.chars() {
94 let width = char_width(ch);
95 for _ in 0..width {
96 map.push(source);
97 }
98 }
99 spans.push(Span::styled(text, style));
100}
101
102fn debug_tag_style() -> Style {
104 Style::default()
105 .fg(Color::DarkGray)
106 .add_modifier(Modifier::DIM)
107}
108
109fn dim_color_for_tilde(color: Color) -> Color {
112 match color {
113 Color::Rgb(r, g, b) => {
114 Color::Rgb(r / 2, g / 2, b / 2)
116 }
117 Color::Indexed(idx) => {
118 if idx < 16 {
123 Color::Rgb(50, 50, 50) } else {
125 Color::Rgb(40, 40, 40) }
127 }
128 Color::Black => Color::Rgb(15, 15, 15),
130 Color::White => Color::Rgb(128, 128, 128),
131 Color::Red => Color::Rgb(100, 30, 30),
132 Color::Green => Color::Rgb(30, 100, 30),
133 Color::Yellow => Color::Rgb(100, 100, 30),
134 Color::Blue => Color::Rgb(30, 30, 100),
135 Color::Magenta => Color::Rgb(100, 30, 100),
136 Color::Cyan => Color::Rgb(30, 100, 100),
137 Color::Gray => Color::Rgb(64, 64, 64),
138 Color::DarkGray => Color::Rgb(40, 40, 40),
139 Color::LightRed => Color::Rgb(128, 50, 50),
140 Color::LightGreen => Color::Rgb(50, 128, 50),
141 Color::LightYellow => Color::Rgb(128, 128, 50),
142 Color::LightBlue => Color::Rgb(50, 50, 128),
143 Color::LightMagenta => Color::Rgb(128, 50, 128),
144 Color::LightCyan => Color::Rgb(50, 128, 128),
145 Color::Reset => Color::Rgb(50, 50, 50),
146 }
147}
148
149struct SpanAccumulator {
154 text: String,
155 style: Style,
156 first_source: Option<usize>,
157}
158
159impl SpanAccumulator {
160 fn new() -> Self {
161 Self {
162 text: String::new(),
163 style: Style::default(),
164 first_source: None,
165 }
166 }
167
168 fn push(
171 &mut self,
172 ch: char,
173 style: Style,
174 source: Option<usize>,
175 spans: &mut Vec<Span<'static>>,
176 map: &mut Vec<Option<usize>>,
177 ) {
178 if !self.text.is_empty() && style != self.style {
180 self.flush(spans, map);
181 }
182
183 if self.text.is_empty() {
185 self.style = style;
186 self.first_source = source;
187 }
188
189 self.text.push(ch);
190
191 let width = char_width(ch);
193 for _ in 0..width {
194 map.push(source);
195 }
196 }
197
198 fn flush(&mut self, spans: &mut Vec<Span<'static>>, _map: &mut Vec<Option<usize>>) {
200 if !self.text.is_empty() {
201 spans.push(Span::styled(std::mem::take(&mut self.text), self.style));
202 self.first_source = None;
203 }
204 }
205}
206
207fn push_debug_tag(spans: &mut Vec<Span<'static>>, map: &mut Vec<Option<usize>>, text: String) {
209 if text.is_empty() {
210 return;
211 }
212 for ch in text.chars() {
214 let width = char_width(ch);
215 for _ in 0..width {
216 map.push(None);
217 }
218 }
219 spans.push(Span::styled(text, debug_tag_style()));
220}
221
222#[derive(Default)]
224struct DebugSpanTracker {
225 active_highlight: Option<Range<usize>>,
227 active_overlays: Vec<Range<usize>>,
229}
230
231impl DebugSpanTracker {
232 fn get_opening_tags(
234 &mut self,
235 byte_pos: Option<usize>,
236 highlight_spans: &[crate::primitives::highlighter::HighlightSpan],
237 viewport_overlays: &[(crate::view::overlay::Overlay, Range<usize>)],
238 ) -> Vec<String> {
239 let mut tags = Vec::new();
240
241 if let Some(bp) = byte_pos {
242 if let Some(span) = highlight_spans.iter().find(|s| s.range.start == bp) {
244 tags.push(format!("<hl:{}-{}>", span.range.start, span.range.end));
245 self.active_highlight = Some(span.range.clone());
246 }
247
248 for (overlay, range) in viewport_overlays.iter() {
250 if range.start == bp {
251 let overlay_type = match &overlay.face {
252 crate::view::overlay::OverlayFace::Underline { .. } => "ul",
253 crate::view::overlay::OverlayFace::Background { .. } => "bg",
254 crate::view::overlay::OverlayFace::Foreground { .. } => "fg",
255 crate::view::overlay::OverlayFace::Style { .. } => "st",
256 crate::view::overlay::OverlayFace::ThemedStyle { .. } => "ts",
257 };
258 tags.push(format!("<{}:{}-{}>", overlay_type, range.start, range.end));
259 self.active_overlays.push(range.clone());
260 }
261 }
262 }
263
264 tags
265 }
266
267 fn get_closing_tags(&mut self, byte_pos: Option<usize>) -> Vec<String> {
269 let mut tags = Vec::new();
270
271 if let Some(bp) = byte_pos {
272 if let Some(ref range) = self.active_highlight {
274 if bp >= range.end {
275 tags.push("</hl>".to_string());
276 self.active_highlight = None;
277 }
278 }
279
280 let mut closed_indices = Vec::new();
282 for (i, range) in self.active_overlays.iter().enumerate() {
283 if bp >= range.end {
284 tags.push("</ov>".to_string());
285 closed_indices.push(i);
286 }
287 }
288 for i in closed_indices.into_iter().rev() {
290 self.active_overlays.remove(i);
291 }
292 }
293
294 tags
295 }
296}
297
298struct ViewData {
300 lines: Vec<ViewLine>,
302}
303
304struct ViewAnchor {
305 start_line_idx: usize,
306 start_line_skip: usize,
307}
308
309struct ComposeLayout {
310 render_area: Rect,
311 left_pad: u16,
312 right_pad: u16,
313}
314
315struct SelectionContext {
316 ranges: Vec<Range<usize>>,
317 block_rects: Vec<(usize, usize, usize, usize)>,
318 cursor_positions: Vec<usize>,
319 primary_cursor_position: usize,
320}
321
322struct DecorationContext {
323 highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
324 semantic_token_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
325 viewport_overlays: Vec<(crate::view::overlay::Overlay, Range<usize>)>,
326 virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>>,
327 diagnostic_lines: HashSet<usize>,
328 line_indicators: BTreeMap<usize, crate::view::margin::LineIndicator>,
330}
331
332struct LineRenderOutput {
333 lines: Vec<Line<'static>>,
334 cursor: Option<(u16, u16)>,
335 last_line_end: Option<LastLineEnd>,
336 content_lines_rendered: usize,
337 view_line_mappings: Vec<ViewLineMapping>,
338}
339
340#[derive(Clone, Copy, Debug, PartialEq, Eq)]
341struct LastLineEnd {
342 pos: (u16, u16),
343 terminated_with_newline: bool,
344}
345
346struct SplitLayout {
347 tabs_rect: Rect,
348 content_rect: Rect,
349 scrollbar_rect: Rect,
350}
351
352struct ViewPreferences {
353 view_mode: ViewMode,
354 compose_width: Option<u16>,
355 compose_column_guides: Option<Vec<u16>>,
356 view_transform: Option<ViewTransformPayload>,
357}
358
359struct LineRenderInput<'a> {
360 state: &'a EditorState,
361 theme: &'a crate::view::theme::Theme,
362 view_lines: &'a [ViewLine],
364 view_anchor: ViewAnchor,
365 render_area: Rect,
366 gutter_width: usize,
367 selection: &'a SelectionContext,
368 decorations: &'a DecorationContext,
369 starting_line_num: usize,
370 visible_line_count: usize,
371 lsp_waiting: bool,
372 is_active: bool,
373 line_wrap: bool,
374 estimated_lines: usize,
375 left_column: usize,
377 relative_line_numbers: bool,
379}
380
381struct CharStyleContext<'a> {
383 byte_pos: Option<usize>,
384 token_style: Option<&'a fresh_core::api::ViewTokenStyle>,
385 ansi_style: Style,
386 is_cursor: bool,
387 is_selected: bool,
388 theme: &'a crate::view::theme::Theme,
389 highlight_spans: &'a [crate::primitives::highlighter::HighlightSpan],
390 semantic_token_spans: &'a [crate::primitives::highlighter::HighlightSpan],
391 viewport_overlays: &'a [(crate::view::overlay::Overlay, Range<usize>)],
392 primary_cursor_position: usize,
393 is_active: bool,
394}
395
396struct CharStyleOutput {
398 style: Style,
399 is_secondary_cursor: bool,
400}
401
402struct LeftMarginContext<'a> {
404 state: &'a EditorState,
405 theme: &'a crate::view::theme::Theme,
406 is_continuation: bool,
407 current_source_line_num: usize,
408 estimated_lines: usize,
409 diagnostic_lines: &'a HashSet<usize>,
410 line_indicators: &'a BTreeMap<usize, crate::view::margin::LineIndicator>,
412 cursor_line: usize,
414 relative_line_numbers: bool,
416}
417
418fn render_left_margin(
420 ctx: &LeftMarginContext,
421 line_spans: &mut Vec<Span<'static>>,
422 line_view_map: &mut Vec<Option<usize>>,
423) {
424 if !ctx.state.margins.left_config.enabled {
425 return;
426 }
427
428 if ctx.is_continuation {
430 push_span_with_map(
431 line_spans,
432 line_view_map,
433 " ".to_string(),
434 Style::default(),
435 None,
436 );
437 } else if ctx.diagnostic_lines.contains(&ctx.current_source_line_num) {
438 push_span_with_map(
440 line_spans,
441 line_view_map,
442 "●".to_string(),
443 Style::default().fg(ratatui::style::Color::Red),
444 None,
445 );
446 } else if let Some(indicator) = ctx.line_indicators.get(&ctx.current_source_line_num) {
447 push_span_with_map(
449 line_spans,
450 line_view_map,
451 indicator.symbol.clone(),
452 Style::default().fg(indicator.color),
453 None,
454 );
455 } else {
456 push_span_with_map(
458 line_spans,
459 line_view_map,
460 " ".to_string(),
461 Style::default(),
462 None,
463 );
464 }
465
466 if ctx.is_continuation {
468 let blank = " ".repeat(ctx.state.margins.left_config.width);
470 push_span_with_map(
471 line_spans,
472 line_view_map,
473 blank,
474 Style::default().fg(ctx.theme.line_number_fg),
475 None,
476 );
477 } else if ctx.relative_line_numbers {
478 let display_num = if ctx.current_source_line_num == ctx.cursor_line {
480 ctx.current_source_line_num + 1
482 } else {
483 ctx.current_source_line_num.abs_diff(ctx.cursor_line)
485 };
486 let rendered_text = format!(
487 "{:>width$}",
488 display_num,
489 width = ctx.state.margins.left_config.width
490 );
491 let margin_style = if ctx.current_source_line_num == ctx.cursor_line {
493 Style::default().fg(ctx.theme.editor_fg)
494 } else {
495 Style::default().fg(ctx.theme.line_number_fg)
496 };
497 push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
498 } else {
499 let margin_content = ctx.state.margins.render_line(
500 ctx.current_source_line_num,
501 crate::view::margin::MarginPosition::Left,
502 ctx.estimated_lines,
503 );
504 let (rendered_text, style_opt) = margin_content.render(ctx.state.margins.left_config.width);
505
506 let margin_style =
508 style_opt.unwrap_or_else(|| Style::default().fg(ctx.theme.line_number_fg));
509
510 push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
511 }
512
513 if ctx.state.margins.left_config.show_separator {
515 let separator_style = Style::default().fg(ctx.theme.line_number_fg);
516 push_span_with_map(
517 line_spans,
518 line_view_map,
519 ctx.state.margins.left_config.separator.clone(),
520 separator_style,
521 None,
522 );
523 }
524}
525
526fn compute_char_style(ctx: &CharStyleContext) -> CharStyleOutput {
528 use crate::view::overlay::OverlayFace;
529
530 let highlight_color = ctx.byte_pos.and_then(|bp| {
532 ctx.highlight_spans
533 .iter()
534 .find(|span| span.range.contains(&bp))
535 .map(|span| span.color)
536 });
537
538 let overlays: Vec<&crate::view::overlay::Overlay> = if let Some(bp) = ctx.byte_pos {
540 ctx.viewport_overlays
541 .iter()
542 .filter(|(_, range)| range.contains(&bp))
543 .map(|(overlay, _)| overlay)
544 .collect()
545 } else {
546 Vec::new()
547 };
548
549 let mut style = if let Some(ts) = ctx.token_style {
552 let mut s = Style::default();
553 if let Some((r, g, b)) = ts.fg {
554 s = s.fg(ratatui::style::Color::Rgb(r, g, b));
555 } else {
556 s = s.fg(ctx.theme.editor_fg);
557 }
558 if let Some((r, g, b)) = ts.bg {
559 s = s.bg(ratatui::style::Color::Rgb(r, g, b));
560 }
561 if ts.bold {
562 s = s.add_modifier(Modifier::BOLD);
563 }
564 if ts.italic {
565 s = s.add_modifier(Modifier::ITALIC);
566 }
567 s
568 } else if ctx.ansi_style.fg.is_some()
569 || ctx.ansi_style.bg.is_some()
570 || !ctx.ansi_style.add_modifier.is_empty()
571 {
572 let mut s = Style::default();
574 if let Some(fg) = ctx.ansi_style.fg {
575 s = s.fg(fg);
576 } else {
577 s = s.fg(ctx.theme.editor_fg);
578 }
579 if let Some(bg) = ctx.ansi_style.bg {
580 s = s.bg(bg);
581 }
582 s = s.add_modifier(ctx.ansi_style.add_modifier);
583 s
584 } else if let Some(color) = highlight_color {
585 Style::default().fg(color)
587 } else {
588 Style::default().fg(ctx.theme.editor_fg)
590 };
591
592 if let Some(color) = highlight_color {
595 if ctx.ansi_style.fg.is_none()
596 && (ctx.ansi_style.bg.is_some() || !ctx.ansi_style.add_modifier.is_empty())
597 {
598 style = style.fg(color);
599 }
600 }
601
602 if ctx.token_style.is_none() {
607 if let Some(bp) = ctx.byte_pos {
608 if let Some(token_span) = ctx
609 .semantic_token_spans
610 .iter()
611 .find(|span| span.range.contains(&bp))
612 {
613 style = style.fg(token_span.color);
614 }
615 }
616 }
617
618 for overlay in &overlays {
620 match &overlay.face {
621 OverlayFace::Underline {
622 color,
623 style: _underline_style,
624 } => {
625 style = style.add_modifier(Modifier::UNDERLINED).fg(*color);
626 }
627 OverlayFace::Background { color } => {
628 style = style.bg(*color);
629 }
630 OverlayFace::Foreground { color } => {
631 style = style.fg(*color);
632 }
633 OverlayFace::Style {
634 style: overlay_style,
635 } => {
636 style = style.patch(*overlay_style);
637 }
638 OverlayFace::ThemedStyle {
639 fallback_style,
640 fg_theme,
641 bg_theme,
642 } => {
643 let mut themed_style = *fallback_style;
644 if let Some(fg_key) = fg_theme {
645 if let Some(color) = ctx.theme.resolve_theme_key(fg_key) {
646 themed_style = themed_style.fg(color);
647 }
648 }
649 if let Some(bg_key) = bg_theme {
650 if let Some(color) = ctx.theme.resolve_theme_key(bg_key) {
651 themed_style = themed_style.bg(color);
652 }
653 }
654 style = style.patch(themed_style);
655 }
656 }
657 }
658
659 if ctx.is_selected {
661 style = Style::default()
662 .fg(ctx.theme.editor_fg)
663 .bg(ctx.theme.selection_bg);
664 }
665
666 let is_secondary_cursor = ctx.is_cursor && ctx.byte_pos != Some(ctx.primary_cursor_position);
671 if ctx.is_active {
672 if ctx.is_cursor {
673 style = style.add_modifier(Modifier::REVERSED);
676 }
677 } else if ctx.is_cursor {
678 style = style.fg(ctx.theme.editor_fg).bg(ctx.theme.inactive_cursor);
679 }
680
681 CharStyleOutput {
682 style,
683 is_secondary_cursor,
684 }
685}
686
687pub struct SplitRenderer;
689
690impl SplitRenderer {
691 #[allow(clippy::too_many_arguments)]
710 #[allow(clippy::type_complexity)]
711 pub fn render_content(
712 frame: &mut Frame,
713 area: Rect,
714 split_manager: &SplitManager,
715 buffers: &mut HashMap<BufferId, EditorState>,
716 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
717 event_logs: &mut HashMap<BufferId, EventLog>,
718 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
719 composite_view_states: &mut HashMap<
720 (crate::model::event::SplitId, BufferId),
721 crate::view::composite_view::CompositeViewState,
722 >,
723 theme: &crate::view::theme::Theme,
724 ansi_background: Option<&AnsiBackground>,
725 background_fade: f32,
726 lsp_waiting: bool,
727 large_file_threshold_bytes: u64,
728 _line_wrap: bool,
729 estimated_line_length: usize,
730 highlight_context_bytes: usize,
731 mut split_view_states: Option<
732 &mut HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
733 >,
734 hide_cursor: bool,
735 hovered_tab: Option<(BufferId, crate::model::event::SplitId, bool)>, hovered_close_split: Option<crate::model::event::SplitId>,
737 hovered_maximize_split: Option<crate::model::event::SplitId>,
738 is_maximized: bool,
739 relative_line_numbers: bool,
740 tab_bar_visible: bool,
741 use_terminal_bg: bool,
742 ) -> (
743 Vec<(
744 crate::model::event::SplitId,
745 BufferId,
746 Rect,
747 Rect,
748 usize,
749 usize,
750 )>,
751 HashMap<crate::model::event::SplitId, crate::view::ui::tabs::TabLayout>, Vec<(crate::model::event::SplitId, u16, u16, u16)>, Vec<(crate::model::event::SplitId, u16, u16, u16)>, HashMap<crate::model::event::SplitId, Vec<ViewLineMapping>>, ) {
756 let _span = tracing::trace_span!("render_content").entered();
757
758 let visible_buffers = split_manager.get_visible_buffers(area);
760 let active_split_id = split_manager.active_split();
761 let has_multiple_splits = visible_buffers.len() > 1;
762
763 let mut split_areas = Vec::new();
765 let mut tab_layouts: HashMap<
766 crate::model::event::SplitId,
767 crate::view::ui::tabs::TabLayout,
768 > = HashMap::new();
769 let mut close_split_areas = Vec::new();
770 let mut maximize_split_areas = Vec::new();
771 let mut view_line_mappings: HashMap<crate::model::event::SplitId, Vec<ViewLineMapping>> =
772 HashMap::new();
773
774 for (split_id, buffer_id, split_area) in visible_buffers {
776 let is_active = split_id == active_split_id;
777
778 let layout = Self::split_layout(split_area, tab_bar_visible);
779 let (split_buffers, tab_scroll_offset) =
780 Self::split_buffers_for_tabs(split_view_states.as_deref(), split_id, buffer_id);
781
782 let tab_hover_for_split = hovered_tab.and_then(|(hover_buf, hover_split, is_close)| {
784 if hover_split == split_id {
785 Some((hover_buf, is_close))
786 } else {
787 None
788 }
789 });
790
791 if tab_bar_visible {
793 let tab_layout = TabsRenderer::render_for_split(
795 frame,
796 layout.tabs_rect,
797 &split_buffers,
798 buffers,
799 buffer_metadata,
800 composite_buffers,
801 buffer_id, theme,
803 is_active,
804 tab_scroll_offset,
805 tab_hover_for_split,
806 );
807
808 tab_layouts.insert(split_id, tab_layout);
810 let tab_row = layout.tabs_rect.y;
811
812 let show_maximize_btn = has_multiple_splits || is_maximized;
816 let show_close_btn = has_multiple_splits && !is_maximized;
817
818 if show_maximize_btn || show_close_btn {
819 let mut btn_x = layout.tabs_rect.x + layout.tabs_rect.width.saturating_sub(2);
822
823 if show_close_btn {
825 let is_hovered = hovered_close_split == Some(split_id);
826 let close_fg = if is_hovered {
827 theme.tab_close_hover_fg
828 } else {
829 theme.line_number_fg
830 };
831 let close_button = Paragraph::new("×")
832 .style(Style::default().fg(close_fg).bg(theme.tab_separator_bg));
833 let close_area = Rect::new(btn_x, tab_row, 1, 1);
834 frame.render_widget(close_button, close_area);
835 close_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
836 btn_x = btn_x.saturating_sub(2); }
838
839 if show_maximize_btn {
841 let is_hovered = hovered_maximize_split == Some(split_id);
842 let max_fg = if is_hovered {
843 theme.tab_close_hover_fg
844 } else {
845 theme.line_number_fg
846 };
847 let icon = if is_maximized { "⧉" } else { "□" };
849 let max_button = Paragraph::new(icon)
850 .style(Style::default().fg(max_fg).bg(theme.tab_separator_bg));
851 let max_area = Rect::new(btn_x, tab_row, 1, 1);
852 frame.render_widget(max_button, max_area);
853 maximize_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
854 }
855 }
856 }
857
858 let state_opt = buffers.get_mut(&buffer_id);
860 let event_log_opt = event_logs.get_mut(&buffer_id);
861
862 if let Some(state) = state_opt {
863 if state.is_composite_buffer {
865 if let Some(composite) = composite_buffers.get(&buffer_id) {
866 if let Some(ref mut svs) = split_view_states {
869 if let Some(split_vs) = svs.get_mut(&split_id) {
870 if split_vs.viewport.width != layout.content_rect.width
871 || split_vs.viewport.height != layout.content_rect.height
872 {
873 split_vs.viewport.resize(
874 layout.content_rect.width,
875 layout.content_rect.height,
876 );
877 }
878 }
879 }
880
881 let pane_count = composite.pane_count();
883 let view_state = composite_view_states
884 .entry((split_id, buffer_id))
885 .or_insert_with(|| {
886 crate::view::composite_view::CompositeViewState::new(
887 buffer_id, pane_count,
888 )
889 });
890 Self::render_composite_buffer(
892 frame,
893 layout.content_rect,
894 composite,
895 buffers,
896 theme,
897 is_active,
898 view_state,
899 use_terminal_bg,
900 );
901
902 let total_rows = composite.row_count();
904 let content_height = layout.content_rect.height.saturating_sub(1) as usize; let (thumb_start, thumb_end) = Self::render_composite_scrollbar(
906 frame,
907 layout.scrollbar_rect,
908 total_rows,
909 view_state.scroll_row,
910 content_height,
911 is_active,
912 );
913
914 split_areas.push((
916 split_id,
917 buffer_id,
918 layout.content_rect,
919 layout.scrollbar_rect,
920 thumb_start,
921 thumb_end,
922 ));
923 }
924 view_line_mappings.insert(split_id, Vec::new());
925 continue;
926 }
927
928 let view_state_opt = split_view_states
932 .as_deref()
933 .and_then(|vs| vs.get(&split_id));
934 let viewport_clone =
935 view_state_opt
936 .map(|vs| vs.viewport.clone())
937 .unwrap_or_else(|| {
938 crate::view::viewport::Viewport::new(
939 layout.content_rect.width,
940 layout.content_rect.height,
941 )
942 });
943 let mut viewport = viewport_clone;
944
945 let saved_cursors = Self::temporary_split_state(
946 state,
947 split_view_states.as_deref(),
948 split_id,
949 is_active,
950 );
951 Self::sync_viewport_to_content(
952 &mut viewport,
953 &mut state.buffer,
954 &state.cursors,
955 layout.content_rect,
956 );
957 let view_prefs =
958 Self::resolve_view_preferences(state, split_view_states.as_deref(), split_id);
959
960 let split_view_mappings = Self::render_buffer_in_split(
961 frame,
962 state,
963 &mut viewport,
964 event_log_opt,
965 layout.content_rect,
966 is_active,
967 theme,
968 ansi_background,
969 background_fade,
970 lsp_waiting,
971 view_prefs.view_mode,
972 view_prefs.compose_width,
973 view_prefs.compose_column_guides,
974 view_prefs.view_transform,
975 estimated_line_length,
976 highlight_context_bytes,
977 buffer_id,
978 hide_cursor,
979 relative_line_numbers,
980 use_terminal_bg,
981 );
982
983 view_line_mappings.insert(split_id, split_view_mappings);
985
986 let buffer_len = state.buffer.len();
989 let (total_lines, top_line) = Self::scrollbar_line_counts(
990 state,
991 &viewport,
992 large_file_threshold_bytes,
993 buffer_len,
994 );
995
996 let (thumb_start, thumb_end) = Self::render_scrollbar(
998 frame,
999 state,
1000 &viewport,
1001 layout.scrollbar_rect,
1002 is_active,
1003 theme,
1004 large_file_threshold_bytes,
1005 total_lines,
1006 top_line,
1007 );
1008
1009 Self::restore_split_state(state, saved_cursors);
1011
1012 if let Some(view_states) = split_view_states.as_deref_mut() {
1017 if let Some(view_state) = view_states.get_mut(&split_id) {
1018 tracing::trace!(
1019 "Writing back viewport: top_byte={}, skip_ensure_visible={}",
1020 viewport.top_byte,
1021 viewport.should_skip_ensure_visible()
1022 );
1023 view_state.viewport = viewport.clone();
1024 }
1025 }
1026
1027 split_areas.push((
1029 split_id,
1030 buffer_id,
1031 layout.content_rect,
1032 layout.scrollbar_rect,
1033 thumb_start,
1034 thumb_end,
1035 ));
1036 }
1037 }
1038
1039 let separators = split_manager.get_separators(area);
1041 for (direction, x, y, length) in separators {
1042 Self::render_separator(frame, direction, x, y, length, theme);
1043 }
1044
1045 (
1046 split_areas,
1047 tab_layouts,
1048 close_split_areas,
1049 maximize_split_areas,
1050 view_line_mappings,
1051 )
1052 }
1053
1054 fn render_separator(
1056 frame: &mut Frame,
1057 direction: SplitDirection,
1058 x: u16,
1059 y: u16,
1060 length: u16,
1061 theme: &crate::view::theme::Theme,
1062 ) {
1063 match direction {
1064 SplitDirection::Horizontal => {
1065 let line_area = Rect::new(x, y, length, 1);
1067 let line_text = "─".repeat(length as usize);
1068 let paragraph =
1069 Paragraph::new(line_text).style(Style::default().fg(theme.split_separator_fg));
1070 frame.render_widget(paragraph, line_area);
1071 }
1072 SplitDirection::Vertical => {
1073 for offset in 0..length {
1075 let cell_area = Rect::new(x, y + offset, 1, 1);
1076 let paragraph =
1077 Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
1078 frame.render_widget(paragraph, cell_area);
1079 }
1080 }
1081 }
1082 }
1083
1084 fn render_composite_buffer(
1087 frame: &mut Frame,
1088 area: Rect,
1089 composite: &crate::model::composite_buffer::CompositeBuffer,
1090 buffers: &mut HashMap<BufferId, EditorState>,
1091 theme: &crate::view::theme::Theme,
1092 _is_active: bool,
1093 view_state: &mut crate::view::composite_view::CompositeViewState,
1094 use_terminal_bg: bool,
1095 ) {
1096 use crate::model::composite_buffer::{CompositeLayout, RowType};
1097
1098 let effective_editor_bg = if use_terminal_bg {
1100 ratatui::style::Color::Reset
1101 } else {
1102 theme.editor_bg
1103 };
1104
1105 let scroll_row = view_state.scroll_row;
1106 let cursor_row = view_state.cursor_row;
1107
1108 frame.render_widget(Clear, area);
1110
1111 let pane_count = composite.sources.len();
1113 if pane_count == 0 {
1114 return;
1115 }
1116
1117 let show_separator = match &composite.layout {
1119 CompositeLayout::SideBySide { show_separator, .. } => *show_separator,
1120 _ => false,
1121 };
1122
1123 let separator_width = if show_separator { 1 } else { 0 };
1125 let total_separators = (pane_count.saturating_sub(1)) as u16 * separator_width;
1126 let available_width = area.width.saturating_sub(total_separators);
1127
1128 let pane_widths: Vec<u16> = match &composite.layout {
1129 CompositeLayout::SideBySide { ratios, .. } => {
1130 let default_ratio = 1.0 / pane_count as f32;
1131 ratios
1132 .iter()
1133 .chain(std::iter::repeat(&default_ratio))
1134 .take(pane_count)
1135 .map(|r| (available_width as f32 * r).round() as u16)
1136 .collect()
1137 }
1138 _ => {
1139 let pane_width = available_width / pane_count as u16;
1141 vec![pane_width; pane_count]
1142 }
1143 };
1144
1145 view_state.pane_widths = pane_widths.clone();
1147
1148 let header_height = 1u16;
1150 let mut x_offset = area.x;
1151 for (idx, (source, &width)) in composite.sources.iter().zip(&pane_widths).enumerate() {
1152 let header_area = Rect::new(x_offset, area.y, width, header_height);
1153 let is_focused = idx == view_state.focused_pane;
1154
1155 let header_style = if is_focused {
1156 Style::default()
1157 .fg(theme.tab_active_fg)
1158 .bg(theme.tab_active_bg)
1159 } else {
1160 Style::default()
1161 .fg(theme.tab_inactive_fg)
1162 .bg(theme.tab_inactive_bg)
1163 };
1164
1165 let header_text = format!(" {} ", source.label);
1166 let header = Paragraph::new(header_text).style(header_style);
1167 frame.render_widget(header, header_area);
1168
1169 x_offset += width + separator_width;
1170 }
1171
1172 let content_y = area.y + header_height;
1174 let content_height = area.height.saturating_sub(header_height);
1175 let visible_rows = content_height as usize;
1176
1177 let alignment = &composite.alignment;
1179 let total_rows = alignment.rows.len();
1180
1181 struct PaneRenderData {
1184 lines: Vec<ViewLine>,
1185 line_to_view_line: HashMap<usize, usize>,
1186 highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
1187 }
1188
1189 let mut pane_render_data: Vec<Option<PaneRenderData>> = Vec::new();
1190
1191 for (pane_idx, source) in composite.sources.iter().enumerate() {
1192 if let Some(source_state) = buffers.get_mut(&source.buffer_id) {
1193 let visible_lines: Vec<usize> = alignment
1195 .rows
1196 .iter()
1197 .skip(scroll_row)
1198 .take(visible_rows)
1199 .filter_map(|row| row.get_pane_line(pane_idx))
1200 .map(|r| r.line)
1201 .collect();
1202
1203 let first_line = visible_lines.iter().copied().min();
1204 let last_line = visible_lines.iter().copied().max();
1205
1206 if let (Some(first_line), Some(last_line)) = (first_line, last_line) {
1207 let top_byte = source_state
1209 .buffer
1210 .line_start_offset(first_line)
1211 .unwrap_or(0);
1212 let end_byte = source_state
1213 .buffer
1214 .line_start_offset(last_line + 1)
1215 .unwrap_or(source_state.buffer.len());
1216
1217 let highlight_spans = source_state.highlighter.highlight_viewport(
1219 &source_state.buffer,
1220 top_byte,
1221 end_byte,
1222 theme,
1223 1024, );
1225
1226 let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80);
1228 let mut viewport =
1229 crate::view::viewport::Viewport::new(pane_width, content_height);
1230 viewport.top_byte = top_byte;
1231 viewport.line_wrap_enabled = false;
1232
1233 let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80) as usize;
1234 let gutter_width = 4; let content_width = pane_width.saturating_sub(gutter_width);
1236
1237 let lines_needed = last_line - first_line + 10;
1240 let view_data = Self::build_view_data(
1241 source_state,
1242 &viewport,
1243 None, 80, lines_needed, false, content_width,
1248 gutter_width,
1249 );
1250
1251 let mut line_to_view_line: HashMap<usize, usize> = HashMap::new();
1253 let mut current_line = first_line;
1254 for (idx, view_line) in view_data.lines.iter().enumerate() {
1255 if should_show_line_number(view_line) {
1256 line_to_view_line.insert(current_line, idx);
1257 current_line += 1;
1258 }
1259 }
1260
1261 pane_render_data.push(Some(PaneRenderData {
1262 lines: view_data.lines,
1263 line_to_view_line,
1264 highlight_spans,
1265 }));
1266 } else {
1267 pane_render_data.push(None);
1268 }
1269 } else {
1270 pane_render_data.push(None);
1271 }
1272 }
1273
1274 for view_row in 0..visible_rows {
1276 let display_row = scroll_row + view_row;
1277 if display_row >= total_rows {
1278 let mut x = area.x;
1280 for &width in &pane_widths {
1281 let tilde_area = Rect::new(x, content_y + view_row as u16, width, 1);
1282 let tilde =
1283 Paragraph::new("~").style(Style::default().fg(theme.line_number_fg));
1284 frame.render_widget(tilde, tilde_area);
1285 x += width + separator_width;
1286 }
1287 continue;
1288 }
1289
1290 let aligned_row = &alignment.rows[display_row];
1291 let is_cursor_row = display_row == cursor_row;
1292 let selection_cols = view_state.selection_column_range(display_row);
1294
1295 let row_bg = match aligned_row.row_type {
1297 RowType::Addition => Some(theme.diff_add_bg),
1298 RowType::Deletion => Some(theme.diff_remove_bg),
1299 RowType::Modification => Some(theme.diff_modify_bg),
1300 RowType::HunkHeader => Some(theme.current_line_bg),
1301 RowType::Context => None,
1302 };
1303
1304 let inline_diffs: Vec<Vec<Range<usize>>> = if aligned_row.row_type
1306 == RowType::Modification
1307 {
1308 let mut line_contents: Vec<Option<String>> = Vec::new();
1310 for (pane_idx, source) in composite.sources.iter().enumerate() {
1311 if let Some(line_ref) = aligned_row.get_pane_line(pane_idx) {
1312 if let Some(source_state) = buffers.get(&source.buffer_id) {
1313 line_contents.push(
1314 source_state
1315 .buffer
1316 .get_line(line_ref.line)
1317 .map(|line| String::from_utf8_lossy(&line).to_string()),
1318 );
1319 } else {
1320 line_contents.push(None);
1321 }
1322 } else {
1323 line_contents.push(None);
1324 }
1325 }
1326
1327 if line_contents.len() >= 2 {
1329 if let (Some(old_text), Some(new_text)) = (&line_contents[0], &line_contents[1])
1330 {
1331 let (old_ranges, new_ranges) = compute_inline_diff(old_text, new_text);
1332 vec![old_ranges, new_ranges]
1333 } else {
1334 vec![Vec::new(); composite.sources.len()]
1335 }
1336 } else {
1337 vec![Vec::new(); composite.sources.len()]
1338 }
1339 } else {
1340 vec![Vec::new(); composite.sources.len()]
1342 };
1343
1344 let mut x_offset = area.x;
1346 for (pane_idx, (_source, &width)) in
1347 composite.sources.iter().zip(&pane_widths).enumerate()
1348 {
1349 let pane_area = Rect::new(x_offset, content_y + view_row as u16, width, 1);
1350
1351 let left_column = view_state
1353 .get_pane_viewport(pane_idx)
1354 .map(|v| v.left_column)
1355 .unwrap_or(0);
1356
1357 let source_line_opt = aligned_row.get_pane_line(pane_idx);
1359
1360 if let Some(source_line_ref) = source_line_opt {
1361 let pane_data = pane_render_data.get(pane_idx).and_then(|opt| opt.as_ref());
1363 let view_line_opt = pane_data.and_then(|data| {
1364 data.line_to_view_line
1365 .get(&source_line_ref.line)
1366 .and_then(|&idx| data.lines.get(idx))
1367 });
1368 let highlight_spans = pane_data
1369 .map(|data| data.highlight_spans.as_slice())
1370 .unwrap_or(&[]);
1371
1372 let gutter_width = 4usize;
1373 let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
1374
1375 let is_focused_pane = pane_idx == view_state.focused_pane;
1376
1377 let bg = if is_cursor_row && is_focused_pane {
1380 theme.current_line_bg
1381 } else {
1382 row_bg.unwrap_or(effective_editor_bg)
1383 };
1384
1385 let pane_selection_cols = if is_focused_pane {
1387 selection_cols
1388 } else {
1389 None
1390 };
1391
1392 let line_num = format!("{:>3} ", source_line_ref.line + 1);
1394 let line_num_style = Style::default().fg(theme.line_number_fg).bg(bg);
1395
1396 let is_cursor_pane = is_focused_pane;
1397 let cursor_column = view_state.cursor_column;
1398
1399 let inline_ranges = inline_diffs.get(pane_idx).cloned().unwrap_or_default();
1401
1402 let highlight_bg = match aligned_row.row_type {
1404 RowType::Deletion => Some(theme.diff_remove_highlight_bg),
1405 RowType::Addition => Some(theme.diff_add_highlight_bg),
1406 RowType::Modification => {
1407 if pane_idx == 0 {
1408 Some(theme.diff_remove_highlight_bg)
1409 } else {
1410 Some(theme.diff_add_highlight_bg)
1411 }
1412 }
1413 _ => None,
1414 };
1415
1416 let mut spans = vec![Span::styled(line_num, line_num_style)];
1418
1419 if let Some(view_line) = view_line_opt {
1420 Self::render_view_line_content(
1422 &mut spans,
1423 view_line,
1424 highlight_spans,
1425 left_column,
1426 max_content_width,
1427 bg,
1428 theme,
1429 is_cursor_row && is_cursor_pane,
1430 cursor_column,
1431 &inline_ranges,
1432 highlight_bg,
1433 pane_selection_cols,
1434 );
1435 } else {
1436 tracing::warn!(
1442 "ViewLine missing for composite buffer: pane={}, line={}, pane_data={}",
1443 pane_idx,
1444 source_line_ref.line,
1445 pane_data.is_some()
1446 );
1447 let base_style = Style::default().fg(theme.editor_fg).bg(bg);
1449 let padding = " ".repeat(max_content_width);
1450 spans.push(Span::styled(padding, base_style));
1451 }
1452
1453 let line = Line::from(spans);
1454 let para = Paragraph::new(line);
1455 frame.render_widget(para, pane_area);
1456 } else {
1457 let is_focused_pane = pane_idx == view_state.focused_pane;
1459 let pane_has_selection = is_focused_pane
1461 && selection_cols
1462 .map(|(start, end)| start == 0 && end == usize::MAX)
1463 .unwrap_or(false);
1464
1465 let bg = if pane_has_selection {
1466 theme.selection_bg
1467 } else if is_cursor_row && is_focused_pane {
1468 theme.current_line_bg
1469 } else {
1470 row_bg.unwrap_or(effective_editor_bg)
1471 };
1472 let style = Style::default().fg(theme.line_number_fg).bg(bg);
1473
1474 let is_cursor_pane = pane_idx == view_state.focused_pane;
1476 if is_cursor_row && is_cursor_pane && view_state.cursor_column == 0 {
1477 let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
1479 let gutter_width = 4usize;
1480 let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
1481 let padding = " ".repeat(max_content_width.saturating_sub(1));
1482 let line = Line::from(vec![
1483 Span::styled(" ", style),
1484 Span::styled(" ", cursor_style),
1485 Span::styled(padding, Style::default().bg(bg)),
1486 ]);
1487 let para = Paragraph::new(line);
1488 frame.render_widget(para, pane_area);
1489 } else {
1490 let gap_style = Style::default().bg(bg);
1492 let empty_content = " ".repeat(width as usize);
1493 let para = Paragraph::new(empty_content).style(gap_style);
1494 frame.render_widget(para, pane_area);
1495 }
1496 }
1497
1498 x_offset += width;
1499
1500 if show_separator && pane_idx < pane_count - 1 {
1502 let sep_area =
1503 Rect::new(x_offset, content_y + view_row as u16, separator_width, 1);
1504 let sep =
1505 Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
1506 frame.render_widget(sep, sep_area);
1507 x_offset += separator_width;
1508 }
1509 }
1510 }
1511 }
1512
1513 #[allow(clippy::too_many_arguments)]
1515 fn render_view_line_content(
1516 spans: &mut Vec<Span<'static>>,
1517 view_line: &ViewLine,
1518 highlight_spans: &[crate::primitives::highlighter::HighlightSpan],
1519 left_column: usize,
1520 max_width: usize,
1521 bg: Color,
1522 theme: &crate::view::theme::Theme,
1523 show_cursor: bool,
1524 cursor_column: usize,
1525 inline_ranges: &[Range<usize>],
1526 highlight_bg: Option<Color>,
1527 selection_cols: Option<(usize, usize)>, ) {
1529 let text = &view_line.text;
1530 let char_source_bytes = &view_line.char_source_bytes;
1531
1532 let chars: Vec<char> = text.chars().collect();
1534 let mut col = 0usize;
1535 let mut rendered = 0usize;
1536 let mut current_span_text = String::new();
1537 let mut current_style: Option<Style> = None;
1538
1539 for (char_idx, ch) in chars.iter().enumerate() {
1540 let char_width = char_width(*ch);
1541
1542 if col < left_column {
1544 col += char_width;
1545 continue;
1546 }
1547
1548 if rendered >= max_width {
1550 break;
1551 }
1552
1553 let byte_pos = char_source_bytes.get(char_idx).and_then(|b| *b);
1555
1556 let highlight_color = byte_pos.and_then(|bp| {
1558 highlight_spans
1559 .iter()
1560 .find(|span| span.range.contains(&bp))
1561 .map(|span| span.color)
1562 });
1563
1564 let in_inline_range = inline_ranges.iter().any(|r| r.contains(&char_idx));
1566
1567 let in_selection = selection_cols
1569 .map(|(start, end)| col >= start && col < end)
1570 .unwrap_or(false);
1571
1572 let char_bg = if in_selection {
1574 theme.selection_bg
1575 } else if in_inline_range {
1576 highlight_bg.unwrap_or(bg)
1577 } else {
1578 bg
1579 };
1580
1581 let char_style = if let Some(color) = highlight_color {
1583 Style::default().fg(color).bg(char_bg)
1584 } else {
1585 Style::default().fg(theme.editor_fg).bg(char_bg)
1586 };
1587
1588 let final_style = if show_cursor && col == cursor_column {
1590 Style::default().fg(theme.editor_bg).bg(theme.editor_fg)
1592 } else {
1593 char_style
1594 };
1595
1596 if let Some(style) = current_style {
1598 if style != final_style && !current_span_text.is_empty() {
1599 spans.push(Span::styled(std::mem::take(&mut current_span_text), style));
1600 }
1601 }
1602
1603 current_style = Some(final_style);
1604 current_span_text.push(*ch);
1605 col += char_width;
1606 rendered += char_width;
1607 }
1608
1609 if !current_span_text.is_empty() {
1611 if let Some(style) = current_style {
1612 spans.push(Span::styled(current_span_text, style));
1613 }
1614 }
1615
1616 if rendered < max_width {
1618 let padding_len = max_width - rendered;
1619 let cursor_visual = cursor_column.saturating_sub(left_column);
1621
1622 if show_cursor && cursor_visual >= rendered && cursor_visual < max_width {
1624 let cursor_offset = cursor_visual - rendered;
1626 let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
1627 let normal_style = Style::default().bg(bg);
1628
1629 if cursor_offset > 0 {
1631 spans.push(Span::styled(" ".repeat(cursor_offset), normal_style));
1632 }
1633 spans.push(Span::styled(" ", cursor_style));
1635 let remaining = padding_len.saturating_sub(cursor_offset + 1);
1637 if remaining > 0 {
1638 spans.push(Span::styled(" ".repeat(remaining), normal_style));
1639 }
1640 } else {
1641 spans.push(Span::styled(
1643 " ".repeat(padding_len),
1644 Style::default().bg(bg),
1645 ));
1646 }
1647 }
1648 }
1649
1650 fn render_composite_scrollbar(
1652 frame: &mut Frame,
1653 scrollbar_rect: Rect,
1654 total_rows: usize,
1655 scroll_row: usize,
1656 viewport_height: usize,
1657 is_active: bool,
1658 ) -> (usize, usize) {
1659 let height = scrollbar_rect.height as usize;
1660 if height == 0 || total_rows == 0 {
1661 return (0, 0);
1662 }
1663
1664 let thumb_size_raw = if total_rows > 0 {
1666 ((viewport_height as f64 / total_rows as f64) * height as f64).ceil() as usize
1667 } else {
1668 1
1669 };
1670
1671 let max_scroll = total_rows.saturating_sub(viewport_height);
1673
1674 let thumb_size = if max_scroll == 0 {
1676 height
1677 } else {
1678 let max_thumb_size = (height as f64 * 0.8).floor() as usize;
1680 thumb_size_raw.max(1).min(max_thumb_size).min(height)
1681 };
1682
1683 let thumb_start = if max_scroll > 0 {
1685 let scroll_ratio = scroll_row.min(max_scroll) as f64 / max_scroll as f64;
1686 let max_thumb_start = height.saturating_sub(thumb_size);
1687 (scroll_ratio * max_thumb_start as f64) as usize
1688 } else {
1689 0
1690 };
1691
1692 let thumb_end = thumb_start + thumb_size;
1693
1694 let track_color = if is_active {
1696 Color::DarkGray
1697 } else {
1698 Color::Black
1699 };
1700 let thumb_color = if is_active {
1701 Color::Gray
1702 } else {
1703 Color::DarkGray
1704 };
1705
1706 for row in 0..height {
1708 let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
1709
1710 let style = if row >= thumb_start && row < thumb_end {
1711 Style::default().bg(thumb_color)
1712 } else {
1713 Style::default().bg(track_color)
1714 };
1715
1716 let paragraph = Paragraph::new(" ").style(style);
1717 frame.render_widget(paragraph, cell_area);
1718 }
1719
1720 (thumb_start, thumb_end)
1721 }
1722
1723 fn split_layout(split_area: Rect, tab_bar_visible: bool) -> SplitLayout {
1724 let tabs_height = if tab_bar_visible { 1u16 } else { 0u16 };
1725 let scrollbar_width = 1u16;
1726
1727 let tabs_rect = Rect::new(split_area.x, split_area.y, split_area.width, tabs_height);
1728 let content_rect = Rect::new(
1729 split_area.x,
1730 split_area.y + tabs_height,
1731 split_area.width.saturating_sub(scrollbar_width),
1732 split_area.height.saturating_sub(tabs_height),
1733 );
1734 let scrollbar_rect = Rect::new(
1735 split_area.x + split_area.width.saturating_sub(scrollbar_width),
1736 split_area.y + tabs_height,
1737 scrollbar_width,
1738 split_area.height.saturating_sub(tabs_height),
1739 );
1740
1741 SplitLayout {
1742 tabs_rect,
1743 content_rect,
1744 scrollbar_rect,
1745 }
1746 }
1747
1748 fn split_buffers_for_tabs(
1749 split_view_states: Option<
1750 &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1751 >,
1752 split_id: crate::model::event::SplitId,
1753 buffer_id: BufferId,
1754 ) -> (Vec<BufferId>, usize) {
1755 if let Some(view_states) = split_view_states {
1756 if let Some(view_state) = view_states.get(&split_id) {
1757 return (
1758 view_state.open_buffers.clone(),
1759 view_state.tab_scroll_offset,
1760 );
1761 }
1762 }
1763 (vec![buffer_id], 0)
1764 }
1765
1766 fn temporary_split_state(
1767 state: &mut EditorState,
1768 split_view_states: Option<
1769 &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1770 >,
1771 split_id: crate::model::event::SplitId,
1772 is_active: bool,
1773 ) -> Option<crate::model::cursor::Cursors> {
1774 if is_active {
1775 return None;
1776 }
1777
1778 if let Some(view_states) = split_view_states {
1779 if let Some(view_state) = view_states.get(&split_id) {
1780 let saved_cursors = Some(std::mem::replace(
1782 &mut state.cursors,
1783 view_state.cursors.clone(),
1784 ));
1785 return saved_cursors;
1786 }
1787 }
1788
1789 None
1790 }
1791
1792 fn restore_split_state(
1793 state: &mut EditorState,
1794 saved_cursors: Option<crate::model::cursor::Cursors>,
1795 ) {
1796 if let Some(cursors) = saved_cursors {
1797 state.cursors = cursors;
1798 }
1799 }
1800
1801 fn sync_viewport_to_content(
1802 viewport: &mut crate::view::viewport::Viewport,
1803 buffer: &mut crate::model::buffer::Buffer,
1804 cursors: &crate::model::cursor::Cursors,
1805 content_rect: Rect,
1806 ) {
1807 let size_changed =
1808 viewport.width != content_rect.width || viewport.height != content_rect.height;
1809
1810 if size_changed {
1811 viewport.resize(content_rect.width, content_rect.height);
1812 }
1813
1814 let primary = *cursors.primary();
1819 viewport.ensure_visible(buffer, &primary);
1820 }
1821
1822 fn resolve_view_preferences(
1823 state: &EditorState,
1824 split_view_states: Option<
1825 &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1826 >,
1827 split_id: crate::model::event::SplitId,
1828 ) -> ViewPreferences {
1829 if let Some(view_states) = split_view_states {
1830 if let Some(view_state) = view_states.get(&split_id) {
1831 return ViewPreferences {
1832 view_mode: view_state.view_mode.clone(),
1833 compose_width: view_state.compose_width,
1834 compose_column_guides: view_state.compose_column_guides.clone(),
1835 view_transform: view_state.view_transform.clone(),
1836 };
1837 }
1838 }
1839
1840 ViewPreferences {
1841 view_mode: state.view_mode.clone(),
1842 compose_width: state.compose_width,
1843 compose_column_guides: state.compose_column_guides.clone(),
1844 view_transform: state.view_transform.clone(),
1845 }
1846 }
1847
1848 fn scrollbar_line_counts(
1849 state: &EditorState,
1850 viewport: &crate::view::viewport::Viewport,
1851 large_file_threshold_bytes: u64,
1852 buffer_len: usize,
1853 ) -> (usize, usize) {
1854 if buffer_len > large_file_threshold_bytes as usize {
1855 return (0, 0);
1856 }
1857
1858 let total_lines = if buffer_len > 0 {
1859 state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1860 } else {
1861 1
1862 };
1863
1864 let top_line = if viewport.top_byte < buffer_len {
1865 state.buffer.get_line_number(viewport.top_byte)
1866 } else {
1867 0
1868 };
1869
1870 (total_lines, top_line)
1871 }
1872
1873 #[allow(clippy::too_many_arguments)]
1876 fn render_scrollbar(
1877 frame: &mut Frame,
1878 state: &EditorState,
1879 viewport: &crate::view::viewport::Viewport,
1880 scrollbar_rect: Rect,
1881 is_active: bool,
1882 _theme: &crate::view::theme::Theme,
1883 large_file_threshold_bytes: u64,
1884 total_lines: usize,
1885 top_line: usize,
1886 ) -> (usize, usize) {
1887 let height = scrollbar_rect.height as usize;
1888 if height == 0 {
1889 return (0, 0);
1890 }
1891
1892 let buffer_len = state.buffer.len();
1893 let viewport_top = viewport.top_byte;
1894 let viewport_height_lines = viewport.height as usize;
1898
1899 let (thumb_start, thumb_size) = if buffer_len > large_file_threshold_bytes as usize {
1901 let thumb_start = if buffer_len > 0 {
1903 ((viewport_top as f64 / buffer_len as f64) * height as f64) as usize
1904 } else {
1905 0
1906 };
1907 (thumb_start, 1)
1908 } else {
1909 let thumb_size_raw = if total_lines > 0 {
1914 ((viewport_height_lines as f64 / total_lines as f64) * height as f64).ceil()
1915 as usize
1916 } else {
1917 1
1918 };
1919
1920 let max_scroll_line = total_lines.saturating_sub(viewport_height_lines);
1924
1925 let thumb_size = if max_scroll_line == 0 {
1928 height
1929 } else {
1930 let max_thumb_size = (height as f64 * 0.8).floor() as usize;
1932 thumb_size_raw.max(1).min(max_thumb_size).min(height)
1933 };
1934
1935 let thumb_start = if max_scroll_line > 0 {
1939 let scroll_ratio = top_line.min(max_scroll_line) as f64 / max_scroll_line as f64;
1941 let max_thumb_start = height.saturating_sub(thumb_size);
1942 (scroll_ratio * max_thumb_start as f64) as usize
1943 } else {
1944 0
1946 };
1947
1948 (thumb_start, thumb_size)
1949 };
1950
1951 let thumb_end = thumb_start + thumb_size;
1952
1953 let track_color = if is_active {
1955 Color::DarkGray
1956 } else {
1957 Color::Black
1958 };
1959 let thumb_color = if is_active {
1960 Color::Gray
1961 } else {
1962 Color::DarkGray
1963 };
1964
1965 for row in 0..height {
1967 let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
1968
1969 let style = if row >= thumb_start && row < thumb_end {
1970 Style::default().bg(thumb_color)
1972 } else {
1973 Style::default().bg(track_color)
1975 };
1976
1977 let paragraph = Paragraph::new(" ").style(style);
1978 frame.render_widget(paragraph, cell_area);
1979 }
1980
1981 (thumb_start, thumb_end)
1983 }
1984
1985 #[allow(clippy::too_many_arguments)]
1986 fn build_view_data(
1987 state: &mut EditorState,
1988 viewport: &crate::view::viewport::Viewport,
1989 view_transform: Option<ViewTransformPayload>,
1990 estimated_line_length: usize,
1991 visible_count: usize,
1992 line_wrap_enabled: bool,
1993 content_width: usize,
1994 gutter_width: usize,
1995 ) -> ViewData {
1996 let is_binary = state.buffer.is_binary();
1998 let line_ending = state.buffer.line_ending();
1999
2000 let base_tokens = Self::build_base_tokens(
2002 &mut state.buffer,
2003 viewport.top_byte,
2004 estimated_line_length,
2005 visible_count,
2006 is_binary,
2007 line_ending,
2008 );
2009
2010 let mut tokens = view_transform.map(|vt| vt.tokens).unwrap_or(base_tokens);
2012
2013 let effective_width = if line_wrap_enabled {
2018 content_width
2019 } else {
2020 MAX_SAFE_LINE_WIDTH
2021 };
2022 tokens = Self::apply_wrapping_transform(tokens, effective_width, gutter_width);
2023
2024 let is_binary = state.buffer.is_binary();
2029 let ansi_aware = !is_binary; let source_lines: Vec<ViewLine> =
2031 ViewLineIterator::new(&tokens, is_binary, ansi_aware, state.tab_size).collect();
2032
2033 let lines = Self::inject_virtual_lines(source_lines, state);
2035
2036 ViewData { lines }
2037 }
2038
2039 fn create_virtual_line(text: &str, style: ratatui::style::Style) -> ViewLine {
2041 use fresh_core::api::ViewTokenStyle;
2042
2043 let text = text.to_string();
2044 let len = text.chars().count();
2045
2046 let token_style = ViewTokenStyle {
2048 fg: style.fg.and_then(|c| match c {
2049 ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
2050 _ => None,
2051 }),
2052 bg: style.bg.and_then(|c| match c {
2053 ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
2054 _ => None,
2055 }),
2056 bold: style.add_modifier.contains(ratatui::style::Modifier::BOLD),
2057 italic: style
2058 .add_modifier
2059 .contains(ratatui::style::Modifier::ITALIC),
2060 };
2061
2062 ViewLine {
2063 text,
2064 char_source_bytes: vec![None; len],
2066 char_styles: vec![Some(token_style); len],
2068 char_visual_cols: (0..len).collect(),
2070 visual_to_char: (0..len).collect(),
2072 tab_starts: HashSet::new(),
2073 line_start: LineStart::AfterInjectedNewline,
2075 ends_with_newline: true,
2076 }
2077 }
2078
2079 fn inject_virtual_lines(source_lines: Vec<ViewLine>, state: &EditorState) -> Vec<ViewLine> {
2081 use crate::view::virtual_text::VirtualTextPosition;
2082
2083 let viewport_start = source_lines
2085 .first()
2086 .and_then(|l| l.char_source_bytes.iter().find_map(|m| *m))
2087 .unwrap_or(0);
2088 let viewport_end = source_lines
2089 .last()
2090 .and_then(|l| l.char_source_bytes.iter().rev().find_map(|m| *m))
2091 .map(|b| b + 1)
2092 .unwrap_or(viewport_start);
2093
2094 let virtual_lines = state.virtual_texts.query_lines_in_range(
2096 &state.marker_list,
2097 viewport_start,
2098 viewport_end,
2099 );
2100
2101 if virtual_lines.is_empty() {
2103 return source_lines;
2104 }
2105
2106 let mut result = Vec::with_capacity(source_lines.len() + virtual_lines.len());
2108
2109 for source_line in source_lines {
2110 let line_start_byte = source_line.char_source_bytes.iter().find_map(|m| *m);
2112 let line_end_byte = source_line
2113 .char_source_bytes
2114 .iter()
2115 .rev()
2116 .find_map(|m| *m)
2117 .map(|b| b + 1);
2118
2119 if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
2121 for (anchor_pos, vtext) in &virtual_lines {
2122 if *anchor_pos >= start
2123 && *anchor_pos < end
2124 && vtext.position == VirtualTextPosition::LineAbove
2125 {
2126 result.push(Self::create_virtual_line(&vtext.text, vtext.style));
2127 }
2128 }
2129 }
2130
2131 result.push(source_line.clone());
2133
2134 if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
2136 for (anchor_pos, vtext) in &virtual_lines {
2137 if *anchor_pos >= start
2138 && *anchor_pos < end
2139 && vtext.position == VirtualTextPosition::LineBelow
2140 {
2141 result.push(Self::create_virtual_line(&vtext.text, vtext.style));
2142 }
2143 }
2144 }
2145 }
2146
2147 result
2148 }
2149
2150 fn build_base_tokens(
2151 buffer: &mut Buffer,
2152 top_byte: usize,
2153 estimated_line_length: usize,
2154 visible_count: usize,
2155 is_binary: bool,
2156 line_ending: crate::model::buffer::LineEnding,
2157 ) -> Vec<fresh_core::api::ViewTokenWire> {
2158 use crate::model::buffer::LineEnding;
2159 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2160
2161 let mut tokens = Vec::new();
2162
2163 if is_binary {
2166 return Self::build_base_tokens_binary(
2167 buffer,
2168 top_byte,
2169 estimated_line_length,
2170 visible_count,
2171 );
2172 }
2173
2174 let mut iter = buffer.line_iterator(top_byte, estimated_line_length);
2175 let mut lines_seen = 0usize;
2176 let max_lines = visible_count.saturating_add(4);
2177
2178 while lines_seen < max_lines {
2179 if let Some((line_start, line_content)) = iter.next_line() {
2180 let mut byte_offset = 0usize;
2181 let content_bytes = line_content.as_bytes();
2182 let mut skip_next_lf = false; let mut chars_this_line = 0usize; for ch in line_content.chars() {
2185 if chars_this_line >= MAX_SAFE_LINE_WIDTH {
2188 tokens.push(ViewTokenWire {
2189 source_offset: None,
2190 kind: ViewTokenWireKind::Break,
2191 style: None,
2192 });
2193 chars_this_line = 0;
2194 lines_seen += 1;
2196 if lines_seen >= max_lines {
2197 break;
2198 }
2199 }
2200 chars_this_line += 1;
2201
2202 let ch_len = ch.len_utf8();
2203 let source_offset = Some(line_start + byte_offset);
2204
2205 match ch {
2206 '\r' => {
2207 let is_crlf_file = line_ending == LineEnding::CRLF;
2211 let next_byte = content_bytes.get(byte_offset + 1);
2212 if is_crlf_file && next_byte == Some(&b'\n') {
2213 tokens.push(ViewTokenWire {
2215 source_offset,
2216 kind: ViewTokenWireKind::Newline,
2217 style: None,
2218 });
2219 skip_next_lf = true;
2221 byte_offset += ch_len;
2222 continue;
2223 }
2224 tokens.push(ViewTokenWire {
2226 source_offset,
2227 kind: ViewTokenWireKind::BinaryByte(ch as u8),
2228 style: None,
2229 });
2230 }
2231 '\n' if skip_next_lf => {
2232 skip_next_lf = false;
2234 byte_offset += ch_len;
2235 continue;
2236 }
2237 '\n' => {
2238 tokens.push(ViewTokenWire {
2239 source_offset,
2240 kind: ViewTokenWireKind::Newline,
2241 style: None,
2242 });
2243 }
2244 ' ' => {
2245 tokens.push(ViewTokenWire {
2246 source_offset,
2247 kind: ViewTokenWireKind::Space,
2248 style: None,
2249 });
2250 }
2251 '\t' => {
2252 tokens.push(ViewTokenWire {
2254 source_offset,
2255 kind: ViewTokenWireKind::Text(ch.to_string()),
2256 style: None,
2257 });
2258 }
2259 _ if Self::is_control_char(ch) => {
2260 tokens.push(ViewTokenWire {
2262 source_offset,
2263 kind: ViewTokenWireKind::BinaryByte(ch as u8),
2264 style: None,
2265 });
2266 }
2267 _ => {
2268 if let Some(last) = tokens.last_mut() {
2270 if let ViewTokenWireKind::Text(ref mut s) = last.kind {
2271 let expected_offset = last.source_offset.map(|o| o + s.len());
2273 if expected_offset == Some(line_start + byte_offset) {
2274 s.push(ch);
2275 byte_offset += ch_len;
2276 continue;
2277 }
2278 }
2279 }
2280 tokens.push(ViewTokenWire {
2281 source_offset,
2282 kind: ViewTokenWireKind::Text(ch.to_string()),
2283 style: None,
2284 });
2285 }
2286 }
2287 byte_offset += ch_len;
2288 }
2289 lines_seen += 1;
2290 } else {
2291 break;
2292 }
2293 }
2294
2295 if tokens.is_empty() {
2297 tokens.push(ViewTokenWire {
2298 source_offset: Some(top_byte),
2299 kind: ViewTokenWireKind::Text(String::new()),
2300 style: None,
2301 });
2302 }
2303
2304 tokens
2305 }
2306
2307 fn build_base_tokens_binary(
2310 buffer: &mut Buffer,
2311 top_byte: usize,
2312 estimated_line_length: usize,
2313 visible_count: usize,
2314 ) -> Vec<fresh_core::api::ViewTokenWire> {
2315 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2316
2317 let mut tokens = Vec::new();
2318 let max_lines = visible_count.saturating_add(4);
2319 let buffer_len = buffer.len();
2320
2321 if top_byte >= buffer_len {
2322 tokens.push(ViewTokenWire {
2323 source_offset: Some(top_byte),
2324 kind: ViewTokenWireKind::Text(String::new()),
2325 style: None,
2326 });
2327 return tokens;
2328 }
2329
2330 let estimated_bytes = estimated_line_length * max_lines * 2;
2332 let bytes_to_read = estimated_bytes.min(buffer_len - top_byte);
2333
2334 let raw_bytes = buffer.slice_bytes(top_byte..top_byte + bytes_to_read);
2336
2337 let mut byte_offset = 0usize;
2338 let mut lines_seen = 0usize;
2339 let mut current_text = String::new();
2340 let mut current_text_start: Option<usize> = None;
2341
2342 let flush_text =
2344 |tokens: &mut Vec<ViewTokenWire>, text: &mut String, start: &mut Option<usize>| {
2345 if !text.is_empty() {
2346 tokens.push(ViewTokenWire {
2347 source_offset: *start,
2348 kind: ViewTokenWireKind::Text(std::mem::take(text)),
2349 style: None,
2350 });
2351 *start = None;
2352 }
2353 };
2354
2355 while byte_offset < raw_bytes.len() && lines_seen < max_lines {
2356 let b = raw_bytes[byte_offset];
2357 let source_offset = top_byte + byte_offset;
2358
2359 match b {
2360 b'\n' => {
2361 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2362 tokens.push(ViewTokenWire {
2363 source_offset: Some(source_offset),
2364 kind: ViewTokenWireKind::Newline,
2365 style: None,
2366 });
2367 lines_seen += 1;
2368 }
2369 b' ' => {
2370 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2371 tokens.push(ViewTokenWire {
2372 source_offset: Some(source_offset),
2373 kind: ViewTokenWireKind::Space,
2374 style: None,
2375 });
2376 }
2377 _ => {
2378 if Self::is_binary_unprintable(b) {
2381 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2383 tokens.push(ViewTokenWire {
2385 source_offset: Some(source_offset),
2386 kind: ViewTokenWireKind::BinaryByte(b),
2387 style: None,
2388 });
2389 } else {
2390 if current_text_start.is_none() {
2393 current_text_start = Some(source_offset);
2394 }
2395 current_text.push(b as char);
2396 }
2397 }
2398 }
2399 byte_offset += 1;
2400 }
2401
2402 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2404
2405 if tokens.is_empty() {
2407 tokens.push(ViewTokenWire {
2408 source_offset: Some(top_byte),
2409 kind: ViewTokenWireKind::Text(String::new()),
2410 style: None,
2411 });
2412 }
2413
2414 tokens
2415 }
2416
2417 fn is_binary_unprintable(b: u8) -> bool {
2429 if b == 0x09 || b == 0x0A {
2433 return false;
2434 }
2435 if b < 0x20 {
2438 return true;
2439 }
2440 if b == 0x7F {
2442 return true;
2443 }
2444 if b >= 0x80 {
2447 return true;
2448 }
2449 false
2450 }
2451
2452 fn is_control_char(ch: char) -> bool {
2455 let code = ch as u32;
2456 if code >= 128 {
2458 return false;
2459 }
2460 let b = code as u8;
2461 if b == 0x09 || b == 0x0A || b == 0x1B {
2463 return false;
2464 }
2465 b < 0x20 || b == 0x7F
2468 }
2469
2470 pub fn build_base_tokens_for_hook(
2472 buffer: &mut Buffer,
2473 top_byte: usize,
2474 estimated_line_length: usize,
2475 visible_count: usize,
2476 is_binary: bool,
2477 line_ending: crate::model::buffer::LineEnding,
2478 ) -> Vec<fresh_core::api::ViewTokenWire> {
2479 Self::build_base_tokens(
2480 buffer,
2481 top_byte,
2482 estimated_line_length,
2483 visible_count,
2484 is_binary,
2485 line_ending,
2486 )
2487 }
2488
2489 fn apply_wrapping_transform(
2490 tokens: Vec<fresh_core::api::ViewTokenWire>,
2491 content_width: usize,
2492 gutter_width: usize,
2493 ) -> Vec<fresh_core::api::ViewTokenWire> {
2494 use crate::primitives::visual_layout::visual_width;
2495 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2496
2497 let mut wrapped = Vec::new();
2498 let mut current_line_width = 0;
2499
2500 let available_width = content_width.saturating_sub(gutter_width);
2502
2503 for token in tokens {
2504 match &token.kind {
2505 ViewTokenWireKind::Newline => {
2506 wrapped.push(token);
2508 current_line_width = 0;
2509 }
2510 ViewTokenWireKind::Text(text) => {
2511 let text_visual_width = visual_width(text, current_line_width);
2513
2514 if current_line_width > 0
2516 && current_line_width + text_visual_width > available_width
2517 {
2518 wrapped.push(ViewTokenWire {
2519 source_offset: None,
2520 kind: ViewTokenWireKind::Break,
2521 style: None,
2522 });
2523 current_line_width = 0;
2524 }
2525
2526 let text_visual_width = visual_width(text, current_line_width);
2528
2529 if text_visual_width > available_width
2533 && !crate::primitives::ansi::contains_ansi_codes(text)
2534 {
2535 use unicode_segmentation::UnicodeSegmentation;
2536
2537 let graphemes: Vec<(usize, &str)> = text.grapheme_indices(true).collect();
2539 let mut grapheme_idx = 0;
2540 let source_base = token.source_offset;
2541
2542 while grapheme_idx < graphemes.len() {
2543 let remaining_width =
2546 available_width.saturating_sub(current_line_width);
2547 if remaining_width == 0 {
2548 wrapped.push(ViewTokenWire {
2550 source_offset: None,
2551 kind: ViewTokenWireKind::Break,
2552 style: None,
2553 });
2554 current_line_width = 0;
2555 continue;
2556 }
2557
2558 let mut chunk_visual_width = 0;
2559 let mut chunk_grapheme_count = 0;
2560 let mut col = current_line_width;
2561
2562 for &(_byte_offset, grapheme) in &graphemes[grapheme_idx..] {
2563 let g_width = if grapheme == "\t" {
2564 crate::primitives::visual_layout::tab_expansion_width(col)
2565 } else {
2566 crate::primitives::display_width::str_width(grapheme)
2567 };
2568
2569 if chunk_visual_width + g_width > remaining_width
2570 && chunk_grapheme_count > 0
2571 {
2572 break;
2573 }
2574
2575 chunk_visual_width += g_width;
2576 chunk_grapheme_count += 1;
2577 col += g_width;
2578 }
2579
2580 if chunk_grapheme_count == 0 {
2581 chunk_grapheme_count = 1;
2583 let grapheme = graphemes[grapheme_idx].1;
2584 chunk_visual_width = if grapheme == "\t" {
2585 crate::primitives::visual_layout::tab_expansion_width(
2586 current_line_width,
2587 )
2588 } else {
2589 crate::primitives::display_width::str_width(grapheme)
2590 };
2591 }
2592
2593 let chunk_start_byte = graphemes[grapheme_idx].0;
2595 let chunk_end_byte =
2596 if grapheme_idx + chunk_grapheme_count < graphemes.len() {
2597 graphemes[grapheme_idx + chunk_grapheme_count].0
2598 } else {
2599 text.len()
2600 };
2601 let chunk = text[chunk_start_byte..chunk_end_byte].to_string();
2602 let chunk_source = source_base.map(|b| b + chunk_start_byte);
2603
2604 wrapped.push(ViewTokenWire {
2605 source_offset: chunk_source,
2606 kind: ViewTokenWireKind::Text(chunk),
2607 style: token.style.clone(),
2608 });
2609
2610 current_line_width += chunk_visual_width;
2611 grapheme_idx += chunk_grapheme_count;
2612
2613 if current_line_width >= available_width {
2615 wrapped.push(ViewTokenWire {
2616 source_offset: None,
2617 kind: ViewTokenWireKind::Break,
2618 style: None,
2619 });
2620 current_line_width = 0;
2621 }
2622 }
2623 } else {
2624 wrapped.push(token);
2625 current_line_width += text_visual_width;
2626 }
2627 }
2628 ViewTokenWireKind::Space => {
2629 if current_line_width + 1 > available_width {
2631 wrapped.push(ViewTokenWire {
2632 source_offset: None,
2633 kind: ViewTokenWireKind::Break,
2634 style: None,
2635 });
2636 current_line_width = 0;
2637 }
2638 wrapped.push(token);
2639 current_line_width += 1;
2640 }
2641 ViewTokenWireKind::Break => {
2642 wrapped.push(token);
2644 current_line_width = 0;
2645 }
2646 ViewTokenWireKind::BinaryByte(_) => {
2647 let byte_display_width = 4;
2649 if current_line_width + byte_display_width > available_width {
2650 wrapped.push(ViewTokenWire {
2651 source_offset: None,
2652 kind: ViewTokenWireKind::Break,
2653 style: None,
2654 });
2655 current_line_width = 0;
2656 }
2657 wrapped.push(token);
2658 current_line_width += byte_display_width;
2659 }
2660 }
2661 }
2662
2663 wrapped
2664 }
2665
2666 fn calculate_view_anchor(view_lines: &[ViewLine], top_byte: usize) -> ViewAnchor {
2667 for (idx, line) in view_lines.iter().enumerate() {
2670 if let Some(first_source) = line.char_source_bytes.iter().find_map(|m| *m) {
2672 if first_source >= top_byte {
2673 let mut start_idx = idx;
2676 while start_idx > 0 {
2677 let prev_line = &view_lines[start_idx - 1];
2678 let prev_has_source =
2680 prev_line.char_source_bytes.iter().any(|m| m.is_some());
2681 if !prev_has_source {
2682 start_idx -= 1;
2683 } else {
2684 break;
2685 }
2686 }
2687 return ViewAnchor {
2688 start_line_idx: start_idx,
2689 start_line_skip: 0,
2690 };
2691 }
2692 }
2693 }
2694
2695 ViewAnchor {
2697 start_line_idx: 0,
2698 start_line_skip: 0,
2699 }
2700 }
2701
2702 fn calculate_compose_layout(
2703 area: Rect,
2704 view_mode: &ViewMode,
2705 compose_width: Option<u16>,
2706 ) -> ComposeLayout {
2707 let should_compose = view_mode == &ViewMode::Compose || compose_width.is_some();
2711
2712 if !should_compose {
2713 return ComposeLayout {
2714 render_area: area,
2715 left_pad: 0,
2716 right_pad: 0,
2717 };
2718 }
2719
2720 let target_width = compose_width.unwrap_or(area.width);
2721 let clamped_width = target_width.min(area.width).max(1);
2722 if clamped_width >= area.width {
2723 return ComposeLayout {
2724 render_area: area,
2725 left_pad: 0,
2726 right_pad: 0,
2727 };
2728 }
2729
2730 let pad_total = area.width - clamped_width;
2731 let left_pad = pad_total / 2;
2732 let right_pad = pad_total - left_pad;
2733
2734 ComposeLayout {
2735 render_area: Rect::new(area.x + left_pad, area.y, clamped_width, area.height),
2736 left_pad,
2737 right_pad,
2738 }
2739 }
2740
2741 fn render_compose_margins(
2742 frame: &mut Frame,
2743 area: Rect,
2744 layout: &ComposeLayout,
2745 _view_mode: &ViewMode,
2746 theme: &crate::view::theme::Theme,
2747 effective_editor_bg: ratatui::style::Color,
2748 ) {
2749 if layout.left_pad == 0 && layout.right_pad == 0 {
2751 return;
2752 }
2753
2754 const PAPER_EDGE_WIDTH: u16 = 1;
2757
2758 let desk_style = Style::default().bg(theme.compose_margin_bg);
2759 let paper_style = Style::default().bg(effective_editor_bg);
2760
2761 if layout.left_pad > 0 {
2762 let paper_edge = PAPER_EDGE_WIDTH.min(layout.left_pad);
2763 let desk_width = layout.left_pad.saturating_sub(paper_edge);
2764
2765 if desk_width > 0 {
2767 let desk_rect = Rect::new(area.x, area.y, desk_width, area.height);
2768 frame.render_widget(Block::default().style(desk_style), desk_rect);
2769 }
2770
2771 if paper_edge > 0 {
2773 let paper_rect = Rect::new(area.x + desk_width, area.y, paper_edge, area.height);
2774 frame.render_widget(Block::default().style(paper_style), paper_rect);
2775 }
2776 }
2777
2778 if layout.right_pad > 0 {
2779 let paper_edge = PAPER_EDGE_WIDTH.min(layout.right_pad);
2780 let desk_width = layout.right_pad.saturating_sub(paper_edge);
2781 let right_start = area.x + layout.left_pad + layout.render_area.width;
2782
2783 if paper_edge > 0 {
2785 let paper_rect = Rect::new(right_start, area.y, paper_edge, area.height);
2786 frame.render_widget(Block::default().style(paper_style), paper_rect);
2787 }
2788
2789 if desk_width > 0 {
2791 let desk_rect =
2792 Rect::new(right_start + paper_edge, area.y, desk_width, area.height);
2793 frame.render_widget(Block::default().style(desk_style), desk_rect);
2794 }
2795 }
2796 }
2797
2798 fn selection_context(state: &EditorState) -> SelectionContext {
2799 let ranges: Vec<Range<usize>> = state
2800 .cursors
2801 .iter()
2802 .filter_map(|(_, cursor)| {
2803 if cursor.selection_mode == SelectionMode::Block {
2806 None
2807 } else {
2808 cursor.selection_range()
2809 }
2810 })
2811 .collect();
2812
2813 let block_rects: Vec<(usize, usize, usize, usize)> = state
2814 .cursors
2815 .iter()
2816 .filter_map(|(_, cursor)| {
2817 if cursor.selection_mode == SelectionMode::Block {
2818 if let Some(anchor) = cursor.block_anchor {
2819 let cur_line = state.buffer.get_line_number(cursor.position);
2821 let cur_line_start = state.buffer.line_start_offset(cur_line).unwrap_or(0);
2822 let cur_col = cursor.position.saturating_sub(cur_line_start);
2823
2824 Some((
2826 anchor.line.min(cur_line),
2827 anchor.column.min(cur_col),
2828 anchor.line.max(cur_line),
2829 anchor.column.max(cur_col),
2830 ))
2831 } else {
2832 None
2833 }
2834 } else {
2835 None
2836 }
2837 })
2838 .collect();
2839
2840 let cursor_positions: Vec<usize> = if state.show_cursors {
2841 state
2842 .cursors
2843 .iter()
2844 .map(|(_, cursor)| cursor.position)
2845 .collect()
2846 } else {
2847 Vec::new()
2848 };
2849
2850 SelectionContext {
2851 ranges,
2852 block_rects,
2853 cursor_positions,
2854 primary_cursor_position: state.cursors.primary().position,
2855 }
2856 }
2857
2858 fn decoration_context(
2859 state: &mut EditorState,
2860 viewport_start: usize,
2861 viewport_end: usize,
2862 primary_cursor_position: usize,
2863 theme: &crate::view::theme::Theme,
2864 highlight_context_bytes: usize,
2865 ) -> DecorationContext {
2866 let viewport_size = viewport_end.saturating_sub(viewport_start);
2869 let highlight_start = viewport_start.saturating_sub(viewport_size);
2870 let highlight_end = viewport_end
2871 .saturating_add(viewport_size)
2872 .min(state.buffer.len());
2873
2874 let highlight_spans = state.highlighter.highlight_viewport(
2875 &state.buffer,
2876 highlight_start,
2877 highlight_end,
2878 theme,
2879 highlight_context_bytes,
2880 );
2881
2882 state.reference_highlight_overlay.update(
2884 &state.buffer,
2885 &mut state.overlays,
2886 &mut state.marker_list,
2887 &mut state.reference_highlighter,
2888 primary_cursor_position,
2889 viewport_start,
2890 viewport_end,
2891 highlight_context_bytes,
2892 theme.semantic_highlight_bg,
2893 );
2894
2895 state.bracket_highlight_overlay.update(
2897 &state.buffer,
2898 &mut state.overlays,
2899 &mut state.marker_list,
2900 primary_cursor_position,
2901 );
2902
2903 let mut semantic_token_spans = Vec::new();
2906 let mut viewport_overlays = Vec::new();
2907 for (overlay, range) in
2908 state
2909 .overlays
2910 .query_viewport(viewport_start, viewport_end, &state.marker_list)
2911 {
2912 if crate::services::lsp::semantic_tokens::is_semantic_token_overlay(overlay) {
2913 if let crate::view::overlay::OverlayFace::Foreground { color } = &overlay.face {
2914 semantic_token_spans.push(crate::primitives::highlighter::HighlightSpan {
2915 range,
2916 color: *color,
2917 });
2918 }
2919 continue;
2920 }
2921
2922 viewport_overlays.push((overlay.clone(), range));
2923 }
2924
2925 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
2927 let diagnostic_lines: HashSet<usize> = viewport_overlays
2928 .iter()
2929 .filter_map(|(overlay, range)| {
2930 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
2931 return Some(state.buffer.get_line_number(range.start));
2932 }
2933 None
2934 })
2935 .collect();
2936
2937 let virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>> =
2938 state
2939 .virtual_texts
2940 .build_lookup(&state.marker_list, viewport_start, viewport_end)
2941 .into_iter()
2942 .map(|(position, texts)| (position, texts.into_iter().cloned().collect()))
2943 .collect();
2944
2945 let line_indicators = state.margins.get_indicators_for_viewport(
2947 viewport_start,
2948 viewport_end,
2949 |byte_offset| state.buffer.get_line_number(byte_offset),
2950 );
2951
2952 DecorationContext {
2953 highlight_spans,
2954 semantic_token_spans,
2955 viewport_overlays,
2956 virtual_text_lookup,
2957 diagnostic_lines,
2958 line_indicators,
2959 }
2960 }
2961
2962 fn calculate_viewport_end(
2965 state: &mut EditorState,
2966 viewport_start: usize,
2967 estimated_line_length: usize,
2968 visible_count: usize,
2969 ) -> usize {
2970 let mut iter_temp = state
2971 .buffer
2972 .line_iterator(viewport_start, estimated_line_length);
2973 let mut viewport_end = viewport_start;
2974 for _ in 0..visible_count {
2975 if let Some((line_start, line_content)) = iter_temp.next_line() {
2976 viewport_end = line_start + line_content.len();
2977 } else {
2978 break;
2979 }
2980 }
2981 viewport_end
2982 }
2983
2984 fn render_view_lines(input: LineRenderInput<'_>) -> LineRenderOutput {
2985 let LineRenderInput {
2986 state,
2987 theme,
2988 view_lines,
2989 view_anchor,
2990 render_area,
2991 gutter_width,
2992 selection,
2993 decorations,
2994 starting_line_num,
2995 visible_line_count,
2996 lsp_waiting,
2997 is_active,
2998 line_wrap,
2999 estimated_lines,
3000 left_column,
3001 relative_line_numbers,
3002 } = input;
3003
3004 let selection_ranges = &selection.ranges;
3005 let block_selections = &selection.block_rects;
3006 let cursor_positions = &selection.cursor_positions;
3007 let primary_cursor_position = selection.primary_cursor_position;
3008
3009 let cursor_line = state.buffer.get_line_number(primary_cursor_position);
3011
3012 let highlight_spans = &decorations.highlight_spans;
3013 let semantic_token_spans = &decorations.semantic_token_spans;
3014 let viewport_overlays = &decorations.viewport_overlays;
3015 let virtual_text_lookup = &decorations.virtual_text_lookup;
3016 let diagnostic_lines = &decorations.diagnostic_lines;
3017 let line_indicators = &decorations.line_indicators;
3018
3019 let mut lines = Vec::new();
3020 let mut view_line_mappings = Vec::new();
3021 let mut lines_rendered = 0usize;
3022 let mut view_iter_idx = view_anchor.start_line_idx;
3023 let mut cursor_screen_x = 0u16;
3024 let mut cursor_screen_y = 0u16;
3025 let mut have_cursor = false;
3026 let mut last_line_end: Option<LastLineEnd> = None;
3027
3028 let is_empty_buffer = state.buffer.is_empty();
3029
3030 let mut last_visible_x: u16 = 0;
3032 let _view_start_line_skip = view_anchor.start_line_skip; let mut current_source_line_num = starting_line_num;
3036 let mut prev_was_source_line = false;
3039
3040 loop {
3041 let current_view_line = if let Some(vl) = view_lines.get(view_iter_idx) {
3043 vl
3044 } else if is_empty_buffer && lines_rendered == 0 {
3045 static EMPTY_LINE: std::sync::OnceLock<ViewLine> = std::sync::OnceLock::new();
3047 EMPTY_LINE.get_or_init(|| ViewLine {
3048 text: String::new(),
3049 char_source_bytes: Vec::new(),
3050 char_styles: Vec::new(),
3051 char_visual_cols: Vec::new(),
3052 visual_to_char: Vec::new(),
3053 tab_starts: HashSet::new(),
3054 line_start: LineStart::Beginning,
3055 ends_with_newline: false,
3056 })
3057 } else {
3058 break;
3059 };
3060
3061 let line_content = current_view_line.text.clone();
3063 let line_has_newline = current_view_line.ends_with_newline;
3064 let line_char_source_bytes = ¤t_view_line.char_source_bytes;
3065 let line_char_styles = ¤t_view_line.char_styles;
3066 let line_visual_to_char = ¤t_view_line.visual_to_char;
3067 let line_tab_starts = ¤t_view_line.tab_starts;
3068 let _line_start_type = current_view_line.line_start; let _source_byte_at_col = |vis_col: usize| -> Option<usize> {
3072 let char_idx = line_visual_to_char.get(vis_col).copied()?;
3073 line_char_source_bytes.get(char_idx).copied().flatten()
3074 };
3075
3076 view_iter_idx += 1;
3077
3078 if lines_rendered >= visible_line_count {
3079 break;
3080 }
3081
3082 let show_line_number = should_show_line_number(current_view_line);
3085
3086 if show_line_number && prev_was_source_line {
3091 current_source_line_num += 1;
3092 }
3093 if show_line_number {
3096 prev_was_source_line = true;
3097 }
3098
3099 let is_continuation = !show_line_number;
3101
3102 lines_rendered += 1;
3103
3104 let left_col = left_column;
3106
3107 let mut line_spans = Vec::new();
3109 let mut line_view_map: Vec<Option<usize>> = Vec::new();
3110 let mut last_seg_y: Option<u16> = None;
3111 let mut _last_seg_width: usize = 0;
3112
3113 let mut span_acc = SpanAccumulator::new();
3116
3117 render_left_margin(
3119 &LeftMarginContext {
3120 state,
3121 theme,
3122 is_continuation,
3123 current_source_line_num,
3124 estimated_lines,
3125 diagnostic_lines,
3126 line_indicators,
3127 cursor_line,
3128 relative_line_numbers,
3129 },
3130 &mut line_spans,
3131 &mut line_view_map,
3132 );
3133
3134 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);
3144 let max_visible_chars = if line_wrap {
3145 (render_area.width as usize)
3148 .saturating_mul(visible_lines_remaining.max(1))
3149 .saturating_add(200)
3150 } else {
3151 (render_area.width as usize).saturating_add(100)
3153 };
3154 let max_chars_to_process = left_col.saturating_add(max_visible_chars);
3155
3156 let line_has_ansi = line_content.contains('\x1b');
3159 let mut ansi_parser = if line_has_ansi {
3160 Some(AnsiParser::new())
3161 } else {
3162 None
3163 };
3164 let mut visible_char_count = 0usize;
3166
3167 let mut debug_tracker = if state.debug_highlight_mode {
3169 Some(DebugSpanTracker::default())
3170 } else {
3171 None
3172 };
3173
3174 let mut first_line_byte_pos: Option<usize> = None;
3176 let mut last_line_byte_pos: Option<usize> = None;
3177
3178 let chars_iterator = line_content.chars().peekable();
3179 for ch in chars_iterator {
3180 let byte_pos = line_char_source_bytes
3183 .get(display_char_idx)
3184 .copied()
3185 .flatten();
3186
3187 if let Some(bp) = byte_pos {
3189 if first_line_byte_pos.is_none() {
3190 first_line_byte_pos = Some(bp);
3191 }
3192 last_line_byte_pos = Some(bp);
3193 }
3194
3195 let ansi_style = if let Some(ref mut parser) = ansi_parser {
3198 match parser.parse_char(ch) {
3199 Some(style) => style,
3200 None => {
3201 if let Some(bp) = byte_pos {
3205 if bp == primary_cursor_position && !have_cursor {
3206 cursor_screen_x = gutter_width as u16
3208 + col_offset.saturating_sub(left_col) as u16;
3209 cursor_screen_y = lines_rendered.saturating_sub(1) as u16;
3210 have_cursor = true;
3211 }
3212 }
3213 byte_index += ch.len_utf8();
3214 display_char_idx += 1;
3215 continue;
3217 }
3218 }
3219 } else {
3220 Style::default()
3222 };
3223
3224 if visible_char_count > max_chars_to_process {
3227 break;
3230 }
3231
3232 if col_offset >= left_col {
3234 let is_tab_start = line_tab_starts.contains(&col_offset);
3236
3237 let is_cursor = byte_pos
3241 .map(|bp| {
3242 if !cursor_positions.contains(&bp) || bp >= state.buffer.len() {
3243 return false;
3244 }
3245 let prev_char_idx = display_char_idx.saturating_sub(1);
3248 let prev_byte_pos =
3249 line_char_source_bytes.get(prev_char_idx).copied().flatten();
3250 display_char_idx == 0 || prev_byte_pos != Some(bp)
3252 })
3253 .unwrap_or(false);
3254
3255 let is_in_block_selection = block_selections.iter().any(
3258 |(start_line, start_col, end_line, end_col)| {
3259 current_source_line_num >= *start_line
3260 && current_source_line_num <= *end_line
3261 && byte_index >= *start_col
3262 && byte_index <= *end_col
3263 },
3264 );
3265
3266 let is_primary_cursor = is_cursor && byte_pos == Some(primary_cursor_position);
3272 let exclude_from_selection = is_cursor && !(is_active && is_primary_cursor);
3273
3274 let is_selected = !exclude_from_selection
3275 && (byte_pos.is_some_and(|bp| {
3276 selection_ranges.iter().any(|range| range.contains(&bp))
3277 }) || is_in_block_selection);
3278
3279 let token_style = line_char_styles
3282 .get(display_char_idx)
3283 .and_then(|s| s.as_ref());
3284 let CharStyleOutput {
3285 style,
3286 is_secondary_cursor,
3287 } = compute_char_style(&CharStyleContext {
3288 byte_pos,
3289 token_style,
3290 ansi_style,
3291 is_cursor,
3292 is_selected,
3293 theme,
3294 highlight_spans,
3295 semantic_token_spans,
3296 viewport_overlays,
3297 primary_cursor_position,
3298 is_active,
3299 });
3300
3301 let tab_indicator: String;
3304 let display_char: &str = if is_cursor && lsp_waiting && is_active {
3305 "⋯"
3306 } else if debug_tracker.is_some() && ch == '\r' {
3307 "\\r"
3309 } else if debug_tracker.is_some() && ch == '\n' {
3310 "\\n"
3312 } else if ch == '\n' {
3313 ""
3314 } else if is_tab_start && state.show_whitespace_tabs {
3315 tab_indicator = "→".to_string();
3317 &tab_indicator
3318 } else {
3319 tab_indicator = ch.to_string();
3320 &tab_indicator
3321 };
3322
3323 if let Some(bp) = byte_pos {
3324 if let Some(vtexts) = virtual_text_lookup.get(&bp) {
3325 for vtext in vtexts
3326 .iter()
3327 .filter(|v| v.position == VirtualTextPosition::BeforeChar)
3328 {
3329 span_acc.flush(&mut line_spans, &mut line_view_map);
3331 let extra_space = if ch == '\n' { " " } else { "" };
3333 let text_with_space = format!("{}{} ", extra_space, vtext.text);
3334 push_span_with_map(
3335 &mut line_spans,
3336 &mut line_view_map,
3337 text_with_space,
3338 vtext.style,
3339 None,
3340 );
3341 }
3342 }
3343 }
3344
3345 if !display_char.is_empty() {
3346 if let Some(ref mut tracker) = debug_tracker {
3348 span_acc.flush(&mut line_spans, &mut line_view_map);
3350 let opening_tags = tracker.get_opening_tags(
3351 byte_pos,
3352 highlight_spans,
3353 viewport_overlays,
3354 );
3355 for tag in opening_tags {
3356 push_debug_tag(&mut line_spans, &mut line_view_map, tag);
3357 }
3358 }
3359
3360 if debug_tracker.is_some() {
3362 if let Some(bp) = byte_pos {
3363 push_debug_tag(
3364 &mut line_spans,
3365 &mut line_view_map,
3366 format!("[{}]", bp),
3367 );
3368 }
3369 }
3370
3371 for c in display_char.chars() {
3374 span_acc.push(c, style, byte_pos, &mut line_spans, &mut line_view_map);
3375 }
3376
3377 if let Some(ref mut tracker) = debug_tracker {
3380 span_acc.flush(&mut line_spans, &mut line_view_map);
3382 let next_byte_pos = byte_pos.map(|bp| bp + ch.len_utf8());
3384 let closing_tags = tracker.get_closing_tags(next_byte_pos);
3385 for tag in closing_tags {
3386 push_debug_tag(&mut line_spans, &mut line_view_map, tag);
3387 }
3388 }
3389 }
3390
3391 if !have_cursor {
3394 if let Some(bp) = byte_pos {
3395 if bp == primary_cursor_position && char_width(ch) == 0 {
3396 cursor_screen_x = gutter_width as u16
3398 + col_offset.saturating_sub(left_col) as u16;
3399 cursor_screen_y = lines.len() as u16;
3400 have_cursor = true;
3401 }
3402 }
3403 }
3404
3405 if let Some(bp) = byte_pos {
3406 if let Some(vtexts) = virtual_text_lookup.get(&bp) {
3407 for vtext in vtexts
3408 .iter()
3409 .filter(|v| v.position == VirtualTextPosition::AfterChar)
3410 {
3411 let text_with_space = format!(" {}", vtext.text);
3412 push_span_with_map(
3413 &mut line_spans,
3414 &mut line_view_map,
3415 text_with_space,
3416 vtext.style,
3417 None,
3418 );
3419 }
3420 }
3421 }
3422
3423 if is_cursor && ch == '\n' {
3424 let should_add_indicator =
3425 if is_active { is_secondary_cursor } else { true };
3426 if should_add_indicator {
3427 span_acc.flush(&mut line_spans, &mut line_view_map);
3430 let cursor_style = if is_active {
3431 Style::default()
3432 .fg(theme.editor_fg)
3433 .bg(theme.editor_bg)
3434 .add_modifier(Modifier::REVERSED)
3435 } else {
3436 Style::default()
3437 .fg(theme.editor_fg)
3438 .bg(theme.inactive_cursor)
3439 };
3440 push_span_with_map(
3441 &mut line_spans,
3442 &mut line_view_map,
3443 " ".to_string(),
3444 cursor_style,
3445 byte_pos,
3446 );
3447 }
3448 }
3449 }
3450
3451 byte_index += ch.len_utf8();
3452 display_char_idx += 1; let ch_width = char_width(ch);
3456 col_offset += ch_width;
3457 visible_char_count += ch_width;
3458 }
3459
3460 span_acc.flush(&mut line_spans, &mut line_view_map);
3462
3463 let content_is_empty = line_content.is_empty();
3467 if line_spans.is_empty() || !line_wrap || content_is_empty {
3468 last_seg_y = Some(lines.len() as u16);
3469 }
3470
3471 if !line_has_newline {
3472 let line_len_chars = line_content.chars().count();
3473
3474 let last_char_idx = line_len_chars.saturating_sub(1);
3476 let after_last_char_idx = line_len_chars;
3477
3478 let last_char_buf_pos =
3479 line_char_source_bytes.get(last_char_idx).copied().flatten();
3480 let after_last_char_buf_pos = line_char_source_bytes
3481 .get(after_last_char_idx)
3482 .copied()
3483 .flatten();
3484
3485 let cursor_at_end = cursor_positions.iter().any(|&pos| {
3486 let matches_after = after_last_char_buf_pos.is_some_and(|bp| pos == bp);
3489 let expected_after_pos = last_char_buf_pos.map(|p| p + 1).unwrap_or(0);
3492 let matches_fallback =
3493 after_last_char_buf_pos.is_none() && pos == expected_after_pos;
3494
3495 matches_after || matches_fallback
3496 });
3497
3498 if cursor_at_end {
3499 let is_primary_at_end = after_last_char_buf_pos
3501 .is_some_and(|bp| bp == primary_cursor_position)
3502 || (after_last_char_buf_pos.is_none()
3503 && primary_cursor_position >= state.buffer.len());
3504
3505 if let Some(seg_y) = last_seg_y {
3507 if is_primary_at_end {
3508 cursor_screen_x = if line_len_chars == 0 {
3513 gutter_width as u16
3514 } else {
3515 gutter_width as u16 + col_offset.saturating_sub(left_col) as u16
3518 };
3519 cursor_screen_y = seg_y;
3520 have_cursor = true;
3521 }
3522 }
3523
3524 let should_add_indicator = if is_active { !is_primary_at_end } else { true };
3525 if should_add_indicator {
3526 let cursor_style = if is_active {
3527 Style::default()
3528 .fg(theme.editor_fg)
3529 .bg(theme.editor_bg)
3530 .add_modifier(Modifier::REVERSED)
3531 } else {
3532 Style::default()
3533 .fg(theme.editor_fg)
3534 .bg(theme.inactive_cursor)
3535 };
3536 push_span_with_map(
3537 &mut line_spans,
3538 &mut line_view_map,
3539 " ".to_string(),
3540 cursor_style,
3541 None,
3542 );
3543 }
3544 }
3545 }
3546
3547 let current_y = lines.len() as u16;
3550 last_seg_y = Some(current_y);
3551
3552 if !line_spans.is_empty() {
3553 for (screen_x, source_offset) in line_view_map.iter().enumerate() {
3556 if let Some(src) = source_offset {
3557 if *src == primary_cursor_position && !have_cursor {
3561 cursor_screen_x = screen_x as u16;
3562 cursor_screen_y = current_y;
3563 have_cursor = true;
3564 }
3565 last_visible_x = screen_x as u16;
3568 }
3569 }
3570 }
3571
3572 if !line_wrap {
3575 let content_width = render_area.width.saturating_sub(gutter_width as u16) as usize;
3577 let remaining_cols = content_width.saturating_sub(visible_char_count);
3578
3579 if remaining_cols > 0 {
3580 let fill_style: Option<Style> = if let (Some(start), Some(end)) =
3583 (first_line_byte_pos, last_line_byte_pos)
3584 {
3585 viewport_overlays
3586 .iter()
3587 .filter(|(overlay, range)| {
3588 overlay.extend_to_line_end
3589 && range.start <= end
3590 && range.end >= start
3591 })
3592 .max_by_key(|(o, _)| o.priority)
3593 .and_then(|(overlay, _)| {
3594 match &overlay.face {
3595 crate::view::overlay::OverlayFace::Background { color } => {
3596 Some(Style::default().fg(*color).bg(*color))
3598 }
3599 crate::view::overlay::OverlayFace::Style { style } => {
3600 style.bg.map(|bg| Style::default().fg(bg).bg(bg))
3603 }
3604 crate::view::overlay::OverlayFace::ThemedStyle {
3605 fallback_style,
3606 bg_theme,
3607 ..
3608 } => {
3609 let bg = bg_theme
3611 .as_ref()
3612 .and_then(|key| theme.resolve_theme_key(key))
3613 .or(fallback_style.bg);
3614 bg.map(|bg| Style::default().fg(bg).bg(bg))
3615 }
3616 _ => None,
3617 }
3618 })
3619 } else {
3620 None
3621 };
3622
3623 if let Some(fill_bg) = fill_style {
3624 let fill_text = " ".repeat(remaining_cols);
3625 push_span_with_map(
3626 &mut line_spans,
3627 &mut line_view_map,
3628 fill_text,
3629 fill_bg,
3630 None,
3631 );
3632 }
3633 }
3634 }
3635
3636 let line_end_byte = if current_view_line.ends_with_newline {
3638 current_view_line
3640 .char_source_bytes
3641 .iter()
3642 .rev()
3643 .find_map(|m| *m)
3644 .unwrap_or(0)
3645 } else {
3646 if let Some((char_idx, &Some(last_byte_start))) = current_view_line
3648 .char_source_bytes
3649 .iter()
3650 .enumerate()
3651 .rev()
3652 .find(|(_, m)| m.is_some())
3653 {
3654 if let Some(last_char) = current_view_line.text.chars().nth(char_idx) {
3656 last_byte_start + last_char.len_utf8()
3657 } else {
3658 last_byte_start
3659 }
3660 } else {
3661 0
3662 }
3663 };
3664
3665 let content_map = if line_view_map.len() >= gutter_width {
3668 line_view_map[gutter_width..].to_vec()
3669 } else {
3670 Vec::new()
3671 };
3672 view_line_mappings.push(ViewLineMapping {
3673 char_source_bytes: content_map.clone(),
3674 visual_to_char: (0..content_map.len()).collect(),
3675 line_end_byte,
3676 });
3677
3678 let line_was_empty = line_spans.is_empty();
3680 lines.push(Line::from(line_spans));
3681
3682 if let Some(y) = last_seg_y {
3685 let end_x = if line_was_empty {
3689 gutter_width as u16
3690 } else {
3691 last_visible_x.saturating_add(1)
3692 };
3693 let line_len_chars = line_content.chars().count();
3694
3695 last_line_end = Some(LastLineEnd {
3696 pos: (end_x, y),
3697 terminated_with_newline: line_has_newline,
3698 });
3699
3700 if line_has_newline && line_len_chars > 0 {
3701 let newline_idx = line_len_chars.saturating_sub(1);
3702 if let Some(Some(src_newline)) = line_char_source_bytes.get(newline_idx) {
3703 if *src_newline == primary_cursor_position {
3704 if line_len_chars == 1 {
3708 cursor_screen_x = gutter_width as u16;
3710 cursor_screen_y = y;
3711 } else {
3712 cursor_screen_x = end_x;
3715 cursor_screen_y = y;
3716 }
3717 have_cursor = true;
3718 }
3719 }
3720 }
3721 }
3722
3723 if lines_rendered >= visible_line_count {
3724 break;
3725 }
3726 }
3727
3728 if let Some(ref end) = last_line_end {
3731 if end.terminated_with_newline && lines_rendered < visible_line_count {
3732 let mut implicit_line_spans = Vec::new();
3734 let implicit_line_num = current_source_line_num + 1;
3735
3736 if state.margins.left_config.enabled {
3737 implicit_line_spans.push(Span::styled(" ", Style::default()));
3739
3740 let estimated_lines = (state.buffer.len() / 80).max(1);
3742 let margin_content = state.margins.render_line(
3743 implicit_line_num,
3744 crate::view::margin::MarginPosition::Left,
3745 estimated_lines,
3746 );
3747 let (rendered_text, style_opt) =
3748 margin_content.render(state.margins.left_config.width);
3749 let margin_style =
3750 style_opt.unwrap_or_else(|| Style::default().fg(theme.line_number_fg));
3751 implicit_line_spans.push(Span::styled(rendered_text, margin_style));
3752
3753 if state.margins.left_config.show_separator {
3755 implicit_line_spans.push(Span::styled(
3756 state.margins.left_config.separator.to_string(),
3757 Style::default().fg(theme.line_number_fg),
3758 ));
3759 }
3760 }
3761
3762 let implicit_y = lines.len() as u16;
3763 lines.push(Line::from(implicit_line_spans));
3764 lines_rendered += 1;
3765
3766 let buffer_len = state.buffer.len();
3769
3770 view_line_mappings.push(ViewLineMapping {
3771 char_source_bytes: Vec::new(),
3772 visual_to_char: Vec::new(),
3773 line_end_byte: buffer_len,
3774 });
3775
3776 if primary_cursor_position == state.buffer.len() && !have_cursor {
3782 cursor_screen_x = gutter_width as u16;
3783 cursor_screen_y = implicit_y;
3784 have_cursor = true;
3785 }
3786 }
3787 }
3788
3789 let eof_fg = dim_color_for_tilde(theme.line_number_fg);
3799 let eof_style = Style::default().fg(eof_fg);
3800 while lines.len() < render_area.height as usize {
3801 let tilde_line = format!(
3803 "~{}",
3804 " ".repeat(render_area.width.saturating_sub(1) as usize)
3805 );
3806 lines.push(Line::styled(tilde_line, eof_style));
3807 }
3808
3809 LineRenderOutput {
3810 lines,
3811 cursor: have_cursor.then_some((cursor_screen_x, cursor_screen_y)),
3812 last_line_end,
3813 content_lines_rendered: lines_rendered,
3814 view_line_mappings,
3815 }
3816 }
3817
3818 fn resolve_cursor_fallback(
3819 current_cursor: Option<(u16, u16)>,
3820 primary_cursor_position: usize,
3821 buffer_len: usize,
3822 buffer_ends_with_newline: bool,
3823 last_line_end: Option<LastLineEnd>,
3824 lines_rendered: usize,
3825 gutter_width: usize,
3826 ) -> Option<(u16, u16)> {
3827 if current_cursor.is_some() || primary_cursor_position != buffer_len {
3828 return current_cursor;
3829 }
3830
3831 if buffer_ends_with_newline {
3832 if let Some(end) = last_line_end {
3833 return Some((gutter_width as u16, end.pos.1.saturating_add(1)));
3836 }
3837 return Some((gutter_width as u16, lines_rendered as u16));
3838 }
3839
3840 last_line_end.map(|end| end.pos)
3841 }
3842
3843 #[allow(clippy::too_many_arguments)]
3846 fn render_buffer_in_split(
3847 frame: &mut Frame,
3848 state: &mut EditorState,
3849 viewport: &mut crate::view::viewport::Viewport,
3850 event_log: Option<&mut EventLog>,
3851 area: Rect,
3852 is_active: bool,
3853 theme: &crate::view::theme::Theme,
3854 ansi_background: Option<&AnsiBackground>,
3855 background_fade: f32,
3856 lsp_waiting: bool,
3857 view_mode: ViewMode,
3858 compose_width: Option<u16>,
3859 compose_column_guides: Option<Vec<u16>>,
3860 view_transform: Option<ViewTransformPayload>,
3861 estimated_line_length: usize,
3862 highlight_context_bytes: usize,
3863 _buffer_id: BufferId,
3864 hide_cursor: bool,
3865 relative_line_numbers: bool,
3866 use_terminal_bg: bool,
3867 ) -> Vec<ViewLineMapping> {
3868 let _span = tracing::trace_span!("render_buffer_in_split").entered();
3869
3870 let effective_editor_bg = if use_terminal_bg {
3872 ratatui::style::Color::Reset
3873 } else {
3874 theme.editor_bg
3875 };
3876
3877 let line_wrap = viewport.line_wrap_enabled;
3878
3879 let overlay_count = state.overlays.all().len();
3880 if overlay_count > 0 {
3881 tracing::trace!("render_content: {} overlays present", overlay_count);
3882 }
3883
3884 let visible_count = viewport.visible_line_count();
3885
3886 let buffer_len = state.buffer.len();
3887 let estimated_lines = (buffer_len / 80).max(1);
3888 state.margins.update_width_for_buffer(estimated_lines);
3889 let gutter_width = state.margins.left_total_width();
3890
3891 let compose_layout = Self::calculate_compose_layout(area, &view_mode, compose_width);
3892 let render_area = compose_layout.render_area;
3893
3894 let view_transform_for_rebuild = view_transform.clone();
3896
3897 let view_data = Self::build_view_data(
3898 state,
3899 viewport,
3900 view_transform,
3901 estimated_line_length,
3902 visible_count,
3903 line_wrap,
3904 render_area.width as usize,
3905 gutter_width,
3906 );
3907
3908 let primary = *state.cursors.primary();
3911 let scrolled = viewport.ensure_visible_in_layout(&view_data.lines, &primary, gutter_width);
3912
3913 let view_data = if scrolled {
3916 Self::build_view_data(
3917 state,
3918 viewport,
3919 view_transform_for_rebuild,
3920 estimated_line_length,
3921 visible_count,
3922 line_wrap,
3923 render_area.width as usize,
3924 gutter_width,
3925 )
3926 } else {
3927 view_data
3928 };
3929
3930 let view_anchor = Self::calculate_view_anchor(&view_data.lines, viewport.top_byte);
3931 Self::render_compose_margins(
3932 frame,
3933 area,
3934 &compose_layout,
3935 &view_mode,
3936 theme,
3937 effective_editor_bg,
3938 );
3939
3940 let selection = Self::selection_context(state);
3941
3942 tracing::trace!(
3943 "Rendering buffer with {} cursors at positions: {:?}, primary at {}, is_active: {}, buffer_len: {}",
3944 selection.cursor_positions.len(),
3945 selection.cursor_positions,
3946 selection.primary_cursor_position,
3947 is_active,
3948 state.buffer.len()
3949 );
3950
3951 if !selection.cursor_positions.is_empty()
3952 && !selection
3953 .cursor_positions
3954 .contains(&selection.primary_cursor_position)
3955 {
3956 tracing::warn!(
3957 "Primary cursor position {} not found in cursor_positions list: {:?}",
3958 selection.primary_cursor_position,
3959 selection.cursor_positions
3960 );
3961 }
3962
3963 let starting_line_num = state
3964 .buffer
3965 .populate_line_cache(viewport.top_byte, visible_count);
3966
3967 let viewport_start = viewport.top_byte;
3968 let viewport_end = Self::calculate_viewport_end(
3969 state,
3970 viewport_start,
3971 estimated_line_length,
3972 visible_count,
3973 );
3974
3975 let decorations = Self::decoration_context(
3976 state,
3977 viewport_start,
3978 viewport_end,
3979 selection.primary_cursor_position,
3980 theme,
3981 highlight_context_bytes,
3982 );
3983
3984 let calculated_offset = viewport.top_view_line_offset;
3991
3992 tracing::trace!(
3993 top_byte = viewport.top_byte,
3994 top_view_line_offset = viewport.top_view_line_offset,
3995 calculated_offset,
3996 view_data_lines = view_data.lines.len(),
3997 "view line offset calculation"
3998 );
3999 let (view_lines_to_render, adjusted_starting_line_num, adjusted_view_anchor) =
4000 if calculated_offset > 0 && calculated_offset < view_data.lines.len() {
4001 let sliced = &view_data.lines[calculated_offset..];
4002
4003 let skipped_lines = &view_data.lines[..calculated_offset];
4006 let skipped_source_lines = skipped_lines
4007 .iter()
4008 .filter(|vl| should_show_line_number(vl))
4009 .count();
4010
4011 let adjusted_line_num = starting_line_num + skipped_source_lines;
4012
4013 let adjusted_anchor = Self::calculate_view_anchor(sliced, viewport.top_byte);
4015
4016 (sliced, adjusted_line_num, adjusted_anchor)
4017 } else {
4018 (&view_data.lines[..], starting_line_num, view_anchor)
4019 };
4020
4021 let render_output = Self::render_view_lines(LineRenderInput {
4022 state,
4023 theme,
4024 view_lines: view_lines_to_render,
4025 view_anchor: adjusted_view_anchor,
4026 render_area,
4027 gutter_width,
4028 selection: &selection,
4029 decorations: &decorations,
4030 starting_line_num: adjusted_starting_line_num,
4031 visible_line_count: visible_count,
4032 lsp_waiting,
4033 is_active,
4034 line_wrap,
4035 estimated_lines,
4036 left_column: viewport.left_column,
4037 relative_line_numbers,
4038 });
4039
4040 let mut lines = render_output.lines;
4041 let background_x_offset = viewport.left_column;
4042
4043 if let Some(bg) = ansi_background {
4044 Self::apply_background_to_lines(
4045 &mut lines,
4046 render_area.width,
4047 bg,
4048 effective_editor_bg,
4049 theme.editor_fg,
4050 background_fade,
4051 background_x_offset,
4052 starting_line_num,
4053 );
4054 }
4055
4056 frame.render_widget(Clear, render_area);
4057 let editor_block = Block::default()
4058 .borders(Borders::NONE)
4059 .style(Style::default().bg(effective_editor_bg));
4060 frame.render_widget(Paragraph::new(lines).block(editor_block), render_area);
4061
4062 if let Some(guides) = compose_column_guides {
4064 let guide_style = Style::default()
4065 .fg(theme.line_number_fg)
4066 .add_modifier(Modifier::DIM);
4067 let guide_height = render_output
4068 .content_lines_rendered
4069 .min(render_area.height as usize);
4070
4071 for col in guides {
4072 let guide_x = render_area.x + gutter_width as u16 + col;
4074
4075 if guide_x >= render_area.x && guide_x < render_area.x + render_area.width {
4077 for row in 0..guide_height {
4078 let cell_area = Rect::new(guide_x, render_area.y + row as u16, 1, 1);
4079 let guide_char = Paragraph::new("│").style(guide_style);
4080 frame.render_widget(guide_char, cell_area);
4081 }
4082 }
4083 }
4084 }
4085
4086 let buffer_ends_with_newline = if !state.buffer.is_empty() {
4087 let last_char = state.get_text_range(state.buffer.len() - 1, state.buffer.len());
4088 last_char == "\n"
4089 } else {
4090 false
4091 };
4092
4093 let cursor = Self::resolve_cursor_fallback(
4094 render_output.cursor,
4095 selection.primary_cursor_position,
4096 state.buffer.len(),
4097 buffer_ends_with_newline,
4098 render_output.last_line_end,
4099 render_output.content_lines_rendered,
4100 gutter_width,
4101 );
4102
4103 if is_active && state.show_cursors && !hide_cursor {
4104 if let Some((cursor_screen_x, cursor_screen_y)) = cursor {
4105 let screen_x = render_area.x.saturating_add(cursor_screen_x);
4107
4108 let max_y = render_area.height.saturating_sub(1);
4113 let clamped_cursor_y = cursor_screen_y.min(max_y);
4114 let screen_y = render_area.y.saturating_add(clamped_cursor_y);
4115
4116 frame.set_cursor_position((screen_x, screen_y));
4117
4118 if let Some(event_log) = event_log {
4119 let cursor_pos = state.cursors.primary().position;
4120 let buffer_len = state.buffer.len();
4121 event_log.log_render_state(cursor_pos, screen_x, screen_y, buffer_len);
4122 }
4123 }
4124 }
4125
4126 render_output.view_line_mappings
4129 }
4130
4131 #[allow(clippy::too_many_arguments)]
4142 fn apply_background_to_lines(
4143 lines: &mut Vec<Line<'static>>,
4144 area_width: u16,
4145 background: &AnsiBackground,
4146 theme_bg: Color,
4147 default_fg: Color,
4148 fade: f32,
4149 x_offset: usize,
4150 y_offset: usize,
4151 ) {
4152 if area_width == 0 {
4153 return;
4154 }
4155
4156 let width = area_width as usize;
4157
4158 for (y, line) in lines.iter_mut().enumerate() {
4159 let mut existing: Vec<(char, Style)> = Vec::new();
4161 let spans = std::mem::take(&mut line.spans);
4162 for span in spans {
4163 let style = span.style;
4164 for ch in span.content.chars() {
4165 existing.push((ch, style));
4166 }
4167 }
4168
4169 let mut chars_with_style = Vec::with_capacity(width);
4170 for x in 0..width {
4171 let sample_x = x_offset + x;
4172 let sample_y = y_offset + y;
4173
4174 let (ch, mut style) = if x < existing.len() {
4175 existing[x]
4176 } else {
4177 (' ', Style::default().fg(default_fg))
4178 };
4179
4180 if let Some(bg_color) = background.faded_color(sample_x, sample_y, theme_bg, fade) {
4181 if style.bg.is_none() || matches!(style.bg, Some(Color::Reset)) {
4182 style = style.bg(bg_color);
4183 }
4184 }
4185
4186 chars_with_style.push((ch, style));
4187 }
4188
4189 line.spans = Self::compress_chars(chars_with_style);
4190 }
4191 }
4192
4193 fn compress_chars(chars: Vec<(char, Style)>) -> Vec<Span<'static>> {
4194 if chars.is_empty() {
4195 return vec![];
4196 }
4197
4198 let mut spans = Vec::new();
4199 let mut current_style = chars[0].1;
4200 let mut current_text = String::new();
4201 current_text.push(chars[0].0);
4202
4203 for (ch, style) in chars.into_iter().skip(1) {
4204 if style == current_style {
4205 current_text.push(ch);
4206 } else {
4207 spans.push(Span::styled(current_text.clone(), current_style));
4208 current_text.clear();
4209 current_text.push(ch);
4210 current_style = style;
4211 }
4212 }
4213
4214 spans.push(Span::styled(current_text, current_style));
4215 spans
4216 }
4217}
4218
4219#[cfg(test)]
4220mod tests {
4221 use crate::model::filesystem::StdFileSystem;
4222 use std::sync::Arc;
4223
4224 fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
4225 Arc::new(StdFileSystem)
4226 }
4227 use super::*;
4228 use crate::model::buffer::Buffer;
4229 use crate::primitives::display_width::str_width;
4230 use crate::view::theme;
4231 use crate::view::theme::Theme;
4232 use crate::view::viewport::Viewport;
4233
4234 fn render_output_for(
4235 content: &str,
4236 cursor_pos: usize,
4237 ) -> (LineRenderOutput, usize, bool, usize) {
4238 render_output_for_with_gutters(content, cursor_pos, false)
4239 }
4240
4241 fn render_output_for_with_gutters(
4242 content: &str,
4243 cursor_pos: usize,
4244 gutters_enabled: bool,
4245 ) -> (LineRenderOutput, usize, bool, usize) {
4246 let mut state = EditorState::new(20, 6, 1024, test_fs());
4247 state.buffer = Buffer::from_str(content, 1024, test_fs());
4248 state.cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
4249 let viewport = Viewport::new(20, 4);
4251 state.margins.left_config.enabled = gutters_enabled;
4253
4254 let render_area = Rect::new(0, 0, 20, 4);
4255 let visible_count = viewport.visible_line_count();
4256 let gutter_width = state.margins.left_total_width();
4257
4258 let view_data = SplitRenderer::build_view_data(
4259 &mut state,
4260 &viewport,
4261 None,
4262 content.len().max(1),
4263 visible_count,
4264 false, render_area.width as usize,
4266 gutter_width,
4267 );
4268 let view_anchor = SplitRenderer::calculate_view_anchor(&view_data.lines, 0);
4269
4270 let estimated_lines = (state.buffer.len() / 80).max(1);
4271 state.margins.update_width_for_buffer(estimated_lines);
4272 let gutter_width = state.margins.left_total_width();
4273
4274 let selection = SplitRenderer::selection_context(&state);
4275 let starting_line_num = state
4276 .buffer
4277 .populate_line_cache(viewport.top_byte, visible_count);
4278 let viewport_start = viewport.top_byte;
4279 let viewport_end = SplitRenderer::calculate_viewport_end(
4280 &mut state,
4281 viewport_start,
4282 content.len().max(1),
4283 visible_count,
4284 );
4285 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
4286 let decorations = SplitRenderer::decoration_context(
4287 &mut state,
4288 viewport_start,
4289 viewport_end,
4290 selection.primary_cursor_position,
4291 &theme,
4292 100_000, );
4294
4295 let output = SplitRenderer::render_view_lines(LineRenderInput {
4296 state: &state,
4297 theme: &theme,
4298 view_lines: &view_data.lines,
4299 view_anchor,
4300 render_area,
4301 gutter_width,
4302 selection: &selection,
4303 decorations: &decorations,
4304 starting_line_num,
4305 visible_line_count: visible_count,
4306 lsp_waiting: false,
4307 is_active: true,
4308 line_wrap: viewport.line_wrap_enabled,
4309 estimated_lines,
4310 left_column: viewport.left_column,
4311 relative_line_numbers: false,
4312 });
4313
4314 (
4315 output,
4316 state.buffer.len(),
4317 content.ends_with('\n'),
4318 selection.primary_cursor_position,
4319 )
4320 }
4321
4322 #[test]
4323 fn last_line_end_tracks_trailing_newline() {
4324 let output = render_output_for("abc\n", 4);
4325 assert_eq!(
4326 output.0.last_line_end,
4327 Some(LastLineEnd {
4328 pos: (3, 0),
4329 terminated_with_newline: true
4330 })
4331 );
4332 }
4333
4334 #[test]
4335 fn last_line_end_tracks_no_trailing_newline() {
4336 let output = render_output_for("abc", 3);
4337 assert_eq!(
4338 output.0.last_line_end,
4339 Some(LastLineEnd {
4340 pos: (3, 0),
4341 terminated_with_newline: false
4342 })
4343 );
4344 }
4345
4346 #[test]
4347 fn cursor_after_newline_places_on_next_line() {
4348 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
4349 let cursor = SplitRenderer::resolve_cursor_fallback(
4350 output.cursor,
4351 cursor_pos,
4352 buffer_len,
4353 buffer_newline,
4354 output.last_line_end,
4355 output.content_lines_rendered,
4356 0, );
4358 assert_eq!(cursor, Some((0, 1)));
4359 }
4360
4361 #[test]
4362 fn cursor_at_end_without_newline_stays_on_line() {
4363 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
4364 let cursor = SplitRenderer::resolve_cursor_fallback(
4365 output.cursor,
4366 cursor_pos,
4367 buffer_len,
4368 buffer_newline,
4369 output.last_line_end,
4370 output.content_lines_rendered,
4371 0, );
4373 assert_eq!(cursor, Some((3, 0)));
4374 }
4375
4376 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
4382 let mut cursor_positions = Vec::new();
4383
4384 let primary_cursor = output.cursor;
4386 if let Some(cursor_pos) = primary_cursor {
4387 cursor_positions.push(cursor_pos);
4388 }
4389
4390 for (line_idx, line) in output.lines.iter().enumerate() {
4392 let mut col = 0u16;
4393 for span in line.spans.iter() {
4394 if span
4396 .style
4397 .add_modifier
4398 .contains(ratatui::style::Modifier::REVERSED)
4399 {
4400 let pos = (col, line_idx as u16);
4401 if primary_cursor != Some(pos) {
4404 cursor_positions.push(pos);
4405 }
4406 }
4407 col += str_width(&span.content) as u16;
4409 }
4410 }
4411
4412 cursor_positions
4413 }
4414
4415 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
4417 eprintln!("\n=== RENDER DEBUG ===");
4418 eprintln!("Content: {:?}", content);
4419 eprintln!("Cursor position: {}", cursor_pos);
4420 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
4421 eprintln!("Last line end: {:?}", output.last_line_end);
4422 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
4423 eprintln!("\nRendered lines:");
4424 for (line_idx, line) in output.lines.iter().enumerate() {
4425 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
4426 for (span_idx, span) in line.spans.iter().enumerate() {
4427 let has_reversed = span
4428 .style
4429 .add_modifier
4430 .contains(ratatui::style::Modifier::REVERSED);
4431 let bg_color = format!("{:?}", span.style.bg);
4432 eprintln!(
4433 " Span {}: {:?} (REVERSED: {}, BG: {})",
4434 span_idx, span.content, has_reversed, bg_color
4435 );
4436 }
4437 }
4438 eprintln!("===================\n");
4439 }
4440
4441 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
4444 let (output, buffer_len, buffer_newline, cursor_pos) =
4445 render_output_for(content, cursor_pos);
4446
4447 let all_cursors = count_all_cursors(&output);
4449
4450 assert!(
4453 all_cursors.len() <= 1,
4454 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
4455 all_cursors.len(),
4456 all_cursors
4457 );
4458
4459 let final_cursor = SplitRenderer::resolve_cursor_fallback(
4460 output.cursor,
4461 cursor_pos,
4462 buffer_len,
4463 buffer_newline,
4464 output.last_line_end,
4465 output.content_lines_rendered,
4466 0, );
4468
4469 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
4471 {
4472 dump_render_output(content, cursor_pos, &output);
4473 }
4474
4475 if let Some(rendered_cursor) = all_cursors.first() {
4477 assert_eq!(
4478 Some(*rendered_cursor),
4479 final_cursor,
4480 "Rendered cursor at {:?} doesn't match final cursor {:?}",
4481 rendered_cursor,
4482 final_cursor
4483 );
4484 }
4485
4486 assert!(
4488 final_cursor.is_some(),
4489 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
4490 all_cursors
4491 );
4492
4493 final_cursor
4494 }
4495
4496 fn check_typing_at_cursor(
4498 content: &str,
4499 cursor_pos: usize,
4500 char_to_type: char,
4501 ) -> (Option<(u16, u16)>, String) {
4502 let cursor_before = get_final_cursor(content, cursor_pos);
4504
4505 let mut new_content = content.to_string();
4507 if cursor_pos <= content.len() {
4508 new_content.insert(cursor_pos, char_to_type);
4509 }
4510
4511 (cursor_before, new_content)
4512 }
4513
4514 #[test]
4515 fn e2e_cursor_at_start_of_nonempty_line() {
4516 let cursor = get_final_cursor("abc", 0);
4518 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
4519
4520 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
4521 assert_eq!(
4522 new_content, "Xabc",
4523 "Typing should insert at cursor position"
4524 );
4525 assert_eq!(cursor_pos, Some((0, 0)));
4526 }
4527
4528 #[test]
4529 fn e2e_cursor_in_middle_of_line() {
4530 let cursor = get_final_cursor("abc", 1);
4532 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
4533
4534 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
4535 assert_eq!(
4536 new_content, "aXbc",
4537 "Typing should insert at cursor position"
4538 );
4539 assert_eq!(cursor_pos, Some((1, 0)));
4540 }
4541
4542 #[test]
4543 fn e2e_cursor_at_end_of_line_no_newline() {
4544 let cursor = get_final_cursor("abc", 3);
4546 assert_eq!(
4547 cursor,
4548 Some((3, 0)),
4549 "Cursor should be at column 3, line 0 (after last char)"
4550 );
4551
4552 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
4553 assert_eq!(new_content, "abcX", "Typing should append at end");
4554 assert_eq!(cursor_pos, Some((3, 0)));
4555 }
4556
4557 #[test]
4558 fn e2e_cursor_at_empty_line() {
4559 let cursor = get_final_cursor("\n", 0);
4561 assert_eq!(
4562 cursor,
4563 Some((0, 0)),
4564 "Cursor on empty line should be at column 0"
4565 );
4566
4567 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
4568 assert_eq!(new_content, "X\n", "Typing should insert before newline");
4569 assert_eq!(cursor_pos, Some((0, 0)));
4570 }
4571
4572 #[test]
4573 fn e2e_cursor_after_newline_at_eof() {
4574 let cursor = get_final_cursor("abc\n", 4);
4576 assert_eq!(
4577 cursor,
4578 Some((0, 1)),
4579 "Cursor after newline at EOF should be on next line"
4580 );
4581
4582 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
4583 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
4584 assert_eq!(cursor_pos, Some((0, 1)));
4585 }
4586
4587 #[test]
4588 fn e2e_cursor_on_newline_with_content() {
4589 let cursor = get_final_cursor("abc\n", 3);
4591 assert_eq!(
4592 cursor,
4593 Some((3, 0)),
4594 "Cursor on newline after content should be after last char"
4595 );
4596
4597 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
4598 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
4599 assert_eq!(cursor_pos, Some((3, 0)));
4600 }
4601
4602 #[test]
4603 fn e2e_cursor_multiline_start_of_second_line() {
4604 let cursor = get_final_cursor("abc\ndef", 4);
4606 assert_eq!(
4607 cursor,
4608 Some((0, 1)),
4609 "Cursor at start of second line should be at column 0, line 1"
4610 );
4611
4612 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
4613 assert_eq!(
4614 new_content, "abc\nXdef",
4615 "Typing should insert at start of second line"
4616 );
4617 assert_eq!(cursor_pos, Some((0, 1)));
4618 }
4619
4620 #[test]
4621 fn e2e_cursor_multiline_end_of_first_line() {
4622 let cursor = get_final_cursor("abc\ndef", 3);
4624 assert_eq!(
4625 cursor,
4626 Some((3, 0)),
4627 "Cursor on newline of first line should be after content"
4628 );
4629
4630 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
4631 assert_eq!(
4632 new_content, "abcX\ndef",
4633 "Typing should insert before newline"
4634 );
4635 assert_eq!(cursor_pos, Some((3, 0)));
4636 }
4637
4638 #[test]
4639 fn e2e_cursor_empty_buffer() {
4640 let cursor = get_final_cursor("", 0);
4642 assert_eq!(
4643 cursor,
4644 Some((0, 0)),
4645 "Cursor in empty buffer should be at origin"
4646 );
4647
4648 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
4649 assert_eq!(
4650 new_content, "X",
4651 "Typing in empty buffer should insert character"
4652 );
4653 assert_eq!(cursor_pos, Some((0, 0)));
4654 }
4655
4656 #[test]
4657 fn e2e_cursor_empty_buffer_with_gutters() {
4658 let (output, buffer_len, buffer_newline, cursor_pos) =
4662 render_output_for_with_gutters("", 0, true);
4663
4664 let gutter_width = {
4668 let mut state = EditorState::new(20, 6, 1024, test_fs());
4669 state.margins.left_config.enabled = true;
4670 state.margins.update_width_for_buffer(1);
4671 state.margins.left_total_width()
4672 };
4673 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
4674
4675 assert_eq!(
4679 output.cursor,
4680 Some((gutter_width as u16, 0)),
4681 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
4682 gutter_width,
4683 output.cursor
4684 );
4685
4686 let final_cursor = SplitRenderer::resolve_cursor_fallback(
4687 output.cursor,
4688 cursor_pos,
4689 buffer_len,
4690 buffer_newline,
4691 output.last_line_end,
4692 output.content_lines_rendered,
4693 gutter_width,
4694 );
4695
4696 assert_eq!(
4698 final_cursor,
4699 Some((gutter_width as u16, 0)),
4700 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
4701 );
4702 }
4703
4704 #[test]
4705 fn e2e_cursor_between_empty_lines() {
4706 let cursor = get_final_cursor("\n\n", 1);
4708 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
4709
4710 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
4711 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
4712 assert_eq!(cursor_pos, Some((0, 1)));
4713 }
4714
4715 #[test]
4716 fn e2e_cursor_at_eof_after_multiple_lines() {
4717 let cursor = get_final_cursor("abc\ndef\nghi", 11);
4719 assert_eq!(
4720 cursor,
4721 Some((3, 2)),
4722 "Cursor at EOF after 'i' should be at column 3, line 2"
4723 );
4724
4725 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
4726 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
4727 assert_eq!(cursor_pos, Some((3, 2)));
4728 }
4729
4730 #[test]
4731 fn e2e_cursor_at_eof_with_trailing_newline() {
4732 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
4734 assert_eq!(
4735 cursor,
4736 Some((0, 3)),
4737 "Cursor after trailing newline should be on line 3"
4738 );
4739
4740 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
4741 assert_eq!(
4742 new_content, "abc\ndef\nghi\nX",
4743 "Typing should insert on new line"
4744 );
4745 assert_eq!(cursor_pos, Some((0, 3)));
4746 }
4747
4748 #[test]
4749 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
4750 let content = "abc\ndef\nghi";
4752
4753 let cursor_at_start = get_final_cursor(content, 0);
4755 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
4756
4757 let cursor_at_eof = get_final_cursor(content, 11);
4759 assert_eq!(
4760 cursor_at_eof,
4761 Some((3, 2)),
4762 "After Ctrl+End, cursor at column 3, line 2"
4763 );
4764
4765 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
4767 assert_eq!(cursor_before_typing, Some((3, 2)));
4768 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
4769
4770 let cursor_after_typing = get_final_cursor(&new_content, 12);
4772 assert_eq!(
4773 cursor_after_typing,
4774 Some((4, 2)),
4775 "After typing, cursor moved to column 4"
4776 );
4777
4778 let cursor_moved_away = get_final_cursor(&new_content, 0);
4780 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
4781 }
4784
4785 #[test]
4786 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
4787 let content = "abc\ndef\nghi\n";
4789
4790 let cursor_at_start = get_final_cursor(content, 0);
4792 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
4793
4794 let cursor_at_eof = get_final_cursor(content, 12);
4796 assert_eq!(
4797 cursor_at_eof,
4798 Some((0, 3)),
4799 "After Ctrl+End, cursor at column 0, line 3 (new line)"
4800 );
4801
4802 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
4804 assert_eq!(cursor_before_typing, Some((0, 3)));
4805 assert_eq!(
4806 new_content, "abc\ndef\nghi\nX",
4807 "Character inserted on new line"
4808 );
4809
4810 let cursor_after_typing = get_final_cursor(&new_content, 13);
4812 assert_eq!(
4813 cursor_after_typing,
4814 Some((1, 3)),
4815 "After typing, cursor should be at column 1, line 3"
4816 );
4817
4818 let cursor_moved_away = get_final_cursor(&new_content, 4);
4820 assert_eq!(
4821 cursor_moved_away,
4822 Some((0, 1)),
4823 "Cursor moved to start of line 1 (position 4 = start of 'def')"
4824 );
4825 }
4826
4827 #[test]
4828 fn e2e_jump_to_end_of_empty_buffer() {
4829 let content = "";
4831
4832 let cursor_at_eof = get_final_cursor(content, 0);
4833 assert_eq!(
4834 cursor_at_eof,
4835 Some((0, 0)),
4836 "Empty buffer: cursor at origin"
4837 );
4838
4839 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
4841 assert_eq!(cursor_before_typing, Some((0, 0)));
4842 assert_eq!(new_content, "X", "Character inserted");
4843
4844 let cursor_after_typing = get_final_cursor(&new_content, 1);
4846 assert_eq!(
4847 cursor_after_typing,
4848 Some((1, 0)),
4849 "After typing, cursor at column 1"
4850 );
4851
4852 let cursor_moved_away = get_final_cursor(&new_content, 0);
4854 assert_eq!(
4855 cursor_moved_away,
4856 Some((0, 0)),
4857 "Cursor moved back to start"
4858 );
4859 }
4860
4861 #[test]
4862 fn e2e_jump_to_end_of_single_empty_line() {
4863 let content = "\n";
4865
4866 let cursor_on_newline = get_final_cursor(content, 0);
4868 assert_eq!(
4869 cursor_on_newline,
4870 Some((0, 0)),
4871 "Cursor on the newline character"
4872 );
4873
4874 let cursor_at_eof = get_final_cursor(content, 1);
4876 assert_eq!(
4877 cursor_at_eof,
4878 Some((0, 1)),
4879 "After Ctrl+End, cursor on line 1"
4880 );
4881
4882 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
4884 assert_eq!(cursor_before_typing, Some((0, 1)));
4885 assert_eq!(new_content, "\nX", "Character on second line");
4886
4887 let cursor_after_typing = get_final_cursor(&new_content, 2);
4888 assert_eq!(
4889 cursor_after_typing,
4890 Some((1, 1)),
4891 "After typing, cursor at column 1, line 1"
4892 );
4893
4894 let cursor_moved_away = get_final_cursor(&new_content, 0);
4896 assert_eq!(
4897 cursor_moved_away,
4898 Some((0, 0)),
4899 "Cursor moved to the newline on line 0"
4900 );
4901 }
4902 use crate::model::buffer::LineEnding;
4913 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4914
4915 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
4917 tokens
4918 .iter()
4919 .map(|t| {
4920 let kind_str = match &t.kind {
4921 ViewTokenWireKind::Text(s) => format!("Text({})", s),
4922 ViewTokenWireKind::Newline => "Newline".to_string(),
4923 ViewTokenWireKind::Space => "Space".to_string(),
4924 ViewTokenWireKind::Break => "Break".to_string(),
4925 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
4926 };
4927 (kind_str, t.source_offset)
4928 })
4929 .collect()
4930 }
4931
4932 #[test]
4935 fn test_build_base_tokens_crlf_single_line() {
4936 let content = b"abc\r\n";
4938 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
4939 buffer.set_line_ending(LineEnding::CRLF);
4940
4941 let tokens = SplitRenderer::build_base_tokens_for_hook(
4942 &mut buffer,
4943 0, 80, 10, false, LineEnding::CRLF,
4948 );
4949
4950 let offsets = extract_token_offsets(&tokens);
4951
4952 assert!(
4955 offsets
4956 .iter()
4957 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
4958 "Expected Text(abc) at offset 0, got: {:?}",
4959 offsets
4960 );
4961 assert!(
4962 offsets
4963 .iter()
4964 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
4965 "Expected Newline at offset 3 (\\r position), got: {:?}",
4966 offsets
4967 );
4968
4969 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
4971 assert_eq!(
4972 newline_count, 1,
4973 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
4974 newline_count, offsets
4975 );
4976 }
4977
4978 #[test]
4981 fn test_build_base_tokens_crlf_multiple_lines() {
4982 let content = b"abc\r\ndef\r\nghi\r\n";
4987 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
4988 buffer.set_line_ending(LineEnding::CRLF);
4989
4990 let tokens = SplitRenderer::build_base_tokens_for_hook(
4991 &mut buffer,
4992 0,
4993 80,
4994 10,
4995 false,
4996 LineEnding::CRLF,
4997 );
4998
4999 let offsets = extract_token_offsets(&tokens);
5000
5001 assert!(
5008 offsets
5009 .iter()
5010 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
5011 "Line 1: Expected Text(abc) at 0, got: {:?}",
5012 offsets
5013 );
5014 assert!(
5015 offsets
5016 .iter()
5017 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
5018 "Line 1: Expected Newline at 3, got: {:?}",
5019 offsets
5020 );
5021
5022 assert!(
5024 offsets
5025 .iter()
5026 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
5027 "Line 2: Expected Text(def) at 5, got: {:?}",
5028 offsets
5029 );
5030 assert!(
5031 offsets
5032 .iter()
5033 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
5034 "Line 2: Expected Newline at 8, got: {:?}",
5035 offsets
5036 );
5037
5038 assert!(
5040 offsets
5041 .iter()
5042 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
5043 "Line 3: Expected Text(ghi) at 10, got: {:?}",
5044 offsets
5045 );
5046 assert!(
5047 offsets
5048 .iter()
5049 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
5050 "Line 3: Expected Newline at 13, got: {:?}",
5051 offsets
5052 );
5053
5054 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
5056 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
5057 }
5058
5059 #[test]
5062 fn test_build_base_tokens_lf_mode_for_comparison() {
5063 let content = b"abc\ndef\n";
5067 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
5068 buffer.set_line_ending(LineEnding::LF);
5069
5070 let tokens = SplitRenderer::build_base_tokens_for_hook(
5071 &mut buffer,
5072 0,
5073 80,
5074 10,
5075 false,
5076 LineEnding::LF,
5077 );
5078
5079 let offsets = extract_token_offsets(&tokens);
5080
5081 assert!(
5083 offsets
5084 .iter()
5085 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
5086 "LF Line 1: Expected Text(abc) at 0"
5087 );
5088 assert!(
5089 offsets
5090 .iter()
5091 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
5092 "LF Line 1: Expected Newline at 3"
5093 );
5094 assert!(
5095 offsets
5096 .iter()
5097 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
5098 "LF Line 2: Expected Text(def) at 4"
5099 );
5100 assert!(
5101 offsets
5102 .iter()
5103 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
5104 "LF Line 2: Expected Newline at 7"
5105 );
5106 }
5107
5108 #[test]
5111 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
5112 let content = b"abc\r\n";
5114 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
5115 buffer.set_line_ending(LineEnding::LF); let tokens = SplitRenderer::build_base_tokens_for_hook(
5118 &mut buffer,
5119 0,
5120 80,
5121 10,
5122 false,
5123 LineEnding::LF,
5124 );
5125
5126 let offsets = extract_token_offsets(&tokens);
5127
5128 assert!(
5130 offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
5131 "LF mode should render \\r as control char <0D>, got: {:?}",
5132 offsets
5133 );
5134 }
5135
5136 #[test]
5139 fn test_build_base_tokens_crlf_from_middle() {
5140 let content = b"abc\r\ndef\r\nghi\r\n";
5143 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
5144 buffer.set_line_ending(LineEnding::CRLF);
5145
5146 let tokens = SplitRenderer::build_base_tokens_for_hook(
5147 &mut buffer,
5148 5, 80,
5150 10,
5151 false,
5152 LineEnding::CRLF,
5153 );
5154
5155 let offsets = extract_token_offsets(&tokens);
5156
5157 assert!(
5161 offsets
5162 .iter()
5163 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
5164 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
5165 offsets
5166 );
5167 assert!(
5168 offsets
5169 .iter()
5170 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
5171 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
5172 offsets
5173 );
5174 }
5175
5176 #[test]
5179 fn test_crlf_highlight_span_lookup() {
5180 use crate::view::ui::view_pipeline::ViewLineIterator;
5181
5182 let content = b"int x;\r\nint y;\r\n";
5187 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
5188 buffer.set_line_ending(LineEnding::CRLF);
5189
5190 let tokens = SplitRenderer::build_base_tokens_for_hook(
5192 &mut buffer,
5193 0,
5194 80,
5195 10,
5196 false,
5197 LineEnding::CRLF,
5198 );
5199
5200 let offsets = extract_token_offsets(&tokens);
5202 eprintln!("Tokens: {:?}", offsets);
5203
5204 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
5206 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
5207
5208 eprintln!(
5211 "Line 1 char_source_bytes: {:?}",
5212 view_lines[0].char_source_bytes
5213 );
5214 assert_eq!(
5215 view_lines[0].char_source_bytes.len(),
5216 7,
5217 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
5218 );
5219 assert_eq!(
5221 view_lines[0].char_source_bytes[0],
5222 Some(0),
5223 "Line 1 'i' -> byte 0"
5224 );
5225 assert_eq!(
5226 view_lines[0].char_source_bytes[4],
5227 Some(4),
5228 "Line 1 'x' -> byte 4"
5229 );
5230 assert_eq!(
5231 view_lines[0].char_source_bytes[5],
5232 Some(5),
5233 "Line 1 ';' -> byte 5"
5234 );
5235 assert_eq!(
5236 view_lines[0].char_source_bytes[6],
5237 Some(6),
5238 "Line 1 newline -> byte 6 (\\r pos)"
5239 );
5240
5241 eprintln!(
5243 "Line 2 char_source_bytes: {:?}",
5244 view_lines[1].char_source_bytes
5245 );
5246 assert_eq!(
5247 view_lines[1].char_source_bytes.len(),
5248 7,
5249 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
5250 );
5251 assert_eq!(
5253 view_lines[1].char_source_bytes[0],
5254 Some(8),
5255 "Line 2 'i' -> byte 8"
5256 );
5257 assert_eq!(
5258 view_lines[1].char_source_bytes[4],
5259 Some(12),
5260 "Line 2 'y' -> byte 12"
5261 );
5262 assert_eq!(
5263 view_lines[1].char_source_bytes[5],
5264 Some(13),
5265 "Line 2 ';' -> byte 13"
5266 );
5267 assert_eq!(
5268 view_lines[1].char_source_bytes[6],
5269 Some(14),
5270 "Line 2 newline -> byte 14 (\\r pos)"
5271 );
5272
5273 let simulated_highlight_spans = [
5277 (0usize..3usize, "keyword"),
5279 (8usize..11usize, "keyword"),
5281 ];
5282
5283 for (line_idx, view_line) in view_lines.iter().enumerate() {
5285 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
5286 if let Some(bp) = byte_pos {
5287 let in_span = simulated_highlight_spans
5288 .iter()
5289 .find(|(range, _)| range.contains(bp))
5290 .map(|(_, name)| *name);
5291
5292 let expected_in_keyword = char_idx < 3;
5294 let actually_in_keyword = in_span == Some("keyword");
5295
5296 if expected_in_keyword != actually_in_keyword {
5297 panic!(
5298 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
5299 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
5300 );
5301 }
5302 }
5303 }
5304 }
5305 }
5306
5307 #[test]
5310 fn test_apply_wrapping_transform_breaks_long_lines() {
5311 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5312
5313 let long_text = "x".repeat(25_000);
5315 let tokens = vec![
5316 ViewTokenWire {
5317 kind: ViewTokenWireKind::Text(long_text),
5318 source_offset: Some(0),
5319 style: None,
5320 },
5321 ViewTokenWire {
5322 kind: ViewTokenWireKind::Newline,
5323 source_offset: Some(25_000),
5324 style: None,
5325 },
5326 ];
5327
5328 let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5330
5331 let break_count = wrapped
5333 .iter()
5334 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
5335 .count();
5336
5337 assert!(
5338 break_count >= 2,
5339 "25K char line should have at least 2 breaks at 10K width, got {}",
5340 break_count
5341 );
5342
5343 let total_chars: usize = wrapped
5345 .iter()
5346 .filter_map(|t| match &t.kind {
5347 ViewTokenWireKind::Text(s) => Some(s.len()),
5348 _ => None,
5349 })
5350 .sum();
5351
5352 assert_eq!(
5353 total_chars, 25_000,
5354 "Total character count should be preserved after wrapping"
5355 );
5356 }
5357
5358 #[test]
5360 fn test_apply_wrapping_transform_preserves_short_lines() {
5361 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5362
5363 let short_text = "x".repeat(100);
5365 let tokens = vec![
5366 ViewTokenWire {
5367 kind: ViewTokenWireKind::Text(short_text.clone()),
5368 source_offset: Some(0),
5369 style: None,
5370 },
5371 ViewTokenWire {
5372 kind: ViewTokenWireKind::Newline,
5373 source_offset: Some(100),
5374 style: None,
5375 },
5376 ];
5377
5378 let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5380
5381 let break_count = wrapped
5383 .iter()
5384 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
5385 .count();
5386
5387 assert_eq!(
5388 break_count, 0,
5389 "Short lines should not have any breaks, got {}",
5390 break_count
5391 );
5392
5393 let text_tokens: Vec<_> = wrapped
5395 .iter()
5396 .filter_map(|t| match &t.kind {
5397 ViewTokenWireKind::Text(s) => Some(s.clone()),
5398 _ => None,
5399 })
5400 .collect();
5401
5402 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
5403 assert_eq!(
5404 text_tokens[0], short_text,
5405 "Text content should be unchanged"
5406 );
5407 }
5408
5409 #[test]
5412 fn test_large_single_line_sequential_data_preserved() {
5413 use crate::view::ui::view_pipeline::ViewLineIterator;
5414 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5415
5416 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
5420
5421 let tokens = vec![
5423 ViewTokenWire {
5424 kind: ViewTokenWireKind::Text(content.clone()),
5425 source_offset: Some(0),
5426 style: None,
5427 },
5428 ViewTokenWire {
5429 kind: ViewTokenWireKind::Newline,
5430 source_offset: Some(content.len()),
5431 style: None,
5432 },
5433 ];
5434
5435 let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5437
5438 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4).collect();
5440
5441 let mut reconstructed = String::new();
5443 for line in &view_lines {
5444 let text = line.text.trim_end_matches('\n');
5446 reconstructed.push_str(text);
5447 }
5448
5449 assert_eq!(
5451 reconstructed.len(),
5452 content.len(),
5453 "Reconstructed content length should match original"
5454 );
5455
5456 for i in 1..=num_markers {
5458 let marker = format!("[{:05}]", i);
5459 assert!(
5460 reconstructed.contains(&marker),
5461 "Missing marker {} after pipeline",
5462 marker
5463 );
5464 }
5465
5466 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
5468 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
5469 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
5470 assert!(
5471 pos_100 < pos_1000 && pos_1000 < pos_3000,
5472 "Markers should be in sequential order: {} < {} < {}",
5473 pos_100,
5474 pos_1000,
5475 pos_3000
5476 );
5477
5478 assert!(
5480 view_lines.len() >= 3,
5481 "35KB content should produce multiple visual lines at 10K width, got {}",
5482 view_lines.len()
5483 );
5484
5485 for (i, line) in view_lines.iter().enumerate() {
5487 assert!(
5488 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
5490 i,
5491 line.text.len()
5492 );
5493 }
5494 }
5495}