1use std::collections::HashMap;
10use std::sync::Arc;
11
12use super::component::Component;
13use super::hstack::HStack;
14use super::layout_node::LayoutViewport;
15use super::scroll_view::ScrollView;
16use super::vstack::{layout_vstack_constrained_with, VStack};
17use crate::ansi::{slice_by_column, visible_width, CURSOR_MARKER};
18
19#[derive(Debug, Clone, Copy, Default)]
21pub struct LayoutRect {
22 pub x: usize,
23 pub y: usize,
24 pub width: usize,
25 pub height: usize,
26}
27
28impl LayoutRect {
29 pub fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
30 Self {
31 x,
32 y,
33 width,
34 height,
35 }
36 }
37
38 pub fn contains(&self, x: usize, y: usize) -> bool {
40 x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height
41 }
42
43 pub fn intersect(&self, other: &LayoutRect) -> LayoutRect {
45 let x = self.x.max(other.x);
46 let y = self.y.max(other.y);
47 let right = (self.x + self.width).min(other.x + other.width);
48 let bottom = (self.y + self.height).min(other.y + other.height);
49 LayoutRect {
50 x,
51 y,
52 width: right.saturating_sub(x),
53 height: bottom.saturating_sub(y),
54 }
55 }
56}
57
58#[derive(Clone)]
60pub struct LayoutBox {
61 pub component: Arc<dyn Component>,
63 pub rect: LayoutRect,
65 pub clip: LayoutRect,
67 pub children: Vec<LayoutBox>,
69 pub parent_index: Option<usize>,
71 pub lines: Option<Vec<String>>,
73 pub line_offset: usize,
75 pub layer: usize,
77 pub scroll_view: Option<Arc<ScrollView>>,
79 pub scroll_content_lines: Option<Vec<String>>,
81}
82
83impl LayoutBox {
84 pub fn new(component: Arc<dyn Component>, rect: LayoutRect, clip: LayoutRect) -> Self {
85 Self {
86 component,
87 rect,
88 clip,
89 children: Vec::new(),
90 parent_index: None,
91 lines: None,
92 line_offset: 0,
93 layer: 0,
94 scroll_view: None,
95 scroll_content_lines: None,
96 }
97 }
98}
99
100#[derive(Clone)]
102pub struct LayoutFrame {
103 pub root: LayoutBox,
105 pub width: usize,
107 pub height: usize,
109 pub lines: Vec<String>,
111 pub primary_scroll_view: Option<Arc<ScrollView>>,
113}
114
115impl LayoutFrame {
116 pub fn new(root: LayoutBox, width: usize, height: usize) -> Self {
118 Self {
119 root,
120 width,
121 height,
122 lines: vec![String::new(); height],
123 primary_scroll_view: None,
124 }
125 }
126}
127
128struct LayoutContext {
130 #[allow(dead_code)]
131 viewport: LayoutViewport,
132 primary_scroll_view: Option<Arc<ScrollView>>,
133 reuse_scroll_content: bool,
134 render_cache: HashMap<usize, HashMap<usize, Vec<String>>>,
143}
144
145impl LayoutContext {
146 fn register_scroll_view(&mut self, sv: &Arc<ScrollView>) {
147 if sv.is_primary() && self.primary_scroll_view.is_none() {
150 self.primary_scroll_view = Some(sv.clone());
151 }
152 }
153
154 fn render_cached(&mut self, component: &Arc<dyn Component>, width: usize) -> Vec<String> {
159 let key = Arc::as_ptr(component) as *const () as usize;
160 if let Some(by_width) = self.render_cache.get(&key) {
161 if let Some(lines) = by_width.get(&width) {
162 return lines.clone();
163 }
164 }
165 let lines = component.render(width);
166 self.render_cache
167 .entry(key)
168 .or_default()
169 .insert(width, lines.clone());
170 lines
171 }
172}
173
174pub fn render_layout_frame(root: Arc<dyn Component>, width: usize, height: usize) -> LayoutFrame {
176 render_layout_frame_impl(root, width, height, false)
177}
178
179pub(crate) fn render_layout_frame_reusing_scroll_content(
182 root: Arc<dyn Component>,
183 width: usize,
184 height: usize,
185) -> LayoutFrame {
186 render_layout_frame_impl(root, width, height, true)
187}
188
189fn render_layout_frame_impl(
190 root: Arc<dyn Component>,
191 width: usize,
192 height: usize,
193 reuse_scroll_content: bool,
194) -> LayoutFrame {
195 let safe_width = width.max(1);
196 let safe_height = height.max(1);
197
198 let mut context = LayoutContext {
199 viewport: LayoutViewport {
200 width: safe_width,
201 height: safe_height,
202 },
203 primary_scroll_view: None,
204 reuse_scroll_content,
205 render_cache: HashMap::new(),
206 };
207
208 let root_box = layout_component(
209 &root,
210 0,
211 0,
212 safe_width,
213 Some(safe_height),
214 LayoutRect::new(0, 0, safe_width, safe_height),
215 &mut context,
216 );
217
218 let mut frame = LayoutFrame::new(root_box, safe_width, safe_height);
219 frame.primary_scroll_view = context.primary_scroll_view;
220
221 paint_box(&frame.root, &mut frame.lines, safe_width);
223
224 frame
225}
226
227fn layout_component(
234 component: &Arc<dyn Component>,
235 x: usize,
236 y: usize,
237 width: usize,
238 height: Option<usize>,
239 clip: LayoutRect,
240 context: &mut LayoutContext,
241) -> LayoutBox {
242 let safe_width = width.max(1);
243
244 if let Some(vstack) = component.as_any().downcast_ref::<VStack>() {
250 return layout_vstack(component, vstack, x, y, safe_width, height, clip, context);
251 }
252 if let Some(hstack) = component.as_any().downcast_ref::<HStack>() {
253 return layout_hstack(component, hstack, x, y, safe_width, height, clip, context);
254 }
255
256 if let Some(sv) = component.as_any().downcast_ref::<ScrollView>() {
260 return layout_scroll_view(component, sv, x, y, safe_width, height, clip, context);
261 }
262
263 let lines = context.render_cached(component, safe_width);
265
266 let allocated_height = height.unwrap_or_else(|| lines.len());
267
268 let line_offset = if lines.len() > allocated_height && allocated_height > 0 {
270 let cursor_line = lines.iter().position(|line| line.contains(CURSOR_MARKER));
271 cursor_line
272 .map(|idx| {
273 if idx >= allocated_height {
274 idx - allocated_height + 1
275 } else {
276 0
277 }
278 })
279 .unwrap_or(0)
280 } else {
281 0
282 };
283
284 let rect = LayoutRect::new(x, y, safe_width, allocated_height);
285
286 LayoutBox {
287 component: component.clone(),
288 rect,
289 clip: clip.intersect(&rect),
290 children: Vec::new(),
291 parent_index: None,
292 lines: Some(lines),
293 line_offset,
294 layer: 0,
295 scroll_view: None,
296 scroll_content_lines: None,
297 }
298}
299
300fn layout_vstack(
302 component: &Arc<dyn Component>,
303 vstack: &VStack,
304 x: usize,
305 y: usize,
306 width: usize,
307 height: Option<usize>,
308 clip: LayoutRect,
309 context: &mut LayoutContext,
310) -> LayoutBox {
311 let children = vstack.get_children();
312 let gap = vstack.gap();
313 let allocated = {
314 let ctx = &mut *context;
315 layout_vstack_constrained_with(
316 &children,
317 width,
318 height.unwrap_or(0),
319 gap,
320 |component, w| ctx.render_cached(component, w).len(),
321 )
322 };
323
324 let total_child_height: usize = allocated.iter().map(|(_, h)| *h).sum();
327 let total_gap = if children.len() > 1 {
328 gap * (children.len() - 1)
329 } else {
330 0
331 };
332 let box_height = height.unwrap_or(total_child_height + total_gap);
333
334 let rect = LayoutRect::new(x, y, width, box_height);
335 let own_clip = clip.intersect(&rect);
336
337 let mut child_boxes = Vec::with_capacity(allocated.len());
338 let mut cursor_y = y;
339 for (idx, (child_component, child_height)) in allocated.iter().enumerate() {
340 if idx > 0 && gap > 0 {
341 cursor_y = cursor_y.saturating_add(gap);
342 }
343 let child_box = layout_component(
344 child_component,
345 x,
346 cursor_y,
347 width,
348 Some(*child_height),
349 own_clip,
350 context,
351 );
352 child_boxes.push(child_box);
356 cursor_y = cursor_y.saturating_add(*child_height);
357 }
358 for (i, child) in child_boxes.iter_mut().enumerate() {
359 child.parent_index = Some(i);
360 child.layer = 0;
361 }
362
363 let mut box_layout = LayoutBox::new(component.clone(), rect, own_clip);
366 box_layout.children = child_boxes;
367 box_layout
368}
369
370fn layout_hstack(
373 component: &Arc<dyn Component>,
374 stack: &HStack,
375 x: usize,
376 y: usize,
377 width: usize,
378 height: Option<usize>,
379 clip: LayoutRect,
380 context: &mut LayoutContext,
381) -> LayoutBox {
382 let children = stack.get_children();
383 let allocated =
384 layout_vstack_constrained_with(&children, width, width, stack.gap(), |child, w| {
385 context
386 .render_cached(child, w)
387 .iter()
388 .map(|line| visible_width(line))
389 .max()
390 .unwrap_or(0)
391 });
392 let mut boxes = Vec::new();
393 let mut column = x;
394 for (index, (child, child_width)) in allocated.into_iter().enumerate() {
395 if index > 0 {
396 column = column.saturating_add(stack.gap());
397 }
398 if child_width > 0 {
399 let child_clip = clip.intersect(&LayoutRect::new(
400 column,
401 y,
402 child_width,
403 height.unwrap_or(clip.height),
404 ));
405 let mut child_box =
406 layout_component(&child, column, y, child_width, height, child_clip, context);
407 child_box.parent_index = Some(index);
408 boxes.push(child_box);
409 }
410 column = column.saturating_add(child_width);
411 }
412 let box_height = height.unwrap_or_else(|| {
413 boxes
414 .iter()
415 .map(|child| child.rect.height)
416 .max()
417 .unwrap_or(0)
418 });
419 let rect = LayoutRect::new(x, y, width, box_height);
420 let mut result = LayoutBox::new(component.clone(), rect, clip.intersect(&rect));
421 result.children = boxes;
422 result
423}
424
425fn layout_scroll_view(
427 component: &Arc<dyn Component>,
428 sv: &ScrollView,
429 x: usize,
430 y: usize,
431 width: usize,
432 height: Option<usize>,
433 clip: LayoutRect,
434 context: &mut LayoutContext,
435) -> LayoutBox {
436 let sv_arc: Arc<ScrollView> = Arc::new(sv.clone());
439 context.register_scroll_view(&sv_arc);
440
441 let allocated_height = height.unwrap_or_else(|| sv.render(width).len());
442 let lines = if context.reuse_scroll_content {
445 sv.render_with_cached_content(width, allocated_height)
446 } else {
447 sv.render_with_viewport(width, allocated_height)
448 };
449
450 let line_offset = if lines.len() > allocated_height && allocated_height > 0 {
451 let cursor_line = lines.iter().position(|line| line.contains(CURSOR_MARKER));
452 cursor_line
453 .map(|idx| {
454 if idx >= allocated_height {
455 idx - allocated_height + 1
456 } else {
457 0
458 }
459 })
460 .unwrap_or(0)
461 } else {
462 0
463 };
464
465 let rect = LayoutRect::new(x, y, width, allocated_height);
466
467 LayoutBox {
468 component: component.clone(),
469 rect,
470 clip: clip.intersect(&rect),
471 children: Vec::new(),
472 parent_index: None,
473 lines: Some(lines),
474 line_offset,
475 layer: 0,
476 scroll_view: Some(sv_arc),
477 scroll_content_lines: None,
478 }
479}
480
481#[allow(dead_code)]
483fn translate_box(lbox: &mut LayoutBox, delta_y: i32) {
484 if delta_y >= 0 {
485 lbox.rect.y += delta_y as usize;
486 } else {
487 lbox.rect.y = lbox.rect.y.saturating_sub((-delta_y) as usize);
488 }
489 for child in &mut lbox.children {
490 translate_box(child, delta_y);
491 }
492}
493
494#[allow(dead_code)]
496fn update_clips(lbox: &mut LayoutBox, parent_clip: LayoutRect) {
497 lbox.clip = parent_clip.intersect(&lbox.rect);
498 let child_clip = lbox.clip;
499 for child in &mut lbox.children {
500 update_clips(child, child_clip);
501 }
502}
503
504fn paint_box(lbox: &LayoutBox, screen: &mut [String], total_width: usize) {
506 if let Some(ref lines) = lbox.lines {
507 let offset = lbox.line_offset;
508
509 let first_row = lbox.rect.y.max(lbox.clip.y);
511 let last_row = (lbox.rect.y + lbox.rect.height)
512 .min(lbox.clip.y + lbox.clip.height)
513 .min(screen.len());
514
515 for row in first_row..last_row {
516 if row >= screen.len() {
517 break;
518 }
519
520 let source_idx = offset + row.saturating_sub(lbox.rect.y);
521 if source_idx >= lines.len() {
522 break;
523 }
524
525 let source_line = &lines[source_idx];
526
527 if lbox.rect.x == 0 && lbox.rect.width >= total_width {
529 screen[row] = source_line.clone();
531 } else {
532 screen[row] = composite_tui_line(
534 &screen[row],
535 source_line,
536 lbox.rect.x,
537 lbox.rect.width,
538 total_width,
539 );
540 }
541 }
542 }
543
544 for child in &lbox.children {
546 paint_box(child, screen, total_width);
547 }
548}
549
550pub fn composite_tui_line(
552 base: &str,
553 overlay: &str,
554 start_col: usize,
555 overlay_width: usize,
556 total_width: usize,
557) -> String {
558 let base_width = visible_width(base);
559 let overlay_actual_width = visible_width(overlay);
560
561 if overlay_actual_width == 0 || start_col >= total_width {
563 return base.to_string();
564 }
565
566 let effective_width = overlay_width.min(total_width.saturating_sub(start_col));
568
569 let overlay_padded = if overlay_actual_width < effective_width {
571 format!(
573 "{}{}",
574 overlay,
575 " ".repeat(effective_width - overlay_actual_width)
576 )
577 } else if overlay_actual_width > effective_width {
578 crate::line_diff::slice(overlay, 0, effective_width)
580 .unwrap_or_else(|| slice_by_column(overlay, 0, effective_width, true))
581 } else {
582 overlay.to_string()
583 };
584
585 let before_width = start_col.min(base_width);
587 let after_start = start_col + effective_width;
588
589 let before = if before_width > 0 {
591 crate::line_diff::slice(base, 0, before_width)
592 .unwrap_or_else(|| slice_by_column(base, 0, before_width, true))
593 } else {
594 String::new()
595 };
596
597 let after = if after_start < base_width {
599 let width = base_width.saturating_sub(after_start);
600 crate::line_diff::slice(base, after_start, width)
601 .unwrap_or_else(|| slice_by_column(base, after_start, width, true))
602 } else {
603 String::new()
604 };
605
606 const SEGMENT_RESET: &str = "\x1b[0m\x1b]8;;\x07";
608
609 let before_pad = if visible_width(&before) < start_col {
611 " ".repeat(start_col - visible_width(&before))
612 } else {
613 String::new()
614 };
615
616 let after_pad = if after_start < total_width {
617 let expected_after_width = total_width.saturating_sub(after_start);
618 let actual_after_width = visible_width(&after);
619 if actual_after_width < expected_after_width {
620 " ".repeat(expected_after_width - actual_after_width)
621 } else {
622 String::new()
623 }
624 } else {
625 String::new()
626 };
627
628 format!(
629 "{}{}{}{}{}{}{}",
630 before, before_pad, SEGMENT_RESET, overlay_padded, SEGMENT_RESET, after, after_pad
631 )
632}
633
634pub fn hit_test(frame: &LayoutFrame, x: usize, y: usize) -> Option<&LayoutBox> {
636 hit_test_box(&frame.root, x, y)
637}
638
639fn hit_test_box(lbox: &LayoutBox, x: usize, y: usize) -> Option<&LayoutBox> {
640 if !lbox.clip.contains(x, y) {
642 return None;
643 }
644
645 if !lbox.rect.contains(x, y) {
647 return None;
648 }
649
650 for child in lbox.children.iter().rev() {
652 if let Some(hit) = hit_test_box(child, x, y) {
653 return Some(hit);
654 }
655 }
656
657 Some(lbox)
659}
660
661pub fn get_scroll_views_at(frame: &LayoutFrame, x: usize, y: usize) -> Vec<Arc<ScrollView>> {
663 let mut result = Vec::new();
664 collect_scroll_views(&frame.root, x, y, &mut result);
665 result
666}
667
668fn collect_scroll_views(lbox: &LayoutBox, x: usize, y: usize, result: &mut Vec<Arc<ScrollView>>) {
669 if !lbox.clip.contains(x, y) || !lbox.rect.contains(x, y) {
670 return;
671 }
672
673 if let Some(ref sv) = lbox.scroll_view {
674 result.push(sv.clone());
675 }
676
677 for child in &lbox.children {
678 collect_scroll_views(child, x, y, result);
679 }
680}
681
682pub fn extract_cursor_position(lines: &[String], height: usize) -> Option<(usize, usize)> {
684 let viewport_top = lines.len().saturating_sub(height);
685
686 for row in (viewport_top..lines.len()).rev() {
687 let line = &lines[row];
688 if let Some(marker_idx) = line.find(CURSOR_MARKER) {
689 let before = &line[..marker_idx];
690 let col = visible_width(before);
691 return Some((row, col));
692 }
693 }
694
695 None
696}
697
698pub fn strip_cursor_markers(lines: &[String]) -> Vec<String> {
700 lines
701 .iter()
702 .map(|line| line.replace(CURSOR_MARKER, ""))
703 .collect()
704}
705
706#[cfg(test)]
707mod tests {
708 #[test]
709 fn overlay_composition_pads_to_its_hit_column_and_keeps_existing_styles() {
710 let output = super::composite_tui_line("\x1b[32mabc\x1b[0m", "Jump", 10, 4, 20);
711 assert_eq!(crate::ansi::strip_ansi(&output), "abc Jump ");
712 assert!(output.contains("\x1b[32mabc"));
713 let output = super::composite_tui_line(
714 "\x1b[31mabcdefghijklmnop\x1b[0m",
715 "\x1b[44mJump\x1b[49m",
716 4,
717 4,
718 16,
719 );
720 assert!(output.contains("\x1b[31mabcd"));
721 assert!(output.contains("\x1b[31mijklmnop"));
722 assert_eq!(crate::ansi::strip_ansi(&output), "abcdJumpijklmnop");
723 }
724
725 use super::*;
726 use crate::{
727 Container, Editor, EditorOptions, EditorStyle, Focusable, ScrollView, ScrollViewOptions,
728 StackChild, StackEntry, Text, VStack,
729 };
730
731 #[test]
732 fn test_layout_rect_contains() {
733 let rect = LayoutRect::new(5, 5, 10, 10);
734 assert!(rect.contains(5, 5)); assert!(rect.contains(10, 10)); assert!(!rect.contains(4, 5)); assert!(!rect.contains(15, 5)); assert!(!rect.contains(5, 15)); }
740
741 #[test]
742 fn test_layout_rect_intersect() {
743 let a = LayoutRect::new(0, 0, 10, 10);
744 let b = LayoutRect::new(5, 5, 10, 10);
745 let c = a.intersect(&b);
746 assert_eq!(c.x, 5);
747 assert_eq!(c.y, 5);
748 assert_eq!(c.width, 5);
749 assert_eq!(c.height, 5);
750 }
751
752 #[test]
753 fn test_composite_tui_line() {
754 let base = "Hello, World!";
755 let overlay = "Rust";
756 let result = composite_tui_line(base, overlay, 7, 4, 20);
757 assert!(result.contains("Hello,"));
758 assert!(result.contains("Rust"));
759 }
760
761 #[test]
762 fn test_render_layout_frame() {
763 let text = Arc::new(Text::new("Hello, World!", 0, 0));
764 let frame = render_layout_frame(text, 20, 10);
765 assert_eq!(frame.width, 20);
766 assert_eq!(frame.height, 10);
767 assert!(!frame.lines.is_empty());
768 }
769
770 #[test]
776 fn test_constrained_layout_dock_pinned_to_bottom() {
777 let transcript = Arc::new(Container::new());
780 for i in 0..40 {
781 transcript.add_child(Arc::new(Text::new(&format!("line {i:02}"), 0, 0)));
782 }
783 let scroll = Arc::new(ScrollView::new(
784 transcript,
785 ScrollViewOptions {
790 follow: crate::FollowMode::None,
791 ..Default::default()
792 },
793 ));
794
795 let editor = Arc::new(Editor::new(
796 EditorOptions::default(),
797 EditorStyle::default(),
798 Arc::new(crate::Keybindings::new()),
799 ));
800 editor.set_focused(true);
801 let editor_container = Arc::new(Container::new());
802 editor_container.add_child(editor.clone());
803 let footer = Arc::new(Text::new("FOOTER LINE", 0, 0));
804
805 let dock = Arc::new(VStack::from_children(vec![
806 StackChild::Entry(
807 StackEntry::new(editor_container.clone())
808 .shrink(0)
809 .min_size(3),
810 ),
811 StackChild::Entry(StackEntry::new(footer.clone())),
812 ]));
813
814 let root = VStack::from_children(vec![
815 StackChild::Entry(
816 StackEntry::new(scroll.clone())
817 .basis(0)
818 .grow(1)
819 .shrink(1)
820 .min_size(1),
821 ),
822 StackChild::Entry(StackEntry::new(dock).shrink(1)),
823 ]);
824
825 let frame = render_layout_frame(Arc::new(root), 40, 10);
827
828 assert_eq!(
830 frame.lines.len(),
831 10,
832 "frame should be clipped to viewport height"
833 );
834
835 let bottom = frame.lines.last().expect("non-empty frame");
840 assert!(
841 bottom.contains("FOOTER"),
842 "footer should be the last painted row; got: {bottom:?}"
843 );
844
845 let on_screen: String = frame.lines.join("\n");
849 assert!(
850 on_screen.contains('─'),
851 "editor top border `─` should be visible on-screen; rendered:\n{on_screen}"
852 );
853
854 assert!(
857 !on_screen.contains("line 39"),
858 "scrollview should be clipped to the viewport, not render all 40 lines"
859 );
860
861 }
866}
867
868#[cfg(test)]
869mod scroll_layout_tests {
870 use super::*;
871 use crate::component::Component;
872 use crate::{Container, ScrollView, ScrollViewOptions, Text};
873 use std::any::Any;
874 use std::sync::atomic::{AtomicUsize, Ordering};
875
876 struct CountingTranscript {
877 renders: Arc<AtomicUsize>,
878 }
879
880 impl Component for CountingTranscript {
881 fn render(&self, _width: usize) -> Vec<String> {
882 self.renders.fetch_add(1, Ordering::SeqCst);
883 (0..100).map(|i| format!("row {i:03}")).collect()
884 }
885
886 fn invalidate(&self) {}
887
888 fn as_any(&self) -> &dyn Any {
889 self
890 }
891 }
892
893 fn tall() -> (Arc<ScrollView>, Arc<Container>) {
894 let inner = Arc::new(Container::new());
895 for i in 0..50 {
896 inner.add_child(Arc::new(Text::new(format!("row {i:02}"), 0, 0)));
897 }
898 let sv = Arc::new(ScrollView::new(
899 inner.clone(),
900 ScrollViewOptions {
901 follow: crate::FollowMode::End,
902 primary: true,
903 ..Default::default()
904 },
905 ));
906 (sv, inner)
907 }
908
909 #[test]
910 fn layout_scroll_view_respects_scroll_top() {
911 let (sv, _) = tall();
912 let root: Arc<dyn Component> = sv.clone();
913 let f1 = render_layout_frame(root.clone(), 40, 10);
915 assert!(
916 f1.lines[0].contains("row 40"),
917 "tail visible: {:?}",
918 f1.lines[0]
919 );
920 sv.scroll_by(-10);
922 let f2 = render_layout_frame(root, 40, 10);
923 assert!(
924 f2.lines[0].contains("row 30"),
925 "after scroll-up, row 30 top: {:?}",
926 f2.lines[0]
927 );
928 }
929
930 #[test]
931 fn scroll_frames_reuse_rendered_transcript() {
932 let renders = Arc::new(AtomicUsize::new(0));
933 let transcript = Arc::new(CountingTranscript {
934 renders: renders.clone(),
935 });
936 let scroll = Arc::new(ScrollView::new(
937 transcript,
938 ScrollViewOptions {
939 follow: crate::FollowMode::End,
940 primary: true,
941 ..Default::default()
942 },
943 ));
944 let root: Arc<dyn Component> = Arc::new(VStack::from_children(vec![
945 crate::StackChild::Entry(
946 crate::StackEntry::new(scroll.clone())
947 .basis(0)
948 .grow(1)
949 .min_size(1),
950 ),
951 crate::StackChild::Entry(crate::StackEntry::new(Arc::new(Text::new("dock", 0, 0)))),
952 ]));
953
954 render_layout_frame(root.clone(), 40, 10);
955 assert_eq!(renders.load(Ordering::SeqCst), 1);
956
957 scroll.scroll_by(-1);
958 render_layout_frame_reusing_scroll_content(root.clone(), 40, 10);
959 assert_eq!(
960 renders.load(Ordering::SeqCst),
961 1,
962 "pure scrolling must not rebuild the transcript",
963 );
964
965 render_layout_frame(root.clone(), 40, 10);
966 assert_eq!(
967 renders.load(Ordering::SeqCst),
968 2,
969 "ordinary frames must refresh mutable transcript content",
970 );
971
972 render_layout_frame_reusing_scroll_content(root, 60, 10);
973 assert_eq!(
974 renders.load(Ordering::SeqCst),
975 3,
976 "a width change must invalidate wrapped transcript lines",
977 );
978 }
979}