Skip to main content

rab/tui/
container.rs

1use crate::tui::Component;
2use crate::tui::component::{RenderCache, RenderCacheKey};
3use crate::tui::overlay::{OverlayEntry, OverlayLayout, OverlayOptions, SizeValue};
4use crate::tui::util::{extract_segments, slice_by_column, visible_width};
5
6/// Marker appended to lines after extraction — matches pi's SEGMENT_RESET
7const SEGMENT_RESET: &str = "\x1b[0m\x1b]8;;\x07";
8
9/// Per-child render cache entry.
10struct ChildCache {
11    /// Cached render output.
12    cache: Option<RenderCache>,
13    /// Whether child needs re-render.
14    dirty: bool,
15}
16
17impl ChildCache {
18    fn new() -> Self {
19        Self {
20            cache: None,
21            dirty: true,
22        }
23    }
24}
25
26/// Container - a component that contains other components rendered vertically.
27/// Supports per-child caching and overlay compositing.
28pub struct Container {
29    children: Vec<Box<dyn Component>>,
30    /// Per-child cache state.
31    child_caches: Vec<ChildCache>,
32    /// Overlay stack (rendered on top of children).
33    overlay_stack: Vec<OverlayEntry>,
34    /// Terminal height (set before render, used for overlay positioning).
35    term_height: usize,
36}
37
38impl Container {
39    pub fn new() -> Self {
40        Self {
41            children: Vec::new(),
42            child_caches: Vec::new(),
43            overlay_stack: Vec::new(),
44            term_height: 24,
45        }
46    }
47
48    /// Set terminal height (must be called before render for correct overlay positioning).
49    pub fn set_term_height(&mut self, height: usize) {
50        self.term_height = height;
51    }
52
53    // ── Child management ──
54
55    pub fn add_child(&mut self, component: Box<dyn Component>) {
56        self.child_caches.push(ChildCache::new());
57        self.children.push(component);
58    }
59
60    pub fn remove_child(&mut self, component: &dyn Component) {
61        let idx = self.children.iter().position(|c| {
62            std::ptr::eq(
63                c.as_ref() as *const dyn Component,
64                component as *const dyn Component,
65            )
66        });
67        if let Some(idx) = idx {
68            self.children.remove(idx);
69            self.child_caches.remove(idx);
70        }
71    }
72
73    pub fn clear(&mut self) {
74        self.children.clear();
75        self.child_caches.clear();
76    }
77
78    pub fn children(&self) -> &[Box<dyn Component>] {
79        &self.children
80    }
81
82    pub fn children_mut(&mut self) -> &mut [Box<dyn Component>] {
83        &mut self.children
84    }
85
86    /// Mark all children as needing re-render.
87    pub fn invalidate_all(&mut self) {
88        for cache in &mut self.child_caches {
89            cache.dirty = true;
90            cache.cache = None;
91        }
92    }
93
94    /// Mark a specific child as needing re-render by index.
95    pub fn invalidate_child(&mut self, index: usize) {
96        if let Some(cache) = self.child_caches.get_mut(index) {
97            cache.dirty = true;
98            cache.cache = None;
99        }
100    }
101
102    /// Get the number of children.
103    pub fn len(&self) -> usize {
104        self.children.len()
105    }
106
107    /// Remove and return the last child, if any.
108    pub fn pop_child(&mut self) -> Option<Box<dyn Component>> {
109        self.child_caches.pop();
110        self.children.pop()
111    }
112
113    /// Check if empty.
114    pub fn is_empty(&self) -> bool {
115        self.children.is_empty()
116    }
117
118    /// Peek at the last child, if any.
119    pub fn last_child(&self) -> Option<&dyn Component> {
120        self.children.last().map(|c| c.as_ref())
121    }
122
123    // ── Overlay management ──
124
125    /// Show an overlay. Returns the overlay ID for later removal.
126    pub fn show_overlay(&mut self, component: Box<dyn Component>, options: OverlayOptions) -> u64 {
127        let id = self.overlay_stack.len() as u64;
128        self.overlay_stack.push(OverlayEntry {
129            component,
130            options,
131            hidden: false,
132            focus_order: id,
133            id,
134        });
135        id
136    }
137
138    /// Hide an overlay by ID.
139    pub fn hide_overlay(&mut self, id: u64) {
140        self.overlay_stack.retain(|e| e.id != id);
141    }
142
143    /// Hide the topmost overlay.
144    pub fn pop_overlay(&mut self) {
145        self.overlay_stack.pop();
146    }
147
148    /// Check if there are any visible overlays.
149    pub fn has_overlays(&self) -> bool {
150        self.overlay_stack.iter().any(|e| !e.hidden)
151    }
152
153    /// Clear all overlays.
154    pub fn clear_overlays(&mut self) {
155        self.overlay_stack.clear();
156    }
157
158    /// Get the overlay stack (for focus management in TUI).
159    pub fn overlay_stack(&self) -> &[OverlayEntry] {
160        &self.overlay_stack
161    }
162
163    pub fn overlay_stack_mut(&mut self) -> &mut Vec<OverlayEntry> {
164        &mut self.overlay_stack
165    }
166}
167
168impl Default for Container {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl Container {
175    /// Composite all visible overlays into the content lines.
176    /// Matches pi's compositeOverlays 1/1.
177    fn composite_overlays(
178        &mut self,
179        base_lines: &[String],
180        term_width: usize,
181        term_height: usize,
182    ) -> Vec<String> {
183        if self.overlay_stack.is_empty() {
184            return base_lines.to_vec();
185        }
186        let mut result = base_lines.to_vec();
187
188        // Collect visible overlay indices sorted by focusOrder (higher = on top)
189        let mut indices: Vec<usize> = self
190            .overlay_stack
191            .iter()
192            .enumerate()
193            .filter(|(_, e)| !e.hidden)
194            .map(|(i, _)| i)
195            .collect();
196        indices.sort_by_key(|&i| self.overlay_stack[i].focus_order);
197
198        // Pre-render all visible overlays and calculate positions
199        struct RenderedOverlay {
200            overlay_lines: Vec<String>,
201            row: usize,
202            col: usize,
203            w: usize,
204        }
205
206        let mut rendered: Vec<RenderedOverlay> = Vec::new();
207        let mut min_lines_needed = result.len();
208
209        for &idx in &indices {
210            let options = self.overlay_stack[idx].options.clone();
211
212            // Get layout with height=0 first to determine width and maxHeight
213            // (width and maxHeight don't depend on overlay height)
214            let first_layout = self.resolve_overlay_layout(&options, 0, term_width, term_height);
215            let width = first_layout.width;
216            let max_height = first_layout.max_height;
217
218            // Render component at calculated width (separate borrow)
219            let mut overlay_lines = self.overlay_stack[idx].component.render(width);
220
221            // Apply maxHeight if specified
222            if let Some(mh) = max_height {
223                overlay_lines.truncate(mh);
224            }
225
226            let overlay_len = overlay_lines.len();
227
228            // Get final row/col with actual overlay height
229            let layout =
230                self.resolve_overlay_layout(&options, overlay_len, term_width, term_height);
231
232            min_lines_needed = min_lines_needed.max(layout.row + overlay_len);
233
234            rendered.push(RenderedOverlay {
235                overlay_lines,
236                row: layout.row,
237                col: layout.col,
238                w: width,
239            });
240        }
241
242        // Pad to at least terminal height so overlays have screen-relative positions.
243        // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
244        // inflation that pushed content into scrollback on terminal widen.
245        let working_height = result.len().max(term_height).max(min_lines_needed);
246
247        // Extend result with empty lines if content is too short for overlay placement
248        while result.len() < working_height {
249            result.push(String::new());
250        }
251
252        let viewport_start = working_height.saturating_sub(term_height);
253
254        // Composite each overlay
255        for ro in &rendered {
256            for (i, overlay_line) in ro.overlay_lines.iter().enumerate() {
257                let idx = viewport_start + ro.row + i;
258                if idx < result.len() {
259                    // Defensive: truncate overlay line to declared width before compositing
260                    let truncated = if visible_width(overlay_line) > ro.w {
261                        slice_by_column(overlay_line, 0, ro.w)
262                    } else {
263                        overlay_line.clone()
264                    };
265                    result[idx] =
266                        self.composite_line_at(&result[idx], &truncated, ro.col, ro.w, term_width);
267                }
268            }
269        }
270
271        result
272    }
273
274    /// Splice overlay content into a base line at a specific column.
275    fn composite_line_at(
276        &self,
277        base_line: &str,
278        overlay_line: &str,
279        start_col: usize,
280        overlay_width: usize,
281        total_width: usize,
282    ) -> String {
283        let after_start = start_col + overlay_width;
284
285        let (before, before_width, after, after_width) = extract_segments(
286            base_line,
287            start_col,
288            after_start,
289            total_width.saturating_sub(after_start),
290            true,
291        );
292
293        let overlay = slice_by_column(overlay_line, 0, overlay_width);
294        let overlay_vis = visible_width(&overlay);
295
296        let before_pad = start_col.saturating_sub(before_width);
297        let overlay_pad = overlay_width.saturating_sub(overlay_vis);
298        let actual_before_width = before_width.max(start_col);
299        let actual_overlay_width = overlay_vis.max(overlay_width);
300        let after_target = total_width.saturating_sub(actual_before_width + actual_overlay_width);
301        let after_pad = after_target.saturating_sub(after_width);
302
303        let mut result = String::new();
304        result.push_str(&before);
305        result.push_str(&" ".repeat(before_pad));
306        result.push_str(SEGMENT_RESET);
307        result.push_str(&overlay);
308        result.push_str(&" ".repeat(overlay_pad));
309        result.push_str(SEGMENT_RESET);
310        result.push_str(&after);
311        result.push_str(&" ".repeat(after_pad));
312
313        let rw = visible_width(&result);
314        if rw > total_width {
315            result = slice_by_column(&result, 0, total_width);
316        }
317
318        result
319    }
320
321    /// Resolve overlay layout from options.
322    fn resolve_overlay_layout(
323        &self,
324        options: &OverlayOptions,
325        overlay_height: usize,
326        term_width: usize,
327        term_height: usize,
328    ) -> OverlayLayout {
329        let margin = options.margin.unwrap_or_default();
330        let margin_top = margin.top;
331        let margin_right = margin.right;
332        let margin_bottom = margin.bottom;
333        let margin_left = margin.left;
334
335        let avail_width = (term_width - margin_left - margin_right).max(1);
336        let avail_height = (term_height - margin_top - margin_bottom).max(1);
337
338        let width = options
339            .width
340            .map(|sv| sv.resolve(term_width))
341            .unwrap_or_else(|| 80.min(avail_width));
342        let width = options.min_width.map(|mw| width.max(mw)).unwrap_or(width);
343        let width = width.max(1).min(avail_width);
344
345        let max_height = options.max_height.map(|sv| sv.resolve(term_height));
346        let max_height = max_height.map(|mh| mh.max(1).min(avail_height));
347
348        let effective_height = match max_height {
349            Some(mh) => overlay_height.min(mh),
350            None => overlay_height,
351        };
352
353        let row = if let Some(ref row_sv) = options.row {
354            match row_sv {
355                SizeValue::Absolute(r) => *r,
356                SizeValue::Percent(p) => {
357                    let max_row = avail_height - effective_height;
358                    margin_top + ((max_row as f64 * p / 100.0).floor() as usize)
359                }
360            }
361        } else {
362            let anchor = options.anchor.unwrap_or_default();
363            Self::resolve_anchor_row(anchor, effective_height, avail_height, margin_top)
364        };
365
366        let col = if let Some(ref col_sv) = options.col {
367            match col_sv {
368                SizeValue::Absolute(c) => *c,
369                SizeValue::Percent(p) => {
370                    let max_col = avail_width - width;
371                    margin_left + ((max_col as f64 * p / 100.0).floor() as usize)
372                }
373            }
374        } else {
375            let anchor = options.anchor.unwrap_or_default();
376            Self::resolve_anchor_col(anchor, width, avail_width, margin_left)
377        };
378
379        let row = (row as isize + options.offset_y.unwrap_or(0)) as usize;
380        let col = (col as isize + options.offset_x.unwrap_or(0)) as usize;
381
382        OverlayLayout {
383            width,
384            row,
385            col,
386            max_height,
387        }
388    }
389
390    fn resolve_anchor_row(
391        anchor: crate::tui::overlay::OverlayAnchor,
392        overlay_height: usize,
393        avail_height: usize,
394        margin_top: usize,
395    ) -> usize {
396        use crate::tui::overlay::OverlayAnchor::*;
397        match anchor {
398            Center | LeftCenter | RightCenter => {
399                margin_top + (avail_height.saturating_sub(overlay_height) / 2)
400            }
401            TopLeft | TopCenter | TopRight => margin_top,
402            BottomLeft | BottomCenter | BottomRight => {
403                margin_top + avail_height.saturating_sub(overlay_height)
404            }
405        }
406    }
407
408    fn resolve_anchor_col(
409        anchor: crate::tui::overlay::OverlayAnchor,
410        overlay_width: usize,
411        avail_width: usize,
412        margin_left: usize,
413    ) -> usize {
414        use crate::tui::overlay::OverlayAnchor::*;
415        match anchor {
416            Center | TopCenter | BottomCenter => {
417                margin_left + (avail_width.saturating_sub(overlay_width) / 2)
418            }
419            TopLeft | LeftCenter | BottomLeft => margin_left,
420            TopRight | RightCenter | BottomRight => {
421                margin_left + avail_width.saturating_sub(overlay_width)
422            }
423        }
424    }
425}
426
427impl Component for Container {
428    fn render(&mut self, width: usize) -> Vec<String> {
429        let mut lines = Vec::new();
430        for (idx, child) in self.children.iter_mut().enumerate() {
431            let cache = &mut self.child_caches[idx];
432            // Use cached output if width matches and the child's cache key
433            // hasn't changed (or child doesn't provide one — always re-render).
434            if !cache.dirty
435                && let Some(ref cached) = cache.cache
436                && cached.key.width == width
437                && child.cache_key(width).is_some_and(|k| k == cached.key)
438            {
439                lines.extend(cached.lines.clone());
440                continue;
441            }
442            let child_lines = child.render(width);
443            child.clear_dirty();
444            let cache_key = child.cache_key(width).unwrap_or(RenderCacheKey {
445                width,
446                expanded: false,
447                state_hash: 0,
448            });
449            cache.cache = Some(RenderCache {
450                key: cache_key,
451                lines: child_lines.clone(),
452            });
453            cache.dirty = false;
454            lines.extend(child_lines);
455        }
456        // Composite overlays on top of base content
457        if !self.overlay_stack.is_empty() {
458            lines = self.composite_overlays(&lines, width, self.term_height);
459        }
460        lines
461    }
462
463    fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> bool {
464        // Route to overlays in reverse order (topmost first)
465        for entry in self.overlay_stack.iter_mut().rev() {
466            if !entry.hidden && entry.component.handle_input(key) {
467                return true;
468            }
469        }
470        // Route to base children
471        for child in self.children.iter_mut().rev() {
472            if child.handle_input(key) {
473                return true;
474            }
475        }
476        false
477    }
478
479    fn invalidate(&mut self) {
480        for child in &mut self.children {
481            child.invalidate();
482        }
483        for cache in &mut self.child_caches {
484            cache.dirty = true;
485            cache.cache = None;
486        }
487    }
488
489    fn is_dirty(&self) -> bool {
490        self.child_caches.iter().any(|c| c.dirty)
491    }
492
493    fn clear_dirty(&mut self) {
494        for cache in &mut self.child_caches {
495            cache.dirty = false;
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::tui::component::Component;
504
505    /// Test component that tracks how many times it was rendered and whether
506    /// it reports as dirty. Used to verify the Container's caching logic.
507    struct TrackRender {
508        render_count: usize,
509        dirty: bool,
510        label: String,
511    }
512
513    impl TrackRender {
514        fn new(label: &str) -> Self {
515            Self {
516                render_count: 0,
517                dirty: true,
518                label: label.to_string(),
519            }
520        }
521    }
522
523    impl Component for TrackRender {
524        fn render(&mut self, _width: usize) -> Vec<String> {
525            self.render_count += 1;
526            vec![format!("{}[{}]", self.label, self.render_count)]
527        }
528
529        fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
530            Some(RenderCacheKey {
531                width,
532                expanded: false,
533                state_hash: self.render_count as u64,
534            })
535        }
536
537        fn is_dirty(&self) -> bool {
538            self.dirty
539        }
540
541        fn clear_dirty(&mut self) {
542            self.dirty = false;
543        }
544    }
545
546    #[test]
547    fn test_re_render_when_dirty() {
548        let mut c = Container::new();
549        let child = Box::new(TrackRender::new("a"));
550        c.add_child(child);
551
552        // First render: child is dirty, should render
553        let lines = c.render(80);
554        assert_eq!(lines.len(), 1);
555        assert_eq!(lines[0], "a[1]");
556
557        // Second render: child is NOT dirty (clear_dirty was called),
558        // should use cache
559        let lines = c.render(80);
560        assert_eq!(lines[0], "a[1]"); // cached
561
562        // Mark dirty and render again
563        c.invalidate_all();
564        let lines = c.render(80);
565        assert_eq!(lines[0], "a[2]"); // re-rendered
566    }
567
568    #[test]
569    fn test_re_render_when_child_stays_dirty() {
570        // A component that always returns is_dirty() = true should
571        // never be cached by the Container.
572        struct AlwaysDirty;
573
574        impl Component for AlwaysDirty {
575            fn render(&mut self, _width: usize) -> Vec<String> {
576                vec!["fresh".to_string()]
577            }
578
579            fn is_dirty(&self) -> bool {
580                true
581            }
582        }
583
584        let mut c = Container::new();
585        c.add_child(Box::new(AlwaysDirty));
586
587        let lines1 = c.render(80);
588        assert_eq!(lines1[0], "fresh");
589
590        // Second render: child is still dirty, should NOT use cache
591        let lines2 = c.render(80);
592        assert_eq!(lines2[0], "fresh");
593
594        // Verify the Container's own cache was NOT used
595        // (the child's render was called)
596        // We can check this by checking that the internal cache was bypassed
597        assert!(
598            !c.child_caches[0].dirty,
599            "child cache should be marked clean after render"
600        );
601    }
602
603    #[test]
604    fn test_cached_after_non_dirty_render() {
605        let mut c = Container::new();
606        c.add_child(Box::new(TrackRender::new("x")));
607
608        // First render
609        c.render(80);
610
611        // Second render with different width — cache miss despite not dirty
612        let lines = c.render(40);
613        assert_eq!(lines[0], "x[2]"); // re-rendered because width differs
614    }
615
616    #[test]
617    fn test_mixed_dirty_and_not_dirty_children() {
618        struct SometimesDirty {
619            toggle: bool,
620        }
621        impl Component for SometimesDirty {
622            fn render(&mut self, _width: usize) -> Vec<String> {
623                vec!["s".to_string()]
624            }
625            fn is_dirty(&self) -> bool {
626                self.toggle
627            }
628            fn clear_dirty(&mut self) {
629                // No-op: clear_dirty is called by Container after render,
630                // but is_dirty depends on toggle, not a flag we clear here
631            }
632        }
633
634        let mut c = Container::new();
635        c.add_child(Box::new(TrackRender::new("a")));
636        c.add_child(Box::new(SometimesDirty { toggle: false }));
637
638        // First render: both children are dirty by initialization (TrackRender)
639        let lines = c.render(80);
640        assert_eq!(lines[0], "a[1]");
641        assert_eq!(lines[1], "s");
642
643        // Second render: TrackRender now not dirty (cleared), SometimesDirty is false
644        // Both should use cache
645        let lines = c.render(80);
646        assert_eq!(lines[0], "a[1]"); // cached
647        assert_eq!(lines[1], "s");
648    }
649}