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    /// Convenience: show an overlay anchored at top-left, full width.
89    /// The pattern used by most agent UI overlays (model selector, auth dialogs, etc.).
90    pub fn show_top_overlay(&mut self, component: Box<dyn Component>) -> u64 {
91        use crate::tui::overlay::{OverlayAnchor, OverlayOptions, SizeValue};
92        self.show_overlay(
93            component,
94            OverlayOptions {
95                width: Some(SizeValue::Percent(100.0)),
96                anchor: Some(OverlayAnchor::TopLeft),
97                ..Default::default()
98            },
99        )
100    }
101
102    pub fn hide_overlay(&mut self, id: u64) {
103        self.root.hide_overlay(id);
104        self.dirty = true;
105    }
106
107    pub fn pop_overlay(&mut self) {
108        self.root.pop_overlay();
109        self.dirty = true;
110    }
111
112    pub fn has_overlays(&self) -> bool {
113        self.root.has_overlays()
114    }
115
116    // ── Input routing ──────────────────────────────────────────────
117
118    /// Route a keyboard event through the overlay input pipeline.
119    /// Should be called BEFORE the application handles the key itself,
120    /// so overlays get first crack at input.
121    pub fn route_input(&mut self, key: &KeyEvent) -> bool {
122        self.root.handle_input(key)
123    }
124
125    /// Route a paste event to overlays or root.
126    pub fn route_paste(&mut self, text: &str) -> bool {
127        // Try overlays first
128        for entry in self.root.overlay_stack_mut().iter_mut().rev() {
129            if !entry.hidden {
130                entry.component.handle_paste(text);
131                return true;
132            }
133        }
134        false
135    }
136
137    // ── Rendering ──────────────────────────────────────────────────
138
139    /// Render the root component tree (including composited overlays),
140    /// then diff-render to screen via Screen.
141    pub fn render(
142        &mut self,
143        width: usize,
144        height: usize,
145        writer: &mut dyn Write,
146    ) -> io::Result<()> {
147        self.width = width;
148        self.height = height;
149        self.root.set_term_height(height);
150
151        // Render root container (includes overlay compositing internally)
152        let mut lines = self.root.render(width);
153
154        // Normalize terminal output
155        for line in lines.iter_mut() {
156            *line = normalize_terminal_output(line);
157        }
158
159        // Diff render via Screen (extracts cursor markers internally)
160        let cursor_pos = self
161            .screen
162            .render(lines.clone(), width as u16, height as u16, writer)?;
163
164        // Position hardware cursor if marker was found
165        if let Some((row, col)) = cursor_pos {
166            self.position_hard_cursor(row, col, writer)?;
167        }
168
169        self.dirty = false;
170        Ok(())
171    }
172
173    /// Move cursor to clean position on exit — past all content
174    pub fn finalize(&mut self, writer: &mut dyn Write) -> io::Result<()> {
175        self.screen.finalize(writer)
176    }
177
178    fn position_hard_cursor(
179        &mut self,
180        row: usize,
181        col: usize,
182        writer: &mut dyn Write,
183    ) -> io::Result<()> {
184        let total = self.screen.total_lines();
185        if total == 0 {
186            return Ok(());
187        }
188        let target_row = row.min(total.saturating_sub(1));
189        let target_col = col.min(self.width.saturating_sub(1));
190
191        // Relative row movement from the physical cursor position (pi-style).
192        // This avoids absolute CSI H which assumes content starts at terminal row 0.
193        let current_row = self.screen.hardware_cursor_row();
194        let row_delta = target_row as i32 - current_row as i32;
195        let mut buf = String::new();
196        if row_delta > 0 {
197            buf.push_str(&format!("\x1b[{}B", row_delta));
198        } else if row_delta < 0 {
199            buf.push_str(&format!("\x1b[{}A", -row_delta));
200        }
201        // Absolute column within the row
202        buf.push_str(&format!("\x1b[{}G", target_col + 1));
203
204        if !buf.is_empty() {
205            write!(writer, "{}", buf)?;
206            writer.flush()?;
207        }
208
209        // Update Screen tracking to match the new physical cursor position
210        // (matching pi's hardwareCursorRow = targetRow in positionHardwareCursor).
211        self.screen.set_hardware_cursor_row(target_row);
212
213        Ok(())
214    }
215}
216
217impl Default for TUI {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::tui::overlay::{OverlayAnchor, OverlayOptions, SizeValue};
227
228    struct TestComponent {
229        text: String,
230    }
231
232    impl Component for TestComponent {
233        fn render(&mut self, _width: usize) -> Vec<String> {
234            vec![self.text.clone()]
235        }
236
237        fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
238            false
239        }
240
241        fn invalidate(&mut self) {}
242    }
243
244    #[test]
245    fn test_tui_new() {
246        let tui = TUI::new();
247        assert!(!tui.has_overlays());
248        assert_eq!(tui.full_redraw_count(), 0);
249    }
250
251    #[test]
252    fn test_show_and_hide_overlay() {
253        let mut tui = TUI::new();
254        let id = tui.show_overlay(
255            Box::new(TestComponent {
256                text: "overlay".into(),
257            }),
258            OverlayOptions::default(),
259        );
260        assert!(tui.has_overlays());
261        tui.hide_overlay(id);
262        assert!(!tui.has_overlays());
263    }
264
265    #[test]
266    fn test_pop_overlay() {
267        let mut tui = TUI::new();
268        tui.show_overlay(
269            Box::new(TestComponent { text: "a".into() }),
270            OverlayOptions::default(),
271        );
272        tui.show_overlay(
273            Box::new(TestComponent { text: "b".into() }),
274            OverlayOptions::default(),
275        );
276        assert!(tui.has_overlays());
277        tui.pop_overlay();
278        assert!(tui.has_overlays()); // still has "a"
279        tui.pop_overlay();
280        assert!(!tui.has_overlays());
281    }
282
283    #[test]
284    fn test_cursor_marker_extraction() {
285        use crate::tui::screen::Screen;
286        let screen = Screen::new();
287        let mut lines = vec![
288            "line 1".to_string(),
289            format!("before{}after", CURSOR_MARKER),
290            "line 3".to_string(),
291        ];
292        let pos = screen.extract_cursor_marker(&mut lines, 10);
293        assert!(pos.is_some());
294        let (row, col) = pos.unwrap();
295        assert_eq!(row, 1);
296        assert_eq!(col, 6); // visible_width("before") = 6
297        assert_eq!(lines[1], "beforeafter");
298        assert!(!lines[1].contains(CURSOR_MARKER));
299    }
300
301    #[test]
302    fn test_cursor_marker_outside_viewport() {
303        use crate::tui::screen::Screen;
304        let screen = Screen::new();
305        // Marker on line 0 but viewport is last 2 lines of 5
306        let mut lines = vec![
307            format!("{}marker", CURSOR_MARKER),
308            "b".to_string(),
309            "c".to_string(),
310            "d".to_string(),
311            "e".to_string(),
312        ];
313        let pos = screen.extract_cursor_marker(&mut lines, 2);
314        assert!(pos.is_none()); // line 0 is not in last 2 of 5
315    }
316
317    #[test]
318    fn test_overlay_layout_center_default() {
319        // Layout resolution is now on Container - we test via overlay rendering
320        let mut c = Container::new();
321        c.set_term_height(24);
322        let child = crate::tui::components::Text::new("test", 0, 0, None);
323        c.show_overlay(Box::new(child), OverlayOptions::default());
324        let lines = c.render(80);
325        assert!(!lines.is_empty());
326    }
327
328    #[test]
329    fn test_overlay_layout_percent_width() {
330        let mut c = Container::new();
331        c.set_term_height(24);
332        let child = crate::tui::components::Text::new("x", 0, 0, None);
333        c.show_overlay(
334            Box::new(child),
335            OverlayOptions {
336                width: Some(SizeValue::Percent(50.0)),
337                ..Default::default()
338            },
339        );
340        let lines = c.render(80);
341        assert!(!lines.is_empty());
342    }
343
344    #[test]
345    fn test_overlay_layout_margin() {
346        let mut c = Container::new();
347        c.set_term_height(24);
348        let child = crate::tui::components::Text::new("test", 0, 0, None);
349        c.show_overlay(
350            Box::new(child),
351            OverlayOptions {
352                margin: Some(crate::tui::overlay::OverlayMargin {
353                    top: 2,
354                    right: 2,
355                    bottom: 2,
356                    left: 2,
357                }),
358                anchor: Some(OverlayAnchor::TopLeft),
359                ..Default::default()
360            },
361        );
362        let lines = c.render(80);
363        assert!(!lines.is_empty());
364    }
365}