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    fn composite_overlays(
177        &mut self,
178        base_lines: &[String],
179        term_width: usize,
180        term_height: usize,
181    ) -> Vec<String> {
182        let mut result = base_lines.to_vec();
183
184        // Collect visible overlay indices sorted by focus order
185        let mut indices: Vec<usize> = self
186            .overlay_stack
187            .iter()
188            .enumerate()
189            .filter(|(_, e)| !e.hidden)
190            .map(|(i, _)| i)
191            .collect();
192        indices.sort_by_key(|&i| self.overlay_stack[i].focus_order);
193
194        let mut min_lines_needed = result.len();
195
196        struct RenderedOverlay {
197            overlay_lines: Vec<String>,
198            layout: OverlayLayout,
199        }
200
201        let mut rendered: Vec<RenderedOverlay> = Vec::new();
202        for &idx in &indices {
203            let options = self.overlay_stack[idx].options.clone();
204            let layout = self.resolve_overlay_layout(&options, 0, term_width, term_height);
205
206            let mut overlay_lines = self.overlay_stack[idx].component.render(layout.width);
207
208            let overlay_height = if let Some(max_h) = layout.max_height {
209                overlay_lines.truncate(max_h);
210                overlay_lines.len()
211            } else {
212                overlay_lines.len()
213            };
214
215            let layout =
216                self.resolve_overlay_layout(&options, overlay_height, term_width, term_height);
217
218            min_lines_needed = min_lines_needed.max(layout.row + overlay_lines.len());
219
220            rendered.push(RenderedOverlay {
221                overlay_lines,
222                layout,
223            });
224        }
225
226        let working_height = result.len().max(term_height).max(min_lines_needed);
227        while result.len() < working_height {
228            result.push(String::new());
229        }
230
231        let viewport_start = working_height.saturating_sub(term_height);
232
233        for ro in &rendered {
234            for (i, overlay_line) in ro.overlay_lines.iter().enumerate() {
235                let idx = viewport_start + ro.layout.row + i;
236                if idx < result.len() {
237                    let truncated = if visible_width(overlay_line) > ro.layout.width {
238                        slice_by_column(overlay_line, 0, ro.layout.width)
239                    } else {
240                        overlay_line.clone()
241                    };
242                    result[idx] = self.composite_line_at(
243                        &result[idx],
244                        &truncated,
245                        ro.layout.col,
246                        ro.layout.width,
247                        term_width,
248                    );
249                }
250            }
251        }
252
253        result
254    }
255
256    /// Splice overlay content into a base line at a specific column.
257    fn composite_line_at(
258        &self,
259        base_line: &str,
260        overlay_line: &str,
261        start_col: usize,
262        overlay_width: usize,
263        total_width: usize,
264    ) -> String {
265        let after_start = start_col + overlay_width;
266
267        let (before, before_width, after, after_width) = extract_segments(
268            base_line,
269            start_col,
270            after_start,
271            total_width.saturating_sub(after_start),
272            true,
273        );
274
275        let overlay = slice_by_column(overlay_line, 0, overlay_width);
276        let overlay_vis = visible_width(&overlay);
277
278        let before_pad = start_col.saturating_sub(before_width);
279        let overlay_pad = overlay_width.saturating_sub(overlay_vis);
280        let actual_before_width = before_width.max(start_col);
281        let actual_overlay_width = overlay_vis.max(overlay_width);
282        let after_target = total_width.saturating_sub(actual_before_width + actual_overlay_width);
283        let after_pad = after_target.saturating_sub(after_width);
284
285        let mut result = String::new();
286        result.push_str(&before);
287        result.push_str(&" ".repeat(before_pad));
288        result.push_str(SEGMENT_RESET);
289        result.push_str(&overlay);
290        result.push_str(&" ".repeat(overlay_pad));
291        result.push_str(SEGMENT_RESET);
292        result.push_str(&after);
293        result.push_str(&" ".repeat(after_pad));
294
295        let rw = visible_width(&result);
296        if rw > total_width {
297            result = slice_by_column(&result, 0, total_width);
298        }
299
300        result
301    }
302
303    /// Resolve overlay layout from options.
304    fn resolve_overlay_layout(
305        &self,
306        options: &OverlayOptions,
307        overlay_height: usize,
308        term_width: usize,
309        term_height: usize,
310    ) -> OverlayLayout {
311        let margin = options.margin.unwrap_or_default();
312        let margin_top = margin.top;
313        let margin_right = margin.right;
314        let margin_bottom = margin.bottom;
315        let margin_left = margin.left;
316
317        let avail_width = (term_width - margin_left - margin_right).max(1);
318        let avail_height = (term_height - margin_top - margin_bottom).max(1);
319
320        let width = options
321            .width
322            .map(|sv| sv.resolve(term_width))
323            .unwrap_or_else(|| 80.min(avail_width));
324        let width = options.min_width.map(|mw| width.max(mw)).unwrap_or(width);
325        let width = width.max(1).min(avail_width);
326
327        let max_height = options.max_height.map(|sv| sv.resolve(term_height));
328        let max_height = max_height.map(|mh| mh.max(1).min(avail_height));
329
330        let effective_height = match max_height {
331            Some(mh) => overlay_height.min(mh),
332            None => overlay_height,
333        };
334
335        let row = if let Some(ref row_sv) = options.row {
336            match row_sv {
337                SizeValue::Absolute(r) => *r,
338                SizeValue::Percent(p) => {
339                    let max_row = avail_height - effective_height;
340                    margin_top + ((max_row as f64 * p / 100.0).floor() as usize)
341                }
342            }
343        } else {
344            let anchor = options.anchor.unwrap_or_default();
345            Self::resolve_anchor_row(anchor, effective_height, avail_height, margin_top)
346        };
347
348        let col = if let Some(ref col_sv) = options.col {
349            match col_sv {
350                SizeValue::Absolute(c) => *c,
351                SizeValue::Percent(p) => {
352                    let max_col = avail_width - width;
353                    margin_left + ((max_col as f64 * p / 100.0).floor() as usize)
354                }
355            }
356        } else {
357            let anchor = options.anchor.unwrap_or_default();
358            Self::resolve_anchor_col(anchor, width, avail_width, margin_left)
359        };
360
361        let row = (row as isize + options.offset_y.unwrap_or(0)) as usize;
362        let col = (col as isize + options.offset_x.unwrap_or(0)) as usize;
363
364        OverlayLayout {
365            width,
366            row,
367            col,
368            max_height,
369        }
370    }
371
372    fn resolve_anchor_row(
373        anchor: crate::tui::overlay::OverlayAnchor,
374        overlay_height: usize,
375        avail_height: usize,
376        margin_top: usize,
377    ) -> usize {
378        use crate::tui::overlay::OverlayAnchor::*;
379        match anchor {
380            Center | LeftCenter | RightCenter => {
381                margin_top + (avail_height.saturating_sub(overlay_height) / 2)
382            }
383            TopLeft | TopCenter | TopRight => margin_top,
384            BottomLeft | BottomCenter | BottomRight => {
385                margin_top + avail_height.saturating_sub(overlay_height)
386            }
387        }
388    }
389
390    fn resolve_anchor_col(
391        anchor: crate::tui::overlay::OverlayAnchor,
392        overlay_width: usize,
393        avail_width: usize,
394        margin_left: usize,
395    ) -> usize {
396        use crate::tui::overlay::OverlayAnchor::*;
397        match anchor {
398            Center | TopCenter | BottomCenter => {
399                margin_left + (avail_width.saturating_sub(overlay_width) / 2)
400            }
401            TopLeft | LeftCenter | BottomLeft => margin_left,
402            TopRight | RightCenter | BottomRight => {
403                margin_left + avail_width.saturating_sub(overlay_width)
404            }
405        }
406    }
407}
408
409impl Component for Container {
410    fn render(&mut self, width: usize) -> Vec<String> {
411        let mut lines = Vec::new();
412        for (idx, child) in self.children.iter_mut().enumerate() {
413            let cache = &mut self.child_caches[idx];
414            // Use cached output if width matches and the child's cache key
415            // hasn't changed (or child doesn't provide one — always re-render).
416            if !cache.dirty
417                && let Some(ref cached) = cache.cache
418                && cached.key.width == width
419                && child.cache_key(width).is_some_and(|k| k == cached.key)
420            {
421                lines.extend(cached.lines.clone());
422                continue;
423            }
424            let child_lines = child.render(width);
425            child.clear_dirty();
426            let cache_key = child.cache_key(width).unwrap_or(RenderCacheKey {
427                width,
428                expanded: false,
429                state_hash: 0,
430            });
431            cache.cache = Some(RenderCache {
432                key: cache_key,
433                lines: child_lines.clone(),
434            });
435            cache.dirty = false;
436            lines.extend(child_lines);
437        }
438        // Composite overlays on top of base content
439        if !self.overlay_stack.is_empty() {
440            lines = self.composite_overlays(&lines, width, self.term_height);
441        }
442        lines
443    }
444
445    fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> bool {
446        // Route to overlays in reverse order (topmost first)
447        for entry in self.overlay_stack.iter_mut().rev() {
448            if !entry.hidden && entry.component.handle_input(key) {
449                return true;
450            }
451        }
452        // Route to base children
453        for child in self.children.iter_mut().rev() {
454            if child.handle_input(key) {
455                return true;
456            }
457        }
458        false
459    }
460
461    fn invalidate(&mut self) {
462        for child in &mut self.children {
463            child.invalidate();
464        }
465        for cache in &mut self.child_caches {
466            cache.dirty = true;
467            cache.cache = None;
468        }
469    }
470
471    fn is_dirty(&self) -> bool {
472        self.child_caches.iter().any(|c| c.dirty)
473    }
474
475    fn clear_dirty(&mut self) {
476        for cache in &mut self.child_caches {
477            cache.dirty = false;
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::tui::component::Component;
486
487    /// Test component that tracks how many times it was rendered and whether
488    /// it reports as dirty. Used to verify the Container's caching logic.
489    struct TrackRender {
490        render_count: usize,
491        dirty: bool,
492        label: String,
493    }
494
495    impl TrackRender {
496        fn new(label: &str) -> Self {
497            Self {
498                render_count: 0,
499                dirty: true,
500                label: label.to_string(),
501            }
502        }
503    }
504
505    impl Component for TrackRender {
506        fn render(&mut self, _width: usize) -> Vec<String> {
507            self.render_count += 1;
508            vec![format!("{}[{}]", self.label, self.render_count)]
509        }
510
511        fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
512            Some(RenderCacheKey {
513                width,
514                expanded: false,
515                state_hash: self.render_count as u64,
516            })
517        }
518
519        fn is_dirty(&self) -> bool {
520            self.dirty
521        }
522
523        fn clear_dirty(&mut self) {
524            self.dirty = false;
525        }
526    }
527
528    #[test]
529    fn test_re_render_when_dirty() {
530        let mut c = Container::new();
531        let child = Box::new(TrackRender::new("a"));
532        c.add_child(child);
533
534        // First render: child is dirty, should render
535        let lines = c.render(80);
536        assert_eq!(lines.len(), 1);
537        assert_eq!(lines[0], "a[1]");
538
539        // Second render: child is NOT dirty (clear_dirty was called),
540        // should use cache
541        let lines = c.render(80);
542        assert_eq!(lines[0], "a[1]"); // cached
543
544        // Mark dirty and render again
545        c.invalidate_all();
546        let lines = c.render(80);
547        assert_eq!(lines[0], "a[2]"); // re-rendered
548    }
549
550    #[test]
551    fn test_re_render_when_child_stays_dirty() {
552        // A component that always returns is_dirty() = true should
553        // never be cached by the Container.
554        struct AlwaysDirty;
555
556        impl Component for AlwaysDirty {
557            fn render(&mut self, _width: usize) -> Vec<String> {
558                vec!["fresh".to_string()]
559            }
560
561            fn is_dirty(&self) -> bool {
562                true
563            }
564        }
565
566        let mut c = Container::new();
567        c.add_child(Box::new(AlwaysDirty));
568
569        let lines1 = c.render(80);
570        assert_eq!(lines1[0], "fresh");
571
572        // Second render: child is still dirty, should NOT use cache
573        let lines2 = c.render(80);
574        assert_eq!(lines2[0], "fresh");
575
576        // Verify the Container's own cache was NOT used
577        // (the child's render was called)
578        // We can check this by checking that the internal cache was bypassed
579        assert!(
580            !c.child_caches[0].dirty,
581            "child cache should be marked clean after render"
582        );
583    }
584
585    #[test]
586    fn test_cached_after_non_dirty_render() {
587        let mut c = Container::new();
588        c.add_child(Box::new(TrackRender::new("x")));
589
590        // First render
591        c.render(80);
592
593        // Second render with different width — cache miss despite not dirty
594        let lines = c.render(40);
595        assert_eq!(lines[0], "x[2]"); // re-rendered because width differs
596    }
597
598    #[test]
599    fn test_mixed_dirty_and_not_dirty_children() {
600        struct SometimesDirty {
601            toggle: bool,
602        }
603        impl Component for SometimesDirty {
604            fn render(&mut self, _width: usize) -> Vec<String> {
605                vec!["s".to_string()]
606            }
607            fn is_dirty(&self) -> bool {
608                self.toggle
609            }
610            fn clear_dirty(&mut self) {
611                // No-op: clear_dirty is called by Container after render,
612                // but is_dirty depends on toggle, not a flag we clear here
613            }
614        }
615
616        let mut c = Container::new();
617        c.add_child(Box::new(TrackRender::new("a")));
618        c.add_child(Box::new(SometimesDirty { toggle: false }));
619
620        // First render: both children are dirty by initialization (TrackRender)
621        let lines = c.render(80);
622        assert_eq!(lines[0], "a[1]");
623        assert_eq!(lines[1], "s");
624
625        // Second render: TrackRender now not dirty (cleared), SometimesDirty is false
626        // Both should use cache
627        let lines = c.render(80);
628        assert_eq!(lines[0], "a[1]"); // cached
629        assert_eq!(lines[1], "s");
630    }
631}