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