1use ratatui::layout::Position;
21
22use crate::widget::{RenderCtx, Renderable};
23
24pub 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 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 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}