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 };
257 tags.push(format!("<{}:{}-{}>", overlay_type, range.start, range.end));
258 self.active_overlays.push(range.clone());
259 }
260 }
261 }
262
263 tags
264 }
265
266 fn get_closing_tags(&mut self, byte_pos: Option<usize>) -> Vec<String> {
268 let mut tags = Vec::new();
269
270 if let Some(bp) = byte_pos {
271 if let Some(ref range) = self.active_highlight {
273 if bp >= range.end {
274 tags.push("</hl>".to_string());
275 self.active_highlight = None;
276 }
277 }
278
279 let mut closed_indices = Vec::new();
281 for (i, range) in self.active_overlays.iter().enumerate() {
282 if bp >= range.end {
283 tags.push("</ov>".to_string());
284 closed_indices.push(i);
285 }
286 }
287 for i in closed_indices.into_iter().rev() {
289 self.active_overlays.remove(i);
290 }
291 }
292
293 tags
294 }
295}
296
297struct ViewData {
299 lines: Vec<ViewLine>,
301}
302
303struct ViewAnchor {
304 start_line_idx: usize,
305 start_line_skip: usize,
306}
307
308struct ComposeLayout {
309 render_area: Rect,
310 left_pad: u16,
311 right_pad: u16,
312}
313
314struct SelectionContext {
315 ranges: Vec<Range<usize>>,
316 block_rects: Vec<(usize, usize, usize, usize)>,
317 cursor_positions: Vec<usize>,
318 primary_cursor_position: usize,
319}
320
321struct DecorationContext {
322 highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
323 semantic_token_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
324 viewport_overlays: Vec<(crate::view::overlay::Overlay, Range<usize>)>,
325 virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>>,
326 diagnostic_lines: HashSet<usize>,
327 line_indicators: BTreeMap<usize, crate::view::margin::LineIndicator>,
329}
330
331struct LineRenderOutput {
332 lines: Vec<Line<'static>>,
333 cursor: Option<(u16, u16)>,
334 last_line_end: Option<LastLineEnd>,
335 content_lines_rendered: usize,
336 view_line_mappings: Vec<ViewLineMapping>,
337}
338
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
340struct LastLineEnd {
341 pos: (u16, u16),
342 terminated_with_newline: bool,
343}
344
345struct SplitLayout {
346 tabs_rect: Rect,
347 content_rect: Rect,
348 scrollbar_rect: Rect,
349}
350
351struct ViewPreferences {
352 view_mode: ViewMode,
353 compose_width: Option<u16>,
354 compose_column_guides: Option<Vec<u16>>,
355 view_transform: Option<ViewTransformPayload>,
356}
357
358struct LineRenderInput<'a> {
359 state: &'a EditorState,
360 theme: &'a crate::view::theme::Theme,
361 view_lines: &'a [ViewLine],
363 view_anchor: ViewAnchor,
364 render_area: Rect,
365 gutter_width: usize,
366 selection: &'a SelectionContext,
367 decorations: &'a DecorationContext,
368 starting_line_num: usize,
369 visible_line_count: usize,
370 lsp_waiting: bool,
371 is_active: bool,
372 line_wrap: bool,
373 estimated_lines: usize,
374 left_column: usize,
376 relative_line_numbers: bool,
378}
379
380struct CharStyleContext<'a> {
382 byte_pos: Option<usize>,
383 token_style: Option<&'a fresh_core::api::ViewTokenStyle>,
384 ansi_style: Style,
385 is_cursor: bool,
386 is_selected: bool,
387 theme: &'a crate::view::theme::Theme,
388 highlight_spans: &'a [crate::primitives::highlighter::HighlightSpan],
389 semantic_token_spans: &'a [crate::primitives::highlighter::HighlightSpan],
390 viewport_overlays: &'a [(crate::view::overlay::Overlay, Range<usize>)],
391 primary_cursor_position: usize,
392 is_active: bool,
393}
394
395struct CharStyleOutput {
397 style: Style,
398 is_secondary_cursor: bool,
399}
400
401struct LeftMarginContext<'a> {
403 state: &'a EditorState,
404 theme: &'a crate::view::theme::Theme,
405 is_continuation: bool,
406 current_source_line_num: usize,
407 estimated_lines: usize,
408 diagnostic_lines: &'a HashSet<usize>,
409 line_indicators: &'a BTreeMap<usize, crate::view::margin::LineIndicator>,
411 cursor_line: usize,
413 relative_line_numbers: bool,
415}
416
417fn render_left_margin(
419 ctx: &LeftMarginContext,
420 line_spans: &mut Vec<Span<'static>>,
421 line_view_map: &mut Vec<Option<usize>>,
422) {
423 if !ctx.state.margins.left_config.enabled {
424 return;
425 }
426
427 if ctx.is_continuation {
429 push_span_with_map(
430 line_spans,
431 line_view_map,
432 " ".to_string(),
433 Style::default(),
434 None,
435 );
436 } else if ctx.diagnostic_lines.contains(&ctx.current_source_line_num) {
437 push_span_with_map(
439 line_spans,
440 line_view_map,
441 "●".to_string(),
442 Style::default().fg(ratatui::style::Color::Red),
443 None,
444 );
445 } else if let Some(indicator) = ctx.line_indicators.get(&ctx.current_source_line_num) {
446 push_span_with_map(
448 line_spans,
449 line_view_map,
450 indicator.symbol.clone(),
451 Style::default().fg(indicator.color),
452 None,
453 );
454 } else {
455 push_span_with_map(
457 line_spans,
458 line_view_map,
459 " ".to_string(),
460 Style::default(),
461 None,
462 );
463 }
464
465 if ctx.is_continuation {
467 let blank = " ".repeat(ctx.state.margins.left_config.width);
469 push_span_with_map(
470 line_spans,
471 line_view_map,
472 blank,
473 Style::default().fg(ctx.theme.line_number_fg),
474 None,
475 );
476 } else if ctx.relative_line_numbers {
477 let display_num = if ctx.current_source_line_num == ctx.cursor_line {
479 ctx.current_source_line_num + 1
481 } else {
482 ctx.current_source_line_num.abs_diff(ctx.cursor_line)
484 };
485 let rendered_text = format!(
486 "{:>width$}",
487 display_num,
488 width = ctx.state.margins.left_config.width
489 );
490 let margin_style = if ctx.current_source_line_num == ctx.cursor_line {
492 Style::default().fg(ctx.theme.editor_fg)
493 } else {
494 Style::default().fg(ctx.theme.line_number_fg)
495 };
496 push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
497 } else {
498 let margin_content = ctx.state.margins.render_line(
499 ctx.current_source_line_num,
500 crate::view::margin::MarginPosition::Left,
501 ctx.estimated_lines,
502 );
503 let (rendered_text, style_opt) = margin_content.render(ctx.state.margins.left_config.width);
504
505 let margin_style =
507 style_opt.unwrap_or_else(|| Style::default().fg(ctx.theme.line_number_fg));
508
509 push_span_with_map(line_spans, line_view_map, rendered_text, margin_style, None);
510 }
511
512 if ctx.state.margins.left_config.show_separator {
514 let separator_style = Style::default().fg(ctx.theme.line_number_fg);
515 push_span_with_map(
516 line_spans,
517 line_view_map,
518 ctx.state.margins.left_config.separator.clone(),
519 separator_style,
520 None,
521 );
522 }
523}
524
525fn compute_char_style(ctx: &CharStyleContext) -> CharStyleOutput {
527 use crate::view::overlay::OverlayFace;
528
529 let highlight_color = ctx.byte_pos.and_then(|bp| {
531 ctx.highlight_spans
532 .iter()
533 .find(|span| span.range.contains(&bp))
534 .map(|span| span.color)
535 });
536
537 let overlays: Vec<&crate::view::overlay::Overlay> = if let Some(bp) = ctx.byte_pos {
539 ctx.viewport_overlays
540 .iter()
541 .filter(|(_, range)| range.contains(&bp))
542 .map(|(overlay, _)| overlay)
543 .collect()
544 } else {
545 Vec::new()
546 };
547
548 let mut style = if let Some(ts) = ctx.token_style {
551 let mut s = Style::default();
552 if let Some((r, g, b)) = ts.fg {
553 s = s.fg(ratatui::style::Color::Rgb(r, g, b));
554 } else {
555 s = s.fg(ctx.theme.editor_fg);
556 }
557 if let Some((r, g, b)) = ts.bg {
558 s = s.bg(ratatui::style::Color::Rgb(r, g, b));
559 }
560 if ts.bold {
561 s = s.add_modifier(Modifier::BOLD);
562 }
563 if ts.italic {
564 s = s.add_modifier(Modifier::ITALIC);
565 }
566 s
567 } else if ctx.ansi_style.fg.is_some()
568 || ctx.ansi_style.bg.is_some()
569 || !ctx.ansi_style.add_modifier.is_empty()
570 {
571 let mut s = Style::default();
573 if let Some(fg) = ctx.ansi_style.fg {
574 s = s.fg(fg);
575 } else {
576 s = s.fg(ctx.theme.editor_fg);
577 }
578 if let Some(bg) = ctx.ansi_style.bg {
579 s = s.bg(bg);
580 }
581 s = s.add_modifier(ctx.ansi_style.add_modifier);
582 s
583 } else if let Some(color) = highlight_color {
584 Style::default().fg(color)
586 } else {
587 Style::default().fg(ctx.theme.editor_fg)
589 };
590
591 if let Some(color) = highlight_color {
594 if ctx.ansi_style.fg.is_none()
595 && (ctx.ansi_style.bg.is_some() || !ctx.ansi_style.add_modifier.is_empty())
596 {
597 style = style.fg(color);
598 }
599 }
600
601 if ctx.token_style.is_none() {
606 if let Some(bp) = ctx.byte_pos {
607 if let Some(token_span) = ctx
608 .semantic_token_spans
609 .iter()
610 .find(|span| span.range.contains(&bp))
611 {
612 style = style.fg(token_span.color);
613 }
614 }
615 }
616
617 for overlay in &overlays {
619 match &overlay.face {
620 OverlayFace::Underline {
621 color,
622 style: _underline_style,
623 } => {
624 style = style.add_modifier(Modifier::UNDERLINED).fg(*color);
625 }
626 OverlayFace::Background { color } => {
627 style = style.bg(*color);
628 }
629 OverlayFace::Foreground { color } => {
630 style = style.fg(*color);
631 }
632 OverlayFace::Style {
633 style: overlay_style,
634 } => {
635 style = style.patch(*overlay_style);
636 }
637 }
638 }
639
640 if ctx.is_selected {
642 style = Style::default()
643 .fg(ctx.theme.editor_fg)
644 .bg(ctx.theme.selection_bg);
645 }
646
647 let is_secondary_cursor = ctx.is_cursor && ctx.byte_pos != Some(ctx.primary_cursor_position);
652 if ctx.is_active {
653 if ctx.is_cursor {
654 style = style.add_modifier(Modifier::REVERSED);
657 }
658 } else if ctx.is_cursor {
659 style = style.fg(ctx.theme.editor_fg).bg(ctx.theme.inactive_cursor);
660 }
661
662 CharStyleOutput {
663 style,
664 is_secondary_cursor,
665 }
666}
667
668pub struct SplitRenderer;
670
671impl SplitRenderer {
672 #[allow(clippy::too_many_arguments)]
691 #[allow(clippy::type_complexity)]
692 pub fn render_content(
693 frame: &mut Frame,
694 area: Rect,
695 split_manager: &SplitManager,
696 buffers: &mut HashMap<BufferId, EditorState>,
697 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
698 event_logs: &mut HashMap<BufferId, EventLog>,
699 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
700 composite_view_states: &mut HashMap<
701 (crate::model::event::SplitId, BufferId),
702 crate::view::composite_view::CompositeViewState,
703 >,
704 theme: &crate::view::theme::Theme,
705 ansi_background: Option<&AnsiBackground>,
706 background_fade: f32,
707 lsp_waiting: bool,
708 large_file_threshold_bytes: u64,
709 _line_wrap: bool,
710 estimated_line_length: usize,
711 highlight_context_bytes: usize,
712 mut split_view_states: Option<
713 &mut HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
714 >,
715 hide_cursor: bool,
716 hovered_tab: Option<(BufferId, crate::model::event::SplitId, bool)>, hovered_close_split: Option<crate::model::event::SplitId>,
718 hovered_maximize_split: Option<crate::model::event::SplitId>,
719 is_maximized: bool,
720 relative_line_numbers: bool,
721 tab_bar_visible: bool,
722 use_terminal_bg: bool,
723 ) -> (
724 Vec<(
725 crate::model::event::SplitId,
726 BufferId,
727 Rect,
728 Rect,
729 usize,
730 usize,
731 )>,
732 Vec<(crate::model::event::SplitId, BufferId, u16, u16, u16, u16)>,
733 Vec<(crate::model::event::SplitId, u16, u16, u16)>, Vec<(crate::model::event::SplitId, u16, u16, u16)>, HashMap<crate::model::event::SplitId, Vec<ViewLineMapping>>, ) {
737 let _span = tracing::trace_span!("render_content").entered();
738
739 let visible_buffers = split_manager.get_visible_buffers(area);
741 let active_split_id = split_manager.active_split();
742 let has_multiple_splits = visible_buffers.len() > 1;
743
744 let mut split_areas = Vec::new();
746 let mut all_tab_areas = Vec::new();
747 let mut close_split_areas = Vec::new();
748 let mut maximize_split_areas = Vec::new();
749 let mut view_line_mappings: HashMap<crate::model::event::SplitId, Vec<ViewLineMapping>> =
750 HashMap::new();
751
752 for (split_id, buffer_id, split_area) in visible_buffers {
754 let is_active = split_id == active_split_id;
755
756 let layout = Self::split_layout(split_area, tab_bar_visible);
757 let (split_buffers, tab_scroll_offset) =
758 Self::split_buffers_for_tabs(split_view_states.as_deref(), split_id, buffer_id);
759
760 let tab_hover_for_split = hovered_tab.and_then(|(hover_buf, hover_split, is_close)| {
762 if hover_split == split_id {
763 Some((hover_buf, is_close))
764 } else {
765 None
766 }
767 });
768
769 if tab_bar_visible {
771 let tab_hit_areas = TabsRenderer::render_for_split(
773 frame,
774 layout.tabs_rect,
775 &split_buffers,
776 buffers,
777 buffer_metadata,
778 composite_buffers,
779 buffer_id, theme,
781 is_active,
782 tab_scroll_offset,
783 tab_hover_for_split,
784 );
785
786 let tab_row = layout.tabs_rect.y;
788 for (buf_id, start_col, end_col, close_start) in tab_hit_areas {
789 all_tab_areas.push((
790 split_id,
791 buf_id,
792 tab_row,
793 start_col,
794 end_col,
795 close_start,
796 ));
797 }
798
799 let show_maximize_btn = has_multiple_splits || is_maximized;
803 let show_close_btn = has_multiple_splits && !is_maximized;
804
805 if show_maximize_btn || show_close_btn {
806 let mut btn_x = layout.tabs_rect.x + layout.tabs_rect.width.saturating_sub(2);
809
810 if show_close_btn {
812 let is_hovered = hovered_close_split == Some(split_id);
813 let close_fg = if is_hovered {
814 theme.tab_close_hover_fg
815 } else {
816 theme.line_number_fg
817 };
818 let close_button = Paragraph::new("×")
819 .style(Style::default().fg(close_fg).bg(theme.tab_separator_bg));
820 let close_area = Rect::new(btn_x, tab_row, 1, 1);
821 frame.render_widget(close_button, close_area);
822 close_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
823 btn_x = btn_x.saturating_sub(2); }
825
826 if show_maximize_btn {
828 let is_hovered = hovered_maximize_split == Some(split_id);
829 let max_fg = if is_hovered {
830 theme.tab_close_hover_fg
831 } else {
832 theme.line_number_fg
833 };
834 let icon = if is_maximized { "⧉" } else { "□" };
836 let max_button = Paragraph::new(icon)
837 .style(Style::default().fg(max_fg).bg(theme.tab_separator_bg));
838 let max_area = Rect::new(btn_x, tab_row, 1, 1);
839 frame.render_widget(max_button, max_area);
840 maximize_split_areas.push((split_id, tab_row, btn_x, btn_x + 1));
841 }
842 }
843 }
844
845 let state_opt = buffers.get_mut(&buffer_id);
847 let event_log_opt = event_logs.get_mut(&buffer_id);
848
849 if let Some(state) = state_opt {
850 if state.is_composite_buffer {
852 if let Some(composite) = composite_buffers.get(&buffer_id) {
853 if let Some(ref mut svs) = split_view_states {
856 if let Some(split_vs) = svs.get_mut(&split_id) {
857 if split_vs.viewport.width != layout.content_rect.width
858 || split_vs.viewport.height != layout.content_rect.height
859 {
860 split_vs.viewport.resize(
861 layout.content_rect.width,
862 layout.content_rect.height,
863 );
864 }
865 }
866 }
867
868 let pane_count = composite.pane_count();
870 let view_state = composite_view_states
871 .entry((split_id, buffer_id))
872 .or_insert_with(|| {
873 crate::view::composite_view::CompositeViewState::new(
874 buffer_id, pane_count,
875 )
876 });
877 Self::render_composite_buffer(
879 frame,
880 layout.content_rect,
881 composite,
882 buffers,
883 theme,
884 is_active,
885 view_state,
886 use_terminal_bg,
887 );
888
889 let total_rows = composite.row_count();
891 let content_height = layout.content_rect.height.saturating_sub(1) as usize; let (thumb_start, thumb_end) = Self::render_composite_scrollbar(
893 frame,
894 layout.scrollbar_rect,
895 total_rows,
896 view_state.scroll_row,
897 content_height,
898 is_active,
899 );
900
901 split_areas.push((
903 split_id,
904 buffer_id,
905 layout.content_rect,
906 layout.scrollbar_rect,
907 thumb_start,
908 thumb_end,
909 ));
910 }
911 view_line_mappings.insert(split_id, Vec::new());
912 continue;
913 }
914
915 let view_state_opt = split_view_states
919 .as_deref()
920 .and_then(|vs| vs.get(&split_id));
921 let viewport_clone =
922 view_state_opt
923 .map(|vs| vs.viewport.clone())
924 .unwrap_or_else(|| {
925 crate::view::viewport::Viewport::new(
926 layout.content_rect.width,
927 layout.content_rect.height,
928 )
929 });
930 let mut viewport = viewport_clone;
931
932 let saved_cursors = Self::temporary_split_state(
933 state,
934 split_view_states.as_deref(),
935 split_id,
936 is_active,
937 );
938 Self::sync_viewport_to_content(
939 &mut viewport,
940 &mut state.buffer,
941 &state.cursors,
942 layout.content_rect,
943 );
944 let view_prefs =
945 Self::resolve_view_preferences(state, split_view_states.as_deref(), split_id);
946
947 let split_view_mappings = Self::render_buffer_in_split(
948 frame,
949 state,
950 &mut viewport,
951 event_log_opt,
952 layout.content_rect,
953 is_active,
954 theme,
955 ansi_background,
956 background_fade,
957 lsp_waiting,
958 view_prefs.view_mode,
959 view_prefs.compose_width,
960 view_prefs.compose_column_guides,
961 view_prefs.view_transform,
962 estimated_line_length,
963 highlight_context_bytes,
964 buffer_id,
965 hide_cursor,
966 relative_line_numbers,
967 use_terminal_bg,
968 );
969
970 view_line_mappings.insert(split_id, split_view_mappings);
972
973 let buffer_len = state.buffer.len();
976 let (total_lines, top_line) = Self::scrollbar_line_counts(
977 state,
978 &viewport,
979 large_file_threshold_bytes,
980 buffer_len,
981 );
982
983 let (thumb_start, thumb_end) = Self::render_scrollbar(
985 frame,
986 state,
987 &viewport,
988 layout.scrollbar_rect,
989 is_active,
990 theme,
991 large_file_threshold_bytes,
992 total_lines,
993 top_line,
994 );
995
996 Self::restore_split_state(state, saved_cursors);
998
999 if let Some(view_states) = split_view_states.as_deref_mut() {
1004 if let Some(view_state) = view_states.get_mut(&split_id) {
1005 tracing::trace!(
1006 "Writing back viewport: top_byte={}, skip_ensure_visible={}",
1007 viewport.top_byte,
1008 viewport.should_skip_ensure_visible()
1009 );
1010 view_state.viewport = viewport.clone();
1011 }
1012 }
1013
1014 split_areas.push((
1016 split_id,
1017 buffer_id,
1018 layout.content_rect,
1019 layout.scrollbar_rect,
1020 thumb_start,
1021 thumb_end,
1022 ));
1023 }
1024 }
1025
1026 let separators = split_manager.get_separators(area);
1028 for (direction, x, y, length) in separators {
1029 Self::render_separator(frame, direction, x, y, length, theme);
1030 }
1031
1032 (
1033 split_areas,
1034 all_tab_areas,
1035 close_split_areas,
1036 maximize_split_areas,
1037 view_line_mappings,
1038 )
1039 }
1040
1041 fn render_separator(
1043 frame: &mut Frame,
1044 direction: SplitDirection,
1045 x: u16,
1046 y: u16,
1047 length: u16,
1048 theme: &crate::view::theme::Theme,
1049 ) {
1050 match direction {
1051 SplitDirection::Horizontal => {
1052 let line_area = Rect::new(x, y, length, 1);
1054 let line_text = "─".repeat(length as usize);
1055 let paragraph =
1056 Paragraph::new(line_text).style(Style::default().fg(theme.split_separator_fg));
1057 frame.render_widget(paragraph, line_area);
1058 }
1059 SplitDirection::Vertical => {
1060 for offset in 0..length {
1062 let cell_area = Rect::new(x, y + offset, 1, 1);
1063 let paragraph =
1064 Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
1065 frame.render_widget(paragraph, cell_area);
1066 }
1067 }
1068 }
1069 }
1070
1071 fn render_composite_buffer(
1074 frame: &mut Frame,
1075 area: Rect,
1076 composite: &crate::model::composite_buffer::CompositeBuffer,
1077 buffers: &mut HashMap<BufferId, EditorState>,
1078 theme: &crate::view::theme::Theme,
1079 _is_active: bool,
1080 view_state: &mut crate::view::composite_view::CompositeViewState,
1081 use_terminal_bg: bool,
1082 ) {
1083 use crate::model::composite_buffer::{CompositeLayout, RowType};
1084
1085 let effective_editor_bg = if use_terminal_bg {
1087 ratatui::style::Color::Reset
1088 } else {
1089 theme.editor_bg
1090 };
1091
1092 let scroll_row = view_state.scroll_row;
1093 let cursor_row = view_state.cursor_row;
1094
1095 frame.render_widget(Clear, area);
1097
1098 let pane_count = composite.sources.len();
1100 if pane_count == 0 {
1101 return;
1102 }
1103
1104 let show_separator = match &composite.layout {
1106 CompositeLayout::SideBySide { show_separator, .. } => *show_separator,
1107 _ => false,
1108 };
1109
1110 let separator_width = if show_separator { 1 } else { 0 };
1112 let total_separators = (pane_count.saturating_sub(1)) as u16 * separator_width;
1113 let available_width = area.width.saturating_sub(total_separators);
1114
1115 let pane_widths: Vec<u16> = match &composite.layout {
1116 CompositeLayout::SideBySide { ratios, .. } => {
1117 let default_ratio = 1.0 / pane_count as f32;
1118 ratios
1119 .iter()
1120 .chain(std::iter::repeat(&default_ratio))
1121 .take(pane_count)
1122 .map(|r| (available_width as f32 * r).round() as u16)
1123 .collect()
1124 }
1125 _ => {
1126 let pane_width = available_width / pane_count as u16;
1128 vec![pane_width; pane_count]
1129 }
1130 };
1131
1132 view_state.pane_widths = pane_widths.clone();
1134
1135 let header_height = 1u16;
1137 let mut x_offset = area.x;
1138 for (idx, (source, &width)) in composite.sources.iter().zip(&pane_widths).enumerate() {
1139 let header_area = Rect::new(x_offset, area.y, width, header_height);
1140 let is_focused = idx == view_state.focused_pane;
1141
1142 let header_style = if is_focused {
1143 Style::default()
1144 .fg(theme.tab_active_fg)
1145 .bg(theme.tab_active_bg)
1146 } else {
1147 Style::default()
1148 .fg(theme.tab_inactive_fg)
1149 .bg(theme.tab_inactive_bg)
1150 };
1151
1152 let header_text = format!(" {} ", source.label);
1153 let header = Paragraph::new(header_text).style(header_style);
1154 frame.render_widget(header, header_area);
1155
1156 x_offset += width + separator_width;
1157 }
1158
1159 let content_y = area.y + header_height;
1161 let content_height = area.height.saturating_sub(header_height);
1162 let visible_rows = content_height as usize;
1163
1164 let alignment = &composite.alignment;
1166 let total_rows = alignment.rows.len();
1167
1168 struct PaneRenderData {
1171 lines: Vec<ViewLine>,
1172 line_to_view_line: HashMap<usize, usize>,
1173 highlight_spans: Vec<crate::primitives::highlighter::HighlightSpan>,
1174 }
1175
1176 let mut pane_render_data: Vec<Option<PaneRenderData>> = Vec::new();
1177
1178 for (pane_idx, source) in composite.sources.iter().enumerate() {
1179 if let Some(source_state) = buffers.get_mut(&source.buffer_id) {
1180 let visible_lines: Vec<usize> = alignment
1182 .rows
1183 .iter()
1184 .skip(scroll_row)
1185 .take(visible_rows)
1186 .filter_map(|row| row.get_pane_line(pane_idx))
1187 .map(|r| r.line)
1188 .collect();
1189
1190 let first_line = visible_lines.iter().copied().min();
1191 let last_line = visible_lines.iter().copied().max();
1192
1193 if let (Some(first_line), Some(last_line)) = (first_line, last_line) {
1194 let top_byte = source_state
1196 .buffer
1197 .line_start_offset(first_line)
1198 .unwrap_or(0);
1199 let end_byte = source_state
1200 .buffer
1201 .line_start_offset(last_line + 1)
1202 .unwrap_or(source_state.buffer.len());
1203
1204 let highlight_spans = source_state.highlighter.highlight_viewport(
1206 &source_state.buffer,
1207 top_byte,
1208 end_byte,
1209 theme,
1210 1024, );
1212
1213 let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80);
1215 let mut viewport =
1216 crate::view::viewport::Viewport::new(pane_width, content_height);
1217 viewport.top_byte = top_byte;
1218 viewport.line_wrap_enabled = false;
1219
1220 let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80) as usize;
1221 let gutter_width = 4; let content_width = pane_width.saturating_sub(gutter_width);
1223
1224 let lines_needed = last_line - first_line + 10;
1227 let view_data = Self::build_view_data(
1228 source_state,
1229 &viewport,
1230 None, 80, lines_needed, false, content_width,
1235 gutter_width,
1236 );
1237
1238 let mut line_to_view_line: HashMap<usize, usize> = HashMap::new();
1240 let mut current_line = first_line;
1241 for (idx, view_line) in view_data.lines.iter().enumerate() {
1242 if should_show_line_number(view_line) {
1243 line_to_view_line.insert(current_line, idx);
1244 current_line += 1;
1245 }
1246 }
1247
1248 pane_render_data.push(Some(PaneRenderData {
1249 lines: view_data.lines,
1250 line_to_view_line,
1251 highlight_spans,
1252 }));
1253 } else {
1254 pane_render_data.push(None);
1255 }
1256 } else {
1257 pane_render_data.push(None);
1258 }
1259 }
1260
1261 for view_row in 0..visible_rows {
1263 let display_row = scroll_row + view_row;
1264 if display_row >= total_rows {
1265 let mut x = area.x;
1267 for &width in &pane_widths {
1268 let tilde_area = Rect::new(x, content_y + view_row as u16, width, 1);
1269 let tilde =
1270 Paragraph::new("~").style(Style::default().fg(theme.line_number_fg));
1271 frame.render_widget(tilde, tilde_area);
1272 x += width + separator_width;
1273 }
1274 continue;
1275 }
1276
1277 let aligned_row = &alignment.rows[display_row];
1278 let is_cursor_row = display_row == cursor_row;
1279 let selection_cols = view_state.selection_column_range(display_row);
1281
1282 let row_bg = match aligned_row.row_type {
1284 RowType::Addition => Some(theme.diff_add_bg),
1285 RowType::Deletion => Some(theme.diff_remove_bg),
1286 RowType::Modification => Some(theme.diff_modify_bg),
1287 RowType::HunkHeader => Some(theme.current_line_bg),
1288 RowType::Context => None,
1289 };
1290
1291 let inline_diffs: Vec<Vec<Range<usize>>> = if aligned_row.row_type
1293 == RowType::Modification
1294 {
1295 let mut line_contents: Vec<Option<String>> = Vec::new();
1297 for (pane_idx, source) in composite.sources.iter().enumerate() {
1298 if let Some(line_ref) = aligned_row.get_pane_line(pane_idx) {
1299 if let Some(source_state) = buffers.get(&source.buffer_id) {
1300 line_contents.push(
1301 source_state
1302 .buffer
1303 .get_line(line_ref.line)
1304 .map(|line| String::from_utf8_lossy(&line).to_string()),
1305 );
1306 } else {
1307 line_contents.push(None);
1308 }
1309 } else {
1310 line_contents.push(None);
1311 }
1312 }
1313
1314 if line_contents.len() >= 2 {
1316 if let (Some(old_text), Some(new_text)) = (&line_contents[0], &line_contents[1])
1317 {
1318 let (old_ranges, new_ranges) = compute_inline_diff(old_text, new_text);
1319 vec![old_ranges, new_ranges]
1320 } else {
1321 vec![Vec::new(); composite.sources.len()]
1322 }
1323 } else {
1324 vec![Vec::new(); composite.sources.len()]
1325 }
1326 } else {
1327 vec![Vec::new(); composite.sources.len()]
1329 };
1330
1331 let mut x_offset = area.x;
1333 for (pane_idx, (_source, &width)) in
1334 composite.sources.iter().zip(&pane_widths).enumerate()
1335 {
1336 let pane_area = Rect::new(x_offset, content_y + view_row as u16, width, 1);
1337
1338 let left_column = view_state
1340 .get_pane_viewport(pane_idx)
1341 .map(|v| v.left_column)
1342 .unwrap_or(0);
1343
1344 let source_line_opt = aligned_row.get_pane_line(pane_idx);
1346
1347 if let Some(source_line_ref) = source_line_opt {
1348 let pane_data = pane_render_data.get(pane_idx).and_then(|opt| opt.as_ref());
1350 let view_line_opt = pane_data.and_then(|data| {
1351 data.line_to_view_line
1352 .get(&source_line_ref.line)
1353 .and_then(|&idx| data.lines.get(idx))
1354 });
1355 let highlight_spans = pane_data
1356 .map(|data| data.highlight_spans.as_slice())
1357 .unwrap_or(&[]);
1358
1359 let gutter_width = 4usize;
1360 let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
1361
1362 let is_focused_pane = pane_idx == view_state.focused_pane;
1363
1364 let bg = if is_cursor_row && is_focused_pane {
1367 theme.current_line_bg
1368 } else {
1369 row_bg.unwrap_or(effective_editor_bg)
1370 };
1371
1372 let pane_selection_cols = if is_focused_pane {
1374 selection_cols
1375 } else {
1376 None
1377 };
1378
1379 let line_num = format!("{:>3} ", source_line_ref.line + 1);
1381 let line_num_style = Style::default().fg(theme.line_number_fg).bg(bg);
1382
1383 let is_cursor_pane = is_focused_pane;
1384 let cursor_column = view_state.cursor_column;
1385
1386 let inline_ranges = inline_diffs.get(pane_idx).cloned().unwrap_or_default();
1388
1389 let highlight_bg = match aligned_row.row_type {
1391 RowType::Deletion => Some(theme.diff_remove_highlight_bg),
1392 RowType::Addition => Some(theme.diff_add_highlight_bg),
1393 RowType::Modification => {
1394 if pane_idx == 0 {
1395 Some(theme.diff_remove_highlight_bg)
1396 } else {
1397 Some(theme.diff_add_highlight_bg)
1398 }
1399 }
1400 _ => None,
1401 };
1402
1403 let mut spans = vec![Span::styled(line_num, line_num_style)];
1405
1406 if let Some(view_line) = view_line_opt {
1407 Self::render_view_line_content(
1409 &mut spans,
1410 view_line,
1411 highlight_spans,
1412 left_column,
1413 max_content_width,
1414 bg,
1415 theme,
1416 is_cursor_row && is_cursor_pane,
1417 cursor_column,
1418 &inline_ranges,
1419 highlight_bg,
1420 pane_selection_cols,
1421 );
1422 } else {
1423 tracing::warn!(
1429 "ViewLine missing for composite buffer: pane={}, line={}, pane_data={}",
1430 pane_idx,
1431 source_line_ref.line,
1432 pane_data.is_some()
1433 );
1434 let base_style = Style::default().fg(theme.editor_fg).bg(bg);
1436 let padding = " ".repeat(max_content_width);
1437 spans.push(Span::styled(padding, base_style));
1438 }
1439
1440 let line = Line::from(spans);
1441 let para = Paragraph::new(line);
1442 frame.render_widget(para, pane_area);
1443 } else {
1444 let is_focused_pane = pane_idx == view_state.focused_pane;
1446 let pane_has_selection = is_focused_pane
1448 && selection_cols
1449 .map(|(start, end)| start == 0 && end == usize::MAX)
1450 .unwrap_or(false);
1451
1452 let bg = if pane_has_selection {
1453 theme.selection_bg
1454 } else if is_cursor_row && is_focused_pane {
1455 theme.current_line_bg
1456 } else {
1457 row_bg.unwrap_or(effective_editor_bg)
1458 };
1459 let style = Style::default().fg(theme.line_number_fg).bg(bg);
1460
1461 let is_cursor_pane = pane_idx == view_state.focused_pane;
1463 if is_cursor_row && is_cursor_pane && view_state.cursor_column == 0 {
1464 let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
1466 let gutter_width = 4usize;
1467 let max_content_width = width.saturating_sub(gutter_width as u16) as usize;
1468 let padding = " ".repeat(max_content_width.saturating_sub(1));
1469 let line = Line::from(vec![
1470 Span::styled(" ", style),
1471 Span::styled(" ", cursor_style),
1472 Span::styled(padding, Style::default().bg(bg)),
1473 ]);
1474 let para = Paragraph::new(line);
1475 frame.render_widget(para, pane_area);
1476 } else {
1477 let gap_style = Style::default().bg(bg);
1479 let empty_content = " ".repeat(width as usize);
1480 let para = Paragraph::new(empty_content).style(gap_style);
1481 frame.render_widget(para, pane_area);
1482 }
1483 }
1484
1485 x_offset += width;
1486
1487 if show_separator && pane_idx < pane_count - 1 {
1489 let sep_area =
1490 Rect::new(x_offset, content_y + view_row as u16, separator_width, 1);
1491 let sep =
1492 Paragraph::new("│").style(Style::default().fg(theme.split_separator_fg));
1493 frame.render_widget(sep, sep_area);
1494 x_offset += separator_width;
1495 }
1496 }
1497 }
1498 }
1499
1500 #[allow(clippy::too_many_arguments)]
1502 fn render_view_line_content(
1503 spans: &mut Vec<Span<'static>>,
1504 view_line: &ViewLine,
1505 highlight_spans: &[crate::primitives::highlighter::HighlightSpan],
1506 left_column: usize,
1507 max_width: usize,
1508 bg: Color,
1509 theme: &crate::view::theme::Theme,
1510 show_cursor: bool,
1511 cursor_column: usize,
1512 inline_ranges: &[Range<usize>],
1513 highlight_bg: Option<Color>,
1514 selection_cols: Option<(usize, usize)>, ) {
1516 let text = &view_line.text;
1517 let char_source_bytes = &view_line.char_source_bytes;
1518
1519 let chars: Vec<char> = text.chars().collect();
1521 let mut col = 0usize;
1522 let mut rendered = 0usize;
1523 let mut current_span_text = String::new();
1524 let mut current_style: Option<Style> = None;
1525
1526 for (char_idx, ch) in chars.iter().enumerate() {
1527 let char_width = char_width(*ch);
1528
1529 if col < left_column {
1531 col += char_width;
1532 continue;
1533 }
1534
1535 if rendered >= max_width {
1537 break;
1538 }
1539
1540 let byte_pos = char_source_bytes.get(char_idx).and_then(|b| *b);
1542
1543 let highlight_color = byte_pos.and_then(|bp| {
1545 highlight_spans
1546 .iter()
1547 .find(|span| span.range.contains(&bp))
1548 .map(|span| span.color)
1549 });
1550
1551 let in_inline_range = inline_ranges.iter().any(|r| r.contains(&char_idx));
1553
1554 let in_selection = selection_cols
1556 .map(|(start, end)| col >= start && col < end)
1557 .unwrap_or(false);
1558
1559 let char_bg = if in_selection {
1561 theme.selection_bg
1562 } else if in_inline_range {
1563 highlight_bg.unwrap_or(bg)
1564 } else {
1565 bg
1566 };
1567
1568 let char_style = if let Some(color) = highlight_color {
1570 Style::default().fg(color).bg(char_bg)
1571 } else {
1572 Style::default().fg(theme.editor_fg).bg(char_bg)
1573 };
1574
1575 let final_style = if show_cursor && col == cursor_column {
1577 Style::default().fg(theme.editor_bg).bg(theme.editor_fg)
1579 } else {
1580 char_style
1581 };
1582
1583 if let Some(style) = current_style {
1585 if style != final_style && !current_span_text.is_empty() {
1586 spans.push(Span::styled(std::mem::take(&mut current_span_text), style));
1587 }
1588 }
1589
1590 current_style = Some(final_style);
1591 current_span_text.push(*ch);
1592 col += char_width;
1593 rendered += char_width;
1594 }
1595
1596 if !current_span_text.is_empty() {
1598 if let Some(style) = current_style {
1599 spans.push(Span::styled(current_span_text, style));
1600 }
1601 }
1602
1603 if rendered < max_width {
1605 let padding_len = max_width - rendered;
1606 let cursor_visual = cursor_column.saturating_sub(left_column);
1608
1609 if show_cursor && cursor_visual >= rendered && cursor_visual < max_width {
1611 let cursor_offset = cursor_visual - rendered;
1613 let cursor_style = Style::default().fg(theme.editor_bg).bg(theme.editor_fg);
1614 let normal_style = Style::default().bg(bg);
1615
1616 if cursor_offset > 0 {
1618 spans.push(Span::styled(" ".repeat(cursor_offset), normal_style));
1619 }
1620 spans.push(Span::styled(" ", cursor_style));
1622 let remaining = padding_len.saturating_sub(cursor_offset + 1);
1624 if remaining > 0 {
1625 spans.push(Span::styled(" ".repeat(remaining), normal_style));
1626 }
1627 } else {
1628 spans.push(Span::styled(
1630 " ".repeat(padding_len),
1631 Style::default().bg(bg),
1632 ));
1633 }
1634 }
1635 }
1636
1637 fn render_composite_scrollbar(
1639 frame: &mut Frame,
1640 scrollbar_rect: Rect,
1641 total_rows: usize,
1642 scroll_row: usize,
1643 viewport_height: usize,
1644 is_active: bool,
1645 ) -> (usize, usize) {
1646 let height = scrollbar_rect.height as usize;
1647 if height == 0 || total_rows == 0 {
1648 return (0, 0);
1649 }
1650
1651 let thumb_size_raw = if total_rows > 0 {
1653 ((viewport_height as f64 / total_rows as f64) * height as f64).ceil() as usize
1654 } else {
1655 1
1656 };
1657
1658 let max_scroll = total_rows.saturating_sub(viewport_height);
1660
1661 let thumb_size = if max_scroll == 0 {
1663 height
1664 } else {
1665 let max_thumb_size = (height as f64 * 0.8).floor() as usize;
1667 thumb_size_raw.max(1).min(max_thumb_size).min(height)
1668 };
1669
1670 let thumb_start = if max_scroll > 0 {
1672 let scroll_ratio = scroll_row.min(max_scroll) as f64 / max_scroll as f64;
1673 let max_thumb_start = height.saturating_sub(thumb_size);
1674 (scroll_ratio * max_thumb_start as f64) as usize
1675 } else {
1676 0
1677 };
1678
1679 let thumb_end = thumb_start + thumb_size;
1680
1681 let track_color = if is_active {
1683 Color::DarkGray
1684 } else {
1685 Color::Black
1686 };
1687 let thumb_color = if is_active {
1688 Color::Gray
1689 } else {
1690 Color::DarkGray
1691 };
1692
1693 for row in 0..height {
1695 let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
1696
1697 let style = if row >= thumb_start && row < thumb_end {
1698 Style::default().bg(thumb_color)
1699 } else {
1700 Style::default().bg(track_color)
1701 };
1702
1703 let paragraph = Paragraph::new(" ").style(style);
1704 frame.render_widget(paragraph, cell_area);
1705 }
1706
1707 (thumb_start, thumb_end)
1708 }
1709
1710 fn split_layout(split_area: Rect, tab_bar_visible: bool) -> SplitLayout {
1711 let tabs_height = if tab_bar_visible { 1u16 } else { 0u16 };
1712 let scrollbar_width = 1u16;
1713
1714 let tabs_rect = Rect::new(split_area.x, split_area.y, split_area.width, tabs_height);
1715 let content_rect = Rect::new(
1716 split_area.x,
1717 split_area.y + tabs_height,
1718 split_area.width.saturating_sub(scrollbar_width),
1719 split_area.height.saturating_sub(tabs_height),
1720 );
1721 let scrollbar_rect = Rect::new(
1722 split_area.x + split_area.width.saturating_sub(scrollbar_width),
1723 split_area.y + tabs_height,
1724 scrollbar_width,
1725 split_area.height.saturating_sub(tabs_height),
1726 );
1727
1728 SplitLayout {
1729 tabs_rect,
1730 content_rect,
1731 scrollbar_rect,
1732 }
1733 }
1734
1735 fn split_buffers_for_tabs(
1736 split_view_states: Option<
1737 &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1738 >,
1739 split_id: crate::model::event::SplitId,
1740 buffer_id: BufferId,
1741 ) -> (Vec<BufferId>, usize) {
1742 if let Some(view_states) = split_view_states {
1743 if let Some(view_state) = view_states.get(&split_id) {
1744 return (
1745 view_state.open_buffers.clone(),
1746 view_state.tab_scroll_offset,
1747 );
1748 }
1749 }
1750 (vec![buffer_id], 0)
1751 }
1752
1753 fn temporary_split_state(
1754 state: &mut EditorState,
1755 split_view_states: Option<
1756 &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1757 >,
1758 split_id: crate::model::event::SplitId,
1759 is_active: bool,
1760 ) -> Option<crate::model::cursor::Cursors> {
1761 if is_active {
1762 return None;
1763 }
1764
1765 if let Some(view_states) = split_view_states {
1766 if let Some(view_state) = view_states.get(&split_id) {
1767 let saved_cursors = Some(std::mem::replace(
1769 &mut state.cursors,
1770 view_state.cursors.clone(),
1771 ));
1772 return saved_cursors;
1773 }
1774 }
1775
1776 None
1777 }
1778
1779 fn restore_split_state(
1780 state: &mut EditorState,
1781 saved_cursors: Option<crate::model::cursor::Cursors>,
1782 ) {
1783 if let Some(cursors) = saved_cursors {
1784 state.cursors = cursors;
1785 }
1786 }
1787
1788 fn sync_viewport_to_content(
1789 viewport: &mut crate::view::viewport::Viewport,
1790 buffer: &mut crate::model::buffer::Buffer,
1791 cursors: &crate::model::cursor::Cursors,
1792 content_rect: Rect,
1793 ) {
1794 let size_changed =
1795 viewport.width != content_rect.width || viewport.height != content_rect.height;
1796
1797 if size_changed {
1798 viewport.resize(content_rect.width, content_rect.height);
1799 }
1800
1801 let primary = *cursors.primary();
1806 viewport.ensure_visible(buffer, &primary);
1807 }
1808
1809 fn resolve_view_preferences(
1810 state: &EditorState,
1811 split_view_states: Option<
1812 &HashMap<crate::model::event::SplitId, crate::view::split::SplitViewState>,
1813 >,
1814 split_id: crate::model::event::SplitId,
1815 ) -> ViewPreferences {
1816 if let Some(view_states) = split_view_states {
1817 if let Some(view_state) = view_states.get(&split_id) {
1818 return ViewPreferences {
1819 view_mode: view_state.view_mode.clone(),
1820 compose_width: view_state.compose_width,
1821 compose_column_guides: view_state.compose_column_guides.clone(),
1822 view_transform: view_state.view_transform.clone(),
1823 };
1824 }
1825 }
1826
1827 ViewPreferences {
1828 view_mode: state.view_mode.clone(),
1829 compose_width: state.compose_width,
1830 compose_column_guides: state.compose_column_guides.clone(),
1831 view_transform: state.view_transform.clone(),
1832 }
1833 }
1834
1835 fn scrollbar_line_counts(
1836 state: &EditorState,
1837 viewport: &crate::view::viewport::Viewport,
1838 large_file_threshold_bytes: u64,
1839 buffer_len: usize,
1840 ) -> (usize, usize) {
1841 if buffer_len > large_file_threshold_bytes as usize {
1842 return (0, 0);
1843 }
1844
1845 let total_lines = if buffer_len > 0 {
1846 state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1847 } else {
1848 1
1849 };
1850
1851 let top_line = if viewport.top_byte < buffer_len {
1852 state.buffer.get_line_number(viewport.top_byte)
1853 } else {
1854 0
1855 };
1856
1857 (total_lines, top_line)
1858 }
1859
1860 #[allow(clippy::too_many_arguments)]
1863 fn render_scrollbar(
1864 frame: &mut Frame,
1865 state: &EditorState,
1866 viewport: &crate::view::viewport::Viewport,
1867 scrollbar_rect: Rect,
1868 is_active: bool,
1869 _theme: &crate::view::theme::Theme,
1870 large_file_threshold_bytes: u64,
1871 total_lines: usize,
1872 top_line: usize,
1873 ) -> (usize, usize) {
1874 let height = scrollbar_rect.height as usize;
1875 if height == 0 {
1876 return (0, 0);
1877 }
1878
1879 let buffer_len = state.buffer.len();
1880 let viewport_top = viewport.top_byte;
1881 let viewport_height_lines = viewport.height as usize;
1885
1886 let (thumb_start, thumb_size) = if buffer_len > large_file_threshold_bytes as usize {
1888 let thumb_start = if buffer_len > 0 {
1890 ((viewport_top as f64 / buffer_len as f64) * height as f64) as usize
1891 } else {
1892 0
1893 };
1894 (thumb_start, 1)
1895 } else {
1896 let thumb_size_raw = if total_lines > 0 {
1901 ((viewport_height_lines as f64 / total_lines as f64) * height as f64).ceil()
1902 as usize
1903 } else {
1904 1
1905 };
1906
1907 let max_scroll_line = total_lines.saturating_sub(viewport_height_lines);
1911
1912 let thumb_size = if max_scroll_line == 0 {
1915 height
1916 } else {
1917 let max_thumb_size = (height as f64 * 0.8).floor() as usize;
1919 thumb_size_raw.max(1).min(max_thumb_size).min(height)
1920 };
1921
1922 let thumb_start = if max_scroll_line > 0 {
1926 let scroll_ratio = top_line.min(max_scroll_line) as f64 / max_scroll_line as f64;
1928 let max_thumb_start = height.saturating_sub(thumb_size);
1929 (scroll_ratio * max_thumb_start as f64) as usize
1930 } else {
1931 0
1933 };
1934
1935 (thumb_start, thumb_size)
1936 };
1937
1938 let thumb_end = thumb_start + thumb_size;
1939
1940 let track_color = if is_active {
1942 Color::DarkGray
1943 } else {
1944 Color::Black
1945 };
1946 let thumb_color = if is_active {
1947 Color::Gray
1948 } else {
1949 Color::DarkGray
1950 };
1951
1952 for row in 0..height {
1954 let cell_area = Rect::new(scrollbar_rect.x, scrollbar_rect.y + row as u16, 1, 1);
1955
1956 let style = if row >= thumb_start && row < thumb_end {
1957 Style::default().bg(thumb_color)
1959 } else {
1960 Style::default().bg(track_color)
1962 };
1963
1964 let paragraph = Paragraph::new(" ").style(style);
1965 frame.render_widget(paragraph, cell_area);
1966 }
1967
1968 (thumb_start, thumb_end)
1970 }
1971
1972 #[allow(clippy::too_many_arguments)]
1973 fn build_view_data(
1974 state: &mut EditorState,
1975 viewport: &crate::view::viewport::Viewport,
1976 view_transform: Option<ViewTransformPayload>,
1977 estimated_line_length: usize,
1978 visible_count: usize,
1979 line_wrap_enabled: bool,
1980 content_width: usize,
1981 gutter_width: usize,
1982 ) -> ViewData {
1983 let is_binary = state.buffer.is_binary();
1985 let line_ending = state.buffer.line_ending();
1986
1987 let base_tokens = Self::build_base_tokens(
1989 &mut state.buffer,
1990 viewport.top_byte,
1991 estimated_line_length,
1992 visible_count,
1993 is_binary,
1994 line_ending,
1995 );
1996
1997 let mut tokens = view_transform.map(|vt| vt.tokens).unwrap_or(base_tokens);
1999
2000 let effective_width = if line_wrap_enabled {
2005 content_width
2006 } else {
2007 MAX_SAFE_LINE_WIDTH
2008 };
2009 tokens = Self::apply_wrapping_transform(tokens, effective_width, gutter_width);
2010
2011 let is_binary = state.buffer.is_binary();
2016 let ansi_aware = !is_binary; let source_lines: Vec<ViewLine> =
2018 ViewLineIterator::new(&tokens, is_binary, ansi_aware, state.tab_size).collect();
2019
2020 let lines = Self::inject_virtual_lines(source_lines, state);
2022
2023 ViewData { lines }
2024 }
2025
2026 fn create_virtual_line(text: &str, style: ratatui::style::Style) -> ViewLine {
2028 use fresh_core::api::ViewTokenStyle;
2029
2030 let text = text.to_string();
2031 let len = text.chars().count();
2032
2033 let token_style = ViewTokenStyle {
2035 fg: style.fg.and_then(|c| match c {
2036 ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
2037 _ => None,
2038 }),
2039 bg: style.bg.and_then(|c| match c {
2040 ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
2041 _ => None,
2042 }),
2043 bold: style.add_modifier.contains(ratatui::style::Modifier::BOLD),
2044 italic: style
2045 .add_modifier
2046 .contains(ratatui::style::Modifier::ITALIC),
2047 };
2048
2049 ViewLine {
2050 text,
2051 char_source_bytes: vec![None; len],
2053 char_styles: vec![Some(token_style); len],
2055 char_visual_cols: (0..len).collect(),
2057 visual_to_char: (0..len).collect(),
2059 tab_starts: HashSet::new(),
2060 line_start: LineStart::AfterInjectedNewline,
2062 ends_with_newline: true,
2063 }
2064 }
2065
2066 fn inject_virtual_lines(source_lines: Vec<ViewLine>, state: &EditorState) -> Vec<ViewLine> {
2068 use crate::view::virtual_text::VirtualTextPosition;
2069
2070 let viewport_start = source_lines
2072 .first()
2073 .and_then(|l| l.char_source_bytes.iter().find_map(|m| *m))
2074 .unwrap_or(0);
2075 let viewport_end = source_lines
2076 .last()
2077 .and_then(|l| l.char_source_bytes.iter().rev().find_map(|m| *m))
2078 .map(|b| b + 1)
2079 .unwrap_or(viewport_start);
2080
2081 let virtual_lines = state.virtual_texts.query_lines_in_range(
2083 &state.marker_list,
2084 viewport_start,
2085 viewport_end,
2086 );
2087
2088 if virtual_lines.is_empty() {
2090 return source_lines;
2091 }
2092
2093 let mut result = Vec::with_capacity(source_lines.len() + virtual_lines.len());
2095
2096 for source_line in source_lines {
2097 let line_start_byte = source_line.char_source_bytes.iter().find_map(|m| *m);
2099 let line_end_byte = source_line
2100 .char_source_bytes
2101 .iter()
2102 .rev()
2103 .find_map(|m| *m)
2104 .map(|b| b + 1);
2105
2106 if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
2108 for (anchor_pos, vtext) in &virtual_lines {
2109 if *anchor_pos >= start
2110 && *anchor_pos < end
2111 && vtext.position == VirtualTextPosition::LineAbove
2112 {
2113 result.push(Self::create_virtual_line(&vtext.text, vtext.style));
2114 }
2115 }
2116 }
2117
2118 result.push(source_line.clone());
2120
2121 if let (Some(start), Some(end)) = (line_start_byte, line_end_byte) {
2123 for (anchor_pos, vtext) in &virtual_lines {
2124 if *anchor_pos >= start
2125 && *anchor_pos < end
2126 && vtext.position == VirtualTextPosition::LineBelow
2127 {
2128 result.push(Self::create_virtual_line(&vtext.text, vtext.style));
2129 }
2130 }
2131 }
2132 }
2133
2134 result
2135 }
2136
2137 fn build_base_tokens(
2138 buffer: &mut Buffer,
2139 top_byte: usize,
2140 estimated_line_length: usize,
2141 visible_count: usize,
2142 is_binary: bool,
2143 line_ending: crate::model::buffer::LineEnding,
2144 ) -> Vec<fresh_core::api::ViewTokenWire> {
2145 use crate::model::buffer::LineEnding;
2146 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2147
2148 let mut tokens = Vec::new();
2149
2150 if is_binary {
2153 return Self::build_base_tokens_binary(
2154 buffer,
2155 top_byte,
2156 estimated_line_length,
2157 visible_count,
2158 );
2159 }
2160
2161 let mut iter = buffer.line_iterator(top_byte, estimated_line_length);
2162 let mut lines_seen = 0usize;
2163 let max_lines = visible_count.saturating_add(4);
2164
2165 while lines_seen < max_lines {
2166 if let Some((line_start, line_content)) = iter.next_line() {
2167 let mut byte_offset = 0usize;
2168 let content_bytes = line_content.as_bytes();
2169 let mut skip_next_lf = false; let mut chars_this_line = 0usize; for ch in line_content.chars() {
2172 if chars_this_line >= MAX_SAFE_LINE_WIDTH {
2175 tokens.push(ViewTokenWire {
2176 source_offset: None,
2177 kind: ViewTokenWireKind::Break,
2178 style: None,
2179 });
2180 chars_this_line = 0;
2181 lines_seen += 1;
2183 if lines_seen >= max_lines {
2184 break;
2185 }
2186 }
2187 chars_this_line += 1;
2188
2189 let ch_len = ch.len_utf8();
2190 let source_offset = Some(line_start + byte_offset);
2191
2192 match ch {
2193 '\r' => {
2194 let is_crlf_file = line_ending == LineEnding::CRLF;
2198 let next_byte = content_bytes.get(byte_offset + 1);
2199 if is_crlf_file && next_byte == Some(&b'\n') {
2200 tokens.push(ViewTokenWire {
2202 source_offset,
2203 kind: ViewTokenWireKind::Newline,
2204 style: None,
2205 });
2206 skip_next_lf = true;
2208 byte_offset += ch_len;
2209 continue;
2210 }
2211 tokens.push(ViewTokenWire {
2213 source_offset,
2214 kind: ViewTokenWireKind::BinaryByte(ch as u8),
2215 style: None,
2216 });
2217 }
2218 '\n' if skip_next_lf => {
2219 skip_next_lf = false;
2221 byte_offset += ch_len;
2222 continue;
2223 }
2224 '\n' => {
2225 tokens.push(ViewTokenWire {
2226 source_offset,
2227 kind: ViewTokenWireKind::Newline,
2228 style: None,
2229 });
2230 }
2231 ' ' => {
2232 tokens.push(ViewTokenWire {
2233 source_offset,
2234 kind: ViewTokenWireKind::Space,
2235 style: None,
2236 });
2237 }
2238 '\t' => {
2239 tokens.push(ViewTokenWire {
2241 source_offset,
2242 kind: ViewTokenWireKind::Text(ch.to_string()),
2243 style: None,
2244 });
2245 }
2246 _ if Self::is_control_char(ch) => {
2247 tokens.push(ViewTokenWire {
2249 source_offset,
2250 kind: ViewTokenWireKind::BinaryByte(ch as u8),
2251 style: None,
2252 });
2253 }
2254 _ => {
2255 if let Some(last) = tokens.last_mut() {
2257 if let ViewTokenWireKind::Text(ref mut s) = last.kind {
2258 let expected_offset = last.source_offset.map(|o| o + s.len());
2260 if expected_offset == Some(line_start + byte_offset) {
2261 s.push(ch);
2262 byte_offset += ch_len;
2263 continue;
2264 }
2265 }
2266 }
2267 tokens.push(ViewTokenWire {
2268 source_offset,
2269 kind: ViewTokenWireKind::Text(ch.to_string()),
2270 style: None,
2271 });
2272 }
2273 }
2274 byte_offset += ch_len;
2275 }
2276 lines_seen += 1;
2277 } else {
2278 break;
2279 }
2280 }
2281
2282 if tokens.is_empty() {
2284 tokens.push(ViewTokenWire {
2285 source_offset: Some(top_byte),
2286 kind: ViewTokenWireKind::Text(String::new()),
2287 style: None,
2288 });
2289 }
2290
2291 tokens
2292 }
2293
2294 fn build_base_tokens_binary(
2297 buffer: &mut Buffer,
2298 top_byte: usize,
2299 estimated_line_length: usize,
2300 visible_count: usize,
2301 ) -> Vec<fresh_core::api::ViewTokenWire> {
2302 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2303
2304 let mut tokens = Vec::new();
2305 let max_lines = visible_count.saturating_add(4);
2306 let buffer_len = buffer.len();
2307
2308 if top_byte >= buffer_len {
2309 tokens.push(ViewTokenWire {
2310 source_offset: Some(top_byte),
2311 kind: ViewTokenWireKind::Text(String::new()),
2312 style: None,
2313 });
2314 return tokens;
2315 }
2316
2317 let estimated_bytes = estimated_line_length * max_lines * 2;
2319 let bytes_to_read = estimated_bytes.min(buffer_len - top_byte);
2320
2321 let raw_bytes = buffer.slice_bytes(top_byte..top_byte + bytes_to_read);
2323
2324 let mut byte_offset = 0usize;
2325 let mut lines_seen = 0usize;
2326 let mut current_text = String::new();
2327 let mut current_text_start: Option<usize> = None;
2328
2329 let flush_text =
2331 |tokens: &mut Vec<ViewTokenWire>, text: &mut String, start: &mut Option<usize>| {
2332 if !text.is_empty() {
2333 tokens.push(ViewTokenWire {
2334 source_offset: *start,
2335 kind: ViewTokenWireKind::Text(std::mem::take(text)),
2336 style: None,
2337 });
2338 *start = None;
2339 }
2340 };
2341
2342 while byte_offset < raw_bytes.len() && lines_seen < max_lines {
2343 let b = raw_bytes[byte_offset];
2344 let source_offset = top_byte + byte_offset;
2345
2346 match b {
2347 b'\n' => {
2348 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2349 tokens.push(ViewTokenWire {
2350 source_offset: Some(source_offset),
2351 kind: ViewTokenWireKind::Newline,
2352 style: None,
2353 });
2354 lines_seen += 1;
2355 }
2356 b' ' => {
2357 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2358 tokens.push(ViewTokenWire {
2359 source_offset: Some(source_offset),
2360 kind: ViewTokenWireKind::Space,
2361 style: None,
2362 });
2363 }
2364 _ => {
2365 if Self::is_binary_unprintable(b) {
2368 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2370 tokens.push(ViewTokenWire {
2372 source_offset: Some(source_offset),
2373 kind: ViewTokenWireKind::BinaryByte(b),
2374 style: None,
2375 });
2376 } else {
2377 if current_text_start.is_none() {
2380 current_text_start = Some(source_offset);
2381 }
2382 current_text.push(b as char);
2383 }
2384 }
2385 }
2386 byte_offset += 1;
2387 }
2388
2389 flush_text(&mut tokens, &mut current_text, &mut current_text_start);
2391
2392 if tokens.is_empty() {
2394 tokens.push(ViewTokenWire {
2395 source_offset: Some(top_byte),
2396 kind: ViewTokenWireKind::Text(String::new()),
2397 style: None,
2398 });
2399 }
2400
2401 tokens
2402 }
2403
2404 fn is_binary_unprintable(b: u8) -> bool {
2416 if b == 0x09 || b == 0x0A {
2420 return false;
2421 }
2422 if b < 0x20 {
2425 return true;
2426 }
2427 if b == 0x7F {
2429 return true;
2430 }
2431 if b >= 0x80 {
2434 return true;
2435 }
2436 false
2437 }
2438
2439 fn is_control_char(ch: char) -> bool {
2442 let code = ch as u32;
2443 if code >= 128 {
2445 return false;
2446 }
2447 let b = code as u8;
2448 if b == 0x09 || b == 0x0A || b == 0x1B {
2450 return false;
2451 }
2452 b < 0x20 || b == 0x7F
2455 }
2456
2457 pub fn build_base_tokens_for_hook(
2459 buffer: &mut Buffer,
2460 top_byte: usize,
2461 estimated_line_length: usize,
2462 visible_count: usize,
2463 is_binary: bool,
2464 line_ending: crate::model::buffer::LineEnding,
2465 ) -> Vec<fresh_core::api::ViewTokenWire> {
2466 Self::build_base_tokens(
2467 buffer,
2468 top_byte,
2469 estimated_line_length,
2470 visible_count,
2471 is_binary,
2472 line_ending,
2473 )
2474 }
2475
2476 fn apply_wrapping_transform(
2477 tokens: Vec<fresh_core::api::ViewTokenWire>,
2478 content_width: usize,
2479 gutter_width: usize,
2480 ) -> Vec<fresh_core::api::ViewTokenWire> {
2481 use crate::primitives::visual_layout::visual_width;
2482 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2483
2484 let mut wrapped = Vec::new();
2485 let mut current_line_width = 0;
2486
2487 let available_width = content_width.saturating_sub(gutter_width);
2489
2490 for token in tokens {
2491 match &token.kind {
2492 ViewTokenWireKind::Newline => {
2493 wrapped.push(token);
2495 current_line_width = 0;
2496 }
2497 ViewTokenWireKind::Text(text) => {
2498 let text_visual_width = visual_width(text, current_line_width);
2500
2501 if current_line_width > 0
2503 && current_line_width + text_visual_width > available_width
2504 {
2505 wrapped.push(ViewTokenWire {
2506 source_offset: None,
2507 kind: ViewTokenWireKind::Break,
2508 style: None,
2509 });
2510 current_line_width = 0;
2511 }
2512
2513 let text_visual_width = visual_width(text, current_line_width);
2515
2516 if text_visual_width > available_width
2520 && !crate::primitives::ansi::contains_ansi_codes(text)
2521 {
2522 use unicode_segmentation::UnicodeSegmentation;
2523
2524 let graphemes: Vec<(usize, &str)> = text.grapheme_indices(true).collect();
2526 let mut grapheme_idx = 0;
2527 let source_base = token.source_offset;
2528
2529 while grapheme_idx < graphemes.len() {
2530 let remaining_width =
2533 available_width.saturating_sub(current_line_width);
2534 if remaining_width == 0 {
2535 wrapped.push(ViewTokenWire {
2537 source_offset: None,
2538 kind: ViewTokenWireKind::Break,
2539 style: None,
2540 });
2541 current_line_width = 0;
2542 continue;
2543 }
2544
2545 let mut chunk_visual_width = 0;
2546 let mut chunk_grapheme_count = 0;
2547 let mut col = current_line_width;
2548
2549 for &(_byte_offset, grapheme) in &graphemes[grapheme_idx..] {
2550 let g_width = if grapheme == "\t" {
2551 crate::primitives::visual_layout::tab_expansion_width(col)
2552 } else {
2553 crate::primitives::display_width::str_width(grapheme)
2554 };
2555
2556 if chunk_visual_width + g_width > remaining_width
2557 && chunk_grapheme_count > 0
2558 {
2559 break;
2560 }
2561
2562 chunk_visual_width += g_width;
2563 chunk_grapheme_count += 1;
2564 col += g_width;
2565 }
2566
2567 if chunk_grapheme_count == 0 {
2568 chunk_grapheme_count = 1;
2570 let grapheme = graphemes[grapheme_idx].1;
2571 chunk_visual_width = if grapheme == "\t" {
2572 crate::primitives::visual_layout::tab_expansion_width(
2573 current_line_width,
2574 )
2575 } else {
2576 crate::primitives::display_width::str_width(grapheme)
2577 };
2578 }
2579
2580 let chunk_start_byte = graphemes[grapheme_idx].0;
2582 let chunk_end_byte =
2583 if grapheme_idx + chunk_grapheme_count < graphemes.len() {
2584 graphemes[grapheme_idx + chunk_grapheme_count].0
2585 } else {
2586 text.len()
2587 };
2588 let chunk = text[chunk_start_byte..chunk_end_byte].to_string();
2589 let chunk_source = source_base.map(|b| b + chunk_start_byte);
2590
2591 wrapped.push(ViewTokenWire {
2592 source_offset: chunk_source,
2593 kind: ViewTokenWireKind::Text(chunk),
2594 style: token.style.clone(),
2595 });
2596
2597 current_line_width += chunk_visual_width;
2598 grapheme_idx += chunk_grapheme_count;
2599
2600 if current_line_width >= available_width {
2602 wrapped.push(ViewTokenWire {
2603 source_offset: None,
2604 kind: ViewTokenWireKind::Break,
2605 style: None,
2606 });
2607 current_line_width = 0;
2608 }
2609 }
2610 } else {
2611 wrapped.push(token);
2612 current_line_width += text_visual_width;
2613 }
2614 }
2615 ViewTokenWireKind::Space => {
2616 if current_line_width + 1 > available_width {
2618 wrapped.push(ViewTokenWire {
2619 source_offset: None,
2620 kind: ViewTokenWireKind::Break,
2621 style: None,
2622 });
2623 current_line_width = 0;
2624 }
2625 wrapped.push(token);
2626 current_line_width += 1;
2627 }
2628 ViewTokenWireKind::Break => {
2629 wrapped.push(token);
2631 current_line_width = 0;
2632 }
2633 ViewTokenWireKind::BinaryByte(_) => {
2634 let byte_display_width = 4;
2636 if current_line_width + byte_display_width > available_width {
2637 wrapped.push(ViewTokenWire {
2638 source_offset: None,
2639 kind: ViewTokenWireKind::Break,
2640 style: None,
2641 });
2642 current_line_width = 0;
2643 }
2644 wrapped.push(token);
2645 current_line_width += byte_display_width;
2646 }
2647 }
2648 }
2649
2650 wrapped
2651 }
2652
2653 fn calculate_view_anchor(view_lines: &[ViewLine], top_byte: usize) -> ViewAnchor {
2654 for (idx, line) in view_lines.iter().enumerate() {
2657 if let Some(first_source) = line.char_source_bytes.iter().find_map(|m| *m) {
2659 if first_source >= top_byte {
2660 let mut start_idx = idx;
2663 while start_idx > 0 {
2664 let prev_line = &view_lines[start_idx - 1];
2665 let prev_has_source =
2667 prev_line.char_source_bytes.iter().any(|m| m.is_some());
2668 if !prev_has_source {
2669 start_idx -= 1;
2670 } else {
2671 break;
2672 }
2673 }
2674 return ViewAnchor {
2675 start_line_idx: start_idx,
2676 start_line_skip: 0,
2677 };
2678 }
2679 }
2680 }
2681
2682 ViewAnchor {
2684 start_line_idx: 0,
2685 start_line_skip: 0,
2686 }
2687 }
2688
2689 fn calculate_compose_layout(
2690 area: Rect,
2691 view_mode: &ViewMode,
2692 compose_width: Option<u16>,
2693 ) -> ComposeLayout {
2694 let should_compose = view_mode == &ViewMode::Compose || compose_width.is_some();
2698
2699 if !should_compose {
2700 return ComposeLayout {
2701 render_area: area,
2702 left_pad: 0,
2703 right_pad: 0,
2704 };
2705 }
2706
2707 let target_width = compose_width.unwrap_or(area.width);
2708 let clamped_width = target_width.min(area.width).max(1);
2709 if clamped_width >= area.width {
2710 return ComposeLayout {
2711 render_area: area,
2712 left_pad: 0,
2713 right_pad: 0,
2714 };
2715 }
2716
2717 let pad_total = area.width - clamped_width;
2718 let left_pad = pad_total / 2;
2719 let right_pad = pad_total - left_pad;
2720
2721 ComposeLayout {
2722 render_area: Rect::new(area.x + left_pad, area.y, clamped_width, area.height),
2723 left_pad,
2724 right_pad,
2725 }
2726 }
2727
2728 fn render_compose_margins(
2729 frame: &mut Frame,
2730 area: Rect,
2731 layout: &ComposeLayout,
2732 _view_mode: &ViewMode,
2733 theme: &crate::view::theme::Theme,
2734 effective_editor_bg: ratatui::style::Color,
2735 ) {
2736 if layout.left_pad == 0 && layout.right_pad == 0 {
2738 return;
2739 }
2740
2741 const PAPER_EDGE_WIDTH: u16 = 1;
2744
2745 let desk_style = Style::default().bg(theme.compose_margin_bg);
2746 let paper_style = Style::default().bg(effective_editor_bg);
2747
2748 if layout.left_pad > 0 {
2749 let paper_edge = PAPER_EDGE_WIDTH.min(layout.left_pad);
2750 let desk_width = layout.left_pad.saturating_sub(paper_edge);
2751
2752 if desk_width > 0 {
2754 let desk_rect = Rect::new(area.x, area.y, desk_width, area.height);
2755 frame.render_widget(Block::default().style(desk_style), desk_rect);
2756 }
2757
2758 if paper_edge > 0 {
2760 let paper_rect = Rect::new(area.x + desk_width, area.y, paper_edge, area.height);
2761 frame.render_widget(Block::default().style(paper_style), paper_rect);
2762 }
2763 }
2764
2765 if layout.right_pad > 0 {
2766 let paper_edge = PAPER_EDGE_WIDTH.min(layout.right_pad);
2767 let desk_width = layout.right_pad.saturating_sub(paper_edge);
2768 let right_start = area.x + layout.left_pad + layout.render_area.width;
2769
2770 if paper_edge > 0 {
2772 let paper_rect = Rect::new(right_start, area.y, paper_edge, area.height);
2773 frame.render_widget(Block::default().style(paper_style), paper_rect);
2774 }
2775
2776 if desk_width > 0 {
2778 let desk_rect =
2779 Rect::new(right_start + paper_edge, area.y, desk_width, area.height);
2780 frame.render_widget(Block::default().style(desk_style), desk_rect);
2781 }
2782 }
2783 }
2784
2785 fn selection_context(state: &EditorState) -> SelectionContext {
2786 let ranges: Vec<Range<usize>> = state
2787 .cursors
2788 .iter()
2789 .filter_map(|(_, cursor)| cursor.selection_range())
2790 .collect();
2791
2792 let block_rects: Vec<(usize, usize, usize, usize)> = state
2793 .cursors
2794 .iter()
2795 .filter_map(|(_, cursor)| {
2796 if cursor.selection_mode == SelectionMode::Block {
2797 if let Some(anchor) = cursor.block_anchor {
2798 let cur_line = state.buffer.get_line_number(cursor.position);
2800 let cur_line_start = state.buffer.line_start_offset(cur_line).unwrap_or(0);
2801 let cur_col = cursor.position.saturating_sub(cur_line_start);
2802
2803 Some((
2805 anchor.line.min(cur_line),
2806 anchor.column.min(cur_col),
2807 anchor.line.max(cur_line),
2808 anchor.column.max(cur_col),
2809 ))
2810 } else {
2811 None
2812 }
2813 } else {
2814 None
2815 }
2816 })
2817 .collect();
2818
2819 let cursor_positions: Vec<usize> = if state.show_cursors {
2820 state
2821 .cursors
2822 .iter()
2823 .map(|(_, cursor)| cursor.position)
2824 .collect()
2825 } else {
2826 Vec::new()
2827 };
2828
2829 SelectionContext {
2830 ranges,
2831 block_rects,
2832 cursor_positions,
2833 primary_cursor_position: state.cursors.primary().position,
2834 }
2835 }
2836
2837 fn decoration_context(
2838 state: &mut EditorState,
2839 viewport_start: usize,
2840 viewport_end: usize,
2841 primary_cursor_position: usize,
2842 theme: &crate::view::theme::Theme,
2843 highlight_context_bytes: usize,
2844 ) -> DecorationContext {
2845 let viewport_size = viewport_end.saturating_sub(viewport_start);
2848 let highlight_start = viewport_start.saturating_sub(viewport_size);
2849 let highlight_end = viewport_end
2850 .saturating_add(viewport_size)
2851 .min(state.buffer.len());
2852
2853 let highlight_spans = state.highlighter.highlight_viewport(
2854 &state.buffer,
2855 highlight_start,
2856 highlight_end,
2857 theme,
2858 highlight_context_bytes,
2859 );
2860
2861 state.reference_highlight_overlay.update(
2863 &state.buffer,
2864 &mut state.overlays,
2865 &mut state.marker_list,
2866 &mut state.reference_highlighter,
2867 primary_cursor_position,
2868 viewport_start,
2869 viewport_end,
2870 highlight_context_bytes,
2871 theme.semantic_highlight_bg,
2872 );
2873
2874 let mut semantic_token_spans = Vec::new();
2877 let mut viewport_overlays = Vec::new();
2878 for (overlay, range) in
2879 state
2880 .overlays
2881 .query_viewport(viewport_start, viewport_end, &state.marker_list)
2882 {
2883 if crate::services::lsp::semantic_tokens::is_semantic_token_overlay(overlay) {
2884 if let crate::view::overlay::OverlayFace::Foreground { color } = &overlay.face {
2885 semantic_token_spans.push(crate::primitives::highlighter::HighlightSpan {
2886 range,
2887 color: *color,
2888 });
2889 }
2890 continue;
2891 }
2892
2893 viewport_overlays.push((overlay.clone(), range));
2894 }
2895
2896 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
2898 let diagnostic_lines: HashSet<usize> = viewport_overlays
2899 .iter()
2900 .filter_map(|(overlay, range)| {
2901 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
2902 return Some(state.buffer.get_line_number(range.start));
2903 }
2904 None
2905 })
2906 .collect();
2907
2908 let virtual_text_lookup: HashMap<usize, Vec<crate::view::virtual_text::VirtualText>> =
2909 state
2910 .virtual_texts
2911 .build_lookup(&state.marker_list, viewport_start, viewport_end)
2912 .into_iter()
2913 .map(|(position, texts)| (position, texts.into_iter().cloned().collect()))
2914 .collect();
2915
2916 let line_indicators = state.margins.get_indicators_for_viewport(
2918 viewport_start,
2919 viewport_end,
2920 |byte_offset| state.buffer.get_line_number(byte_offset),
2921 );
2922
2923 DecorationContext {
2924 highlight_spans,
2925 semantic_token_spans,
2926 viewport_overlays,
2927 virtual_text_lookup,
2928 diagnostic_lines,
2929 line_indicators,
2930 }
2931 }
2932
2933 fn calculate_viewport_end(
2936 state: &mut EditorState,
2937 viewport_start: usize,
2938 estimated_line_length: usize,
2939 visible_count: usize,
2940 ) -> usize {
2941 let mut iter_temp = state
2942 .buffer
2943 .line_iterator(viewport_start, estimated_line_length);
2944 let mut viewport_end = viewport_start;
2945 for _ in 0..visible_count {
2946 if let Some((line_start, line_content)) = iter_temp.next_line() {
2947 viewport_end = line_start + line_content.len();
2948 } else {
2949 break;
2950 }
2951 }
2952 viewport_end
2953 }
2954
2955 fn render_view_lines(input: LineRenderInput<'_>) -> LineRenderOutput {
2956 let LineRenderInput {
2957 state,
2958 theme,
2959 view_lines,
2960 view_anchor,
2961 render_area,
2962 gutter_width,
2963 selection,
2964 decorations,
2965 starting_line_num,
2966 visible_line_count,
2967 lsp_waiting,
2968 is_active,
2969 line_wrap,
2970 estimated_lines,
2971 left_column,
2972 relative_line_numbers,
2973 } = input;
2974
2975 let selection_ranges = &selection.ranges;
2976 let block_selections = &selection.block_rects;
2977 let cursor_positions = &selection.cursor_positions;
2978 let primary_cursor_position = selection.primary_cursor_position;
2979
2980 let cursor_line = state.buffer.get_line_number(primary_cursor_position);
2982
2983 let highlight_spans = &decorations.highlight_spans;
2984 let semantic_token_spans = &decorations.semantic_token_spans;
2985 let viewport_overlays = &decorations.viewport_overlays;
2986 let virtual_text_lookup = &decorations.virtual_text_lookup;
2987 let diagnostic_lines = &decorations.diagnostic_lines;
2988 let line_indicators = &decorations.line_indicators;
2989
2990 let mut lines = Vec::new();
2991 let mut view_line_mappings = Vec::new();
2992 let mut lines_rendered = 0usize;
2993 let mut view_iter_idx = view_anchor.start_line_idx;
2994 let mut cursor_screen_x = 0u16;
2995 let mut cursor_screen_y = 0u16;
2996 let mut have_cursor = false;
2997 let mut last_line_end: Option<LastLineEnd> = None;
2998
2999 let is_empty_buffer = state.buffer.is_empty();
3000
3001 let mut last_visible_x: u16 = 0;
3003 let _view_start_line_skip = view_anchor.start_line_skip; let mut current_source_line_num = starting_line_num;
3007 let mut prev_was_source_line = false;
3010
3011 loop {
3012 let current_view_line = if let Some(vl) = view_lines.get(view_iter_idx) {
3014 vl
3015 } else if is_empty_buffer && lines_rendered == 0 {
3016 static EMPTY_LINE: std::sync::OnceLock<ViewLine> = std::sync::OnceLock::new();
3018 EMPTY_LINE.get_or_init(|| ViewLine {
3019 text: String::new(),
3020 char_source_bytes: Vec::new(),
3021 char_styles: Vec::new(),
3022 char_visual_cols: Vec::new(),
3023 visual_to_char: Vec::new(),
3024 tab_starts: HashSet::new(),
3025 line_start: LineStart::Beginning,
3026 ends_with_newline: false,
3027 })
3028 } else {
3029 break;
3030 };
3031
3032 let line_content = current_view_line.text.clone();
3034 let line_has_newline = current_view_line.ends_with_newline;
3035 let line_char_source_bytes = ¤t_view_line.char_source_bytes;
3036 let line_char_styles = ¤t_view_line.char_styles;
3037 let line_visual_to_char = ¤t_view_line.visual_to_char;
3038 let line_tab_starts = ¤t_view_line.tab_starts;
3039 let _line_start_type = current_view_line.line_start; let _source_byte_at_col = |vis_col: usize| -> Option<usize> {
3043 let char_idx = line_visual_to_char.get(vis_col).copied()?;
3044 line_char_source_bytes.get(char_idx).copied().flatten()
3045 };
3046
3047 view_iter_idx += 1;
3048
3049 if lines_rendered >= visible_line_count {
3050 break;
3051 }
3052
3053 let show_line_number = should_show_line_number(current_view_line);
3056
3057 if show_line_number && prev_was_source_line {
3062 current_source_line_num += 1;
3063 }
3064 if show_line_number {
3067 prev_was_source_line = true;
3068 }
3069
3070 let is_continuation = !show_line_number;
3072
3073 lines_rendered += 1;
3074
3075 let left_col = left_column;
3077
3078 let mut line_spans = Vec::new();
3080 let mut line_view_map: Vec<Option<usize>> = Vec::new();
3081 let mut last_seg_y: Option<u16> = None;
3082 let mut _last_seg_width: usize = 0;
3083
3084 let mut span_acc = SpanAccumulator::new();
3087
3088 render_left_margin(
3090 &LeftMarginContext {
3091 state,
3092 theme,
3093 is_continuation,
3094 current_source_line_num,
3095 estimated_lines,
3096 diagnostic_lines,
3097 line_indicators,
3098 cursor_line,
3099 relative_line_numbers,
3100 },
3101 &mut line_spans,
3102 &mut line_view_map,
3103 );
3104
3105 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);
3115 let max_visible_chars = if line_wrap {
3116 (render_area.width as usize)
3119 .saturating_mul(visible_lines_remaining.max(1))
3120 .saturating_add(200)
3121 } else {
3122 (render_area.width as usize).saturating_add(100)
3124 };
3125 let max_chars_to_process = left_col.saturating_add(max_visible_chars);
3126
3127 let line_has_ansi = line_content.contains('\x1b');
3130 let mut ansi_parser = if line_has_ansi {
3131 Some(AnsiParser::new())
3132 } else {
3133 None
3134 };
3135 let mut visible_char_count = 0usize;
3137
3138 let mut debug_tracker = if state.debug_highlight_mode {
3140 Some(DebugSpanTracker::default())
3141 } else {
3142 None
3143 };
3144
3145 let mut first_line_byte_pos: Option<usize> = None;
3147 let mut last_line_byte_pos: Option<usize> = None;
3148
3149 let chars_iterator = line_content.chars().peekable();
3150 for ch in chars_iterator {
3151 let byte_pos = line_char_source_bytes
3154 .get(display_char_idx)
3155 .copied()
3156 .flatten();
3157
3158 if let Some(bp) = byte_pos {
3160 if first_line_byte_pos.is_none() {
3161 first_line_byte_pos = Some(bp);
3162 }
3163 last_line_byte_pos = Some(bp);
3164 }
3165
3166 let ansi_style = if let Some(ref mut parser) = ansi_parser {
3169 match parser.parse_char(ch) {
3170 Some(style) => style,
3171 None => {
3172 if let Some(bp) = byte_pos {
3176 if bp == primary_cursor_position && !have_cursor {
3177 cursor_screen_x = gutter_width as u16
3179 + col_offset.saturating_sub(left_col) as u16;
3180 cursor_screen_y = lines_rendered.saturating_sub(1) as u16;
3181 have_cursor = true;
3182 }
3183 }
3184 byte_index += ch.len_utf8();
3185 display_char_idx += 1;
3186 continue;
3188 }
3189 }
3190 } else {
3191 Style::default()
3193 };
3194
3195 if visible_char_count > max_chars_to_process {
3198 break;
3201 }
3202
3203 if col_offset >= left_col {
3205 let is_tab_start = line_tab_starts.contains(&col_offset);
3207
3208 let is_cursor = byte_pos
3212 .map(|bp| {
3213 if !cursor_positions.contains(&bp) || bp >= state.buffer.len() {
3214 return false;
3215 }
3216 let prev_char_idx = display_char_idx.saturating_sub(1);
3219 let prev_byte_pos =
3220 line_char_source_bytes.get(prev_char_idx).copied().flatten();
3221 display_char_idx == 0 || prev_byte_pos != Some(bp)
3223 })
3224 .unwrap_or(false);
3225
3226 let is_in_block_selection = block_selections.iter().any(
3229 |(start_line, start_col, end_line, end_col)| {
3230 current_source_line_num >= *start_line
3231 && current_source_line_num <= *end_line
3232 && byte_index >= *start_col
3233 && byte_index <= *end_col
3234 },
3235 );
3236
3237 let is_primary_cursor = is_cursor && byte_pos == Some(primary_cursor_position);
3243 let exclude_from_selection = is_cursor && !(is_active && is_primary_cursor);
3244
3245 let is_selected = !exclude_from_selection
3246 && (byte_pos.is_some_and(|bp| {
3247 selection_ranges.iter().any(|range| range.contains(&bp))
3248 }) || is_in_block_selection);
3249
3250 let token_style = line_char_styles
3253 .get(display_char_idx)
3254 .and_then(|s| s.as_ref());
3255 let CharStyleOutput {
3256 style,
3257 is_secondary_cursor,
3258 } = compute_char_style(&CharStyleContext {
3259 byte_pos,
3260 token_style,
3261 ansi_style,
3262 is_cursor,
3263 is_selected,
3264 theme,
3265 highlight_spans,
3266 semantic_token_spans,
3267 viewport_overlays,
3268 primary_cursor_position,
3269 is_active,
3270 });
3271
3272 let tab_indicator: String;
3275 let display_char: &str = if is_cursor && lsp_waiting && is_active {
3276 "⋯"
3277 } else if debug_tracker.is_some() && ch == '\r' {
3278 "\\r"
3280 } else if debug_tracker.is_some() && ch == '\n' {
3281 "\\n"
3283 } else if ch == '\n' {
3284 ""
3285 } else if is_tab_start && state.show_whitespace_tabs {
3286 tab_indicator = "→".to_string();
3288 &tab_indicator
3289 } else {
3290 tab_indicator = ch.to_string();
3291 &tab_indicator
3292 };
3293
3294 if let Some(bp) = byte_pos {
3295 if let Some(vtexts) = virtual_text_lookup.get(&bp) {
3296 for vtext in vtexts
3297 .iter()
3298 .filter(|v| v.position == VirtualTextPosition::BeforeChar)
3299 {
3300 span_acc.flush(&mut line_spans, &mut line_view_map);
3302 let extra_space = if ch == '\n' { " " } else { "" };
3304 let text_with_space = format!("{}{} ", extra_space, vtext.text);
3305 push_span_with_map(
3306 &mut line_spans,
3307 &mut line_view_map,
3308 text_with_space,
3309 vtext.style,
3310 None,
3311 );
3312 }
3313 }
3314 }
3315
3316 if !display_char.is_empty() {
3317 if let Some(ref mut tracker) = debug_tracker {
3319 span_acc.flush(&mut line_spans, &mut line_view_map);
3321 let opening_tags = tracker.get_opening_tags(
3322 byte_pos,
3323 highlight_spans,
3324 viewport_overlays,
3325 );
3326 for tag in opening_tags {
3327 push_debug_tag(&mut line_spans, &mut line_view_map, tag);
3328 }
3329 }
3330
3331 if debug_tracker.is_some() {
3333 if let Some(bp) = byte_pos {
3334 push_debug_tag(
3335 &mut line_spans,
3336 &mut line_view_map,
3337 format!("[{}]", bp),
3338 );
3339 }
3340 }
3341
3342 for c in display_char.chars() {
3345 span_acc.push(c, style, byte_pos, &mut line_spans, &mut line_view_map);
3346 }
3347
3348 if let Some(ref mut tracker) = debug_tracker {
3351 span_acc.flush(&mut line_spans, &mut line_view_map);
3353 let next_byte_pos = byte_pos.map(|bp| bp + ch.len_utf8());
3355 let closing_tags = tracker.get_closing_tags(next_byte_pos);
3356 for tag in closing_tags {
3357 push_debug_tag(&mut line_spans, &mut line_view_map, tag);
3358 }
3359 }
3360 }
3361
3362 if !have_cursor {
3365 if let Some(bp) = byte_pos {
3366 if bp == primary_cursor_position && char_width(ch) == 0 {
3367 cursor_screen_x = gutter_width as u16
3369 + col_offset.saturating_sub(left_col) as u16;
3370 cursor_screen_y = lines.len() as u16;
3371 have_cursor = true;
3372 }
3373 }
3374 }
3375
3376 if let Some(bp) = byte_pos {
3377 if let Some(vtexts) = virtual_text_lookup.get(&bp) {
3378 for vtext in vtexts
3379 .iter()
3380 .filter(|v| v.position == VirtualTextPosition::AfterChar)
3381 {
3382 let text_with_space = format!(" {}", vtext.text);
3383 push_span_with_map(
3384 &mut line_spans,
3385 &mut line_view_map,
3386 text_with_space,
3387 vtext.style,
3388 None,
3389 );
3390 }
3391 }
3392 }
3393
3394 if is_cursor && ch == '\n' {
3395 let should_add_indicator =
3396 if is_active { is_secondary_cursor } else { true };
3397 if should_add_indicator {
3398 span_acc.flush(&mut line_spans, &mut line_view_map);
3401 let cursor_style = if is_active {
3402 Style::default()
3403 .fg(theme.editor_fg)
3404 .bg(theme.editor_bg)
3405 .add_modifier(Modifier::REVERSED)
3406 } else {
3407 Style::default()
3408 .fg(theme.editor_fg)
3409 .bg(theme.inactive_cursor)
3410 };
3411 push_span_with_map(
3412 &mut line_spans,
3413 &mut line_view_map,
3414 " ".to_string(),
3415 cursor_style,
3416 byte_pos,
3417 );
3418 }
3419 }
3420 }
3421
3422 byte_index += ch.len_utf8();
3423 display_char_idx += 1; let ch_width = char_width(ch);
3427 col_offset += ch_width;
3428 visible_char_count += ch_width;
3429 }
3430
3431 span_acc.flush(&mut line_spans, &mut line_view_map);
3433
3434 let content_is_empty = line_content.is_empty();
3438 if line_spans.is_empty() || !line_wrap || content_is_empty {
3439 last_seg_y = Some(lines.len() as u16);
3440 }
3441
3442 if !line_has_newline {
3443 let line_len_chars = line_content.chars().count();
3444
3445 let last_char_idx = line_len_chars.saturating_sub(1);
3447 let after_last_char_idx = line_len_chars;
3448
3449 let last_char_buf_pos =
3450 line_char_source_bytes.get(last_char_idx).copied().flatten();
3451 let after_last_char_buf_pos = line_char_source_bytes
3452 .get(after_last_char_idx)
3453 .copied()
3454 .flatten();
3455
3456 let cursor_at_end = cursor_positions.iter().any(|&pos| {
3457 let matches_after = after_last_char_buf_pos.is_some_and(|bp| pos == bp);
3460 let expected_after_pos = last_char_buf_pos.map(|p| p + 1).unwrap_or(0);
3463 let matches_fallback =
3464 after_last_char_buf_pos.is_none() && pos == expected_after_pos;
3465
3466 matches_after || matches_fallback
3467 });
3468
3469 if cursor_at_end {
3470 let is_primary_at_end = after_last_char_buf_pos
3472 .is_some_and(|bp| bp == primary_cursor_position)
3473 || (after_last_char_buf_pos.is_none()
3474 && primary_cursor_position >= state.buffer.len());
3475
3476 if let Some(seg_y) = last_seg_y {
3478 if is_primary_at_end {
3479 cursor_screen_x = if line_len_chars == 0 {
3484 gutter_width as u16
3485 } else {
3486 gutter_width as u16 + col_offset.saturating_sub(left_col) as u16
3489 };
3490 cursor_screen_y = seg_y;
3491 have_cursor = true;
3492 }
3493 }
3494
3495 let should_add_indicator = if is_active { !is_primary_at_end } else { true };
3496 if should_add_indicator {
3497 let cursor_style = if is_active {
3498 Style::default()
3499 .fg(theme.editor_fg)
3500 .bg(theme.editor_bg)
3501 .add_modifier(Modifier::REVERSED)
3502 } else {
3503 Style::default()
3504 .fg(theme.editor_fg)
3505 .bg(theme.inactive_cursor)
3506 };
3507 push_span_with_map(
3508 &mut line_spans,
3509 &mut line_view_map,
3510 " ".to_string(),
3511 cursor_style,
3512 None,
3513 );
3514 }
3515 }
3516 }
3517
3518 let current_y = lines.len() as u16;
3521 last_seg_y = Some(current_y);
3522
3523 if !line_spans.is_empty() {
3524 for (screen_x, source_offset) in line_view_map.iter().enumerate() {
3527 if let Some(src) = source_offset {
3528 if *src == primary_cursor_position && !have_cursor {
3532 cursor_screen_x = screen_x as u16;
3533 cursor_screen_y = current_y;
3534 have_cursor = true;
3535 }
3536 last_visible_x = screen_x as u16;
3539 }
3540 }
3541 }
3542
3543 if !line_wrap {
3546 let content_width = render_area.width.saturating_sub(gutter_width as u16) as usize;
3548 let remaining_cols = content_width.saturating_sub(visible_char_count);
3549
3550 if remaining_cols > 0 {
3551 let fill_style: Option<Style> = if let (Some(start), Some(end)) =
3554 (first_line_byte_pos, last_line_byte_pos)
3555 {
3556 viewport_overlays
3557 .iter()
3558 .filter(|(overlay, range)| {
3559 overlay.extend_to_line_end
3560 && range.start <= end
3561 && range.end >= start
3562 })
3563 .max_by_key(|(o, _)| o.priority)
3564 .and_then(|(overlay, _)| {
3565 match &overlay.face {
3566 crate::view::overlay::OverlayFace::Background { color } => {
3567 Some(Style::default().fg(*color).bg(*color))
3569 }
3570 crate::view::overlay::OverlayFace::Style { style } => {
3571 style.bg.map(|bg| Style::default().fg(bg).bg(bg))
3574 }
3575 _ => None,
3576 }
3577 })
3578 } else {
3579 None
3580 };
3581
3582 if let Some(fill_bg) = fill_style {
3583 let fill_text = " ".repeat(remaining_cols);
3584 push_span_with_map(
3585 &mut line_spans,
3586 &mut line_view_map,
3587 fill_text,
3588 fill_bg,
3589 None,
3590 );
3591 }
3592 }
3593 }
3594
3595 let line_end_byte = if current_view_line.ends_with_newline {
3597 current_view_line
3599 .char_source_bytes
3600 .iter()
3601 .rev()
3602 .find_map(|m| *m)
3603 .unwrap_or(0)
3604 } else {
3605 if let Some((char_idx, &Some(last_byte_start))) = current_view_line
3607 .char_source_bytes
3608 .iter()
3609 .enumerate()
3610 .rev()
3611 .find(|(_, m)| m.is_some())
3612 {
3613 if let Some(last_char) = current_view_line.text.chars().nth(char_idx) {
3615 last_byte_start + last_char.len_utf8()
3616 } else {
3617 last_byte_start
3618 }
3619 } else {
3620 0
3621 }
3622 };
3623
3624 let content_map = if line_view_map.len() >= gutter_width {
3627 line_view_map[gutter_width..].to_vec()
3628 } else {
3629 Vec::new()
3630 };
3631 view_line_mappings.push(ViewLineMapping {
3632 char_source_bytes: content_map.clone(),
3633 visual_to_char: (0..content_map.len()).collect(),
3634 line_end_byte,
3635 });
3636
3637 let line_was_empty = line_spans.is_empty();
3639 lines.push(Line::from(line_spans));
3640
3641 if let Some(y) = last_seg_y {
3644 let end_x = if line_was_empty {
3648 gutter_width as u16
3649 } else {
3650 last_visible_x.saturating_add(1)
3651 };
3652 let line_len_chars = line_content.chars().count();
3653
3654 last_line_end = Some(LastLineEnd {
3655 pos: (end_x, y),
3656 terminated_with_newline: line_has_newline,
3657 });
3658
3659 if line_has_newline && line_len_chars > 0 {
3660 let newline_idx = line_len_chars.saturating_sub(1);
3661 if let Some(Some(src_newline)) = line_char_source_bytes.get(newline_idx) {
3662 if *src_newline == primary_cursor_position {
3663 if line_len_chars == 1 {
3667 cursor_screen_x = gutter_width as u16;
3669 cursor_screen_y = y;
3670 } else {
3671 cursor_screen_x = end_x;
3674 cursor_screen_y = y;
3675 }
3676 have_cursor = true;
3677 }
3678 }
3679 }
3680 }
3681
3682 if lines_rendered >= visible_line_count {
3683 break;
3684 }
3685 }
3686
3687 if let Some(ref end) = last_line_end {
3690 if end.terminated_with_newline && lines_rendered < visible_line_count {
3691 let mut implicit_line_spans = Vec::new();
3693 let implicit_line_num = current_source_line_num + 1;
3694
3695 if state.margins.left_config.enabled {
3696 implicit_line_spans.push(Span::styled(" ", Style::default()));
3698
3699 let estimated_lines = (state.buffer.len() / 80).max(1);
3701 let margin_content = state.margins.render_line(
3702 implicit_line_num,
3703 crate::view::margin::MarginPosition::Left,
3704 estimated_lines,
3705 );
3706 let (rendered_text, style_opt) =
3707 margin_content.render(state.margins.left_config.width);
3708 let margin_style =
3709 style_opt.unwrap_or_else(|| Style::default().fg(theme.line_number_fg));
3710 implicit_line_spans.push(Span::styled(rendered_text, margin_style));
3711
3712 if state.margins.left_config.show_separator {
3714 implicit_line_spans.push(Span::styled(
3715 state.margins.left_config.separator.to_string(),
3716 Style::default().fg(theme.line_number_fg),
3717 ));
3718 }
3719 }
3720
3721 let implicit_y = lines.len() as u16;
3722 lines.push(Line::from(implicit_line_spans));
3723 lines_rendered += 1;
3724
3725 let buffer_len = state.buffer.len();
3728
3729 view_line_mappings.push(ViewLineMapping {
3730 char_source_bytes: Vec::new(),
3731 visual_to_char: Vec::new(),
3732 line_end_byte: buffer_len,
3733 });
3734
3735 if primary_cursor_position == state.buffer.len() && !have_cursor {
3741 cursor_screen_x = gutter_width as u16;
3742 cursor_screen_y = implicit_y;
3743 have_cursor = true;
3744 }
3745 }
3746 }
3747
3748 let eof_fg = dim_color_for_tilde(theme.line_number_fg);
3758 let eof_style = Style::default().fg(eof_fg);
3759 while lines.len() < render_area.height as usize {
3760 let tilde_line = format!(
3762 "~{}",
3763 " ".repeat(render_area.width.saturating_sub(1) as usize)
3764 );
3765 lines.push(Line::styled(tilde_line, eof_style));
3766 }
3767
3768 LineRenderOutput {
3769 lines,
3770 cursor: have_cursor.then_some((cursor_screen_x, cursor_screen_y)),
3771 last_line_end,
3772 content_lines_rendered: lines_rendered,
3773 view_line_mappings,
3774 }
3775 }
3776
3777 fn resolve_cursor_fallback(
3778 current_cursor: Option<(u16, u16)>,
3779 primary_cursor_position: usize,
3780 buffer_len: usize,
3781 buffer_ends_with_newline: bool,
3782 last_line_end: Option<LastLineEnd>,
3783 lines_rendered: usize,
3784 gutter_width: usize,
3785 ) -> Option<(u16, u16)> {
3786 if current_cursor.is_some() || primary_cursor_position != buffer_len {
3787 return current_cursor;
3788 }
3789
3790 if buffer_ends_with_newline {
3791 if let Some(end) = last_line_end {
3792 return Some((gutter_width as u16, end.pos.1.saturating_add(1)));
3795 }
3796 return Some((gutter_width as u16, lines_rendered as u16));
3797 }
3798
3799 last_line_end.map(|end| end.pos)
3800 }
3801
3802 #[allow(clippy::too_many_arguments)]
3805 fn render_buffer_in_split(
3806 frame: &mut Frame,
3807 state: &mut EditorState,
3808 viewport: &mut crate::view::viewport::Viewport,
3809 event_log: Option<&mut EventLog>,
3810 area: Rect,
3811 is_active: bool,
3812 theme: &crate::view::theme::Theme,
3813 ansi_background: Option<&AnsiBackground>,
3814 background_fade: f32,
3815 lsp_waiting: bool,
3816 view_mode: ViewMode,
3817 compose_width: Option<u16>,
3818 compose_column_guides: Option<Vec<u16>>,
3819 view_transform: Option<ViewTransformPayload>,
3820 estimated_line_length: usize,
3821 highlight_context_bytes: usize,
3822 _buffer_id: BufferId,
3823 hide_cursor: bool,
3824 relative_line_numbers: bool,
3825 use_terminal_bg: bool,
3826 ) -> Vec<ViewLineMapping> {
3827 let _span = tracing::trace_span!("render_buffer_in_split").entered();
3828
3829 let effective_editor_bg = if use_terminal_bg {
3831 ratatui::style::Color::Reset
3832 } else {
3833 theme.editor_bg
3834 };
3835
3836 let line_wrap = viewport.line_wrap_enabled;
3837
3838 let overlay_count = state.overlays.all().len();
3839 if overlay_count > 0 {
3840 tracing::trace!("render_content: {} overlays present", overlay_count);
3841 }
3842
3843 let visible_count = viewport.visible_line_count();
3844
3845 let buffer_len = state.buffer.len();
3846 let estimated_lines = (buffer_len / 80).max(1);
3847 state.margins.update_width_for_buffer(estimated_lines);
3848 let gutter_width = state.margins.left_total_width();
3849
3850 let compose_layout = Self::calculate_compose_layout(area, &view_mode, compose_width);
3851 let render_area = compose_layout.render_area;
3852
3853 let view_transform_for_rebuild = view_transform.clone();
3855
3856 let view_data = Self::build_view_data(
3857 state,
3858 viewport,
3859 view_transform,
3860 estimated_line_length,
3861 visible_count,
3862 line_wrap,
3863 render_area.width as usize,
3864 gutter_width,
3865 );
3866
3867 let primary = *state.cursors.primary();
3870 let scrolled = viewport.ensure_visible_in_layout(&view_data.lines, &primary, gutter_width);
3871
3872 let view_data = if scrolled {
3875 Self::build_view_data(
3876 state,
3877 viewport,
3878 view_transform_for_rebuild,
3879 estimated_line_length,
3880 visible_count,
3881 line_wrap,
3882 render_area.width as usize,
3883 gutter_width,
3884 )
3885 } else {
3886 view_data
3887 };
3888
3889 let view_anchor = Self::calculate_view_anchor(&view_data.lines, viewport.top_byte);
3890 Self::render_compose_margins(
3891 frame,
3892 area,
3893 &compose_layout,
3894 &view_mode,
3895 theme,
3896 effective_editor_bg,
3897 );
3898
3899 let selection = Self::selection_context(state);
3900
3901 tracing::trace!(
3902 "Rendering buffer with {} cursors at positions: {:?}, primary at {}, is_active: {}, buffer_len: {}",
3903 selection.cursor_positions.len(),
3904 selection.cursor_positions,
3905 selection.primary_cursor_position,
3906 is_active,
3907 state.buffer.len()
3908 );
3909
3910 if !selection.cursor_positions.is_empty()
3911 && !selection
3912 .cursor_positions
3913 .contains(&selection.primary_cursor_position)
3914 {
3915 tracing::warn!(
3916 "Primary cursor position {} not found in cursor_positions list: {:?}",
3917 selection.primary_cursor_position,
3918 selection.cursor_positions
3919 );
3920 }
3921
3922 let starting_line_num = state
3923 .buffer
3924 .populate_line_cache(viewport.top_byte, visible_count);
3925
3926 let viewport_start = viewport.top_byte;
3927 let viewport_end = Self::calculate_viewport_end(
3928 state,
3929 viewport_start,
3930 estimated_line_length,
3931 visible_count,
3932 );
3933
3934 let decorations = Self::decoration_context(
3935 state,
3936 viewport_start,
3937 viewport_end,
3938 selection.primary_cursor_position,
3939 theme,
3940 highlight_context_bytes,
3941 );
3942
3943 let calculated_offset = viewport.top_view_line_offset;
3950
3951 tracing::trace!(
3952 top_byte = viewport.top_byte,
3953 top_view_line_offset = viewport.top_view_line_offset,
3954 calculated_offset,
3955 view_data_lines = view_data.lines.len(),
3956 "view line offset calculation"
3957 );
3958 let (view_lines_to_render, adjusted_starting_line_num, adjusted_view_anchor) =
3959 if calculated_offset > 0 && calculated_offset < view_data.lines.len() {
3960 let sliced = &view_data.lines[calculated_offset..];
3961
3962 let skipped_lines = &view_data.lines[..calculated_offset];
3965 let skipped_source_lines = skipped_lines
3966 .iter()
3967 .filter(|vl| should_show_line_number(vl))
3968 .count();
3969
3970 let adjusted_line_num = starting_line_num + skipped_source_lines;
3971
3972 let adjusted_anchor = Self::calculate_view_anchor(sliced, viewport.top_byte);
3974
3975 (sliced, adjusted_line_num, adjusted_anchor)
3976 } else {
3977 (&view_data.lines[..], starting_line_num, view_anchor)
3978 };
3979
3980 let render_output = Self::render_view_lines(LineRenderInput {
3981 state,
3982 theme,
3983 view_lines: view_lines_to_render,
3984 view_anchor: adjusted_view_anchor,
3985 render_area,
3986 gutter_width,
3987 selection: &selection,
3988 decorations: &decorations,
3989 starting_line_num: adjusted_starting_line_num,
3990 visible_line_count: visible_count,
3991 lsp_waiting,
3992 is_active,
3993 line_wrap,
3994 estimated_lines,
3995 left_column: viewport.left_column,
3996 relative_line_numbers,
3997 });
3998
3999 let mut lines = render_output.lines;
4000 let background_x_offset = viewport.left_column;
4001
4002 if let Some(bg) = ansi_background {
4003 Self::apply_background_to_lines(
4004 &mut lines,
4005 render_area.width,
4006 bg,
4007 effective_editor_bg,
4008 theme.editor_fg,
4009 background_fade,
4010 background_x_offset,
4011 starting_line_num,
4012 );
4013 }
4014
4015 frame.render_widget(Clear, render_area);
4016 let editor_block = Block::default()
4017 .borders(Borders::NONE)
4018 .style(Style::default().bg(effective_editor_bg));
4019 frame.render_widget(Paragraph::new(lines).block(editor_block), render_area);
4020
4021 if let Some(guides) = compose_column_guides {
4023 let guide_style = Style::default()
4024 .fg(theme.line_number_fg)
4025 .add_modifier(Modifier::DIM);
4026 let guide_height = render_output
4027 .content_lines_rendered
4028 .min(render_area.height as usize);
4029
4030 for col in guides {
4031 let guide_x = render_area.x + gutter_width as u16 + col;
4033
4034 if guide_x >= render_area.x && guide_x < render_area.x + render_area.width {
4036 for row in 0..guide_height {
4037 let cell_area = Rect::new(guide_x, render_area.y + row as u16, 1, 1);
4038 let guide_char = Paragraph::new("│").style(guide_style);
4039 frame.render_widget(guide_char, cell_area);
4040 }
4041 }
4042 }
4043 }
4044
4045 let buffer_ends_with_newline = if !state.buffer.is_empty() {
4046 let last_char = state.get_text_range(state.buffer.len() - 1, state.buffer.len());
4047 last_char == "\n"
4048 } else {
4049 false
4050 };
4051
4052 let cursor = Self::resolve_cursor_fallback(
4053 render_output.cursor,
4054 selection.primary_cursor_position,
4055 state.buffer.len(),
4056 buffer_ends_with_newline,
4057 render_output.last_line_end,
4058 render_output.content_lines_rendered,
4059 gutter_width,
4060 );
4061
4062 if is_active && state.show_cursors && !hide_cursor {
4063 if let Some((cursor_screen_x, cursor_screen_y)) = cursor {
4064 let screen_x = render_area.x.saturating_add(cursor_screen_x);
4066
4067 let max_y = render_area.height.saturating_sub(1);
4072 let clamped_cursor_y = cursor_screen_y.min(max_y);
4073 let screen_y = render_area.y.saturating_add(clamped_cursor_y);
4074
4075 frame.set_cursor_position((screen_x, screen_y));
4076
4077 if let Some(event_log) = event_log {
4078 let cursor_pos = state.cursors.primary().position;
4079 let buffer_len = state.buffer.len();
4080 event_log.log_render_state(cursor_pos, screen_x, screen_y, buffer_len);
4081 }
4082 }
4083 }
4084
4085 render_output.view_line_mappings
4088 }
4089
4090 #[allow(clippy::too_many_arguments)]
4101 fn apply_background_to_lines(
4102 lines: &mut Vec<Line<'static>>,
4103 area_width: u16,
4104 background: &AnsiBackground,
4105 theme_bg: Color,
4106 default_fg: Color,
4107 fade: f32,
4108 x_offset: usize,
4109 y_offset: usize,
4110 ) {
4111 if area_width == 0 {
4112 return;
4113 }
4114
4115 let width = area_width as usize;
4116
4117 for (y, line) in lines.iter_mut().enumerate() {
4118 let mut existing: Vec<(char, Style)> = Vec::new();
4120 let spans = std::mem::take(&mut line.spans);
4121 for span in spans {
4122 let style = span.style;
4123 for ch in span.content.chars() {
4124 existing.push((ch, style));
4125 }
4126 }
4127
4128 let mut chars_with_style = Vec::with_capacity(width);
4129 for x in 0..width {
4130 let sample_x = x_offset + x;
4131 let sample_y = y_offset + y;
4132
4133 let (ch, mut style) = if x < existing.len() {
4134 existing[x]
4135 } else {
4136 (' ', Style::default().fg(default_fg))
4137 };
4138
4139 if let Some(bg_color) = background.faded_color(sample_x, sample_y, theme_bg, fade) {
4140 if style.bg.is_none() || matches!(style.bg, Some(Color::Reset)) {
4141 style = style.bg(bg_color);
4142 }
4143 }
4144
4145 chars_with_style.push((ch, style));
4146 }
4147
4148 line.spans = Self::compress_chars(chars_with_style);
4149 }
4150 }
4151
4152 fn compress_chars(chars: Vec<(char, Style)>) -> Vec<Span<'static>> {
4153 if chars.is_empty() {
4154 return vec![];
4155 }
4156
4157 let mut spans = Vec::new();
4158 let mut current_style = chars[0].1;
4159 let mut current_text = String::new();
4160 current_text.push(chars[0].0);
4161
4162 for (ch, style) in chars.into_iter().skip(1) {
4163 if style == current_style {
4164 current_text.push(ch);
4165 } else {
4166 spans.push(Span::styled(current_text.clone(), current_style));
4167 current_text.clear();
4168 current_text.push(ch);
4169 current_style = style;
4170 }
4171 }
4172
4173 spans.push(Span::styled(current_text, current_style));
4174 spans
4175 }
4176}
4177
4178#[cfg(test)]
4179mod tests {
4180 use super::*;
4181 use crate::model::buffer::Buffer;
4182 use crate::primitives::display_width::str_width;
4183 use crate::view::theme;
4184 use crate::view::theme::Theme;
4185 use crate::view::viewport::Viewport;
4186
4187 fn render_output_for(
4188 content: &str,
4189 cursor_pos: usize,
4190 ) -> (LineRenderOutput, usize, bool, usize) {
4191 render_output_for_with_gutters(content, cursor_pos, false)
4192 }
4193
4194 fn render_output_for_with_gutters(
4195 content: &str,
4196 cursor_pos: usize,
4197 gutters_enabled: bool,
4198 ) -> (LineRenderOutput, usize, bool, usize) {
4199 let mut state = EditorState::new(20, 6, 1024);
4200 state.buffer = Buffer::from_str(content, 1024);
4201 state.cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
4202 let viewport = Viewport::new(20, 4);
4204 state.margins.left_config.enabled = gutters_enabled;
4206
4207 let render_area = Rect::new(0, 0, 20, 4);
4208 let visible_count = viewport.visible_line_count();
4209 let gutter_width = state.margins.left_total_width();
4210
4211 let view_data = SplitRenderer::build_view_data(
4212 &mut state,
4213 &viewport,
4214 None,
4215 content.len().max(1),
4216 visible_count,
4217 false, render_area.width as usize,
4219 gutter_width,
4220 );
4221 let view_anchor = SplitRenderer::calculate_view_anchor(&view_data.lines, 0);
4222
4223 let estimated_lines = (state.buffer.len() / 80).max(1);
4224 state.margins.update_width_for_buffer(estimated_lines);
4225 let gutter_width = state.margins.left_total_width();
4226
4227 let selection = SplitRenderer::selection_context(&state);
4228 let starting_line_num = state
4229 .buffer
4230 .populate_line_cache(viewport.top_byte, visible_count);
4231 let viewport_start = viewport.top_byte;
4232 let viewport_end = SplitRenderer::calculate_viewport_end(
4233 &mut state,
4234 viewport_start,
4235 content.len().max(1),
4236 visible_count,
4237 );
4238 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
4239 let decorations = SplitRenderer::decoration_context(
4240 &mut state,
4241 viewport_start,
4242 viewport_end,
4243 selection.primary_cursor_position,
4244 &theme,
4245 100_000, );
4247
4248 let output = SplitRenderer::render_view_lines(LineRenderInput {
4249 state: &state,
4250 theme: &theme,
4251 view_lines: &view_data.lines,
4252 view_anchor,
4253 render_area,
4254 gutter_width,
4255 selection: &selection,
4256 decorations: &decorations,
4257 starting_line_num,
4258 visible_line_count: visible_count,
4259 lsp_waiting: false,
4260 is_active: true,
4261 line_wrap: viewport.line_wrap_enabled,
4262 estimated_lines,
4263 left_column: viewport.left_column,
4264 relative_line_numbers: false,
4265 });
4266
4267 (
4268 output,
4269 state.buffer.len(),
4270 content.ends_with('\n'),
4271 selection.primary_cursor_position,
4272 )
4273 }
4274
4275 #[test]
4276 fn last_line_end_tracks_trailing_newline() {
4277 let output = render_output_for("abc\n", 4);
4278 assert_eq!(
4279 output.0.last_line_end,
4280 Some(LastLineEnd {
4281 pos: (3, 0),
4282 terminated_with_newline: true
4283 })
4284 );
4285 }
4286
4287 #[test]
4288 fn last_line_end_tracks_no_trailing_newline() {
4289 let output = render_output_for("abc", 3);
4290 assert_eq!(
4291 output.0.last_line_end,
4292 Some(LastLineEnd {
4293 pos: (3, 0),
4294 terminated_with_newline: false
4295 })
4296 );
4297 }
4298
4299 #[test]
4300 fn cursor_after_newline_places_on_next_line() {
4301 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
4302 let cursor = SplitRenderer::resolve_cursor_fallback(
4303 output.cursor,
4304 cursor_pos,
4305 buffer_len,
4306 buffer_newline,
4307 output.last_line_end,
4308 output.content_lines_rendered,
4309 0, );
4311 assert_eq!(cursor, Some((0, 1)));
4312 }
4313
4314 #[test]
4315 fn cursor_at_end_without_newline_stays_on_line() {
4316 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
4317 let cursor = SplitRenderer::resolve_cursor_fallback(
4318 output.cursor,
4319 cursor_pos,
4320 buffer_len,
4321 buffer_newline,
4322 output.last_line_end,
4323 output.content_lines_rendered,
4324 0, );
4326 assert_eq!(cursor, Some((3, 0)));
4327 }
4328
4329 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
4335 let mut cursor_positions = Vec::new();
4336
4337 let primary_cursor = output.cursor;
4339 if let Some(cursor_pos) = primary_cursor {
4340 cursor_positions.push(cursor_pos);
4341 }
4342
4343 for (line_idx, line) in output.lines.iter().enumerate() {
4345 let mut col = 0u16;
4346 for span in line.spans.iter() {
4347 if span
4349 .style
4350 .add_modifier
4351 .contains(ratatui::style::Modifier::REVERSED)
4352 {
4353 let pos = (col, line_idx as u16);
4354 if primary_cursor != Some(pos) {
4357 cursor_positions.push(pos);
4358 }
4359 }
4360 col += str_width(&span.content) as u16;
4362 }
4363 }
4364
4365 cursor_positions
4366 }
4367
4368 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
4370 eprintln!("\n=== RENDER DEBUG ===");
4371 eprintln!("Content: {:?}", content);
4372 eprintln!("Cursor position: {}", cursor_pos);
4373 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
4374 eprintln!("Last line end: {:?}", output.last_line_end);
4375 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
4376 eprintln!("\nRendered lines:");
4377 for (line_idx, line) in output.lines.iter().enumerate() {
4378 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
4379 for (span_idx, span) in line.spans.iter().enumerate() {
4380 let has_reversed = span
4381 .style
4382 .add_modifier
4383 .contains(ratatui::style::Modifier::REVERSED);
4384 let bg_color = format!("{:?}", span.style.bg);
4385 eprintln!(
4386 " Span {}: {:?} (REVERSED: {}, BG: {})",
4387 span_idx, span.content, has_reversed, bg_color
4388 );
4389 }
4390 }
4391 eprintln!("===================\n");
4392 }
4393
4394 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
4397 let (output, buffer_len, buffer_newline, cursor_pos) =
4398 render_output_for(content, cursor_pos);
4399
4400 let all_cursors = count_all_cursors(&output);
4402
4403 assert!(
4406 all_cursors.len() <= 1,
4407 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
4408 all_cursors.len(),
4409 all_cursors
4410 );
4411
4412 let final_cursor = SplitRenderer::resolve_cursor_fallback(
4413 output.cursor,
4414 cursor_pos,
4415 buffer_len,
4416 buffer_newline,
4417 output.last_line_end,
4418 output.content_lines_rendered,
4419 0, );
4421
4422 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
4424 {
4425 dump_render_output(content, cursor_pos, &output);
4426 }
4427
4428 if let Some(rendered_cursor) = all_cursors.first() {
4430 assert_eq!(
4431 Some(*rendered_cursor),
4432 final_cursor,
4433 "Rendered cursor at {:?} doesn't match final cursor {:?}",
4434 rendered_cursor,
4435 final_cursor
4436 );
4437 }
4438
4439 assert!(
4441 final_cursor.is_some(),
4442 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
4443 all_cursors
4444 );
4445
4446 final_cursor
4447 }
4448
4449 fn check_typing_at_cursor(
4451 content: &str,
4452 cursor_pos: usize,
4453 char_to_type: char,
4454 ) -> (Option<(u16, u16)>, String) {
4455 let cursor_before = get_final_cursor(content, cursor_pos);
4457
4458 let mut new_content = content.to_string();
4460 if cursor_pos <= content.len() {
4461 new_content.insert(cursor_pos, char_to_type);
4462 }
4463
4464 (cursor_before, new_content)
4465 }
4466
4467 #[test]
4468 fn e2e_cursor_at_start_of_nonempty_line() {
4469 let cursor = get_final_cursor("abc", 0);
4471 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
4472
4473 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
4474 assert_eq!(
4475 new_content, "Xabc",
4476 "Typing should insert at cursor position"
4477 );
4478 assert_eq!(cursor_pos, Some((0, 0)));
4479 }
4480
4481 #[test]
4482 fn e2e_cursor_in_middle_of_line() {
4483 let cursor = get_final_cursor("abc", 1);
4485 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
4486
4487 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
4488 assert_eq!(
4489 new_content, "aXbc",
4490 "Typing should insert at cursor position"
4491 );
4492 assert_eq!(cursor_pos, Some((1, 0)));
4493 }
4494
4495 #[test]
4496 fn e2e_cursor_at_end_of_line_no_newline() {
4497 let cursor = get_final_cursor("abc", 3);
4499 assert_eq!(
4500 cursor,
4501 Some((3, 0)),
4502 "Cursor should be at column 3, line 0 (after last char)"
4503 );
4504
4505 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
4506 assert_eq!(new_content, "abcX", "Typing should append at end");
4507 assert_eq!(cursor_pos, Some((3, 0)));
4508 }
4509
4510 #[test]
4511 fn e2e_cursor_at_empty_line() {
4512 let cursor = get_final_cursor("\n", 0);
4514 assert_eq!(
4515 cursor,
4516 Some((0, 0)),
4517 "Cursor on empty line should be at column 0"
4518 );
4519
4520 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
4521 assert_eq!(new_content, "X\n", "Typing should insert before newline");
4522 assert_eq!(cursor_pos, Some((0, 0)));
4523 }
4524
4525 #[test]
4526 fn e2e_cursor_after_newline_at_eof() {
4527 let cursor = get_final_cursor("abc\n", 4);
4529 assert_eq!(
4530 cursor,
4531 Some((0, 1)),
4532 "Cursor after newline at EOF should be on next line"
4533 );
4534
4535 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
4536 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
4537 assert_eq!(cursor_pos, Some((0, 1)));
4538 }
4539
4540 #[test]
4541 fn e2e_cursor_on_newline_with_content() {
4542 let cursor = get_final_cursor("abc\n", 3);
4544 assert_eq!(
4545 cursor,
4546 Some((3, 0)),
4547 "Cursor on newline after content should be after last char"
4548 );
4549
4550 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
4551 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
4552 assert_eq!(cursor_pos, Some((3, 0)));
4553 }
4554
4555 #[test]
4556 fn e2e_cursor_multiline_start_of_second_line() {
4557 let cursor = get_final_cursor("abc\ndef", 4);
4559 assert_eq!(
4560 cursor,
4561 Some((0, 1)),
4562 "Cursor at start of second line should be at column 0, line 1"
4563 );
4564
4565 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
4566 assert_eq!(
4567 new_content, "abc\nXdef",
4568 "Typing should insert at start of second line"
4569 );
4570 assert_eq!(cursor_pos, Some((0, 1)));
4571 }
4572
4573 #[test]
4574 fn e2e_cursor_multiline_end_of_first_line() {
4575 let cursor = get_final_cursor("abc\ndef", 3);
4577 assert_eq!(
4578 cursor,
4579 Some((3, 0)),
4580 "Cursor on newline of first line should be after content"
4581 );
4582
4583 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
4584 assert_eq!(
4585 new_content, "abcX\ndef",
4586 "Typing should insert before newline"
4587 );
4588 assert_eq!(cursor_pos, Some((3, 0)));
4589 }
4590
4591 #[test]
4592 fn e2e_cursor_empty_buffer() {
4593 let cursor = get_final_cursor("", 0);
4595 assert_eq!(
4596 cursor,
4597 Some((0, 0)),
4598 "Cursor in empty buffer should be at origin"
4599 );
4600
4601 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
4602 assert_eq!(
4603 new_content, "X",
4604 "Typing in empty buffer should insert character"
4605 );
4606 assert_eq!(cursor_pos, Some((0, 0)));
4607 }
4608
4609 #[test]
4610 fn e2e_cursor_empty_buffer_with_gutters() {
4611 let (output, buffer_len, buffer_newline, cursor_pos) =
4615 render_output_for_with_gutters("", 0, true);
4616
4617 let gutter_width = {
4621 let mut state = EditorState::new(20, 6, 1024);
4622 state.margins.left_config.enabled = true;
4623 state.margins.update_width_for_buffer(1);
4624 state.margins.left_total_width()
4625 };
4626 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
4627
4628 assert_eq!(
4632 output.cursor,
4633 Some((gutter_width as u16, 0)),
4634 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
4635 gutter_width,
4636 output.cursor
4637 );
4638
4639 let final_cursor = SplitRenderer::resolve_cursor_fallback(
4640 output.cursor,
4641 cursor_pos,
4642 buffer_len,
4643 buffer_newline,
4644 output.last_line_end,
4645 output.content_lines_rendered,
4646 gutter_width,
4647 );
4648
4649 assert_eq!(
4651 final_cursor,
4652 Some((gutter_width as u16, 0)),
4653 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
4654 );
4655 }
4656
4657 #[test]
4658 fn e2e_cursor_between_empty_lines() {
4659 let cursor = get_final_cursor("\n\n", 1);
4661 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
4662
4663 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
4664 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
4665 assert_eq!(cursor_pos, Some((0, 1)));
4666 }
4667
4668 #[test]
4669 fn e2e_cursor_at_eof_after_multiple_lines() {
4670 let cursor = get_final_cursor("abc\ndef\nghi", 11);
4672 assert_eq!(
4673 cursor,
4674 Some((3, 2)),
4675 "Cursor at EOF after 'i' should be at column 3, line 2"
4676 );
4677
4678 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
4679 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
4680 assert_eq!(cursor_pos, Some((3, 2)));
4681 }
4682
4683 #[test]
4684 fn e2e_cursor_at_eof_with_trailing_newline() {
4685 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
4687 assert_eq!(
4688 cursor,
4689 Some((0, 3)),
4690 "Cursor after trailing newline should be on line 3"
4691 );
4692
4693 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
4694 assert_eq!(
4695 new_content, "abc\ndef\nghi\nX",
4696 "Typing should insert on new line"
4697 );
4698 assert_eq!(cursor_pos, Some((0, 3)));
4699 }
4700
4701 #[test]
4702 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
4703 let content = "abc\ndef\nghi";
4705
4706 let cursor_at_start = get_final_cursor(content, 0);
4708 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
4709
4710 let cursor_at_eof = get_final_cursor(content, 11);
4712 assert_eq!(
4713 cursor_at_eof,
4714 Some((3, 2)),
4715 "After Ctrl+End, cursor at column 3, line 2"
4716 );
4717
4718 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
4720 assert_eq!(cursor_before_typing, Some((3, 2)));
4721 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
4722
4723 let cursor_after_typing = get_final_cursor(&new_content, 12);
4725 assert_eq!(
4726 cursor_after_typing,
4727 Some((4, 2)),
4728 "After typing, cursor moved to column 4"
4729 );
4730
4731 let cursor_moved_away = get_final_cursor(&new_content, 0);
4733 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
4734 }
4737
4738 #[test]
4739 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
4740 let content = "abc\ndef\nghi\n";
4742
4743 let cursor_at_start = get_final_cursor(content, 0);
4745 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
4746
4747 let cursor_at_eof = get_final_cursor(content, 12);
4749 assert_eq!(
4750 cursor_at_eof,
4751 Some((0, 3)),
4752 "After Ctrl+End, cursor at column 0, line 3 (new line)"
4753 );
4754
4755 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
4757 assert_eq!(cursor_before_typing, Some((0, 3)));
4758 assert_eq!(
4759 new_content, "abc\ndef\nghi\nX",
4760 "Character inserted on new line"
4761 );
4762
4763 let cursor_after_typing = get_final_cursor(&new_content, 13);
4765 assert_eq!(
4766 cursor_after_typing,
4767 Some((1, 3)),
4768 "After typing, cursor should be at column 1, line 3"
4769 );
4770
4771 let cursor_moved_away = get_final_cursor(&new_content, 4);
4773 assert_eq!(
4774 cursor_moved_away,
4775 Some((0, 1)),
4776 "Cursor moved to start of line 1 (position 4 = start of 'def')"
4777 );
4778 }
4779
4780 #[test]
4781 fn e2e_jump_to_end_of_empty_buffer() {
4782 let content = "";
4784
4785 let cursor_at_eof = get_final_cursor(content, 0);
4786 assert_eq!(
4787 cursor_at_eof,
4788 Some((0, 0)),
4789 "Empty buffer: cursor at origin"
4790 );
4791
4792 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
4794 assert_eq!(cursor_before_typing, Some((0, 0)));
4795 assert_eq!(new_content, "X", "Character inserted");
4796
4797 let cursor_after_typing = get_final_cursor(&new_content, 1);
4799 assert_eq!(
4800 cursor_after_typing,
4801 Some((1, 0)),
4802 "After typing, cursor at column 1"
4803 );
4804
4805 let cursor_moved_away = get_final_cursor(&new_content, 0);
4807 assert_eq!(
4808 cursor_moved_away,
4809 Some((0, 0)),
4810 "Cursor moved back to start"
4811 );
4812 }
4813
4814 #[test]
4815 fn e2e_jump_to_end_of_single_empty_line() {
4816 let content = "\n";
4818
4819 let cursor_on_newline = get_final_cursor(content, 0);
4821 assert_eq!(
4822 cursor_on_newline,
4823 Some((0, 0)),
4824 "Cursor on the newline character"
4825 );
4826
4827 let cursor_at_eof = get_final_cursor(content, 1);
4829 assert_eq!(
4830 cursor_at_eof,
4831 Some((0, 1)),
4832 "After Ctrl+End, cursor on line 1"
4833 );
4834
4835 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
4837 assert_eq!(cursor_before_typing, Some((0, 1)));
4838 assert_eq!(new_content, "\nX", "Character on second line");
4839
4840 let cursor_after_typing = get_final_cursor(&new_content, 2);
4841 assert_eq!(
4842 cursor_after_typing,
4843 Some((1, 1)),
4844 "After typing, cursor at column 1, line 1"
4845 );
4846
4847 let cursor_moved_away = get_final_cursor(&new_content, 0);
4849 assert_eq!(
4850 cursor_moved_away,
4851 Some((0, 0)),
4852 "Cursor moved to the newline on line 0"
4853 );
4854 }
4855 use crate::model::buffer::LineEnding;
4866 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4867
4868 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
4870 tokens
4871 .iter()
4872 .map(|t| {
4873 let kind_str = match &t.kind {
4874 ViewTokenWireKind::Text(s) => format!("Text({})", s),
4875 ViewTokenWireKind::Newline => "Newline".to_string(),
4876 ViewTokenWireKind::Space => "Space".to_string(),
4877 ViewTokenWireKind::Break => "Break".to_string(),
4878 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
4879 };
4880 (kind_str, t.source_offset)
4881 })
4882 .collect()
4883 }
4884
4885 #[test]
4888 fn test_build_base_tokens_crlf_single_line() {
4889 let content = b"abc\r\n";
4891 let mut buffer = Buffer::from_bytes(content.to_vec());
4892 buffer.set_line_ending(LineEnding::CRLF);
4893
4894 let tokens = SplitRenderer::build_base_tokens_for_hook(
4895 &mut buffer,
4896 0, 80, 10, false, LineEnding::CRLF,
4901 );
4902
4903 let offsets = extract_token_offsets(&tokens);
4904
4905 assert!(
4908 offsets
4909 .iter()
4910 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
4911 "Expected Text(abc) at offset 0, got: {:?}",
4912 offsets
4913 );
4914 assert!(
4915 offsets
4916 .iter()
4917 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
4918 "Expected Newline at offset 3 (\\r position), got: {:?}",
4919 offsets
4920 );
4921
4922 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
4924 assert_eq!(
4925 newline_count, 1,
4926 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
4927 newline_count, offsets
4928 );
4929 }
4930
4931 #[test]
4934 fn test_build_base_tokens_crlf_multiple_lines() {
4935 let content = b"abc\r\ndef\r\nghi\r\n";
4940 let mut buffer = Buffer::from_bytes(content.to_vec());
4941 buffer.set_line_ending(LineEnding::CRLF);
4942
4943 let tokens = SplitRenderer::build_base_tokens_for_hook(
4944 &mut buffer,
4945 0,
4946 80,
4947 10,
4948 false,
4949 LineEnding::CRLF,
4950 );
4951
4952 let offsets = extract_token_offsets(&tokens);
4953
4954 assert!(
4961 offsets
4962 .iter()
4963 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
4964 "Line 1: Expected Text(abc) at 0, got: {:?}",
4965 offsets
4966 );
4967 assert!(
4968 offsets
4969 .iter()
4970 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
4971 "Line 1: Expected Newline at 3, got: {:?}",
4972 offsets
4973 );
4974
4975 assert!(
4977 offsets
4978 .iter()
4979 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
4980 "Line 2: Expected Text(def) at 5, got: {:?}",
4981 offsets
4982 );
4983 assert!(
4984 offsets
4985 .iter()
4986 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
4987 "Line 2: Expected Newline at 8, got: {:?}",
4988 offsets
4989 );
4990
4991 assert!(
4993 offsets
4994 .iter()
4995 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
4996 "Line 3: Expected Text(ghi) at 10, got: {:?}",
4997 offsets
4998 );
4999 assert!(
5000 offsets
5001 .iter()
5002 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
5003 "Line 3: Expected Newline at 13, got: {:?}",
5004 offsets
5005 );
5006
5007 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
5009 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
5010 }
5011
5012 #[test]
5015 fn test_build_base_tokens_lf_mode_for_comparison() {
5016 let content = b"abc\ndef\n";
5020 let mut buffer = Buffer::from_bytes(content.to_vec());
5021 buffer.set_line_ending(LineEnding::LF);
5022
5023 let tokens = SplitRenderer::build_base_tokens_for_hook(
5024 &mut buffer,
5025 0,
5026 80,
5027 10,
5028 false,
5029 LineEnding::LF,
5030 );
5031
5032 let offsets = extract_token_offsets(&tokens);
5033
5034 assert!(
5036 offsets
5037 .iter()
5038 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
5039 "LF Line 1: Expected Text(abc) at 0"
5040 );
5041 assert!(
5042 offsets
5043 .iter()
5044 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
5045 "LF Line 1: Expected Newline at 3"
5046 );
5047 assert!(
5048 offsets
5049 .iter()
5050 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
5051 "LF Line 2: Expected Text(def) at 4"
5052 );
5053 assert!(
5054 offsets
5055 .iter()
5056 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
5057 "LF Line 2: Expected Newline at 7"
5058 );
5059 }
5060
5061 #[test]
5064 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
5065 let content = b"abc\r\n";
5067 let mut buffer = Buffer::from_bytes(content.to_vec());
5068 buffer.set_line_ending(LineEnding::LF); 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.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
5084 "LF mode should render \\r as control char <0D>, got: {:?}",
5085 offsets
5086 );
5087 }
5088
5089 #[test]
5092 fn test_build_base_tokens_crlf_from_middle() {
5093 let content = b"abc\r\ndef\r\nghi\r\n";
5096 let mut buffer = Buffer::from_bytes(content.to_vec());
5097 buffer.set_line_ending(LineEnding::CRLF);
5098
5099 let tokens = SplitRenderer::build_base_tokens_for_hook(
5100 &mut buffer,
5101 5, 80,
5103 10,
5104 false,
5105 LineEnding::CRLF,
5106 );
5107
5108 let offsets = extract_token_offsets(&tokens);
5109
5110 assert!(
5114 offsets
5115 .iter()
5116 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
5117 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
5118 offsets
5119 );
5120 assert!(
5121 offsets
5122 .iter()
5123 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
5124 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
5125 offsets
5126 );
5127 }
5128
5129 #[test]
5132 fn test_crlf_highlight_span_lookup() {
5133 use crate::view::ui::view_pipeline::ViewLineIterator;
5134
5135 let content = b"int x;\r\nint y;\r\n";
5140 let mut buffer = Buffer::from_bytes(content.to_vec());
5141 buffer.set_line_ending(LineEnding::CRLF);
5142
5143 let tokens = SplitRenderer::build_base_tokens_for_hook(
5145 &mut buffer,
5146 0,
5147 80,
5148 10,
5149 false,
5150 LineEnding::CRLF,
5151 );
5152
5153 let offsets = extract_token_offsets(&tokens);
5155 eprintln!("Tokens: {:?}", offsets);
5156
5157 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
5159 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
5160
5161 eprintln!(
5164 "Line 1 char_source_bytes: {:?}",
5165 view_lines[0].char_source_bytes
5166 );
5167 assert_eq!(
5168 view_lines[0].char_source_bytes.len(),
5169 7,
5170 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
5171 );
5172 assert_eq!(
5174 view_lines[0].char_source_bytes[0],
5175 Some(0),
5176 "Line 1 'i' -> byte 0"
5177 );
5178 assert_eq!(
5179 view_lines[0].char_source_bytes[4],
5180 Some(4),
5181 "Line 1 'x' -> byte 4"
5182 );
5183 assert_eq!(
5184 view_lines[0].char_source_bytes[5],
5185 Some(5),
5186 "Line 1 ';' -> byte 5"
5187 );
5188 assert_eq!(
5189 view_lines[0].char_source_bytes[6],
5190 Some(6),
5191 "Line 1 newline -> byte 6 (\\r pos)"
5192 );
5193
5194 eprintln!(
5196 "Line 2 char_source_bytes: {:?}",
5197 view_lines[1].char_source_bytes
5198 );
5199 assert_eq!(
5200 view_lines[1].char_source_bytes.len(),
5201 7,
5202 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
5203 );
5204 assert_eq!(
5206 view_lines[1].char_source_bytes[0],
5207 Some(8),
5208 "Line 2 'i' -> byte 8"
5209 );
5210 assert_eq!(
5211 view_lines[1].char_source_bytes[4],
5212 Some(12),
5213 "Line 2 'y' -> byte 12"
5214 );
5215 assert_eq!(
5216 view_lines[1].char_source_bytes[5],
5217 Some(13),
5218 "Line 2 ';' -> byte 13"
5219 );
5220 assert_eq!(
5221 view_lines[1].char_source_bytes[6],
5222 Some(14),
5223 "Line 2 newline -> byte 14 (\\r pos)"
5224 );
5225
5226 let simulated_highlight_spans = [
5230 (0usize..3usize, "keyword"),
5232 (8usize..11usize, "keyword"),
5234 ];
5235
5236 for (line_idx, view_line) in view_lines.iter().enumerate() {
5238 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
5239 if let Some(bp) = byte_pos {
5240 let in_span = simulated_highlight_spans
5241 .iter()
5242 .find(|(range, _)| range.contains(bp))
5243 .map(|(_, name)| *name);
5244
5245 let expected_in_keyword = char_idx < 3;
5247 let actually_in_keyword = in_span == Some("keyword");
5248
5249 if expected_in_keyword != actually_in_keyword {
5250 panic!(
5251 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
5252 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
5253 );
5254 }
5255 }
5256 }
5257 }
5258 }
5259
5260 #[test]
5263 fn test_apply_wrapping_transform_breaks_long_lines() {
5264 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5265
5266 let long_text = "x".repeat(25_000);
5268 let tokens = vec![
5269 ViewTokenWire {
5270 kind: ViewTokenWireKind::Text(long_text),
5271 source_offset: Some(0),
5272 style: None,
5273 },
5274 ViewTokenWire {
5275 kind: ViewTokenWireKind::Newline,
5276 source_offset: Some(25_000),
5277 style: None,
5278 },
5279 ];
5280
5281 let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5283
5284 let break_count = wrapped
5286 .iter()
5287 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
5288 .count();
5289
5290 assert!(
5291 break_count >= 2,
5292 "25K char line should have at least 2 breaks at 10K width, got {}",
5293 break_count
5294 );
5295
5296 let total_chars: usize = wrapped
5298 .iter()
5299 .filter_map(|t| match &t.kind {
5300 ViewTokenWireKind::Text(s) => Some(s.len()),
5301 _ => None,
5302 })
5303 .sum();
5304
5305 assert_eq!(
5306 total_chars, 25_000,
5307 "Total character count should be preserved after wrapping"
5308 );
5309 }
5310
5311 #[test]
5313 fn test_apply_wrapping_transform_preserves_short_lines() {
5314 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5315
5316 let short_text = "x".repeat(100);
5318 let tokens = vec![
5319 ViewTokenWire {
5320 kind: ViewTokenWireKind::Text(short_text.clone()),
5321 source_offset: Some(0),
5322 style: None,
5323 },
5324 ViewTokenWire {
5325 kind: ViewTokenWireKind::Newline,
5326 source_offset: Some(100),
5327 style: None,
5328 },
5329 ];
5330
5331 let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5333
5334 let break_count = wrapped
5336 .iter()
5337 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
5338 .count();
5339
5340 assert_eq!(
5341 break_count, 0,
5342 "Short lines should not have any breaks, got {}",
5343 break_count
5344 );
5345
5346 let text_tokens: Vec<_> = wrapped
5348 .iter()
5349 .filter_map(|t| match &t.kind {
5350 ViewTokenWireKind::Text(s) => Some(s.clone()),
5351 _ => None,
5352 })
5353 .collect();
5354
5355 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
5356 assert_eq!(
5357 text_tokens[0], short_text,
5358 "Text content should be unchanged"
5359 );
5360 }
5361
5362 #[test]
5365 fn test_large_single_line_sequential_data_preserved() {
5366 use crate::view::ui::view_pipeline::ViewLineIterator;
5367 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5368
5369 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
5373
5374 let tokens = vec![
5376 ViewTokenWire {
5377 kind: ViewTokenWireKind::Text(content.clone()),
5378 source_offset: Some(0),
5379 style: None,
5380 },
5381 ViewTokenWire {
5382 kind: ViewTokenWireKind::Newline,
5383 source_offset: Some(content.len()),
5384 style: None,
5385 },
5386 ];
5387
5388 let wrapped = SplitRenderer::apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0);
5390
5391 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4).collect();
5393
5394 let mut reconstructed = String::new();
5396 for line in &view_lines {
5397 let text = line.text.trim_end_matches('\n');
5399 reconstructed.push_str(text);
5400 }
5401
5402 assert_eq!(
5404 reconstructed.len(),
5405 content.len(),
5406 "Reconstructed content length should match original"
5407 );
5408
5409 for i in 1..=num_markers {
5411 let marker = format!("[{:05}]", i);
5412 assert!(
5413 reconstructed.contains(&marker),
5414 "Missing marker {} after pipeline",
5415 marker
5416 );
5417 }
5418
5419 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
5421 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
5422 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
5423 assert!(
5424 pos_100 < pos_1000 && pos_1000 < pos_3000,
5425 "Markers should be in sequential order: {} < {} < {}",
5426 pos_100,
5427 pos_1000,
5428 pos_3000
5429 );
5430
5431 assert!(
5433 view_lines.len() >= 3,
5434 "35KB content should produce multiple visual lines at 10K width, got {}",
5435 view_lines.len()
5436 );
5437
5438 for (i, line) in view_lines.iter().enumerate() {
5440 assert!(
5441 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
5443 i,
5444 line.text.len()
5445 );
5446 }
5447 }
5448}