Skip to main content

rab/tui/
tui_core.rs

1use std::io::{self, Write};
2
3use crossterm::event::KeyEvent;
4
5use crate::tui::Component;
6use crate::tui::container::Container;
7use crate::tui::overlay::OverlayOptions;
8use crate::tui::screen::Screen;
9use crate::tui::util::normalize_terminal_output;
10
11/// Cursor marker constant (matches pi: APC pi:c ST)
12pub const CURSOR_MARKER: &str = "\x1b_pi:c\x07";
13
14// =============================================================================
15// TUI — Main class for managing terminal UI with differential rendering
16// and overlay compositing. Wraps Screen and adds overlay stack, focus
17// management, and input pipeline.
18//
19// Pi reference: packages/tui/src/tui.ts
20// =============================================================================
21
22pub struct TUI {
23    /// The root container — all top-level children are added here.
24    /// Overlays are also managed through Container's overlay stack.
25    /// Matches pi's `class TUI extends Container`.
26    pub root: Container,
27
28    /// The diff renderer
29    screen: Screen,
30    /// Terminal dimensions (cached)
31    width: usize,
32    height: usize,
33    /// Whether content changed since last render
34    dirty: bool,
35}
36
37impl TUI {
38    pub fn new() -> Self {
39        Self {
40            root: Container::new(),
41            screen: Screen::new(),
42            width: 80,
43            height: 24,
44            dirty: true,
45        }
46    }
47
48    // ── Screen delegation ─────────────────────────────────────────
49
50    pub fn screen_mut(&mut self) -> &mut Screen {
51        &mut self.screen
52    }
53
54    pub fn full_redraw_count(&self) -> usize {
55        self.screen.full_redraw_count()
56    }
57
58    pub fn set_clear_on_shrink(&mut self, enabled: bool) {
59        self.screen.set_clear_on_shrink(enabled);
60    }
61
62    pub fn set_dimensions(&mut self, width: usize, height: usize) {
63        self.width = width;
64        self.height = height;
65        self.root.set_term_height(height);
66    }
67
68    pub fn get_dimensions(&self) -> (usize, usize) {
69        (self.width, self.height)
70    }
71
72    pub fn request_render(&mut self) {
73        self.dirty = true;
74    }
75
76    pub fn is_dirty(&self) -> bool {
77        self.dirty
78    }
79
80    // ── Overlay system (delegates to Container) ───────────────────
81
82    pub fn show_overlay(&mut self, component: Box<dyn Component>, options: OverlayOptions) -> u64 {
83        let id = self.root.show_overlay(component, options);
84        self.dirty = true;
85        id
86    }
87
88    pub fn hide_overlay(&mut self, id: u64) {
89        self.root.hide_overlay(id);
90        self.dirty = true;
91    }
92
93    pub fn pop_overlay(&mut self) {
94        self.root.pop_overlay();
95        self.dirty = true;
96    }
97
98    pub fn has_overlays(&self) -> bool {
99        self.root.has_overlays()
100    }
101
102    // ── Input routing ──────────────────────────────────────────────
103
104    /// Route a keyboard event through the overlay input pipeline.
105    /// Should be called BEFORE the application handles the key itself,
106    /// so overlays get first crack at input.
107    pub fn route_input(&mut self, key: &KeyEvent) -> bool {
108        self.root.handle_input(key)
109    }
110
111    /// Route a paste event to overlays or root.
112    pub fn route_paste(&mut self, text: &str) -> bool {
113        // Try overlays first
114        for entry in self.root.overlay_stack_mut().iter_mut().rev() {
115            if !entry.hidden {
116                entry.component.handle_paste(text);
117                return true;
118            }
119        }
120        false
121    }
122
123    // ── Rendering ──────────────────────────────────────────────────
124
125    /// Render the root component tree (including composited overlays),
126    /// then diff-render to screen via Screen.
127    pub fn render(
128        &mut self,
129        width: usize,
130        height: usize,
131        writer: &mut dyn Write,
132    ) -> io::Result<()> {
133        self.width = width;
134        self.height = height;
135        self.root.set_term_height(height);
136
137        // Render root container (includes overlay compositing internally)
138        let mut lines = self.root.render(width);
139
140        // Normalize terminal output
141        for line in lines.iter_mut() {
142            *line = normalize_terminal_output(line);
143        }
144
145        // Diff render via Screen (extracts cursor markers internally)
146        let cursor_pos = self
147            .screen
148            .render(lines.clone(), width as u16, height as u16, writer)?;
149
150        // Position hardware cursor if marker was found
151        if let Some((row, col)) = cursor_pos {
152            self.position_hard_cursor(row, col, writer)?;
153        }
154
155        self.dirty = false;
156        Ok(())
157    }
158
159    /// Move cursor to clean position on exit — past all content
160    pub fn finalize(&mut self, writer: &mut dyn Write) -> io::Result<()> {
161        self.screen.finalize(writer)
162    }
163
164    fn position_hard_cursor(
165        &mut self,
166        row: usize,
167        col: usize,
168        writer: &mut dyn Write,
169    ) -> io::Result<()> {
170        let total = self.screen.total_lines();
171        if total == 0 {
172            return Ok(());
173        }
174        let target_row = row.min(total.saturating_sub(1));
175        let target_col = col.min(self.width.saturating_sub(1));
176
177        // Relative row movement from the physical cursor position (pi-style).
178        // This avoids absolute CSI H which assumes content starts at terminal row 0.
179        let current_row = self.screen.hardware_cursor_row();
180        let row_delta = target_row as i32 - current_row as i32;
181        let mut buf = String::new();
182        if row_delta > 0 {
183            buf.push_str(&format!("\x1b[{}B", row_delta));
184        } else if row_delta < 0 {
185            buf.push_str(&format!("\x1b[{}A", -row_delta));
186        }
187        // Absolute column within the row
188        buf.push_str(&format!("\x1b[{}G", target_col + 1));
189
190        if !buf.is_empty() {
191            write!(writer, "{}", buf)?;
192            writer.flush()?;
193        }
194
195        // Update Screen tracking to match the new physical cursor position
196        // (matching pi's hardwareCursorRow = targetRow in positionHardwareCursor).
197        self.screen.set_hardware_cursor_row(target_row);
198
199        Ok(())
200    }
201}
202
203impl Default for TUI {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::tui::overlay::{OverlayAnchor, OverlayOptions, SizeValue};
213
214    struct TestComponent {
215        text: String,
216    }
217
218    impl Component for TestComponent {
219        fn render(&mut self, _width: usize) -> Vec<String> {
220            vec![self.text.clone()]
221        }
222
223        fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
224            false
225        }
226
227        fn invalidate(&mut self) {}
228    }
229
230    #[test]
231    fn test_tui_new() {
232        let tui = TUI::new();
233        assert!(!tui.has_overlays());
234        assert_eq!(tui.full_redraw_count(), 0);
235    }
236
237    #[test]
238    fn test_show_and_hide_overlay() {
239        let mut tui = TUI::new();
240        let id = tui.show_overlay(
241            Box::new(TestComponent {
242                text: "overlay".into(),
243            }),
244            OverlayOptions::default(),
245        );
246        assert!(tui.has_overlays());
247        tui.hide_overlay(id);
248        assert!(!tui.has_overlays());
249    }
250
251    #[test]
252    fn test_pop_overlay() {
253        let mut tui = TUI::new();
254        tui.show_overlay(
255            Box::new(TestComponent { text: "a".into() }),
256            OverlayOptions::default(),
257        );
258        tui.show_overlay(
259            Box::new(TestComponent { text: "b".into() }),
260            OverlayOptions::default(),
261        );
262        assert!(tui.has_overlays());
263        tui.pop_overlay();
264        assert!(tui.has_overlays()); // still has "a"
265        tui.pop_overlay();
266        assert!(!tui.has_overlays());
267    }
268
269    #[test]
270    fn test_cursor_marker_extraction() {
271        use crate::tui::screen::Screen;
272        let screen = Screen::new();
273        let mut lines = vec![
274            "line 1".to_string(),
275            format!("before{}after", CURSOR_MARKER),
276            "line 3".to_string(),
277        ];
278        let pos = screen.extract_cursor_marker(&mut lines, 10);
279        assert!(pos.is_some());
280        let (row, col) = pos.unwrap();
281        assert_eq!(row, 1);
282        assert_eq!(col, 6); // visible_width("before") = 6
283        assert_eq!(lines[1], "beforeafter");
284        assert!(!lines[1].contains(CURSOR_MARKER));
285    }
286
287    #[test]
288    fn test_cursor_marker_outside_viewport() {
289        use crate::tui::screen::Screen;
290        let screen = Screen::new();
291        // Marker on line 0 but viewport is last 2 lines of 5
292        let mut lines = vec![
293            format!("{}marker", CURSOR_MARKER),
294            "b".to_string(),
295            "c".to_string(),
296            "d".to_string(),
297            "e".to_string(),
298        ];
299        let pos = screen.extract_cursor_marker(&mut lines, 2);
300        assert!(pos.is_none()); // line 0 is not in last 2 of 5
301    }
302
303    #[test]
304    fn test_overlay_layout_center_default() {
305        // Layout resolution is now on Container - we test via overlay rendering
306        let mut c = Container::new();
307        c.set_term_height(24);
308        let child = crate::tui::components::Text::new("test", 0, 0, None);
309        c.show_overlay(Box::new(child), OverlayOptions::default());
310        let lines = c.render(80);
311        assert!(!lines.is_empty());
312    }
313
314    #[test]
315    fn test_overlay_layout_percent_width() {
316        let mut c = Container::new();
317        c.set_term_height(24);
318        let child = crate::tui::components::Text::new("x", 0, 0, None);
319        c.show_overlay(
320            Box::new(child),
321            OverlayOptions {
322                width: Some(SizeValue::Percent(50.0)),
323                ..Default::default()
324            },
325        );
326        let lines = c.render(80);
327        assert!(!lines.is_empty());
328    }
329
330    #[test]
331    fn test_overlay_layout_margin() {
332        let mut c = Container::new();
333        c.set_term_height(24);
334        let child = crate::tui::components::Text::new("test", 0, 0, None);
335        c.show_overlay(
336            Box::new(child),
337            OverlayOptions {
338                margin: Some(crate::tui::overlay::OverlayMargin {
339                    top: 2,
340                    right: 2,
341                    bottom: 2,
342                    left: 2,
343                }),
344                anchor: Some(OverlayAnchor::TopLeft),
345                ..Default::default()
346            },
347        );
348        let lines = c.render(80);
349        assert!(!lines.is_empty());
350    }
351}