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