1mod base_tokens;
16mod char_style;
17mod folding;
18mod gutter;
19mod layout;
20mod orchestration;
21mod post_pass;
22mod scrollbar;
23mod spans;
24mod style;
25mod transforms;
26mod view_data;
27
28use crate::app::types::ViewLineMapping;
29use crate::app::BufferMetadata;
30use crate::model::buffer::Buffer;
31use crate::model::event::{BufferId, EventLog, LeafId, SplitDirection};
32use crate::primitives::ansi_background::AnsiBackground;
33use crate::state::EditorState;
34use crate::view::split::SplitManager;
35use ratatui::layout::Rect;
36use ratatui::Frame;
37use std::collections::HashMap;
38
39const MAX_SAFE_LINE_WIDTH: usize = 10_000;
45
46pub struct SplitRenderer;
52
53impl SplitRenderer {
54 #[allow(clippy::too_many_arguments)]
55 #[allow(clippy::type_complexity)]
56 pub fn render_content(
57 frame: &mut Frame,
58 area: Rect,
59 split_manager: &SplitManager,
60 buffers: &mut HashMap<BufferId, EditorState>,
61 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
62 event_logs: &mut HashMap<BufferId, EventLog>,
63 composite_buffers: &mut HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
64 composite_view_states: &mut HashMap<
65 (LeafId, BufferId),
66 crate::view::composite_view::CompositeViewState,
67 >,
68 theme: &crate::view::theme::Theme,
69 ansi_background: Option<&AnsiBackground>,
70 background_fade: f32,
71 lsp_waiting: bool,
72 large_file_threshold_bytes: u64,
73 line_wrap: bool,
74 estimated_line_length: usize,
75 highlight_context_bytes: usize,
76 split_view_states: Option<&mut HashMap<LeafId, crate::view::split::SplitViewState>>,
77 grouped_subtrees: &HashMap<LeafId, crate::view::split::SplitNode>,
78 hide_cursor: bool,
79 hovered_tab: Option<(crate::view::split::TabTarget, LeafId, bool)>,
80 hovered_close_split: Option<LeafId>,
81 hovered_maximize_split: Option<LeafId>,
82 is_maximized: bool,
83 relative_line_numbers: bool,
84 tab_bar_visible: bool,
85 use_terminal_bg: bool,
86 session_mode: bool,
87 software_cursor_only: bool,
88 show_vertical_scrollbar: bool,
89 show_horizontal_scrollbar: bool,
90 diagnostics_inline_text: bool,
91 show_tilde: bool,
92 cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
93 screen_width: u16,
94 ) -> (
95 Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
96 HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
97 Vec<(LeafId, u16, u16, u16)>,
98 Vec<(LeafId, u16, u16, u16)>,
99 HashMap<LeafId, Vec<ViewLineMapping>>,
100 Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
101 Vec<(
102 crate::model::event::ContainerId,
103 SplitDirection,
104 u16,
105 u16,
106 u16,
107 )>,
108 ) {
109 orchestration::render_content(
110 frame,
111 area,
112 split_manager,
113 buffers,
114 buffer_metadata,
115 event_logs,
116 composite_buffers,
117 composite_view_states,
118 theme,
119 ansi_background,
120 background_fade,
121 lsp_waiting,
122 large_file_threshold_bytes,
123 line_wrap,
124 estimated_line_length,
125 highlight_context_bytes,
126 split_view_states,
127 grouped_subtrees,
128 hide_cursor,
129 hovered_tab,
130 hovered_close_split,
131 hovered_maximize_split,
132 is_maximized,
133 relative_line_numbers,
134 tab_bar_visible,
135 use_terminal_bg,
136 session_mode,
137 software_cursor_only,
138 show_vertical_scrollbar,
139 show_horizontal_scrollbar,
140 diagnostics_inline_text,
141 show_tilde,
142 cell_theme_map,
143 screen_width,
144 )
145 }
146
147 #[allow(clippy::too_many_arguments)]
148 pub fn compute_content_layout(
149 area: Rect,
150 split_manager: &SplitManager,
151 buffers: &mut HashMap<BufferId, EditorState>,
152 split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
153 theme: &crate::view::theme::Theme,
154 lsp_waiting: bool,
155 estimated_line_length: usize,
156 highlight_context_bytes: usize,
157 relative_line_numbers: bool,
158 use_terminal_bg: bool,
159 session_mode: bool,
160 software_cursor_only: bool,
161 tab_bar_visible: bool,
162 show_vertical_scrollbar: bool,
163 show_horizontal_scrollbar: bool,
164 diagnostics_inline_text: bool,
165 show_tilde: bool,
166 ) -> HashMap<LeafId, Vec<ViewLineMapping>> {
167 orchestration::compute_content_layout(
168 area,
169 split_manager,
170 buffers,
171 split_view_states,
172 theme,
173 lsp_waiting,
174 estimated_line_length,
175 highlight_context_bytes,
176 relative_line_numbers,
177 use_terminal_bg,
178 session_mode,
179 software_cursor_only,
180 tab_bar_visible,
181 show_vertical_scrollbar,
182 show_horizontal_scrollbar,
183 diagnostics_inline_text,
184 show_tilde,
185 )
186 }
187
188 pub fn build_base_tokens_for_hook(
191 buffer: &mut Buffer,
192 top_byte: usize,
193 estimated_line_length: usize,
194 visible_count: usize,
195 is_binary: bool,
196 line_ending: crate::model::buffer::LineEnding,
197 ) -> Vec<fresh_core::api::ViewTokenWire> {
198 orchestration::build_base_tokens_for_hook(
199 buffer,
200 top_byte,
201 estimated_line_length,
202 visible_count,
203 is_binary,
204 line_ending,
205 )
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::folding::fold_indicators_for_viewport;
212 use super::layout::{calculate_view_anchor, calculate_viewport_end};
213 use super::orchestration::overlays::{decoration_context, selection_context};
214 use super::orchestration::render_buffer::resolve_cursor_fallback;
215 use super::orchestration::render_line::{
216 render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
217 };
218 use super::post_pass::apply_osc8_to_cells;
219 use super::transforms::apply_wrapping_transform;
220 use super::view_data::build_view_data;
221 use super::*;
222
223 use crate::model::buffer::{Buffer, LineEnding};
224 use crate::model::filesystem::StdFileSystem;
225 use crate::primitives::display_width::str_width;
226 use crate::state::{EditorState, ViewMode};
227 use crate::view::folding::FoldManager;
228 use crate::view::theme;
229 use crate::view::theme::Theme;
230 use crate::view::ui::view_pipeline::{LineStart, ViewLine};
231 use crate::view::viewport::Viewport;
232 use fresh_core::api::ViewTokenWire;
233 use lsp_types::FoldingRange;
234 use std::collections::HashSet;
235 use std::sync::Arc;
236
237 fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
238 Arc::new(StdFileSystem)
239 }
240
241 fn render_output_for(
242 content: &str,
243 cursor_pos: usize,
244 ) -> (LineRenderOutput, usize, bool, usize) {
245 render_output_for_with_gutters(content, cursor_pos, false)
246 }
247
248 fn render_output_for_with_gutters(
249 content: &str,
250 cursor_pos: usize,
251 gutters_enabled: bool,
252 ) -> (LineRenderOutput, usize, bool, usize) {
253 let mut state = EditorState::new(20, 6, 1024, test_fs());
254 state.buffer = Buffer::from_str(content, 1024, test_fs());
255 let mut cursors = crate::model::cursor::Cursors::new();
256 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
257 let viewport = Viewport::new(20, 4);
259 state.margins.left_config.enabled = gutters_enabled;
261
262 let render_area = Rect::new(0, 0, 20, 4);
263 let visible_count = viewport.visible_line_count();
264 let gutter_width = state.margins.left_total_width();
265 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
266 let empty_folds = FoldManager::new();
267
268 let view_data = build_view_data(
269 &mut state,
270 &viewport,
271 None,
272 content.len().max(1),
273 visible_count,
274 false, render_area.width as usize,
276 gutter_width,
277 &ViewMode::Source, &empty_folds,
279 &theme,
280 );
281 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
282
283 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
284 state.margins.update_width_for_buffer(estimated_lines, true);
285 let gutter_width = state.margins.left_total_width();
286
287 let selection = selection_context(&state, &cursors);
288 let _ = state
289 .buffer
290 .populate_line_cache(viewport.top_byte, visible_count);
291 let viewport_start = viewport.top_byte;
292 let viewport_end = calculate_viewport_end(
293 &mut state,
294 viewport_start,
295 content.len().max(1),
296 visible_count,
297 );
298 let decorations = decoration_context(
299 &mut state,
300 viewport_start,
301 viewport_end,
302 selection.primary_cursor_position,
303 &empty_folds,
304 &theme,
305 100_000, &ViewMode::Source, false, &[],
309 );
310
311 let mut dummy_theme_map = Vec::new();
312 let output = render_view_lines(LineRenderInput {
313 state: &state,
314 theme: &theme,
315 view_lines: &view_data.lines,
316 view_anchor,
317 render_area,
318 gutter_width,
319 selection: &selection,
320 decorations: &decorations,
321 visible_line_count: visible_count,
322 lsp_waiting: false,
323 is_active: true,
324 line_wrap: viewport.line_wrap_enabled,
325 estimated_lines,
326 left_column: viewport.left_column,
327 relative_line_numbers: false,
328 session_mode: false,
329 software_cursor_only: false,
330 show_line_numbers: true, byte_offset_mode: false, show_tilde: true,
333 highlight_current_line: true,
334 cell_theme_map: &mut dummy_theme_map,
335 screen_width: 0,
336 });
337
338 (
339 output,
340 state.buffer.len(),
341 content.ends_with('\n'),
342 selection.primary_cursor_position,
343 )
344 }
345
346 #[test]
347 fn test_folding_hides_lines_and_adds_placeholder() {
348 let content = "header\nline1\nline2\ntail\n";
349 let mut state = EditorState::new(40, 6, 1024, test_fs());
350 state.buffer = Buffer::from_str(content, 1024, test_fs());
351
352 let start = state.buffer.line_start_offset(1).unwrap();
353 let end = state.buffer.line_start_offset(3).unwrap();
354 let mut folds = FoldManager::new();
355 folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
356
357 let viewport = Viewport::new(40, 6);
358 let gutter_width = state.margins.left_total_width();
359 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
360 let view_data = build_view_data(
361 &mut state,
362 &viewport,
363 None,
364 content.len().max(1),
365 viewport.visible_line_count(),
366 false,
367 40,
368 gutter_width,
369 &ViewMode::Source,
370 &folds,
371 &theme,
372 );
373
374 let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
375 assert!(lines.iter().any(|l| l.contains("header")));
376 assert!(lines.iter().any(|l| l.contains("tail")));
377 assert!(!lines.iter().any(|l| l.contains("line1")));
378 assert!(!lines.iter().any(|l| l.contains("line2")));
379 assert!(lines
380 .iter()
381 .any(|l| l.contains("header") && l.contains("...")));
382 }
383
384 #[test]
385 fn test_fold_indicators_collapsed_and_expanded() {
386 let content = "a\nb\nc\nd\n";
387 let mut state = EditorState::new(40, 6, 1024, test_fs());
388 state.buffer = Buffer::from_str(content, 1024, test_fs());
389
390 let lsp_ranges = vec![
391 FoldingRange {
392 start_line: 0,
393 end_line: 1,
394 start_character: None,
395 end_character: None,
396 kind: None,
397 collapsed_text: None,
398 },
399 FoldingRange {
400 start_line: 1,
401 end_line: 2,
402 start_character: None,
403 end_character: None,
404 kind: None,
405 collapsed_text: None,
406 },
407 ];
408 state
409 .folding_ranges
410 .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
411
412 let start = state.buffer.line_start_offset(1).unwrap();
413 let end = state.buffer.line_start_offset(2).unwrap();
414 let mut folds = FoldManager::new();
415 folds.add(&mut state.marker_list, start, end, None);
416
417 let line1_byte = state.buffer.line_start_offset(1).unwrap();
418 let view_lines = vec![ViewLine {
419 text: "b\n".to_string(),
420 source_start_byte: Some(line1_byte),
421 char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
422 char_styles: vec![None, None],
423 char_visual_cols: vec![0, 1],
424 visual_to_char: vec![0, 1],
425 tab_starts: HashSet::new(),
426 line_start: LineStart::AfterSourceNewline,
427 ends_with_newline: true,
428 }];
429
430 let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
431
432 assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
434 assert_eq!(
436 indicators.get(&line1_byte).map(|i| i.collapsed),
437 Some(false)
438 );
439 }
440
441 #[test]
442 fn last_line_end_tracks_trailing_newline() {
443 let output = render_output_for("abc\n", 4);
444 assert_eq!(
445 output.0.last_line_end,
446 Some(LastLineEnd {
447 pos: (3, 0),
448 terminated_with_newline: true
449 })
450 );
451 }
452
453 #[test]
454 fn last_line_end_tracks_no_trailing_newline() {
455 let output = render_output_for("abc", 3);
456 assert_eq!(
457 output.0.last_line_end,
458 Some(LastLineEnd {
459 pos: (3, 0),
460 terminated_with_newline: false
461 })
462 );
463 }
464
465 #[test]
466 fn cursor_after_newline_places_on_next_line() {
467 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
468 let cursor = resolve_cursor_fallback(
469 output.cursor,
470 cursor_pos,
471 buffer_len,
472 buffer_newline,
473 output.last_line_end,
474 output.content_lines_rendered,
475 0, );
477 assert_eq!(cursor, Some((0, 1)));
478 }
479
480 #[test]
481 fn cursor_at_end_without_newline_stays_on_line() {
482 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
483 let cursor = resolve_cursor_fallback(
484 output.cursor,
485 cursor_pos,
486 buffer_len,
487 buffer_newline,
488 output.last_line_end,
489 output.content_lines_rendered,
490 0, );
492 assert_eq!(cursor, Some((3, 0)));
493 }
494
495 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
501 let mut cursor_positions = Vec::new();
502
503 let primary_cursor = output.cursor;
505 if let Some(cursor_pos) = primary_cursor {
506 cursor_positions.push(cursor_pos);
507 }
508
509 for (line_idx, line) in output.lines.iter().enumerate() {
511 let mut col = 0u16;
512 for span in line.spans.iter() {
513 if span
515 .style
516 .add_modifier
517 .contains(ratatui::style::Modifier::REVERSED)
518 {
519 let pos = (col, line_idx as u16);
520 if primary_cursor != Some(pos) {
523 cursor_positions.push(pos);
524 }
525 }
526 col += str_width(&span.content) as u16;
528 }
529 }
530
531 cursor_positions
532 }
533
534 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
536 eprintln!("\n=== RENDER DEBUG ===");
537 eprintln!("Content: {:?}", content);
538 eprintln!("Cursor position: {}", cursor_pos);
539 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
540 eprintln!("Last line end: {:?}", output.last_line_end);
541 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
542 eprintln!("\nRendered lines:");
543 for (line_idx, line) in output.lines.iter().enumerate() {
544 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
545 for (span_idx, span) in line.spans.iter().enumerate() {
546 let has_reversed = span
547 .style
548 .add_modifier
549 .contains(ratatui::style::Modifier::REVERSED);
550 let bg_color = format!("{:?}", span.style.bg);
551 eprintln!(
552 " Span {}: {:?} (REVERSED: {}, BG: {})",
553 span_idx, span.content, has_reversed, bg_color
554 );
555 }
556 }
557 eprintln!("===================\n");
558 }
559
560 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
563 let (output, buffer_len, buffer_newline, cursor_pos) =
564 render_output_for(content, cursor_pos);
565
566 let all_cursors = count_all_cursors(&output);
568
569 assert!(
572 all_cursors.len() <= 1,
573 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
574 all_cursors.len(),
575 all_cursors
576 );
577
578 let final_cursor = resolve_cursor_fallback(
579 output.cursor,
580 cursor_pos,
581 buffer_len,
582 buffer_newline,
583 output.last_line_end,
584 output.content_lines_rendered,
585 0, );
587
588 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
590 {
591 dump_render_output(content, cursor_pos, &output);
592 }
593
594 if let Some(rendered_cursor) = all_cursors.first() {
596 assert_eq!(
597 Some(*rendered_cursor),
598 final_cursor,
599 "Rendered cursor at {:?} doesn't match final cursor {:?}",
600 rendered_cursor,
601 final_cursor
602 );
603 }
604
605 assert!(
607 final_cursor.is_some(),
608 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
609 all_cursors
610 );
611
612 final_cursor
613 }
614
615 fn check_typing_at_cursor(
617 content: &str,
618 cursor_pos: usize,
619 char_to_type: char,
620 ) -> (Option<(u16, u16)>, String) {
621 let cursor_before = get_final_cursor(content, cursor_pos);
623
624 let mut new_content = content.to_string();
626 if cursor_pos <= content.len() {
627 new_content.insert(cursor_pos, char_to_type);
628 }
629
630 (cursor_before, new_content)
631 }
632
633 #[test]
634 fn e2e_cursor_at_start_of_nonempty_line() {
635 let cursor = get_final_cursor("abc", 0);
637 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
638
639 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
640 assert_eq!(
641 new_content, "Xabc",
642 "Typing should insert at cursor position"
643 );
644 assert_eq!(cursor_pos, Some((0, 0)));
645 }
646
647 #[test]
648 fn e2e_cursor_in_middle_of_line() {
649 let cursor = get_final_cursor("abc", 1);
651 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
652
653 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
654 assert_eq!(
655 new_content, "aXbc",
656 "Typing should insert at cursor position"
657 );
658 assert_eq!(cursor_pos, Some((1, 0)));
659 }
660
661 #[test]
662 fn e2e_cursor_at_end_of_line_no_newline() {
663 let cursor = get_final_cursor("abc", 3);
665 assert_eq!(
666 cursor,
667 Some((3, 0)),
668 "Cursor should be at column 3, line 0 (after last char)"
669 );
670
671 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
672 assert_eq!(new_content, "abcX", "Typing should append at end");
673 assert_eq!(cursor_pos, Some((3, 0)));
674 }
675
676 #[test]
677 fn e2e_cursor_at_empty_line() {
678 let cursor = get_final_cursor("\n", 0);
680 assert_eq!(
681 cursor,
682 Some((0, 0)),
683 "Cursor on empty line should be at column 0"
684 );
685
686 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
687 assert_eq!(new_content, "X\n", "Typing should insert before newline");
688 assert_eq!(cursor_pos, Some((0, 0)));
689 }
690
691 #[test]
692 fn e2e_cursor_after_newline_at_eof() {
693 let cursor = get_final_cursor("abc\n", 4);
695 assert_eq!(
696 cursor,
697 Some((0, 1)),
698 "Cursor after newline at EOF should be on next line"
699 );
700
701 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
702 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
703 assert_eq!(cursor_pos, Some((0, 1)));
704 }
705
706 #[test]
707 fn e2e_cursor_on_newline_with_content() {
708 let cursor = get_final_cursor("abc\n", 3);
710 assert_eq!(
711 cursor,
712 Some((3, 0)),
713 "Cursor on newline after content should be after last char"
714 );
715
716 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
717 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
718 assert_eq!(cursor_pos, Some((3, 0)));
719 }
720
721 #[test]
722 fn e2e_cursor_multiline_start_of_second_line() {
723 let cursor = get_final_cursor("abc\ndef", 4);
725 assert_eq!(
726 cursor,
727 Some((0, 1)),
728 "Cursor at start of second line should be at column 0, line 1"
729 );
730
731 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
732 assert_eq!(
733 new_content, "abc\nXdef",
734 "Typing should insert at start of second line"
735 );
736 assert_eq!(cursor_pos, Some((0, 1)));
737 }
738
739 #[test]
740 fn e2e_cursor_multiline_end_of_first_line() {
741 let cursor = get_final_cursor("abc\ndef", 3);
743 assert_eq!(
744 cursor,
745 Some((3, 0)),
746 "Cursor on newline of first line should be after content"
747 );
748
749 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
750 assert_eq!(
751 new_content, "abcX\ndef",
752 "Typing should insert before newline"
753 );
754 assert_eq!(cursor_pos, Some((3, 0)));
755 }
756
757 #[test]
758 fn e2e_cursor_empty_buffer() {
759 let cursor = get_final_cursor("", 0);
761 assert_eq!(
762 cursor,
763 Some((0, 0)),
764 "Cursor in empty buffer should be at origin"
765 );
766
767 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
768 assert_eq!(
769 new_content, "X",
770 "Typing in empty buffer should insert character"
771 );
772 assert_eq!(cursor_pos, Some((0, 0)));
773 }
774
775 #[test]
776 fn e2e_cursor_empty_buffer_with_gutters() {
777 let (output, buffer_len, buffer_newline, cursor_pos) =
781 render_output_for_with_gutters("", 0, true);
782
783 let gutter_width = {
787 let mut state = EditorState::new(20, 6, 1024, test_fs());
788 state.margins.left_config.enabled = true;
789 state.margins.update_width_for_buffer(1, true);
790 state.margins.left_total_width()
791 };
792 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
793
794 assert_eq!(
798 output.cursor,
799 Some((gutter_width as u16, 0)),
800 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
801 gutter_width,
802 output.cursor
803 );
804
805 let final_cursor = resolve_cursor_fallback(
806 output.cursor,
807 cursor_pos,
808 buffer_len,
809 buffer_newline,
810 output.last_line_end,
811 output.content_lines_rendered,
812 gutter_width,
813 );
814
815 assert_eq!(
817 final_cursor,
818 Some((gutter_width as u16, 0)),
819 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
820 );
821 }
822
823 #[test]
824 fn e2e_cursor_between_empty_lines() {
825 let cursor = get_final_cursor("\n\n", 1);
827 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
828
829 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
830 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
831 assert_eq!(cursor_pos, Some((0, 1)));
832 }
833
834 #[test]
835 fn e2e_cursor_at_eof_after_multiple_lines() {
836 let cursor = get_final_cursor("abc\ndef\nghi", 11);
838 assert_eq!(
839 cursor,
840 Some((3, 2)),
841 "Cursor at EOF after 'i' should be at column 3, line 2"
842 );
843
844 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
845 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
846 assert_eq!(cursor_pos, Some((3, 2)));
847 }
848
849 #[test]
850 fn e2e_cursor_at_eof_with_trailing_newline() {
851 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
853 assert_eq!(
854 cursor,
855 Some((0, 3)),
856 "Cursor after trailing newline should be on line 3"
857 );
858
859 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
860 assert_eq!(
861 new_content, "abc\ndef\nghi\nX",
862 "Typing should insert on new line"
863 );
864 assert_eq!(cursor_pos, Some((0, 3)));
865 }
866
867 #[test]
868 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
869 let content = "abc\ndef\nghi";
871
872 let cursor_at_start = get_final_cursor(content, 0);
874 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
875
876 let cursor_at_eof = get_final_cursor(content, 11);
878 assert_eq!(
879 cursor_at_eof,
880 Some((3, 2)),
881 "After Ctrl+End, cursor at column 3, line 2"
882 );
883
884 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
886 assert_eq!(cursor_before_typing, Some((3, 2)));
887 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
888
889 let cursor_after_typing = get_final_cursor(&new_content, 12);
891 assert_eq!(
892 cursor_after_typing,
893 Some((4, 2)),
894 "After typing, cursor moved to column 4"
895 );
896
897 let cursor_moved_away = get_final_cursor(&new_content, 0);
899 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
900 }
903
904 #[test]
905 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
906 let content = "abc\ndef\nghi\n";
908
909 let cursor_at_start = get_final_cursor(content, 0);
911 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
912
913 let cursor_at_eof = get_final_cursor(content, 12);
915 assert_eq!(
916 cursor_at_eof,
917 Some((0, 3)),
918 "After Ctrl+End, cursor at column 0, line 3 (new line)"
919 );
920
921 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
923 assert_eq!(cursor_before_typing, Some((0, 3)));
924 assert_eq!(
925 new_content, "abc\ndef\nghi\nX",
926 "Character inserted on new line"
927 );
928
929 let cursor_after_typing = get_final_cursor(&new_content, 13);
931 assert_eq!(
932 cursor_after_typing,
933 Some((1, 3)),
934 "After typing, cursor should be at column 1, line 3"
935 );
936
937 let cursor_moved_away = get_final_cursor(&new_content, 4);
939 assert_eq!(
940 cursor_moved_away,
941 Some((0, 1)),
942 "Cursor moved to start of line 1 (position 4 = start of 'def')"
943 );
944 }
945
946 #[test]
947 fn e2e_jump_to_end_of_empty_buffer() {
948 let content = "";
950
951 let cursor_at_eof = get_final_cursor(content, 0);
952 assert_eq!(
953 cursor_at_eof,
954 Some((0, 0)),
955 "Empty buffer: cursor at origin"
956 );
957
958 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
960 assert_eq!(cursor_before_typing, Some((0, 0)));
961 assert_eq!(new_content, "X", "Character inserted");
962
963 let cursor_after_typing = get_final_cursor(&new_content, 1);
965 assert_eq!(
966 cursor_after_typing,
967 Some((1, 0)),
968 "After typing, cursor at column 1"
969 );
970
971 let cursor_moved_away = get_final_cursor(&new_content, 0);
973 assert_eq!(
974 cursor_moved_away,
975 Some((0, 0)),
976 "Cursor moved back to start"
977 );
978 }
979
980 #[test]
981 fn e2e_jump_to_end_of_single_empty_line() {
982 let content = "\n";
984
985 let cursor_on_newline = get_final_cursor(content, 0);
987 assert_eq!(
988 cursor_on_newline,
989 Some((0, 0)),
990 "Cursor on the newline character"
991 );
992
993 let cursor_at_eof = get_final_cursor(content, 1);
995 assert_eq!(
996 cursor_at_eof,
997 Some((0, 1)),
998 "After Ctrl+End, cursor on line 1"
999 );
1000
1001 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1003 assert_eq!(cursor_before_typing, Some((0, 1)));
1004 assert_eq!(new_content, "\nX", "Character on second line");
1005
1006 let cursor_after_typing = get_final_cursor(&new_content, 2);
1007 assert_eq!(
1008 cursor_after_typing,
1009 Some((1, 1)),
1010 "After typing, cursor at column 1, line 1"
1011 );
1012
1013 let cursor_moved_away = get_final_cursor(&new_content, 0);
1015 assert_eq!(
1016 cursor_moved_away,
1017 Some((0, 0)),
1018 "Cursor moved to the newline on line 0"
1019 );
1020 }
1021 use fresh_core::api::ViewTokenWireKind;
1032
1033 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1035 tokens
1036 .iter()
1037 .map(|t| {
1038 let kind_str = match &t.kind {
1039 ViewTokenWireKind::Text(s) => format!("Text({})", s),
1040 ViewTokenWireKind::Newline => "Newline".to_string(),
1041 ViewTokenWireKind::Space => "Space".to_string(),
1042 ViewTokenWireKind::Break => "Break".to_string(),
1043 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1044 };
1045 (kind_str, t.source_offset)
1046 })
1047 .collect()
1048 }
1049
1050 #[test]
1053 fn test_build_base_tokens_crlf_single_line() {
1054 let content = b"abc\r\n";
1056 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1057 buffer.set_line_ending(LineEnding::CRLF);
1058
1059 let tokens = SplitRenderer::build_base_tokens_for_hook(
1060 &mut buffer,
1061 0, 80, 10, false, LineEnding::CRLF,
1066 );
1067
1068 let offsets = extract_token_offsets(&tokens);
1069
1070 assert!(
1073 offsets
1074 .iter()
1075 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1076 "Expected Text(abc) at offset 0, got: {:?}",
1077 offsets
1078 );
1079 assert!(
1080 offsets
1081 .iter()
1082 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1083 "Expected Newline at offset 3 (\\r position), got: {:?}",
1084 offsets
1085 );
1086
1087 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1089 assert_eq!(
1090 newline_count, 1,
1091 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1092 newline_count, offsets
1093 );
1094 }
1095
1096 #[test]
1099 fn test_build_base_tokens_crlf_multiple_lines() {
1100 let content = b"abc\r\ndef\r\nghi\r\n";
1105 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1106 buffer.set_line_ending(LineEnding::CRLF);
1107
1108 let tokens = SplitRenderer::build_base_tokens_for_hook(
1109 &mut buffer,
1110 0,
1111 80,
1112 10,
1113 false,
1114 LineEnding::CRLF,
1115 );
1116
1117 let offsets = extract_token_offsets(&tokens);
1118
1119 assert!(
1126 offsets
1127 .iter()
1128 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1129 "Line 1: Expected Text(abc) at 0, got: {:?}",
1130 offsets
1131 );
1132 assert!(
1133 offsets
1134 .iter()
1135 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1136 "Line 1: Expected Newline at 3, got: {:?}",
1137 offsets
1138 );
1139
1140 assert!(
1142 offsets
1143 .iter()
1144 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1145 "Line 2: Expected Text(def) at 5, got: {:?}",
1146 offsets
1147 );
1148 assert!(
1149 offsets
1150 .iter()
1151 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1152 "Line 2: Expected Newline at 8, got: {:?}",
1153 offsets
1154 );
1155
1156 assert!(
1158 offsets
1159 .iter()
1160 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1161 "Line 3: Expected Text(ghi) at 10, got: {:?}",
1162 offsets
1163 );
1164 assert!(
1165 offsets
1166 .iter()
1167 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
1168 "Line 3: Expected Newline at 13, got: {:?}",
1169 offsets
1170 );
1171
1172 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1174 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
1175 }
1176
1177 #[test]
1180 fn test_build_base_tokens_lf_mode_for_comparison() {
1181 let content = b"abc\ndef\n";
1185 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1186 buffer.set_line_ending(LineEnding::LF);
1187
1188 let tokens = SplitRenderer::build_base_tokens_for_hook(
1189 &mut buffer,
1190 0,
1191 80,
1192 10,
1193 false,
1194 LineEnding::LF,
1195 );
1196
1197 let offsets = extract_token_offsets(&tokens);
1198
1199 assert!(
1201 offsets
1202 .iter()
1203 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1204 "LF Line 1: Expected Text(abc) at 0"
1205 );
1206 assert!(
1207 offsets
1208 .iter()
1209 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1210 "LF Line 1: Expected Newline at 3"
1211 );
1212 assert!(
1213 offsets
1214 .iter()
1215 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
1216 "LF Line 2: Expected Text(def) at 4"
1217 );
1218 assert!(
1219 offsets
1220 .iter()
1221 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
1222 "LF Line 2: Expected Newline at 7"
1223 );
1224 }
1225
1226 #[test]
1229 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
1230 let content = b"abc\r\n";
1232 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1233 buffer.set_line_ending(LineEnding::LF); let tokens = SplitRenderer::build_base_tokens_for_hook(
1236 &mut buffer,
1237 0,
1238 80,
1239 10,
1240 false,
1241 LineEnding::LF,
1242 );
1243
1244 let offsets = extract_token_offsets(&tokens);
1245
1246 assert!(
1248 offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
1249 "LF mode should render \\r as control char <0D>, got: {:?}",
1250 offsets
1251 );
1252 }
1253
1254 #[test]
1257 fn test_build_base_tokens_crlf_from_middle() {
1258 let content = b"abc\r\ndef\r\nghi\r\n";
1261 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1262 buffer.set_line_ending(LineEnding::CRLF);
1263
1264 let tokens = SplitRenderer::build_base_tokens_for_hook(
1265 &mut buffer,
1266 5, 80,
1268 10,
1269 false,
1270 LineEnding::CRLF,
1271 );
1272
1273 let offsets = extract_token_offsets(&tokens);
1274
1275 assert!(
1279 offsets
1280 .iter()
1281 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1282 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
1283 offsets
1284 );
1285 assert!(
1286 offsets
1287 .iter()
1288 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1289 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
1290 offsets
1291 );
1292 }
1293
1294 #[test]
1297 fn test_crlf_highlight_span_lookup() {
1298 use crate::view::ui::view_pipeline::ViewLineIterator;
1299
1300 let content = b"int x;\r\nint y;\r\n";
1305 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1306 buffer.set_line_ending(LineEnding::CRLF);
1307
1308 let tokens = SplitRenderer::build_base_tokens_for_hook(
1310 &mut buffer,
1311 0,
1312 80,
1313 10,
1314 false,
1315 LineEnding::CRLF,
1316 );
1317
1318 let offsets = extract_token_offsets(&tokens);
1320 eprintln!("Tokens: {:?}", offsets);
1321
1322 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1324 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
1325
1326 eprintln!(
1329 "Line 1 char_source_bytes: {:?}",
1330 view_lines[0].char_source_bytes
1331 );
1332 assert_eq!(
1333 view_lines[0].char_source_bytes.len(),
1334 7,
1335 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
1336 );
1337 assert_eq!(
1339 view_lines[0].char_source_bytes[0],
1340 Some(0),
1341 "Line 1 'i' -> byte 0"
1342 );
1343 assert_eq!(
1344 view_lines[0].char_source_bytes[4],
1345 Some(4),
1346 "Line 1 'x' -> byte 4"
1347 );
1348 assert_eq!(
1349 view_lines[0].char_source_bytes[5],
1350 Some(5),
1351 "Line 1 ';' -> byte 5"
1352 );
1353 assert_eq!(
1354 view_lines[0].char_source_bytes[6],
1355 Some(6),
1356 "Line 1 newline -> byte 6 (\\r pos)"
1357 );
1358
1359 eprintln!(
1361 "Line 2 char_source_bytes: {:?}",
1362 view_lines[1].char_source_bytes
1363 );
1364 assert_eq!(
1365 view_lines[1].char_source_bytes.len(),
1366 7,
1367 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
1368 );
1369 assert_eq!(
1371 view_lines[1].char_source_bytes[0],
1372 Some(8),
1373 "Line 2 'i' -> byte 8"
1374 );
1375 assert_eq!(
1376 view_lines[1].char_source_bytes[4],
1377 Some(12),
1378 "Line 2 'y' -> byte 12"
1379 );
1380 assert_eq!(
1381 view_lines[1].char_source_bytes[5],
1382 Some(13),
1383 "Line 2 ';' -> byte 13"
1384 );
1385 assert_eq!(
1386 view_lines[1].char_source_bytes[6],
1387 Some(14),
1388 "Line 2 newline -> byte 14 (\\r pos)"
1389 );
1390
1391 let simulated_highlight_spans = [
1395 (0usize..3usize, "keyword"),
1397 (8usize..11usize, "keyword"),
1399 ];
1400
1401 for (line_idx, view_line) in view_lines.iter().enumerate() {
1403 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
1404 if let Some(bp) = byte_pos {
1405 let in_span = simulated_highlight_spans
1406 .iter()
1407 .find(|(range, _)| range.contains(bp))
1408 .map(|(_, name)| *name);
1409
1410 let expected_in_keyword = char_idx < 3;
1412 let actually_in_keyword = in_span == Some("keyword");
1413
1414 if expected_in_keyword != actually_in_keyword {
1415 panic!(
1416 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
1417 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
1418 );
1419 }
1420 }
1421 }
1422 }
1423 }
1424
1425 #[test]
1428 fn test_apply_wrapping_transform_breaks_long_lines() {
1429 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1430
1431 let long_text = "x".repeat(25_000);
1433 let tokens = vec![
1434 ViewTokenWire {
1435 kind: ViewTokenWireKind::Text(long_text),
1436 source_offset: Some(0),
1437 style: None,
1438 },
1439 ViewTokenWire {
1440 kind: ViewTokenWireKind::Newline,
1441 source_offset: Some(25_000),
1442 style: None,
1443 },
1444 ];
1445
1446 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1448
1449 let break_count = wrapped
1451 .iter()
1452 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1453 .count();
1454
1455 assert!(
1456 break_count >= 2,
1457 "25K char line should have at least 2 breaks at 10K width, got {}",
1458 break_count
1459 );
1460
1461 let total_chars: usize = wrapped
1463 .iter()
1464 .filter_map(|t| match &t.kind {
1465 ViewTokenWireKind::Text(s) => Some(s.len()),
1466 _ => None,
1467 })
1468 .sum();
1469
1470 assert_eq!(
1471 total_chars, 25_000,
1472 "Total character count should be preserved after wrapping"
1473 );
1474 }
1475
1476 #[cfg(test)]
1502 mod wrap_boundary_property {
1503 use super::apply_wrapping_transform;
1504 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1505 use proptest::prelude::*;
1506 use unicode_segmentation::UnicodeSegmentation;
1507
1508 const MAX_LOOKBACK: usize = 16;
1512
1513 fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
1514 let mut tokens: Vec<ViewTokenWire> = Vec::new();
1515 let mut buf = String::new();
1516 let mut buf_start = 0usize;
1517 for (i, c) in input.char_indices() {
1518 if c == ' ' {
1519 if !buf.is_empty() {
1520 tokens.push(ViewTokenWire {
1521 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1522 source_offset: Some(buf_start),
1523 style: None,
1524 });
1525 }
1526 tokens.push(ViewTokenWire {
1527 kind: ViewTokenWireKind::Space,
1528 source_offset: Some(i),
1529 style: None,
1530 });
1531 buf_start = i + 1;
1532 } else {
1533 if buf.is_empty() {
1534 buf_start = i;
1535 }
1536 buf.push(c);
1537 }
1538 }
1539 if !buf.is_empty() {
1540 tokens.push(ViewTokenWire {
1541 kind: ViewTokenWireKind::Text(buf.clone()),
1542 source_offset: Some(buf_start),
1543 style: None,
1544 });
1545 }
1546 tokens.push(ViewTokenWire {
1547 kind: ViewTokenWireKind::Newline,
1548 source_offset: Some(input.len()),
1549 style: None,
1550 });
1551 tokens
1552 }
1553
1554 fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
1559 let mut rows: Vec<String> = vec![String::new()];
1560 for t in wrapped {
1561 match &t.kind {
1562 ViewTokenWireKind::Text(s) => {
1563 rows.last_mut().unwrap().push_str(s);
1564 }
1565 ViewTokenWireKind::Space => {
1566 rows.last_mut().unwrap().push(' ');
1567 }
1568 ViewTokenWireKind::Break => {
1569 rows.push(String::new());
1570 }
1571 ViewTokenWireKind::Newline => {
1572 }
1575 _ => {}
1576 }
1577 }
1578 rows
1579 }
1580
1581 proptest! {
1582 #![proptest_config(ProptestConfig {
1586 cases: 256,
1587 .. ProptestConfig::default()
1588 })]
1589
1590 #[test]
1593 fn prop_wrap_respects_boundaries(
1594 input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
1595 content_width in 5usize..40,
1596 ) {
1597 let tokens = tokens_from_input(&input);
1600 let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
1601 let rows = visual_rows(&wrapped);
1602
1603 for (i, row) in rows.iter().enumerate() {
1605 prop_assert!(
1606 row.chars().count() <= content_width,
1607 "row {i} {:?} has width {} > content_width {content_width}",
1608 row,
1609 row.chars().count(),
1610 );
1611 }
1612
1613 let reconstructed: String = rows.concat();
1615 prop_assert_eq!(
1616 &reconstructed,
1617 &input,
1618 "reconstruction differs from input"
1619 );
1620
1621 let boundaries: std::collections::BTreeSet<usize> = input
1625 .split_word_bound_indices()
1626 .map(|(i, _)| i)
1627 .chain(std::iter::once(input.len()))
1628 .collect();
1629
1630 let mut cursor_bytes = 0usize;
1631 let mut cursor_chars = 0usize;
1632 for (i, row) in rows.iter().enumerate() {
1633 let row_bytes = row.len();
1634 let row_chars = row.chars().count();
1635 let row_end_bytes = cursor_bytes + row_bytes;
1636 let row_end_chars = cursor_chars + row_chars;
1637 let is_last = i + 1 == rows.len();
1638
1639 if !is_last {
1640 let input_bytes = input.as_bytes();
1646 let prev_is_space =
1647 row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
1648 let next_is_space = row_end_bytes < input_bytes.len()
1649 && input_bytes[row_end_bytes] == b' ';
1650 let is_mid_text = !prev_is_space && !next_is_space;
1651 if !is_mid_text {
1652 cursor_bytes = row_end_bytes;
1653 cursor_chars = row_end_chars;
1654 continue;
1655 }
1656
1657 let hard_cap_chars = cursor_chars + content_width;
1660 let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
1661 let floor_chars = cursor_chars
1662 + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
1663 let floor_bytes = char_index_to_byte(&input, floor_chars);
1664
1665 let max_in_window = boundaries
1673 .range(floor_bytes..=hard_cap_bytes)
1674 .next_back()
1675 .copied();
1676 match max_in_window {
1677 Some(max_b) => {
1678 prop_assert_eq!(
1679 row_end_bytes,
1680 max_b,
1681 "split at byte {} but largest word boundary in \
1682 [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
1683 row_end_bytes,
1684 floor_bytes,
1685 hard_cap_bytes,
1686 max_b,
1687 row,
1688 input,
1689 );
1690 }
1691 None => {
1692 prop_assert_eq!(
1693 row_end_bytes,
1694 hard_cap_bytes,
1695 "no word boundary in [floor={}, hard_cap={}], so \
1696 char-split must land at hard_cap, but split is at \
1697 byte {}; row={:?}, input={:?}",
1698 floor_bytes,
1699 hard_cap_bytes,
1700 row_end_bytes,
1701 row,
1702 input,
1703 );
1704 }
1705 }
1706 }
1707
1708 cursor_bytes = row_end_bytes;
1709 cursor_chars = row_end_chars;
1710 }
1711 }
1712 }
1713
1714 fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1717 s.char_indices()
1718 .nth(char_idx)
1719 .map(|(b, _)| b)
1720 .unwrap_or(s.len())
1721 }
1722 }
1723
1724 #[test]
1726 fn test_apply_wrapping_transform_preserves_short_lines() {
1727 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1728
1729 let short_text = "x".repeat(100);
1731 let tokens = vec![
1732 ViewTokenWire {
1733 kind: ViewTokenWireKind::Text(short_text.clone()),
1734 source_offset: Some(0),
1735 style: None,
1736 },
1737 ViewTokenWire {
1738 kind: ViewTokenWireKind::Newline,
1739 source_offset: Some(100),
1740 style: None,
1741 },
1742 ];
1743
1744 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1746
1747 let break_count = wrapped
1749 .iter()
1750 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1751 .count();
1752
1753 assert_eq!(
1754 break_count, 0,
1755 "Short lines should not have any breaks, got {}",
1756 break_count
1757 );
1758
1759 let text_tokens: Vec<_> = wrapped
1761 .iter()
1762 .filter_map(|t| match &t.kind {
1763 ViewTokenWireKind::Text(s) => Some(s.clone()),
1764 _ => None,
1765 })
1766 .collect();
1767
1768 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
1769 assert_eq!(
1770 text_tokens[0], short_text,
1771 "Text content should be unchanged"
1772 );
1773 }
1774
1775 #[test]
1778 fn test_large_single_line_sequential_data_preserved() {
1779 use crate::view::ui::view_pipeline::ViewLineIterator;
1780 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1781
1782 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
1786
1787 let tokens = vec![
1789 ViewTokenWire {
1790 kind: ViewTokenWireKind::Text(content.clone()),
1791 source_offset: Some(0),
1792 style: None,
1793 },
1794 ViewTokenWire {
1795 kind: ViewTokenWireKind::Newline,
1796 source_offset: Some(content.len()),
1797 style: None,
1798 },
1799 ];
1800
1801 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1803
1804 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
1806
1807 let mut reconstructed = String::new();
1809 for line in &view_lines {
1810 let text = line.text.trim_end_matches('\n');
1812 reconstructed.push_str(text);
1813 }
1814
1815 assert_eq!(
1817 reconstructed.len(),
1818 content.len(),
1819 "Reconstructed content length should match original"
1820 );
1821
1822 for i in 1..=num_markers {
1824 let marker = format!("[{:05}]", i);
1825 assert!(
1826 reconstructed.contains(&marker),
1827 "Missing marker {} after pipeline",
1828 marker
1829 );
1830 }
1831
1832 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
1834 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
1835 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
1836 assert!(
1837 pos_100 < pos_1000 && pos_1000 < pos_3000,
1838 "Markers should be in sequential order: {} < {} < {}",
1839 pos_100,
1840 pos_1000,
1841 pos_3000
1842 );
1843
1844 assert!(
1846 view_lines.len() >= 3,
1847 "35KB content should produce multiple visual lines at 10K width, got {}",
1848 view_lines.len()
1849 );
1850
1851 for (i, line) in view_lines.iter().enumerate() {
1853 assert!(
1854 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
1856 i,
1857 line.text.len()
1858 );
1859 }
1860 }
1861
1862 fn strip_osc8(s: &str) -> String {
1864 let mut result = String::with_capacity(s.len());
1865 let bytes = s.as_bytes();
1866 let mut i = 0;
1867 while i < bytes.len() {
1868 if i + 3 < bytes.len()
1869 && bytes[i] == 0x1b
1870 && bytes[i + 1] == b']'
1871 && bytes[i + 2] == b'8'
1872 && bytes[i + 3] == b';'
1873 {
1874 i += 4;
1875 while i < bytes.len() && bytes[i] != 0x07 {
1876 i += 1;
1877 }
1878 if i < bytes.len() {
1879 i += 1;
1880 }
1881 } else {
1882 result.push(bytes[i] as char);
1883 i += 1;
1884 }
1885 }
1886 result
1887 }
1888
1889 fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
1892 let width = buf.area().width;
1893 let mut s = String::new();
1894 let mut col = 0u16;
1895 while col < width {
1896 let cell = &buf[(col, y)];
1897 let stripped = strip_osc8(cell.symbol());
1898 let chars = stripped.chars().count();
1899 if chars > 1 {
1900 s.push_str(&stripped);
1901 col += chars as u16;
1902 } else {
1903 s.push_str(&stripped);
1904 col += 1;
1905 }
1906 }
1907 s.trim_end().to_string()
1908 }
1909
1910 #[test]
1911 fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
1912 use ratatui::buffer::Buffer;
1913 use ratatui::layout::Rect;
1914
1915 let text = "[Quick Install](#installation)";
1917 let area = Rect::new(0, 0, 40, 1);
1918 let mut buf = Buffer::empty(area);
1919 for (i, ch) in text.chars().enumerate() {
1920 if (i as u16) < 40 {
1921 buf[(i as u16, 0)].set_symbol(&ch.to_string());
1922 }
1923 }
1924
1925 let url = "https://example.com";
1927
1928 apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
1930
1931 let row = read_row(&buf, 0);
1932 assert_eq!(
1933 row, text,
1934 "After OSC 8 application, reading the row should reproduce the original text"
1935 );
1936
1937 let cell14 = strip_osc8(buf[(14, 0)].symbol());
1939 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
1940
1941 let cell0 = strip_osc8(buf[(0, 0)].symbol());
1943 assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
1944 }
1945
1946 #[test]
1947 fn test_apply_osc8_stable_across_reapply() {
1948 use ratatui::buffer::Buffer;
1949 use ratatui::layout::Rect;
1950
1951 let text = "[Quick Install](#installation)";
1952 let area = Rect::new(0, 0, 40, 1);
1953
1954 let mut buf1 = Buffer::empty(area);
1956 for (i, ch) in text.chars().enumerate() {
1957 if (i as u16) < 40 {
1958 buf1[(i as u16, 0)].set_symbol(&ch.to_string());
1959 }
1960 }
1961 apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
1962 let row1 = read_row(&buf1, 0);
1963
1964 let mut buf2 = Buffer::empty(area);
1966 for (i, ch) in text.chars().enumerate() {
1967 if (i as u16) < 40 {
1968 buf2[(i as u16, 0)].set_symbol(&ch.to_string());
1969 }
1970 }
1971 apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
1972 let row2 = read_row(&buf2, 0);
1973
1974 assert_eq!(row1, text);
1975 assert_eq!(row2, text);
1976 }
1977
1978 #[test]
1979 #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
1980 fn test_apply_osc8_diff_between_renders() {
1981 use ratatui::buffer::Buffer;
1982 use ratatui::layout::Rect;
1983
1984 let area = Rect::new(0, 0, 40, 1);
1987
1988 let concealed = "Quick Install";
1990 let mut frame1 = Buffer::empty(area);
1991 for (i, ch) in concealed.chars().enumerate() {
1992 frame1[(i as u16, 0)].set_symbol(&ch.to_string());
1993 }
1994 apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
1996
1997 let prev = Buffer::empty(area);
1999 let mut backend = Buffer::empty(area);
2000 let diff1 = prev.diff(&frame1);
2001 for (x, y, cell) in &diff1 {
2002 backend[(*x, *y)] = (*cell).clone();
2003 }
2004
2005 let full = "[Quick Install](#installation)";
2007 let mut frame2 = Buffer::empty(area);
2008 for (i, ch) in full.chars().enumerate() {
2009 if (i as u16) < 40 {
2010 frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2011 }
2012 }
2013 apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2015
2016 let diff2 = frame1.diff(&frame2);
2018 for (x, y, cell) in &diff2 {
2019 backend[(*x, *y)] = (*cell).clone();
2020 }
2021
2022 let row = read_row(&backend, 0);
2024 assert_eq!(
2025 row, full,
2026 "After diff-based update from concealed to unconcealed, \
2027 backend should show full text"
2028 );
2029
2030 let cell14 = strip_osc8(backend[(14, 0)].symbol());
2032 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
2033 }
2034
2035 fn render_with_highlight_option(
2038 content: &str,
2039 cursor_pos: usize,
2040 highlight_current_line: bool,
2041 ) -> LineRenderOutput {
2042 let mut state = EditorState::new(20, 6, 1024, test_fs());
2043 state.buffer = Buffer::from_str(content, 1024, test_fs());
2044 let mut cursors = crate::model::cursor::Cursors::new();
2045 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
2046 let viewport = Viewport::new(20, 4);
2047 state.margins.left_config.enabled = false;
2048
2049 let render_area = Rect::new(0, 0, 20, 4);
2050 let visible_count = viewport.visible_line_count();
2051 let gutter_width = state.margins.left_total_width();
2052 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
2053 let empty_folds = FoldManager::new();
2054
2055 let view_data = build_view_data(
2056 &mut state,
2057 &viewport,
2058 None,
2059 content.len().max(1),
2060 visible_count,
2061 false,
2062 render_area.width as usize,
2063 gutter_width,
2064 &ViewMode::Source,
2065 &empty_folds,
2066 &theme,
2067 );
2068 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
2069
2070 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
2071 state.margins.update_width_for_buffer(estimated_lines, true);
2072 let gutter_width = state.margins.left_total_width();
2073
2074 let selection = selection_context(&state, &cursors);
2075 let _ = state
2076 .buffer
2077 .populate_line_cache(viewport.top_byte, visible_count);
2078 let viewport_start = viewport.top_byte;
2079 let viewport_end = calculate_viewport_end(
2080 &mut state,
2081 viewport_start,
2082 content.len().max(1),
2083 visible_count,
2084 );
2085 let decorations = decoration_context(
2086 &mut state,
2087 viewport_start,
2088 viewport_end,
2089 selection.primary_cursor_position,
2090 &empty_folds,
2091 &theme,
2092 100_000,
2093 &ViewMode::Source,
2094 false,
2095 &[],
2096 );
2097
2098 render_view_lines(LineRenderInput {
2099 state: &state,
2100 theme: &theme,
2101 view_lines: &view_data.lines,
2102 view_anchor,
2103 render_area,
2104 gutter_width,
2105 selection: &selection,
2106 decorations: &decorations,
2107 visible_line_count: visible_count,
2108 lsp_waiting: false,
2109 is_active: true,
2110 line_wrap: viewport.line_wrap_enabled,
2111 estimated_lines,
2112 left_column: viewport.left_column,
2113 relative_line_numbers: false,
2114 session_mode: false,
2115 software_cursor_only: false,
2116 show_line_numbers: false,
2117 byte_offset_mode: false,
2118 show_tilde: true,
2119 highlight_current_line,
2120 cell_theme_map: &mut Vec::new(),
2121 screen_width: 0,
2122 })
2123 }
2124
2125 fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
2127 let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
2128 if let Some(line) = output.lines.get(line_idx) {
2129 line.spans
2130 .iter()
2131 .any(|span| span.style.bg == Some(current_line_bg))
2132 } else {
2133 false
2134 }
2135 }
2136
2137 #[test]
2138 fn current_line_highlight_enabled_highlights_cursor_line() {
2139 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
2140 assert!(
2142 line_has_current_line_bg(&output, 0),
2143 "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
2144 );
2145 assert!(
2147 !line_has_current_line_bg(&output, 1),
2148 "Non-cursor line (line 1) should NOT have current_line_bg"
2149 );
2150 }
2151
2152 #[test]
2153 fn current_line_highlight_disabled_no_highlight() {
2154 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
2155 assert!(
2157 !line_has_current_line_bg(&output, 0),
2158 "Cursor line should NOT have current_line_bg when highlighting is disabled"
2159 );
2160 assert!(
2161 !line_has_current_line_bg(&output, 1),
2162 "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
2163 );
2164 }
2165
2166 #[test]
2167 fn current_line_highlight_follows_cursor_position() {
2168 let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
2170 assert!(
2171 !line_has_current_line_bg(&output, 0),
2172 "Line 0 should NOT have current_line_bg when cursor is on line 1"
2173 );
2174 assert!(
2175 line_has_current_line_bg(&output, 1),
2176 "Line 1 should have current_line_bg when cursor is there"
2177 );
2178 assert!(
2179 !line_has_current_line_bg(&output, 2),
2180 "Line 2 should NOT have current_line_bg when cursor is on line 1"
2181 );
2182 }
2183}