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 pub fn build_base_tokens_for_hook(
195 buffer: &mut Buffer,
196 top_byte: usize,
197 estimated_line_length: usize,
198 visible_count: usize,
199 is_binary: bool,
200 line_ending: crate::model::buffer::LineEnding,
201 ) -> Vec<fresh_core::api::ViewTokenWire> {
202 orchestration::build_base_tokens_for_hook(
203 buffer,
204 top_byte,
205 estimated_line_length,
206 visible_count,
207 is_binary,
208 line_ending,
209 )
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::folding::fold_indicators_for_viewport;
216 use super::layout::{calculate_view_anchor, calculate_viewport_end};
217 use super::orchestration::overlays::{decoration_context, selection_context};
218 use super::orchestration::render_buffer::resolve_cursor_fallback;
219 use super::orchestration::render_line::{
220 render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
221 };
222 use super::post_pass::apply_osc8_to_cells;
223 use super::transforms::apply_wrapping_transform;
224 use super::view_data::build_view_data;
225 use super::*;
226
227 use crate::model::buffer::{Buffer, LineEnding};
228 use crate::model::filesystem::StdFileSystem;
229 use crate::primitives::display_width::str_width;
230 use crate::state::{EditorState, ViewMode};
231 use crate::view::folding::FoldManager;
232 use crate::view::theme;
233 use crate::view::theme::Theme;
234 use crate::view::ui::view_pipeline::{LineStart, ViewLine};
235 use crate::view::viewport::Viewport;
236 use fresh_core::api::ViewTokenWire;
237 use lsp_types::FoldingRange;
238 use std::collections::HashSet;
239 use std::sync::Arc;
240
241 fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
242 Arc::new(StdFileSystem)
243 }
244
245 fn render_output_for(
246 content: &str,
247 cursor_pos: usize,
248 ) -> (LineRenderOutput, usize, bool, usize) {
249 render_output_for_with_gutters(content, cursor_pos, false)
250 }
251
252 fn render_output_for_with_gutters(
253 content: &str,
254 cursor_pos: usize,
255 gutters_enabled: bool,
256 ) -> (LineRenderOutput, usize, bool, usize) {
257 let mut state = EditorState::new(20, 6, 1024, test_fs());
258 state.buffer = Buffer::from_str(content, 1024, test_fs());
259 let mut cursors = crate::model::cursor::Cursors::new();
260 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
261 let viewport = Viewport::new(20, 4);
263 state.margins.left_config.enabled = gutters_enabled;
265
266 let render_area = Rect::new(0, 0, 20, 4);
267 let visible_count = viewport.visible_line_count();
268 let gutter_width = state.margins.left_total_width();
269 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
270 let empty_folds = FoldManager::new();
271
272 let view_data = build_view_data(
273 &mut state,
274 &viewport,
275 None,
276 content.len().max(1),
277 visible_count,
278 false, render_area.width as usize,
280 gutter_width,
281 &ViewMode::Source, &empty_folds,
283 &theme,
284 );
285 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
286
287 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
288 state.margins.update_width_for_buffer(estimated_lines, true);
289 let gutter_width = state.margins.left_total_width();
290
291 let selection = selection_context(&state, &cursors);
292 let _ = state
293 .buffer
294 .populate_line_cache(viewport.top_byte, visible_count);
295 let viewport_start = viewport.top_byte;
296 let viewport_end = calculate_viewport_end(
297 &mut state,
298 viewport_start,
299 content.len().max(1),
300 visible_count,
301 );
302 let decorations = decoration_context(
303 &mut state,
304 viewport_start,
305 viewport_end,
306 selection.primary_cursor_position,
307 &empty_folds,
308 &theme,
309 100_000, &ViewMode::Source, false, &[],
313 );
314
315 let mut dummy_theme_map = Vec::new();
316 let output = render_view_lines(LineRenderInput {
317 state: &state,
318 theme: &theme,
319 view_lines: &view_data.lines,
320 view_anchor,
321 render_area,
322 gutter_width,
323 selection: &selection,
324 decorations: &decorations,
325 visible_line_count: visible_count,
326 lsp_waiting: false,
327 is_active: true,
328 line_wrap: viewport.line_wrap_enabled,
329 estimated_lines,
330 left_column: viewport.left_column,
331 relative_line_numbers: false,
332 session_mode: false,
333 software_cursor_only: false,
334 show_line_numbers: true, byte_offset_mode: false, show_tilde: true,
337 highlight_current_line: true,
338 cell_theme_map: &mut dummy_theme_map,
339 screen_width: 0,
340 });
341
342 (
343 output,
344 state.buffer.len(),
345 content.ends_with('\n'),
346 selection.primary_cursor_position,
347 )
348 }
349
350 #[test]
351 fn test_folding_hides_lines_and_adds_placeholder() {
352 let content = "header\nline1\nline2\ntail\n";
353 let mut state = EditorState::new(40, 6, 1024, test_fs());
354 state.buffer = Buffer::from_str(content, 1024, test_fs());
355
356 let start = state.buffer.line_start_offset(1).unwrap();
357 let end = state.buffer.line_start_offset(3).unwrap();
358 let mut folds = FoldManager::new();
359 folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
360
361 let viewport = Viewport::new(40, 6);
362 let gutter_width = state.margins.left_total_width();
363 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
364 let view_data = build_view_data(
365 &mut state,
366 &viewport,
367 None,
368 content.len().max(1),
369 viewport.visible_line_count(),
370 false,
371 40,
372 gutter_width,
373 &ViewMode::Source,
374 &folds,
375 &theme,
376 );
377
378 let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
379 assert!(lines.iter().any(|l| l.contains("header")));
380 assert!(lines.iter().any(|l| l.contains("tail")));
381 assert!(!lines.iter().any(|l| l.contains("line1")));
382 assert!(!lines.iter().any(|l| l.contains("line2")));
383 assert!(lines
384 .iter()
385 .any(|l| l.contains("header") && l.contains("...")));
386 }
387
388 #[test]
389 fn test_fold_indicators_collapsed_and_expanded() {
390 let content = "a\nb\nc\nd\n";
391 let mut state = EditorState::new(40, 6, 1024, test_fs());
392 state.buffer = Buffer::from_str(content, 1024, test_fs());
393
394 let lsp_ranges = vec![
395 FoldingRange {
396 start_line: 0,
397 end_line: 1,
398 start_character: None,
399 end_character: None,
400 kind: None,
401 collapsed_text: None,
402 },
403 FoldingRange {
404 start_line: 1,
405 end_line: 2,
406 start_character: None,
407 end_character: None,
408 kind: None,
409 collapsed_text: None,
410 },
411 ];
412 state
413 .folding_ranges
414 .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
415
416 let start = state.buffer.line_start_offset(1).unwrap();
417 let end = state.buffer.line_start_offset(2).unwrap();
418 let mut folds = FoldManager::new();
419 folds.add(&mut state.marker_list, start, end, None);
420
421 let line1_byte = state.buffer.line_start_offset(1).unwrap();
422 let view_lines = vec![ViewLine {
423 text: "b\n".to_string(),
424 source_start_byte: Some(line1_byte),
425 char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
426 char_styles: vec![None, None],
427 char_visual_cols: vec![0, 1],
428 visual_to_char: vec![0, 1],
429 tab_starts: HashSet::new(),
430 line_start: LineStart::AfterSourceNewline,
431 ends_with_newline: true,
432 }];
433
434 let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
435
436 assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
438 assert_eq!(
440 indicators.get(&line1_byte).map(|i| i.collapsed),
441 Some(false)
442 );
443 }
444
445 #[test]
446 fn last_line_end_tracks_trailing_newline() {
447 let output = render_output_for("abc\n", 4);
448 assert_eq!(
449 output.0.last_line_end,
450 Some(LastLineEnd {
451 pos: (3, 0),
452 terminated_with_newline: true
453 })
454 );
455 }
456
457 #[test]
458 fn last_line_end_tracks_no_trailing_newline() {
459 let output = render_output_for("abc", 3);
460 assert_eq!(
461 output.0.last_line_end,
462 Some(LastLineEnd {
463 pos: (3, 0),
464 terminated_with_newline: false
465 })
466 );
467 }
468
469 #[test]
470 fn cursor_after_newline_places_on_next_line() {
471 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
472 let cursor = resolve_cursor_fallback(
473 output.cursor,
474 cursor_pos,
475 buffer_len,
476 buffer_newline,
477 output.last_line_end,
478 output.content_lines_rendered,
479 0, );
481 assert_eq!(cursor, Some((0, 1)));
482 }
483
484 #[test]
485 fn cursor_at_end_without_newline_stays_on_line() {
486 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
487 let cursor = resolve_cursor_fallback(
488 output.cursor,
489 cursor_pos,
490 buffer_len,
491 buffer_newline,
492 output.last_line_end,
493 output.content_lines_rendered,
494 0, );
496 assert_eq!(cursor, Some((3, 0)));
497 }
498
499 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
505 let mut cursor_positions = Vec::new();
506
507 let primary_cursor = output.cursor;
509 if let Some(cursor_pos) = primary_cursor {
510 cursor_positions.push(cursor_pos);
511 }
512
513 for (line_idx, line) in output.lines.iter().enumerate() {
515 let mut col = 0u16;
516 for span in line.spans.iter() {
517 if span
519 .style
520 .add_modifier
521 .contains(ratatui::style::Modifier::REVERSED)
522 {
523 let pos = (col, line_idx as u16);
524 if primary_cursor != Some(pos) {
527 cursor_positions.push(pos);
528 }
529 }
530 col += str_width(&span.content) as u16;
532 }
533 }
534
535 cursor_positions
536 }
537
538 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
540 eprintln!("\n=== RENDER DEBUG ===");
541 eprintln!("Content: {:?}", content);
542 eprintln!("Cursor position: {}", cursor_pos);
543 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
544 eprintln!("Last line end: {:?}", output.last_line_end);
545 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
546 eprintln!("\nRendered lines:");
547 for (line_idx, line) in output.lines.iter().enumerate() {
548 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
549 for (span_idx, span) in line.spans.iter().enumerate() {
550 let has_reversed = span
551 .style
552 .add_modifier
553 .contains(ratatui::style::Modifier::REVERSED);
554 let bg_color = format!("{:?}", span.style.bg);
555 eprintln!(
556 " Span {}: {:?} (REVERSED: {}, BG: {})",
557 span_idx, span.content, has_reversed, bg_color
558 );
559 }
560 }
561 eprintln!("===================\n");
562 }
563
564 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
567 let (output, buffer_len, buffer_newline, cursor_pos) =
568 render_output_for(content, cursor_pos);
569
570 let all_cursors = count_all_cursors(&output);
572
573 assert!(
576 all_cursors.len() <= 1,
577 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
578 all_cursors.len(),
579 all_cursors
580 );
581
582 let final_cursor = resolve_cursor_fallback(
583 output.cursor,
584 cursor_pos,
585 buffer_len,
586 buffer_newline,
587 output.last_line_end,
588 output.content_lines_rendered,
589 0, );
591
592 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
594 {
595 dump_render_output(content, cursor_pos, &output);
596 }
597
598 if let Some(rendered_cursor) = all_cursors.first() {
600 assert_eq!(
601 Some(*rendered_cursor),
602 final_cursor,
603 "Rendered cursor at {:?} doesn't match final cursor {:?}",
604 rendered_cursor,
605 final_cursor
606 );
607 }
608
609 assert!(
611 final_cursor.is_some(),
612 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
613 all_cursors
614 );
615
616 final_cursor
617 }
618
619 fn check_typing_at_cursor(
621 content: &str,
622 cursor_pos: usize,
623 char_to_type: char,
624 ) -> (Option<(u16, u16)>, String) {
625 let cursor_before = get_final_cursor(content, cursor_pos);
627
628 let mut new_content = content.to_string();
630 if cursor_pos <= content.len() {
631 new_content.insert(cursor_pos, char_to_type);
632 }
633
634 (cursor_before, new_content)
635 }
636
637 #[test]
638 fn e2e_cursor_at_start_of_nonempty_line() {
639 let cursor = get_final_cursor("abc", 0);
641 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
642
643 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
644 assert_eq!(
645 new_content, "Xabc",
646 "Typing should insert at cursor position"
647 );
648 assert_eq!(cursor_pos, Some((0, 0)));
649 }
650
651 #[test]
652 fn e2e_cursor_in_middle_of_line() {
653 let cursor = get_final_cursor("abc", 1);
655 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
656
657 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
658 assert_eq!(
659 new_content, "aXbc",
660 "Typing should insert at cursor position"
661 );
662 assert_eq!(cursor_pos, Some((1, 0)));
663 }
664
665 #[test]
666 fn e2e_cursor_at_end_of_line_no_newline() {
667 let cursor = get_final_cursor("abc", 3);
669 assert_eq!(
670 cursor,
671 Some((3, 0)),
672 "Cursor should be at column 3, line 0 (after last char)"
673 );
674
675 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
676 assert_eq!(new_content, "abcX", "Typing should append at end");
677 assert_eq!(cursor_pos, Some((3, 0)));
678 }
679
680 #[test]
681 fn e2e_cursor_at_empty_line() {
682 let cursor = get_final_cursor("\n", 0);
684 assert_eq!(
685 cursor,
686 Some((0, 0)),
687 "Cursor on empty line should be at column 0"
688 );
689
690 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
691 assert_eq!(new_content, "X\n", "Typing should insert before newline");
692 assert_eq!(cursor_pos, Some((0, 0)));
693 }
694
695 #[test]
696 fn e2e_cursor_after_newline_at_eof() {
697 let cursor = get_final_cursor("abc\n", 4);
699 assert_eq!(
700 cursor,
701 Some((0, 1)),
702 "Cursor after newline at EOF should be on next line"
703 );
704
705 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
706 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
707 assert_eq!(cursor_pos, Some((0, 1)));
708 }
709
710 #[test]
711 fn e2e_cursor_on_newline_with_content() {
712 let cursor = get_final_cursor("abc\n", 3);
714 assert_eq!(
715 cursor,
716 Some((3, 0)),
717 "Cursor on newline after content should be after last char"
718 );
719
720 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
721 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
722 assert_eq!(cursor_pos, Some((3, 0)));
723 }
724
725 #[test]
726 fn e2e_cursor_multiline_start_of_second_line() {
727 let cursor = get_final_cursor("abc\ndef", 4);
729 assert_eq!(
730 cursor,
731 Some((0, 1)),
732 "Cursor at start of second line should be at column 0, line 1"
733 );
734
735 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
736 assert_eq!(
737 new_content, "abc\nXdef",
738 "Typing should insert at start of second line"
739 );
740 assert_eq!(cursor_pos, Some((0, 1)));
741 }
742
743 #[test]
744 fn e2e_cursor_multiline_end_of_first_line() {
745 let cursor = get_final_cursor("abc\ndef", 3);
747 assert_eq!(
748 cursor,
749 Some((3, 0)),
750 "Cursor on newline of first line should be after content"
751 );
752
753 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
754 assert_eq!(
755 new_content, "abcX\ndef",
756 "Typing should insert before newline"
757 );
758 assert_eq!(cursor_pos, Some((3, 0)));
759 }
760
761 #[test]
762 fn e2e_cursor_empty_buffer() {
763 let cursor = get_final_cursor("", 0);
765 assert_eq!(
766 cursor,
767 Some((0, 0)),
768 "Cursor in empty buffer should be at origin"
769 );
770
771 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
772 assert_eq!(
773 new_content, "X",
774 "Typing in empty buffer should insert character"
775 );
776 assert_eq!(cursor_pos, Some((0, 0)));
777 }
778
779 #[test]
780 fn e2e_cursor_empty_buffer_with_gutters() {
781 let (output, buffer_len, buffer_newline, cursor_pos) =
785 render_output_for_with_gutters("", 0, true);
786
787 let gutter_width = {
791 let mut state = EditorState::new(20, 6, 1024, test_fs());
792 state.margins.left_config.enabled = true;
793 state.margins.update_width_for_buffer(1, true);
794 state.margins.left_total_width()
795 };
796 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
797
798 assert_eq!(
802 output.cursor,
803 Some((gutter_width as u16, 0)),
804 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
805 gutter_width,
806 output.cursor
807 );
808
809 let final_cursor = resolve_cursor_fallback(
810 output.cursor,
811 cursor_pos,
812 buffer_len,
813 buffer_newline,
814 output.last_line_end,
815 output.content_lines_rendered,
816 gutter_width,
817 );
818
819 assert_eq!(
821 final_cursor,
822 Some((gutter_width as u16, 0)),
823 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
824 );
825 }
826
827 #[test]
828 fn e2e_cursor_between_empty_lines() {
829 let cursor = get_final_cursor("\n\n", 1);
831 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
832
833 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
834 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
835 assert_eq!(cursor_pos, Some((0, 1)));
836 }
837
838 #[test]
839 fn e2e_cursor_at_eof_after_multiple_lines() {
840 let cursor = get_final_cursor("abc\ndef\nghi", 11);
842 assert_eq!(
843 cursor,
844 Some((3, 2)),
845 "Cursor at EOF after 'i' should be at column 3, line 2"
846 );
847
848 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
849 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
850 assert_eq!(cursor_pos, Some((3, 2)));
851 }
852
853 #[test]
854 fn e2e_cursor_at_eof_with_trailing_newline() {
855 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
857 assert_eq!(
858 cursor,
859 Some((0, 3)),
860 "Cursor after trailing newline should be on line 3"
861 );
862
863 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
864 assert_eq!(
865 new_content, "abc\ndef\nghi\nX",
866 "Typing should insert on new line"
867 );
868 assert_eq!(cursor_pos, Some((0, 3)));
869 }
870
871 #[test]
872 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
873 let content = "abc\ndef\nghi";
875
876 let cursor_at_start = get_final_cursor(content, 0);
878 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
879
880 let cursor_at_eof = get_final_cursor(content, 11);
882 assert_eq!(
883 cursor_at_eof,
884 Some((3, 2)),
885 "After Ctrl+End, cursor at column 3, line 2"
886 );
887
888 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
890 assert_eq!(cursor_before_typing, Some((3, 2)));
891 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
892
893 let cursor_after_typing = get_final_cursor(&new_content, 12);
895 assert_eq!(
896 cursor_after_typing,
897 Some((4, 2)),
898 "After typing, cursor moved to column 4"
899 );
900
901 let cursor_moved_away = get_final_cursor(&new_content, 0);
903 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
904 }
907
908 #[test]
909 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
910 let content = "abc\ndef\nghi\n";
912
913 let cursor_at_start = get_final_cursor(content, 0);
915 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
916
917 let cursor_at_eof = get_final_cursor(content, 12);
919 assert_eq!(
920 cursor_at_eof,
921 Some((0, 3)),
922 "After Ctrl+End, cursor at column 0, line 3 (new line)"
923 );
924
925 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
927 assert_eq!(cursor_before_typing, Some((0, 3)));
928 assert_eq!(
929 new_content, "abc\ndef\nghi\nX",
930 "Character inserted on new line"
931 );
932
933 let cursor_after_typing = get_final_cursor(&new_content, 13);
935 assert_eq!(
936 cursor_after_typing,
937 Some((1, 3)),
938 "After typing, cursor should be at column 1, line 3"
939 );
940
941 let cursor_moved_away = get_final_cursor(&new_content, 4);
943 assert_eq!(
944 cursor_moved_away,
945 Some((0, 1)),
946 "Cursor moved to start of line 1 (position 4 = start of 'def')"
947 );
948 }
949
950 #[test]
951 fn e2e_jump_to_end_of_empty_buffer() {
952 let content = "";
954
955 let cursor_at_eof = get_final_cursor(content, 0);
956 assert_eq!(
957 cursor_at_eof,
958 Some((0, 0)),
959 "Empty buffer: cursor at origin"
960 );
961
962 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
964 assert_eq!(cursor_before_typing, Some((0, 0)));
965 assert_eq!(new_content, "X", "Character inserted");
966
967 let cursor_after_typing = get_final_cursor(&new_content, 1);
969 assert_eq!(
970 cursor_after_typing,
971 Some((1, 0)),
972 "After typing, cursor at column 1"
973 );
974
975 let cursor_moved_away = get_final_cursor(&new_content, 0);
977 assert_eq!(
978 cursor_moved_away,
979 Some((0, 0)),
980 "Cursor moved back to start"
981 );
982 }
983
984 #[test]
985 fn e2e_jump_to_end_of_single_empty_line() {
986 let content = "\n";
988
989 let cursor_on_newline = get_final_cursor(content, 0);
991 assert_eq!(
992 cursor_on_newline,
993 Some((0, 0)),
994 "Cursor on the newline character"
995 );
996
997 let cursor_at_eof = get_final_cursor(content, 1);
999 assert_eq!(
1000 cursor_at_eof,
1001 Some((0, 1)),
1002 "After Ctrl+End, cursor on line 1"
1003 );
1004
1005 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1007 assert_eq!(cursor_before_typing, Some((0, 1)));
1008 assert_eq!(new_content, "\nX", "Character on second line");
1009
1010 let cursor_after_typing = get_final_cursor(&new_content, 2);
1011 assert_eq!(
1012 cursor_after_typing,
1013 Some((1, 1)),
1014 "After typing, cursor at column 1, line 1"
1015 );
1016
1017 let cursor_moved_away = get_final_cursor(&new_content, 0);
1019 assert_eq!(
1020 cursor_moved_away,
1021 Some((0, 0)),
1022 "Cursor moved to the newline on line 0"
1023 );
1024 }
1025 use fresh_core::api::ViewTokenWireKind;
1036
1037 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1039 tokens
1040 .iter()
1041 .map(|t| {
1042 let kind_str = match &t.kind {
1043 ViewTokenWireKind::Text(s) => format!("Text({})", s),
1044 ViewTokenWireKind::Newline => "Newline".to_string(),
1045 ViewTokenWireKind::Space => "Space".to_string(),
1046 ViewTokenWireKind::Break => "Break".to_string(),
1047 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1048 };
1049 (kind_str, t.source_offset)
1050 })
1051 .collect()
1052 }
1053
1054 #[test]
1057 fn test_build_base_tokens_crlf_single_line() {
1058 let content = b"abc\r\n";
1060 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1061 buffer.set_line_ending(LineEnding::CRLF);
1062
1063 let tokens = SplitRenderer::build_base_tokens_for_hook(
1064 &mut buffer,
1065 0, 80, 10, false, LineEnding::CRLF,
1070 );
1071
1072 let offsets = extract_token_offsets(&tokens);
1073
1074 assert!(
1077 offsets
1078 .iter()
1079 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1080 "Expected Text(abc) at offset 0, got: {:?}",
1081 offsets
1082 );
1083 assert!(
1084 offsets
1085 .iter()
1086 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1087 "Expected Newline at offset 3 (\\r position), got: {:?}",
1088 offsets
1089 );
1090
1091 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1093 assert_eq!(
1094 newline_count, 1,
1095 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1096 newline_count, offsets
1097 );
1098 }
1099
1100 #[test]
1103 fn test_build_base_tokens_crlf_multiple_lines() {
1104 let content = b"abc\r\ndef\r\nghi\r\n";
1109 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1110 buffer.set_line_ending(LineEnding::CRLF);
1111
1112 let tokens = SplitRenderer::build_base_tokens_for_hook(
1113 &mut buffer,
1114 0,
1115 80,
1116 10,
1117 false,
1118 LineEnding::CRLF,
1119 );
1120
1121 let offsets = extract_token_offsets(&tokens);
1122
1123 assert!(
1130 offsets
1131 .iter()
1132 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1133 "Line 1: Expected Text(abc) at 0, got: {:?}",
1134 offsets
1135 );
1136 assert!(
1137 offsets
1138 .iter()
1139 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1140 "Line 1: Expected Newline at 3, got: {:?}",
1141 offsets
1142 );
1143
1144 assert!(
1146 offsets
1147 .iter()
1148 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1149 "Line 2: Expected Text(def) at 5, got: {:?}",
1150 offsets
1151 );
1152 assert!(
1153 offsets
1154 .iter()
1155 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1156 "Line 2: Expected Newline at 8, got: {:?}",
1157 offsets
1158 );
1159
1160 assert!(
1162 offsets
1163 .iter()
1164 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1165 "Line 3: Expected Text(ghi) at 10, got: {:?}",
1166 offsets
1167 );
1168 assert!(
1169 offsets
1170 .iter()
1171 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
1172 "Line 3: Expected Newline at 13, got: {:?}",
1173 offsets
1174 );
1175
1176 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1178 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
1179 }
1180
1181 #[test]
1184 fn test_build_base_tokens_lf_mode_for_comparison() {
1185 let content = b"abc\ndef\n";
1189 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1190 buffer.set_line_ending(LineEnding::LF);
1191
1192 let tokens = SplitRenderer::build_base_tokens_for_hook(
1193 &mut buffer,
1194 0,
1195 80,
1196 10,
1197 false,
1198 LineEnding::LF,
1199 );
1200
1201 let offsets = extract_token_offsets(&tokens);
1202
1203 assert!(
1205 offsets
1206 .iter()
1207 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1208 "LF Line 1: Expected Text(abc) at 0"
1209 );
1210 assert!(
1211 offsets
1212 .iter()
1213 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1214 "LF Line 1: Expected Newline at 3"
1215 );
1216 assert!(
1217 offsets
1218 .iter()
1219 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
1220 "LF Line 2: Expected Text(def) at 4"
1221 );
1222 assert!(
1223 offsets
1224 .iter()
1225 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
1226 "LF Line 2: Expected Newline at 7"
1227 );
1228 }
1229
1230 #[test]
1233 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
1234 let content = b"abc\r\n";
1236 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1237 buffer.set_line_ending(LineEnding::LF); let tokens = SplitRenderer::build_base_tokens_for_hook(
1240 &mut buffer,
1241 0,
1242 80,
1243 10,
1244 false,
1245 LineEnding::LF,
1246 );
1247
1248 let offsets = extract_token_offsets(&tokens);
1249
1250 assert!(
1252 offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
1253 "LF mode should render \\r as control char <0D>, got: {:?}",
1254 offsets
1255 );
1256 }
1257
1258 #[test]
1261 fn test_build_base_tokens_crlf_from_middle() {
1262 let content = b"abc\r\ndef\r\nghi\r\n";
1265 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1266 buffer.set_line_ending(LineEnding::CRLF);
1267
1268 let tokens = SplitRenderer::build_base_tokens_for_hook(
1269 &mut buffer,
1270 5, 80,
1272 10,
1273 false,
1274 LineEnding::CRLF,
1275 );
1276
1277 let offsets = extract_token_offsets(&tokens);
1278
1279 assert!(
1283 offsets
1284 .iter()
1285 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1286 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
1287 offsets
1288 );
1289 assert!(
1290 offsets
1291 .iter()
1292 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1293 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
1294 offsets
1295 );
1296 }
1297
1298 #[test]
1301 fn test_crlf_highlight_span_lookup() {
1302 use crate::view::ui::view_pipeline::ViewLineIterator;
1303
1304 let content = b"int x;\r\nint y;\r\n";
1309 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1310 buffer.set_line_ending(LineEnding::CRLF);
1311
1312 let tokens = SplitRenderer::build_base_tokens_for_hook(
1314 &mut buffer,
1315 0,
1316 80,
1317 10,
1318 false,
1319 LineEnding::CRLF,
1320 );
1321
1322 let offsets = extract_token_offsets(&tokens);
1324 eprintln!("Tokens: {:?}", offsets);
1325
1326 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1328 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
1329
1330 eprintln!(
1333 "Line 1 char_source_bytes: {:?}",
1334 view_lines[0].char_source_bytes
1335 );
1336 assert_eq!(
1337 view_lines[0].char_source_bytes.len(),
1338 7,
1339 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
1340 );
1341 assert_eq!(
1343 view_lines[0].char_source_bytes[0],
1344 Some(0),
1345 "Line 1 'i' -> byte 0"
1346 );
1347 assert_eq!(
1348 view_lines[0].char_source_bytes[4],
1349 Some(4),
1350 "Line 1 'x' -> byte 4"
1351 );
1352 assert_eq!(
1353 view_lines[0].char_source_bytes[5],
1354 Some(5),
1355 "Line 1 ';' -> byte 5"
1356 );
1357 assert_eq!(
1358 view_lines[0].char_source_bytes[6],
1359 Some(6),
1360 "Line 1 newline -> byte 6 (\\r pos)"
1361 );
1362
1363 eprintln!(
1365 "Line 2 char_source_bytes: {:?}",
1366 view_lines[1].char_source_bytes
1367 );
1368 assert_eq!(
1369 view_lines[1].char_source_bytes.len(),
1370 7,
1371 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
1372 );
1373 assert_eq!(
1375 view_lines[1].char_source_bytes[0],
1376 Some(8),
1377 "Line 2 'i' -> byte 8"
1378 );
1379 assert_eq!(
1380 view_lines[1].char_source_bytes[4],
1381 Some(12),
1382 "Line 2 'y' -> byte 12"
1383 );
1384 assert_eq!(
1385 view_lines[1].char_source_bytes[5],
1386 Some(13),
1387 "Line 2 ';' -> byte 13"
1388 );
1389 assert_eq!(
1390 view_lines[1].char_source_bytes[6],
1391 Some(14),
1392 "Line 2 newline -> byte 14 (\\r pos)"
1393 );
1394
1395 let simulated_highlight_spans = [
1399 (0usize..3usize, "keyword"),
1401 (8usize..11usize, "keyword"),
1403 ];
1404
1405 for (line_idx, view_line) in view_lines.iter().enumerate() {
1407 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
1408 if let Some(bp) = byte_pos {
1409 let in_span = simulated_highlight_spans
1410 .iter()
1411 .find(|(range, _)| range.contains(bp))
1412 .map(|(_, name)| *name);
1413
1414 let expected_in_keyword = char_idx < 3;
1416 let actually_in_keyword = in_span == Some("keyword");
1417
1418 if expected_in_keyword != actually_in_keyword {
1419 panic!(
1420 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
1421 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
1422 );
1423 }
1424 }
1425 }
1426 }
1427 }
1428
1429 #[test]
1432 fn test_apply_wrapping_transform_breaks_long_lines() {
1433 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1434
1435 let long_text = "x".repeat(25_000);
1437 let tokens = vec![
1438 ViewTokenWire {
1439 kind: ViewTokenWireKind::Text(long_text),
1440 source_offset: Some(0),
1441 style: None,
1442 },
1443 ViewTokenWire {
1444 kind: ViewTokenWireKind::Newline,
1445 source_offset: Some(25_000),
1446 style: None,
1447 },
1448 ];
1449
1450 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1452
1453 let break_count = wrapped
1455 .iter()
1456 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1457 .count();
1458
1459 assert!(
1460 break_count >= 2,
1461 "25K char line should have at least 2 breaks at 10K width, got {}",
1462 break_count
1463 );
1464
1465 let total_chars: usize = wrapped
1467 .iter()
1468 .filter_map(|t| match &t.kind {
1469 ViewTokenWireKind::Text(s) => Some(s.len()),
1470 _ => None,
1471 })
1472 .sum();
1473
1474 assert_eq!(
1475 total_chars, 25_000,
1476 "Total character count should be preserved after wrapping"
1477 );
1478 }
1479
1480 #[cfg(test)]
1506 mod wrap_boundary_property {
1507 use super::apply_wrapping_transform;
1508 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1509 use proptest::prelude::*;
1510 use unicode_segmentation::UnicodeSegmentation;
1511
1512 const MAX_LOOKBACK: usize = 16;
1516
1517 fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
1518 let mut tokens: Vec<ViewTokenWire> = Vec::new();
1519 let mut buf = String::new();
1520 let mut buf_start = 0usize;
1521 for (i, c) in input.char_indices() {
1522 if c == ' ' {
1523 if !buf.is_empty() {
1524 tokens.push(ViewTokenWire {
1525 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1526 source_offset: Some(buf_start),
1527 style: None,
1528 });
1529 }
1530 tokens.push(ViewTokenWire {
1531 kind: ViewTokenWireKind::Space,
1532 source_offset: Some(i),
1533 style: None,
1534 });
1535 buf_start = i + 1;
1536 } else {
1537 if buf.is_empty() {
1538 buf_start = i;
1539 }
1540 buf.push(c);
1541 }
1542 }
1543 if !buf.is_empty() {
1544 tokens.push(ViewTokenWire {
1545 kind: ViewTokenWireKind::Text(buf.clone()),
1546 source_offset: Some(buf_start),
1547 style: None,
1548 });
1549 }
1550 tokens.push(ViewTokenWire {
1551 kind: ViewTokenWireKind::Newline,
1552 source_offset: Some(input.len()),
1553 style: None,
1554 });
1555 tokens
1556 }
1557
1558 fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
1563 let mut rows: Vec<String> = vec![String::new()];
1564 for t in wrapped {
1565 match &t.kind {
1566 ViewTokenWireKind::Text(s) => {
1567 rows.last_mut().unwrap().push_str(s);
1568 }
1569 ViewTokenWireKind::Space => {
1570 rows.last_mut().unwrap().push(' ');
1571 }
1572 ViewTokenWireKind::Break => {
1573 rows.push(String::new());
1574 }
1575 ViewTokenWireKind::Newline => {
1576 }
1579 _ => {}
1580 }
1581 }
1582 rows
1583 }
1584
1585 proptest! {
1586 #![proptest_config(ProptestConfig {
1590 cases: 256,
1591 .. ProptestConfig::default()
1592 })]
1593
1594 #[test]
1597 fn prop_wrap_respects_boundaries(
1598 input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
1599 content_width in 5usize..40,
1600 ) {
1601 let tokens = tokens_from_input(&input);
1604 let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
1605 let rows = visual_rows(&wrapped);
1606
1607 for (i, row) in rows.iter().enumerate() {
1609 prop_assert!(
1610 row.chars().count() <= content_width,
1611 "row {i} {:?} has width {} > content_width {content_width}",
1612 row,
1613 row.chars().count(),
1614 );
1615 }
1616
1617 let reconstructed: String = rows.concat();
1619 prop_assert_eq!(
1620 &reconstructed,
1621 &input,
1622 "reconstruction differs from input"
1623 );
1624
1625 let boundaries: std::collections::BTreeSet<usize> = input
1629 .split_word_bound_indices()
1630 .map(|(i, _)| i)
1631 .chain(std::iter::once(input.len()))
1632 .collect();
1633
1634 let mut cursor_bytes = 0usize;
1635 let mut cursor_chars = 0usize;
1636 for (i, row) in rows.iter().enumerate() {
1637 let row_bytes = row.len();
1638 let row_chars = row.chars().count();
1639 let row_end_bytes = cursor_bytes + row_bytes;
1640 let row_end_chars = cursor_chars + row_chars;
1641 let is_last = i + 1 == rows.len();
1642
1643 if !is_last {
1644 let input_bytes = input.as_bytes();
1650 let prev_is_space =
1651 row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
1652 let next_is_space = row_end_bytes < input_bytes.len()
1653 && input_bytes[row_end_bytes] == b' ';
1654 let is_mid_text = !prev_is_space && !next_is_space;
1655 if !is_mid_text {
1656 cursor_bytes = row_end_bytes;
1657 cursor_chars = row_end_chars;
1658 continue;
1659 }
1660
1661 let hard_cap_chars = cursor_chars + content_width;
1664 let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
1665 let floor_chars = cursor_chars
1666 + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
1667 let floor_bytes = char_index_to_byte(&input, floor_chars);
1668
1669 let max_in_window = boundaries
1677 .range(floor_bytes..=hard_cap_bytes)
1678 .next_back()
1679 .copied();
1680 match max_in_window {
1681 Some(max_b) => {
1682 prop_assert_eq!(
1683 row_end_bytes,
1684 max_b,
1685 "split at byte {} but largest word boundary in \
1686 [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
1687 row_end_bytes,
1688 floor_bytes,
1689 hard_cap_bytes,
1690 max_b,
1691 row,
1692 input,
1693 );
1694 }
1695 None => {
1696 prop_assert_eq!(
1697 row_end_bytes,
1698 hard_cap_bytes,
1699 "no word boundary in [floor={}, hard_cap={}], so \
1700 char-split must land at hard_cap, but split is at \
1701 byte {}; row={:?}, input={:?}",
1702 floor_bytes,
1703 hard_cap_bytes,
1704 row_end_bytes,
1705 row,
1706 input,
1707 );
1708 }
1709 }
1710 }
1711
1712 cursor_bytes = row_end_bytes;
1713 cursor_chars = row_end_chars;
1714 }
1715 }
1716 }
1717
1718 fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1721 s.char_indices()
1722 .nth(char_idx)
1723 .map(|(b, _)| b)
1724 .unwrap_or(s.len())
1725 }
1726 }
1727
1728 #[test]
1730 fn test_apply_wrapping_transform_preserves_short_lines() {
1731 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1732
1733 let short_text = "x".repeat(100);
1735 let tokens = vec![
1736 ViewTokenWire {
1737 kind: ViewTokenWireKind::Text(short_text.clone()),
1738 source_offset: Some(0),
1739 style: None,
1740 },
1741 ViewTokenWire {
1742 kind: ViewTokenWireKind::Newline,
1743 source_offset: Some(100),
1744 style: None,
1745 },
1746 ];
1747
1748 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1750
1751 let break_count = wrapped
1753 .iter()
1754 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1755 .count();
1756
1757 assert_eq!(
1758 break_count, 0,
1759 "Short lines should not have any breaks, got {}",
1760 break_count
1761 );
1762
1763 let text_tokens: Vec<_> = wrapped
1765 .iter()
1766 .filter_map(|t| match &t.kind {
1767 ViewTokenWireKind::Text(s) => Some(s.clone()),
1768 _ => None,
1769 })
1770 .collect();
1771
1772 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
1773 assert_eq!(
1774 text_tokens[0], short_text,
1775 "Text content should be unchanged"
1776 );
1777 }
1778
1779 #[test]
1782 fn test_large_single_line_sequential_data_preserved() {
1783 use crate::view::ui::view_pipeline::ViewLineIterator;
1784 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1785
1786 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
1790
1791 let tokens = vec![
1793 ViewTokenWire {
1794 kind: ViewTokenWireKind::Text(content.clone()),
1795 source_offset: Some(0),
1796 style: None,
1797 },
1798 ViewTokenWire {
1799 kind: ViewTokenWireKind::Newline,
1800 source_offset: Some(content.len()),
1801 style: None,
1802 },
1803 ];
1804
1805 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1807
1808 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
1810
1811 let mut reconstructed = String::new();
1813 for line in &view_lines {
1814 let text = line.text.trim_end_matches('\n');
1816 reconstructed.push_str(text);
1817 }
1818
1819 assert_eq!(
1821 reconstructed.len(),
1822 content.len(),
1823 "Reconstructed content length should match original"
1824 );
1825
1826 for i in 1..=num_markers {
1828 let marker = format!("[{:05}]", i);
1829 assert!(
1830 reconstructed.contains(&marker),
1831 "Missing marker {} after pipeline",
1832 marker
1833 );
1834 }
1835
1836 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
1838 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
1839 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
1840 assert!(
1841 pos_100 < pos_1000 && pos_1000 < pos_3000,
1842 "Markers should be in sequential order: {} < {} < {}",
1843 pos_100,
1844 pos_1000,
1845 pos_3000
1846 );
1847
1848 assert!(
1850 view_lines.len() >= 3,
1851 "35KB content should produce multiple visual lines at 10K width, got {}",
1852 view_lines.len()
1853 );
1854
1855 for (i, line) in view_lines.iter().enumerate() {
1857 assert!(
1858 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
1860 i,
1861 line.text.len()
1862 );
1863 }
1864 }
1865
1866 fn strip_osc8(s: &str) -> String {
1868 let mut result = String::with_capacity(s.len());
1869 let bytes = s.as_bytes();
1870 let mut i = 0;
1871 while i < bytes.len() {
1872 if i + 3 < bytes.len()
1873 && bytes[i] == 0x1b
1874 && bytes[i + 1] == b']'
1875 && bytes[i + 2] == b'8'
1876 && bytes[i + 3] == b';'
1877 {
1878 i += 4;
1879 while i < bytes.len() && bytes[i] != 0x07 {
1880 i += 1;
1881 }
1882 if i < bytes.len() {
1883 i += 1;
1884 }
1885 } else {
1886 result.push(bytes[i] as char);
1887 i += 1;
1888 }
1889 }
1890 result
1891 }
1892
1893 fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
1896 let width = buf.area().width;
1897 let mut s = String::new();
1898 let mut col = 0u16;
1899 while col < width {
1900 let cell = &buf[(col, y)];
1901 let stripped = strip_osc8(cell.symbol());
1902 let chars = stripped.chars().count();
1903 if chars > 1 {
1904 s.push_str(&stripped);
1905 col += chars as u16;
1906 } else {
1907 s.push_str(&stripped);
1908 col += 1;
1909 }
1910 }
1911 s.trim_end().to_string()
1912 }
1913
1914 #[test]
1915 fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
1916 use ratatui::buffer::Buffer;
1917 use ratatui::layout::Rect;
1918
1919 let text = "[Quick Install](#installation)";
1921 let area = Rect::new(0, 0, 40, 1);
1922 let mut buf = Buffer::empty(area);
1923 for (i, ch) in text.chars().enumerate() {
1924 if (i as u16) < 40 {
1925 buf[(i as u16, 0)].set_symbol(&ch.to_string());
1926 }
1927 }
1928
1929 let url = "https://example.com";
1931
1932 apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
1934
1935 let row = read_row(&buf, 0);
1936 assert_eq!(
1937 row, text,
1938 "After OSC 8 application, reading the row should reproduce the original text"
1939 );
1940
1941 let cell14 = strip_osc8(buf[(14, 0)].symbol());
1943 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
1944
1945 let cell0 = strip_osc8(buf[(0, 0)].symbol());
1947 assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
1948 }
1949
1950 #[test]
1951 fn test_apply_osc8_stable_across_reapply() {
1952 use ratatui::buffer::Buffer;
1953 use ratatui::layout::Rect;
1954
1955 let text = "[Quick Install](#installation)";
1956 let area = Rect::new(0, 0, 40, 1);
1957
1958 let mut buf1 = Buffer::empty(area);
1960 for (i, ch) in text.chars().enumerate() {
1961 if (i as u16) < 40 {
1962 buf1[(i as u16, 0)].set_symbol(&ch.to_string());
1963 }
1964 }
1965 apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
1966 let row1 = read_row(&buf1, 0);
1967
1968 let mut buf2 = Buffer::empty(area);
1970 for (i, ch) in text.chars().enumerate() {
1971 if (i as u16) < 40 {
1972 buf2[(i as u16, 0)].set_symbol(&ch.to_string());
1973 }
1974 }
1975 apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
1976 let row2 = read_row(&buf2, 0);
1977
1978 assert_eq!(row1, text);
1979 assert_eq!(row2, text);
1980 }
1981
1982 #[test]
1983 #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
1984 fn test_apply_osc8_diff_between_renders() {
1985 use ratatui::buffer::Buffer;
1986 use ratatui::layout::Rect;
1987
1988 let area = Rect::new(0, 0, 40, 1);
1991
1992 let concealed = "Quick Install";
1994 let mut frame1 = Buffer::empty(area);
1995 for (i, ch) in concealed.chars().enumerate() {
1996 frame1[(i as u16, 0)].set_symbol(&ch.to_string());
1997 }
1998 apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2000
2001 let prev = Buffer::empty(area);
2003 let mut backend = Buffer::empty(area);
2004 let diff1 = prev.diff(&frame1);
2005 for (x, y, cell) in &diff1 {
2006 backend[(*x, *y)] = (*cell).clone();
2007 }
2008
2009 let full = "[Quick Install](#installation)";
2011 let mut frame2 = Buffer::empty(area);
2012 for (i, ch) in full.chars().enumerate() {
2013 if (i as u16) < 40 {
2014 frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2015 }
2016 }
2017 apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2019
2020 let diff2 = frame1.diff(&frame2);
2022 for (x, y, cell) in &diff2 {
2023 backend[(*x, *y)] = (*cell).clone();
2024 }
2025
2026 let row = read_row(&backend, 0);
2028 assert_eq!(
2029 row, full,
2030 "After diff-based update from concealed to unconcealed, \
2031 backend should show full text"
2032 );
2033
2034 let cell14 = strip_osc8(backend[(14, 0)].symbol());
2036 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
2037 }
2038
2039 fn render_with_highlight_option(
2042 content: &str,
2043 cursor_pos: usize,
2044 highlight_current_line: bool,
2045 ) -> LineRenderOutput {
2046 let mut state = EditorState::new(20, 6, 1024, test_fs());
2047 state.buffer = Buffer::from_str(content, 1024, test_fs());
2048 let mut cursors = crate::model::cursor::Cursors::new();
2049 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
2050 let viewport = Viewport::new(20, 4);
2051 state.margins.left_config.enabled = false;
2052
2053 let render_area = Rect::new(0, 0, 20, 4);
2054 let visible_count = viewport.visible_line_count();
2055 let gutter_width = state.margins.left_total_width();
2056 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
2057 let empty_folds = FoldManager::new();
2058
2059 let view_data = build_view_data(
2060 &mut state,
2061 &viewport,
2062 None,
2063 content.len().max(1),
2064 visible_count,
2065 false,
2066 render_area.width as usize,
2067 gutter_width,
2068 &ViewMode::Source,
2069 &empty_folds,
2070 &theme,
2071 );
2072 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
2073
2074 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
2075 state.margins.update_width_for_buffer(estimated_lines, true);
2076 let gutter_width = state.margins.left_total_width();
2077
2078 let selection = selection_context(&state, &cursors);
2079 let _ = state
2080 .buffer
2081 .populate_line_cache(viewport.top_byte, visible_count);
2082 let viewport_start = viewport.top_byte;
2083 let viewport_end = calculate_viewport_end(
2084 &mut state,
2085 viewport_start,
2086 content.len().max(1),
2087 visible_count,
2088 );
2089 let decorations = decoration_context(
2090 &mut state,
2091 viewport_start,
2092 viewport_end,
2093 selection.primary_cursor_position,
2094 &empty_folds,
2095 &theme,
2096 100_000,
2097 &ViewMode::Source,
2098 false,
2099 &[],
2100 );
2101
2102 render_view_lines(LineRenderInput {
2103 state: &state,
2104 theme: &theme,
2105 view_lines: &view_data.lines,
2106 view_anchor,
2107 render_area,
2108 gutter_width,
2109 selection: &selection,
2110 decorations: &decorations,
2111 visible_line_count: visible_count,
2112 lsp_waiting: false,
2113 is_active: true,
2114 line_wrap: viewport.line_wrap_enabled,
2115 estimated_lines,
2116 left_column: viewport.left_column,
2117 relative_line_numbers: false,
2118 session_mode: false,
2119 software_cursor_only: false,
2120 show_line_numbers: false,
2121 byte_offset_mode: false,
2122 show_tilde: true,
2123 highlight_current_line,
2124 cell_theme_map: &mut Vec::new(),
2125 screen_width: 0,
2126 })
2127 }
2128
2129 fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
2131 let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
2132 if let Some(line) = output.lines.get(line_idx) {
2133 line.spans
2134 .iter()
2135 .any(|span| span.style.bg == Some(current_line_bg))
2136 } else {
2137 false
2138 }
2139 }
2140
2141 #[test]
2142 fn current_line_highlight_enabled_highlights_cursor_line() {
2143 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
2144 assert!(
2146 line_has_current_line_bg(&output, 0),
2147 "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
2148 );
2149 assert!(
2151 !line_has_current_line_bg(&output, 1),
2152 "Non-cursor line (line 1) should NOT have current_line_bg"
2153 );
2154 }
2155
2156 #[test]
2157 fn current_line_highlight_disabled_no_highlight() {
2158 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
2159 assert!(
2161 !line_has_current_line_bg(&output, 0),
2162 "Cursor line should NOT have current_line_bg when highlighting is disabled"
2163 );
2164 assert!(
2165 !line_has_current_line_bg(&output, 1),
2166 "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
2167 );
2168 }
2169
2170 #[test]
2171 fn current_line_highlight_follows_cursor_position() {
2172 let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
2174 assert!(
2175 !line_has_current_line_bg(&output, 0),
2176 "Line 0 should NOT have current_line_bg when cursor is on line 1"
2177 );
2178 assert!(
2179 line_has_current_line_bg(&output, 1),
2180 "Line 1 should have current_line_bg when cursor is there"
2181 );
2182 assert!(
2183 !line_has_current_line_bg(&output, 2),
2184 "Line 2 should NOT have current_line_bg when cursor is on line 1"
2185 );
2186 }
2187}