Skip to main content

photon_ui/
tui.rs

1use std::io;
2
3use crate::{
4    Component,
5    layout::layout::Layout,
6    renderer::{
7        RenderStrategy,
8        Rendered,
9        Renderer,
10    },
11    terminal::Terminal,
12};
13
14/// Anchor point for positioning an overlay on the terminal screen.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum Anchor {
17    Center,
18    TopLeft,
19    TopRight,
20    BottomLeft,
21    BottomRight,
22    TopCenter,
23    BottomCenter,
24    LeftCenter,
25    RightCenter,
26}
27
28/// How an overlay's position is expressed.
29#[derive(Debug, Clone, PartialEq)]
30pub enum OverlayPosition {
31    /// Position relative to an anchor point.
32    Anchor(Anchor),
33    /// Absolute coordinates `(row, col)`.
34    At(u16, u16),
35    /// Percentage coordinates as strings, e.g. `"50%"`.
36    Percent(String, String),
37}
38
39/// Constraints applied when computing an overlay's final position.
40#[derive(Debug, Clone)]
41pub struct OverlayConstraints {
42    /// Minimum width in columns.
43    pub min_width: u16,
44    /// Maximum height in rows.
45    pub max_height: u16,
46    /// Margin from screen edges when using an anchor.
47    pub margin: u16,
48    /// Horizontal offset applied after computing the anchor position.
49    pub offset_x: i16,
50    /// Vertical offset applied after computing the anchor position.
51    pub offset_y: i16,
52    /// Optional visibility predicate: `(cols, rows) -> bool`.
53    pub visible: Option<fn(u16, u16) -> bool>,
54}
55
56pub use crate::layout::Rect;
57
58/// A floating component rendered on top of the main UI.
59pub struct Overlay {
60    /// The component to render.
61    pub content: Box<dyn Component>,
62    /// How the overlay's position is determined.
63    pub position: OverlayPosition,
64    /// Sizing and visibility constraints.
65    pub constraints: OverlayConstraints,
66}
67
68impl Overlay {
69    /// Compute the screen rectangle for this overlay given the terminal size
70    /// and the content's natural dimensions.
71    ///
72    /// Returns `None` if the overlay's visibility predicate returns `false`.
73    pub fn compute_position(
74        &self,
75        term_w: u16,
76        term_h: u16,
77        content_w: u16,
78        content_h: u16,
79    ) -> Option<Rect> {
80        let w = content_w.max(self.constraints.min_width);
81        let h = content_h.min(self.constraints.max_height).max(1);
82
83        if let Some(vis) = self.constraints.visible {
84            if !vis(term_w, term_h) {
85                return None;
86            }
87        }
88
89        let (row, col) = match &self.position {
90            | OverlayPosition::Anchor(anchor) => {
91                let r = match anchor {
92                    | Anchor::Center | Anchor::LeftCenter | Anchor::RightCenter => {
93                        (term_h.saturating_sub(h)) / 2
94                    },
95                    | Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
96                        self.constraints.margin
97                    },
98                    | Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
99                        term_h.saturating_sub(h + self.constraints.margin)
100                    },
101                };
102                let c = match anchor {
103                    | Anchor::Center | Anchor::TopCenter | Anchor::BottomCenter => {
104                        (term_w.saturating_sub(w)) / 2
105                    },
106                    | Anchor::TopLeft | Anchor::BottomLeft | Anchor::LeftCenter => {
107                        self.constraints.margin
108                    },
109                    | Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter => {
110                        term_w.saturating_sub(w + self.constraints.margin)
111                    },
112                };
113                (r, c)
114            },
115            | OverlayPosition::At(r, c) => (*r, *c),
116            | OverlayPosition::Percent(px, py) => {
117                let parse_pct = |s: &str| -> u16 {
118                    s.trim_end_matches('%').parse::<f64>().unwrap_or(0.0) as u16
119                };
120                let pct_x = parse_pct(px);
121                let pct_y = parse_pct(py);
122                let r = (term_h as f64 * pct_y as f64 / 100.0) as u16;
123                let c = (term_w as f64 * pct_x as f64 / 100.0) as u16;
124                (r, c)
125            },
126        };
127
128        Some(Rect {
129            y: (row as i16 + self.constraints.offset_y).max(0) as u16,
130            x: (col as i16 + self.constraints.offset_x).max(0) as u16,
131            width: w.min(term_w.saturating_sub(col)),
132            height: h.min(term_h.saturating_sub(row)),
133        })
134    }
135}
136
137/// Top-level TUI manager.
138///
139/// Owns the terminal, a list of mounted components, overlays, and a
140/// [`Renderer`] that performs differential drawing. Only one component
141/// receives focus at a time; it is the sole recipient of input events.
142///
143/// # Example
144///
145/// ```no_run
146/// use photon_ui::{
147///     TUI,
148///     TestTerminal,
149///     components::Text,
150/// };
151///
152/// let mut tui = TUI::new(Box::new(TestTerminal::new(80, 24)));
153/// tui.mount(Box::new(Text::new("Hello", 0, 0)));
154/// tui.render_frame().unwrap();
155/// ```
156pub struct TUI {
157    terminal: Box<dyn Terminal>,
158    children: Vec<Box<dyn Component>>,
159    overlays: Vec<Overlay>,
160    modal: Option<Box<dyn Component>>,
161    focused_index: Option<usize>,
162    pre_modal_focus: Option<usize>,
163    renderer: Renderer,
164    size: (u16, u16),
165    previous_image_ids: std::collections::HashSet<u32>,
166    hardware_cursor: bool,
167    layout: Option<Layout>,
168}
169
170impl TUI {
171    /// Create a new TUI backed by the given terminal.
172    pub fn new(terminal: Box<dyn Terminal>) -> Self {
173        Self {
174            terminal,
175            children: Vec::new(),
176            overlays: Vec::new(),
177            modal: None,
178            focused_index: None,
179            pre_modal_focus: None,
180            renderer: Renderer::new(),
181            size: (80, 24),
182            previous_image_ids: std::collections::HashSet::new(),
183            hardware_cursor: std::env::var("PHOTON_UI_HARDWARE_CURSOR").is_ok(),
184            layout: None,
185        }
186    }
187
188    /// Borrow the underlying terminal.
189    pub fn terminal(&self) -> &dyn Terminal {
190        &*self.terminal
191    }
192
193    /// Add a component to the TUI.
194    ///
195    /// The component is appended to the children list. If no component
196    /// currently has focus, the new component receives focus automatically.
197    pub fn mount(&mut self, component: Box<dyn Component>) {
198        let idx = self.children.len();
199        self.children.push(component);
200        if self.focused_index.is_none() {
201            self.set_focus(idx);
202        }
203    }
204
205    /// Move focus to the component at `index`.
206    ///
207    /// The previously focused component, if any, is unfocused first.
208    pub fn set_focus(&mut self, index: usize) {
209        if let Some(old) = self.focused_index {
210            if old < self.children.len() {
211                if let Some(f) = self.children[old].as_focusable_mut() {
212                    f.set_focused(false);
213                }
214            }
215        }
216        self.focused_index = Some(index);
217        if index < self.children.len() {
218            if let Some(f) = self.children[index].as_focusable_mut() {
219                f.set_focused(true);
220            }
221        }
222    }
223
224    /// Remove all children and reset focus.
225    pub fn clear_children(&mut self) {
226        self.children.clear();
227        self.focused_index = None;
228    }
229
230    /// Add an overlay on top of the main UI.
231    pub fn add_overlay(&mut self, overlay: Overlay) {
232        self.overlays.push(overlay);
233    }
234
235    /// Remove all overlays.
236    pub fn clear_overlays(&mut self) {
237        self.overlays.clear();
238    }
239
240    /// Show a modal dialog on top of the main UI.
241    ///
242    /// The modal captures all input until it is dismissed. Focus is moved to
243    /// the modal content automatically. When dismissed, focus returns to the
244    /// previously focused component.
245    pub fn show_modal(&mut self, modal: Box<dyn Component>) {
246        self.pre_modal_focus = self.focused_index;
247        self.modal = Some(modal);
248        if let Some(ref mut m) = self.modal {
249            if let Some(f) = m.as_focusable_mut() {
250                f.set_focused(true);
251            }
252        }
253    }
254
255    /// Dismiss the currently open modal, restoring previous focus.
256    pub fn dismiss_modal(&mut self) {
257        if let Some(ref mut m) = self.modal {
258            if let Some(f) = m.as_focusable_mut() {
259                f.set_focused(false);
260            }
261        }
262        self.modal = None;
263        if let Some(idx) = self.pre_modal_focus {
264            if idx < self.children.len() {
265                self.set_focus(idx);
266            }
267        }
268        self.pre_modal_focus = None;
269    }
270
271    /// Returns `true` if a modal is currently open.
272    pub fn modal_active(&self) -> bool {
273        self.modal.is_some()
274    }
275
276    /// Set a layout for splitting the terminal area among children.
277    pub fn set_layout(&mut self, layout: Layout) {
278        self.layout = Some(layout);
279    }
280
281    /// Clear the layout, reverting to vertical stacking.
282    pub fn clear_layout(&mut self) {
283        self.layout = None;
284    }
285
286    /// Reset the TUI for a fresh page / screen.
287    ///
288    /// Clears all children, overlays, and layout, and schedules a full
289    /// screen redraw so no stale content or ANSI attributes bleed through.
290    pub fn reset(&mut self) {
291        self.children.clear();
292        self.focused_index = None;
293        self.pre_modal_focus = None;
294        self.overlays.clear();
295        self.modal = None;
296        self.layout = None;
297        self.renderer
298            .set_strategy(crate::renderer::RenderStrategy::FullRedraw);
299    }
300
301    /// Restore the terminal (leave alternate screen, disable raw mode, show
302    /// cursor).
303    pub fn stop(&mut self) -> io::Result<()> {
304        self.terminal.stop()
305    }
306
307    /// Render one frame to the terminal.
308    ///
309    /// 1. Queries terminal size.
310    /// 2. Decides [`RenderStrategy`] (first render, full redraw on resize, or
311    ///    diff).
312    /// 3. Renders all children and overlays into a composite screen buffer.
313    /// 4. Deletes stale terminal images.
314    /// 5. Writes the result through the [`Renderer`].
315    /// 6. Positions the hardware cursor.
316    pub fn render_frame(&mut self) -> io::Result<()> {
317        let (width, height) = self.terminal.size()?;
318        let size_changed = self.size != (width, height);
319        self.size = (width, height);
320
321        if self.renderer.previous().is_none() {
322            self.renderer.set_strategy(RenderStrategy::FirstRender);
323        } else if size_changed {
324            self.renderer.set_strategy(RenderStrategy::FullRedraw);
325        } else {
326            self.renderer.set_strategy(RenderStrategy::Diff);
327        }
328
329        // Render children using layout if set, otherwise stack vertically.
330        let mut screen = Rendered::empty();
331        let term_rect = Rect::new(0, 0, width, height);
332
333        if let Some(layout) = &self.layout {
334            let areas = layout.split(term_rect);
335            for (child, area) in self.children.iter().zip(areas.iter()) {
336                if let Ok(rendered) = child.render_rect(*area) {
337                    rendered.blit_into_rect(&mut screen, *area);
338                }
339            }
340        } else {
341            // Original vertical stacking behavior
342            let mut row = 0usize;
343            for child in &self.children {
344                if let Ok(rendered) = child.render(width) {
345                    for line in &rendered.lines {
346                        screen.lines.push(line.clone());
347                    }
348                    if let Some((r, c)) = rendered.cursor {
349                        screen.cursor = Some((row + r, c));
350                    }
351                    screen.images.extend(rendered.images);
352                    row += rendered.lines.len();
353                }
354            }
355        }
356
357        // Pad to terminal height so overlays can be placed at absolute rows.
358        if !self.overlays.is_empty() {
359            while screen.lines.len() < height as usize {
360                screen.lines.push("".to_string());
361            }
362        }
363
364        for overlay in &self.overlays {
365            if let Ok(rendered) = overlay.content.render(width) {
366                if let Some(rect) =
367                    overlay.compute_position(width, height, rendered.lines.len() as u16, 1)
368                {
369                    rendered.blit_onto(&mut screen, rect.y, rect.x);
370                }
371            }
372        }
373
374        // Render modal centered on top of everything.
375        if let Some(ref modal) = self.modal {
376            if let Ok(rendered) = modal.render(width) {
377                let modal_h = rendered.lines.len() as u16;
378                let modal_w =
379                    crate::utils::visible_width(rendered.lines.first().unwrap_or(&String::new()))
380                        as u16;
381                let row = (height.saturating_sub(modal_h)) / 2;
382                let col = (width.saturating_sub(modal_w)) / 2;
383                rendered.blit_onto(&mut screen, row, col);
384            }
385        }
386
387        let current_ids: std::collections::HashSet<u32> =
388            screen.images.iter().map(|i| i.id).collect();
389        for id in &self.previous_image_ids {
390            if !current_ids.contains(id) {
391                self.terminal
392                    .write(&format!("\x1b_Ga=d,d=I,i={}\x1b\\", id))?;
393            }
394        }
395        self.previous_image_ids = current_ids;
396
397        self.renderer.render(&mut *self.terminal, &screen)?;
398
399        if let Some((row, col)) = screen.cursor {
400            self.terminal.move_cursor(row as u16, col as u16)?;
401            if self.hardware_cursor {
402                self.terminal.show_cursor()?;
403            } else {
404                self.terminal.hide_cursor()?;
405            }
406        }
407
408        Ok(())
409    }
410
411    /// Compute the composite screen buffer without writing to the terminal.
412    /// Test-only helper to inspect layout.
413    #[cfg(test)]
414    fn compose_screen(&self, width: u16, height: u16) -> crate::renderer::Rendered {
415        let mut screen = crate::renderer::Rendered::empty();
416        let term_rect = Rect::new(0, 0, width, height);
417
418        if let Some(layout) = &self.layout {
419            let areas = layout.split(term_rect);
420            for (child, area) in self.children.iter().zip(areas.iter()) {
421                if let Ok(rendered) = child.render_rect(*area) {
422                    rendered.blit_into_rect(&mut screen, *area);
423                }
424            }
425        } else {
426            let mut row = 0usize;
427            for child in &self.children {
428                if let Ok(rendered) = child.render(width) {
429                    for line in &rendered.lines {
430                        screen.lines.push(line.clone());
431                    }
432                    if let Some((r, c)) = rendered.cursor {
433                        screen.cursor = Some((row + r, c));
434                    }
435                    row += rendered.lines.len();
436                }
437            }
438        }
439        screen
440    }
441
442    /// Dispatch an event to the focused component, falling back to other
443    /// children if the focused one returns [`InputResult::Ignored`].
444    ///
445    /// Also handles `Tab` to cycle focus between focusable children.
446    pub fn handle_input(&mut self, event: &crate::events::Event) {
447        // Modal capture: when a modal is open, Esc dismisses it and all other
448        // input is routed to the modal content.
449        if let Some(ref mut modal) = self.modal {
450            if let crate::events::Event::Key(key) = event {
451                if key.code == crossterm::event::KeyCode::Esc {
452                    self.dismiss_modal();
453                    return;
454                }
455            }
456            modal.handle_input(event);
457            return;
458        }
459
460        // Handle Tab to cycle focus. Try the focused child first so nested
461        // containers (e.g. Div) can manage their own focus cycling.
462        if let crate::events::Event::Key(key) = event {
463            if key.code == crossterm::event::KeyCode::Tab {
464                if let Some(idx) = self.focused_index {
465                    if idx < self.children.len() {
466                        let result = self.children[idx].handle_input(event);
467                        if !matches!(result, crate::InputResult::Ignored) {
468                            return;
469                        }
470                    }
471                }
472                self.cycle_focus(1);
473                return;
474            }
475            if key.code == crossterm::event::KeyCode::BackTab {
476                if let Some(idx) = self.focused_index {
477                    if idx < self.children.len() {
478                        let result = self.children[idx].handle_input(event);
479                        if !matches!(result, crate::InputResult::Ignored) {
480                            return;
481                        }
482                    }
483                }
484                self.cycle_focus(-1);
485                return;
486            }
487        }
488
489        // Try focused child first
490        if let Some(idx) = self.focused_index {
491            if idx < self.children.len() {
492                let result = self.children[idx].handle_input(event);
493                if !matches!(result, crate::InputResult::Ignored) {
494                    return;
495                }
496            }
497        }
498
499        // Fall through to other children
500        for (i, child) in self.children.iter_mut().enumerate() {
501            if Some(i) == self.focused_index {
502                continue;
503            }
504            let result = child.handle_input(event);
505            if !matches!(result, crate::InputResult::Ignored) {
506                return;
507            }
508        }
509    }
510
511    /// Move focus to the next (or previous) focusable component.
512    fn cycle_focus(&mut self, delta: isize) {
513        let focusable: Vec<usize> = self
514            .children
515            .iter()
516            .enumerate()
517            .filter(|(_, c)| c.as_focusable().is_some())
518            .map(|(i, _)| i)
519            .collect();
520        if focusable.is_empty() {
521            return;
522        }
523
524        let current = match self
525            .focused_index
526            .and_then(|idx| focusable.iter().position(|&i| i == idx))
527        {
528            | Some(pos) => pos,
529            | None => {
530                self.set_focus(focusable[0]);
531                return;
532            },
533        };
534
535        let new_pos = if delta >= 0 {
536            (current + delta as usize) % focusable.len()
537        } else {
538            let d = (-delta) as usize % focusable.len();
539            (current + focusable.len() - d) % focusable.len()
540        };
541        self.set_focus(focusable[new_pos]);
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::{
549        TestTerminal,
550        components::Text,
551    };
552
553    #[test]
554    fn tui_set_focus_invalid_index() {
555        let term = TestTerminal::new(80, 24);
556        let mut tui = TUI::new(Box::new(term));
557        tui.mount(Box::new(Text::new("a", 0, 0)));
558        tui.set_focus(5); // should not panic
559    }
560
561    #[test]
562    fn tui_handle_input_no_focus() {
563        let term = TestTerminal::new(80, 24);
564        let mut tui = TUI::new(Box::new(term));
565        tui.handle_input(&crate::events::Event::Resize(10, 10)); // should not panic
566    }
567
568    #[test]
569    fn tui_render_with_overlay() {
570        let term = TestTerminal::new(80, 24);
571        let mut tui = TUI::new(Box::new(term));
572        tui.mount(Box::new(Text::new("hello", 0, 0)));
573        let overlay = Overlay {
574            content: Box::new(Text::new("popup", 0, 0)),
575            position: OverlayPosition::Anchor(Anchor::Center),
576            constraints: OverlayConstraints {
577                min_width: 5,
578                max_height: 3,
579                margin: 1,
580                offset_x: 0,
581                offset_y: 0,
582                visible: None,
583            },
584        };
585        tui.overlays.push(overlay);
586        tui.render_frame().unwrap();
587    }
588
589    #[test]
590    fn tui_full_redraw_on_resize() {
591        let term = TestTerminal::new(80, 24);
592        let mut tui = TUI::new(Box::new(term));
593        tui.mount(Box::new(Text::new("hello", 0, 0)));
594        tui.render_frame().unwrap();
595        // Simulate resize by changing terminal size
596        let new_term = TestTerminal::new(100, 30);
597        tui.terminal = Box::new(new_term);
598        tui.render_frame().unwrap();
599    }
600
601    struct ImageComponent;
602    impl Component for ImageComponent {
603        fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
604            Ok(Rendered {
605                lines: vec!["img".into()],
606                cursor: None,
607                images: vec![crate::renderer::ImageCommand {
608                    id: 1,
609                    data: "data".into(),
610                }],
611            })
612        }
613    }
614
615    #[test]
616    fn tui_image_cleanup() {
617        let term = TestTerminal::new(80, 24);
618        let mut tui = TUI::new(Box::new(term));
619        tui.mount(Box::new(ImageComponent));
620        tui.render_frame().unwrap();
621        // Now replace with text component (no images)
622        tui.children.clear();
623        tui.children.push(Box::new(Text::new("text", 0, 0)));
624        tui.render_frame().unwrap();
625        // Just verify no panic
626    }
627
628    #[test]
629    fn tui_hardware_cursor() {
630        unsafe {
631            std::env::set_var("PHOTON_UI_HARDWARE_CURSOR", "1");
632        }
633        let term = TestTerminal::new(80, 24);
634        let mut tui = TUI::new(Box::new(term));
635        tui.mount(Box::new(Text::new("hello", 0, 0)));
636        tui.render_frame().unwrap();
637        unsafe {
638            std::env::remove_var("PHOTON_UI_HARDWARE_CURSOR");
639        }
640    }
641
642    #[test]
643    fn tui_tab_cycles_focus() {
644        let term = TestTerminal::new(80, 24);
645        let mut tui = TUI::new(Box::new(term));
646        tui.mount(Box::new(Text::new("a", 0, 0))); // not focusable
647        let list = crate::components::SelectList::new(vec!["x".into()], 1);
648        tui.mount(Box::new(list));
649        let input = crate::components::Input::new();
650        tui.mount(Box::new(input));
651
652        // First mounted component gets focus (Text at index 0)
653        assert_eq!(tui.focused_index, Some(0));
654
655        // Tab moves to first focusable (SelectList at index 1)
656        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
657            crossterm::event::KeyCode::Tab,
658            crossterm::event::KeyModifiers::empty(),
659        )));
660        assert_eq!(tui.focused_index, Some(1));
661
662        // Tab moves to next focusable (Input at index 2)
663        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
664            crossterm::event::KeyCode::Tab,
665            crossterm::event::KeyModifiers::empty(),
666        )));
667        assert_eq!(tui.focused_index, Some(2));
668
669        // Tab wraps back to first focusable
670        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
671            crossterm::event::KeyCode::Tab,
672            crossterm::event::KeyModifiers::empty(),
673        )));
674        assert_eq!(tui.focused_index, Some(1));
675    }
676
677    #[test]
678    fn tui_backtab_cycles_backward() {
679        let term = TestTerminal::new(80, 24);
680        let mut tui = TUI::new(Box::new(term));
681        let list = crate::components::SelectList::new(vec!["x".into()], 1);
682        tui.mount(Box::new(list));
683        let input = crate::components::Input::new();
684        tui.mount(Box::new(input));
685
686        // Start on SelectList (index 0)
687        assert_eq!(tui.focused_index, Some(0));
688
689        // BackTab moves to previous focusable (wraps to Input)
690        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
691            crossterm::event::KeyCode::BackTab,
692            crossterm::event::KeyModifiers::empty(),
693        )));
694        assert_eq!(tui.focused_index, Some(1));
695    }
696
697    #[test]
698    fn tui_cycle_focus_single_focusable() {
699        let term = TestTerminal::new(80, 24);
700        let mut tui = TUI::new(Box::new(term));
701        let list = crate::components::SelectList::new(vec!["x".into()], 1);
702        tui.mount(Box::new(list));
703
704        // Tab with only one focusable stays on it
705        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
706            crossterm::event::KeyCode::Tab,
707            crossterm::event::KeyModifiers::empty(),
708        )));
709        assert_eq!(tui.focused_index, Some(0));
710    }
711
712    #[test]
713    fn tui_no_focusables_no_panic() {
714        let term = TestTerminal::new(80, 24);
715        let mut tui = TUI::new(Box::new(term));
716        tui.mount(Box::new(Text::new("hello", 0, 0))); // not focusable
717        // Tab with no focusables should not panic
718        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
719            crossterm::event::KeyCode::Tab,
720            crossterm::event::KeyModifiers::empty(),
721        )));
722    }
723
724    #[test]
725    fn tui_terminal_borrow() {
726        let term = TestTerminal::new(80, 24);
727        let tui = TUI::new(Box::new(term));
728        let _ = tui.terminal();
729    }
730
731    #[test]
732    fn tui_handle_input_fallthrough() {
733        let term = TestTerminal::new(80, 24);
734        let mut tui = TUI::new(Box::new(term));
735        // Add two text components (not focusable)
736        tui.mount(Box::new(Text::new("a", 0, 0)));
737        tui.mount(Box::new(Text::new("b", 0, 0)));
738        // A non-Tab key should fall through without panic
739        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
740            crossterm::event::KeyCode::Char('x'),
741            crossterm::event::KeyModifiers::empty(),
742        )));
743    }
744
745    #[test]
746    fn overlay_compute_position_all_anchors() {
747        let constraints = OverlayConstraints {
748            min_width: 5,
749            max_height: 3,
750            margin: 1,
751            offset_x: 0,
752            offset_y: 0,
753            visible: None,
754        };
755        let anchors = vec![
756            Anchor::Center,
757            Anchor::TopLeft,
758            Anchor::TopRight,
759            Anchor::BottomLeft,
760            Anchor::BottomRight,
761            Anchor::TopCenter,
762            Anchor::BottomCenter,
763            Anchor::LeftCenter,
764            Anchor::RightCenter,
765        ];
766        for anchor in anchors {
767            let overlay = Overlay {
768                content: Box::new(Text::new("test", 0, 0)),
769                position: OverlayPosition::Anchor(anchor),
770                constraints: constraints.clone(),
771            };
772            let rect = overlay.compute_position(80, 24, 10, 2);
773            assert!(rect.is_some(), "anchor {:?} should produce a rect", anchor);
774        }
775    }
776
777    #[test]
778    fn overlay_compute_position_at() {
779        let overlay = Overlay {
780            content: Box::new(Text::new("test", 0, 0)),
781            position: OverlayPosition::At(5, 10),
782            constraints: OverlayConstraints {
783                min_width: 5,
784                max_height: 3,
785                margin: 0,
786                offset_x: 0,
787                offset_y: 0,
788                visible: None,
789            },
790        };
791        let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
792        assert_eq!(rect.y, 5);
793        assert_eq!(rect.x, 10);
794    }
795
796    #[test]
797    fn overlay_compute_position_percent() {
798        let overlay = Overlay {
799            content: Box::new(Text::new("test", 0, 0)),
800            position: OverlayPosition::Percent("50%".into(), "25%".into()),
801            constraints: OverlayConstraints {
802                min_width: 5,
803                max_height: 3,
804                margin: 0,
805                offset_x: 0,
806                offset_y: 0,
807                visible: None,
808            },
809        };
810        let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
811        assert_eq!(rect.y, 10);
812        assert_eq!(rect.x, 50);
813    }
814
815    #[test]
816    fn overlay_compute_position_percent_invalid() {
817        let overlay = Overlay {
818            content: Box::new(Text::new("test", 0, 0)),
819            position: OverlayPosition::Percent("abc".into(), "xyz".into()),
820            constraints: OverlayConstraints {
821                min_width: 5,
822                max_height: 3,
823                margin: 0,
824                offset_x: 0,
825                offset_y: 0,
826                visible: None,
827            },
828        };
829        let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
830        assert_eq!(rect.y, 0);
831        assert_eq!(rect.x, 0);
832    }
833
834    #[test]
835    fn overlay_compute_position_visible_false() {
836        let overlay = Overlay {
837            content: Box::new(Text::new("test", 0, 0)),
838            position: OverlayPosition::Anchor(Anchor::Center),
839            constraints: OverlayConstraints {
840                min_width: 5,
841                max_height: 3,
842                margin: 0,
843                offset_x: 0,
844                offset_y: 0,
845                visible: Some(|_w, _h| false),
846            },
847        };
848        assert!(overlay.compute_position(80, 24, 10, 2).is_none());
849    }
850
851    #[test]
852    fn overlay_compute_position_with_offset() {
853        let overlay = Overlay {
854            content: Box::new(Text::new("test", 0, 0)),
855            position: OverlayPosition::At(10, 10),
856            constraints: OverlayConstraints {
857                min_width: 5,
858                max_height: 3,
859                margin: 0,
860                offset_x: 5,
861                offset_y: -3,
862                visible: None,
863            },
864        };
865        let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
866        assert_eq!(rect.y, 7);
867        assert_eq!(rect.x, 15);
868    }
869
870    #[test]
871    fn overlay_compute_position_negative_offset_clamped() {
872        let overlay = Overlay {
873            content: Box::new(Text::new("test", 0, 0)),
874            position: OverlayPosition::At(0, 0),
875            constraints: OverlayConstraints {
876                min_width: 5,
877                max_height: 3,
878                margin: 0,
879                offset_x: -5,
880                offset_y: -5,
881                visible: None,
882            },
883        };
884        let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
885        assert_eq!(rect.y, 0);
886        assert_eq!(rect.x, 0);
887    }
888
889    #[test]
890    fn overlay_compute_position_size_clamped() {
891        let overlay = Overlay {
892            content: Box::new(Text::new("test", 0, 0)),
893            position: OverlayPosition::At(70, 20),
894            constraints: OverlayConstraints {
895                min_width: 5,
896                max_height: 3,
897                margin: 0,
898                offset_x: 0,
899                offset_y: 0,
900                visible: None,
901            },
902        };
903        let rect = overlay.compute_position(80, 24, 20, 10).unwrap();
904        // width should be min(term_w - col, w) = min(80-20, 20) = 20
905        assert_eq!(rect.width, 20);
906        // height: h = 10.min(3).max(1) = 3, then min(3, 24.saturating_sub(70)) = min(3,
907        // 0) = 0
908        assert_eq!(rect.height, 0);
909    }
910
911    struct CursorComponent;
912    impl Component for CursorComponent {
913        fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
914            Ok(Rendered {
915                lines: vec!["cursor".into()],
916                cursor: Some((0, 3)),
917                images: vec![],
918            })
919        }
920    }
921
922    #[test]
923    fn tui_render_frame_with_cursor() {
924        let term = TestTerminal::new(80, 24);
925        let mut tui = TUI::new(Box::new(term));
926        tui.mount(Box::new(CursorComponent));
927        tui.render_frame().unwrap();
928    }
929
930    #[test]
931    fn tui_demo_layout_exact() {
932        let term = TestTerminal::new(80, 24);
933        let mut tui = TUI::new(Box::new(term));
934
935        tui.mount(Box::new(Text::new("Photon UI Demo", 2, 1)));
936        tui.mount(Box::new(Text::new(
937            "j/k = navigate list   Tab = switch focus   i = insert mode   Esc = normal mode   q = quit",
938            2, 0,
939        )));
940        let list = crate::components::SelectList::new(
941            vec![
942                "Option 1: Hello world".into(),
943                "Option 2: Foo bar baz".into(),
944                "Option 3: Lorem ipsum".into(),
945                "Option 4: Vim bindings".into(),
946                "Option 5: Blazing fast".into(),
947            ],
948            3,
949        );
950        tui.mount(Box::new(list));
951        let input = crate::components::Input::new();
952        tui.mount(Box::new(input));
953        tui.set_focus(2);
954
955        let screen = tui.compose_screen(80, 24);
956
957        // Expected layout (8 content lines):
958        // 0: blank (Text1 pad_y)
959        // 1: Photon UI Demo
960        // 2: blank (Text1 pad_y)
961        // 3: keybindings text
962        // 4: first list item (selected)
963        // 5: second list item
964        // 6: third list item
965        // 7: input line
966        assert_eq!(
967            screen.lines.len(),
968            8,
969            "expected 8 content lines, got {}",
970            screen.lines.len()
971        );
972        assert_eq!(
973            screen.lines[0].trim_end(),
974            "",
975            "row 0 should be blank from Text1 pad_y"
976        );
977        assert!(
978            screen.lines[1].contains("Photon UI Demo"),
979            "row 1 should contain header: got {:?}",
980            screen.lines[1]
981        );
982        assert_eq!(
983            screen.lines[2].trim_end(),
984            "",
985            "row 2 should be blank from Text1 pad_y"
986        );
987        assert!(
988            screen.lines[3].contains("j/k = navigate"),
989            "row 3 should contain keybindings: got {:?}",
990            screen.lines[3]
991        );
992        assert!(
993            screen.lines[4].contains("> Option 1"),
994            "row 4 should be selected list item: got {:?}",
995            screen.lines[4]
996        );
997        assert!(
998            screen.lines[5].contains("  Option 2"),
999            "row 5 should be unselected list item: got {:?}",
1000            screen.lines[5]
1001        );
1002        assert!(
1003            screen.lines[6].contains("  Option 3"),
1004            "row 6 should be unselected list item: got {:?}",
1005            screen.lines[6]
1006        );
1007        assert_eq!(
1008            screen.lines[7].trim_end(),
1009            "",
1010            "row 7 should be empty input line"
1011        );
1012    }
1013
1014    /// Regression: reset() must clear children, overlays, layout, focus,
1015    /// and schedule a FullRedraw so stale content doesn't bleed through.
1016    #[test]
1017    fn tui_reset_clears_all_and_schedules_redraw() {
1018        let term = TestTerminal::new(80, 24);
1019        let mut tui = TUI::new(Box::new(term));
1020
1021        tui.mount(Box::new(crate::components::Text::new("hello", 0, 0)));
1022        tui.set_focus(0);
1023        tui.add_overlay(Overlay {
1024            content: Box::new(crate::components::Text::new("popup", 0, 0)),
1025            position: OverlayPosition::Anchor(Anchor::Center),
1026            constraints: OverlayConstraints {
1027                min_width: 10,
1028                max_height: 3,
1029                margin: 2,
1030                offset_x: 0,
1031                offset_y: 0,
1032                visible: None,
1033            },
1034        });
1035        tui.set_layout(crate::layout::layout::Layout::vertical([
1036            crate::layout::Constraint::Length(1),
1037        ]));
1038        tui.render_frame().unwrap();
1039
1040        // Verify preconditions: screen has content
1041        let screen_before = tui.compose_screen(80, 24);
1042        assert!(
1043            !screen_before.lines.is_empty(),
1044            "precondition: screen should have content"
1045        );
1046
1047        tui.reset();
1048
1049        // After reset, compose_screen should be empty
1050        let screen = tui.compose_screen(80, 24);
1051        assert!(screen.lines.is_empty(), "reset should clear all children");
1052
1053        // render_frame should not panic after reset (FullRedraw is scheduled
1054        // internally)
1055        tui.render_frame().unwrap();
1056    }
1057
1058    #[test]
1059    fn tui_show_modal_captures_input() {
1060        let term = TestTerminal::new(80, 24);
1061        let mut tui = TUI::new(Box::new(term));
1062        tui.mount(Box::new(Text::new("background", 0, 0)));
1063        tui.set_focus(0);
1064
1065        let modal_content = Text::new("modal text", 0, 0);
1066        tui.show_modal(Box::new(modal_content));
1067        assert!(tui.modal_active());
1068
1069        // Esc should dismiss the modal
1070        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
1071            crossterm::event::KeyCode::Esc,
1072            crossterm::event::KeyModifiers::empty(),
1073        )));
1074        assert!(!tui.modal_active());
1075    }
1076
1077    #[test]
1078    fn tui_modal_restores_focus_on_dismiss() {
1079        let term = TestTerminal::new(80, 24);
1080        let mut tui = TUI::new(Box::new(term));
1081        let list = crate::components::SelectList::new(vec!["x".into()], 1);
1082        tui.mount(Box::new(list));
1083        assert_eq!(tui.focused_index, Some(0));
1084
1085        tui.show_modal(Box::new(Text::new("modal", 0, 0)));
1086        tui.dismiss_modal();
1087        assert_eq!(tui.focused_index, Some(0));
1088    }
1089
1090    #[test]
1091    fn tui_modal_renders_without_panic() {
1092        let term = TestTerminal::new(80, 24);
1093        let mut tui = TUI::new(Box::new(term));
1094        tui.mount(Box::new(Text::new("background", 0, 0)));
1095
1096        let modal_content = crate::components::Modal::new(Box::new(Text::new("hello", 0, 0)));
1097        tui.show_modal(Box::new(modal_content));
1098        // render_frame should not panic with an active modal
1099        tui.render_frame().unwrap();
1100    }
1101}