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 preview_buffer: Option<BufferId>,
63 event_logs: &mut HashMap<BufferId, EventLog>,
64 composite_buffers: &mut HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
65 composite_view_states: &mut HashMap<
66 (LeafId, BufferId),
67 crate::view::composite_view::CompositeViewState,
68 >,
69 theme: &crate::view::theme::Theme,
70 ansi_background: Option<&AnsiBackground>,
71 background_fade: f32,
72 lsp_waiting: bool,
73 large_file_threshold_bytes: u64,
74 line_wrap: bool,
75 estimated_line_length: usize,
76 highlight_context_bytes: usize,
77 split_view_states: Option<&mut HashMap<LeafId, crate::view::split::SplitViewState>>,
78 grouped_subtrees: &HashMap<LeafId, crate::view::split::SplitNode>,
79 hide_cursor: bool,
80 hovered_tab: Option<(crate::view::split::TabTarget, LeafId, bool)>,
81 hovered_close_split: Option<LeafId>,
82 hovered_maximize_split: Option<LeafId>,
83 is_maximized: bool,
84 relative_line_numbers: bool,
85 tab_bar_visible: bool,
86 use_terminal_bg: bool,
87 session_mode: bool,
88 software_cursor_only: bool,
89 show_vertical_scrollbar: bool,
90 show_horizontal_scrollbar: bool,
91 diagnostics_inline_text: bool,
92 show_tilde: bool,
93 highlight_current_column: bool,
94 hide_current_line_on_selection: bool,
95 cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
96 screen_width: u16,
97 pending_hardware_cursor: &mut Option<(u16, u16)>,
98 draw_tab_bar: bool,
101 ) -> (
102 Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
103 HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
104 Vec<(LeafId, u16, u16, u16)>,
105 Vec<(LeafId, u16, u16, u16)>,
106 HashMap<LeafId, Vec<ViewLineMapping>>,
107 Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
108 Vec<(
109 crate::model::event::ContainerId,
110 SplitDirection,
111 u16,
112 u16,
113 u16,
114 )>,
115 ) {
116 orchestration::render_content(
117 frame,
118 area,
119 split_manager,
120 buffers,
121 buffer_metadata,
122 preview_buffer,
123 event_logs,
124 composite_buffers,
125 composite_view_states,
126 theme,
127 ansi_background,
128 background_fade,
129 lsp_waiting,
130 large_file_threshold_bytes,
131 line_wrap,
132 estimated_line_length,
133 highlight_context_bytes,
134 split_view_states,
135 grouped_subtrees,
136 hide_cursor,
137 hovered_tab,
138 hovered_close_split,
139 hovered_maximize_split,
140 is_maximized,
141 relative_line_numbers,
142 tab_bar_visible,
143 use_terminal_bg,
144 session_mode,
145 software_cursor_only,
146 show_vertical_scrollbar,
147 show_horizontal_scrollbar,
148 diagnostics_inline_text,
149 show_tilde,
150 highlight_current_column,
151 hide_current_line_on_selection,
152 cell_theme_map,
153 screen_width,
154 pending_hardware_cursor,
155 draw_tab_bar,
156 )
157 }
158
159 #[allow(clippy::too_many_arguments)]
160 pub fn compute_content_layout(
161 area: Rect,
162 split_manager: &SplitManager,
163 buffers: &mut HashMap<BufferId, EditorState>,
164 split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
165 theme: &crate::view::theme::Theme,
166 lsp_waiting: bool,
167 estimated_line_length: usize,
168 highlight_context_bytes: usize,
169 relative_line_numbers: bool,
170 use_terminal_bg: bool,
171 session_mode: bool,
172 software_cursor_only: bool,
173 tab_bar_visible: bool,
174 show_vertical_scrollbar: bool,
175 show_horizontal_scrollbar: bool,
176 diagnostics_inline_text: bool,
177 show_tilde: bool,
178 ) -> HashMap<LeafId, Vec<ViewLineMapping>> {
179 orchestration::compute_content_layout(
180 area,
181 split_manager,
182 buffers,
183 split_view_states,
184 theme,
185 lsp_waiting,
186 estimated_line_length,
187 highlight_context_bytes,
188 relative_line_numbers,
189 use_terminal_bg,
190 session_mode,
191 software_cursor_only,
192 tab_bar_visible,
193 show_vertical_scrollbar,
194 show_horizontal_scrollbar,
195 diagnostics_inline_text,
196 show_tilde,
197 )
198 }
199
200 #[allow(clippy::too_many_arguments)]
210 pub fn render_phantom_leaf(
211 frame: &mut Frame,
212 state: &mut EditorState,
213 cursors: &crate::model::cursor::Cursors,
214 viewport: &mut crate::view::viewport::Viewport,
215 folds: &mut crate::view::folding::FoldManager,
216 event_log: Option<&mut EventLog>,
217 area: Rect,
218 theme: &crate::view::theme::Theme,
219 ansi_background: Option<&AnsiBackground>,
220 background_fade: f32,
221 view_mode: crate::state::ViewMode,
222 compose_width: Option<u16>,
223 compose_column_guides: Option<Vec<u16>>,
224 view_transform: Option<crate::services::plugins::api::ViewTransformPayload>,
225 estimated_line_length: usize,
226 highlight_context_bytes: usize,
227 buffer_id: BufferId,
228 relative_line_numbers: bool,
229 use_terminal_bg: bool,
230 session_mode: bool,
231 software_cursor_only: bool,
232 rulers: &[usize],
233 show_line_numbers: bool,
234 highlight_current_line: bool,
235 diagnostics_inline_text: bool,
236 show_tilde: bool,
237 highlight_current_column: bool,
238 cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
239 screen_width: u16,
240 ) -> Vec<crate::app::types::ViewLineMapping> {
241 let mut sink: Option<(u16, u16)> = None;
250 orchestration::render_buffer_in_split(
251 frame,
252 state,
253 cursors,
254 viewport,
255 folds,
256 event_log,
257 area,
258 false,
259 theme,
260 ansi_background,
261 background_fade,
262 false,
263 view_mode,
264 compose_width,
265 compose_column_guides,
266 view_transform,
267 estimated_line_length,
268 highlight_context_bytes,
269 buffer_id,
270 true,
271 relative_line_numbers,
272 use_terminal_bg,
273 session_mode,
274 software_cursor_only,
275 rulers,
276 show_line_numbers,
277 highlight_current_line,
278 diagnostics_inline_text,
279 show_tilde,
280 highlight_current_column,
281 cell_theme_map,
282 screen_width,
283 &mut sink,
284 )
285 }
286
287 pub fn build_base_tokens_for_hook(
290 buffer: &mut Buffer,
291 top_byte: usize,
292 estimated_line_length: usize,
293 visible_count: usize,
294 is_binary: bool,
295 line_ending: crate::model::buffer::LineEnding,
296 ) -> Vec<fresh_core::api::ViewTokenWire> {
297 orchestration::build_base_tokens_for_hook(
298 buffer,
299 top_byte,
300 estimated_line_length,
301 visible_count,
302 is_binary,
303 line_ending,
304 )
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::folding::fold_indicators_for_viewport;
311 use super::layout::{calculate_view_anchor, calculate_viewport_end};
312 use super::orchestration::overlays::{decoration_context, selection_context};
313 use super::orchestration::render_buffer::resolve_cursor_fallback;
314 use super::orchestration::render_line::{
315 render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
316 };
317 use super::post_pass::apply_osc8_to_cells;
318 use super::transforms::apply_wrapping_transform;
319 use super::view_data::build_view_data;
320 use super::*;
321
322 use crate::model::buffer::{Buffer, LineEnding};
323 use crate::model::filesystem::StdFileSystem;
324 use crate::primitives::display_width::str_width;
325 use crate::state::{EditorState, ViewMode};
326 use crate::view::folding::FoldManager;
327 use crate::view::theme;
328 use crate::view::theme::Theme;
329 use crate::view::ui::view_pipeline::{LineStart, ViewLine};
330 use crate::view::viewport::Viewport;
331 use fresh_core::api::ViewTokenWire;
332 use lsp_types::FoldingRange;
333 use std::collections::HashSet;
334 use std::sync::Arc;
335
336 fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
337 Arc::new(StdFileSystem)
338 }
339
340 fn render_output_for(
341 content: &str,
342 cursor_pos: usize,
343 ) -> (LineRenderOutput, usize, bool, usize) {
344 render_output_for_with_gutters(content, cursor_pos, false)
345 }
346
347 fn render_output_for_with_gutters(
348 content: &str,
349 cursor_pos: usize,
350 gutters_enabled: bool,
351 ) -> (LineRenderOutput, usize, bool, usize) {
352 let mut state = EditorState::new(20, 6, 1024, test_fs());
353 state.buffer = Buffer::from_str(content, 1024, test_fs());
354 let mut cursors = crate::model::cursor::Cursors::new();
355 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
356 let viewport = Viewport::new(20, 4);
358 state.margins.left_config.enabled = gutters_enabled;
360
361 let render_area = Rect::new(0, 0, 20, 4);
362 let visible_count = viewport.visible_line_count();
363 let gutter_width = state.margins.left_total_width();
364 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
365 let empty_folds = FoldManager::new();
366
367 let view_data = build_view_data(
368 &mut state,
369 &viewport,
370 None,
371 content.len().max(1),
372 visible_count,
373 false, render_area.width as usize,
375 gutter_width,
376 &ViewMode::Source, &empty_folds,
378 &theme,
379 );
380 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
381
382 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
383 state.margins.update_width_for_buffer(estimated_lines, true);
384 let gutter_width = state.margins.left_total_width();
385
386 let selection = selection_context(&state, &cursors);
387 let _ = state
388 .buffer
389 .populate_line_cache(viewport.top_byte, visible_count);
390 let viewport_start = viewport.top_byte;
391 let viewport_end = calculate_viewport_end(
392 &mut state,
393 viewport_start,
394 content.len().max(1),
395 visible_count,
396 );
397 let decorations = decoration_context(
398 &mut state,
399 viewport_start,
400 viewport_end,
401 selection.primary_cursor_position,
402 &empty_folds,
403 &theme,
404 100_000, &ViewMode::Source, false, &[],
408 );
409
410 let mut dummy_theme_map = Vec::new();
411 let output = render_view_lines(LineRenderInput {
412 state: &state,
413 theme: &theme,
414 view_lines: &view_data.lines,
415 view_anchor,
416 render_area,
417 gutter_width,
418 selection: &selection,
419 decorations: &decorations,
420 visible_line_count: visible_count,
421 lsp_waiting: false,
422 is_active: true,
423 line_wrap: viewport.line_wrap_enabled,
424 estimated_lines,
425 left_column: viewport.left_column,
426 relative_line_numbers: false,
427 session_mode: false,
428 software_cursor_only: false,
429 show_line_numbers: true, byte_offset_mode: false, show_tilde: true,
432 highlight_current_line: true,
433 cell_theme_map: &mut dummy_theme_map,
434 screen_width: 0,
435 });
436
437 (
438 output,
439 state.buffer.len(),
440 content.ends_with('\n'),
441 selection.primary_cursor_position,
442 )
443 }
444
445 #[test]
446 fn test_folding_hides_lines_and_adds_placeholder() {
447 let content = "header\nline1\nline2\ntail\n";
448 let mut state = EditorState::new(40, 6, 1024, test_fs());
449 state.buffer = Buffer::from_str(content, 1024, test_fs());
450
451 let start = state.buffer.line_start_offset(1).unwrap();
452 let end = state.buffer.line_start_offset(3).unwrap();
453 let mut folds = FoldManager::new();
454 folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
455
456 let viewport = Viewport::new(40, 6);
457 let gutter_width = state.margins.left_total_width();
458 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
459 let view_data = build_view_data(
460 &mut state,
461 &viewport,
462 None,
463 content.len().max(1),
464 viewport.visible_line_count(),
465 false,
466 40,
467 gutter_width,
468 &ViewMode::Source,
469 &folds,
470 &theme,
471 );
472
473 let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
474 assert!(lines.iter().any(|l| l.contains("header")));
475 assert!(lines.iter().any(|l| l.contains("tail")));
476 assert!(!lines.iter().any(|l| l.contains("line1")));
477 assert!(!lines.iter().any(|l| l.contains("line2")));
478 assert!(lines
479 .iter()
480 .any(|l| l.contains("header") && l.contains("...")));
481 }
482
483 #[test]
484 fn test_fold_indicators_collapsed_and_expanded() {
485 let content = "a\nb\nc\nd\n";
486 let mut state = EditorState::new(40, 6, 1024, test_fs());
487 state.buffer = Buffer::from_str(content, 1024, test_fs());
488
489 let lsp_ranges = vec![
490 FoldingRange {
491 start_line: 0,
492 end_line: 1,
493 start_character: None,
494 end_character: None,
495 kind: None,
496 collapsed_text: None,
497 },
498 FoldingRange {
499 start_line: 1,
500 end_line: 2,
501 start_character: None,
502 end_character: None,
503 kind: None,
504 collapsed_text: None,
505 },
506 ];
507 state
508 .folding_ranges
509 .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
510
511 let start = state.buffer.line_start_offset(1).unwrap();
512 let end = state.buffer.line_start_offset(2).unwrap();
513 let mut folds = FoldManager::new();
514 folds.add(&mut state.marker_list, start, end, None);
515
516 let line1_byte = state.buffer.line_start_offset(1).unwrap();
517 let view_lines = vec![ViewLine {
518 text: "b\n".to_string(),
519 source_start_byte: Some(line1_byte),
520 char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
521 char_styles: vec![None, None],
522 char_visual_cols: vec![0, 1],
523 visual_to_char: vec![0, 1],
524 tab_starts: HashSet::new(),
525 line_start: LineStart::AfterSourceNewline,
526 ends_with_newline: true,
527 virtual_gutter_glyph: None,
528 virtual_line_style: None,
529 }];
530
531 let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
532
533 assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
535 assert_eq!(
537 indicators.get(&line1_byte).map(|i| i.collapsed),
538 Some(false)
539 );
540 }
541
542 #[test]
543 fn last_line_end_tracks_trailing_newline() {
544 let output = render_output_for("abc\n", 4);
545 assert_eq!(
546 output.0.last_line_end,
547 Some(LastLineEnd {
548 pos: (3, 0),
549 terminated_with_newline: true
550 })
551 );
552 }
553
554 #[test]
555 fn last_line_end_tracks_no_trailing_newline() {
556 let output = render_output_for("abc", 3);
557 assert_eq!(
558 output.0.last_line_end,
559 Some(LastLineEnd {
560 pos: (3, 0),
561 terminated_with_newline: false
562 })
563 );
564 }
565
566 #[test]
567 fn cursor_after_newline_places_on_next_line() {
568 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
569 let cursor = resolve_cursor_fallback(
570 output.cursor,
571 cursor_pos,
572 buffer_len,
573 buffer_newline,
574 output.last_line_end,
575 output.content_lines_rendered,
576 0, );
578 assert_eq!(cursor, Some((0, 1)));
579 }
580
581 #[test]
582 fn cursor_at_end_without_newline_stays_on_line() {
583 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
584 let cursor = resolve_cursor_fallback(
585 output.cursor,
586 cursor_pos,
587 buffer_len,
588 buffer_newline,
589 output.last_line_end,
590 output.content_lines_rendered,
591 0, );
593 assert_eq!(cursor, Some((3, 0)));
594 }
595
596 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
602 let mut cursor_positions = Vec::new();
603
604 let primary_cursor = output.cursor;
606 if let Some(cursor_pos) = primary_cursor {
607 cursor_positions.push(cursor_pos);
608 }
609
610 for (line_idx, line) in output.lines.iter().enumerate() {
612 let mut col = 0u16;
613 for span in line.spans.iter() {
614 if span
616 .style
617 .add_modifier
618 .contains(ratatui::style::Modifier::REVERSED)
619 {
620 let pos = (col, line_idx as u16);
621 if primary_cursor != Some(pos) {
624 cursor_positions.push(pos);
625 }
626 }
627 col += str_width(&span.content) as u16;
629 }
630 }
631
632 cursor_positions
633 }
634
635 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
637 eprintln!("\n=== RENDER DEBUG ===");
638 eprintln!("Content: {:?}", content);
639 eprintln!("Cursor position: {}", cursor_pos);
640 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
641 eprintln!("Last line end: {:?}", output.last_line_end);
642 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
643 eprintln!("\nRendered lines:");
644 for (line_idx, line) in output.lines.iter().enumerate() {
645 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
646 for (span_idx, span) in line.spans.iter().enumerate() {
647 let has_reversed = span
648 .style
649 .add_modifier
650 .contains(ratatui::style::Modifier::REVERSED);
651 let bg_color = format!("{:?}", span.style.bg);
652 eprintln!(
653 " Span {}: {:?} (REVERSED: {}, BG: {})",
654 span_idx, span.content, has_reversed, bg_color
655 );
656 }
657 }
658 eprintln!("===================\n");
659 }
660
661 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
664 let (output, buffer_len, buffer_newline, cursor_pos) =
665 render_output_for(content, cursor_pos);
666
667 let all_cursors = count_all_cursors(&output);
669
670 assert!(
673 all_cursors.len() <= 1,
674 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
675 all_cursors.len(),
676 all_cursors
677 );
678
679 let final_cursor = resolve_cursor_fallback(
680 output.cursor,
681 cursor_pos,
682 buffer_len,
683 buffer_newline,
684 output.last_line_end,
685 output.content_lines_rendered,
686 0, );
688
689 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
691 {
692 dump_render_output(content, cursor_pos, &output);
693 }
694
695 if let Some(rendered_cursor) = all_cursors.first() {
697 assert_eq!(
698 Some(*rendered_cursor),
699 final_cursor,
700 "Rendered cursor at {:?} doesn't match final cursor {:?}",
701 rendered_cursor,
702 final_cursor
703 );
704 }
705
706 assert!(
708 final_cursor.is_some(),
709 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
710 all_cursors
711 );
712
713 final_cursor
714 }
715
716 fn check_typing_at_cursor(
718 content: &str,
719 cursor_pos: usize,
720 char_to_type: char,
721 ) -> (Option<(u16, u16)>, String) {
722 let cursor_before = get_final_cursor(content, cursor_pos);
724
725 let mut new_content = content.to_string();
727 if cursor_pos <= content.len() {
728 new_content.insert(cursor_pos, char_to_type);
729 }
730
731 (cursor_before, new_content)
732 }
733
734 #[test]
735 fn e2e_cursor_at_start_of_nonempty_line() {
736 let cursor = get_final_cursor("abc", 0);
738 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
739
740 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
741 assert_eq!(
742 new_content, "Xabc",
743 "Typing should insert at cursor position"
744 );
745 assert_eq!(cursor_pos, Some((0, 0)));
746 }
747
748 #[test]
749 fn e2e_cursor_in_middle_of_line() {
750 let cursor = get_final_cursor("abc", 1);
752 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
753
754 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
755 assert_eq!(
756 new_content, "aXbc",
757 "Typing should insert at cursor position"
758 );
759 assert_eq!(cursor_pos, Some((1, 0)));
760 }
761
762 #[test]
763 fn e2e_cursor_at_end_of_line_no_newline() {
764 let cursor = get_final_cursor("abc", 3);
766 assert_eq!(
767 cursor,
768 Some((3, 0)),
769 "Cursor should be at column 3, line 0 (after last char)"
770 );
771
772 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
773 assert_eq!(new_content, "abcX", "Typing should append at end");
774 assert_eq!(cursor_pos, Some((3, 0)));
775 }
776
777 #[test]
778 fn e2e_cursor_at_empty_line() {
779 let cursor = get_final_cursor("\n", 0);
781 assert_eq!(
782 cursor,
783 Some((0, 0)),
784 "Cursor on empty line should be at column 0"
785 );
786
787 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
788 assert_eq!(new_content, "X\n", "Typing should insert before newline");
789 assert_eq!(cursor_pos, Some((0, 0)));
790 }
791
792 #[test]
793 fn e2e_cursor_after_newline_at_eof() {
794 let cursor = get_final_cursor("abc\n", 4);
796 assert_eq!(
797 cursor,
798 Some((0, 1)),
799 "Cursor after newline at EOF should be on next line"
800 );
801
802 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
803 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
804 assert_eq!(cursor_pos, Some((0, 1)));
805 }
806
807 #[test]
808 fn e2e_cursor_on_newline_with_content() {
809 let cursor = get_final_cursor("abc\n", 3);
811 assert_eq!(
812 cursor,
813 Some((3, 0)),
814 "Cursor on newline after content should be after last char"
815 );
816
817 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
818 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
819 assert_eq!(cursor_pos, Some((3, 0)));
820 }
821
822 #[test]
823 fn e2e_cursor_multiline_start_of_second_line() {
824 let cursor = get_final_cursor("abc\ndef", 4);
826 assert_eq!(
827 cursor,
828 Some((0, 1)),
829 "Cursor at start of second line should be at column 0, line 1"
830 );
831
832 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
833 assert_eq!(
834 new_content, "abc\nXdef",
835 "Typing should insert at start of second line"
836 );
837 assert_eq!(cursor_pos, Some((0, 1)));
838 }
839
840 #[test]
841 fn e2e_cursor_multiline_end_of_first_line() {
842 let cursor = get_final_cursor("abc\ndef", 3);
844 assert_eq!(
845 cursor,
846 Some((3, 0)),
847 "Cursor on newline of first line should be after content"
848 );
849
850 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
851 assert_eq!(
852 new_content, "abcX\ndef",
853 "Typing should insert before newline"
854 );
855 assert_eq!(cursor_pos, Some((3, 0)));
856 }
857
858 #[test]
859 fn e2e_cursor_empty_buffer() {
860 let cursor = get_final_cursor("", 0);
862 assert_eq!(
863 cursor,
864 Some((0, 0)),
865 "Cursor in empty buffer should be at origin"
866 );
867
868 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
869 assert_eq!(
870 new_content, "X",
871 "Typing in empty buffer should insert character"
872 );
873 assert_eq!(cursor_pos, Some((0, 0)));
874 }
875
876 #[test]
877 fn e2e_cursor_empty_buffer_with_gutters() {
878 let (output, buffer_len, buffer_newline, cursor_pos) =
882 render_output_for_with_gutters("", 0, true);
883
884 let gutter_width = {
888 let mut state = EditorState::new(20, 6, 1024, test_fs());
889 state.margins.left_config.enabled = true;
890 state.margins.update_width_for_buffer(1, true);
891 state.margins.left_total_width()
892 };
893 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
894
895 assert_eq!(
899 output.cursor,
900 Some((gutter_width as u16, 0)),
901 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
902 gutter_width,
903 output.cursor
904 );
905
906 let final_cursor = resolve_cursor_fallback(
907 output.cursor,
908 cursor_pos,
909 buffer_len,
910 buffer_newline,
911 output.last_line_end,
912 output.content_lines_rendered,
913 gutter_width,
914 );
915
916 assert_eq!(
918 final_cursor,
919 Some((gutter_width as u16, 0)),
920 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
921 );
922 }
923
924 #[test]
925 fn e2e_cursor_between_empty_lines() {
926 let cursor = get_final_cursor("\n\n", 1);
928 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
929
930 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
931 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
932 assert_eq!(cursor_pos, Some((0, 1)));
933 }
934
935 #[test]
936 fn e2e_cursor_at_eof_after_multiple_lines() {
937 let cursor = get_final_cursor("abc\ndef\nghi", 11);
939 assert_eq!(
940 cursor,
941 Some((3, 2)),
942 "Cursor at EOF after 'i' should be at column 3, line 2"
943 );
944
945 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
946 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
947 assert_eq!(cursor_pos, Some((3, 2)));
948 }
949
950 #[test]
951 fn e2e_cursor_at_eof_with_trailing_newline() {
952 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
954 assert_eq!(
955 cursor,
956 Some((0, 3)),
957 "Cursor after trailing newline should be on line 3"
958 );
959
960 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
961 assert_eq!(
962 new_content, "abc\ndef\nghi\nX",
963 "Typing should insert on new line"
964 );
965 assert_eq!(cursor_pos, Some((0, 3)));
966 }
967
968 #[test]
969 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
970 let content = "abc\ndef\nghi";
972
973 let cursor_at_start = get_final_cursor(content, 0);
975 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
976
977 let cursor_at_eof = get_final_cursor(content, 11);
979 assert_eq!(
980 cursor_at_eof,
981 Some((3, 2)),
982 "After Ctrl+End, cursor at column 3, line 2"
983 );
984
985 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
987 assert_eq!(cursor_before_typing, Some((3, 2)));
988 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
989
990 let cursor_after_typing = get_final_cursor(&new_content, 12);
992 assert_eq!(
993 cursor_after_typing,
994 Some((4, 2)),
995 "After typing, cursor moved to column 4"
996 );
997
998 let cursor_moved_away = get_final_cursor(&new_content, 0);
1000 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
1001 }
1004
1005 #[test]
1006 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
1007 let content = "abc\ndef\nghi\n";
1009
1010 let cursor_at_start = get_final_cursor(content, 0);
1012 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
1013
1014 let cursor_at_eof = get_final_cursor(content, 12);
1016 assert_eq!(
1017 cursor_at_eof,
1018 Some((0, 3)),
1019 "After Ctrl+End, cursor at column 0, line 3 (new line)"
1020 );
1021
1022 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
1024 assert_eq!(cursor_before_typing, Some((0, 3)));
1025 assert_eq!(
1026 new_content, "abc\ndef\nghi\nX",
1027 "Character inserted on new line"
1028 );
1029
1030 let cursor_after_typing = get_final_cursor(&new_content, 13);
1032 assert_eq!(
1033 cursor_after_typing,
1034 Some((1, 3)),
1035 "After typing, cursor should be at column 1, line 3"
1036 );
1037
1038 let cursor_moved_away = get_final_cursor(&new_content, 4);
1040 assert_eq!(
1041 cursor_moved_away,
1042 Some((0, 1)),
1043 "Cursor moved to start of line 1 (position 4 = start of 'def')"
1044 );
1045 }
1046
1047 #[test]
1048 fn e2e_jump_to_end_of_empty_buffer() {
1049 let content = "";
1051
1052 let cursor_at_eof = get_final_cursor(content, 0);
1053 assert_eq!(
1054 cursor_at_eof,
1055 Some((0, 0)),
1056 "Empty buffer: cursor at origin"
1057 );
1058
1059 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
1061 assert_eq!(cursor_before_typing, Some((0, 0)));
1062 assert_eq!(new_content, "X", "Character inserted");
1063
1064 let cursor_after_typing = get_final_cursor(&new_content, 1);
1066 assert_eq!(
1067 cursor_after_typing,
1068 Some((1, 0)),
1069 "After typing, cursor at column 1"
1070 );
1071
1072 let cursor_moved_away = get_final_cursor(&new_content, 0);
1074 assert_eq!(
1075 cursor_moved_away,
1076 Some((0, 0)),
1077 "Cursor moved back to start"
1078 );
1079 }
1080
1081 #[test]
1082 fn e2e_jump_to_end_of_single_empty_line() {
1083 let content = "\n";
1085
1086 let cursor_on_newline = get_final_cursor(content, 0);
1088 assert_eq!(
1089 cursor_on_newline,
1090 Some((0, 0)),
1091 "Cursor on the newline character"
1092 );
1093
1094 let cursor_at_eof = get_final_cursor(content, 1);
1096 assert_eq!(
1097 cursor_at_eof,
1098 Some((0, 1)),
1099 "After Ctrl+End, cursor on line 1"
1100 );
1101
1102 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1104 assert_eq!(cursor_before_typing, Some((0, 1)));
1105 assert_eq!(new_content, "\nX", "Character on second line");
1106
1107 let cursor_after_typing = get_final_cursor(&new_content, 2);
1108 assert_eq!(
1109 cursor_after_typing,
1110 Some((1, 1)),
1111 "After typing, cursor at column 1, line 1"
1112 );
1113
1114 let cursor_moved_away = get_final_cursor(&new_content, 0);
1116 assert_eq!(
1117 cursor_moved_away,
1118 Some((0, 0)),
1119 "Cursor moved to the newline on line 0"
1120 );
1121 }
1122 use fresh_core::api::ViewTokenWireKind;
1133
1134 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1136 tokens
1137 .iter()
1138 .map(|t| {
1139 let kind_str = match &t.kind {
1140 ViewTokenWireKind::Text(s) => format!("Text({})", s),
1141 ViewTokenWireKind::Newline => "Newline".to_string(),
1142 ViewTokenWireKind::Space => "Space".to_string(),
1143 ViewTokenWireKind::Break => "Break".to_string(),
1144 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1145 };
1146 (kind_str, t.source_offset)
1147 })
1148 .collect()
1149 }
1150
1151 #[test]
1154 fn test_build_base_tokens_crlf_single_line() {
1155 let content = b"abc\r\n";
1157 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1158 buffer.set_line_ending(LineEnding::CRLF);
1159
1160 let tokens = SplitRenderer::build_base_tokens_for_hook(
1161 &mut buffer,
1162 0, 80, 10, false, LineEnding::CRLF,
1167 );
1168
1169 let offsets = extract_token_offsets(&tokens);
1170
1171 assert!(
1174 offsets
1175 .iter()
1176 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1177 "Expected Text(abc) at offset 0, got: {:?}",
1178 offsets
1179 );
1180 assert!(
1181 offsets
1182 .iter()
1183 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1184 "Expected Newline at offset 3 (\\r position), got: {:?}",
1185 offsets
1186 );
1187
1188 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1190 assert_eq!(
1191 newline_count, 1,
1192 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1193 newline_count, offsets
1194 );
1195 }
1196
1197 #[test]
1200 fn test_build_base_tokens_crlf_multiple_lines() {
1201 let content = b"abc\r\ndef\r\nghi\r\n";
1206 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1207 buffer.set_line_ending(LineEnding::CRLF);
1208
1209 let tokens = SplitRenderer::build_base_tokens_for_hook(
1210 &mut buffer,
1211 0,
1212 80,
1213 10,
1214 false,
1215 LineEnding::CRLF,
1216 );
1217
1218 let offsets = extract_token_offsets(&tokens);
1219
1220 assert!(
1227 offsets
1228 .iter()
1229 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1230 "Line 1: Expected Text(abc) at 0, got: {:?}",
1231 offsets
1232 );
1233 assert!(
1234 offsets
1235 .iter()
1236 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1237 "Line 1: Expected Newline at 3, got: {:?}",
1238 offsets
1239 );
1240
1241 assert!(
1243 offsets
1244 .iter()
1245 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1246 "Line 2: Expected Text(def) at 5, got: {:?}",
1247 offsets
1248 );
1249 assert!(
1250 offsets
1251 .iter()
1252 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1253 "Line 2: Expected Newline at 8, got: {:?}",
1254 offsets
1255 );
1256
1257 assert!(
1259 offsets
1260 .iter()
1261 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1262 "Line 3: Expected Text(ghi) at 10, got: {:?}",
1263 offsets
1264 );
1265 assert!(
1266 offsets
1267 .iter()
1268 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
1269 "Line 3: Expected Newline at 13, got: {:?}",
1270 offsets
1271 );
1272
1273 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1275 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
1276 }
1277
1278 #[test]
1281 fn test_build_base_tokens_lf_mode_for_comparison() {
1282 let content = b"abc\ndef\n";
1286 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1287 buffer.set_line_ending(LineEnding::LF);
1288
1289 let tokens = SplitRenderer::build_base_tokens_for_hook(
1290 &mut buffer,
1291 0,
1292 80,
1293 10,
1294 false,
1295 LineEnding::LF,
1296 );
1297
1298 let offsets = extract_token_offsets(&tokens);
1299
1300 assert!(
1302 offsets
1303 .iter()
1304 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1305 "LF Line 1: Expected Text(abc) at 0"
1306 );
1307 assert!(
1308 offsets
1309 .iter()
1310 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1311 "LF Line 1: Expected Newline at 3"
1312 );
1313 assert!(
1314 offsets
1315 .iter()
1316 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
1317 "LF Line 2: Expected Text(def) at 4"
1318 );
1319 assert!(
1320 offsets
1321 .iter()
1322 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
1323 "LF Line 2: Expected Newline at 7"
1324 );
1325 }
1326
1327 #[test]
1330 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
1331 let content = b"abc\r\n";
1333 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1334 buffer.set_line_ending(LineEnding::LF); let tokens = SplitRenderer::build_base_tokens_for_hook(
1337 &mut buffer,
1338 0,
1339 80,
1340 10,
1341 false,
1342 LineEnding::LF,
1343 );
1344
1345 let offsets = extract_token_offsets(&tokens);
1346
1347 assert!(
1349 offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
1350 "LF mode should render \\r as control char <0D>, got: {:?}",
1351 offsets
1352 );
1353 }
1354
1355 #[test]
1358 fn test_build_base_tokens_crlf_from_middle() {
1359 let content = b"abc\r\ndef\r\nghi\r\n";
1362 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1363 buffer.set_line_ending(LineEnding::CRLF);
1364
1365 let tokens = SplitRenderer::build_base_tokens_for_hook(
1366 &mut buffer,
1367 5, 80,
1369 10,
1370 false,
1371 LineEnding::CRLF,
1372 );
1373
1374 let offsets = extract_token_offsets(&tokens);
1375
1376 assert!(
1380 offsets
1381 .iter()
1382 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1383 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
1384 offsets
1385 );
1386 assert!(
1387 offsets
1388 .iter()
1389 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
1390 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
1391 offsets
1392 );
1393 }
1394
1395 #[test]
1398 fn test_crlf_highlight_span_lookup() {
1399 use crate::view::ui::view_pipeline::ViewLineIterator;
1400
1401 let content = b"int x;\r\nint y;\r\n";
1406 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1407 buffer.set_line_ending(LineEnding::CRLF);
1408
1409 let tokens = SplitRenderer::build_base_tokens_for_hook(
1411 &mut buffer,
1412 0,
1413 80,
1414 10,
1415 false,
1416 LineEnding::CRLF,
1417 );
1418
1419 let offsets = extract_token_offsets(&tokens);
1421 eprintln!("Tokens: {:?}", offsets);
1422
1423 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
1425 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
1426
1427 eprintln!(
1430 "Line 1 char_source_bytes: {:?}",
1431 view_lines[0].char_source_bytes
1432 );
1433 assert_eq!(
1434 view_lines[0].char_source_bytes.len(),
1435 7,
1436 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
1437 );
1438 assert_eq!(
1440 view_lines[0].char_source_bytes[0],
1441 Some(0),
1442 "Line 1 'i' -> byte 0"
1443 );
1444 assert_eq!(
1445 view_lines[0].char_source_bytes[4],
1446 Some(4),
1447 "Line 1 'x' -> byte 4"
1448 );
1449 assert_eq!(
1450 view_lines[0].char_source_bytes[5],
1451 Some(5),
1452 "Line 1 ';' -> byte 5"
1453 );
1454 assert_eq!(
1455 view_lines[0].char_source_bytes[6],
1456 Some(6),
1457 "Line 1 newline -> byte 6 (\\r pos)"
1458 );
1459
1460 eprintln!(
1462 "Line 2 char_source_bytes: {:?}",
1463 view_lines[1].char_source_bytes
1464 );
1465 assert_eq!(
1466 view_lines[1].char_source_bytes.len(),
1467 7,
1468 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
1469 );
1470 assert_eq!(
1472 view_lines[1].char_source_bytes[0],
1473 Some(8),
1474 "Line 2 'i' -> byte 8"
1475 );
1476 assert_eq!(
1477 view_lines[1].char_source_bytes[4],
1478 Some(12),
1479 "Line 2 'y' -> byte 12"
1480 );
1481 assert_eq!(
1482 view_lines[1].char_source_bytes[5],
1483 Some(13),
1484 "Line 2 ';' -> byte 13"
1485 );
1486 assert_eq!(
1487 view_lines[1].char_source_bytes[6],
1488 Some(14),
1489 "Line 2 newline -> byte 14 (\\r pos)"
1490 );
1491
1492 let simulated_highlight_spans = [
1496 (0usize..3usize, "keyword"),
1498 (8usize..11usize, "keyword"),
1500 ];
1501
1502 for (line_idx, view_line) in view_lines.iter().enumerate() {
1504 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
1505 if let Some(bp) = byte_pos {
1506 let in_span = simulated_highlight_spans
1507 .iter()
1508 .find(|(range, _)| range.contains(bp))
1509 .map(|(_, name)| *name);
1510
1511 let expected_in_keyword = char_idx < 3;
1513 let actually_in_keyword = in_span == Some("keyword");
1514
1515 if expected_in_keyword != actually_in_keyword {
1516 panic!(
1517 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
1518 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
1519 );
1520 }
1521 }
1522 }
1523 }
1524 }
1525
1526 #[test]
1529 fn test_apply_wrapping_transform_breaks_long_lines() {
1530 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1531
1532 let long_text = "x".repeat(25_000);
1534 let tokens = vec![
1535 ViewTokenWire {
1536 kind: ViewTokenWireKind::Text(long_text),
1537 source_offset: Some(0),
1538 style: None,
1539 },
1540 ViewTokenWire {
1541 kind: ViewTokenWireKind::Newline,
1542 source_offset: Some(25_000),
1543 style: None,
1544 },
1545 ];
1546
1547 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1549
1550 let break_count = wrapped
1552 .iter()
1553 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1554 .count();
1555
1556 assert!(
1557 break_count >= 2,
1558 "25K char line should have at least 2 breaks at 10K width, got {}",
1559 break_count
1560 );
1561
1562 let total_chars: usize = wrapped
1564 .iter()
1565 .filter_map(|t| match &t.kind {
1566 ViewTokenWireKind::Text(s) => Some(s.len()),
1567 _ => None,
1568 })
1569 .sum();
1570
1571 assert_eq!(
1572 total_chars, 25_000,
1573 "Total character count should be preserved after wrapping"
1574 );
1575 }
1576
1577 #[cfg(test)]
1603 mod wrap_boundary_property {
1604 use super::apply_wrapping_transform;
1605 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1606 use proptest::prelude::*;
1607 use unicode_segmentation::UnicodeSegmentation;
1608
1609 const MAX_LOOKBACK: usize = 16;
1613
1614 fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
1615 let mut tokens: Vec<ViewTokenWire> = Vec::new();
1616 let mut buf = String::new();
1617 let mut buf_start = 0usize;
1618 for (i, c) in input.char_indices() {
1619 if c == ' ' {
1620 if !buf.is_empty() {
1621 tokens.push(ViewTokenWire {
1622 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1623 source_offset: Some(buf_start),
1624 style: None,
1625 });
1626 }
1627 tokens.push(ViewTokenWire {
1628 kind: ViewTokenWireKind::Space,
1629 source_offset: Some(i),
1630 style: None,
1631 });
1632 buf_start = i + 1;
1633 } else {
1634 if buf.is_empty() {
1635 buf_start = i;
1636 }
1637 buf.push(c);
1638 }
1639 }
1640 if !buf.is_empty() {
1641 tokens.push(ViewTokenWire {
1642 kind: ViewTokenWireKind::Text(buf.clone()),
1643 source_offset: Some(buf_start),
1644 style: None,
1645 });
1646 }
1647 tokens.push(ViewTokenWire {
1648 kind: ViewTokenWireKind::Newline,
1649 source_offset: Some(input.len()),
1650 style: None,
1651 });
1652 tokens
1653 }
1654
1655 fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
1660 let mut rows: Vec<String> = vec![String::new()];
1661 for t in wrapped {
1662 match &t.kind {
1663 ViewTokenWireKind::Text(s) => {
1664 rows.last_mut().unwrap().push_str(s);
1665 }
1666 ViewTokenWireKind::Space => {
1667 rows.last_mut().unwrap().push(' ');
1668 }
1669 ViewTokenWireKind::Break => {
1670 rows.push(String::new());
1671 }
1672 ViewTokenWireKind::Newline => {
1673 }
1676 _ => {}
1677 }
1678 }
1679 rows
1680 }
1681
1682 proptest! {
1683 #![proptest_config(ProptestConfig {
1687 cases: 256,
1688 .. ProptestConfig::default()
1689 })]
1690
1691 #[test]
1694 fn prop_wrap_respects_boundaries(
1695 input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
1696 content_width in 5usize..40,
1697 ) {
1698 let tokens = tokens_from_input(&input);
1701 let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
1702 let rows = visual_rows(&wrapped);
1703
1704 for (i, row) in rows.iter().enumerate() {
1706 prop_assert!(
1707 row.chars().count() <= content_width,
1708 "row {i} {:?} has width {} > content_width {content_width}",
1709 row,
1710 row.chars().count(),
1711 );
1712 }
1713
1714 let reconstructed: String = rows.concat();
1716 prop_assert_eq!(
1717 &reconstructed,
1718 &input,
1719 "reconstruction differs from input"
1720 );
1721
1722 let boundaries: std::collections::BTreeSet<usize> = input
1726 .split_word_bound_indices()
1727 .map(|(i, _)| i)
1728 .chain(std::iter::once(input.len()))
1729 .collect();
1730
1731 let mut cursor_bytes = 0usize;
1732 let mut cursor_chars = 0usize;
1733 for (i, row) in rows.iter().enumerate() {
1734 let row_bytes = row.len();
1735 let row_chars = row.chars().count();
1736 let row_end_bytes = cursor_bytes + row_bytes;
1737 let row_end_chars = cursor_chars + row_chars;
1738 let is_last = i + 1 == rows.len();
1739
1740 if !is_last {
1741 let input_bytes = input.as_bytes();
1747 let prev_is_space =
1748 row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
1749 let next_is_space = row_end_bytes < input_bytes.len()
1750 && input_bytes[row_end_bytes] == b' ';
1751 let is_mid_text = !prev_is_space && !next_is_space;
1752 if !is_mid_text {
1753 cursor_bytes = row_end_bytes;
1754 cursor_chars = row_end_chars;
1755 continue;
1756 }
1757
1758 let hard_cap_chars = cursor_chars + content_width;
1761 let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
1762 let floor_chars = cursor_chars
1763 + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
1764 let floor_bytes = char_index_to_byte(&input, floor_chars);
1765
1766 let max_in_window = boundaries
1774 .range(floor_bytes..=hard_cap_bytes)
1775 .next_back()
1776 .copied();
1777 match max_in_window {
1778 Some(max_b) => {
1779 prop_assert_eq!(
1780 row_end_bytes,
1781 max_b,
1782 "split at byte {} but largest word boundary in \
1783 [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
1784 row_end_bytes,
1785 floor_bytes,
1786 hard_cap_bytes,
1787 max_b,
1788 row,
1789 input,
1790 );
1791 }
1792 None => {
1793 prop_assert_eq!(
1794 row_end_bytes,
1795 hard_cap_bytes,
1796 "no word boundary in [floor={}, hard_cap={}], so \
1797 char-split must land at hard_cap, but split is at \
1798 byte {}; row={:?}, input={:?}",
1799 floor_bytes,
1800 hard_cap_bytes,
1801 row_end_bytes,
1802 row,
1803 input,
1804 );
1805 }
1806 }
1807 }
1808
1809 cursor_bytes = row_end_bytes;
1810 cursor_chars = row_end_chars;
1811 }
1812 }
1813 }
1814
1815 fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1818 s.char_indices()
1819 .nth(char_idx)
1820 .map(|(b, _)| b)
1821 .unwrap_or(s.len())
1822 }
1823 }
1824
1825 fn tokenize_for_wrap(text: &str) -> Vec<fresh_core::api::ViewTokenWire> {
1830 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1831 let mut tokens = Vec::new();
1832 let mut buf = String::new();
1833 let mut buf_start: Option<usize> = None;
1834 for (i, ch) in text.char_indices() {
1835 if ch == ' ' {
1836 if !buf.is_empty() {
1837 tokens.push(ViewTokenWire {
1838 source_offset: buf_start,
1839 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
1840 style: None,
1841 });
1842 buf_start = None;
1843 }
1844 tokens.push(ViewTokenWire {
1845 source_offset: Some(i),
1846 kind: ViewTokenWireKind::Space,
1847 style: None,
1848 });
1849 } else {
1850 if buf.is_empty() {
1851 buf_start = Some(i);
1852 }
1853 buf.push(ch);
1854 }
1855 }
1856 if !buf.is_empty() {
1857 tokens.push(ViewTokenWire {
1858 source_offset: buf_start,
1859 kind: ViewTokenWireKind::Text(buf),
1860 style: None,
1861 });
1862 }
1863 tokens
1864 }
1865
1866 fn rows_from_wrapped(wrapped: &[fresh_core::api::ViewTokenWire]) -> Vec<String> {
1869 use fresh_core::api::ViewTokenWireKind;
1870 let mut rows: Vec<String> = vec![String::new()];
1871 for tok in wrapped {
1872 match &tok.kind {
1873 ViewTokenWireKind::Text(s) => rows.last_mut().unwrap().push_str(s),
1874 ViewTokenWireKind::Space => rows.last_mut().unwrap().push(' '),
1875 ViewTokenWireKind::Newline => {}
1876 ViewTokenWireKind::Break => rows.push(String::new()),
1877 ViewTokenWireKind::BinaryByte(_) => {}
1878 }
1879 }
1880 if rows.last().map(|r| r.is_empty()).unwrap_or(false) {
1881 rows.pop();
1882 }
1883 rows
1884 }
1885
1886 #[test]
1892 fn issue_1363_no_leading_space_on_continuation_row() {
1893 let tokens = tokenize_for_wrap("AAAAA BBBBBB CCCC");
1894 let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
1895 let rows = rows_from_wrapped(&wrapped);
1896 assert_eq!(rows.len(), 2, "expected 2 rows, got {:?}", rows);
1897 for (i, row) in rows.iter().enumerate() {
1898 assert!(
1899 !row.starts_with(' '),
1900 "row {i} {:?} starts with whitespace (issue #1363): rows = {:?}",
1901 row,
1902 rows,
1903 );
1904 assert!(
1905 row.chars().count() <= 12,
1906 "row {i} {:?} width {} exceeds eff_width 12 (issue #1363): no char may overflow",
1907 row,
1908 row.chars().count(),
1909 );
1910 }
1911 }
1912
1913 #[test]
1917 fn issue_1363_back_up_preserves_content() {
1918 let input = "AAAAA BBBBBB CCCC";
1919 let tokens = tokenize_for_wrap(input);
1920 let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
1921 let rows = rows_from_wrapped(&wrapped);
1922 let reconstructed: String = rows.concat();
1923 assert_eq!(reconstructed, input, "rows = {:?}", rows);
1924 }
1925
1926 #[test]
1933 fn issue_1363_single_word_row_falls_back() {
1934 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1935 let tokens = vec![
1936 ViewTokenWire {
1937 source_offset: Some(0),
1938 kind: ViewTokenWireKind::Text("XXXXXXXX".to_string()),
1939 style: None,
1940 },
1941 ViewTokenWire {
1942 source_offset: Some(8),
1943 kind: ViewTokenWireKind::Space,
1944 style: None,
1945 },
1946 ViewTokenWire {
1947 source_offset: Some(9),
1948 kind: ViewTokenWireKind::Text("YYYY".to_string()),
1949 style: None,
1950 },
1951 ];
1952 let wrapped = apply_wrapping_transform(tokens, 8, 0, false);
1953 let rows = rows_from_wrapped(&wrapped);
1954 for row in &rows {
1955 assert!(
1956 row.chars().count() <= 8,
1957 "row {:?} exceeds eff_width 8 in fallback case",
1958 row,
1959 );
1960 }
1961 }
1962
1963 #[test]
1965 fn test_apply_wrapping_transform_preserves_short_lines() {
1966 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
1967
1968 let short_text = "x".repeat(100);
1970 let tokens = vec![
1971 ViewTokenWire {
1972 kind: ViewTokenWireKind::Text(short_text.clone()),
1973 source_offset: Some(0),
1974 style: None,
1975 },
1976 ViewTokenWire {
1977 kind: ViewTokenWireKind::Newline,
1978 source_offset: Some(100),
1979 style: None,
1980 },
1981 ];
1982
1983 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
1985
1986 let break_count = wrapped
1988 .iter()
1989 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
1990 .count();
1991
1992 assert_eq!(
1993 break_count, 0,
1994 "Short lines should not have any breaks, got {}",
1995 break_count
1996 );
1997
1998 let text_tokens: Vec<_> = wrapped
2000 .iter()
2001 .filter_map(|t| match &t.kind {
2002 ViewTokenWireKind::Text(s) => Some(s.clone()),
2003 _ => None,
2004 })
2005 .collect();
2006
2007 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
2008 assert_eq!(
2009 text_tokens[0], short_text,
2010 "Text content should be unchanged"
2011 );
2012 }
2013
2014 #[test]
2017 fn test_large_single_line_sequential_data_preserved() {
2018 use crate::view::ui::view_pipeline::ViewLineIterator;
2019 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2020
2021 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
2025
2026 let tokens = vec![
2028 ViewTokenWire {
2029 kind: ViewTokenWireKind::Text(content.clone()),
2030 source_offset: Some(0),
2031 style: None,
2032 },
2033 ViewTokenWire {
2034 kind: ViewTokenWireKind::Newline,
2035 source_offset: Some(content.len()),
2036 style: None,
2037 },
2038 ];
2039
2040 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2042
2043 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
2045
2046 let mut reconstructed = String::new();
2048 for line in &view_lines {
2049 let text = line.text.trim_end_matches('\n');
2051 reconstructed.push_str(text);
2052 }
2053
2054 assert_eq!(
2056 reconstructed.len(),
2057 content.len(),
2058 "Reconstructed content length should match original"
2059 );
2060
2061 for i in 1..=num_markers {
2063 let marker = format!("[{:05}]", i);
2064 assert!(
2065 reconstructed.contains(&marker),
2066 "Missing marker {} after pipeline",
2067 marker
2068 );
2069 }
2070
2071 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
2073 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
2074 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
2075 assert!(
2076 pos_100 < pos_1000 && pos_1000 < pos_3000,
2077 "Markers should be in sequential order: {} < {} < {}",
2078 pos_100,
2079 pos_1000,
2080 pos_3000
2081 );
2082
2083 assert!(
2085 view_lines.len() >= 3,
2086 "35KB content should produce multiple visual lines at 10K width, got {}",
2087 view_lines.len()
2088 );
2089
2090 for (i, line) in view_lines.iter().enumerate() {
2092 assert!(
2093 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
2095 i,
2096 line.text.len()
2097 );
2098 }
2099 }
2100
2101 fn strip_osc8(s: &str) -> String {
2103 let mut result = String::with_capacity(s.len());
2104 let bytes = s.as_bytes();
2105 let mut i = 0;
2106 while i < bytes.len() {
2107 if i + 3 < bytes.len()
2108 && bytes[i] == 0x1b
2109 && bytes[i + 1] == b']'
2110 && bytes[i + 2] == b'8'
2111 && bytes[i + 3] == b';'
2112 {
2113 i += 4;
2114 while i < bytes.len() && bytes[i] != 0x07 {
2115 i += 1;
2116 }
2117 if i < bytes.len() {
2118 i += 1;
2119 }
2120 } else {
2121 result.push(bytes[i] as char);
2122 i += 1;
2123 }
2124 }
2125 result
2126 }
2127
2128 fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
2131 let width = buf.area().width;
2132 let mut s = String::new();
2133 let mut col = 0u16;
2134 while col < width {
2135 let cell = &buf[(col, y)];
2136 let stripped = strip_osc8(cell.symbol());
2137 let chars = stripped.chars().count();
2138 if chars > 1 {
2139 s.push_str(&stripped);
2140 col += chars as u16;
2141 } else {
2142 s.push_str(&stripped);
2143 col += 1;
2144 }
2145 }
2146 s.trim_end().to_string()
2147 }
2148
2149 #[test]
2150 fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
2151 use ratatui::buffer::Buffer;
2152 use ratatui::layout::Rect;
2153
2154 let text = "[Quick Install](#installation)";
2156 let area = Rect::new(0, 0, 40, 1);
2157 let mut buf = Buffer::empty(area);
2158 for (i, ch) in text.chars().enumerate() {
2159 if (i as u16) < 40 {
2160 buf[(i as u16, 0)].set_symbol(&ch.to_string());
2161 }
2162 }
2163
2164 let url = "https://example.com";
2166
2167 apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
2169
2170 let row = read_row(&buf, 0);
2171 assert_eq!(
2172 row, text,
2173 "After OSC 8 application, reading the row should reproduce the original text"
2174 );
2175
2176 let cell14 = strip_osc8(buf[(14, 0)].symbol());
2178 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
2179
2180 let cell0 = strip_osc8(buf[(0, 0)].symbol());
2182 assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
2183 }
2184
2185 #[test]
2186 fn test_apply_osc8_stable_across_reapply() {
2187 use ratatui::buffer::Buffer;
2188 use ratatui::layout::Rect;
2189
2190 let text = "[Quick Install](#installation)";
2191 let area = Rect::new(0, 0, 40, 1);
2192
2193 let mut buf1 = Buffer::empty(area);
2195 for (i, ch) in text.chars().enumerate() {
2196 if (i as u16) < 40 {
2197 buf1[(i as u16, 0)].set_symbol(&ch.to_string());
2198 }
2199 }
2200 apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
2201 let row1 = read_row(&buf1, 0);
2202
2203 let mut buf2 = Buffer::empty(area);
2205 for (i, ch) in text.chars().enumerate() {
2206 if (i as u16) < 40 {
2207 buf2[(i as u16, 0)].set_symbol(&ch.to_string());
2208 }
2209 }
2210 apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
2211 let row2 = read_row(&buf2, 0);
2212
2213 assert_eq!(row1, text);
2214 assert_eq!(row2, text);
2215 }
2216
2217 #[test]
2218 #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
2219 fn test_apply_osc8_diff_between_renders() {
2220 use ratatui::buffer::Buffer;
2221 use ratatui::layout::Rect;
2222
2223 let area = Rect::new(0, 0, 40, 1);
2226
2227 let concealed = "Quick Install";
2229 let mut frame1 = Buffer::empty(area);
2230 for (i, ch) in concealed.chars().enumerate() {
2231 frame1[(i as u16, 0)].set_symbol(&ch.to_string());
2232 }
2233 apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2235
2236 let prev = Buffer::empty(area);
2238 let mut backend = Buffer::empty(area);
2239 let diff1 = prev.diff(&frame1);
2240 for (x, y, cell) in &diff1 {
2241 backend[(*x, *y)] = (*cell).clone();
2242 }
2243
2244 let full = "[Quick Install](#installation)";
2246 let mut frame2 = Buffer::empty(area);
2247 for (i, ch) in full.chars().enumerate() {
2248 if (i as u16) < 40 {
2249 frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2250 }
2251 }
2252 apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2254
2255 let diff2 = frame1.diff(&frame2);
2257 for (x, y, cell) in &diff2 {
2258 backend[(*x, *y)] = (*cell).clone();
2259 }
2260
2261 let row = read_row(&backend, 0);
2263 assert_eq!(
2264 row, full,
2265 "After diff-based update from concealed to unconcealed, \
2266 backend should show full text"
2267 );
2268
2269 let cell14 = strip_osc8(backend[(14, 0)].symbol());
2271 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
2272 }
2273
2274 fn render_with_highlight_option(
2277 content: &str,
2278 cursor_pos: usize,
2279 highlight_current_line: bool,
2280 ) -> LineRenderOutput {
2281 let mut state = EditorState::new(20, 6, 1024, test_fs());
2282 state.buffer = Buffer::from_str(content, 1024, test_fs());
2283 let mut cursors = crate::model::cursor::Cursors::new();
2284 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
2285 let viewport = Viewport::new(20, 4);
2286 state.margins.left_config.enabled = false;
2287
2288 let render_area = Rect::new(0, 0, 20, 4);
2289 let visible_count = viewport.visible_line_count();
2290 let gutter_width = state.margins.left_total_width();
2291 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
2292 let empty_folds = FoldManager::new();
2293
2294 let view_data = build_view_data(
2295 &mut state,
2296 &viewport,
2297 None,
2298 content.len().max(1),
2299 visible_count,
2300 false,
2301 render_area.width as usize,
2302 gutter_width,
2303 &ViewMode::Source,
2304 &empty_folds,
2305 &theme,
2306 );
2307 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
2308
2309 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
2310 state.margins.update_width_for_buffer(estimated_lines, true);
2311 let gutter_width = state.margins.left_total_width();
2312
2313 let selection = selection_context(&state, &cursors);
2314 let _ = state
2315 .buffer
2316 .populate_line_cache(viewport.top_byte, visible_count);
2317 let viewport_start = viewport.top_byte;
2318 let viewport_end = calculate_viewport_end(
2319 &mut state,
2320 viewport_start,
2321 content.len().max(1),
2322 visible_count,
2323 );
2324 let decorations = decoration_context(
2325 &mut state,
2326 viewport_start,
2327 viewport_end,
2328 selection.primary_cursor_position,
2329 &empty_folds,
2330 &theme,
2331 100_000,
2332 &ViewMode::Source,
2333 false,
2334 &[],
2335 );
2336
2337 render_view_lines(LineRenderInput {
2338 state: &state,
2339 theme: &theme,
2340 view_lines: &view_data.lines,
2341 view_anchor,
2342 render_area,
2343 gutter_width,
2344 selection: &selection,
2345 decorations: &decorations,
2346 visible_line_count: visible_count,
2347 lsp_waiting: false,
2348 is_active: true,
2349 line_wrap: viewport.line_wrap_enabled,
2350 estimated_lines,
2351 left_column: viewport.left_column,
2352 relative_line_numbers: false,
2353 session_mode: false,
2354 software_cursor_only: false,
2355 show_line_numbers: false,
2356 byte_offset_mode: false,
2357 show_tilde: true,
2358 highlight_current_line,
2359 cell_theme_map: &mut Vec::new(),
2360 screen_width: 0,
2361 })
2362 }
2363
2364 fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
2366 let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
2367 if let Some(line) = output.lines.get(line_idx) {
2368 line.spans
2369 .iter()
2370 .any(|span| span.style.bg == Some(current_line_bg))
2371 } else {
2372 false
2373 }
2374 }
2375
2376 #[test]
2377 fn current_line_highlight_enabled_highlights_cursor_line() {
2378 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
2379 assert!(
2381 line_has_current_line_bg(&output, 0),
2382 "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
2383 );
2384 assert!(
2386 !line_has_current_line_bg(&output, 1),
2387 "Non-cursor line (line 1) should NOT have current_line_bg"
2388 );
2389 }
2390
2391 #[test]
2392 fn current_line_highlight_disabled_no_highlight() {
2393 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
2394 assert!(
2396 !line_has_current_line_bg(&output, 0),
2397 "Cursor line should NOT have current_line_bg when highlighting is disabled"
2398 );
2399 assert!(
2400 !line_has_current_line_bg(&output, 1),
2401 "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
2402 );
2403 }
2404
2405 #[test]
2406 fn current_line_highlight_follows_cursor_position() {
2407 let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
2409 assert!(
2410 !line_has_current_line_bg(&output, 0),
2411 "Line 0 should NOT have current_line_bg when cursor is on line 1"
2412 );
2413 assert!(
2414 line_has_current_line_bg(&output, 1),
2415 "Line 1 should have current_line_bg when cursor is there"
2416 );
2417 assert!(
2418 !line_has_current_line_bg(&output, 2),
2419 "Line 2 should NOT have current_line_bg when cursor is on line 1"
2420 );
2421 }
2422
2423 #[test]
2430 fn wrap_str_to_width_matches_apply_wrapping_transform() {
2431 use crate::primitives::visual_layout::wrap_str_to_width;
2432 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2433
2434 let cases: &[(&str, usize)] = &[
2438 ("hello world how are you today friend", 12),
2439 ("the quick brown fox jumps over the lazy dog", 18),
2440 ("https://example.com/very-long-path/file", 24),
2441 (&"x".repeat(120), 32),
2442 (&"abc ".repeat(40), 25),
2443 ("dialog.getButton(...).setOnClickListener", 24),
2444 ];
2445
2446 for &(text, wrap_width) in cases {
2447 let helper_chunks = wrap_str_to_width(text, wrap_width);
2449 let helper_strings: Vec<&str> =
2450 helper_chunks.iter().map(|r| &text[r.clone()]).collect();
2451
2452 let tokens = vec![ViewTokenWire {
2457 kind: ViewTokenWireKind::Text(text.to_string()),
2458 source_offset: Some(0),
2459 style: None,
2460 }];
2461 let wrapped = apply_wrapping_transform(tokens, wrap_width, 0, false);
2462
2463 let mut transform_strings: Vec<String> = Vec::new();
2468 for tok in &wrapped {
2469 match &tok.kind {
2470 ViewTokenWireKind::Text(t) => transform_strings.push(t.clone()),
2471 ViewTokenWireKind::Break => {}
2472 other => panic!("unexpected token kind in agreement test: {:?}", other),
2473 }
2474 }
2475
2476 assert_eq!(
2477 transform_strings
2478 .iter()
2479 .map(String::as_str)
2480 .collect::<Vec<_>>(),
2481 helper_strings,
2482 "wrap mismatch for text={text:?} wrap_width={wrap_width}",
2483 );
2484 }
2485 }
2486}