Skip to main content

rpi_tui/
layout.rs

1//! Layout system for constrained rendering in TuiAltScreen.
2//!
3//! This module implements the layout system described in `tui-plan.md`:
4//! - LayoutBox/LayoutFrame for tracking component positions
5//! - Stack layout allocation with basis/grow/shrink
6//! - Painting visible rows with clipping
7//! - Hit testing for mouse events
8
9use 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/// A rectangle in the layout.
20#[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    /// Check if a point is inside this rectangle.
39    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    /// Intersect with another rectangle.
44    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/// A box in the layout tree.
59#[derive(Clone)]
60pub struct LayoutBox {
61    /// The component this box represents.
62    pub component: Arc<dyn Component>,
63    /// The allocated rectangle.
64    pub rect: LayoutRect,
65    /// The clipping rectangle (intersection of all ancestor clips).
66    pub clip: LayoutRect,
67    /// Child boxes.
68    pub children: Vec<LayoutBox>,
69    /// Parent box index.
70    pub parent_index: Option<usize>,
71    /// Rendered lines (cached).
72    pub lines: Option<Vec<String>>,
73    /// Line offset for cursor visibility.
74    pub line_offset: usize,
75    /// Layer for hit testing.
76    pub layer: usize,
77    /// ScrollView reference if this is a scroll view.
78    pub scroll_view: Option<Arc<ScrollView>>,
79    /// Scroll content lines (pre-rendered).
80    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/// A complete layout frame for rendering.
101#[derive(Clone)]
102pub struct LayoutFrame {
103    /// Root box.
104    pub root: LayoutBox,
105    /// Total width.
106    pub width: usize,
107    /// Total height.
108    pub height: usize,
109    /// Rendered screen lines.
110    pub lines: Vec<String>,
111    /// Primary scroll view (if any).
112    pub primary_scroll_view: Option<Arc<ScrollView>>,
113}
114
115impl LayoutFrame {
116    /// Create a new layout frame.
117    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
128/// Context for layout operations.
129struct LayoutContext {
130    #[allow(dead_code)]
131    viewport: LayoutViewport,
132    primary_scroll_view: Option<Arc<ScrollView>>,
133    reuse_scroll_content: bool,
134    /// Frame-local render cache: component pointer → width → rendered lines.
135    ///
136    /// Mirrors upstream `LayoutContext.renderCache` (`packages/tui/src/
137    /// layout.ts`): inside one layout pass a component renders at most once per
138    /// width, so measuring an auto-basis child does not re-render it when it is
139    /// painted. `reuse_scroll_content`/`invalidate` are handled by each
140    /// component's own cache; this only dedupes work within a frame, so it is
141    /// dropped at the end of the frame.
142    render_cache: HashMap<usize, HashMap<usize, Vec<String>>>,
143}
144
145impl LayoutContext {
146    fn register_scroll_view(&mut self, sv: &Arc<ScrollView>) {
147        // Keep the first primary scroll view encountered (the root transcript
148        // area). Non-primary scroll views are not tracked globally.
149        if sv.is_primary() && self.primary_scroll_view.is_none() {
150            self.primary_scroll_view = Some(sv.clone());
151        }
152    }
153
154    /// Render `component` at `width`, memoized for the current layout pass.
155    ///
156    /// The cache key is the `Arc` allocation address; every component is kept
157    /// alive by the tree for the whole pass, so the address is stable.
158    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
174/// Render a component tree into a layout frame with constrained layout.
175pub fn render_layout_frame(root: Arc<dyn Component>, width: usize, height: usize) -> LayoutFrame {
176    render_layout_frame_impl(root, width, height, false)
177}
178
179/// Render a frame where only scroll positions and non-scroll content changed.
180/// Scroll views may reuse their last full child rendering.
181pub(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 the root box into the screen lines
222    paint_box(&frame.root, &mut frame.lines, safe_width);
223
224    frame
225}
226
227/// Layout a single component and its children.
228///
229/// Recognizes `VStack` and `ScrollView` (via `as_any` downcast) and lays
230/// them out with the existing constrained machinery, so stack `grow`/`shrink`/
231/// `min_size` semantics are honored and the scroll view clips to its
232/// allocated viewport. Any other component is treated as a leaf.
233fn 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    // VStack: distribute the available height across children according to
245    // their basis/grow/shrink/min_size (mirrors TS stack layout). This was
246    // previously dead — the layout engine treated the root VStack as a leaf
247    // and concatenated each child's full intrinsic height, which pushed the
248    // bottom dock below the visible viewport.
249    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    // ScrollView: clip the child to the allocated viewport height and render
257    // only the visible window. Without this the scroll view emitted *all*
258    // transcript lines, overflowing past the terminal bottom.
259    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    // ---- Leaf (Container/Editor/Footer/Text/...) ----
264    let lines = context.render_cached(component, safe_width);
265
266    let allocated_height = height.unwrap_or_else(|| lines.len());
267
268    // Calculate line offset for cursor visibility
269    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
300/// Lay out a VStack by distributing `height` across its children.
301fn 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    // The box's own rect spans its full allocated height (or, when
325    // unconstrained, the sum of child heights + gaps).
326    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        // `parent_index` is the child's ordinal within its parent; `paint_box`
353        // does not rely on it, but `hit_test` walks children front-to-back so
354        // keeping the positional index is harmless.
355        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    // The parent box carries no lines of its own — its children paint into
364    // the screen buffer directly via `paint_box` recursion.
365    let mut box_layout = LayoutBox::new(component.clone(), rect, own_clip);
366    box_layout.children = child_boxes;
367    box_layout
368}
369
370/// Horizontal stacks must recurse through the layout engine so scroll viewport,
371/// cursor and hit-test rectangles remain tied to each child's allocated area.
372fn 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
425/// Lay out a ScrollView by clipping its child to the allocated viewport.
426fn 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    // Track the primary scroll view globally so `get_primary_scroll_view`
437    // returns the live transcript area.
438    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    // Cached frames are used only for pure viewport movement or animation in
443    // the bottom dock. Ordinary content updates always refresh this cache.
444    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/// Translate a box and its children by a delta.
482#[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/// Update clips for a box and its children.
495#[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
504/// Paint a box and its children into the screen buffer.
505fn 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        // Calculate visible row range
510        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            // Composite the line into the screen
528            if lbox.rect.x == 0 && lbox.rect.width >= total_width {
529                // Fast path: full-width box at left edge
530                screen[row] = source_line.clone();
531            } else {
532                // Composite at the correct position
533                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    // Paint children
545    for child in &lbox.children {
546        paint_box(child, screen, total_width);
547    }
548}
549
550/// Composite an overlay line onto a base line at a given column position.
551pub 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 is empty or starts beyond total width, return base
562    if overlay_actual_width == 0 || start_col >= total_width {
563        return base.to_string();
564    }
565
566    // Calculate the portion of overlay that fits
567    let effective_width = overlay_width.min(total_width.saturating_sub(start_col));
568
569    // Slice or pad overlay to effective width
570    let overlay_padded = if overlay_actual_width < effective_width {
571        // Pad right
572        format!(
573            "{}{}",
574            overlay,
575            " ".repeat(effective_width - overlay_actual_width)
576        )
577    } else if overlay_actual_width > effective_width {
578        // Truncate
579        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    // Build the composite line
586    let before_width = start_col.min(base_width);
587    let after_start = start_col + effective_width;
588
589    // Get before portion from base
590    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    // Get after portion from base
598    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    // Combine with resets to prevent style leakage
607    const SEGMENT_RESET: &str = "\x1b[0m\x1b]8;;\x07";
608
609    // Calculate padding
610    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
634/// Hit test to find the deepest box at a given coordinate.
635pub 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    // Check if point is in clip
641    if !lbox.clip.contains(x, y) {
642        return None;
643    }
644
645    // Check if point is in rect
646    if !lbox.rect.contains(x, y) {
647        return None;
648    }
649
650    // Check children first (front to back)
651    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    // Return this box if no child was hit
658    Some(lbox)
659}
660
661/// Find all scroll views at a given coordinate.
662pub 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
682/// Extract cursor position from rendered lines.
683pub 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
698/// Strip cursor markers from lines.
699pub 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)); // Top-left corner
735        assert!(rect.contains(10, 10)); // Inside
736        assert!(!rect.contains(4, 5)); // Left of
737        assert!(!rect.contains(15, 5)); // Right of
738        assert!(!rect.contains(5, 15)); // Below
739    }
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    /// Regression for the leaf-only layout bug: a root `VStack[ scrollview
771    /// (grow1), dock ]` must pin the dock (editor borders + footer) to the
772    /// bottom of the viewport and clip the scrollview to the remainder. Before
773    /// the fix the dock was pushed below the visible rows and the screen was
774    /// black where the editor should be.
775    #[test]
776    fn test_constrained_layout_dock_pinned_to_bottom() {
777        // Tall transcript (many lines) so the scrollview's intrinsic height
778        // exceeds the viewport — it must be clipped, not overflow.
779        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            // FollowMode::None so the scrollview doesn't jump to the end
786            // (which would push the first 30 lines off-screen) — we want to
787            // assert the top of the transcript is visible, i.e. clipped, not
788            // that it rendered *all* 40 lines.
789            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        // 40-col wide, 10-row tall viewport.
826        let frame = render_layout_frame(Arc::new(root), 40, 10);
827
828        // The screen buffer must be exactly the viewport height.
829        assert_eq!(
830            frame.lines.len(),
831            10,
832            "frame should be clipped to viewport height"
833        );
834
835        // The dock (editor borders + footer) must occupy the bottom rows of
836        // the painted screen — the editor renders 3 rows (top border, content,
837        // bottom border) + the footer 1 row = 4 dock rows. The bottom-most
838        // painted row must be the footer.
839        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        // The editor's top border (a run of `─`) must appear on-screen just
846        // above the footer region. Borders are colored via the theme border
847        // color, so check for the `─` glyph rather than a raw repeat.
848        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        // The scrollview must NOT paint all 40 transcript lines — it must be
855        // clipped to the top rows. "line 39" (the last) must be absent.
856        assert!(
857            !on_screen.contains("line 39"),
858            "scrollview should be clipped to the viewport, not render all 40 lines"
859        );
860
861        // Primary scroll view should be tracked so get_primary_scroll_view /
862        // frame.primary_scroll_view is populated for the scroll routing.
863        // (The default ScrollView is non-primary; skip that assertion here and
864        // rely on the is_primary branch in layout_scroll_view.)
865    }
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        // Initial: following end → tail visible.
914        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        // Scroll up, then re-layout: older rows must appear.
921        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}