1use tracing::debug;
4
5use crate::vdom::VNode;
6use crossterm::{
7 event::{self, DisableMouseCapture, Event, KeyEvent},
8 execute,
9 terminal::{disable_raw_mode, enable_raw_mode},
10};
11use ratatui::{
12 backend::CrosstermBackend,
13 layout::Rect,
14 prelude::Widget,
15 widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
16 Terminal,
17};
18use std::io;
19use std::time::Duration;
20
21pub struct RatatuiBackend {
23 terminal: Terminal<CrosstermBackend<io::Stdout>>,
24}
25
26impl RatatuiBackend {
27 pub fn new() -> io::Result<Self> {
29 debug!("TUI backend initialized");
30 enable_raw_mode()?;
31 let mut stdout = io::stdout();
32 execute!(stdout, DisableMouseCapture)?;
34
35 let backend = CrosstermBackend::new(stdout);
36 let terminal = Terminal::new(backend)?;
37
38 Ok(Self { terminal })
39 }
40
41 pub fn render_vdom(&mut self, vnode: &VNode) -> io::Result<()> {
43 self.terminal.draw(|f| {
44 let size = f.area();
45 render_vdom_to_ratatui(vnode, size, f.buffer_mut());
46 })?;
47 Ok(())
48 }
49
50 pub fn size(&self) -> io::Result<Rect> {
52 let size = self.terminal.size()?;
53 Ok(Rect::new(0, 0, size.width, size.height))
54 }
55
56 pub fn clear(&mut self) -> io::Result<()> {
58 self.terminal.clear()
59 }
60}
61
62impl Drop for RatatuiBackend {
63 fn drop(&mut self) {
64 debug!("TUI cleanup");
65 let _ = disable_raw_mode();
67 }
68}
69
70pub fn render_vdom_to_ratatui(vnode: &VNode, area: Rect, buf: &mut ratatui::buffer::Buffer) {
73 match vnode {
74 VNode::Text(text) => {
75 let paragraph = Paragraph::new(text.as_str()).wrap(Wrap { trim: true });
76 paragraph.render(area, buf);
77 }
78 VNode::Element {
79 tag,
80 attrs,
81 children,
82 } => {
83 match tag.as_str() {
84 "box" => {
85 let title = attrs.get("title").map(|s| s.as_str()).unwrap_or("");
87
88 let block = Block::default().title(title).borders(Borders::ALL);
89
90 let inner_area = block.inner(area);
91 block.render(area, buf);
92
93 if !children.is_empty() {
94 render_vdom_to_ratatui(&children[0], inner_area, buf);
96 }
97 }
98 "list" => {
99 let items: Vec<ListItem> = children
101 .iter()
102 .filter_map(|child| {
103 if let VNode::Text(text) = child {
104 Some(ListItem::new(text.as_str()))
105 } else {
106 None
107 }
108 })
109 .collect();
110
111 let list = List::new(items);
112 list.render(area, buf);
113 }
114 "text" => {
115 let text = children
117 .iter()
118 .filter_map(|child| {
119 if let VNode::Text(t) = child {
120 Some(t.as_str())
121 } else {
122 None
123 }
124 })
125 .collect::<Vec<_>>()
126 .join(" ");
127
128 let paragraph = Paragraph::new(text).wrap(Wrap { trim: true });
129 paragraph.render(area, buf);
130 }
131 _ => {
132 for (i, child) in children.iter().enumerate() {
134 let child_height = area.height / children.len() as u16;
135 let child_area = Rect {
136 x: area.x,
137 y: area.y + (i as u16 * child_height),
138 width: area.width,
139 height: child_height,
140 };
141 render_vdom_to_ratatui(child, child_area, buf);
142 }
143 }
144 }
145 }
146 }
147}
148
149pub type EventCallback = fn(KeyEvent) -> bool;
151
152pub fn run_event_loop<F>(mut backend: RatatuiBackend, vnode: &VNode, callback: F) -> io::Result<()>
154where
155 F: Fn(KeyEvent) -> bool,
156{
157 backend.render_vdom(vnode)?;
159
160 loop {
161 if event::poll(Duration::from_millis(16))? {
163 if let Event::Key(key) = event::read()? {
164 if callback(key) {
166 break;
167 }
168 backend.render_vdom(vnode)?;
170 }
171 } else {
172 backend.render_vdom(vnode)?;
174 }
175 }
176
177 Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use ratatui::buffer::Buffer;
184
185 #[test]
186 fn test_backend_new() {
187 let backend = RatatuiBackend::new();
191 match backend {
192 Ok(_) => {} Err(_) => {
194 }
196 }
197 }
198
199 #[test]
200 fn test_render_text_to_ratatui() {
201 let vnode = VNode::Text("Hello, World!".to_string());
202 let area = Rect::new(0, 0, 20, 1);
203 let mut buffer = Buffer::empty(area);
204
205 render_vdom_to_ratatui(&vnode, area, &mut buffer);
206
207 let cell = buffer.cell((0, 0)).unwrap();
209 assert_eq!(cell.symbol(), "H");
210 }
211
212 #[test]
213 fn test_render_box_to_ratatui() {
214 let vnode = VNode::Element {
215 tag: "box".to_string(),
216 attrs: {
217 let mut map = std::collections::HashMap::new();
218 map.insert("title".to_string(), "Test".to_string());
219 map
220 },
221 children: vec![VNode::Text("Content".to_string())],
222 };
223
224 let area = Rect::new(0, 0, 20, 10);
225 let mut buffer = Buffer::empty(area);
226
227 render_vdom_to_ratatui(&vnode, area, &mut buffer);
228
229 let cell = buffer.cell((0, 0)).unwrap();
231 assert!(!cell.symbol().is_empty());
233 }
234
235 #[test]
236 fn test_render_list_to_ratatui() {
237 let vnode = VNode::Element {
238 tag: "list".to_string(),
239 attrs: std::collections::HashMap::new(),
240 children: vec![
241 VNode::Text("Item 1".to_string()),
242 VNode::Text("Item 2".to_string()),
243 ],
244 };
245
246 let area = Rect::new(0, 0, 20, 5);
247 let mut buffer = Buffer::empty(area);
248
249 render_vdom_to_ratatui(&vnode, area, &mut buffer);
250
251 let cell = buffer.cell((0, 0)).unwrap();
253 assert!(!cell.symbol().is_empty());
254 }
255}