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::{OverlayAnchor, OverlayEntry, OverlayLayout, OverlayOptions, SizeValue};
8use crate::tui::screen::Screen;
9use crate::tui::util::{
10    extract_segments, normalize_terminal_output, slice_by_column, visible_width,
11};
12
13/// Marker appended to lines after extraction — matches pi's SEGMENT_RESET
14const SEGMENT_RESET: &str = "\x1b[0m\x1b]8;;\x07";
15
16/// Cursor marker constant (matches pi: APC pi:c ST)
17pub const CURSOR_MARKER: &str = "\x1b_pi:c\x07";
18
19// =============================================================================
20// TUI — Main class for managing terminal UI with differential rendering
21// and overlay compositing. Wraps Screen and adds overlay stack, focus
22// management, and input pipeline.
23//
24// Pi reference: packages/tui/src/tui.ts
25// =============================================================================
26
27pub struct TUI {
28    /// The root container — all top-level children are added here.
29    /// Matches pi's `class TUI extends Container` — the TUI itself renders
30    /// this root container first, then applies overlays.
31    pub root: Container,
32
33    /// The diff renderer
34    screen: Screen,
35    /// Terminal dimensions (cached)
36    width: usize,
37    height: usize,
38    /// Whether content changed since last render
39    dirty: bool,
40
41    // Overlay stack
42    overlay_stack: Vec<OverlayEntry>,
43    next_overlay_id: u64,
44    focus_order_counter: u64,
45
46    // Focus state
47    /// Currently focused component index (within overlay stack, or None for base)
48    focused_component: Option<usize>,
49}
50
51impl TUI {
52    pub fn new() -> Self {
53        Self {
54            root: Container::new(),
55            screen: Screen::new(),
56            width: 80,
57            height: 24,
58            dirty: true,
59            overlay_stack: Vec::new(),
60            next_overlay_id: 0,
61            focus_order_counter: 0,
62            focused_component: None,
63        }
64    }
65
66    // ── Screen delegation ─────────────────────────────────────────
67
68    pub fn screen_mut(&mut self) -> &mut Screen {
69        &mut self.screen
70    }
71
72    pub fn full_redraw_count(&self) -> usize {
73        self.screen.full_redraw_count()
74    }
75
76    pub fn set_clear_on_shrink(&mut self, enabled: bool) {
77        self.screen.set_clear_on_shrink(enabled);
78    }
79
80    pub fn set_dimensions(&mut self, width: usize, height: usize) {
81        self.width = width;
82        self.height = height;
83    }
84
85    pub fn get_dimensions(&self) -> (usize, usize) {
86        (self.width, self.height)
87    }
88
89    pub fn request_render(&mut self) {
90        self.dirty = true;
91    }
92
93    pub fn is_dirty(&self) -> bool {
94        self.dirty
95    }
96
97    // ── Overlay system ─────────────────────────────────────────────
98
99    /// Show an overlay component with configurable positioning and sizing.
100    /// Returns an overlay ID that can be used with `hide_overlay`.
101    pub fn show_overlay(&mut self, component: Box<dyn Component>, options: OverlayOptions) -> u64 {
102        let id = self.next_overlay_id;
103        self.next_overlay_id += 1;
104
105        let is_capturing = !options.non_capturing;
106
107        let entry = OverlayEntry {
108            component,
109            options,
110            pre_focus: self.focused_component,
111            hidden: false,
112            focus_order: self.focus_order_counter,
113            id,
114        };
115        self.focus_order_counter += 1;
116        self.overlay_stack.push(entry);
117
118        // Focus the overlay if it's capturing
119        if is_capturing {
120            let idx = self.overlay_stack.len() - 1;
121            self.focused_component = Some(idx);
122        }
123
124        self.dirty = true;
125        id
126    }
127
128    /// Hide an overlay by ID
129    pub fn hide_overlay(&mut self, id: u64) {
130        let pos = self.overlay_stack.iter().position(|e| e.id == id);
131        if let Some(idx) = pos {
132            let entry = self.overlay_stack.remove(idx);
133
134            // If this overlay had focus, restore to previous focus
135            if self.focused_component == Some(idx) {
136                let restored = self.topmost_visible_overlay();
137                self.focused_component = restored.or(entry.pre_focus);
138            } else if let Some(focused) = self.focused_component {
139                // Adjust focus index if removal shifted it
140                if focused > idx {
141                    self.focused_component = Some(focused - 1);
142                }
143            }
144
145            self.dirty = true;
146        }
147    }
148
149    /// Hide the topmost overlay and restore previous focus
150    pub fn pop_overlay(&mut self) {
151        if let Some(entry) = self.overlay_stack.pop() {
152            if self.focused_component == Some(self.overlay_stack.len()) {
153                let restored = self.topmost_visible_overlay();
154                self.focused_component = restored.or(entry.pre_focus);
155            }
156            self.dirty = true;
157        }
158    }
159
160    /// Check if there are any visible overlays
161    pub fn has_overlays(&self) -> bool {
162        self.overlay_stack.iter().any(|e| !e.hidden)
163    }
164
165    /// Get the topmost visible capturing overlay index
166    fn topmost_visible_overlay(&self) -> Option<usize> {
167        self.overlay_stack
168            .iter()
169            .enumerate()
170            .rev()
171            .find(|(_, e)| !e.hidden && !e.options.non_capturing)
172            .map(|(i, _)| i)
173    }
174
175    // ── Focus management ───────────────────────────────────────────
176
177    /// Set focus to a specific overlay or None for base content
178    pub fn set_focus(&mut self, overlay_idx: Option<usize>) {
179        self.focused_component = overlay_idx;
180    }
181
182    /// Get current focus target
183    pub fn focused_overlay(&self) -> Option<usize> {
184        self.focused_component
185    }
186
187    // ── Input routing ──────────────────────────────────────────────
188
189    /// Route a keyboard event through the overlay input pipeline.
190    /// Returns true if the input was consumed by an overlay.
191    ///
192    /// Should be called BEFORE the application handles the key itself,
193    /// so overlays get first crack at input.
194    pub fn route_input(&mut self, key: &KeyEvent) -> bool {
195        // If an overlay is focused, route input to it first
196        if let Some(idx) = self.focused_component
197            && let Some(entry) = self.overlay_stack.get_mut(idx)
198            && !entry.hidden
199            && entry.component.handle_input(key)
200        {
201            return true;
202        }
203
204        // Route to all visible non-capturing overlays in reverse order
205        for entry in self.overlay_stack.iter_mut().rev() {
206            if !entry.hidden && entry.options.non_capturing && entry.component.handle_input(key) {
207                return true;
208            }
209        }
210
211        false
212    }
213
214    /// Route a paste event to the focused overlay component or root.
215    /// Matches pi's input pipeline where paste is sent to handleInput.
216    pub fn route_paste(&mut self, text: &str) -> bool {
217        if let Some(idx) = self.focused_component
218            && let Some(entry) = self.overlay_stack.get_mut(idx)
219            && !entry.hidden
220        {
221            entry.component.handle_paste(text);
222            return true;
223        }
224        false
225    }
226
227    // ── Rendering ──────────────────────────────────────────────────
228
229    /// Render the root component tree, composite overlays, then diff-render to screen.
230    ///
231    /// Matches pi's TUI.render() which extends Container:
232    /// 1. Render root Container (all permanent children)
233    /// 2. Append chat_buffer (bridge from compose_ui during migration)
234    /// 3. Composite any overlays on top
235    /// 4. Diff-render via Screen
236    pub fn render(
237        &mut self,
238        width: usize,
239        height: usize,
240        writer: &mut dyn Write,
241    ) -> io::Result<()> {
242        self.width = width;
243        self.height = height;
244
245        // 1. Render root container (all sections in correct order).
246        //    Root children: header_section → chat_container → pending_section →
247        //    status_section → queued_section → working_section → editor → footer
248        let mut lines = self.root.render(width);
249
250        // 3. Composite overlays into the rendered lines
251        if !self.overlay_stack.is_empty() {
252            lines = self.composite_overlays(&lines, width, height);
253        }
254
255        // 3. Extract cursor marker and strip it from lines
256        let cursor_pos = self.extract_cursor_position(&mut lines, height);
257
258        // 4. Apply segment reset (normalize terminal output)
259        for line in lines.iter_mut() {
260            *line = normalize_terminal_output(line);
261        }
262
263        // 5. Delegate to Screen for diff rendering
264        self.screen
265            .render(lines.clone(), width as u16, height as u16, writer)?;
266
267        // 6. Position hardware cursor if marker was found
268        if let Some((row, col)) = cursor_pos {
269            self.position_hard_cursor(row, col, writer)?;
270            // Sync Screen's cursor tracking with actual hardware cursor position
271            self.screen.set_hardware_cursor_row(row);
272        }
273
274        self.dirty = false;
275        Ok(())
276    }
277
278    /// Move cursor to clean position on exit — past all content
279    pub fn finalize(&mut self, writer: &mut dyn Write) -> io::Result<()> {
280        self.screen.finalize(writer)
281    }
282
283    // ── Private helpers ────────────────────────────────────────────
284
285    /// Composite all visible overlays into the content lines.
286    ///
287    /// Each overlay is pre-rendered at the width determined by its options.
288    /// Lines are then composited at the calculated row/col position over the
289    /// base content. Overlays with higher focus_order appear on top.
290    fn composite_overlays(
291        &self,
292        base_lines: &[String],
293        term_width: usize,
294        term_height: usize,
295    ) -> Vec<String> {
296        let mut result = base_lines.to_vec();
297
298        // Collect visible overlays sorted by focus order
299        let mut visible: Vec<&OverlayEntry> =
300            self.overlay_stack.iter().filter(|e| !e.hidden).collect();
301        visible.sort_by_key(|e| e.focus_order);
302
303        let mut min_lines_needed = result.len();
304
305        // Pre-render each overlay and calculate layout
306        struct RenderedOverlay {
307            overlay_lines: Vec<String>,
308            layout: OverlayLayout,
309        }
310
311        let rendered: Vec<RenderedOverlay> = visible
312            .iter()
313            .map(|entry| {
314                // Resolve layout with height=0 first to determine width
315                let layout =
316                    self.resolve_overlay_layout(&entry.options, 0, term_width, term_height);
317
318                // Render component at calculated width
319                let mut overlay_lines = entry.component.render(layout.width);
320
321                // Apply max_height
322                let overlay_height = if let Some(max_h) = layout.max_height {
323                    overlay_lines.truncate(max_h);
324                    overlay_lines.len()
325                } else {
326                    overlay_lines.len()
327                };
328
329                // Re-resolve with actual height for proper row/col
330                let layout = self.resolve_overlay_layout(
331                    &entry.options,
332                    overlay_height,
333                    term_width,
334                    term_height,
335                );
336
337                min_lines_needed = min_lines_needed.max(layout.row + overlay_lines.len());
338
339                RenderedOverlay {
340                    overlay_lines,
341                    layout,
342                }
343            })
344            .collect();
345
346        // Ensure result has enough lines
347        let working_height = result.len().max(term_height).max(min_lines_needed);
348        while result.len() < working_height {
349            result.push(String::new());
350        }
351
352        let viewport_start = working_height.saturating_sub(term_height);
353
354        // Composite each overlay
355        for ro in &rendered {
356            for (i, overlay_line) in ro.overlay_lines.iter().enumerate() {
357                let idx = viewport_start + ro.layout.row + i;
358                if idx < result.len() {
359                    let truncated = if visible_width(overlay_line) > ro.layout.width {
360                        slice_by_column(overlay_line, 0, ro.layout.width)
361                    } else {
362                        overlay_line.clone()
363                    };
364                    result[idx] = self.composite_line_at(
365                        &result[idx],
366                        &truncated,
367                        ro.layout.col,
368                        ro.layout.width,
369                        term_width,
370                    );
371                }
372            }
373        }
374
375        result
376    }
377
378    /// Splice overlay content into a base line at a specific column.
379    /// Single-pass optimized — matches pi's compositeLineAt.
380    fn composite_line_at(
381        &self,
382        base_line: &str,
383        overlay_line: &str,
384        start_col: usize,
385        overlay_width: usize,
386        total_width: usize,
387    ) -> String {
388        let after_start = start_col + overlay_width;
389
390        // Extract before and after segments from base line
391        let (before, before_width, after, after_width) = extract_segments(
392            base_line,
393            start_col,
394            after_start,
395            total_width.saturating_sub(after_start),
396            true,
397        );
398
399        // Slice overlay to declared width (strict to exclude wide chars at boundary)
400        let overlay = slice_by_column(overlay_line, 0, overlay_width);
401        let overlay_vis = visible_width(&overlay);
402
403        // Pad segments to target widths
404        let before_pad = start_col.saturating_sub(before_width);
405        let overlay_pad = overlay_width.saturating_sub(overlay_vis);
406        let actual_before_width = before_width.max(start_col);
407        let actual_overlay_width = overlay_vis.max(overlay_width);
408        let after_target = total_width.saturating_sub(actual_before_width + actual_overlay_width);
409        let after_pad = after_target.saturating_sub(after_width);
410
411        // Compose result with segment resets
412        let mut result = String::new();
413        result.push_str(&before);
414        result.push_str(&" ".repeat(before_pad));
415        result.push_str(SEGMENT_RESET);
416        result.push_str(&overlay);
417        result.push_str(&" ".repeat(overlay_pad));
418        result.push_str(SEGMENT_RESET);
419        result.push_str(&after);
420        result.push_str(&" ".repeat(after_pad));
421
422        // Safety truncation
423        let rw = visible_width(&result);
424        if rw > total_width {
425            result = slice_by_column(&result, 0, total_width);
426        }
427
428        result
429    }
430
431    /// Resolve overlay layout from options.
432    fn resolve_overlay_layout(
433        &self,
434        options: &OverlayOptions,
435        overlay_height: usize,
436        term_width: usize,
437        term_height: usize,
438    ) -> OverlayLayout {
439        // Parse margin
440        let margin = options.margin.unwrap_or_default();
441        let margin_top = margin.top;
442        let margin_right = margin.right;
443        let margin_bottom = margin.bottom;
444        let margin_left = margin.left;
445
446        let avail_width = (term_width - margin_left - margin_right).max(1);
447        let avail_height = (term_height - margin_top - margin_bottom).max(1);
448
449        // Resolve width
450        let width = options
451            .width
452            .map(|sv| sv.resolve(term_width))
453            .unwrap_or_else(|| 80.min(avail_width));
454        let width = options.min_width.map(|mw| width.max(mw)).unwrap_or(width);
455        let width = width.max(1).min(avail_width);
456
457        // Resolve max_height
458        let max_height = options.max_height.map(|sv| sv.resolve(term_height));
459        let max_height = max_height.map(|mh| mh.max(1).min(avail_height));
460
461        // Effective overlay height
462        let effective_height = match max_height {
463            Some(mh) => overlay_height.min(mh),
464            None => overlay_height,
465        };
466
467        // Resolve position
468        let row = if let Some(ref row_sv) = options.row {
469            match row_sv {
470                SizeValue::Absolute(r) => *r,
471                SizeValue::Percent(p) => {
472                    let max_row = avail_height - effective_height;
473                    margin_top + ((max_row as f64 * p / 100.0).floor() as usize)
474                }
475            }
476        } else {
477            let anchor = options.anchor.unwrap_or_default();
478            self.resolve_anchor_row(anchor, effective_height, avail_height, margin_top)
479        };
480
481        let col = if let Some(ref col_sv) = options.col {
482            match col_sv {
483                SizeValue::Absolute(c) => *c,
484                SizeValue::Percent(p) => {
485                    let max_col = avail_width - width;
486                    margin_left + ((max_col as f64 * p / 100.0).floor() as usize)
487                }
488            }
489        } else {
490            let anchor = options.anchor.unwrap_or_default();
491            self.resolve_anchor_col(anchor, width, avail_width, margin_left)
492        };
493
494        // Apply offsets
495        let row = (row as isize + options.offset_y.unwrap_or(0)) as usize;
496        let col = (col as isize + options.offset_x.unwrap_or(0)) as usize;
497
498        // Clamp to terminal bounds
499        let row = row
500            .max(margin_top)
501            .min(term_height - margin_bottom - effective_height);
502        let col = col.max(margin_left).min(term_width - margin_right - width);
503
504        OverlayLayout {
505            width,
506            row,
507            col,
508            max_height,
509        }
510    }
511
512    fn resolve_anchor_row(
513        &self,
514        anchor: OverlayAnchor,
515        height: usize,
516        avail_height: usize,
517        margin_top: usize,
518    ) -> usize {
519        match anchor {
520            OverlayAnchor::TopLeft | OverlayAnchor::TopCenter | OverlayAnchor::TopRight => {
521                margin_top
522            }
523            OverlayAnchor::BottomLeft
524            | OverlayAnchor::BottomCenter
525            | OverlayAnchor::BottomRight => margin_top + avail_height - height,
526            OverlayAnchor::LeftCenter | OverlayAnchor::Center | OverlayAnchor::RightCenter => {
527                margin_top + (avail_height - height) / 2
528            }
529        }
530    }
531
532    fn resolve_anchor_col(
533        &self,
534        anchor: OverlayAnchor,
535        width: usize,
536        avail_width: usize,
537        margin_left: usize,
538    ) -> usize {
539        match anchor {
540            OverlayAnchor::TopLeft | OverlayAnchor::LeftCenter | OverlayAnchor::BottomLeft => {
541                margin_left
542            }
543            OverlayAnchor::TopRight | OverlayAnchor::RightCenter | OverlayAnchor::BottomRight => {
544                margin_left + avail_width - width
545            }
546            OverlayAnchor::TopCenter | OverlayAnchor::Center | OverlayAnchor::BottomCenter => {
547                margin_left + (avail_width - width) / 2
548            }
549        }
550    }
551
552    /// Find and extract cursor position from rendered lines.
553    /// Searches for CURSOR_MARKER, calculates its position, and strips it.
554    /// Only scans the bottom `height` lines (visible viewport).
555    fn extract_cursor_position(
556        &self,
557        lines: &mut [String],
558        height: usize,
559    ) -> Option<(usize, usize)> {
560        let viewport_top = lines.len().saturating_sub(height);
561        for row in (viewport_top..lines.len()).rev() {
562            let line = &lines[row];
563            if let Some(marker_idx) = line.find(CURSOR_MARKER) {
564                let col = visible_width(&line[..marker_idx]);
565                // Strip marker
566                let before = &line[..marker_idx];
567                let after = &line[marker_idx + CURSOR_MARKER.len()..];
568                lines[row] = format!("{}{}", before, after);
569                return Some((row, col));
570            }
571        }
572        None
573    }
574
575    /// Position hardware cursor at the given row/col (relative to viewport).
576    fn position_hard_cursor(
577        &self,
578        row: usize,
579        col: usize,
580        writer: &mut dyn Write,
581    ) -> io::Result<()> {
582        // Calculate viewport position
583        let viewport_top = self.screen.prev_viewport_top();
584        if row < viewport_top {
585            return Ok(());
586        }
587        let screen_row = row - viewport_top;
588        if screen_row >= self.height {
589            return Ok(());
590        }
591        let screen_col = col.min(self.width - 1);
592
593        // Move cursor: CSI <row> ; <col> H (1-based)
594        write!(writer, "\x1b[{};{}H", screen_row + 1, screen_col + 1)?;
595        writer.flush()?;
596        Ok(())
597    }
598}
599
600impl Default for TUI {
601    fn default() -> Self {
602        Self::new()
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::tui::overlay::{OverlayMargin, OverlayOptions};
610
611    struct TestComponent {
612        text: String,
613    }
614
615    impl Component for TestComponent {
616        fn render(&self, _width: usize) -> Vec<String> {
617            vec![self.text.clone()]
618        }
619
620        fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
621            false
622        }
623
624        fn invalidate(&mut self) {}
625    }
626
627    #[test]
628    fn test_tui_new() {
629        let tui = TUI::new();
630        assert!(!tui.has_overlays());
631        assert_eq!(tui.full_redraw_count(), 0);
632    }
633
634    #[test]
635    fn test_show_and_hide_overlay() {
636        let mut tui = TUI::new();
637        let id = tui.show_overlay(
638            Box::new(TestComponent {
639                text: "overlay".into(),
640            }),
641            OverlayOptions::default(),
642        );
643        assert!(tui.has_overlays());
644        tui.hide_overlay(id);
645        assert!(!tui.has_overlays());
646    }
647
648    #[test]
649    fn test_pop_overlay() {
650        let mut tui = TUI::new();
651        tui.show_overlay(
652            Box::new(TestComponent { text: "a".into() }),
653            OverlayOptions::default(),
654        );
655        tui.show_overlay(
656            Box::new(TestComponent { text: "b".into() }),
657            OverlayOptions::default(),
658        );
659        assert!(tui.has_overlays());
660        tui.pop_overlay();
661        assert!(tui.has_overlays()); // still has "a"
662        tui.pop_overlay();
663        assert!(!tui.has_overlays());
664    }
665
666    #[test]
667    fn test_cursor_marker_extraction() {
668        let tui = TUI::new();
669        let mut lines = vec![
670            "line 1".to_string(),
671            format!("before{}after", CURSOR_MARKER),
672            "line 3".to_string(),
673        ];
674        let pos = tui.extract_cursor_position(&mut lines, 10);
675        assert!(pos.is_some());
676        let (row, col) = pos.unwrap();
677        assert_eq!(row, 1);
678        assert_eq!(col, 6); // visible_width("before") = 6
679        assert_eq!(lines[1], "beforeafter");
680        assert!(!lines[1].contains(CURSOR_MARKER));
681    }
682
683    #[test]
684    fn test_cursor_marker_outside_viewport() {
685        let tui = TUI::new();
686        // Marker on line 0 but viewport is last 2 lines of 5
687        let mut lines = vec![
688            format!("{}marker", CURSOR_MARKER),
689            "b".to_string(),
690            "c".to_string(),
691            "d".to_string(),
692            "e".to_string(),
693        ];
694        let pos = tui.extract_cursor_position(&mut lines, 2);
695        assert!(pos.is_none()); // line 0 is not in last 2 of 5
696    }
697
698    #[test]
699    fn test_composite_line_at_basic() {
700        let tui = TUI::new();
701        let result = tui.composite_line_at("hello world", "!!", 6, 2, 13);
702        assert_eq!(visible_width(&result), 13);
703        assert!(result.contains("!!"));
704    }
705
706    #[test]
707    fn test_composite_line_at_no_overflow() {
708        let tui = TUI::new();
709        let result = tui.composite_line_at("abcdefghij", "12345", 2, 5, 12);
710        assert_eq!(visible_width(&result), 12);
711    }
712
713    #[test]
714    fn test_overlay_layout_center_default() {
715        let tui = TUI::new();
716        let layout = tui.resolve_overlay_layout(&OverlayOptions::default(), 5, 80, 24);
717        // Default: centered, width=min(80,80)=80, row=center
718        assert_eq!(layout.width, 80);
719        // avail_height = 24, height=5 → center = (24-5)/2 = 9
720        assert_eq!(layout.row, 9);
721        assert_eq!(layout.col, 0);
722        assert!(layout.max_height.is_none());
723    }
724
725    #[test]
726    fn test_overlay_layout_percent_width() {
727        let tui = TUI::new();
728        let opts = OverlayOptions {
729            width: Some(SizeValue::Percent(50.0)),
730            ..Default::default()
731        };
732        let layout = tui.resolve_overlay_layout(&opts, 5, 80, 24);
733        assert_eq!(layout.width, 40); // 50% of 80
734    }
735
736    #[test]
737    fn test_overlay_layout_margin() {
738        let tui = TUI::new();
739        let opts = OverlayOptions {
740            margin: Some(OverlayMargin {
741                top: 2,
742                right: 2,
743                bottom: 2,
744                left: 2,
745            }),
746            anchor: Some(OverlayAnchor::TopLeft),
747            ..Default::default()
748        };
749        let layout = tui.resolve_overlay_layout(&opts, 5, 80, 24);
750        assert_eq!(layout.row, 2);
751        assert_eq!(layout.col, 2);
752    }
753}