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 virtual_gutter_glyph: None,
520 virtual_line_style: None,
521 }];
522
523 let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
524
525 assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
527 assert_eq!(
529 indicators.get(&line1_byte).map(|i| i.collapsed),
530 Some(false)
531 );
532 }
533
534 #[test]
535 fn last_line_end_tracks_trailing_newline() {
536 let output = render_output_for("abc\n", 4);
537 assert_eq!(
538 output.0.last_line_end,
539 Some(LastLineEnd {
540 pos: (3, 0),
541 terminated_with_newline: true
542 })
543 );
544 }
545
546 #[test]
547 fn last_line_end_tracks_no_trailing_newline() {
548 let output = render_output_for("abc", 3);
549 assert_eq!(
550 output.0.last_line_end,
551 Some(LastLineEnd {
552 pos: (3, 0),
553 terminated_with_newline: false
554 })
555 );
556 }
557
558 #[test]
559 fn cursor_after_newline_places_on_next_line() {
560 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
561 let cursor = resolve_cursor_fallback(
562 output.cursor,
563 cursor_pos,
564 buffer_len,
565 buffer_newline,
566 output.last_line_end,
567 output.content_lines_rendered,
568 0, );
570 assert_eq!(cursor, Some((0, 1)));
571 }
572
573 #[test]
574 fn cursor_at_end_without_newline_stays_on_line() {
575 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
576 let cursor = resolve_cursor_fallback(
577 output.cursor,
578 cursor_pos,
579 buffer_len,
580 buffer_newline,
581 output.last_line_end,
582 output.content_lines_rendered,
583 0, );
585 assert_eq!(cursor, Some((3, 0)));
586 }
587
588 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
594 let mut cursor_positions = Vec::new();
595
596 let primary_cursor = output.cursor;
598 if let Some(cursor_pos) = primary_cursor {
599 cursor_positions.push(cursor_pos);
600 }
601
602 for (line_idx, line) in output.lines.iter().enumerate() {
604 let mut col = 0u16;
605 for span in line.spans.iter() {
606 if span
608 .style
609 .add_modifier
610 .contains(ratatui::style::Modifier::REVERSED)
611 {
612 let pos = (col, line_idx as u16);
613 if primary_cursor != Some(pos) {
616 cursor_positions.push(pos);
617 }
618 }
619 col += str_width(&span.content) as u16;
621 }
622 }
623
624 cursor_positions
625 }
626
627 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
629 eprintln!("\n=== RENDER DEBUG ===");
630 eprintln!("Content: {:?}", content);
631 eprintln!("Cursor position: {}", cursor_pos);
632 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
633 eprintln!("Last line end: {:?}", output.last_line_end);
634 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
635 eprintln!("\nRendered lines:");
636 for (line_idx, line) in output.lines.iter().enumerate() {
637 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
638 for (span_idx, span) in line.spans.iter().enumerate() {
639 let has_reversed = span
640 .style
641 .add_modifier
642 .contains(ratatui::style::Modifier::REVERSED);
643 let bg_color = format!("{:?}", span.style.bg);
644 eprintln!(
645 " Span {}: {:?} (REVERSED: {}, BG: {})",
646 span_idx, span.content, has_reversed, bg_color
647 );
648 }
649 }
650 eprintln!("===================\n");
651 }
652
653 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
656 let (output, buffer_len, buffer_newline, cursor_pos) =
657 render_output_for(content, cursor_pos);
658
659 let all_cursors = count_all_cursors(&output);
661
662 assert!(
665 all_cursors.len() <= 1,
666 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
667 all_cursors.len(),
668 all_cursors
669 );
670
671 let final_cursor = resolve_cursor_fallback(
672 output.cursor,
673 cursor_pos,
674 buffer_len,
675 buffer_newline,
676 output.last_line_end,
677 output.content_lines_rendered,
678 0, );
680
681 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
683 {
684 dump_render_output(content, cursor_pos, &output);
685 }
686
687 if let Some(rendered_cursor) = all_cursors.first() {
689 assert_eq!(
690 Some(*rendered_cursor),
691 final_cursor,
692 "Rendered cursor at {:?} doesn't match final cursor {:?}",
693 rendered_cursor,
694 final_cursor
695 );
696 }
697
698 assert!(
700 final_cursor.is_some(),
701 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
702 all_cursors
703 );
704
705 final_cursor
706 }
707
708 fn check_typing_at_cursor(
710 content: &str,
711 cursor_pos: usize,
712 char_to_type: char,
713 ) -> (Option<(u16, u16)>, String) {
714 let cursor_before = get_final_cursor(content, cursor_pos);
716
717 let mut new_content = content.to_string();
719 if cursor_pos <= content.len() {
720 new_content.insert(cursor_pos, char_to_type);
721 }
722
723 (cursor_before, new_content)
724 }
725
726 #[test]
727 fn e2e_cursor_at_start_of_nonempty_line() {
728 let cursor = get_final_cursor("abc", 0);
730 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
731
732 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
733 assert_eq!(
734 new_content, "Xabc",
735 "Typing should insert at cursor position"
736 );
737 assert_eq!(cursor_pos, Some((0, 0)));
738 }
739
740 #[test]
741 fn e2e_cursor_in_middle_of_line() {
742 let cursor = get_final_cursor("abc", 1);
744 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
745
746 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
747 assert_eq!(
748 new_content, "aXbc",
749 "Typing should insert at cursor position"
750 );
751 assert_eq!(cursor_pos, Some((1, 0)));
752 }
753
754 #[test]
755 fn e2e_cursor_at_end_of_line_no_newline() {
756 let cursor = get_final_cursor("abc", 3);
758 assert_eq!(
759 cursor,
760 Some((3, 0)),
761 "Cursor should be at column 3, line 0 (after last char)"
762 );
763
764 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
765 assert_eq!(new_content, "abcX", "Typing should append at end");
766 assert_eq!(cursor_pos, Some((3, 0)));
767 }
768
769 #[test]
770 fn e2e_cursor_at_empty_line() {
771 let cursor = get_final_cursor("\n", 0);
773 assert_eq!(
774 cursor,
775 Some((0, 0)),
776 "Cursor on empty line should be at column 0"
777 );
778
779 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
780 assert_eq!(new_content, "X\n", "Typing should insert before newline");
781 assert_eq!(cursor_pos, Some((0, 0)));
782 }
783
784 #[test]
785 fn e2e_cursor_after_newline_at_eof() {
786 let cursor = get_final_cursor("abc\n", 4);
788 assert_eq!(
789 cursor,
790 Some((0, 1)),
791 "Cursor after newline at EOF should be on next line"
792 );
793
794 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
795 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
796 assert_eq!(cursor_pos, Some((0, 1)));
797 }
798
799 #[test]
800 fn e2e_cursor_on_newline_with_content() {
801 let cursor = get_final_cursor("abc\n", 3);
803 assert_eq!(
804 cursor,
805 Some((3, 0)),
806 "Cursor on newline after content should be after last char"
807 );
808
809 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
810 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
811 assert_eq!(cursor_pos, Some((3, 0)));
812 }
813
814 #[test]
815 fn e2e_cursor_multiline_start_of_second_line() {
816 let cursor = get_final_cursor("abc\ndef", 4);
818 assert_eq!(
819 cursor,
820 Some((0, 1)),
821 "Cursor at start of second line should be at column 0, line 1"
822 );
823
824 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
825 assert_eq!(
826 new_content, "abc\nXdef",
827 "Typing should insert at start of second line"
828 );
829 assert_eq!(cursor_pos, Some((0, 1)));
830 }
831
832 #[test]
833 fn e2e_cursor_multiline_end_of_first_line() {
834 let cursor = get_final_cursor("abc\ndef", 3);
836 assert_eq!(
837 cursor,
838 Some((3, 0)),
839 "Cursor on newline of first line should be after content"
840 );
841
842 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
843 assert_eq!(
844 new_content, "abcX\ndef",
845 "Typing should insert before newline"
846 );
847 assert_eq!(cursor_pos, Some((3, 0)));
848 }
849
850 #[test]
851 fn e2e_cursor_empty_buffer() {
852 let cursor = get_final_cursor("", 0);
854 assert_eq!(
855 cursor,
856 Some((0, 0)),
857 "Cursor in empty buffer should be at origin"
858 );
859
860 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
861 assert_eq!(
862 new_content, "X",
863 "Typing in empty buffer should insert character"
864 );
865 assert_eq!(cursor_pos, Some((0, 0)));
866 }
867
868 #[test]
869 fn e2e_cursor_empty_buffer_with_gutters() {
870 let (output, buffer_len, buffer_newline, cursor_pos) =
874 render_output_for_with_gutters("", 0, true);
875
876 let gutter_width = {
880 let mut state = EditorState::new(20, 6, 1024, test_fs());
881 state.margins.left_config.enabled = true;
882 state.margins.update_width_for_buffer(1, true);
883 state.margins.left_total_width()
884 };
885 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
886
887 assert_eq!(
891 output.cursor,
892 Some((gutter_width as u16, 0)),
893 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
894 gutter_width,
895 output.cursor
896 );
897
898 let final_cursor = resolve_cursor_fallback(
899 output.cursor,
900 cursor_pos,
901 buffer_len,
902 buffer_newline,
903 output.last_line_end,
904 output.content_lines_rendered,
905 gutter_width,
906 );
907
908 assert_eq!(
910 final_cursor,
911 Some((gutter_width as u16, 0)),
912 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
913 );
914 }
915
916 #[test]
917 fn e2e_cursor_between_empty_lines() {
918 let cursor = get_final_cursor("\n\n", 1);
920 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
921
922 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
923 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
924 assert_eq!(cursor_pos, Some((0, 1)));
925 }
926
927 #[test]
928 fn e2e_cursor_at_eof_after_multiple_lines() {
929 let cursor = get_final_cursor("abc\ndef\nghi", 11);
931 assert_eq!(
932 cursor,
933 Some((3, 2)),
934 "Cursor at EOF after 'i' should be at column 3, line 2"
935 );
936
937 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
938 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
939 assert_eq!(cursor_pos, Some((3, 2)));
940 }
941
942 #[test]
943 fn e2e_cursor_at_eof_with_trailing_newline() {
944 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
946 assert_eq!(
947 cursor,
948 Some((0, 3)),
949 "Cursor after trailing newline should be on line 3"
950 );
951
952 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
953 assert_eq!(
954 new_content, "abc\ndef\nghi\nX",
955 "Typing should insert on new line"
956 );
957 assert_eq!(cursor_pos, Some((0, 3)));
958 }
959
960 #[test]
961 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
962 let content = "abc\ndef\nghi";
964
965 let cursor_at_start = get_final_cursor(content, 0);
967 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
968
969 let cursor_at_eof = get_final_cursor(content, 11);
971 assert_eq!(
972 cursor_at_eof,
973 Some((3, 2)),
974 "After Ctrl+End, cursor at column 3, line 2"
975 );
976
977 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
979 assert_eq!(cursor_before_typing, Some((3, 2)));
980 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
981
982 let cursor_after_typing = get_final_cursor(&new_content, 12);
984 assert_eq!(
985 cursor_after_typing,
986 Some((4, 2)),
987 "After typing, cursor moved to column 4"
988 );
989
990 let cursor_moved_away = get_final_cursor(&new_content, 0);
992 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
993 }
996
997 #[test]
998 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
999 let content = "abc\ndef\nghi\n";
1001
1002 let cursor_at_start = get_final_cursor(content, 0);
1004 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
1005
1006 let cursor_at_eof = get_final_cursor(content, 12);
1008 assert_eq!(
1009 cursor_at_eof,
1010 Some((0, 3)),
1011 "After Ctrl+End, cursor at column 0, line 3 (new line)"
1012 );
1013
1014 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
1016 assert_eq!(cursor_before_typing, Some((0, 3)));
1017 assert_eq!(
1018 new_content, "abc\ndef\nghi\nX",
1019 "Character inserted on new line"
1020 );
1021
1022 let cursor_after_typing = get_final_cursor(&new_content, 13);
1024 assert_eq!(
1025 cursor_after_typing,
1026 Some((1, 3)),
1027 "After typing, cursor should be at column 1, line 3"
1028 );
1029
1030 let cursor_moved_away = get_final_cursor(&new_content, 4);
1032 assert_eq!(
1033 cursor_moved_away,
1034 Some((0, 1)),
1035 "Cursor moved to start of line 1 (position 4 = start of 'def')"
1036 );
1037 }
1038
1039 #[test]
1040 fn e2e_jump_to_end_of_empty_buffer() {
1041 let content = "";
1043
1044 let cursor_at_eof = get_final_cursor(content, 0);
1045 assert_eq!(
1046 cursor_at_eof,
1047 Some((0, 0)),
1048 "Empty buffer: cursor at origin"
1049 );
1050
1051 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
1053 assert_eq!(cursor_before_typing, Some((0, 0)));
1054 assert_eq!(new_content, "X", "Character inserted");
1055
1056 let cursor_after_typing = get_final_cursor(&new_content, 1);
1058 assert_eq!(
1059 cursor_after_typing,
1060 Some((1, 0)),
1061 "After typing, cursor at column 1"
1062 );
1063
1064 let cursor_moved_away = get_final_cursor(&new_content, 0);
1066 assert_eq!(
1067 cursor_moved_away,
1068 Some((0, 0)),
1069 "Cursor moved back to start"
1070 );
1071 }
1072
1073 #[test]
1074 fn e2e_jump_to_end_of_single_empty_line() {
1075 let content = "\n";
1077
1078 let cursor_on_newline = get_final_cursor(content, 0);
1080 assert_eq!(
1081 cursor_on_newline,
1082 Some((0, 0)),
1083 "Cursor on the newline character"
1084 );
1085
1086 let cursor_at_eof = get_final_cursor(content, 1);
1088 assert_eq!(
1089 cursor_at_eof,
1090 Some((0, 1)),
1091 "After Ctrl+End, cursor on line 1"
1092 );
1093
1094 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1096 assert_eq!(cursor_before_typing, Some((0, 1)));
1097 assert_eq!(new_content, "\nX", "Character on second line");
1098
1099 let cursor_after_typing = get_final_cursor(&new_content, 2);
1100 assert_eq!(
1101 cursor_after_typing,
1102 Some((1, 1)),
1103 "After typing, cursor at column 1, line 1"
1104 );
1105
1106 let cursor_moved_away = get_final_cursor(&new_content, 0);
1108 assert_eq!(
1109 cursor_moved_away,
1110 Some((0, 0)),
1111 "Cursor moved to the newline on line 0"
1112 );
1113 }
1114 use fresh_core::api::ViewTokenWireKind;
1125
1126 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1128 tokens
1129 .iter()
1130 .map(|t| {
1131 let kind_str = match &t.kind {
1132 ViewTokenWireKind::Text(s) => format!("Text({})", s),
1133 ViewTokenWireKind::Newline => "Newline".to_string(),
1134 ViewTokenWireKind::Space => "Space".to_string(),
1135 ViewTokenWireKind::Break => "Break".to_string(),
1136 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1137 };
1138 (kind_str, t.source_offset)
1139 })
1140 .collect()
1141 }
1142
1143 #[test]
1146 fn test_build_base_tokens_crlf_single_line() {
1147 let content = b"abc\r\n";
1149 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1150 buffer.set_line_ending(LineEnding::CRLF);
1151
1152 let tokens = SplitRenderer::build_base_tokens_for_hook(
1153 &mut buffer,
1154 0, 80, 10, false, LineEnding::CRLF,
1159 );
1160
1161 let offsets = extract_token_offsets(&tokens);
1162
1163 assert!(
1166 offsets
1167 .iter()
1168 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1169 "Expected Text(abc) at offset 0, got: {:?}",
1170 offsets
1171 );
1172 assert!(
1173 offsets
1174 .iter()
1175 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1176 "Expected Newline at offset 3 (\\r position), got: {:?}",
1177 offsets
1178 );
1179
1180 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1182 assert_eq!(
1183 newline_count, 1,
1184 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1185 newline_count, offsets
1186 );
1187 }
1188
1189 #[test]
1192 fn test_build_base_tokens_crlf_multiple_lines() {
1193 let content = b"abc\r\ndef\r\nghi\r\n";
1198 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1199 buffer.set_line_ending(LineEnding::CRLF);
1200
1201 let tokens = SplitRenderer::build_base_tokens_for_hook(
1202 &mut buffer,
1203 0,
1204 80,
1205 10,
1206 false,
1207 LineEnding::CRLF,
1208 );
1209
1210 let offsets = extract_token_offsets(&tokens);
1211
1212 assert!(
1219 offsets
1220 .iter()
1221 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1222 "Line 1: Expected Text(abc) at 0, got: {:?}",
1223 offsets
1224 );
1225 assert!(
1226 offsets
1227 .iter()
1228 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1229 "Line 1: Expected Newline at 3, got: {:?}",
1230 offsets
1231 );
1232
1233 assert!(
1235 offsets
1236 .iter()
1237 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1238 "Line 2: Expected Text(def) at 5, got: {:?}",
1239 offsets
1240 );
1241 assert!(
1242 offsets
1243 .iter()
1244 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1245 "Line 2: Expected Newline at 8, got: {:?}",
1246 offsets
1247 );
1248
1249 assert!(
1251 offsets
1252 .iter()
1253 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1254 "Line 3: Expected Text(ghi) at 10, got: {:?}",
1255 offsets
1256 );
1257 assert!(
1258 offsets
1259 .iter()
1260 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
1261 "Line 3: Expected Newline at 13, got: {:?}",
1262 offsets
1263 );
1264
1265 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1267 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
1268 }
1269
1270 #[test]
1273 fn test_build_base_tokens_lf_mode_for_comparison() {
1274 let content = b"abc\ndef\n";
1278 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1279 buffer.set_line_ending(LineEnding::LF);
1280
1281 let tokens = SplitRenderer::build_base_tokens_for_hook(
1282 &mut buffer,
1283 0,
1284 80,
1285 10,
1286 false,
1287 LineEnding::LF,
1288 );
1289
1290 let offsets = extract_token_offsets(&tokens);
1291
1292 assert!(
1294 offsets
1295 .iter()
1296 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1297 "LF Line 1: Expected Text(abc) at 0"
1298 );
1299 assert!(
1300 offsets
1301 .iter()
1302 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1303 "LF Line 1: Expected Newline at 3"
1304 );
1305 assert!(
1306 offsets
1307 .iter()
1308 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
1309 "LF Line 2: Expected Text(def) at 4"
1310 );
1311 assert!(
1312 offsets
1313 .iter()
1314 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
1315 "LF Line 2: Expected Newline at 7"
1316 );
1317 }
1318
1319 #[test]
1322 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
1323 let content = b"abc\r\n";
1325 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1326 buffer.set_line_ending(LineEnding::LF); let tokens = SplitRenderer::build_base_tokens_for_hook(
1329 &mut buffer,
1330 0,
1331 80,
1332 10,
1333 false,
1334 LineEnding::LF,
1335 );
1336
1337 let offsets = extract_token_offsets(&tokens);
1338
1339 assert!(
1341 offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
1342 "LF mode should render \\r as control char <0D>, got: {:?}",
1343 offsets
1344 );
1345 }
1346
1347 #[test]
1350 fn test_build_base_tokens_crlf_from_middle() {
1351 let content = b"abc\r\ndef\r\nghi\r\n";
1354 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1355 buffer.set_line_ending(LineEnding::CRLF);
1356
1357 let tokens = SplitRenderer::build_base_tokens_for_hook(
1358 &mut buffer,
1359 5, 80,
1361 10,
1362 false,
1363 LineEnding::CRLF,
1364 );
1365
1366 let offsets = extract_token_offsets(&tokens);
1367
1368 assert!(
1372 offsets
1373 .iter()
1374 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1375 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
1376 offsets
1377 );
1378 assert!(
1379 offsets
1380 .iter()
1381 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1382 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
1383 offsets
1384 );
1385 }
1386
1387 #[test]
1390 fn test_crlf_highlight_span_lookup() {
1391 use crate::view::ui::view_pipeline::ViewLineIterator;
1392
1393 let content = b"int x;\r\nint y;\r\n";
1398 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1399 buffer.set_line_ending(LineEnding::CRLF);
1400
1401 let tokens = SplitRenderer::build_base_tokens_for_hook(
1403 &mut buffer,
1404 0,
1405 80,
1406 10,
1407 false,
1408 LineEnding::CRLF,
1409 );
1410
1411 let offsets = extract_token_offsets(&tokens);
1413 eprintln!("Tokens: {:?}", offsets);
1414
1415 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1417 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
1418
1419 eprintln!(
1422 "Line 1 char_source_bytes: {:?}",
1423 view_lines[0].char_source_bytes
1424 );
1425 assert_eq!(
1426 view_lines[0].char_source_bytes.len(),
1427 7,
1428 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
1429 );
1430 assert_eq!(
1432 view_lines[0].char_source_bytes[0],
1433 Some(0),
1434 "Line 1 'i' -> byte 0"
1435 );
1436 assert_eq!(
1437 view_lines[0].char_source_bytes[4],
1438 Some(4),
1439 "Line 1 'x' -> byte 4"
1440 );
1441 assert_eq!(
1442 view_lines[0].char_source_bytes[5],
1443 Some(5),
1444 "Line 1 ';' -> byte 5"
1445 );
1446 assert_eq!(
1447 view_lines[0].char_source_bytes[6],
1448 Some(6),
1449 "Line 1 newline -> byte 6 (\\r pos)"
1450 );
1451
1452 eprintln!(
1454 "Line 2 char_source_bytes: {:?}",
1455 view_lines[1].char_source_bytes
1456 );
1457 assert_eq!(
1458 view_lines[1].char_source_bytes.len(),
1459 7,
1460 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
1461 );
1462 assert_eq!(
1464 view_lines[1].char_source_bytes[0],
1465 Some(8),
1466 "Line 2 'i' -> byte 8"
1467 );
1468 assert_eq!(
1469 view_lines[1].char_source_bytes[4],
1470 Some(12),
1471 "Line 2 'y' -> byte 12"
1472 );
1473 assert_eq!(
1474 view_lines[1].char_source_bytes[5],
1475 Some(13),
1476 "Line 2 ';' -> byte 13"
1477 );
1478 assert_eq!(
1479 view_lines[1].char_source_bytes[6],
1480 Some(14),
1481 "Line 2 newline -> byte 14 (\\r pos)"
1482 );
1483
1484 let simulated_highlight_spans = [
1488 (0usize..3usize, "keyword"),
1490 (8usize..11usize, "keyword"),
1492 ];
1493
1494 for (line_idx, view_line) in view_lines.iter().enumerate() {
1496 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
1497 if let Some(bp) = byte_pos {
1498 let in_span = simulated_highlight_spans
1499 .iter()
1500 .find(|(range, _)| range.contains(bp))
1501 .map(|(_, name)| *name);
1502
1503 let expected_in_keyword = char_idx < 3;
1505 let actually_in_keyword = in_span == Some("keyword");
1506
1507 if expected_in_keyword != actually_in_keyword {
1508 panic!(
1509 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
1510 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
1511 );
1512 }
1513 }
1514 }
1515 }
1516 }
1517
1518 #[test]
1521 fn test_apply_wrapping_transform_breaks_long_lines() {
1522 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1523
1524 let long_text = "x".repeat(25_000);
1526 let tokens = vec![
1527 ViewTokenWire {
1528 kind: ViewTokenWireKind::Text(long_text),
1529 source_offset: Some(0),
1530 style: None,
1531 },
1532 ViewTokenWire {
1533 kind: ViewTokenWireKind::Newline,
1534 source_offset: Some(25_000),
1535 style: None,
1536 },
1537 ];
1538
1539 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1541
1542 let break_count = wrapped
1544 .iter()
1545 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1546 .count();
1547
1548 assert!(
1549 break_count >= 2,
1550 "25K char line should have at least 2 breaks at 10K width, got {}",
1551 break_count
1552 );
1553
1554 let total_chars: usize = wrapped
1556 .iter()
1557 .filter_map(|t| match &t.kind {
1558 ViewTokenWireKind::Text(s) => Some(s.len()),
1559 _ => None,
1560 })
1561 .sum();
1562
1563 assert_eq!(
1564 total_chars, 25_000,
1565 "Total character count should be preserved after wrapping"
1566 );
1567 }
1568
1569 #[cfg(test)]
1595 mod wrap_boundary_property {
1596 use super::apply_wrapping_transform;
1597 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1598 use proptest::prelude::*;
1599 use unicode_segmentation::UnicodeSegmentation;
1600
1601 const MAX_LOOKBACK: usize = 16;
1605
1606 fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
1607 let mut tokens: Vec<ViewTokenWire> = Vec::new();
1608 let mut buf = String::new();
1609 let mut buf_start = 0usize;
1610 for (i, c) in input.char_indices() {
1611 if c == ' ' {
1612 if !buf.is_empty() {
1613 tokens.push(ViewTokenWire {
1614 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1615 source_offset: Some(buf_start),
1616 style: None,
1617 });
1618 }
1619 tokens.push(ViewTokenWire {
1620 kind: ViewTokenWireKind::Space,
1621 source_offset: Some(i),
1622 style: None,
1623 });
1624 buf_start = i + 1;
1625 } else {
1626 if buf.is_empty() {
1627 buf_start = i;
1628 }
1629 buf.push(c);
1630 }
1631 }
1632 if !buf.is_empty() {
1633 tokens.push(ViewTokenWire {
1634 kind: ViewTokenWireKind::Text(buf.clone()),
1635 source_offset: Some(buf_start),
1636 style: None,
1637 });
1638 }
1639 tokens.push(ViewTokenWire {
1640 kind: ViewTokenWireKind::Newline,
1641 source_offset: Some(input.len()),
1642 style: None,
1643 });
1644 tokens
1645 }
1646
1647 fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
1652 let mut rows: Vec<String> = vec![String::new()];
1653 for t in wrapped {
1654 match &t.kind {
1655 ViewTokenWireKind::Text(s) => {
1656 rows.last_mut().unwrap().push_str(s);
1657 }
1658 ViewTokenWireKind::Space => {
1659 rows.last_mut().unwrap().push(' ');
1660 }
1661 ViewTokenWireKind::Break => {
1662 rows.push(String::new());
1663 }
1664 ViewTokenWireKind::Newline => {
1665 }
1668 _ => {}
1669 }
1670 }
1671 rows
1672 }
1673
1674 proptest! {
1675 #![proptest_config(ProptestConfig {
1679 cases: 256,
1680 .. ProptestConfig::default()
1681 })]
1682
1683 #[test]
1686 fn prop_wrap_respects_boundaries(
1687 input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
1688 content_width in 5usize..40,
1689 ) {
1690 let tokens = tokens_from_input(&input);
1693 let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
1694 let rows = visual_rows(&wrapped);
1695
1696 for (i, row) in rows.iter().enumerate() {
1698 prop_assert!(
1699 row.chars().count() <= content_width,
1700 "row {i} {:?} has width {} > content_width {content_width}",
1701 row,
1702 row.chars().count(),
1703 );
1704 }
1705
1706 let reconstructed: String = rows.concat();
1708 prop_assert_eq!(
1709 &reconstructed,
1710 &input,
1711 "reconstruction differs from input"
1712 );
1713
1714 let boundaries: std::collections::BTreeSet<usize> = input
1718 .split_word_bound_indices()
1719 .map(|(i, _)| i)
1720 .chain(std::iter::once(input.len()))
1721 .collect();
1722
1723 let mut cursor_bytes = 0usize;
1724 let mut cursor_chars = 0usize;
1725 for (i, row) in rows.iter().enumerate() {
1726 let row_bytes = row.len();
1727 let row_chars = row.chars().count();
1728 let row_end_bytes = cursor_bytes + row_bytes;
1729 let row_end_chars = cursor_chars + row_chars;
1730 let is_last = i + 1 == rows.len();
1731
1732 if !is_last {
1733 let input_bytes = input.as_bytes();
1739 let prev_is_space =
1740 row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
1741 let next_is_space = row_end_bytes < input_bytes.len()
1742 && input_bytes[row_end_bytes] == b' ';
1743 let is_mid_text = !prev_is_space && !next_is_space;
1744 if !is_mid_text {
1745 cursor_bytes = row_end_bytes;
1746 cursor_chars = row_end_chars;
1747 continue;
1748 }
1749
1750 let hard_cap_chars = cursor_chars + content_width;
1753 let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
1754 let floor_chars = cursor_chars
1755 + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
1756 let floor_bytes = char_index_to_byte(&input, floor_chars);
1757
1758 let max_in_window = boundaries
1766 .range(floor_bytes..=hard_cap_bytes)
1767 .next_back()
1768 .copied();
1769 match max_in_window {
1770 Some(max_b) => {
1771 prop_assert_eq!(
1772 row_end_bytes,
1773 max_b,
1774 "split at byte {} but largest word boundary in \
1775 [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
1776 row_end_bytes,
1777 floor_bytes,
1778 hard_cap_bytes,
1779 max_b,
1780 row,
1781 input,
1782 );
1783 }
1784 None => {
1785 prop_assert_eq!(
1786 row_end_bytes,
1787 hard_cap_bytes,
1788 "no word boundary in [floor={}, hard_cap={}], so \
1789 char-split must land at hard_cap, but split is at \
1790 byte {}; row={:?}, input={:?}",
1791 floor_bytes,
1792 hard_cap_bytes,
1793 row_end_bytes,
1794 row,
1795 input,
1796 );
1797 }
1798 }
1799 }
1800
1801 cursor_bytes = row_end_bytes;
1802 cursor_chars = row_end_chars;
1803 }
1804 }
1805 }
1806
1807 fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1810 s.char_indices()
1811 .nth(char_idx)
1812 .map(|(b, _)| b)
1813 .unwrap_or(s.len())
1814 }
1815 }
1816
1817 #[test]
1819 fn test_apply_wrapping_transform_preserves_short_lines() {
1820 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1821
1822 let short_text = "x".repeat(100);
1824 let tokens = vec![
1825 ViewTokenWire {
1826 kind: ViewTokenWireKind::Text(short_text.clone()),
1827 source_offset: Some(0),
1828 style: None,
1829 },
1830 ViewTokenWire {
1831 kind: ViewTokenWireKind::Newline,
1832 source_offset: Some(100),
1833 style: None,
1834 },
1835 ];
1836
1837 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1839
1840 let break_count = wrapped
1842 .iter()
1843 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1844 .count();
1845
1846 assert_eq!(
1847 break_count, 0,
1848 "Short lines should not have any breaks, got {}",
1849 break_count
1850 );
1851
1852 let text_tokens: Vec<_> = wrapped
1854 .iter()
1855 .filter_map(|t| match &t.kind {
1856 ViewTokenWireKind::Text(s) => Some(s.clone()),
1857 _ => None,
1858 })
1859 .collect();
1860
1861 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
1862 assert_eq!(
1863 text_tokens[0], short_text,
1864 "Text content should be unchanged"
1865 );
1866 }
1867
1868 #[test]
1871 fn test_large_single_line_sequential_data_preserved() {
1872 use crate::view::ui::view_pipeline::ViewLineIterator;
1873 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1874
1875 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
1879
1880 let tokens = vec![
1882 ViewTokenWire {
1883 kind: ViewTokenWireKind::Text(content.clone()),
1884 source_offset: Some(0),
1885 style: None,
1886 },
1887 ViewTokenWire {
1888 kind: ViewTokenWireKind::Newline,
1889 source_offset: Some(content.len()),
1890 style: None,
1891 },
1892 ];
1893
1894 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1896
1897 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
1899
1900 let mut reconstructed = String::new();
1902 for line in &view_lines {
1903 let text = line.text.trim_end_matches('\n');
1905 reconstructed.push_str(text);
1906 }
1907
1908 assert_eq!(
1910 reconstructed.len(),
1911 content.len(),
1912 "Reconstructed content length should match original"
1913 );
1914
1915 for i in 1..=num_markers {
1917 let marker = format!("[{:05}]", i);
1918 assert!(
1919 reconstructed.contains(&marker),
1920 "Missing marker {} after pipeline",
1921 marker
1922 );
1923 }
1924
1925 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
1927 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
1928 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
1929 assert!(
1930 pos_100 < pos_1000 && pos_1000 < pos_3000,
1931 "Markers should be in sequential order: {} < {} < {}",
1932 pos_100,
1933 pos_1000,
1934 pos_3000
1935 );
1936
1937 assert!(
1939 view_lines.len() >= 3,
1940 "35KB content should produce multiple visual lines at 10K width, got {}",
1941 view_lines.len()
1942 );
1943
1944 for (i, line) in view_lines.iter().enumerate() {
1946 assert!(
1947 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
1949 i,
1950 line.text.len()
1951 );
1952 }
1953 }
1954
1955 fn strip_osc8(s: &str) -> String {
1957 let mut result = String::with_capacity(s.len());
1958 let bytes = s.as_bytes();
1959 let mut i = 0;
1960 while i < bytes.len() {
1961 if i + 3 < bytes.len()
1962 && bytes[i] == 0x1b
1963 && bytes[i + 1] == b']'
1964 && bytes[i + 2] == b'8'
1965 && bytes[i + 3] == b';'
1966 {
1967 i += 4;
1968 while i < bytes.len() && bytes[i] != 0x07 {
1969 i += 1;
1970 }
1971 if i < bytes.len() {
1972 i += 1;
1973 }
1974 } else {
1975 result.push(bytes[i] as char);
1976 i += 1;
1977 }
1978 }
1979 result
1980 }
1981
1982 fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
1985 let width = buf.area().width;
1986 let mut s = String::new();
1987 let mut col = 0u16;
1988 while col < width {
1989 let cell = &buf[(col, y)];
1990 let stripped = strip_osc8(cell.symbol());
1991 let chars = stripped.chars().count();
1992 if chars > 1 {
1993 s.push_str(&stripped);
1994 col += chars as u16;
1995 } else {
1996 s.push_str(&stripped);
1997 col += 1;
1998 }
1999 }
2000 s.trim_end().to_string()
2001 }
2002
2003 #[test]
2004 fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
2005 use ratatui::buffer::Buffer;
2006 use ratatui::layout::Rect;
2007
2008 let text = "[Quick Install](#installation)";
2010 let area = Rect::new(0, 0, 40, 1);
2011 let mut buf = Buffer::empty(area);
2012 for (i, ch) in text.chars().enumerate() {
2013 if (i as u16) < 40 {
2014 buf[(i as u16, 0)].set_symbol(&ch.to_string());
2015 }
2016 }
2017
2018 let url = "https://example.com";
2020
2021 apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
2023
2024 let row = read_row(&buf, 0);
2025 assert_eq!(
2026 row, text,
2027 "After OSC 8 application, reading the row should reproduce the original text"
2028 );
2029
2030 let cell14 = strip_osc8(buf[(14, 0)].symbol());
2032 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
2033
2034 let cell0 = strip_osc8(buf[(0, 0)].symbol());
2036 assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
2037 }
2038
2039 #[test]
2040 fn test_apply_osc8_stable_across_reapply() {
2041 use ratatui::buffer::Buffer;
2042 use ratatui::layout::Rect;
2043
2044 let text = "[Quick Install](#installation)";
2045 let area = Rect::new(0, 0, 40, 1);
2046
2047 let mut buf1 = Buffer::empty(area);
2049 for (i, ch) in text.chars().enumerate() {
2050 if (i as u16) < 40 {
2051 buf1[(i as u16, 0)].set_symbol(&ch.to_string());
2052 }
2053 }
2054 apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
2055 let row1 = read_row(&buf1, 0);
2056
2057 let mut buf2 = Buffer::empty(area);
2059 for (i, ch) in text.chars().enumerate() {
2060 if (i as u16) < 40 {
2061 buf2[(i as u16, 0)].set_symbol(&ch.to_string());
2062 }
2063 }
2064 apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
2065 let row2 = read_row(&buf2, 0);
2066
2067 assert_eq!(row1, text);
2068 assert_eq!(row2, text);
2069 }
2070
2071 #[test]
2072 #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
2073 fn test_apply_osc8_diff_between_renders() {
2074 use ratatui::buffer::Buffer;
2075 use ratatui::layout::Rect;
2076
2077 let area = Rect::new(0, 0, 40, 1);
2080
2081 let concealed = "Quick Install";
2083 let mut frame1 = Buffer::empty(area);
2084 for (i, ch) in concealed.chars().enumerate() {
2085 frame1[(i as u16, 0)].set_symbol(&ch.to_string());
2086 }
2087 apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2089
2090 let prev = Buffer::empty(area);
2092 let mut backend = Buffer::empty(area);
2093 let diff1 = prev.diff(&frame1);
2094 for (x, y, cell) in &diff1 {
2095 backend[(*x, *y)] = (*cell).clone();
2096 }
2097
2098 let full = "[Quick Install](#installation)";
2100 let mut frame2 = Buffer::empty(area);
2101 for (i, ch) in full.chars().enumerate() {
2102 if (i as u16) < 40 {
2103 frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2104 }
2105 }
2106 apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2108
2109 let diff2 = frame1.diff(&frame2);
2111 for (x, y, cell) in &diff2 {
2112 backend[(*x, *y)] = (*cell).clone();
2113 }
2114
2115 let row = read_row(&backend, 0);
2117 assert_eq!(
2118 row, full,
2119 "After diff-based update from concealed to unconcealed, \
2120 backend should show full text"
2121 );
2122
2123 let cell14 = strip_osc8(backend[(14, 0)].symbol());
2125 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
2126 }
2127
2128 fn render_with_highlight_option(
2131 content: &str,
2132 cursor_pos: usize,
2133 highlight_current_line: bool,
2134 ) -> LineRenderOutput {
2135 let mut state = EditorState::new(20, 6, 1024, test_fs());
2136 state.buffer = Buffer::from_str(content, 1024, test_fs());
2137 let mut cursors = crate::model::cursor::Cursors::new();
2138 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
2139 let viewport = Viewport::new(20, 4);
2140 state.margins.left_config.enabled = false;
2141
2142 let render_area = Rect::new(0, 0, 20, 4);
2143 let visible_count = viewport.visible_line_count();
2144 let gutter_width = state.margins.left_total_width();
2145 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
2146 let empty_folds = FoldManager::new();
2147
2148 let view_data = build_view_data(
2149 &mut state,
2150 &viewport,
2151 None,
2152 content.len().max(1),
2153 visible_count,
2154 false,
2155 render_area.width as usize,
2156 gutter_width,
2157 &ViewMode::Source,
2158 &empty_folds,
2159 &theme,
2160 );
2161 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
2162
2163 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
2164 state.margins.update_width_for_buffer(estimated_lines, true);
2165 let gutter_width = state.margins.left_total_width();
2166
2167 let selection = selection_context(&state, &cursors);
2168 let _ = state
2169 .buffer
2170 .populate_line_cache(viewport.top_byte, visible_count);
2171 let viewport_start = viewport.top_byte;
2172 let viewport_end = calculate_viewport_end(
2173 &mut state,
2174 viewport_start,
2175 content.len().max(1),
2176 visible_count,
2177 );
2178 let decorations = decoration_context(
2179 &mut state,
2180 viewport_start,
2181 viewport_end,
2182 selection.primary_cursor_position,
2183 &empty_folds,
2184 &theme,
2185 100_000,
2186 &ViewMode::Source,
2187 false,
2188 &[],
2189 );
2190
2191 render_view_lines(LineRenderInput {
2192 state: &state,
2193 theme: &theme,
2194 view_lines: &view_data.lines,
2195 view_anchor,
2196 render_area,
2197 gutter_width,
2198 selection: &selection,
2199 decorations: &decorations,
2200 visible_line_count: visible_count,
2201 lsp_waiting: false,
2202 is_active: true,
2203 line_wrap: viewport.line_wrap_enabled,
2204 estimated_lines,
2205 left_column: viewport.left_column,
2206 relative_line_numbers: false,
2207 session_mode: false,
2208 software_cursor_only: false,
2209 show_line_numbers: false,
2210 byte_offset_mode: false,
2211 show_tilde: true,
2212 highlight_current_line,
2213 cell_theme_map: &mut Vec::new(),
2214 screen_width: 0,
2215 })
2216 }
2217
2218 fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
2220 let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
2221 if let Some(line) = output.lines.get(line_idx) {
2222 line.spans
2223 .iter()
2224 .any(|span| span.style.bg == Some(current_line_bg))
2225 } else {
2226 false
2227 }
2228 }
2229
2230 #[test]
2231 fn current_line_highlight_enabled_highlights_cursor_line() {
2232 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
2233 assert!(
2235 line_has_current_line_bg(&output, 0),
2236 "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
2237 );
2238 assert!(
2240 !line_has_current_line_bg(&output, 1),
2241 "Non-cursor line (line 1) should NOT have current_line_bg"
2242 );
2243 }
2244
2245 #[test]
2246 fn current_line_highlight_disabled_no_highlight() {
2247 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
2248 assert!(
2250 !line_has_current_line_bg(&output, 0),
2251 "Cursor line should NOT have current_line_bg when highlighting is disabled"
2252 );
2253 assert!(
2254 !line_has_current_line_bg(&output, 1),
2255 "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
2256 );
2257 }
2258
2259 #[test]
2260 fn current_line_highlight_follows_cursor_position() {
2261 let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
2263 assert!(
2264 !line_has_current_line_bg(&output, 0),
2265 "Line 0 should NOT have current_line_bg when cursor is on line 1"
2266 );
2267 assert!(
2268 line_has_current_line_bg(&output, 1),
2269 "Line 1 should have current_line_bg when cursor is there"
2270 );
2271 assert!(
2272 !line_has_current_line_bg(&output, 2),
2273 "Line 2 should NOT have current_line_bg when cursor is on line 1"
2274 );
2275 }
2276
2277 #[test]
2284 fn wrap_str_to_width_matches_apply_wrapping_transform() {
2285 use crate::primitives::visual_layout::wrap_str_to_width;
2286 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2287
2288 let cases: &[(&str, usize)] = &[
2292 ("hello world how are you today friend", 12),
2293 ("the quick brown fox jumps over the lazy dog", 18),
2294 ("https://example.com/very-long-path/file", 24),
2295 (&"x".repeat(120), 32),
2296 (&"abc ".repeat(40), 25),
2297 ("dialog.getButton(...).setOnClickListener", 24),
2298 ];
2299
2300 for &(text, wrap_width) in cases {
2301 let helper_chunks = wrap_str_to_width(text, wrap_width);
2303 let helper_strings: Vec<&str> =
2304 helper_chunks.iter().map(|r| &text[r.clone()]).collect();
2305
2306 let tokens = vec![ViewTokenWire {
2311 kind: ViewTokenWireKind::Text(text.to_string()),
2312 source_offset: Some(0),
2313 style: None,
2314 }];
2315 let wrapped = apply_wrapping_transform(tokens, wrap_width, 0, false);
2316
2317 let mut transform_strings: Vec<String> = Vec::new();
2322 for tok in &wrapped {
2323 match &tok.kind {
2324 ViewTokenWireKind::Text(t) => transform_strings.push(t.clone()),
2325 ViewTokenWireKind::Break => {}
2326 other => panic!("unexpected token kind in agreement test: {:?}", other),
2327 }
2328 }
2329
2330 assert_eq!(
2331 transform_strings
2332 .iter()
2333 .map(String::as_str)
2334 .collect::<Vec<_>>(),
2335 helper_strings,
2336 "wrap mismatch for text={text:?} wrap_width={wrap_width}",
2337 );
2338 }
2339 }
2340}