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