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::config::IndentationGuideMode;
31use crate::model::buffer::Buffer;
32use crate::model::event::{BufferId, EventLog, LeafId, SplitDirection};
33use crate::primitives::ansi_background::AnsiBackground;
34use crate::state::EditorState;
35use crate::view::split::SplitManager;
36use ratatui::layout::Rect;
37use std::collections::HashMap;
38
39const MAX_SAFE_LINE_WIDTH: usize = 10_000;
45
46#[derive(Debug, Clone, Copy)]
54pub struct EditorRenderConfig<'a> {
55 pub large_file_threshold_bytes: u64,
56 pub line_wrap: bool,
57 pub estimated_line_length: usize,
58 pub highlight_context_bytes: usize,
59 pub relative_line_numbers: bool,
60 pub use_terminal_bg: bool,
61 pub show_vertical_scrollbar: bool,
62 pub show_horizontal_scrollbar: bool,
63 pub diagnostics_inline_text: bool,
64 pub show_tilde: bool,
65 pub highlight_current_column: bool,
66 pub indentation_guide: IndentationGuideMode,
67 pub indentation_guide_glyph: &'a str,
68 pub hide_current_line_on_selection: bool,
69 pub background_fade: f32,
70 pub software_cursor_only: bool,
71}
72
73impl<'a> EditorRenderConfig<'a> {
74 pub fn new(
79 editor: &'a crate::config::EditorConfig,
80 background_fade: f32,
81 software_cursor_only: bool,
82 ) -> Self {
83 Self {
84 large_file_threshold_bytes: editor.large_file_threshold_bytes,
85 line_wrap: editor.line_wrap,
86 estimated_line_length: editor.estimated_line_length,
87 highlight_context_bytes: editor.highlight_context_bytes,
88 relative_line_numbers: editor.relative_line_numbers,
89 use_terminal_bg: editor.use_terminal_bg,
90 show_vertical_scrollbar: editor.show_vertical_scrollbar,
91 show_horizontal_scrollbar: editor.show_horizontal_scrollbar,
92 diagnostics_inline_text: editor.diagnostics_inline_text,
93 show_tilde: editor.show_tilde,
94 highlight_current_column: editor.highlight_current_column,
95 indentation_guide: editor.indentation_guide,
96 indentation_guide_glyph: &editor.indentation_guide_glyph,
97 hide_current_line_on_selection: editor.hide_current_line_on_selection,
98 background_fade,
99 software_cursor_only,
100 }
101 }
102}
103
104#[derive(Clone, Copy)]
114pub struct RenderStyle<'a> {
115 pub theme: &'a crate::view::theme::Theme,
116 pub ansi_background: Option<&'a AnsiBackground>,
117 pub cfg: EditorRenderConfig<'a>,
118}
119
120pub struct SplitRenderer;
126
127impl SplitRenderer {
128 #[allow(clippy::too_many_arguments)]
129 #[allow(clippy::type_complexity)]
130 pub fn render_content(
131 buf: &mut ratatui::buffer::Buffer,
132 area: Rect,
133 split_manager: &SplitManager,
134 buffers: &mut HashMap<BufferId, EditorState>,
135 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
136 preview_buffer: Option<BufferId>,
137 event_logs: &mut HashMap<BufferId, EventLog>,
138 composite_buffers: &mut HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
139 composite_view_states: &mut HashMap<
140 (LeafId, BufferId),
141 crate::view::composite_view::CompositeViewState,
142 >,
143 style: RenderStyle<'_>,
144 lsp_waiting: bool,
145 split_view_states: Option<&mut HashMap<LeafId, crate::view::split::SplitViewState>>,
146 grouped_subtrees: &HashMap<LeafId, crate::view::split::SplitNode>,
147 hide_cursor: bool,
148 hovered_tab: Option<(crate::view::split::TabTarget, LeafId, bool)>,
149 hovered_close_split: Option<LeafId>,
150 hovered_maximize_split: Option<LeafId>,
151 is_maximized: bool,
152 tab_bar_visible: bool,
153 session_mode: bool,
154 terminal_mode: bool,
155 cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
156 screen_width: u16,
157 pending_hardware_cursor: &mut Option<(u16, u16)>,
158 draw_tab_bar: bool,
161 ) -> (
162 Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
163 HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
164 Vec<(LeafId, u16, u16, u16)>,
165 Vec<(LeafId, u16, u16, u16)>,
166 HashMap<LeafId, Vec<ViewLineMapping>>,
167 Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
168 Vec<(
169 crate::model::event::ContainerId,
170 SplitDirection,
171 u16,
172 u16,
173 u16,
174 )>,
175 ) {
176 orchestration::render_content(
177 buf,
178 area,
179 split_manager,
180 buffers,
181 buffer_metadata,
182 preview_buffer,
183 event_logs,
184 composite_buffers,
185 composite_view_states,
186 style,
187 lsp_waiting,
188 split_view_states,
189 grouped_subtrees,
190 hide_cursor,
191 hovered_tab,
192 hovered_close_split,
193 hovered_maximize_split,
194 is_maximized,
195 tab_bar_visible,
196 session_mode,
197 terminal_mode,
198 cell_theme_map,
199 screen_width,
200 pending_hardware_cursor,
201 draw_tab_bar,
202 )
203 }
204
205 #[allow(clippy::too_many_arguments)]
206 pub fn compute_content_layout(
207 area: Rect,
208 split_manager: &SplitManager,
209 buffers: &mut HashMap<BufferId, EditorState>,
210 split_view_states: &mut HashMap<LeafId, crate::view::split::SplitViewState>,
211 theme: &crate::view::theme::Theme,
212 lsp_waiting: bool,
213 estimated_line_length: usize,
214 highlight_context_bytes: usize,
215 relative_line_numbers: bool,
216 use_terminal_bg: bool,
217 session_mode: bool,
218 software_cursor_only: bool,
219 tab_bar_visible: bool,
220 show_vertical_scrollbar: bool,
221 show_horizontal_scrollbar: bool,
222 diagnostics_inline_text: bool,
223 show_tilde: bool,
224 ) -> HashMap<LeafId, Vec<ViewLineMapping>> {
225 orchestration::compute_content_layout(
226 area,
227 split_manager,
228 buffers,
229 split_view_states,
230 theme,
231 lsp_waiting,
232 estimated_line_length,
233 highlight_context_bytes,
234 relative_line_numbers,
235 use_terminal_bg,
236 session_mode,
237 software_cursor_only,
238 tab_bar_visible,
239 show_vertical_scrollbar,
240 show_horizontal_scrollbar,
241 diagnostics_inline_text,
242 show_tilde,
243 )
244 }
245
246 #[allow(clippy::too_many_arguments)]
256 pub fn render_phantom_leaf(
257 buf: &mut ratatui::buffer::Buffer,
258 state: &mut EditorState,
259 cursors: &crate::model::cursor::Cursors,
260 viewport: &mut crate::view::viewport::Viewport,
261 folds: &mut crate::view::folding::FoldManager,
262 event_log: Option<&mut EventLog>,
263 area: Rect,
264 style: RenderStyle<'_>,
265 view_mode: crate::state::ViewMode,
266 compose_width: Option<u16>,
267 compose_column_guides: Option<Vec<u16>>,
268 view_transform: Option<crate::services::plugins::api::ViewTransformPayload>,
269 buffer_id: BufferId,
270 session_mode: bool,
271 rulers: &[usize],
272 show_line_numbers: bool,
273 highlight_current_line: bool,
274 show_tilde: bool,
275 highlight_current_column: bool,
276 cell_theme_map: &mut Vec<crate::app::types::CellThemeInfo>,
277 screen_width: u16,
278 ) -> Vec<crate::app::types::ViewLineMapping> {
279 let mut sink: Option<(u16, u16)> = None;
288 orchestration::render_buffer_in_split(
289 buf,
290 state,
291 cursors,
292 viewport,
293 folds,
294 event_log,
295 area,
296 false,
297 style,
298 false,
299 view_mode,
300 compose_width,
301 compose_column_guides,
302 view_transform,
303 buffer_id,
304 true,
305 session_mode,
306 rulers,
307 show_line_numbers,
308 highlight_current_line,
309 show_tilde,
310 highlight_current_column,
311 cell_theme_map,
312 screen_width,
313 &mut sink,
314 )
315 }
316
317 pub fn build_base_tokens_for_hook(
320 buffer: &mut Buffer,
321 top_byte: usize,
322 estimated_line_length: usize,
323 visible_count: usize,
324 is_binary: bool,
325 line_ending: crate::model::buffer::LineEnding,
326 ) -> Vec<fresh_core::api::ViewTokenWire> {
327 orchestration::build_base_tokens_for_hook(
328 buffer,
329 top_byte,
330 estimated_line_length,
331 visible_count,
332 is_binary,
333 line_ending,
334 )
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::folding::fold_indicators_for_viewport;
341 use super::layout::{calculate_view_anchor, calculate_viewport_end};
342 use super::orchestration::overlays::{decoration_context, selection_context};
343 use super::orchestration::render_buffer::resolve_cursor_fallback;
344 use super::orchestration::render_line::{
345 render_view_lines, LastLineEnd, LineRenderInput, LineRenderOutput,
346 };
347 use super::post_pass::apply_osc8_to_cells;
348 use super::transforms::apply_wrapping_transform;
349 use super::view_data::build_view_data;
350 use super::*;
351
352 use crate::model::buffer::{Buffer, LineEnding};
353 use crate::model::filesystem::StdFileSystem;
354 use crate::primitives::display_width::str_width;
355 use crate::state::{EditorState, ViewMode};
356 use crate::view::folding::FoldManager;
357 use crate::view::theme;
358 use crate::view::theme::Theme;
359 use crate::view::ui::view_pipeline::{LineStart, ViewLine};
360 use crate::view::viewport::Viewport;
361 use fresh_core::api::ViewTokenWire;
362 use lsp_types::FoldingRange;
363 use std::collections::HashSet;
364 use std::sync::Arc;
365
366 fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
367 Arc::new(StdFileSystem)
368 }
369
370 fn render_output_for(
371 content: &str,
372 cursor_pos: usize,
373 ) -> (LineRenderOutput, usize, bool, usize) {
374 render_output_for_with_gutters(content, cursor_pos, false)
375 }
376
377 fn render_output_for_with_gutters(
378 content: &str,
379 cursor_pos: usize,
380 gutters_enabled: bool,
381 ) -> (LineRenderOutput, usize, bool, usize) {
382 render_output_for_with_options(
383 content,
384 cursor_pos,
385 gutters_enabled,
386 IndentationGuideMode::None,
387 crate::config::default_indentation_guide_glyph(),
388 0,
389 None,
390 )
391 }
392
393 fn render_output_for_with_indentation_guide(
394 content: &str,
395 cursor_pos: usize,
396 left_column: usize,
397 ) -> (LineRenderOutput, usize, bool, usize) {
398 render_output_for_with_indentation_guide_mode(
399 content,
400 cursor_pos,
401 left_column,
402 IndentationGuideMode::All,
403 )
404 }
405
406 fn render_output_for_with_indentation_guide_mode(
407 content: &str,
408 cursor_pos: usize,
409 left_column: usize,
410 indentation_guide: IndentationGuideMode,
411 ) -> (LineRenderOutput, usize, bool, usize) {
412 render_output_for_with_options(
413 content,
414 cursor_pos,
415 false,
416 indentation_guide,
417 crate::config::default_indentation_guide_glyph(),
418 left_column,
419 None,
420 )
421 }
422
423 fn render_output_for_with_indentation_guide_mode_and_tab_size(
424 content: &str,
425 cursor_pos: usize,
426 left_column: usize,
427 indentation_guide: IndentationGuideMode,
428 tab_size: usize,
429 ) -> (LineRenderOutput, usize, bool, usize) {
430 render_output_for_with_options(
431 content,
432 cursor_pos,
433 false,
434 indentation_guide,
435 crate::config::default_indentation_guide_glyph(),
436 left_column,
437 Some(tab_size),
438 )
439 }
440
441 fn render_output_for_with_options(
442 content: &str,
443 cursor_pos: usize,
444 gutters_enabled: bool,
445 indentation_guide: IndentationGuideMode,
446 indentation_guide_glyph: String,
447 left_column: usize,
448 tab_size: Option<usize>,
449 ) -> (LineRenderOutput, usize, bool, usize) {
450 let mut state = EditorState::new(20, 6, 1024, test_fs());
451 state.buffer = Buffer::from_str(content, 1024, test_fs());
452 if let Some(tab_size) = tab_size {
453 state.buffer_settings.tab_size = tab_size;
454 }
455 let mut cursors = crate::model::cursor::Cursors::new();
456 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
457 let mut viewport = Viewport::new(20, 4);
459 viewport.left_column = left_column;
460 state.margins.left_config.enabled = gutters_enabled;
462
463 let render_area = Rect::new(0, 0, 20, 4);
464 let visible_count = viewport.visible_line_count();
465 let gutter_width = state.margins.left_total_width();
466 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
467 let empty_folds = FoldManager::new();
468
469 let view_data = build_view_data(
470 &mut state,
471 &viewport,
472 None,
473 content.len().max(1),
474 visible_count,
475 false, render_area.width as usize,
477 gutter_width,
478 &ViewMode::Source, &empty_folds,
480 &theme,
481 );
482 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
483
484 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
485 state.margins.update_width_for_buffer(estimated_lines, true);
486 let gutter_width = state.margins.left_total_width();
487
488 let selection = selection_context(&state, &cursors);
489 let _ = state
490 .buffer
491 .populate_line_cache(viewport.top_byte, visible_count);
492 let viewport_start = viewport.top_byte;
493 let viewport_end = calculate_viewport_end(
494 &mut state,
495 viewport_start,
496 content.len().max(1),
497 visible_count,
498 );
499 let decorations = decoration_context(
500 &mut state,
501 viewport_start,
502 viewport_end,
503 selection.primary_cursor_position,
504 &empty_folds,
505 &theme,
506 100_000, &ViewMode::Source, false, &[],
510 );
511
512 let mut dummy_theme_map = Vec::new();
513 let output = render_view_lines(LineRenderInput {
514 state: &state,
515 theme: &theme,
516 view_lines: &view_data.lines,
517 view_anchor,
518 render_area,
519 gutter_width,
520 selection: &selection,
521 decorations: &decorations,
522 visible_line_count: visible_count,
523 lsp_waiting: false,
524 is_active: true,
525 line_wrap: viewport.line_wrap_enabled,
526 estimated_lines,
527 left_column: viewport.left_column,
528 relative_line_numbers: false,
529 session_mode: false,
530 software_cursor_only: false,
531 show_line_numbers: true, byte_offset_mode: false, show_tilde: true,
534 highlight_current_line: true,
535 indentation_guide,
536 indentation_guide_glyph: &indentation_guide_glyph,
537 cell_theme_map: &mut dummy_theme_map,
538 screen_width: 0,
539 });
540
541 (
542 output,
543 state.buffer.len(),
544 content.ends_with('\n'),
545 selection.primary_cursor_position,
546 )
547 }
548
549 fn render_output_scrolled_with_indentation_guide(
553 content: &str,
554 top_byte: usize,
555 ) -> LineRenderOutput {
556 let mut state = EditorState::new(20, 6, 1024, test_fs());
557 state.buffer = Buffer::from_str(content, 1024, test_fs());
558 let mut cursors = crate::model::cursor::Cursors::new();
559 cursors.primary_mut().position = 0;
560 let mut viewport = Viewport::new(20, 10);
561 viewport.top_byte = top_byte;
562 state.margins.left_config.enabled = false;
563
564 let render_area = Rect::new(0, 0, 20, 10);
565 let visible_count = viewport.visible_line_count();
566 let gutter_width = state.margins.left_total_width();
567 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
568 let empty_folds = FoldManager::new();
569
570 let view_data = build_view_data(
571 &mut state,
572 &viewport,
573 None,
574 content.len().max(1),
575 visible_count,
576 false,
577 render_area.width as usize,
578 gutter_width,
579 &ViewMode::Source,
580 &empty_folds,
581 &theme,
582 );
583 let view_anchor = calculate_view_anchor(&view_data.lines, viewport.top_byte);
584
585 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
586 state.margins.update_width_for_buffer(estimated_lines, true);
587 let gutter_width = state.margins.left_total_width();
588
589 let selection = selection_context(&state, &cursors);
590 let _ = state
591 .buffer
592 .populate_line_cache(viewport.top_byte, visible_count);
593 let viewport_start = viewport.top_byte;
594 let viewport_end = calculate_viewport_end(
595 &mut state,
596 viewport_start,
597 content.len().max(1),
598 visible_count,
599 );
600 let decorations = decoration_context(
601 &mut state,
602 viewport_start,
603 viewport_end,
604 selection.primary_cursor_position,
605 &empty_folds,
606 &theme,
607 100_000,
608 &ViewMode::Source,
609 false,
610 &[],
611 );
612
613 let glyph = crate::config::default_indentation_guide_glyph();
614 let mut dummy_theme_map = Vec::new();
615 render_view_lines(LineRenderInput {
616 state: &state,
617 theme: &theme,
618 view_lines: &view_data.lines,
619 view_anchor,
620 render_area,
621 gutter_width,
622 selection: &selection,
623 decorations: &decorations,
624 visible_line_count: visible_count,
625 lsp_waiting: false,
626 is_active: true,
627 line_wrap: viewport.line_wrap_enabled,
628 estimated_lines,
629 left_column: viewport.left_column,
630 relative_line_numbers: false,
631 session_mode: false,
632 software_cursor_only: false,
633 show_line_numbers: true,
634 byte_offset_mode: false,
635 show_tilde: true,
636 highlight_current_line: true,
637 indentation_guide: IndentationGuideMode::All,
638 indentation_guide_glyph: &glyph,
639 cell_theme_map: &mut dummy_theme_map,
640 screen_width: 0,
641 })
642 }
643
644 fn rendered_line_text(output: &LineRenderOutput, line_idx: usize) -> String {
645 output.lines[line_idx]
646 .spans
647 .iter()
648 .map(|span| span.content.as_ref())
649 .collect::<String>()
650 .trim_end()
651 .to_string()
652 }
653
654 #[test]
655 fn indentation_guide_disabled_preserves_leading_spaces() {
656 let (output, _, _, _) = render_output_for(" let x = 1;\n", 0);
657
658 assert_eq!(rendered_line_text(&output, 0), " let x = 1;");
659 assert!(!rendered_line_text(&output, 0).contains('▏'));
660 }
661
662 #[test]
663 fn indentation_guide_render_for_space_indents_but_not_root_lines() {
664 let (output, _, _, _) = render_output_for_with_indentation_guide(
665 "fn main()\n let child = 1;\n let grandchild = 2;\nroot\n",
666 0,
667 0,
668 );
669
670 assert_eq!(rendered_line_text(&output, 0), "fn main()");
671 assert_eq!(rendered_line_text(&output, 1), "▏ let child = 1;");
672 assert_eq!(
673 rendered_line_text(&output, 2),
674 "▏ ▏ let grandchild = 2;"
675 );
676 assert_eq!(rendered_line_text(&output, 3), "root");
677 }
678
679 #[test]
680 fn indentation_guide_follow_four_space_file_indents_when_tab_size_is_two() {
681 let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
682 "fn main() {\n child();\n grandchild();\n}\n",
683 0,
684 0,
685 IndentationGuideMode::All,
686 2,
687 );
688
689 assert_eq!(rendered_line_text(&output, 0), "fn main() {");
690 assert_eq!(rendered_line_text(&output, 1), "▏ child();");
691 assert_eq!(rendered_line_text(&output, 2), "▏ ▏ grandchild();");
692 assert_eq!(rendered_line_text(&output, 3), "}");
693 }
694
695 #[test]
696 fn indentation_guide_active_mode_uses_file_indent_width_when_tab_size_is_two() {
697 let content = "function test() {\n if (1) {\n // test\n }\n}\n";
698 let cursor_pos = content.find("// test").unwrap();
699 let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
700 content,
701 cursor_pos,
702 0,
703 IndentationGuideMode::Active,
704 2,
705 );
706
707 assert_eq!(rendered_line_text(&output, 0), "function test() {");
708 assert_eq!(rendered_line_text(&output, 1), " if (1) {");
709 assert_eq!(rendered_line_text(&output, 2), " ▏ // test");
710 assert_eq!(rendered_line_text(&output, 3), " }");
711 }
712
713 #[test]
714 fn indentation_guide_follow_nearest_earlier_parent_indent_when_widths_vary() {
715 let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
716 "root\n child\n grand\n sibling\n",
717 0,
718 0,
719 IndentationGuideMode::All,
720 4,
721 );
722
723 assert_eq!(rendered_line_text(&output, 0), "root");
724 assert_eq!(rendered_line_text(&output, 1), "▏ child");
725 assert_eq!(rendered_line_text(&output, 2), "▏ ▏ grand");
726 assert_eq!(rendered_line_text(&output, 3), "▏ sibling");
727 }
728
729 #[test]
730 fn indentation_guide_active_mode_uses_nearest_earlier_parent_indent() {
731 let content = "root\n child\n grand\n sibling\n";
732 let cursor_pos = content.find("grand").unwrap();
733 let (output, _, _, _) = render_output_for_with_indentation_guide_mode_and_tab_size(
734 content,
735 cursor_pos,
736 0,
737 IndentationGuideMode::Active,
738 4,
739 );
740
741 assert_eq!(rendered_line_text(&output, 0), "root");
742 assert_eq!(rendered_line_text(&output, 1), " child");
743 assert_eq!(rendered_line_text(&output, 2), " ▏ grand");
744 assert_eq!(rendered_line_text(&output, 3), " sibling");
745 }
746
747 #[test]
748 fn indentation_guide_resume_after_lower_indent_line() {
749 let (output, _, _, _) = render_output_for_with_indentation_guide(
750 " before\n lower\n after\n",
751 0,
752 0,
753 );
754
755 assert_eq!(rendered_line_text(&output, 0), "▏ ▏ before");
756 assert_eq!(rendered_line_text(&output, 1), "▏ lower");
757 assert_eq!(rendered_line_text(&output, 2), "▏ ▏ after");
758 }
759
760 #[test]
761 fn indentation_guide_all_mode_draws_through_blank_lines() {
762 let blank = " ".repeat(8);
769 let content =
770 format!("fn f() {{\n if x {{\n a;\n{blank}\n b;\n }}\n}}\n");
771 let output = render_output_scrolled_with_indentation_guide(&content, 0);
772
773 assert_eq!(rendered_line_text(&output, 0), "fn f() {");
774 assert_eq!(rendered_line_text(&output, 1), "▏ if x {");
775 assert_eq!(rendered_line_text(&output, 2), "▏ ▏ a;");
776 assert_eq!(rendered_line_text(&output, 3), "▏ ▏");
777 assert_eq!(rendered_line_text(&output, 4), "▏ ▏ b;");
778 assert_eq!(rendered_line_text(&output, 5), "▏ }");
779 assert_eq!(rendered_line_text(&output, 6), "}");
780 }
781
782 #[test]
783 fn indentation_guide_empty_line_does_not_collapse_staircase() {
784 let content = "fn f() {\n if x {\n a;\n\n b;\n }\n}\n";
791 let output = render_output_scrolled_with_indentation_guide(content, 0);
792
793 assert_eq!(rendered_line_text(&output, 0), "fn f() {");
794 assert_eq!(rendered_line_text(&output, 1), "▏ if x {");
795 assert_eq!(rendered_line_text(&output, 2), "▏ ▏ a;");
796 assert_eq!(rendered_line_text(&output, 4), "▏ ▏ b;");
798 assert_eq!(rendered_line_text(&output, 5), "▏ }");
799 }
800
801 #[test]
802 fn indentation_guide_survives_scroll_past_block_opener() {
803 let content = concat!(
810 "mod m {\n", " fn f() {\n", " let arr = [\n", " a,\n", " b,\n", " ];\n", " let c = 1;\n", " }\n",
818 "}\n",
819 );
820 let top_byte = content.find(" let arr = [").unwrap();
821 let output = render_output_scrolled_with_indentation_guide(content, top_byte);
822
823 assert_eq!(rendered_line_text(&output, 0), "▏ ▏ let arr = [");
826 assert_eq!(rendered_line_text(&output, 1), "▏ ▏ ▏ a,");
829 assert_eq!(rendered_line_text(&output, 2), "▏ ▏ ▏ b,");
830 assert_eq!(rendered_line_text(&output, 3), "▏ ▏ ];");
831 }
832
833 #[test]
834 fn indentation_guide_draws_through_blank_line_when_opener_scrolled_off() {
835 let blank = " ".repeat(12);
845 let content = format!(
846 "mod m {{\n fn f() {{\n let arr = [\n alpha_value,\n{blank}\n beta_value,\n ];\n let after = compute();\n }}\n}}\n"
847 );
848 let top_byte = content.find(" let arr = [").unwrap();
849 let output = render_output_scrolled_with_indentation_guide(&content, top_byte);
850
851 assert_eq!(rendered_line_text(&output, 0), "▏ ▏ let arr = [");
852 assert_eq!(rendered_line_text(&output, 1), "▏ ▏ ▏ alpha_value,");
853 assert_eq!(rendered_line_text(&output, 2), "▏ ▏ ▏");
856 assert_eq!(rendered_line_text(&output, 3), "▏ ▏ ▏ beta_value,");
857 }
858
859 #[test]
860 fn indentation_guide_draws_through_completely_empty_lines() {
861 let root = "int main() {\n\n greet();\n \n return 0;\n}\n";
866 let out = render_output_scrolled_with_indentation_guide(root, 0);
867 assert_eq!(rendered_line_text(&out, 0), "int main() {");
868 assert_eq!(rendered_line_text(&out, 1), "▏"); assert_eq!(rendered_line_text(&out, 2), "▏ greet();");
870 assert_eq!(rendered_line_text(&out, 3), "▏"); assert_eq!(rendered_line_text(&out, 4), "▏ return 0;");
872 assert_eq!(rendered_line_text(&out, 5), "}");
873
874 let nested = "fn f() {\n if x {\n a;\n\n b;\n }\n}\n";
875 let out = render_output_scrolled_with_indentation_guide(nested, 0);
876 assert_eq!(rendered_line_text(&out, 2), "▏ ▏ a;");
877 assert_eq!(rendered_line_text(&out, 3), "▏ ▏");
879 assert_eq!(rendered_line_text(&out, 4), "▏ ▏ b;");
880 }
881
882 #[test]
883 fn indentation_guide_empty_line_after_opener_flows_into_body() {
884 let content = "if outer {\n\n inner();\n}\n";
889 let out = render_output_scrolled_with_indentation_guide(content, 0);
890 assert_eq!(rendered_line_text(&out, 0), "if outer {");
891 assert_eq!(rendered_line_text(&out, 1), "▏"); assert_eq!(rendered_line_text(&out, 2), "▏ inner();");
893 }
894
895 #[test]
896 fn indentation_guide_render_for_tabs() {
897 let (output, _, _, _) =
898 render_output_for_with_indentation_guide("\tchild\n\t\tgrand\n", 0, 0);
899
900 assert_eq!(rendered_line_text(&output, 0), "▏ child");
901 assert_eq!(rendered_line_text(&output, 1), "▏ ▏ grand");
902 }
903
904 #[test]
905 fn indentation_guide_respect_horizontal_scroll() {
906 let (output, _, _, _) = render_output_for_with_indentation_guide(" grand\n", 0, 4);
907
908 assert_eq!(rendered_line_text(&output, 0), "▏ grand");
909 }
910
911 #[test]
912 fn indentation_guide_use_configured_glyph() {
913 let (output, _, _, _) = render_output_for_with_options(
914 " grand\n",
915 0,
916 false,
917 IndentationGuideMode::All,
918 "┊".to_string(),
919 0,
920 None,
921 );
922
923 assert_eq!(rendered_line_text(&output, 0), "┊ ┊ grand");
924 }
925
926 #[test]
927 fn indentation_guide_renderer_normalizes_blank_and_padded_glyphs() {
928 let (output, _, _, _) = render_output_for_with_options(
929 " child\n",
930 0,
931 false,
932 IndentationGuideMode::All,
933 " ┊ ".to_string(),
934 0,
935 None,
936 );
937 assert_eq!(rendered_line_text(&output, 0), "┊ child");
938
939 let (output, _, _, _) = render_output_for_with_options(
940 " child\n",
941 0,
942 false,
943 IndentationGuideMode::All,
944 " ".to_string(),
945 0,
946 None,
947 );
948 assert_eq!(rendered_line_text(&output, 0), "▏ child");
949 }
950
951 #[test]
952 fn indentation_guide_renderer_uses_one_character_from_glyph_setting() {
953 let (output, _, _, _) = render_output_for_with_options(
954 " child\n",
955 0,
956 false,
957 IndentationGuideMode::All,
958 " ABC ".to_string(),
959 0,
960 None,
961 );
962
963 assert_eq!(rendered_line_text(&output, 0), "A child");
964 }
965
966 #[test]
967 fn indentation_guide_renderer_rejects_double_width_glyphs() {
968 let (output, _, _, _) = render_output_for_with_options(
969 " child\n",
970 0,
971 false,
972 IndentationGuideMode::All,
973 "😀".to_string(),
974 0,
975 None,
976 );
977
978 assert_eq!(rendered_line_text(&output, 0), "▏ child");
979 }
980
981 #[test]
982 fn indentation_guide_active_mode_renders_only_innermost_active_block() {
983 let content = " if ready {\n inner\n sibling\n }\n";
984 let cursor_pos = content.find("inner").unwrap();
985 let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
986 content,
987 cursor_pos,
988 0,
989 IndentationGuideMode::Active,
990 );
991
992 assert_eq!(rendered_line_text(&output, 0), " if ready {");
993 assert_eq!(rendered_line_text(&output, 1), " ▏ inner");
994 assert_eq!(rendered_line_text(&output, 2), " ▏ sibling");
995 assert_eq!(rendered_line_text(&output, 3), " }");
996 }
997
998 #[test]
999 fn indentation_guide_active_mode_updates_when_cursor_changes_blocks() {
1000 let content = " a\n x\n b\n y\n";
1003
1004 let first_cursor = content.find('x').unwrap();
1005 let (first_output, _, _, _) = render_output_for_with_indentation_guide_mode(
1006 content,
1007 first_cursor,
1008 0,
1009 IndentationGuideMode::Active,
1010 );
1011 assert_eq!(rendered_line_text(&first_output, 1), " ▏ x");
1012 assert_eq!(rendered_line_text(&first_output, 3), " y");
1013
1014 let second_cursor = content.find('y').unwrap();
1015 let (second_output, _, _, _) = render_output_for_with_indentation_guide_mode(
1016 content,
1017 second_cursor,
1018 0,
1019 IndentationGuideMode::Active,
1020 );
1021 assert_eq!(rendered_line_text(&second_output, 1), " x");
1022 assert_eq!(rendered_line_text(&second_output, 3), " ▏ y");
1023 }
1024
1025 #[test]
1026 fn indentation_guide_active_mode_supports_tabs() {
1027 let content = "\tchild\n\t\tgrand\n";
1028 let cursor_pos = content.find("grand").unwrap();
1029 let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1030 content,
1031 cursor_pos,
1032 0,
1033 IndentationGuideMode::Active,
1034 );
1035
1036 assert_eq!(rendered_line_text(&output, 0), "→ child");
1039 assert_eq!(rendered_line_text(&output, 1), "→ ▏ grand");
1040 }
1041
1042 #[test]
1043 fn indentation_guide_active_mode_cursor_on_block_header_uses_child_block() {
1044 let content = " if (1) {\n // test\n }\n";
1049 let cursor_pos = content.find('{').unwrap();
1050 let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1051 content,
1052 cursor_pos,
1053 0,
1054 IndentationGuideMode::Active,
1055 );
1056
1057 assert_eq!(rendered_line_text(&output, 0), " if (1) {");
1058 assert_eq!(rendered_line_text(&output, 1), " ▏ // test");
1059 assert_eq!(rendered_line_text(&output, 2), " }");
1060 }
1061
1062 #[test]
1063 fn indentation_guide_active_mode_cursor_in_body_uses_enclosing_block() {
1064 let content = " if (1) {\n // test\n }\n";
1065 let cursor_pos = content.find("// test").unwrap();
1066 let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1067 content,
1068 cursor_pos,
1069 0,
1070 IndentationGuideMode::Active,
1071 );
1072
1073 assert_eq!(rendered_line_text(&output, 0), " if (1) {");
1074 assert_eq!(rendered_line_text(&output, 1), " ▏ // test");
1075 assert_eq!(rendered_line_text(&output, 2), " }");
1076 }
1077
1078 #[test]
1079 fn indentation_guide_active_mode_dedent_line_with_no_parent_has_no_guide() {
1080 let content = " if (1) {\n // test\n }\n";
1083 let cursor_pos = content.find(" }").unwrap() + 4;
1084 let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1085 content,
1086 cursor_pos,
1087 0,
1088 IndentationGuideMode::Active,
1089 );
1090
1091 assert_eq!(rendered_line_text(&output, 0), " if (1) {");
1092 assert_eq!(rendered_line_text(&output, 1), " // test");
1093 assert_eq!(rendered_line_text(&output, 2), " }");
1094 }
1095
1096 #[test]
1097 fn indentation_guide_active_mode_dedent_line_uses_enclosing_block_when_nested() {
1098 let content = "fn () {\n if (1) {\n // test\n }\n}\n";
1101 let cursor_pos = content.find(" }").unwrap() + 4;
1102 let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1103 content,
1104 cursor_pos,
1105 0,
1106 IndentationGuideMode::Active,
1107 );
1108
1109 assert_eq!(rendered_line_text(&output, 0), "fn () {");
1112 assert_eq!(rendered_line_text(&output, 1), "▏ if (1) {");
1113 assert_eq!(rendered_line_text(&output, 2), "▏ // test");
1114 assert_eq!(rendered_line_text(&output, 3), "▏ }");
1115 }
1116
1117 #[test]
1118 fn indentation_guide_all_mode_is_cursor_independent() {
1119 let content = " if (1) {\n // test\n }\n";
1121 let cursor_pos = content.find('{').unwrap() + 1;
1122 let (output, _, _, _) = render_output_for_with_indentation_guide(content, cursor_pos, 0);
1123
1124 assert_eq!(rendered_line_text(&output, 0), "▏ if (1) {");
1125 assert_eq!(rendered_line_text(&output, 1), "▏ ▏ // test");
1126 assert_eq!(rendered_line_text(&output, 2), "▏ }");
1127 }
1128
1129 #[test]
1130 fn indentation_guide_active_mode_root_header_draws_column_zero_guide() {
1131 let (output, _, _, _) = render_output_for_with_indentation_guide_mode(
1134 "root\n child\n",
1135 0,
1136 0,
1137 IndentationGuideMode::Active,
1138 );
1139
1140 assert_eq!(rendered_line_text(&output, 0), "root");
1141 assert_eq!(rendered_line_text(&output, 1), "▏ child");
1142 }
1143
1144 #[test]
1145 fn test_folding_hides_lines_and_adds_placeholder() {
1146 let content = "header\nline1\nline2\ntail\n";
1147 let mut state = EditorState::new(40, 6, 1024, test_fs());
1148 state.buffer = Buffer::from_str(content, 1024, test_fs());
1149
1150 let start = state.buffer.line_start_offset(1).unwrap();
1151 let end = state.buffer.line_start_offset(3).unwrap();
1152 let mut folds = FoldManager::new();
1153 folds.add(&mut state.marker_list, start, end, Some("...".to_string()));
1154
1155 let viewport = Viewport::new(40, 6);
1156 let gutter_width = state.margins.left_total_width();
1157 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1158 let view_data = build_view_data(
1159 &mut state,
1160 &viewport,
1161 None,
1162 content.len().max(1),
1163 viewport.visible_line_count(),
1164 false,
1165 40,
1166 gutter_width,
1167 &ViewMode::Source,
1168 &folds,
1169 &theme,
1170 );
1171
1172 let lines: Vec<String> = view_data.lines.iter().map(|l| l.text.clone()).collect();
1173 assert!(lines.iter().any(|l| l.contains("header")));
1174 assert!(lines.iter().any(|l| l.contains("tail")));
1175 assert!(!lines.iter().any(|l| l.contains("line1")));
1176 assert!(!lines.iter().any(|l| l.contains("line2")));
1177 assert!(lines
1178 .iter()
1179 .any(|l| l.contains("header") && l.contains("...")));
1180 }
1181
1182 #[test]
1183 fn fold_indicator_lands_on_header_not_blank_line_after_it() {
1184 let content = "int main() {\n\n body();\n \n more();\n}\n";
1191 let mut state = EditorState::new(40, 10, 1024, test_fs());
1192 state.buffer = Buffer::from_str(content, 1024, test_fs());
1193 let viewport = Viewport::new(40, 10);
1194 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1195 let folds = FoldManager::new();
1196 let view_data = build_view_data(
1197 &mut state,
1198 &viewport,
1199 None,
1200 content.len().max(1),
1201 viewport.visible_line_count(),
1202 false,
1203 40,
1204 0,
1205 &ViewMode::Source,
1206 &folds,
1207 &theme,
1208 );
1209
1210 let indicators = fold_indicators_for_viewport(&state, &folds, &view_data.lines);
1211
1212 let header_byte = 0; let blank_byte = content.find("\n\n").unwrap() + 1; assert!(
1215 indicators.contains_key(&header_byte),
1216 "fold marker should be on the function header"
1217 );
1218 assert!(
1219 !indicators.contains_key(&blank_byte),
1220 "fold marker must not be on the blank line"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_fold_indicators_collapsed_and_expanded() {
1226 let content = "a\nb\nc\nd\n";
1227 let mut state = EditorState::new(40, 6, 1024, test_fs());
1228 state.buffer = Buffer::from_str(content, 1024, test_fs());
1229
1230 let lsp_ranges = vec![
1231 FoldingRange {
1232 start_line: 0,
1233 end_line: 1,
1234 start_character: None,
1235 end_character: None,
1236 kind: None,
1237 collapsed_text: None,
1238 },
1239 FoldingRange {
1240 start_line: 1,
1241 end_line: 2,
1242 start_character: None,
1243 end_character: None,
1244 kind: None,
1245 collapsed_text: None,
1246 },
1247 ];
1248 state
1249 .folding_ranges
1250 .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
1251
1252 let start = state.buffer.line_start_offset(1).unwrap();
1253 let end = state.buffer.line_start_offset(2).unwrap();
1254 let mut folds = FoldManager::new();
1255 folds.add(&mut state.marker_list, start, end, None);
1256
1257 let line1_byte = state.buffer.line_start_offset(1).unwrap();
1258 let view_lines = vec![ViewLine {
1259 text: "b\n".to_string(),
1260 source_start_byte: Some(line1_byte),
1261 char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)],
1262 char_styles: vec![None, None],
1263 char_visual_cols: vec![0, 1],
1264 visual_to_char: vec![0, 1],
1265 tab_starts: HashSet::new(),
1266 line_start: LineStart::AfterSourceNewline,
1267 ends_with_newline: true,
1268 virtual_gutter_glyph: None,
1269 virtual_line_style: None,
1270 }];
1271
1272 let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines);
1273
1274 assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true));
1276 assert_eq!(
1278 indicators.get(&line1_byte).map(|i| i.collapsed),
1279 Some(false)
1280 );
1281 }
1282
1283 #[test]
1284 fn last_line_end_tracks_trailing_newline() {
1285 let output = render_output_for("abc\n", 4);
1286 assert_eq!(
1287 output.0.last_line_end,
1288 Some(LastLineEnd {
1289 pos: (3, 0),
1290 terminated_with_newline: true
1291 })
1292 );
1293 }
1294
1295 #[test]
1296 fn last_line_end_tracks_no_trailing_newline() {
1297 let output = render_output_for("abc", 3);
1298 assert_eq!(
1299 output.0.last_line_end,
1300 Some(LastLineEnd {
1301 pos: (3, 0),
1302 terminated_with_newline: false
1303 })
1304 );
1305 }
1306
1307 #[test]
1308 fn cursor_after_newline_places_on_next_line() {
1309 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc\n", 4);
1310 let cursor = resolve_cursor_fallback(
1311 output.cursor,
1312 cursor_pos,
1313 buffer_len,
1314 buffer_newline,
1315 output.last_line_end,
1316 output.content_lines_rendered,
1317 0, );
1319 assert_eq!(cursor, Some((0, 1)));
1320 }
1321
1322 #[test]
1323 fn cursor_at_end_without_newline_stays_on_line() {
1324 let (output, buffer_len, buffer_newline, cursor_pos) = render_output_for("abc", 3);
1325 let cursor = resolve_cursor_fallback(
1326 output.cursor,
1327 cursor_pos,
1328 buffer_len,
1329 buffer_newline,
1330 output.last_line_end,
1331 output.content_lines_rendered,
1332 0, );
1334 assert_eq!(cursor, Some((3, 0)));
1335 }
1336
1337 fn count_all_cursors(output: &LineRenderOutput) -> Vec<(u16, u16)> {
1343 let mut cursor_positions = Vec::new();
1344
1345 let primary_cursor = output.cursor;
1347 if let Some(cursor_pos) = primary_cursor {
1348 cursor_positions.push(cursor_pos);
1349 }
1350
1351 for (line_idx, line) in output.lines.iter().enumerate() {
1353 let mut col = 0u16;
1354 for span in line.spans.iter() {
1355 if span
1357 .style
1358 .add_modifier
1359 .contains(ratatui::style::Modifier::REVERSED)
1360 {
1361 let pos = (col, line_idx as u16);
1362 if primary_cursor != Some(pos) {
1365 cursor_positions.push(pos);
1366 }
1367 }
1368 col += str_width(&span.content) as u16;
1370 }
1371 }
1372
1373 cursor_positions
1374 }
1375
1376 fn dump_render_output(content: &str, cursor_pos: usize, output: &LineRenderOutput) {
1378 eprintln!("\n=== RENDER DEBUG ===");
1379 eprintln!("Content: {:?}", content);
1380 eprintln!("Cursor position: {}", cursor_pos);
1381 eprintln!("Hardware cursor (output.cursor): {:?}", output.cursor);
1382 eprintln!("Last line end: {:?}", output.last_line_end);
1383 eprintln!("Content lines rendered: {}", output.content_lines_rendered);
1384 eprintln!("\nRendered lines:");
1385 for (line_idx, line) in output.lines.iter().enumerate() {
1386 eprintln!(" Line {}: {} spans", line_idx, line.spans.len());
1387 for (span_idx, span) in line.spans.iter().enumerate() {
1388 let has_reversed = span
1389 .style
1390 .add_modifier
1391 .contains(ratatui::style::Modifier::REVERSED);
1392 let bg_color = format!("{:?}", span.style.bg);
1393 eprintln!(
1394 " Span {}: {:?} (REVERSED: {}, BG: {})",
1395 span_idx, span.content, has_reversed, bg_color
1396 );
1397 }
1398 }
1399 eprintln!("===================\n");
1400 }
1401
1402 fn get_final_cursor(content: &str, cursor_pos: usize) -> Option<(u16, u16)> {
1405 let (output, buffer_len, buffer_newline, cursor_pos) =
1406 render_output_for(content, cursor_pos);
1407
1408 let all_cursors = count_all_cursors(&output);
1410
1411 assert!(
1414 all_cursors.len() <= 1,
1415 "Expected at most 1 cursor in rendered output, found {} at positions: {:?}",
1416 all_cursors.len(),
1417 all_cursors
1418 );
1419
1420 let final_cursor = resolve_cursor_fallback(
1421 output.cursor,
1422 cursor_pos,
1423 buffer_len,
1424 buffer_newline,
1425 output.last_line_end,
1426 output.content_lines_rendered,
1427 0, );
1429
1430 if all_cursors.len() > 1 || (all_cursors.len() == 1 && Some(all_cursors[0]) != final_cursor)
1432 {
1433 dump_render_output(content, cursor_pos, &output);
1434 }
1435
1436 if let Some(rendered_cursor) = all_cursors.first() {
1438 assert_eq!(
1439 Some(*rendered_cursor),
1440 final_cursor,
1441 "Rendered cursor at {:?} doesn't match final cursor {:?}",
1442 rendered_cursor,
1443 final_cursor
1444 );
1445 }
1446
1447 assert!(
1449 final_cursor.is_some(),
1450 "Expected a final cursor position, but got None. Rendered cursors: {:?}",
1451 all_cursors
1452 );
1453
1454 final_cursor
1455 }
1456
1457 fn check_typing_at_cursor(
1459 content: &str,
1460 cursor_pos: usize,
1461 char_to_type: char,
1462 ) -> (Option<(u16, u16)>, String) {
1463 let cursor_before = get_final_cursor(content, cursor_pos);
1465
1466 let mut new_content = content.to_string();
1468 if cursor_pos <= content.len() {
1469 new_content.insert(cursor_pos, char_to_type);
1470 }
1471
1472 (cursor_before, new_content)
1473 }
1474
1475 #[test]
1476 fn e2e_cursor_at_start_of_nonempty_line() {
1477 let cursor = get_final_cursor("abc", 0);
1479 assert_eq!(cursor, Some((0, 0)), "Cursor should be at column 0, line 0");
1480
1481 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 0, 'X');
1482 assert_eq!(
1483 new_content, "Xabc",
1484 "Typing should insert at cursor position"
1485 );
1486 assert_eq!(cursor_pos, Some((0, 0)));
1487 }
1488
1489 #[test]
1490 fn e2e_cursor_in_middle_of_line() {
1491 let cursor = get_final_cursor("abc", 1);
1493 assert_eq!(cursor, Some((1, 0)), "Cursor should be at column 1, line 0");
1494
1495 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 1, 'X');
1496 assert_eq!(
1497 new_content, "aXbc",
1498 "Typing should insert at cursor position"
1499 );
1500 assert_eq!(cursor_pos, Some((1, 0)));
1501 }
1502
1503 #[test]
1504 fn e2e_cursor_at_end_of_line_no_newline() {
1505 let cursor = get_final_cursor("abc", 3);
1507 assert_eq!(
1508 cursor,
1509 Some((3, 0)),
1510 "Cursor should be at column 3, line 0 (after last char)"
1511 );
1512
1513 let (cursor_pos, new_content) = check_typing_at_cursor("abc", 3, 'X');
1514 assert_eq!(new_content, "abcX", "Typing should append at end");
1515 assert_eq!(cursor_pos, Some((3, 0)));
1516 }
1517
1518 #[test]
1519 fn e2e_cursor_at_empty_line() {
1520 let cursor = get_final_cursor("\n", 0);
1522 assert_eq!(
1523 cursor,
1524 Some((0, 0)),
1525 "Cursor on empty line should be at column 0"
1526 );
1527
1528 let (cursor_pos, new_content) = check_typing_at_cursor("\n", 0, 'X');
1529 assert_eq!(new_content, "X\n", "Typing should insert before newline");
1530 assert_eq!(cursor_pos, Some((0, 0)));
1531 }
1532
1533 #[test]
1534 fn e2e_cursor_after_newline_at_eof() {
1535 let cursor = get_final_cursor("abc\n", 4);
1537 assert_eq!(
1538 cursor,
1539 Some((0, 1)),
1540 "Cursor after newline at EOF should be on next line"
1541 );
1542
1543 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 4, 'X');
1544 assert_eq!(new_content, "abc\nX", "Typing should insert on new line");
1545 assert_eq!(cursor_pos, Some((0, 1)));
1546 }
1547
1548 #[test]
1549 fn e2e_cursor_on_newline_with_content() {
1550 let cursor = get_final_cursor("abc\n", 3);
1552 assert_eq!(
1553 cursor,
1554 Some((3, 0)),
1555 "Cursor on newline after content should be after last char"
1556 );
1557
1558 let (cursor_pos, new_content) = check_typing_at_cursor("abc\n", 3, 'X');
1559 assert_eq!(new_content, "abcX\n", "Typing should insert before newline");
1560 assert_eq!(cursor_pos, Some((3, 0)));
1561 }
1562
1563 #[test]
1564 fn e2e_cursor_multiline_start_of_second_line() {
1565 let cursor = get_final_cursor("abc\ndef", 4);
1567 assert_eq!(
1568 cursor,
1569 Some((0, 1)),
1570 "Cursor at start of second line should be at column 0, line 1"
1571 );
1572
1573 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 4, 'X');
1574 assert_eq!(
1575 new_content, "abc\nXdef",
1576 "Typing should insert at start of second line"
1577 );
1578 assert_eq!(cursor_pos, Some((0, 1)));
1579 }
1580
1581 #[test]
1582 fn e2e_cursor_multiline_end_of_first_line() {
1583 let cursor = get_final_cursor("abc\ndef", 3);
1585 assert_eq!(
1586 cursor,
1587 Some((3, 0)),
1588 "Cursor on newline of first line should be after content"
1589 );
1590
1591 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef", 3, 'X');
1592 assert_eq!(
1593 new_content, "abcX\ndef",
1594 "Typing should insert before newline"
1595 );
1596 assert_eq!(cursor_pos, Some((3, 0)));
1597 }
1598
1599 #[test]
1600 fn e2e_cursor_empty_buffer() {
1601 let cursor = get_final_cursor("", 0);
1603 assert_eq!(
1604 cursor,
1605 Some((0, 0)),
1606 "Cursor in empty buffer should be at origin"
1607 );
1608
1609 let (cursor_pos, new_content) = check_typing_at_cursor("", 0, 'X');
1610 assert_eq!(
1611 new_content, "X",
1612 "Typing in empty buffer should insert character"
1613 );
1614 assert_eq!(cursor_pos, Some((0, 0)));
1615 }
1616
1617 #[test]
1618 fn e2e_cursor_empty_buffer_with_gutters() {
1619 let (output, buffer_len, buffer_newline, cursor_pos) =
1623 render_output_for_with_gutters("", 0, true);
1624
1625 let gutter_width = {
1629 let mut state = EditorState::new(20, 6, 1024, test_fs());
1630 state.margins.left_config.enabled = true;
1631 state.margins.update_width_for_buffer(1, true);
1632 state.margins.left_total_width()
1633 };
1634 assert!(gutter_width > 0, "Gutter width should be > 0 when enabled");
1635
1636 assert_eq!(
1640 output.cursor,
1641 Some((gutter_width as u16, 0)),
1642 "RENDERED cursor in empty buffer should be at gutter_width ({}), got {:?}",
1643 gutter_width,
1644 output.cursor
1645 );
1646
1647 let final_cursor = resolve_cursor_fallback(
1648 output.cursor,
1649 cursor_pos,
1650 buffer_len,
1651 buffer_newline,
1652 output.last_line_end,
1653 output.content_lines_rendered,
1654 gutter_width,
1655 );
1656
1657 assert_eq!(
1659 final_cursor,
1660 Some((gutter_width as u16, 0)),
1661 "Cursor in empty buffer with gutters should be at gutter_width, not column 0"
1662 );
1663 }
1664
1665 #[test]
1666 fn e2e_cursor_between_empty_lines() {
1667 let cursor = get_final_cursor("\n\n", 1);
1669 assert_eq!(cursor, Some((0, 1)), "Cursor on second empty line");
1670
1671 let (cursor_pos, new_content) = check_typing_at_cursor("\n\n", 1, 'X');
1672 assert_eq!(new_content, "\nX\n", "Typing should insert on second line");
1673 assert_eq!(cursor_pos, Some((0, 1)));
1674 }
1675
1676 #[test]
1677 fn e2e_cursor_at_eof_after_multiple_lines() {
1678 let cursor = get_final_cursor("abc\ndef\nghi", 11);
1680 assert_eq!(
1681 cursor,
1682 Some((3, 2)),
1683 "Cursor at EOF after 'i' should be at column 3, line 2"
1684 );
1685
1686 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi", 11, 'X');
1687 assert_eq!(new_content, "abc\ndef\nghiX", "Typing should append at end");
1688 assert_eq!(cursor_pos, Some((3, 2)));
1689 }
1690
1691 #[test]
1692 fn e2e_cursor_at_eof_with_trailing_newline() {
1693 let cursor = get_final_cursor("abc\ndef\nghi\n", 12);
1695 assert_eq!(
1696 cursor,
1697 Some((0, 3)),
1698 "Cursor after trailing newline should be on line 3"
1699 );
1700
1701 let (cursor_pos, new_content) = check_typing_at_cursor("abc\ndef\nghi\n", 12, 'X');
1702 assert_eq!(
1703 new_content, "abc\ndef\nghi\nX",
1704 "Typing should insert on new line"
1705 );
1706 assert_eq!(cursor_pos, Some((0, 3)));
1707 }
1708
1709 #[test]
1710 fn e2e_jump_to_end_of_buffer_no_trailing_newline() {
1711 let content = "abc\ndef\nghi";
1713
1714 let cursor_at_start = get_final_cursor(content, 0);
1716 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
1717
1718 let cursor_at_eof = get_final_cursor(content, 11);
1720 assert_eq!(
1721 cursor_at_eof,
1722 Some((3, 2)),
1723 "After Ctrl+End, cursor at column 3, line 2"
1724 );
1725
1726 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 11, 'X');
1728 assert_eq!(cursor_before_typing, Some((3, 2)));
1729 assert_eq!(new_content, "abc\ndef\nghiX", "Character appended at end");
1730
1731 let cursor_after_typing = get_final_cursor(&new_content, 12);
1733 assert_eq!(
1734 cursor_after_typing,
1735 Some((4, 2)),
1736 "After typing, cursor moved to column 4"
1737 );
1738
1739 let cursor_moved_away = get_final_cursor(&new_content, 0);
1741 assert_eq!(cursor_moved_away, Some((0, 0)), "Cursor moved to start");
1742 }
1745
1746 #[test]
1747 fn e2e_jump_to_end_of_buffer_with_trailing_newline() {
1748 let content = "abc\ndef\nghi\n";
1750
1751 let cursor_at_start = get_final_cursor(content, 0);
1753 assert_eq!(cursor_at_start, Some((0, 0)), "Cursor starts at beginning");
1754
1755 let cursor_at_eof = get_final_cursor(content, 12);
1757 assert_eq!(
1758 cursor_at_eof,
1759 Some((0, 3)),
1760 "After Ctrl+End, cursor at column 0, line 3 (new line)"
1761 );
1762
1763 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 12, 'X');
1765 assert_eq!(cursor_before_typing, Some((0, 3)));
1766 assert_eq!(
1767 new_content, "abc\ndef\nghi\nX",
1768 "Character inserted on new line"
1769 );
1770
1771 let cursor_after_typing = get_final_cursor(&new_content, 13);
1773 assert_eq!(
1774 cursor_after_typing,
1775 Some((1, 3)),
1776 "After typing, cursor should be at column 1, line 3"
1777 );
1778
1779 let cursor_moved_away = get_final_cursor(&new_content, 4);
1781 assert_eq!(
1782 cursor_moved_away,
1783 Some((0, 1)),
1784 "Cursor moved to start of line 1 (position 4 = start of 'def')"
1785 );
1786 }
1787
1788 #[test]
1789 fn e2e_jump_to_end_of_empty_buffer() {
1790 let content = "";
1792
1793 let cursor_at_eof = get_final_cursor(content, 0);
1794 assert_eq!(
1795 cursor_at_eof,
1796 Some((0, 0)),
1797 "Empty buffer: cursor at origin"
1798 );
1799
1800 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 0, 'X');
1802 assert_eq!(cursor_before_typing, Some((0, 0)));
1803 assert_eq!(new_content, "X", "Character inserted");
1804
1805 let cursor_after_typing = get_final_cursor(&new_content, 1);
1807 assert_eq!(
1808 cursor_after_typing,
1809 Some((1, 0)),
1810 "After typing, cursor at column 1"
1811 );
1812
1813 let cursor_moved_away = get_final_cursor(&new_content, 0);
1815 assert_eq!(
1816 cursor_moved_away,
1817 Some((0, 0)),
1818 "Cursor moved back to start"
1819 );
1820 }
1821
1822 #[test]
1823 fn e2e_jump_to_end_of_single_empty_line() {
1824 let content = "\n";
1826
1827 let cursor_on_newline = get_final_cursor(content, 0);
1829 assert_eq!(
1830 cursor_on_newline,
1831 Some((0, 0)),
1832 "Cursor on the newline character"
1833 );
1834
1835 let cursor_at_eof = get_final_cursor(content, 1);
1837 assert_eq!(
1838 cursor_at_eof,
1839 Some((0, 1)),
1840 "After Ctrl+End, cursor on line 1"
1841 );
1842
1843 let (cursor_before_typing, new_content) = check_typing_at_cursor(content, 1, 'X');
1845 assert_eq!(cursor_before_typing, Some((0, 1)));
1846 assert_eq!(new_content, "\nX", "Character on second line");
1847
1848 let cursor_after_typing = get_final_cursor(&new_content, 2);
1849 assert_eq!(
1850 cursor_after_typing,
1851 Some((1, 1)),
1852 "After typing, cursor at column 1, line 1"
1853 );
1854
1855 let cursor_moved_away = get_final_cursor(&new_content, 0);
1857 assert_eq!(
1858 cursor_moved_away,
1859 Some((0, 0)),
1860 "Cursor moved to the newline on line 0"
1861 );
1862 }
1863 use fresh_core::api::ViewTokenWireKind;
1874
1875 fn extract_token_offsets(tokens: &[ViewTokenWire]) -> Vec<(String, Option<usize>)> {
1877 tokens
1878 .iter()
1879 .map(|t| {
1880 let kind_str = match &t.kind {
1881 ViewTokenWireKind::Text(s) => format!("Text({})", s),
1882 ViewTokenWireKind::Newline => "Newline".to_string(),
1883 ViewTokenWireKind::Space => "Space".to_string(),
1884 ViewTokenWireKind::Break => "Break".to_string(),
1885 ViewTokenWireKind::BinaryByte(b) => format!("Byte(0x{:02x})", b),
1886 };
1887 (kind_str, t.source_offset)
1888 })
1889 .collect()
1890 }
1891
1892 #[test]
1895 fn test_build_base_tokens_crlf_single_line() {
1896 let content = b"abc\r\n";
1898 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1899 buffer.set_line_ending(LineEnding::CRLF);
1900
1901 let tokens = SplitRenderer::build_base_tokens_for_hook(
1902 &mut buffer,
1903 0, 80, 10, false, LineEnding::CRLF,
1908 );
1909
1910 let offsets = extract_token_offsets(&tokens);
1911
1912 assert!(
1915 offsets
1916 .iter()
1917 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1918 "Expected Text(abc) at offset 0, got: {:?}",
1919 offsets
1920 );
1921 assert!(
1922 offsets
1923 .iter()
1924 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1925 "Expected Newline at offset 3 (\\r position), got: {:?}",
1926 offsets
1927 );
1928
1929 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
1931 assert_eq!(
1932 newline_count, 1,
1933 "Should have exactly 1 Newline token for CRLF, got {}: {:?}",
1934 newline_count, offsets
1935 );
1936 }
1937
1938 #[test]
1941 fn test_build_base_tokens_crlf_multiple_lines() {
1942 let content = b"abc\r\ndef\r\nghi\r\n";
1947 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1948 buffer.set_line_ending(LineEnding::CRLF);
1949
1950 let tokens = SplitRenderer::build_base_tokens_for_hook(
1951 &mut buffer,
1952 0,
1953 80,
1954 10,
1955 false,
1956 LineEnding::CRLF,
1957 );
1958
1959 let offsets = extract_token_offsets(&tokens);
1960
1961 assert!(
1968 offsets
1969 .iter()
1970 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
1971 "Line 1: Expected Text(abc) at 0, got: {:?}",
1972 offsets
1973 );
1974 assert!(
1975 offsets
1976 .iter()
1977 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
1978 "Line 1: Expected Newline at 3, got: {:?}",
1979 offsets
1980 );
1981
1982 assert!(
1984 offsets
1985 .iter()
1986 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
1987 "Line 2: Expected Text(def) at 5, got: {:?}",
1988 offsets
1989 );
1990 assert!(
1991 offsets
1992 .iter()
1993 .any(|(kind, off)| kind == "Newline" && *off == Some(8)),
1994 "Line 2: Expected Newline at 8, got: {:?}",
1995 offsets
1996 );
1997
1998 assert!(
2000 offsets
2001 .iter()
2002 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
2003 "Line 3: Expected Text(ghi) at 10, got: {:?}",
2004 offsets
2005 );
2006 assert!(
2007 offsets
2008 .iter()
2009 .any(|(kind, off)| kind == "Newline" && *off == Some(13)),
2010 "Line 3: Expected Newline at 13, got: {:?}",
2011 offsets
2012 );
2013
2014 let newline_count = offsets.iter().filter(|(k, _)| k == "Newline").count();
2016 assert_eq!(newline_count, 3, "Should have 3 Newline tokens");
2017 }
2018
2019 #[test]
2022 fn test_build_base_tokens_lf_mode_for_comparison() {
2023 let content = b"abc\ndef\n";
2027 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2028 buffer.set_line_ending(LineEnding::LF);
2029
2030 let tokens = SplitRenderer::build_base_tokens_for_hook(
2031 &mut buffer,
2032 0,
2033 80,
2034 10,
2035 false,
2036 LineEnding::LF,
2037 );
2038
2039 let offsets = extract_token_offsets(&tokens);
2040
2041 assert!(
2043 offsets
2044 .iter()
2045 .any(|(kind, off)| kind == "Text(abc)" && *off == Some(0)),
2046 "LF Line 1: Expected Text(abc) at 0"
2047 );
2048 assert!(
2049 offsets
2050 .iter()
2051 .any(|(kind, off)| kind == "Newline" && *off == Some(3)),
2052 "LF Line 1: Expected Newline at 3"
2053 );
2054 assert!(
2055 offsets
2056 .iter()
2057 .any(|(kind, off)| kind == "Text(def)" && *off == Some(4)),
2058 "LF Line 2: Expected Text(def) at 4"
2059 );
2060 assert!(
2061 offsets
2062 .iter()
2063 .any(|(kind, off)| kind == "Newline" && *off == Some(7)),
2064 "LF Line 2: Expected Newline at 7"
2065 );
2066 }
2067
2068 #[test]
2071 fn test_build_base_tokens_crlf_in_lf_mode_shows_control_char() {
2072 let content = b"abc\r\n";
2074 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2075 buffer.set_line_ending(LineEnding::LF); let tokens = SplitRenderer::build_base_tokens_for_hook(
2078 &mut buffer,
2079 0,
2080 80,
2081 10,
2082 false,
2083 LineEnding::LF,
2084 );
2085
2086 let offsets = extract_token_offsets(&tokens);
2087
2088 assert!(
2090 offsets.iter().any(|(kind, _)| kind == "Byte(0x0d)"),
2091 "LF mode should render \\r as control char <0D>, got: {:?}",
2092 offsets
2093 );
2094 }
2095
2096 #[test]
2099 fn test_build_base_tokens_crlf_from_middle() {
2100 let content = b"abc\r\ndef\r\nghi\r\n";
2103 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2104 buffer.set_line_ending(LineEnding::CRLF);
2105
2106 let tokens = SplitRenderer::build_base_tokens_for_hook(
2107 &mut buffer,
2108 5, 80,
2110 10,
2111 false,
2112 LineEnding::CRLF,
2113 );
2114
2115 let offsets = extract_token_offsets(&tokens);
2116
2117 assert!(
2121 offsets
2122 .iter()
2123 .any(|(kind, off)| kind == "Text(def)" && *off == Some(5)),
2124 "Starting from byte 5: Expected Text(def) at 5, got: {:?}",
2125 offsets
2126 );
2127 assert!(
2128 offsets
2129 .iter()
2130 .any(|(kind, off)| kind == "Text(ghi)" && *off == Some(10)),
2131 "Starting from byte 5: Expected Text(ghi) at 10, got: {:?}",
2132 offsets
2133 );
2134 }
2135
2136 #[test]
2139 fn test_crlf_highlight_span_lookup() {
2140 use crate::view::ui::view_pipeline::ViewLineIterator;
2141
2142 let content = b"int x;\r\nint y;\r\n";
2147 let mut buffer = Buffer::from_bytes(content.to_vec(), test_fs());
2148 buffer.set_line_ending(LineEnding::CRLF);
2149
2150 let tokens = SplitRenderer::build_base_tokens_for_hook(
2152 &mut buffer,
2153 0,
2154 80,
2155 10,
2156 false,
2157 LineEnding::CRLF,
2158 );
2159
2160 let offsets = extract_token_offsets(&tokens);
2162 eprintln!("Tokens: {:?}", offsets);
2163
2164 let view_lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4, false).collect();
2166 assert_eq!(view_lines.len(), 2, "Should have 2 view lines");
2167
2168 eprintln!(
2171 "Line 1 char_source_bytes: {:?}",
2172 view_lines[0].char_source_bytes
2173 );
2174 assert_eq!(
2175 view_lines[0].char_source_bytes.len(),
2176 7,
2177 "Line 1 should have 7 chars: 'i','n','t',' ','x',';','\\n'"
2178 );
2179 assert_eq!(
2181 view_lines[0].char_source_bytes[0],
2182 Some(0),
2183 "Line 1 'i' -> byte 0"
2184 );
2185 assert_eq!(
2186 view_lines[0].char_source_bytes[4],
2187 Some(4),
2188 "Line 1 'x' -> byte 4"
2189 );
2190 assert_eq!(
2191 view_lines[0].char_source_bytes[5],
2192 Some(5),
2193 "Line 1 ';' -> byte 5"
2194 );
2195 assert_eq!(
2196 view_lines[0].char_source_bytes[6],
2197 Some(6),
2198 "Line 1 newline -> byte 6 (\\r pos)"
2199 );
2200
2201 eprintln!(
2203 "Line 2 char_source_bytes: {:?}",
2204 view_lines[1].char_source_bytes
2205 );
2206 assert_eq!(
2207 view_lines[1].char_source_bytes.len(),
2208 7,
2209 "Line 2 should have 7 chars: 'i','n','t',' ','y',';','\\n'"
2210 );
2211 assert_eq!(
2213 view_lines[1].char_source_bytes[0],
2214 Some(8),
2215 "Line 2 'i' -> byte 8"
2216 );
2217 assert_eq!(
2218 view_lines[1].char_source_bytes[4],
2219 Some(12),
2220 "Line 2 'y' -> byte 12"
2221 );
2222 assert_eq!(
2223 view_lines[1].char_source_bytes[5],
2224 Some(13),
2225 "Line 2 ';' -> byte 13"
2226 );
2227 assert_eq!(
2228 view_lines[1].char_source_bytes[6],
2229 Some(14),
2230 "Line 2 newline -> byte 14 (\\r pos)"
2231 );
2232
2233 let simulated_highlight_spans = [
2237 (0usize..3usize, "keyword"),
2239 (8usize..11usize, "keyword"),
2241 ];
2242
2243 for (line_idx, view_line) in view_lines.iter().enumerate() {
2245 for (char_idx, byte_pos) in view_line.char_source_bytes.iter().enumerate() {
2246 if let Some(bp) = byte_pos {
2247 let in_span = simulated_highlight_spans
2248 .iter()
2249 .find(|(range, _)| range.contains(bp))
2250 .map(|(_, name)| *name);
2251
2252 let expected_in_keyword = char_idx < 3;
2254 let actually_in_keyword = in_span == Some("keyword");
2255
2256 if expected_in_keyword != actually_in_keyword {
2257 panic!(
2258 "CRLF offset drift detected! Line {} char {} (byte {}): expected keyword={}, got keyword={}",
2259 line_idx + 1, char_idx, bp, expected_in_keyword, actually_in_keyword
2260 );
2261 }
2262 }
2263 }
2264 }
2265 }
2266
2267 #[test]
2270 fn test_apply_wrapping_transform_breaks_long_lines() {
2271 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2272
2273 let long_text = "x".repeat(25_000);
2275 let tokens = vec![
2276 ViewTokenWire {
2277 kind: ViewTokenWireKind::Text(long_text),
2278 source_offset: Some(0),
2279 style: None,
2280 },
2281 ViewTokenWire {
2282 kind: ViewTokenWireKind::Newline,
2283 source_offset: Some(25_000),
2284 style: None,
2285 },
2286 ];
2287
2288 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2290
2291 let break_count = wrapped
2293 .iter()
2294 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
2295 .count();
2296
2297 assert!(
2298 break_count >= 2,
2299 "25K char line should have at least 2 breaks at 10K width, got {}",
2300 break_count
2301 );
2302
2303 let total_chars: usize = wrapped
2305 .iter()
2306 .filter_map(|t| match &t.kind {
2307 ViewTokenWireKind::Text(s) => Some(s.len()),
2308 _ => None,
2309 })
2310 .sum();
2311
2312 assert_eq!(
2313 total_chars, 25_000,
2314 "Total character count should be preserved after wrapping"
2315 );
2316 }
2317
2318 #[cfg(test)]
2344 mod wrap_boundary_property {
2345 use super::apply_wrapping_transform;
2346 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2347 use proptest::prelude::*;
2348 use unicode_segmentation::UnicodeSegmentation;
2349
2350 const MAX_LOOKBACK: usize = 16;
2354
2355 fn tokens_from_input(input: &str) -> Vec<ViewTokenWire> {
2356 let mut tokens: Vec<ViewTokenWire> = Vec::new();
2357 let mut buf = String::new();
2358 let mut buf_start = 0usize;
2359 for (i, c) in input.char_indices() {
2360 if c == ' ' {
2361 if !buf.is_empty() {
2362 tokens.push(ViewTokenWire {
2363 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
2364 source_offset: Some(buf_start),
2365 style: None,
2366 });
2367 }
2368 tokens.push(ViewTokenWire {
2369 kind: ViewTokenWireKind::Space,
2370 source_offset: Some(i),
2371 style: None,
2372 });
2373 buf_start = i + 1;
2374 } else {
2375 if buf.is_empty() {
2376 buf_start = i;
2377 }
2378 buf.push(c);
2379 }
2380 }
2381 if !buf.is_empty() {
2382 tokens.push(ViewTokenWire {
2383 kind: ViewTokenWireKind::Text(buf.clone()),
2384 source_offset: Some(buf_start),
2385 style: None,
2386 });
2387 }
2388 tokens.push(ViewTokenWire {
2389 kind: ViewTokenWireKind::Newline,
2390 source_offset: Some(input.len()),
2391 style: None,
2392 });
2393 tokens
2394 }
2395
2396 fn visual_rows(wrapped: &[ViewTokenWire]) -> Vec<String> {
2401 let mut rows: Vec<String> = vec![String::new()];
2402 for t in wrapped {
2403 match &t.kind {
2404 ViewTokenWireKind::Text(s) => {
2405 rows.last_mut().unwrap().push_str(s);
2406 }
2407 ViewTokenWireKind::Space => {
2408 rows.last_mut().unwrap().push(' ');
2409 }
2410 ViewTokenWireKind::Break => {
2411 rows.push(String::new());
2412 }
2413 ViewTokenWireKind::Newline => {
2414 }
2417 _ => {}
2418 }
2419 }
2420 rows
2421 }
2422
2423 proptest! {
2424 #![proptest_config(ProptestConfig {
2428 cases: 256,
2429 .. ProptestConfig::default()
2430 })]
2431
2432 #[test]
2435 fn prop_wrap_respects_boundaries(
2436 input in "[a-zA-Z0-9().,:;/_=+ \\-]{1,120}",
2437 content_width in 5usize..40,
2438 ) {
2439 let tokens = tokens_from_input(&input);
2442 let wrapped = apply_wrapping_transform(tokens, content_width, 0, false);
2443 let rows = visual_rows(&wrapped);
2444
2445 for (i, row) in rows.iter().enumerate() {
2447 prop_assert!(
2448 row.chars().count() <= content_width,
2449 "row {i} {:?} has width {} > content_width {content_width}",
2450 row,
2451 row.chars().count(),
2452 );
2453 }
2454
2455 let reconstructed: String = rows.concat();
2457 prop_assert_eq!(
2458 &reconstructed,
2459 &input,
2460 "reconstruction differs from input"
2461 );
2462
2463 let boundaries: std::collections::BTreeSet<usize> = input
2467 .split_word_bound_indices()
2468 .map(|(i, _)| i)
2469 .chain(std::iter::once(input.len()))
2470 .collect();
2471
2472 let mut cursor_bytes = 0usize;
2473 let mut cursor_chars = 0usize;
2474 for (i, row) in rows.iter().enumerate() {
2475 let row_bytes = row.len();
2476 let row_chars = row.chars().count();
2477 let row_end_bytes = cursor_bytes + row_bytes;
2478 let row_end_chars = cursor_chars + row_chars;
2479 let is_last = i + 1 == rows.len();
2480
2481 if !is_last {
2482 let input_bytes = input.as_bytes();
2488 let prev_is_space =
2489 row_end_bytes > 0 && input_bytes[row_end_bytes - 1] == b' ';
2490 let next_is_space = row_end_bytes < input_bytes.len()
2491 && input_bytes[row_end_bytes] == b' ';
2492 let is_mid_text = !prev_is_space && !next_is_space;
2493 if !is_mid_text {
2494 cursor_bytes = row_end_bytes;
2495 cursor_chars = row_end_chars;
2496 continue;
2497 }
2498
2499 let hard_cap_chars = cursor_chars + content_width;
2502 let hard_cap_bytes = char_index_to_byte(&input, hard_cap_chars);
2503 let floor_chars = cursor_chars
2504 + content_width.saturating_sub(MAX_LOOKBACK).max(content_width / 2);
2505 let floor_bytes = char_index_to_byte(&input, floor_chars);
2506
2507 let max_in_window = boundaries
2515 .range(floor_bytes..=hard_cap_bytes)
2516 .next_back()
2517 .copied();
2518 match max_in_window {
2519 Some(max_b) => {
2520 prop_assert_eq!(
2521 row_end_bytes,
2522 max_b,
2523 "split at byte {} but largest word boundary in \
2524 [floor={}, hard_cap={}] is {}; row={:?}, input={:?}",
2525 row_end_bytes,
2526 floor_bytes,
2527 hard_cap_bytes,
2528 max_b,
2529 row,
2530 input,
2531 );
2532 }
2533 None => {
2534 prop_assert_eq!(
2535 row_end_bytes,
2536 hard_cap_bytes,
2537 "no word boundary in [floor={}, hard_cap={}], so \
2538 char-split must land at hard_cap, but split is at \
2539 byte {}; row={:?}, input={:?}",
2540 floor_bytes,
2541 hard_cap_bytes,
2542 row_end_bytes,
2543 row,
2544 input,
2545 );
2546 }
2547 }
2548 }
2549
2550 cursor_bytes = row_end_bytes;
2551 cursor_chars = row_end_chars;
2552 }
2553 }
2554 }
2555
2556 fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
2559 s.char_indices()
2560 .nth(char_idx)
2561 .map(|(b, _)| b)
2562 .unwrap_or(s.len())
2563 }
2564 }
2565
2566 fn tokenize_for_wrap(text: &str) -> Vec<fresh_core::api::ViewTokenWire> {
2571 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2572 let mut tokens = Vec::new();
2573 let mut buf = String::new();
2574 let mut buf_start: Option<usize> = None;
2575 for (i, ch) in text.char_indices() {
2576 if ch == ' ' {
2577 if !buf.is_empty() {
2578 tokens.push(ViewTokenWire {
2579 source_offset: buf_start,
2580 kind: ViewTokenWireKind::Text(std::mem::take(&mut buf)),
2581 style: None,
2582 });
2583 buf_start = None;
2584 }
2585 tokens.push(ViewTokenWire {
2586 source_offset: Some(i),
2587 kind: ViewTokenWireKind::Space,
2588 style: None,
2589 });
2590 } else {
2591 if buf.is_empty() {
2592 buf_start = Some(i);
2593 }
2594 buf.push(ch);
2595 }
2596 }
2597 if !buf.is_empty() {
2598 tokens.push(ViewTokenWire {
2599 source_offset: buf_start,
2600 kind: ViewTokenWireKind::Text(buf),
2601 style: None,
2602 });
2603 }
2604 tokens
2605 }
2606
2607 fn rows_from_wrapped(wrapped: &[fresh_core::api::ViewTokenWire]) -> Vec<String> {
2610 use fresh_core::api::ViewTokenWireKind;
2611 let mut rows: Vec<String> = vec![String::new()];
2612 for tok in wrapped {
2613 match &tok.kind {
2614 ViewTokenWireKind::Text(s) => rows.last_mut().unwrap().push_str(s),
2615 ViewTokenWireKind::Space => rows.last_mut().unwrap().push(' '),
2616 ViewTokenWireKind::Newline => {}
2617 ViewTokenWireKind::Break => rows.push(String::new()),
2618 ViewTokenWireKind::BinaryByte(_) => {}
2619 }
2620 }
2621 if rows.last().map(|r| r.is_empty()).unwrap_or(false) {
2622 rows.pop();
2623 }
2624 rows
2625 }
2626
2627 #[test]
2633 fn issue_1363_no_leading_space_on_continuation_row() {
2634 let tokens = tokenize_for_wrap("AAAAA BBBBBB CCCC");
2635 let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
2636 let rows = rows_from_wrapped(&wrapped);
2637 assert_eq!(rows.len(), 2, "expected 2 rows, got {:?}", rows);
2638 for (i, row) in rows.iter().enumerate() {
2639 assert!(
2640 !row.starts_with(' '),
2641 "row {i} {:?} starts with whitespace (issue #1363): rows = {:?}",
2642 row,
2643 rows,
2644 );
2645 assert!(
2646 row.chars().count() <= 12,
2647 "row {i} {:?} width {} exceeds eff_width 12 (issue #1363): no char may overflow",
2648 row,
2649 row.chars().count(),
2650 );
2651 }
2652 }
2653
2654 #[test]
2658 fn issue_1363_back_up_preserves_content() {
2659 let input = "AAAAA BBBBBB CCCC";
2660 let tokens = tokenize_for_wrap(input);
2661 let wrapped = apply_wrapping_transform(tokens, 12, 0, false);
2662 let rows = rows_from_wrapped(&wrapped);
2663 let reconstructed: String = rows.concat();
2664 assert_eq!(reconstructed, input, "rows = {:?}", rows);
2665 }
2666
2667 #[test]
2674 fn issue_1363_single_word_row_falls_back() {
2675 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2676 let tokens = vec![
2677 ViewTokenWire {
2678 source_offset: Some(0),
2679 kind: ViewTokenWireKind::Text("XXXXXXXX".to_string()),
2680 style: None,
2681 },
2682 ViewTokenWire {
2683 source_offset: Some(8),
2684 kind: ViewTokenWireKind::Space,
2685 style: None,
2686 },
2687 ViewTokenWire {
2688 source_offset: Some(9),
2689 kind: ViewTokenWireKind::Text("YYYY".to_string()),
2690 style: None,
2691 },
2692 ];
2693 let wrapped = apply_wrapping_transform(tokens, 8, 0, false);
2694 let rows = rows_from_wrapped(&wrapped);
2695 for row in &rows {
2696 assert!(
2697 row.chars().count() <= 8,
2698 "row {:?} exceeds eff_width 8 in fallback case",
2699 row,
2700 );
2701 }
2702 }
2703
2704 #[test]
2706 fn test_apply_wrapping_transform_preserves_short_lines() {
2707 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2708
2709 let short_text = "x".repeat(100);
2711 let tokens = vec![
2712 ViewTokenWire {
2713 kind: ViewTokenWireKind::Text(short_text.clone()),
2714 source_offset: Some(0),
2715 style: None,
2716 },
2717 ViewTokenWire {
2718 kind: ViewTokenWireKind::Newline,
2719 source_offset: Some(100),
2720 style: None,
2721 },
2722 ];
2723
2724 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2726
2727 let break_count = wrapped
2729 .iter()
2730 .filter(|t| matches!(t.kind, ViewTokenWireKind::Break))
2731 .count();
2732
2733 assert_eq!(
2734 break_count, 0,
2735 "Short lines should not have any breaks, got {}",
2736 break_count
2737 );
2738
2739 let text_tokens: Vec<_> = wrapped
2741 .iter()
2742 .filter_map(|t| match &t.kind {
2743 ViewTokenWireKind::Text(s) => Some(s.clone()),
2744 _ => None,
2745 })
2746 .collect();
2747
2748 assert_eq!(text_tokens.len(), 1, "Should have exactly one Text token");
2749 assert_eq!(
2750 text_tokens[0], short_text,
2751 "Text content should be unchanged"
2752 );
2753 }
2754
2755 #[test]
2758 fn test_large_single_line_sequential_data_preserved() {
2759 use crate::view::ui::view_pipeline::ViewLineIterator;
2760 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2761
2762 let num_markers = 5_000; let content: String = (1..=num_markers).map(|i| format!("[{:05}]", i)).collect();
2766
2767 let tokens = vec![
2769 ViewTokenWire {
2770 kind: ViewTokenWireKind::Text(content.clone()),
2771 source_offset: Some(0),
2772 style: None,
2773 },
2774 ViewTokenWire {
2775 kind: ViewTokenWireKind::Newline,
2776 source_offset: Some(content.len()),
2777 style: None,
2778 },
2779 ];
2780
2781 let wrapped = apply_wrapping_transform(tokens, MAX_SAFE_LINE_WIDTH, 0, false);
2783
2784 let view_lines: Vec<_> = ViewLineIterator::new(&wrapped, false, false, 4, false).collect();
2786
2787 let mut reconstructed = String::new();
2789 for line in &view_lines {
2790 let text = line.text.trim_end_matches('\n');
2792 reconstructed.push_str(text);
2793 }
2794
2795 assert_eq!(
2797 reconstructed.len(),
2798 content.len(),
2799 "Reconstructed content length should match original"
2800 );
2801
2802 for i in 1..=num_markers {
2804 let marker = format!("[{:05}]", i);
2805 assert!(
2806 reconstructed.contains(&marker),
2807 "Missing marker {} after pipeline",
2808 marker
2809 );
2810 }
2811
2812 let pos_100 = reconstructed.find("[00100]").expect("Should find [00100]");
2814 let pos_1000 = reconstructed.find("[01000]").expect("Should find [01000]");
2815 let pos_3000 = reconstructed.find("[03000]").expect("Should find [03000]");
2816 assert!(
2817 pos_100 < pos_1000 && pos_1000 < pos_3000,
2818 "Markers should be in sequential order: {} < {} < {}",
2819 pos_100,
2820 pos_1000,
2821 pos_3000
2822 );
2823
2824 assert!(
2826 view_lines.len() >= 3,
2827 "35KB content should produce multiple visual lines at 10K width, got {}",
2828 view_lines.len()
2829 );
2830
2831 for (i, line) in view_lines.iter().enumerate() {
2833 assert!(
2834 line.text.len() <= MAX_SAFE_LINE_WIDTH + 10, "ViewLine {} exceeds safe width: {} chars",
2836 i,
2837 line.text.len()
2838 );
2839 }
2840 }
2841
2842 fn strip_osc8(s: &str) -> String {
2844 let mut result = String::with_capacity(s.len());
2845 let bytes = s.as_bytes();
2846 let mut i = 0;
2847 while i < bytes.len() {
2848 if i + 3 < bytes.len()
2849 && bytes[i] == 0x1b
2850 && bytes[i + 1] == b']'
2851 && bytes[i + 2] == b'8'
2852 && bytes[i + 3] == b';'
2853 {
2854 i += 4;
2855 while i < bytes.len() && bytes[i] != 0x07 {
2856 i += 1;
2857 }
2858 if i < bytes.len() {
2859 i += 1;
2860 }
2861 } else {
2862 result.push(bytes[i] as char);
2863 i += 1;
2864 }
2865 }
2866 result
2867 }
2868
2869 fn read_row(buf: &ratatui::buffer::Buffer, y: u16) -> String {
2872 let width = buf.area().width;
2873 let mut s = String::new();
2874 let mut col = 0u16;
2875 while col < width {
2876 let cell = &buf[(col, y)];
2877 let stripped = strip_osc8(cell.symbol());
2878 let chars = stripped.chars().count();
2879 if chars > 1 {
2880 s.push_str(&stripped);
2881 col += chars as u16;
2882 } else {
2883 s.push_str(&stripped);
2884 col += 1;
2885 }
2886 }
2887 s.trim_end().to_string()
2888 }
2889
2890 #[test]
2891 fn test_apply_osc8_to_cells_preserves_adjacent_cells() {
2892 use ratatui::buffer::Buffer;
2893 use ratatui::layout::Rect;
2894
2895 let text = "[Quick Install](#installation)";
2897 let area = Rect::new(0, 0, 40, 1);
2898 let mut buf = Buffer::empty(area);
2899 for (i, ch) in text.chars().enumerate() {
2900 if (i as u16) < 40 {
2901 buf[(i as u16, 0)].set_symbol(&ch.to_string());
2902 }
2903 }
2904
2905 let url = "https://example.com";
2907
2908 apply_osc8_to_cells(&mut buf, 1, 14, 0, url, Some((0, 0)));
2910
2911 let row = read_row(&buf, 0);
2912 assert_eq!(
2913 row, text,
2914 "After OSC 8 application, reading the row should reproduce the original text"
2915 );
2916
2917 let cell14 = strip_osc8(buf[(14, 0)].symbol());
2919 assert_eq!(cell14, "]", "Cell 14 (']') must not be modified by OSC 8");
2920
2921 let cell0 = strip_osc8(buf[(0, 0)].symbol());
2923 assert_eq!(cell0, "[", "Cell 0 ('[') must not be modified by OSC 8");
2924 }
2925
2926 #[test]
2927 fn test_apply_osc8_stable_across_reapply() {
2928 use ratatui::buffer::Buffer;
2929 use ratatui::layout::Rect;
2930
2931 let text = "[Quick Install](#installation)";
2932 let area = Rect::new(0, 0, 40, 1);
2933
2934 let mut buf1 = Buffer::empty(area);
2936 for (i, ch) in text.chars().enumerate() {
2937 if (i as u16) < 40 {
2938 buf1[(i as u16, 0)].set_symbol(&ch.to_string());
2939 }
2940 }
2941 apply_osc8_to_cells(&mut buf1, 1, 14, 0, "https://example.com", Some((0, 0)));
2942 let row1 = read_row(&buf1, 0);
2943
2944 let mut buf2 = Buffer::empty(area);
2946 for (i, ch) in text.chars().enumerate() {
2947 if (i as u16) < 40 {
2948 buf2[(i as u16, 0)].set_symbol(&ch.to_string());
2949 }
2950 }
2951 apply_osc8_to_cells(&mut buf2, 1, 14, 0, "https://example.com", Some((5, 0)));
2952 let row2 = read_row(&buf2, 0);
2953
2954 assert_eq!(row1, text);
2955 assert_eq!(row2, text);
2956 }
2957
2958 #[test]
2959 #[ignore = "OSC 8 hyperlinks disabled pending ratatui diff fix"]
2960 fn test_apply_osc8_diff_between_renders() {
2961 use ratatui::buffer::Buffer;
2962 use ratatui::layout::Rect;
2963
2964 let area = Rect::new(0, 0, 40, 1);
2967
2968 let concealed = "Quick Install";
2970 let mut frame1 = Buffer::empty(area);
2971 for (i, ch) in concealed.chars().enumerate() {
2972 frame1[(i as u16, 0)].set_symbol(&ch.to_string());
2973 }
2974 apply_osc8_to_cells(&mut frame1, 0, 13, 0, "https://example.com", Some((0, 5)));
2976
2977 let prev = Buffer::empty(area);
2979 let mut backend = Buffer::empty(area);
2980 let diff1 = prev.diff(&frame1);
2981 for (x, y, cell) in &diff1 {
2982 backend[(*x, *y)] = (*cell).clone();
2983 }
2984
2985 let full = "[Quick Install](#installation)";
2987 let mut frame2 = Buffer::empty(area);
2988 for (i, ch) in full.chars().enumerate() {
2989 if (i as u16) < 40 {
2990 frame2[(i as u16, 0)].set_symbol(&ch.to_string());
2991 }
2992 }
2993 apply_osc8_to_cells(&mut frame2, 1, 14, 0, "https://example.com", Some((0, 0)));
2995
2996 let diff2 = frame1.diff(&frame2);
2998 for (x, y, cell) in &diff2 {
2999 backend[(*x, *y)] = (*cell).clone();
3000 }
3001
3002 let row = read_row(&backend, 0);
3004 assert_eq!(
3005 row, full,
3006 "After diff-based update from concealed to unconcealed, \
3007 backend should show full text"
3008 );
3009
3010 let cell14 = strip_osc8(backend[(14, 0)].symbol());
3012 assert_eq!(cell14, "]", "Cell 14 must be ']' after unconcealed render");
3013 }
3014
3015 fn render_with_highlight_option(
3018 content: &str,
3019 cursor_pos: usize,
3020 highlight_current_line: bool,
3021 ) -> LineRenderOutput {
3022 let mut state = EditorState::new(20, 6, 1024, test_fs());
3023 state.buffer = Buffer::from_str(content, 1024, test_fs());
3024 let mut cursors = crate::model::cursor::Cursors::new();
3025 cursors.primary_mut().position = cursor_pos.min(state.buffer.len());
3026 let viewport = Viewport::new(20, 4);
3027 state.margins.left_config.enabled = false;
3028
3029 let render_area = Rect::new(0, 0, 20, 4);
3030 let visible_count = viewport.visible_line_count();
3031 let gutter_width = state.margins.left_total_width();
3032 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
3033 let empty_folds = FoldManager::new();
3034
3035 let view_data = build_view_data(
3036 &mut state,
3037 &viewport,
3038 None,
3039 content.len().max(1),
3040 visible_count,
3041 false,
3042 render_area.width as usize,
3043 gutter_width,
3044 &ViewMode::Source,
3045 &empty_folds,
3046 &theme,
3047 );
3048 let view_anchor = calculate_view_anchor(&view_data.lines, 0);
3049
3050 let estimated_lines = (state.buffer.len() / state.buffer.estimated_line_length()).max(1);
3051 state.margins.update_width_for_buffer(estimated_lines, true);
3052 let gutter_width = state.margins.left_total_width();
3053
3054 let selection = selection_context(&state, &cursors);
3055 let _ = state
3056 .buffer
3057 .populate_line_cache(viewport.top_byte, visible_count);
3058 let viewport_start = viewport.top_byte;
3059 let viewport_end = calculate_viewport_end(
3060 &mut state,
3061 viewport_start,
3062 content.len().max(1),
3063 visible_count,
3064 );
3065 let decorations = decoration_context(
3066 &mut state,
3067 viewport_start,
3068 viewport_end,
3069 selection.primary_cursor_position,
3070 &empty_folds,
3071 &theme,
3072 100_000,
3073 &ViewMode::Source,
3074 false,
3075 &[],
3076 );
3077
3078 render_view_lines(LineRenderInput {
3079 state: &state,
3080 theme: &theme,
3081 view_lines: &view_data.lines,
3082 view_anchor,
3083 render_area,
3084 gutter_width,
3085 selection: &selection,
3086 decorations: &decorations,
3087 visible_line_count: visible_count,
3088 lsp_waiting: false,
3089 is_active: true,
3090 line_wrap: viewport.line_wrap_enabled,
3091 estimated_lines,
3092 left_column: viewport.left_column,
3093 relative_line_numbers: false,
3094 session_mode: false,
3095 software_cursor_only: false,
3096 show_line_numbers: false,
3097 byte_offset_mode: false,
3098 show_tilde: true,
3099 highlight_current_line,
3100 indentation_guide: IndentationGuideMode::None,
3101 indentation_guide_glyph: "▏",
3102 cell_theme_map: &mut Vec::new(),
3103 screen_width: 0,
3104 })
3105 }
3106
3107 fn line_has_current_line_bg(output: &LineRenderOutput, line_idx: usize) -> bool {
3109 let current_line_bg = ratatui::style::Color::Rgb(40, 40, 40);
3110 if let Some(line) = output.lines.get(line_idx) {
3111 line.spans
3112 .iter()
3113 .any(|span| span.style.bg == Some(current_line_bg))
3114 } else {
3115 false
3116 }
3117 }
3118
3119 #[test]
3120 fn current_line_highlight_enabled_highlights_cursor_line() {
3121 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, true);
3122 assert!(
3124 line_has_current_line_bg(&output, 0),
3125 "Cursor line (line 0) should have current_line_bg when highlighting is enabled"
3126 );
3127 assert!(
3129 !line_has_current_line_bg(&output, 1),
3130 "Non-cursor line (line 1) should NOT have current_line_bg"
3131 );
3132 }
3133
3134 #[test]
3135 fn current_line_highlight_disabled_no_highlight() {
3136 let output = render_with_highlight_option("abc\ndef\nghi\n", 0, false);
3137 assert!(
3139 !line_has_current_line_bg(&output, 0),
3140 "Cursor line should NOT have current_line_bg when highlighting is disabled"
3141 );
3142 assert!(
3143 !line_has_current_line_bg(&output, 1),
3144 "Non-cursor line should NOT have current_line_bg when highlighting is disabled"
3145 );
3146 }
3147
3148 #[test]
3149 fn current_line_highlight_follows_cursor_position() {
3150 let output = render_with_highlight_option("abc\ndef\nghi\n", 4, true);
3152 assert!(
3153 !line_has_current_line_bg(&output, 0),
3154 "Line 0 should NOT have current_line_bg when cursor is on line 1"
3155 );
3156 assert!(
3157 line_has_current_line_bg(&output, 1),
3158 "Line 1 should have current_line_bg when cursor is there"
3159 );
3160 assert!(
3161 !line_has_current_line_bg(&output, 2),
3162 "Line 2 should NOT have current_line_bg when cursor is on line 1"
3163 );
3164 }
3165
3166 #[test]
3173 fn wrap_str_to_width_matches_apply_wrapping_transform() {
3174 use crate::primitives::visual_layout::wrap_str_to_width;
3175 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3176
3177 let cases: &[(&str, usize)] = &[
3181 ("hello world how are you today friend", 12),
3182 ("the quick brown fox jumps over the lazy dog", 18),
3183 ("https://example.com/very-long-path/file", 24),
3184 (&"x".repeat(120), 32),
3185 (&"abc ".repeat(40), 25),
3186 ("dialog.getButton(...).setOnClickListener", 24),
3187 ];
3188
3189 for &(text, wrap_width) in cases {
3190 let helper_chunks = wrap_str_to_width(text, wrap_width);
3192 let helper_strings: Vec<&str> =
3193 helper_chunks.iter().map(|r| &text[r.clone()]).collect();
3194
3195 let tokens = vec![ViewTokenWire {
3200 kind: ViewTokenWireKind::Text(text.to_string()),
3201 source_offset: Some(0),
3202 style: None,
3203 }];
3204 let wrapped = apply_wrapping_transform(tokens, wrap_width, 0, false);
3205
3206 let mut transform_strings: Vec<String> = Vec::new();
3211 for tok in &wrapped {
3212 match &tok.kind {
3213 ViewTokenWireKind::Text(t) => transform_strings.push(t.clone()),
3214 ViewTokenWireKind::Break => {}
3215 other => panic!("unexpected token kind in agreement test: {:?}", other),
3216 }
3217 }
3218
3219 assert_eq!(
3220 transform_strings
3221 .iter()
3222 .map(String::as_str)
3223 .collect::<Vec<_>>(),
3224 helper_strings,
3225 "wrap mismatch for text={text:?} wrap_width={wrap_width}",
3226 );
3227 }
3228 }
3229}