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 fn tokenize_for_wrap(text: &str) -> Vec<fresh_core::api::ViewTokenWire> {
1822 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1823 let mut tokens = Vec::new();
1824 let mut buf = String::new();
1825 let mut buf_start: Option<usize> = None;
1826 for (i, ch) in text.char_indices() {
1827 if ch == ' ' {
1828 if !buf.is_empty() {
1829 tokens.push(ViewTokenWire {
1830 source_offset: buf_start,
1831 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1832 style: None,
1833 });
1834 buf_start = None;
1835 }
1836 tokens.push(ViewTokenWire {
1837 source_offset: Some(i),
1838 kind: ViewTokenWireKind::Space,
1839 style: None,
1840 });
1841 } else {
1842 if buf.is_empty() {
1843 buf_start = Some(i);
1844 }
1845 buf.push(ch);
1846 }
1847 }
1848 if !buf.is_empty() {
1849 tokens.push(ViewTokenWire {
1850 source_offset: buf_start,
1851 kind: ViewTokenWireKind::Text(buf),
1852 style: None,
1853 });
1854 }
1855 tokens
1856 }
1857
1858 fn rows_from_wrapped(wrapped: &[fresh_core::api::ViewTokenWire]) -> Vec<String> {
1861 use fresh_core::api::ViewTokenWireKind;
1862 let mut rows: Vec<String> = vec![String::new()];
1863 for tok in wrapped {
1864 match &tok.kind {
1865 ViewTokenWireKind::Text(s) => rows.last_mut().unwrap().push_str(s),
1866 ViewTokenWireKind::Space => rows.last_mut().unwrap().push(' '),
1867 ViewTokenWireKind::Newline => {}
1868 ViewTokenWireKind::Break => rows.push(String::new()),
1869 ViewTokenWireKind::BinaryByte(_) => {}
1870 }
1871 }
1872 if rows.last().map(|r| r.is_empty()).unwrap_or(false) {
1873 rows.pop();
1874 }
1875 rows
1876 }
1877
1878 #[test]
1884 fn issue_1363_no_leading_space_on_continuation_row() {
1885 let tokens = tokenize_for_wrap("AAAAA BBBBBB CCCC");
1886 let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
1887 let rows = rows_from_wrapped(&wrapped);
1888 assert_eq!(rows.len(), 2, "expected 2 rows, got {:?}", rows);
1889 for (i, row) in rows.iter().enumerate() {
1890 assert!(
1891 !row.starts_with(' '),
1892 "row {i} {:?} starts with whitespace (issue #1363): rows = {:?}",
1893 row,
1894 rows,
1895 );
1896 assert!(
1897 row.chars().count() <= 12,
1898 "row {i} {:?} width {} exceeds eff_width 12 (issue #1363): no char may overflow",
1899 row,
1900 row.chars().count(),
1901 );
1902 }
1903 }
1904
1905 #[test]
1909 fn issue_1363_back_up_preserves_content() {
1910 let input = "AAAAA BBBBBB CCCC";
1911 let tokens = tokenize_for_wrap(input);
1912 let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
1913 let rows = rows_from_wrapped(&wrapped);
1914 let reconstructed: String = rows.concat();
1915 assert_eq!(reconstructed, input, "rows = {:?}", rows);
1916 }
1917
1918 #[test]
1925 fn issue_1363_single_word_row_falls_back() {
1926 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1927 let tokens = vec![
1928 ViewTokenWire {
1929 source_offset: Some(0),
1930 kind: ViewTokenWireKind::Text("XXXXXXXX".to_string()),
1931 style: None,
1932 },
1933 ViewTokenWire {
1934 source_offset: Some(8),
1935 kind: ViewTokenWireKind::Space,
1936 style: None,
1937 },
1938 ViewTokenWire {
1939 source_offset: Some(9),
1940 kind: ViewTokenWireKind::Text("YYYY".to_string()),
1941 style: None,
1942 },
1943 ];
1944 let wrapped = apply_wrapping_transform(tokens, 8, 0, false);
1945 let rows = rows_from_wrapped(&wrapped);
1946 for row in &rows {
1947 assert!(
1948 row.chars().count() <= 8,
1949 "row {:?} exceeds eff_width 8 in fallback case",
1950 row,
1951 );
1952 }
1953 }
1954
1955 #[test]
1957 fn test_apply_wrapping_transform_preserves_short_lines() {
1958 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1959
1960 let short_text = "x".repeat(100);
1962 let tokens = vec![
1963 ViewTokenWire {
1964 kind: ViewTokenWireKind::Text(short_text.clone()),
1965 source_offset: Some(0),
1966 style: None,
1967 },
1968 ViewTokenWire {
1969 kind: ViewTokenWireKind::Newline,
1970 source_offset: Some(100),
1971 style: None,
1972 },
1973 ];
1974
1975 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1977
1978 let break_count = wrapped
1980 .iter()
1981 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1982 .count();
1983
1984 assert_eq!(
1985 break_count, 0,
1986 "Short lines should not have any breaks, got {}",
1987 break_count
1988 );
1989
1990 let text_tokens: Vec<_> = wrapped
1992 .iter()
1993 .filter_map(|t| match &t.kind {
1994 ViewTokenWireKind::Text(s) => Some(s.clone()),
1995 _ => None,
1996 })
1997 .collect();
1998
1999 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
2000 assert_eq!(
2001 text_tokens[0], short_text,
2002 "Text content should be unchanged"
2003 );
2004 }
2005
2006 #[test]
2009 fn test_large_single_line_sequential_data_preserved() {
2010 use crate::view::ui::view_pipeline::ViewLineIterator;
2011 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2012
2013 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
2017
2018 let tokens = vec![
2020 ViewTokenWire {
2021 kind: ViewTokenWireKind::Text(content.clone()),
2022 source_offset: Some(0),
2023 style: None,
2024 },
2025 ViewTokenWire {
2026 kind: ViewTokenWireKind::Newline,
2027 source_offset: Some(content.len()),
2028 style: None,
2029 },
2030 ];
2031
2032 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2034
2035 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
2037
2038 let mut reconstructed = String::new();
2040 for line in &view_lines {
2041 let text = line.text.trim_end_matches('\n');
2043 reconstructed.push_str(text);
2044 }
2045
2046 assert_eq!(
2048 reconstructed.len(),
2049 content.len(),
2050 "Reconstructed content length should match original"
2051 );
2052
2053 for i in 1..=num_markers {
2055 let marker = format!("[{:05}]", i);
2056 assert!(
2057 reconstructed.contains(&marker),
2058 "Missing marker {} after pipeline",
2059 marker
2060 );
2061 }
2062
2063 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
2065 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
2066 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
2067 assert!(
2068 pos_100 < pos_1000 && pos_1000 < pos_3000,
2069 "Markers should be in sequential order: {} < {} < {}",
2070 pos_100,
2071 pos_1000,
2072 pos_3000
2073 );
2074
2075 assert!(
2077 view_lines.len() >= 3,
2078 "35KB content should produce multiple visual lines at 10K width, got {}",
2079 view_lines.len()
2080 );
2081
2082 for (i, line) in view_lines.iter().enumerate() {
2084 assert!(
2085 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
2087 i,
2088 line.text.len()
2089 );
2090 }
2091 }
2092
2093 fn strip_osc8(s: &str) -> String {
2095 let mut result = String::with_capacity(s.len());
2096 let bytes = s.as_bytes();
2097 let mut i = 0;
2098 while i < bytes.len() {
2099 if i + 3 < bytes.len()
2100 && bytes[i] == 0x1b
2101 && bytes[i + 1] == b']'
2102 && bytes[i + 2] == b'8'
2103 && bytes[i + 3] == b';'
2104 {
2105 i += 4;
2106 while i < bytes.len() && bytes[i] != 0x07 {
2107 i += 1;
2108 }
2109 if i < bytes.len() {
2110 i += 1;
2111 }
2112 } else {
2113 result.push(bytes[i] as char);
2114 i += 1;
2115 }
2116 }
2117 result
2118 }
2119
2120 fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
2123 let width = buf.area().width;
2124 let mut s = String::new();
2125 let mut col = 0u16;
2126 while col < width {
2127 let cell = &buf[(col, y)];
2128 let stripped = strip_osc8(cell.symbol());
2129 let chars = stripped.chars().count();
2130 if chars > 1 {
2131 s.push_str(&stripped);
2132 col += chars as u16;
2133 } else {
2134 s.push_str(&stripped);
2135 col += 1;
2136 }
2137 }
2138 s.trim_end().to_string()
2139 }
2140
2141 #[test]
2142 fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
2143 use ratatui::buffer::Buffer;
2144 use ratatui::layout::Rect;
2145
2146 let text = "[Quick Install](#installation)";
2148 let area = Rect::new(0, 0, 40, 1);
2149 let mut buf = Buffer::empty(area);
2150 for (i, ch) in text.chars().enumerate() {
2151 if (i as u16) < 40 {
2152 buf[(i as u16, 0)].set_symbol(&ch.to_string());
2153 }
2154 }
2155
2156 let url = "https://example.com";
2158
2159 apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
2161
2162 let row = read_row(&buf, 0);
2163 assert_eq!(
2164 row, text,
2165 "After OSC 8 application, reading the row should reproduce the original text"
2166 );
2167
2168 let cell14 = strip_osc8(buf[(14, 0)].symbol());
2170 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
2171
2172 let cell0 = strip_osc8(buf[(0, 0)].symbol());
2174 assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
2175 }
2176
2177 #[test]
2178 fn test_apply_osc8_stable_across_reapply() {
2179 use ratatui::buffer::Buffer;
2180 use ratatui::layout::Rect;
2181
2182 let text = "[Quick Install](#installation)";
2183 let area = Rect::new(0, 0, 40, 1);
2184
2185 let mut buf1 = Buffer::empty(area);
2187 for (i, ch) in text.chars().enumerate() {
2188 if (i as u16) < 40 {
2189 buf1[(i as u16, 0)].set_symbol(&ch.to_string());
2190 }
2191 }
2192 apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
2193 let row1 = read_row(&buf1, 0);
2194
2195 let mut buf2 = Buffer::empty(area);
2197 for (i, ch) in text.chars().enumerate() {
2198 if (i as u16) < 40 {
2199 buf2[(i as u16, 0)].set_symbol(&ch.to_string());
2200 }
2201 }
2202 apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
2203 let row2 = read_row(&buf2, 0);
2204
2205 assert_eq!(row1, text);
2206 assert_eq!(row2, text);
2207 }
2208
2209 #[test]
2210 #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
2211 fn test_apply_osc8_diff_between_renders() {
2212 use ratatui::buffer::Buffer;
2213 use ratatui::layout::Rect;
2214
2215 let area = Rect::new(0, 0, 40, 1);
2218
2219 let concealed = "Quick Install";
2221 let mut frame1 = Buffer::empty(area);
2222 for (i, ch) in concealed.chars().enumerate() {
2223 frame1[(i as u16, 0)].set_symbol(&ch.to_string());
2224 }
2225 apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2227
2228 let prev = Buffer::empty(area);
2230 let mut backend = Buffer::empty(area);
2231 let diff1 = prev.diff(&frame1);
2232 for (x, y, cell) in &diff1 {
2233 backend[(*x, *y)] = (*cell).clone();
2234 }
2235
2236 let full = "[Quick Install](#installation)";
2238 let mut frame2 = Buffer::empty(area);
2239 for (i, ch) in full.chars().enumerate() {
2240 if (i as u16) < 40 {
2241 frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2242 }
2243 }
2244 apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2246
2247 let diff2 = frame1.diff(&frame2);
2249 for (x, y, cell) in &diff2 {
2250 backend[(*x, *y)] = (*cell).clone();
2251 }
2252
2253 let row = read_row(&backend, 0);
2255 assert_eq!(
2256 row, full,
2257 "After diff-based update from concealed to unconcealed, \
2258 backend should show full text"
2259 );
2260
2261 let cell14 = strip_osc8(backend[(14, 0)].symbol());
2263 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
2264 }
2265
2266 fn render_with_highlight_option(
2269 content: &str,
2270 cursor_pos: usize,
2271 highlight_current_line: bool,
2272 ) -> LineRenderOutput {
2273 let mut state = EditorState::new(20, 6, 1024, test_fs());
2274 state.buffer = Buffer::from_str(content, 1024, test_fs());
2275 let mut cursors = crate::model::cursor::Cursors::new();
2276 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
2277 let viewport = Viewport::new(20, 4);
2278 state.margins.left_config.enabled = false;
2279
2280 let render_area = Rect::new(0, 0, 20, 4);
2281 let visible_count = viewport.visible_line_count();
2282 let gutter_width = state.margins.left_total_width();
2283 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
2284 let empty_folds = FoldManager::new();
2285
2286 let view_data = build_view_data(
2287 &mut state,
2288 &viewport,
2289 None,
2290 content.len().max(1),
2291 visible_count,
2292 false,
2293 render_area.width as usize,
2294 gutter_width,
2295 &ViewMode::Source,
2296 &empty_folds,
2297 &theme,
2298 );
2299 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
2300
2301 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
2302 state.margins.update_width_for_buffer(estimated_lines, true);
2303 let gutter_width = state.margins.left_total_width();
2304
2305 let selection = selection_context(&state, &cursors);
2306 let _ = state
2307 .buffer
2308 .populate_line_cache(viewport.top_byte, visible_count);
2309 let viewport_start = viewport.top_byte;
2310 let viewport_end = calculate_viewport_end(
2311 &mut state,
2312 viewport_start,
2313 content.len().max(1),
2314 visible_count,
2315 );
2316 let decorations = decoration_context(
2317 &mut state,
2318 viewport_start,
2319 viewport_end,
2320 selection.primary_cursor_position,
2321 &empty_folds,
2322 &theme,
2323 100_000,
2324 &ViewMode::Source,
2325 false,
2326 &[],
2327 );
2328
2329 render_view_lines(LineRenderInput {
2330 state: &state,
2331 theme: &theme,
2332 view_lines: &view_data.lines,
2333 view_anchor,
2334 render_area,
2335 gutter_width,
2336 selection: &selection,
2337 decorations: &decorations,
2338 visible_line_count: visible_count,
2339 lsp_waiting: false,
2340 is_active: true,
2341 line_wrap: viewport.line_wrap_enabled,
2342 estimated_lines,
2343 left_column: viewport.left_column,
2344 relative_line_numbers: false,
2345 session_mode: false,
2346 software_cursor_only: false,
2347 show_line_numbers: false,
2348 byte_offset_mode: false,
2349 show_tilde: true,
2350 highlight_current_line,
2351 cell_theme_map: &mut Vec::new(),
2352 screen_width: 0,
2353 })
2354 }
2355
2356 fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
2358 let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
2359 if let Some(line) = output.lines.get(line_idx) {
2360 line.spans
2361 .iter()
2362 .any(|span| span.style.bg == Some(current_line_bg))
2363 } else {
2364 false
2365 }
2366 }
2367
2368 #[test]
2369 fn current_line_highlight_enabled_highlights_cursor_line() {
2370 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
2371 assert!(
2373 line_has_current_line_bg(&output, 0),
2374 "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
2375 );
2376 assert!(
2378 !line_has_current_line_bg(&output, 1),
2379 "Non-cursor line (line 1) should NOT have current_line_bg"
2380 );
2381 }
2382
2383 #[test]
2384 fn current_line_highlight_disabled_no_highlight() {
2385 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
2386 assert!(
2388 !line_has_current_line_bg(&output, 0),
2389 "Cursor line should NOT have current_line_bg when highlighting is disabled"
2390 );
2391 assert!(
2392 !line_has_current_line_bg(&output, 1),
2393 "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
2394 );
2395 }
2396
2397 #[test]
2398 fn current_line_highlight_follows_cursor_position() {
2399 let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
2401 assert!(
2402 !line_has_current_line_bg(&output, 0),
2403 "Line 0 should NOT have current_line_bg when cursor is on line 1"
2404 );
2405 assert!(
2406 line_has_current_line_bg(&output, 1),
2407 "Line 1 should have current_line_bg when cursor is there"
2408 );
2409 assert!(
2410 !line_has_current_line_bg(&output, 2),
2411 "Line 2 should NOT have current_line_bg when cursor is on line 1"
2412 );
2413 }
2414
2415 #[test]
2422 fn wrap_str_to_width_matches_apply_wrapping_transform() {
2423 use crate::primitives::visual_layout::wrap_str_to_width;
2424 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2425
2426 let cases: &[(&str, usize)] = &[
2430 ("hello world how are you today friend", 12),
2431 ("the quick brown fox jumps over the lazy dog", 18),
2432 ("https://example.com/very-long-path/file", 24),
2433 (&"x".repeat(120), 32),
2434 (&"abc ".repeat(40), 25),
2435 ("dialog.getButton(...).setOnClickListener", 24),
2436 ];
2437
2438 for &(text, wrap_width) in cases {
2439 let helper_chunks = wrap_str_to_width(text, wrap_width);
2441 let helper_strings: Vec<&str> =
2442 helper_chunks.iter().map(|r| &text[r.clone()]).collect();
2443
2444 let tokens = vec![ViewTokenWire {
2449 kind: ViewTokenWireKind::Text(text.to_string()),
2450 source_offset: Some(0),
2451 style: None,
2452 }];
2453 let wrapped = apply_wrapping_transform(tokens, wrap_width, 0, false);
2454
2455 let mut transform_strings: Vec<String> = Vec::new();
2460 for tok in &wrapped {
2461 match &tok.kind {
2462 ViewTokenWireKind::Text(t) => transform_strings.push(t.clone()),
2463 ViewTokenWireKind::Break => {}
2464 other => panic!("unexpected token kind in agreement test: {:?}", other),
2465 }
2466 }
2467
2468 assert_eq!(
2469 transform_strings
2470 .iter()
2471 .map(String::as_str)
2472 .collect::<Vec<_>>(),
2473 helper_strings,
2474 "wrap mismatch for text={text:?} wrap_width={wrap_width}",
2475 );
2476 }
2477 }
2478}