Skip to main content

oxi_tui/widget/
tree.rs

1//! The retained widget tree root. Owns the top-level widget and tracks its
2//! content hash and resolved cursor position across frames.
3//!
4//! ## Frame lifecycle (the memoization contract)
5//!
6//! 1. **`any_hash_changed`** — walk the root, aggregating child hashes via
7//!    [`Renderable::content_hash`][crate::widget::Renderable]. The pipeline
8//!    compares this to last frame's hash; if unchanged (and no resize) it
9//!    skips rendering and flushing entirely — an idle frame is ~free.
10//! 2. **`render`** — when the hash changed, paint the tree into the frame
11//!    buffer, then take the cursor slot the widgets requested and resolve
12//!    it against `last_cursor`. The resolved position is handed to
13//!    [`CursorState::reconcile`][crate::pipeline::CursorState], which emits
14//!    cursor bytes only when something actually changed.
15//!
16//! Composite widgets get finer-grained skip by wrapping children in
17//! [`RetainedChild`][crate::widget::RetainedChild]: a token change in one
18//! subtree trips the root hash, but only that subtree re-renders.
19
20use ratatui::layout::Position;
21
22use crate::widget::{RenderCtx, Renderable};
23
24/// Top-level retained widget and its cross-frame state.
25pub struct RetainedTree {
26    root: Box<dyn Renderable>,
27    last_hash: u64,
28    last_cursor: Option<Position>,
29}
30
31impl RetainedTree {
32    #[must_use]
33    pub fn new(root: Box<dyn Renderable>) -> Self {
34        Self {
35            root,
36            last_hash: 0,
37            last_cursor: None,
38        }
39    }
40
41    /// Reports whether the root content hash changed since the last call.
42    pub fn any_hash_changed(&mut self) -> bool {
43        let hash = self.root.content_hash();
44        let changed = hash != self.last_hash;
45        self.last_hash = hash;
46        changed
47    }
48
49    /// Renders the tree and resolves its cursor request for this frame.
50    pub fn render(&mut self, ctx: &mut RenderCtx) -> Option<Position> {
51        let area = ctx.area();
52        self.root.render(area, ctx);
53        let slot = ctx.take_cursor_slot();
54        let cursor = slot.resolve(self.last_cursor);
55        self.last_cursor = cursor;
56        cursor
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::pipeline::CursorSlot;
64    use ratatui::Terminal;
65    use ratatui::backend::TestBackend;
66    use ratatui::layout::Rect;
67
68    struct StubWidget {
69        hash_value: u64,
70        cursor_to_emit: CursorSlot,
71        render_count: usize,
72    }
73
74    impl Renderable for StubWidget {
75        fn content_hash(&self) -> u64 {
76            self.hash_value
77        }
78
79        fn height_for(&self, _width: u16, _ctx: &RenderCtx) -> u16 {
80            1
81        }
82
83        fn render(&mut self, _area: Rect, ctx: &mut RenderCtx) {
84            self.render_count += 1;
85            match self.cursor_to_emit {
86                CursorSlot::Show(position) => ctx.set_cursor(position),
87                CursorSlot::Hide => ctx.hide_cursor(),
88                CursorSlot::NotSet => {}
89            }
90        }
91    }
92
93    const P1: Position = Position { x: 1, y: 1 };
94
95    fn run_frame(
96        tree: &mut RetainedTree,
97        terminal: &mut Terminal<TestBackend>,
98    ) -> Option<Position> {
99        let theme = crate::theme::Theme::dark();
100        let caps = crate::theme::TerminalCaps::default();
101        let mut cursor_position = None;
102        terminal
103            .draw(|frame| {
104                let mut ctx = RenderCtx::new(frame, &theme, &caps);
105                cursor_position = tree.render(&mut ctx);
106            })
107            .unwrap();
108        cursor_position
109    }
110
111    #[test]
112    fn first_call_emits_cursor_from_show() {
113        let mut tree = RetainedTree::new(Box::new(StubWidget {
114            hash_value: 100,
115            cursor_to_emit: CursorSlot::Show(P1),
116            render_count: 0,
117        }));
118        let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap();
119        assert_eq!(run_frame(&mut tree, &mut terminal), Some(P1));
120    }
121
122    #[test]
123    fn notset_falls_back_to_last_cursor_across_frames() {
124        let mut tree = RetainedTree::new(Box::new(StubWidget {
125            hash_value: 100,
126            cursor_to_emit: CursorSlot::Show(P1),
127            render_count: 0,
128        }));
129        let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap();
130        assert_eq!(run_frame(&mut tree, &mut terminal), Some(P1));
131        assert_eq!(CursorSlot::NotSet.resolve(tree.last_cursor), Some(P1));
132    }
133
134    #[test]
135    fn hide_overrides_last_cursor_fallback() {
136        assert_eq!(CursorSlot::Hide.resolve(Some(P1)), None);
137    }
138
139    #[test]
140    fn any_hash_changed_first_call_true() {
141        let mut tree = RetainedTree::new(Box::new(StubWidget {
142            hash_value: 42,
143            cursor_to_emit: CursorSlot::NotSet,
144            render_count: 0,
145        }));
146        assert!(tree.any_hash_changed());
147    }
148
149    #[test]
150    fn any_hash_changed_unchanged_false() {
151        let mut tree = RetainedTree::new(Box::new(StubWidget {
152            hash_value: 42,
153            cursor_to_emit: CursorSlot::NotSet,
154            render_count: 0,
155        }));
156        assert!(tree.any_hash_changed());
157        assert!(!tree.any_hash_changed());
158    }
159}