1pub(crate) mod base_tokens;
16mod char_style;
17mod folding;
18mod gutter;
19mod layout;
20mod orchestration;
21mod post_pass;
22mod scrollbar;
23mod spans;
24mod style;
25pub(crate) mod 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 highlight_current_column: bool,
93 cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
94 screen_width: u16,
95 pending_hardware_cursor: &mut Option<(u16, u16)>,
96 ) -> (
97 Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
98 HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
99 Vec<(LeafId, u16, u16, u16)>,
100 Vec<(LeafId, u16, u16, u16)>,
101 HashMap<LeafId, Vec<ViewLineMapping>>,
102 Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
103 Vec<(
104 crate::model::event::ContainerId,
105 SplitDirection,
106 u16,
107 u16,
108 u16,
109 )>,
110 ) {
111 orchestration::render_content(
112 frame,
113 area,
114 split_manager,
115 buffers,
116 buffer_metadata,
117 event_logs,
118 composite_buffers,
119 composite_view_states,
120 theme,
121 ansi_background,
122 background_fade,
123 lsp_waiting,
124 large_file_threshold_bytes,
125 line_wrap,
126 estimated_line_length,
127 highlight_context_bytes,
128 split_view_states,
129 grouped_subtrees,
130 hide_cursor,
131 hovered_tab,
132 hovered_close_split,
133 hovered_maximize_split,
134 is_maximized,
135 relative_line_numbers,
136 tab_bar_visible,
137 use_terminal_bg,
138 session_mode,
139 software_cursor_only,
140 show_vertical_scrollbar,
141 show_horizontal_scrollbar,
142 diagnostics_inline_text,
143 show_tilde,
144 highlight_current_column,
145 cell_theme_map,
146 screen_width,
147 pending_hardware_cursor,
148 )
149 }
150
151 #[allow(clippy::too_many_arguments)]
152 pub fn compute_content_layout(
153 area: Rect,
154 split_manager: &SplitManager,
155 buffers: &mut HashMap<BufferId, EditorState>,
156 split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
157 theme: &crate::view::theme::Theme,
158 lsp_waiting: bool,
159 estimated_line_length: usize,
160 highlight_context_bytes: usize,
161 relative_line_numbers: bool,
162 use_terminal_bg: bool,
163 session_mode: bool,
164 software_cursor_only: bool,
165 tab_bar_visible: bool,
166 show_vertical_scrollbar: bool,
167 show_horizontal_scrollbar: bool,
168 diagnostics_inline_text: bool,
169 show_tilde: bool,
170 ) -> HashMap<LeafId, Vec<ViewLineMapping>> {
171 orchestration::compute_content_layout(
172 area,
173 split_manager,
174 buffers,
175 split_view_states,
176 theme,
177 lsp_waiting,
178 estimated_line_length,
179 highlight_context_bytes,
180 relative_line_numbers,
181 use_terminal_bg,
182 session_mode,
183 software_cursor_only,
184 tab_bar_visible,
185 show_vertical_scrollbar,
186 show_horizontal_scrollbar,
187 diagnostics_inline_text,
188 show_tilde,
189 )
190 }
191
192 #[allow(clippy::too_many_arguments)]
202 pub fn render_phantom_leaf(
203 frame: &mut Frame,
204 state: &mut EditorState,
205 cursors: &crate::model::cursor::Cursors,
206 viewport: &mut crate::view::viewport::Viewport,
207 folds: &mut crate::view::folding::FoldManager,
208 event_log: Option<&mut EventLog>,
209 area: Rect,
210 theme: &crate::view::theme::Theme,
211 ansi_background: Option<&AnsiBackground>,
212 background_fade: f32,
213 view_mode: crate::state::ViewMode,
214 compose_width: Option<u16>,
215 compose_column_guides: Option<Vec<u16>>,
216 view_transform: Option<crate::services::plugins::api::ViewTransformPayload>,
217 estimated_line_length: usize,
218 highlight_context_bytes: usize,
219 buffer_id: BufferId,
220 relative_line_numbers: bool,
221 use_terminal_bg: bool,
222 session_mode: bool,
223 software_cursor_only: bool,
224 rulers: &[usize],
225 show_line_numbers: bool,
226 highlight_current_line: bool,
227 diagnostics_inline_text: bool,
228 show_tilde: bool,
229 highlight_current_column: bool,
230 cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
231 screen_width: u16,
232 ) -> Vec<crate::app::types::ViewLineMapping> {
233 let mut sink: Option<(u16, u16)> = None;
242 orchestration::render_buffer_in_split(
243 frame,
244 state,
245 cursors,
246 viewport,
247 folds,
248 event_log,
249 area,
250 false,
251 theme,
252 ansi_background,
253 background_fade,
254 false,
255 view_mode,
256 compose_width,
257 compose_column_guides,
258 view_transform,
259 estimated_line_length,
260 highlight_context_bytes,
261 buffer_id,
262 true,
263 relative_line_numbers,
264 use_terminal_bg,
265 session_mode,
266 software_cursor_only,
267 rulers,
268 show_line_numbers,
269 highlight_current_line,
270 diagnostics_inline_text,
271 show_tilde,
272 highlight_current_column,
273 cell_theme_map,
274 screen_width,
275 &mut sink,
276 )
277 }
278
279 pub fn build_base_tokens_for_hook(
282 buffer: &mut Buffer,
283 top_byte: usize,
284 estimated_line_length: usize,
285 visible_count: usize,
286 is_binary: bool,
287 line_ending: crate::model::buffer::LineEnding,
288 ) -> Vec<fresh_core::api::ViewTokenWire> {
289 orchestration::build_base_tokens_for_hook(
290 buffer,
291 top_byte,
292 estimated_line_length,
293 visible_count,
294 is_binary,
295 line_ending,
296 )
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::folding::fold_indicators_for_viewport;
303 use super::layout::{calculate_view_anchor, calculate_viewport_end};
304 use super::orchestration::overlays::{decoration_context, selection_context};
305 use super::orchestration::render_buffer::resolve_cursor_fallback;
306 use super::orchestration::render_line::{
307 render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
308 };
309 use super::post_pass::apply_osc8_to_cells;
310 use super::transforms::apply_wrapping_transform;
311 use super::view_data::build_view_data;
312 use super::*;
313
314 use crate::model::buffer::{Buffer, LineEnding};
315 use crate::model::filesystem::StdFileSystem;
316 use crate::primitives::display_width::str_width;
317 use crate::state::{EditorState, ViewMode};
318 use crate::view::folding::FoldManager;
319 use crate::view::theme;
320 use crate::view::theme::Theme;
321 use crate::view::ui::view_pipeline::{LineStart, ViewLine};
322 use crate::view::viewport::Viewport;
323 use fresh_core::api::ViewTokenWire;
324 use lsp_types::FoldingRange;
325 use std::collections::HashSet;
326 use std::sync::Arc;
327
328 fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
329 Arc::new(StdFileSystem)
330 }
331
332 fn render_output_for(
333 content: &str,
334 cursor_pos: usize,
335 ) -> (LineRenderOutput, usize, bool, usize) {
336 render_output_for_with_gutters(content, cursor_pos, false)
337 }
338
339 fn render_output_for_with_gutters(
340 content: &str,
341 cursor_pos: usize,
342 gutters_enabled: bool,
343 ) -> (LineRenderOutput, usize, bool, usize) {
344 let mut state = EditorState::new(20, 6, 1024, test_fs());
345 state.buffer = Buffer::from_str(content, 1024, test_fs());
346 let mut cursors = crate::model::cursor::Cursors::new();
347 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
348 let viewport = Viewport::new(20, 4);
350 state.margins.left_config.enabled = gutters_enabled;
352
353 let render_area = Rect::new(0, 0, 20, 4);
354 let visible_count = viewport.visible_line_count();
355 let gutter_width = state.margins.left_total_width();
356 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
357 let empty_folds = FoldManager::new();
358
359 let view_data = build_view_data(
360 &mut state,
361 &viewport,
362 None,
363 content.len().max(1),
364 visible_count,
365 false, render_area.width as usize,
367 gutter_width,
368 &ViewMode::Source, &empty_folds,
370 &theme,
371 );
372 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
373
374 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
375 state.margins.update_width_for_buffer(estimated_lines, true);
376 let gutter_width = state.margins.left_total_width();
377
378 let selection = selection_context(&state, &cursors);
379 let _ = state
380 .buffer
381 .populate_line_cache(viewport.top_byte, visible_count);
382 let viewport_start = viewport.top_byte;
383 let viewport_end = calculate_viewport_end(
384 &mut state,
385 viewport_start,
386 content.len().max(1),
387 visible_count,
388 );
389 let decorations = decoration_context(
390 &mut state,
391 viewport_start,
392 viewport_end,
393 selection.primary_cursor_position,
394 &empty_folds,
395 &theme,
396 100_000, &ViewMode::Source, false, &[],
400 );
401
402 let mut dummy_theme_map = Vec::new();
403 let output = render_view_lines(LineRenderInput {
404 state: &state,
405 theme: &theme,
406 view_lines: &view_data.lines,
407 view_anchor,
408 render_area,
409 gutter_width,
410 selection: &selection,
411 decorations: &decorations,
412 visible_line_count: visible_count,
413 lsp_waiting: false,
414 is_active: true,
415 line_wrap: viewport.line_wrap_enabled,
416 estimated_lines,
417 left_column: viewport.left_column,
418 relative_line_numbers: false,
419 session_mode: false,
420 software_cursor_only: false,
421 show_line_numbers: true, byte_offset_mode: false, show_tilde: true,
424 highlight_current_line: true,
425 cell_theme_map: &mut dummy_theme_map,
426 screen_width: 0,
427 });
428
429 (
430 output,
431 state.buffer.len(),
432 content.ends_with('\n'),
433 selection.primary_cursor_position,
434 )
435 }
436
437 #[test]
438 fn test_folding_hides_lines_and_adds_placeholder() {
439 let content = "header\nline1\nline2\ntail\n";
440 let mut state = EditorState::new(40, 6, 1024, test_fs());
441 state.buffer = Buffer::from_str(content, 1024, test_fs());
442
443 let start = state.buffer.line_start_offset(1).unwrap();
444 let end = state.buffer.line_start_offset(3).unwrap();
445 let mut folds = FoldManager::new();
446 folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
447
448 let viewport = Viewport::new(40, 6);
449 let gutter_width = state.margins.left_total_width();
450 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
451 let view_data = build_view_data(
452 &mut state,
453 &viewport,
454 None,
455 content.len().max(1),
456 viewport.visible_line_count(),
457 false,
458 40,
459 gutter_width,
460 &ViewMode::Source,
461 &folds,
462 &theme,
463 );
464
465 let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
466 assert!(lines.iter().any(|l| l.contains("header")));
467 assert!(lines.iter().any(|l| l.contains("tail")));
468 assert!(!lines.iter().any(|l| l.contains("line1")));
469 assert!(!lines.iter().any(|l| l.contains("line2")));
470 assert!(lines
471 .iter()
472 .any(|l| l.contains("header") && l.contains("...")));
473 }
474
475 #[test]
476 fn test_fold_indicators_collapsed_and_expanded() {
477 let content = "a\nb\nc\nd\n";
478 let mut state = EditorState::new(40, 6, 1024, test_fs());
479 state.buffer = Buffer::from_str(content, 1024, test_fs());
480
481 let lsp_ranges = vec![
482 FoldingRange {
483 start_line: 0,
484 end_line: 1,
485 start_character: None,
486 end_character: None,
487 kind: None,
488 collapsed_text: None,
489 },
490 FoldingRange {
491 start_line: 1,
492 end_line: 2,
493 start_character: None,
494 end_character: None,
495 kind: None,
496 collapsed_text: None,
497 },
498 ];
499 state
500 .folding_ranges
501 .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
502
503 let start = state.buffer.line_start_offset(1).unwrap();
504 let end = state.buffer.line_start_offset(2).unwrap();
505 let mut folds = FoldManager::new();
506 folds.add(&mut state.marker_list, start, end, None);
507
508 let line1_byte = state.buffer.line_start_offset(1).unwrap();
509 let view_lines = vec![ViewLine {
510 text: "b\n".to_string(),
511 source_start_byte: Some(line1_byte),
512 char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
513 char_styles: vec![None, None],
514 char_visual_cols: vec![0, 1],
515 visual_to_char: vec![0, 1],
516 tab_starts: HashSet::new(),
517 line_start: LineStart::AfterSourceNewline,
518 ends_with_newline: true,
519 }];
520
521 let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
522
523 assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
525 assert_eq!(
527 indicators.get(&line1_byte).map(|i| i.collapsed),
528 Some(false)
529 );
530 }
531
532 #[test]
533 fn last_line_end_tracks_trailing_newline() {
534 let output = render_output_for("abc\n", 4);
535 assert_eq!(
536 output.0.last_line_end,
537 Some(LastLineEnd {
538 pos: (3, 0),
539 terminated_with_newline: true
540 })
541 );
542 }
543
544 #[test]
545 fn last_line_end_tracks_no_trailing_newline() {
546 let output = render_output_for("abc", 3);
547 assert_eq!(
548 output.0.last_line_end,
549 Some(LastLineEnd {
550 pos: (3, 0),
551 terminated_with_newline: false
552 })
553 );
554 }
555
556 #[test]
557 fn cursor_after_newline_places_on_next_line() {
558 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
559 let cursor = resolve_cursor_fallback(
560 output.cursor,
561 cursor_pos,
562 buffer_len,
563 buffer_newline,
564 output.last_line_end,
565 output.content_lines_rendered,
566 0, );
568 assert_eq!(cursor, Some((0, 1)));
569 }
570
571 #[test]
572 fn cursor_at_end_without_newline_stays_on_line() {
573 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
574 let cursor = resolve_cursor_fallback(
575 output.cursor,
576 cursor_pos,
577 buffer_len,
578 buffer_newline,
579 output.last_line_end,
580 output.content_lines_rendered,
581 0, );
583 assert_eq!(cursor, Some((3, 0)));
584 }
585
586 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
592 let mut cursor_positions = Vec::new();
593
594 let primary_cursor = output.cursor;
596 if let Some(cursor_pos) = primary_cursor {
597 cursor_positions.push(cursor_pos);
598 }
599
600 for (line_idx, line) in output.lines.iter().enumerate() {
602 let mut col = 0u16;
603 for span in line.spans.iter() {
604 if span
606 .style
607 .add_modifier
608 .contains(ratatui::style::Modifier::REVERSED)
609 {
610 let pos = (col, line_idx as u16);
611 if primary_cursor != Some(pos) {
614 cursor_positions.push(pos);
615 }
616 }
617 col += str_width(&span.content) as u16;
619 }
620 }
621
622 cursor_positions
623 }
624
625 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
627 eprintln!("\n=== RENDER DEBUG ===");
628 eprintln!("Content: {:?}", content);
629 eprintln!("Cursor position: {}", cursor_pos);
630 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
631 eprintln!("Last line end: {:?}", output.last_line_end);
632 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
633 eprintln!("\nRendered lines:");
634 for (line_idx, line) in output.lines.iter().enumerate() {
635 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
636 for (span_idx, span) in line.spans.iter().enumerate() {
637 let has_reversed = span
638 .style
639 .add_modifier
640 .contains(ratatui::style::Modifier::REVERSED);
641 let bg_color = format!("{:?}", span.style.bg);
642 eprintln!(
643 " Span {}: {:?} (REVERSED: {}, BG: {})",
644 span_idx, span.content, has_reversed, bg_color
645 );
646 }
647 }
648 eprintln!("===================\n");
649 }
650
651 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
654 let (output, buffer_len, buffer_newline, cursor_pos) =
655 render_output_for(content, cursor_pos);
656
657 let all_cursors = count_all_cursors(&output);
659
660 assert!(
663 all_cursors.len() <= 1,
664 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
665 all_cursors.len(),
666 all_cursors
667 );
668
669 let final_cursor = resolve_cursor_fallback(
670 output.cursor,
671 cursor_pos,
672 buffer_len,
673 buffer_newline,
674 output.last_line_end,
675 output.content_lines_rendered,
676 0, );
678
679 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
681 {
682 dump_render_output(content, cursor_pos, &output);
683 }
684
685 if let Some(rendered_cursor) = all_cursors.first() {
687 assert_eq!(
688 Some(*rendered_cursor),
689 final_cursor,
690 "Rendered cursor at {:?} doesn't match final cursor {:?}",
691 rendered_cursor,
692 final_cursor
693 );
694 }
695
696 assert!(
698 final_cursor.is_some(),
699 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
700 all_cursors
701 );
702
703 final_cursor
704 }
705
706 fn check_typing_at_cursor(
708 content: &str,
709 cursor_pos: usize,
710 char_to_type: char,
711 ) -> (Option<(u16, u16)>, String) {
712 let cursor_before = get_final_cursor(content, cursor_pos);
714
715 let mut new_content = content.to_string();
717 if cursor_pos <= content.len() {
718 new_content.insert(cursor_pos, char_to_type);
719 }
720
721 (cursor_before, new_content)
722 }
723
724 #[test]
725 fn e2e_cursor_at_start_of_nonempty_line() {
726 let cursor = get_final_cursor("abc", 0);
728 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
729
730 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
731 assert_eq!(
732 new_content, "Xabc",
733 "Typing should insert at cursor position"
734 );
735 assert_eq!(cursor_pos, Some((0, 0)));
736 }
737
738 #[test]
739 fn e2e_cursor_in_middle_of_line() {
740 let cursor = get_final_cursor("abc", 1);
742 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
743
744 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
745 assert_eq!(
746 new_content, "aXbc",
747 "Typing should insert at cursor position"
748 );
749 assert_eq!(cursor_pos, Some((1, 0)));
750 }
751
752 #[test]
753 fn e2e_cursor_at_end_of_line_no_newline() {
754 let cursor = get_final_cursor("abc", 3);
756 assert_eq!(
757 cursor,
758 Some((3, 0)),
759 "Cursor should be at column 3, line 0 (after last char)"
760 );
761
762 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
763 assert_eq!(new_content, "abcX", "Typing should append at end");
764 assert_eq!(cursor_pos, Some((3, 0)));
765 }
766
767 #[test]
768 fn e2e_cursor_at_empty_line() {
769 let cursor = get_final_cursor("\n", 0);
771 assert_eq!(
772 cursor,
773 Some((0, 0)),
774 "Cursor on empty line should be at column 0"
775 );
776
777 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
778 assert_eq!(new_content, "X\n", "Typing should insert before newline");
779 assert_eq!(cursor_pos, Some((0, 0)));
780 }
781
782 #[test]
783 fn e2e_cursor_after_newline_at_eof() {
784 let cursor = get_final_cursor("abc\n", 4);
786 assert_eq!(
787 cursor,
788 Some((0, 1)),
789 "Cursor after newline at EOF should be on next line"
790 );
791
792 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
793 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
794 assert_eq!(cursor_pos, Some((0, 1)));
795 }
796
797 #[test]
798 fn e2e_cursor_on_newline_with_content() {
799 let cursor = get_final_cursor("abc\n", 3);
801 assert_eq!(
802 cursor,
803 Some((3, 0)),
804 "Cursor on newline after content should be after last char"
805 );
806
807 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
808 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
809 assert_eq!(cursor_pos, Some((3, 0)));
810 }
811
812 #[test]
813 fn e2e_cursor_multiline_start_of_second_line() {
814 let cursor = get_final_cursor("abc\ndef", 4);
816 assert_eq!(
817 cursor,
818 Some((0, 1)),
819 "Cursor at start of second line should be at column 0, line 1"
820 );
821
822 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
823 assert_eq!(
824 new_content, "abc\nXdef",
825 "Typing should insert at start of second line"
826 );
827 assert_eq!(cursor_pos, Some((0, 1)));
828 }
829
830 #[test]
831 fn e2e_cursor_multiline_end_of_first_line() {
832 let cursor = get_final_cursor("abc\ndef", 3);
834 assert_eq!(
835 cursor,
836 Some((3, 0)),
837 "Cursor on newline of first line should be after content"
838 );
839
840 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
841 assert_eq!(
842 new_content, "abcX\ndef",
843 "Typing should insert before newline"
844 );
845 assert_eq!(cursor_pos, Some((3, 0)));
846 }
847
848 #[test]
849 fn e2e_cursor_empty_buffer() {
850 let cursor = get_final_cursor("", 0);
852 assert_eq!(
853 cursor,
854 Some((0, 0)),
855 "Cursor in empty buffer should be at origin"
856 );
857
858 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
859 assert_eq!(
860 new_content, "X",
861 "Typing in empty buffer should insert character"
862 );
863 assert_eq!(cursor_pos, Some((0, 0)));
864 }
865
866 #[test]
867 fn e2e_cursor_empty_buffer_with_gutters() {
868 let (output, buffer_len, buffer_newline, cursor_pos) =
872 render_output_for_with_gutters("", 0, true);
873
874 let gutter_width = {
878 let mut state = EditorState::new(20, 6, 1024, test_fs());
879 state.margins.left_config.enabled = true;
880 state.margins.update_width_for_buffer(1, true);
881 state.margins.left_total_width()
882 };
883 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
884
885 assert_eq!(
889 output.cursor,
890 Some((gutter_width as u16, 0)),
891 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
892 gutter_width,
893 output.cursor
894 );
895
896 let final_cursor = resolve_cursor_fallback(
897 output.cursor,
898 cursor_pos,
899 buffer_len,
900 buffer_newline,
901 output.last_line_end,
902 output.content_lines_rendered,
903 gutter_width,
904 );
905
906 assert_eq!(
908 final_cursor,
909 Some((gutter_width as u16, 0)),
910 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
911 );
912 }
913
914 #[test]
915 fn e2e_cursor_between_empty_lines() {
916 let cursor = get_final_cursor("\n\n", 1);
918 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
919
920 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
921 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
922 assert_eq!(cursor_pos, Some((0, 1)));
923 }
924
925 #[test]
926 fn e2e_cursor_at_eof_after_multiple_lines() {
927 let cursor = get_final_cursor("abc\ndef\nghi", 11);
929 assert_eq!(
930 cursor,
931 Some((3, 2)),
932 "Cursor at EOF after 'i' should be at column 3, line 2"
933 );
934
935 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
936 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
937 assert_eq!(cursor_pos, Some((3, 2)));
938 }
939
940 #[test]
941 fn e2e_cursor_at_eof_with_trailing_newline() {
942 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
944 assert_eq!(
945 cursor,
946 Some((0, 3)),
947 "Cursor after trailing newline should be on line 3"
948 );
949
950 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
951 assert_eq!(
952 new_content, "abc\ndef\nghi\nX",
953 "Typing should insert on new line"
954 );
955 assert_eq!(cursor_pos, Some((0, 3)));
956 }
957
958 #[test]
959 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
960 let content = "abc\ndef\nghi";
962
963 let cursor_at_start = get_final_cursor(content, 0);
965 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
966
967 let cursor_at_eof = get_final_cursor(content, 11);
969 assert_eq!(
970 cursor_at_eof,
971 Some((3, 2)),
972 "After Ctrl+End, cursor at column 3, line 2"
973 );
974
975 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
977 assert_eq!(cursor_before_typing, Some((3, 2)));
978 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
979
980 let cursor_after_typing = get_final_cursor(&new_content, 12);
982 assert_eq!(
983 cursor_after_typing,
984 Some((4, 2)),
985 "After typing, cursor moved to column 4"
986 );
987
988 let cursor_moved_away = get_final_cursor(&new_content, 0);
990 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
991 }
994
995 #[test]
996 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
997 let content = "abc\ndef\nghi\n";
999
1000 let cursor_at_start = get_final_cursor(content, 0);
1002 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
1003
1004 let cursor_at_eof = get_final_cursor(content, 12);
1006 assert_eq!(
1007 cursor_at_eof,
1008 Some((0, 3)),
1009 "After Ctrl+End, cursor at column 0, line 3 (new line)"
1010 );
1011
1012 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
1014 assert_eq!(cursor_before_typing, Some((0, 3)));
1015 assert_eq!(
1016 new_content, "abc\ndef\nghi\nX",
1017 "Character inserted on new line"
1018 );
1019
1020 let cursor_after_typing = get_final_cursor(&new_content, 13);
1022 assert_eq!(
1023 cursor_after_typing,
1024 Some((1, 3)),
1025 "After typing, cursor should be at column 1, line 3"
1026 );
1027
1028 let cursor_moved_away = get_final_cursor(&new_content, 4);
1030 assert_eq!(
1031 cursor_moved_away,
1032 Some((0, 1)),
1033 "Cursor moved to start of line 1 (position 4 = start of 'def')"
1034 );
1035 }
1036
1037 #[test]
1038 fn e2e_jump_to_end_of_empty_buffer() {
1039 let content = "";
1041
1042 let cursor_at_eof = get_final_cursor(content, 0);
1043 assert_eq!(
1044 cursor_at_eof,
1045 Some((0, 0)),
1046 "Empty buffer: cursor at origin"
1047 );
1048
1049 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
1051 assert_eq!(cursor_before_typing, Some((0, 0)));
1052 assert_eq!(new_content, "X", "Character inserted");
1053
1054 let cursor_after_typing = get_final_cursor(&new_content, 1);
1056 assert_eq!(
1057 cursor_after_typing,
1058 Some((1, 0)),
1059 "After typing, cursor at column 1"
1060 );
1061
1062 let cursor_moved_away = get_final_cursor(&new_content, 0);
1064 assert_eq!(
1065 cursor_moved_away,
1066 Some((0, 0)),
1067 "Cursor moved back to start"
1068 );
1069 }
1070
1071 #[test]
1072 fn e2e_jump_to_end_of_single_empty_line() {
1073 let content = "\n";
1075
1076 let cursor_on_newline = get_final_cursor(content, 0);
1078 assert_eq!(
1079 cursor_on_newline,
1080 Some((0, 0)),
1081 "Cursor on the newline character"
1082 );
1083
1084 let cursor_at_eof = get_final_cursor(content, 1);
1086 assert_eq!(
1087 cursor_at_eof,
1088 Some((0, 1)),
1089 "After Ctrl+End, cursor on line 1"
1090 );
1091
1092 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1094 assert_eq!(cursor_before_typing, Some((0, 1)));
1095 assert_eq!(new_content, "\nX", "Character on second line");
1096
1097 let cursor_after_typing = get_final_cursor(&new_content, 2);
1098 assert_eq!(
1099 cursor_after_typing,
1100 Some((1, 1)),
1101 "After typing, cursor at column 1, line 1"
1102 );
1103
1104 let cursor_moved_away = get_final_cursor(&new_content, 0);
1106 assert_eq!(
1107 cursor_moved_away,
1108 Some((0, 0)),
1109 "Cursor moved to the newline on line 0"
1110 );
1111 }
1112 use fresh_core::api::ViewTokenWireKind;
1123
1124 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1126 tokens
1127 .iter()
1128 .map(|t| {
1129 let kind_str = match &t.kind {
1130 ViewTokenWireKind::Text(s) => format!("Text({})", s),
1131 ViewTokenWireKind::Newline => "Newline".to_string(),
1132 ViewTokenWireKind::Space => "Space".to_string(),
1133 ViewTokenWireKind::Break => "Break".to_string(),
1134 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1135 };
1136 (kind_str, t.source_offset)
1137 })
1138 .collect()
1139 }
1140
1141 #[test]
1144 fn test_build_base_tokens_crlf_single_line() {
1145 let content = b"abc\r\n";
1147 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1148 buffer.set_line_ending(LineEnding::CRLF);
1149
1150 let tokens = SplitRenderer::build_base_tokens_for_hook(
1151 &mut buffer,
1152 0, 80, 10, false, LineEnding::CRLF,
1157 );
1158
1159 let offsets = extract_token_offsets(&tokens);
1160
1161 assert!(
1164 offsets
1165 .iter()
1166 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1167 "Expected Text(abc) at offset 0, got: {:?}",
1168 offsets
1169 );
1170 assert!(
1171 offsets
1172 .iter()
1173 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1174 "Expected Newline at offset 3 (\\r position), got: {:?}",
1175 offsets
1176 );
1177
1178 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1180 assert_eq!(
1181 newline_count, 1,
1182 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1183 newline_count, offsets
1184 );
1185 }
1186
1187 #[test]
1190 fn test_build_base_tokens_crlf_multiple_lines() {
1191 let content = b"abc\r\ndef\r\nghi\r\n";
1196 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1197 buffer.set_line_ending(LineEnding::CRLF);
1198
1199 let tokens = SplitRenderer::build_base_tokens_for_hook(
1200 &mut buffer,
1201 0,
1202 80,
1203 10,
1204 false,
1205 LineEnding::CRLF,
1206 );
1207
1208 let offsets = extract_token_offsets(&tokens);
1209
1210 assert!(
1217 offsets
1218 .iter()
1219 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1220 "Line 1: Expected Text(abc) at 0, got: {:?}",
1221 offsets
1222 );
1223 assert!(
1224 offsets
1225 .iter()
1226 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1227 "Line 1: Expected Newline at 3, got: {:?}",
1228 offsets
1229 );
1230
1231 assert!(
1233 offsets
1234 .iter()
1235 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1236 "Line 2: Expected Text(def) at 5, got: {:?}",
1237 offsets
1238 );
1239 assert!(
1240 offsets
1241 .iter()
1242 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1243 "Line 2: Expected Newline at 8, got: {:?}",
1244 offsets
1245 );
1246
1247 assert!(
1249 offsets
1250 .iter()
1251 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1252 "Line 3: Expected Text(ghi) at 10, got: {:?}",
1253 offsets
1254 );
1255 assert!(
1256 offsets
1257 .iter()
1258 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
1259 "Line 3: Expected Newline at 13, got: {:?}",
1260 offsets
1261 );
1262
1263 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1265 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
1266 }
1267
1268 #[test]
1271 fn test_build_base_tokens_lf_mode_for_comparison() {
1272 let content = b"abc\ndef\n";
1276 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1277 buffer.set_line_ending(LineEnding::LF);
1278
1279 let tokens = SplitRenderer::build_base_tokens_for_hook(
1280 &mut buffer,
1281 0,
1282 80,
1283 10,
1284 false,
1285 LineEnding::LF,
1286 );
1287
1288 let offsets = extract_token_offsets(&tokens);
1289
1290 assert!(
1292 offsets
1293 .iter()
1294 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1295 "LF Line 1: Expected Text(abc) at 0"
1296 );
1297 assert!(
1298 offsets
1299 .iter()
1300 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1301 "LF Line 1: Expected Newline at 3"
1302 );
1303 assert!(
1304 offsets
1305 .iter()
1306 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
1307 "LF Line 2: Expected Text(def) at 4"
1308 );
1309 assert!(
1310 offsets
1311 .iter()
1312 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
1313 "LF Line 2: Expected Newline at 7"
1314 );
1315 }
1316
1317 #[test]
1320 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
1321 let content = b"abc\r\n";
1323 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1324 buffer.set_line_ending(LineEnding::LF); let tokens = SplitRenderer::build_base_tokens_for_hook(
1327 &mut buffer,
1328 0,
1329 80,
1330 10,
1331 false,
1332 LineEnding::LF,
1333 );
1334
1335 let offsets = extract_token_offsets(&tokens);
1336
1337 assert!(
1339 offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
1340 "LF mode should render \\r as control char <0D>, got: {:?}",
1341 offsets
1342 );
1343 }
1344
1345 #[test]
1348 fn test_build_base_tokens_crlf_from_middle() {
1349 let content = b"abc\r\ndef\r\nghi\r\n";
1352 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1353 buffer.set_line_ending(LineEnding::CRLF);
1354
1355 let tokens = SplitRenderer::build_base_tokens_for_hook(
1356 &mut buffer,
1357 5, 80,
1359 10,
1360 false,
1361 LineEnding::CRLF,
1362 );
1363
1364 let offsets = extract_token_offsets(&tokens);
1365
1366 assert!(
1370 offsets
1371 .iter()
1372 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1373 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
1374 offsets
1375 );
1376 assert!(
1377 offsets
1378 .iter()
1379 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1380 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
1381 offsets
1382 );
1383 }
1384
1385 #[test]
1388 fn test_crlf_highlight_span_lookup() {
1389 use crate::view::ui::view_pipeline::ViewLineIterator;
1390
1391 let content = b"int x;\r\nint y;\r\n";
1396 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1397 buffer.set_line_ending(LineEnding::CRLF);
1398
1399 let tokens = SplitRenderer::build_base_tokens_for_hook(
1401 &mut buffer,
1402 0,
1403 80,
1404 10,
1405 false,
1406 LineEnding::CRLF,
1407 );
1408
1409 let offsets = extract_token_offsets(&tokens);
1411 eprintln!("Tokens: {:?}", offsets);
1412
1413 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1415 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
1416
1417 eprintln!(
1420 "Line 1 char_source_bytes: {:?}",
1421 view_lines[0].char_source_bytes
1422 );
1423 assert_eq!(
1424 view_lines[0].char_source_bytes.len(),
1425 7,
1426 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
1427 );
1428 assert_eq!(
1430 view_lines[0].char_source_bytes[0],
1431 Some(0),
1432 "Line 1 'i' -> byte 0"
1433 );
1434 assert_eq!(
1435 view_lines[0].char_source_bytes[4],
1436 Some(4),
1437 "Line 1 'x' -> byte 4"
1438 );
1439 assert_eq!(
1440 view_lines[0].char_source_bytes[5],
1441 Some(5),
1442 "Line 1 ';' -> byte 5"
1443 );
1444 assert_eq!(
1445 view_lines[0].char_source_bytes[6],
1446 Some(6),
1447 "Line 1 newline -> byte 6 (\\r pos)"
1448 );
1449
1450 eprintln!(
1452 "Line 2 char_source_bytes: {:?}",
1453 view_lines[1].char_source_bytes
1454 );
1455 assert_eq!(
1456 view_lines[1].char_source_bytes.len(),
1457 7,
1458 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
1459 );
1460 assert_eq!(
1462 view_lines[1].char_source_bytes[0],
1463 Some(8),
1464 "Line 2 'i' -> byte 8"
1465 );
1466 assert_eq!(
1467 view_lines[1].char_source_bytes[4],
1468 Some(12),
1469 "Line 2 'y' -> byte 12"
1470 );
1471 assert_eq!(
1472 view_lines[1].char_source_bytes[5],
1473 Some(13),
1474 "Line 2 ';' -> byte 13"
1475 );
1476 assert_eq!(
1477 view_lines[1].char_source_bytes[6],
1478 Some(14),
1479 "Line 2 newline -> byte 14 (\\r pos)"
1480 );
1481
1482 let simulated_highlight_spans = [
1486 (0usize..3usize, "keyword"),
1488 (8usize..11usize, "keyword"),
1490 ];
1491
1492 for (line_idx, view_line) in view_lines.iter().enumerate() {
1494 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
1495 if let Some(bp) = byte_pos {
1496 let in_span = simulated_highlight_spans
1497 .iter()
1498 .find(|(range, _)| range.contains(bp))
1499 .map(|(_, name)| *name);
1500
1501 let expected_in_keyword = char_idx < 3;
1503 let actually_in_keyword = in_span == Some("keyword");
1504
1505 if expected_in_keyword != actually_in_keyword {
1506 panic!(
1507 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
1508 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
1509 );
1510 }
1511 }
1512 }
1513 }
1514 }
1515
1516 #[test]
1519 fn test_apply_wrapping_transform_breaks_long_lines() {
1520 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1521
1522 let long_text = "x".repeat(25_000);
1524 let tokens = vec![
1525 ViewTokenWire {
1526 kind: ViewTokenWireKind::Text(long_text),
1527 source_offset: Some(0),
1528 style: None,
1529 },
1530 ViewTokenWire {
1531 kind: ViewTokenWireKind::Newline,
1532 source_offset: Some(25_000),
1533 style: None,
1534 },
1535 ];
1536
1537 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1539
1540 let break_count = wrapped
1542 .iter()
1543 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1544 .count();
1545
1546 assert!(
1547 break_count >= 2,
1548 "25K char line should have at least 2 breaks at 10K width, got {}",
1549 break_count
1550 );
1551
1552 let total_chars: usize = wrapped
1554 .iter()
1555 .filter_map(|t| match &t.kind {
1556 ViewTokenWireKind::Text(s) => Some(s.len()),
1557 _ => None,
1558 })
1559 .sum();
1560
1561 assert_eq!(
1562 total_chars, 25_000,
1563 "Total character count should be preserved after wrapping"
1564 );
1565 }
1566
1567 #[cfg(test)]
1593 mod wrap_boundary_property {
1594 use super::apply_wrapping_transform;
1595 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1596 use proptest::prelude::*;
1597 use unicode_segmentation::UnicodeSegmentation;
1598
1599 const MAX_LOOKBACK: usize = 16;
1603
1604 fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
1605 let mut tokens: Vec<ViewTokenWire> = Vec::new();
1606 let mut buf = String::new();
1607 let mut buf_start = 0usize;
1608 for (i, c) in input.char_indices() {
1609 if c == ' ' {
1610 if !buf.is_empty() {
1611 tokens.push(ViewTokenWire {
1612 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1613 source_offset: Some(buf_start),
1614 style: None,
1615 });
1616 }
1617 tokens.push(ViewTokenWire {
1618 kind: ViewTokenWireKind::Space,
1619 source_offset: Some(i),
1620 style: None,
1621 });
1622 buf_start = i + 1;
1623 } else {
1624 if buf.is_empty() {
1625 buf_start = i;
1626 }
1627 buf.push(c);
1628 }
1629 }
1630 if !buf.is_empty() {
1631 tokens.push(ViewTokenWire {
1632 kind: ViewTokenWireKind::Text(buf.clone()),
1633 source_offset: Some(buf_start),
1634 style: None,
1635 });
1636 }
1637 tokens.push(ViewTokenWire {
1638 kind: ViewTokenWireKind::Newline,
1639 source_offset: Some(input.len()),
1640 style: None,
1641 });
1642 tokens
1643 }
1644
1645 fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
1650 let mut rows: Vec<String> = vec![String::new()];
1651 for t in wrapped {
1652 match &t.kind {
1653 ViewTokenWireKind::Text(s) => {
1654 rows.last_mut().unwrap().push_str(s);
1655 }
1656 ViewTokenWireKind::Space => {
1657 rows.last_mut().unwrap().push(' ');
1658 }
1659 ViewTokenWireKind::Break => {
1660 rows.push(String::new());
1661 }
1662 ViewTokenWireKind::Newline => {
1663 }
1666 _ => {}
1667 }
1668 }
1669 rows
1670 }
1671
1672 proptest! {
1673 #![proptest_config(ProptestConfig {
1677 cases: 256,
1678 .. ProptestConfig::default()
1679 })]
1680
1681 #[test]
1684 fn prop_wrap_respects_boundaries(
1685 input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
1686 content_width in 5usize..40,
1687 ) {
1688 let tokens = tokens_from_input(&input);
1691 let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
1692 let rows = visual_rows(&wrapped);
1693
1694 for (i, row) in rows.iter().enumerate() {
1696 prop_assert!(
1697 row.chars().count() <= content_width,
1698 "row {i} {:?} has width {} > content_width {content_width}",
1699 row,
1700 row.chars().count(),
1701 );
1702 }
1703
1704 let reconstructed: String = rows.concat();
1706 prop_assert_eq!(
1707 &reconstructed,
1708 &input,
1709 "reconstruction differs from input"
1710 );
1711
1712 let boundaries: std::collections::BTreeSet<usize> = input
1716 .split_word_bound_indices()
1717 .map(|(i, _)| i)
1718 .chain(std::iter::once(input.len()))
1719 .collect();
1720
1721 let mut cursor_bytes = 0usize;
1722 let mut cursor_chars = 0usize;
1723 for (i, row) in rows.iter().enumerate() {
1724 let row_bytes = row.len();
1725 let row_chars = row.chars().count();
1726 let row_end_bytes = cursor_bytes + row_bytes;
1727 let row_end_chars = cursor_chars + row_chars;
1728 let is_last = i + 1 == rows.len();
1729
1730 if !is_last {
1731 let input_bytes = input.as_bytes();
1737 let prev_is_space =
1738 row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
1739 let next_is_space = row_end_bytes < input_bytes.len()
1740 && input_bytes[row_end_bytes] == b' ';
1741 let is_mid_text = !prev_is_space && !next_is_space;
1742 if !is_mid_text {
1743 cursor_bytes = row_end_bytes;
1744 cursor_chars = row_end_chars;
1745 continue;
1746 }
1747
1748 let hard_cap_chars = cursor_chars + content_width;
1751 let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
1752 let floor_chars = cursor_chars
1753 + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
1754 let floor_bytes = char_index_to_byte(&input, floor_chars);
1755
1756 let max_in_window = boundaries
1764 .range(floor_bytes..=hard_cap_bytes)
1765 .next_back()
1766 .copied();
1767 match max_in_window {
1768 Some(max_b) => {
1769 prop_assert_eq!(
1770 row_end_bytes,
1771 max_b,
1772 "split at byte {} but largest word boundary in \
1773 [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
1774 row_end_bytes,
1775 floor_bytes,
1776 hard_cap_bytes,
1777 max_b,
1778 row,
1779 input,
1780 );
1781 }
1782 None => {
1783 prop_assert_eq!(
1784 row_end_bytes,
1785 hard_cap_bytes,
1786 "no word boundary in [floor={}, hard_cap={}], so \
1787 char-split must land at hard_cap, but split is at \
1788 byte {}; row={:?}, input={:?}",
1789 floor_bytes,
1790 hard_cap_bytes,
1791 row_end_bytes,
1792 row,
1793 input,
1794 );
1795 }
1796 }
1797 }
1798
1799 cursor_bytes = row_end_bytes;
1800 cursor_chars = row_end_chars;
1801 }
1802 }
1803 }
1804
1805 fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1808 s.char_indices()
1809 .nth(char_idx)
1810 .map(|(b, _)| b)
1811 .unwrap_or(s.len())
1812 }
1813 }
1814
1815 #[test]
1817 fn test_apply_wrapping_transform_preserves_short_lines() {
1818 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1819
1820 let short_text = "x".repeat(100);
1822 let tokens = vec![
1823 ViewTokenWire {
1824 kind: ViewTokenWireKind::Text(short_text.clone()),
1825 source_offset: Some(0),
1826 style: None,
1827 },
1828 ViewTokenWire {
1829 kind: ViewTokenWireKind::Newline,
1830 source_offset: Some(100),
1831 style: None,
1832 },
1833 ];
1834
1835 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1837
1838 let break_count = wrapped
1840 .iter()
1841 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1842 .count();
1843
1844 assert_eq!(
1845 break_count, 0,
1846 "Short lines should not have any breaks, got {}",
1847 break_count
1848 );
1849
1850 let text_tokens: Vec<_> = wrapped
1852 .iter()
1853 .filter_map(|t| match &t.kind {
1854 ViewTokenWireKind::Text(s) => Some(s.clone()),
1855 _ => None,
1856 })
1857 .collect();
1858
1859 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
1860 assert_eq!(
1861 text_tokens[0], short_text,
1862 "Text content should be unchanged"
1863 );
1864 }
1865
1866 #[test]
1869 fn test_large_single_line_sequential_data_preserved() {
1870 use crate::view::ui::view_pipeline::ViewLineIterator;
1871 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1872
1873 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
1877
1878 let tokens = vec![
1880 ViewTokenWire {
1881 kind: ViewTokenWireKind::Text(content.clone()),
1882 source_offset: Some(0),
1883 style: None,
1884 },
1885 ViewTokenWire {
1886 kind: ViewTokenWireKind::Newline,
1887 source_offset: Some(content.len()),
1888 style: None,
1889 },
1890 ];
1891
1892 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1894
1895 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
1897
1898 let mut reconstructed = String::new();
1900 for line in &view_lines {
1901 let text = line.text.trim_end_matches('\n');
1903 reconstructed.push_str(text);
1904 }
1905
1906 assert_eq!(
1908 reconstructed.len(),
1909 content.len(),
1910 "Reconstructed content length should match original"
1911 );
1912
1913 for i in 1..=num_markers {
1915 let marker = format!("[{:05}]", i);
1916 assert!(
1917 reconstructed.contains(&marker),
1918 "Missing marker {} after pipeline",
1919 marker
1920 );
1921 }
1922
1923 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
1925 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
1926 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
1927 assert!(
1928 pos_100 < pos_1000 && pos_1000 < pos_3000,
1929 "Markers should be in sequential order: {} < {} < {}",
1930 pos_100,
1931 pos_1000,
1932 pos_3000
1933 );
1934
1935 assert!(
1937 view_lines.len() >= 3,
1938 "35KB content should produce multiple visual lines at 10K width, got {}",
1939 view_lines.len()
1940 );
1941
1942 for (i, line) in view_lines.iter().enumerate() {
1944 assert!(
1945 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
1947 i,
1948 line.text.len()
1949 );
1950 }
1951 }
1952
1953 fn strip_osc8(s: &str) -> String {
1955 let mut result = String::with_capacity(s.len());
1956 let bytes = s.as_bytes();
1957 let mut i = 0;
1958 while i < bytes.len() {
1959 if i + 3 < bytes.len()
1960 && bytes[i] == 0x1b
1961 && bytes[i + 1] == b']'
1962 && bytes[i + 2] == b'8'
1963 && bytes[i + 3] == b';'
1964 {
1965 i += 4;
1966 while i < bytes.len() && bytes[i] != 0x07 {
1967 i += 1;
1968 }
1969 if i < bytes.len() {
1970 i += 1;
1971 }
1972 } else {
1973 result.push(bytes[i] as char);
1974 i += 1;
1975 }
1976 }
1977 result
1978 }
1979
1980 fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
1983 let width = buf.area().width;
1984 let mut s = String::new();
1985 let mut col = 0u16;
1986 while col < width {
1987 let cell = &buf[(col, y)];
1988 let stripped = strip_osc8(cell.symbol());
1989 let chars = stripped.chars().count();
1990 if chars > 1 {
1991 s.push_str(&stripped);
1992 col += chars as u16;
1993 } else {
1994 s.push_str(&stripped);
1995 col += 1;
1996 }
1997 }
1998 s.trim_end().to_string()
1999 }
2000
2001 #[test]
2002 fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
2003 use ratatui::buffer::Buffer;
2004 use ratatui::layout::Rect;
2005
2006 let text = "[Quick Install](#installation)";
2008 let area = Rect::new(0, 0, 40, 1);
2009 let mut buf = Buffer::empty(area);
2010 for (i, ch) in text.chars().enumerate() {
2011 if (i as u16) < 40 {
2012 buf[(i as u16, 0)].set_symbol(&ch.to_string());
2013 }
2014 }
2015
2016 let url = "https://example.com";
2018
2019 apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
2021
2022 let row = read_row(&buf, 0);
2023 assert_eq!(
2024 row, text,
2025 "After OSC 8 application, reading the row should reproduce the original text"
2026 );
2027
2028 let cell14 = strip_osc8(buf[(14, 0)].symbol());
2030 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
2031
2032 let cell0 = strip_osc8(buf[(0, 0)].symbol());
2034 assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
2035 }
2036
2037 #[test]
2038 fn test_apply_osc8_stable_across_reapply() {
2039 use ratatui::buffer::Buffer;
2040 use ratatui::layout::Rect;
2041
2042 let text = "[Quick Install](#installation)";
2043 let area = Rect::new(0, 0, 40, 1);
2044
2045 let mut buf1 = Buffer::empty(area);
2047 for (i, ch) in text.chars().enumerate() {
2048 if (i as u16) < 40 {
2049 buf1[(i as u16, 0)].set_symbol(&ch.to_string());
2050 }
2051 }
2052 apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
2053 let row1 = read_row(&buf1, 0);
2054
2055 let mut buf2 = Buffer::empty(area);
2057 for (i, ch) in text.chars().enumerate() {
2058 if (i as u16) < 40 {
2059 buf2[(i as u16, 0)].set_symbol(&ch.to_string());
2060 }
2061 }
2062 apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
2063 let row2 = read_row(&buf2, 0);
2064
2065 assert_eq!(row1, text);
2066 assert_eq!(row2, text);
2067 }
2068
2069 #[test]
2070 #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
2071 fn test_apply_osc8_diff_between_renders() {
2072 use ratatui::buffer::Buffer;
2073 use ratatui::layout::Rect;
2074
2075 let area = Rect::new(0, 0, 40, 1);
2078
2079 let concealed = "Quick Install";
2081 let mut frame1 = Buffer::empty(area);
2082 for (i, ch) in concealed.chars().enumerate() {
2083 frame1[(i as u16, 0)].set_symbol(&ch.to_string());
2084 }
2085 apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2087
2088 let prev = Buffer::empty(area);
2090 let mut backend = Buffer::empty(area);
2091 let diff1 = prev.diff(&frame1);
2092 for (x, y, cell) in &diff1 {
2093 backend[(*x, *y)] = (*cell).clone();
2094 }
2095
2096 let full = "[Quick Install](#installation)";
2098 let mut frame2 = Buffer::empty(area);
2099 for (i, ch) in full.chars().enumerate() {
2100 if (i as u16) < 40 {
2101 frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2102 }
2103 }
2104 apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2106
2107 let diff2 = frame1.diff(&frame2);
2109 for (x, y, cell) in &diff2 {
2110 backend[(*x, *y)] = (*cell).clone();
2111 }
2112
2113 let row = read_row(&backend, 0);
2115 assert_eq!(
2116 row, full,
2117 "After diff-based update from concealed to unconcealed, \
2118 backend should show full text"
2119 );
2120
2121 let cell14 = strip_osc8(backend[(14, 0)].symbol());
2123 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
2124 }
2125
2126 fn render_with_highlight_option(
2129 content: &str,
2130 cursor_pos: usize,
2131 highlight_current_line: bool,
2132 ) -> LineRenderOutput {
2133 let mut state = EditorState::new(20, 6, 1024, test_fs());
2134 state.buffer = Buffer::from_str(content, 1024, test_fs());
2135 let mut cursors = crate::model::cursor::Cursors::new();
2136 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
2137 let viewport = Viewport::new(20, 4);
2138 state.margins.left_config.enabled = false;
2139
2140 let render_area = Rect::new(0, 0, 20, 4);
2141 let visible_count = viewport.visible_line_count();
2142 let gutter_width = state.margins.left_total_width();
2143 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
2144 let empty_folds = FoldManager::new();
2145
2146 let view_data = build_view_data(
2147 &mut state,
2148 &viewport,
2149 None,
2150 content.len().max(1),
2151 visible_count,
2152 false,
2153 render_area.width as usize,
2154 gutter_width,
2155 &ViewMode::Source,
2156 &empty_folds,
2157 &theme,
2158 );
2159 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
2160
2161 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
2162 state.margins.update_width_for_buffer(estimated_lines, true);
2163 let gutter_width = state.margins.left_total_width();
2164
2165 let selection = selection_context(&state, &cursors);
2166 let _ = state
2167 .buffer
2168 .populate_line_cache(viewport.top_byte, visible_count);
2169 let viewport_start = viewport.top_byte;
2170 let viewport_end = calculate_viewport_end(
2171 &mut state,
2172 viewport_start,
2173 content.len().max(1),
2174 visible_count,
2175 );
2176 let decorations = decoration_context(
2177 &mut state,
2178 viewport_start,
2179 viewport_end,
2180 selection.primary_cursor_position,
2181 &empty_folds,
2182 &theme,
2183 100_000,
2184 &ViewMode::Source,
2185 false,
2186 &[],
2187 );
2188
2189 render_view_lines(LineRenderInput {
2190 state: &state,
2191 theme: &theme,
2192 view_lines: &view_data.lines,
2193 view_anchor,
2194 render_area,
2195 gutter_width,
2196 selection: &selection,
2197 decorations: &decorations,
2198 visible_line_count: visible_count,
2199 lsp_waiting: false,
2200 is_active: true,
2201 line_wrap: viewport.line_wrap_enabled,
2202 estimated_lines,
2203 left_column: viewport.left_column,
2204 relative_line_numbers: false,
2205 session_mode: false,
2206 software_cursor_only: false,
2207 show_line_numbers: false,
2208 byte_offset_mode: false,
2209 show_tilde: true,
2210 highlight_current_line,
2211 cell_theme_map: &mut Vec::new(),
2212 screen_width: 0,
2213 })
2214 }
2215
2216 fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
2218 let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
2219 if let Some(line) = output.lines.get(line_idx) {
2220 line.spans
2221 .iter()
2222 .any(|span| span.style.bg == Some(current_line_bg))
2223 } else {
2224 false
2225 }
2226 }
2227
2228 #[test]
2229 fn current_line_highlight_enabled_highlights_cursor_line() {
2230 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
2231 assert!(
2233 line_has_current_line_bg(&output, 0),
2234 "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
2235 );
2236 assert!(
2238 !line_has_current_line_bg(&output, 1),
2239 "Non-cursor line (line 1) should NOT have current_line_bg"
2240 );
2241 }
2242
2243 #[test]
2244 fn current_line_highlight_disabled_no_highlight() {
2245 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
2246 assert!(
2248 !line_has_current_line_bg(&output, 0),
2249 "Cursor line should NOT have current_line_bg when highlighting is disabled"
2250 );
2251 assert!(
2252 !line_has_current_line_bg(&output, 1),
2253 "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
2254 );
2255 }
2256
2257 #[test]
2258 fn current_line_highlight_follows_cursor_position() {
2259 let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
2261 assert!(
2262 !line_has_current_line_bg(&output, 0),
2263 "Line 0 should NOT have current_line_bg when cursor is on line 1"
2264 );
2265 assert!(
2266 line_has_current_line_bg(&output, 1),
2267 "Line 1 should have current_line_bg when cursor is there"
2268 );
2269 assert!(
2270 !line_has_current_line_bg(&output, 2),
2271 "Line 2 should NOT have current_line_bg when cursor is on line 1"
2272 );
2273 }
2274
2275 #[test]
2282 fn wrap_str_to_width_matches_apply_wrapping_transform() {
2283 use crate::primitives::visual_layout::wrap_str_to_width;
2284 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2285
2286 let cases: &[(&str, usize)] = &[
2290 ("hello world how are you today friend", 12),
2291 ("the quick brown fox jumps over the lazy dog", 18),
2292 ("https://example.com/very-long-path/file", 24),
2293 (&"x".repeat(120), 32),
2294 (&"abc ".repeat(40), 25),
2295 ("dialog.getButton(...).setOnClickListener", 24),
2296 ];
2297
2298 for &(text, wrap_width) in cases {
2299 let helper_chunks = wrap_str_to_width(text, wrap_width);
2301 let helper_strings: Vec<&str> =
2302 helper_chunks.iter().map(|r| &text[r.clone()]).collect();
2303
2304 let tokens = vec![ViewTokenWire {
2309 kind: ViewTokenWireKind::Text(text.to_string()),
2310 source_offset: Some(0),
2311 style: None,
2312 }];
2313 let wrapped = apply_wrapping_transform(tokens, wrap_width, 0, false);
2314
2315 let mut transform_strings: Vec<String> = Vec::new();
2320 for tok in &wrapped {
2321 match &tok.kind {
2322 ViewTokenWireKind::Text(t) => transform_strings.push(t.clone()),
2323 ViewTokenWireKind::Break => {}
2324 other => panic!("unexpected token kind in agreement test: {:?}", other),
2325 }
2326 }
2327
2328 assert_eq!(
2329 transform_strings
2330 .iter()
2331 .map(String::as_str)
2332 .collect::<Vec<_>>(),
2333 helper_strings,
2334 "wrap mismatch for text={text:?} wrap_width={wrap_width}",
2335 );
2336 }
2337 }
2338}