Skip to main content

photon_ui/
tui.rs

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