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                self.renderer.forget_image(*id);
475            }
476        }
477        self.previous_image_ids = current_ids;
478
479        try_io!(self.renderer.render(&mut *self.terminal, &screen));
480
481        if let Some((row, col)) = screen.cursor {
482            try_io!(self.terminal.move_cursor(row as u16, col as u16));
483            if self.hardware_cursor {
484                try_io!(self.terminal.show_cursor());
485            } else {
486                try_io!(self.terminal.hide_cursor());
487            }
488        }
489
490        Ok(())
491    }
492
493    /// Compute the composite screen buffer without writing to the terminal.
494    /// Test-only helper to inspect layout.
495    #[cfg(test)]
496    fn compose_screen(&self, width: u16, height: u16) -> crate::renderer::Rendered {
497        let mut compositor = Compositor::new(width, height);
498        for (i, layer) in self.layers.iter().enumerate().rev() {
499            let focused = self.layer_focus.get(i).copied().flatten();
500            let rendered = layer.render(width, height, focused);
501            compositor.add_layer(&rendered, &layer.shadow);
502        }
503        compositor.finalize()
504    }
505
506    /// Dispatch an event to the focused component, falling back to other
507    /// components if the focused one returns [`crate::InputResult::Ignored`].
508    ///
509    /// Also handles `Tab` to cycle focus between focusable components.
510    pub fn handle_input(&mut self, event: &crate::events::Event) {
511        // Modal capture: when a modal is open, Esc dismisses it and all other
512        // input is routed to the modal content.
513        if let Some(ref mut _modal) = self.modal &&
514            let crate::events::Event::Key(key) = event &&
515            key.code == crossterm::event::KeyCode::Esc
516        {
517            self.dismiss_modal();
518            return;
519        }
520        if let Some(ref mut modal) = self.modal {
521            modal.handle_input(event);
522            return;
523        }
524
525        // Handle Tab to cycle focus. Try the focused component first so nested
526        // containers (e.g. Div) can manage their own focus cycling.
527        if let crate::events::Event::Key(key) = event {
528            if key.code == crossterm::event::KeyCode::Tab {
529                if self.try_handle_focused(event) {
530                    return;
531                }
532                self.cycle_focus(1);
533                return;
534            }
535            if key.code == crossterm::event::KeyCode::BackTab {
536                if self.try_handle_focused(event) {
537                    return;
538                }
539                self.cycle_focus(-1);
540                return;
541            }
542        }
543
544        if self.try_handle_focused(event) {
545            return;
546        }
547
548        // Fall through to other components in all layers.
549        let focused_layer = self.focused_layer;
550        for (layer_idx, layer) in self.layers.iter_mut().enumerate() {
551            for (component_idx, child) in layer.components.iter_mut().enumerate() {
552                if layer_idx == focused_layer &&
553                    Some(component_idx) == self.layer_focus.get(layer_idx).copied().flatten()
554                {
555                    continue;
556                }
557                let result = child.handle_input(event);
558                if !matches!(result, crate::InputResult::Ignored) {
559                    return;
560                }
561            }
562        }
563    }
564
565    fn try_handle_focused(&mut self, event: &crate::events::Event) -> bool {
566        if let Some(layer_idx) = self.focused_layer_safe() &&
567            let Some(component_idx) = self.layer_focus.get(layer_idx).copied().flatten() &&
568            component_idx < self.layers[layer_idx].components.len()
569        {
570            let result = self.layers[layer_idx].components[component_idx].handle_input(event);
571            if !matches!(result, crate::InputResult::Ignored) {
572                return true;
573            }
574        }
575        false
576    }
577
578    /// Move focus to the next (or previous) focusable component.
579    fn cycle_focus(&mut self, delta: isize) {
580        let mut focusable: Vec<(usize, usize)> = Vec::new();
581        for (layer_idx, layer) in self.layers.iter().enumerate() {
582            for (component_idx, component) in layer.components.iter().enumerate() {
583                if component.as_focusable().is_some() {
584                    focusable.push((layer_idx, component_idx));
585                }
586            }
587        }
588        if focusable.is_empty() {
589            return;
590        }
591
592        let current_layer = self.focused_layer_safe();
593        let current_component =
594            current_layer.and_then(|l| self.layer_focus.get(l).copied().flatten());
595        let current = match current_component.and_then(|c| {
596            focusable
597                .iter()
598                .position(|&(l, comp)| Some(l) == current_layer && comp == c)
599        }) {
600            | Some(pos) => pos,
601            | None => {
602                self.set_focus_tuple(focusable[0]);
603                return;
604            },
605        };
606
607        let new_pos = if delta >= 0 {
608            (current + delta as usize) % focusable.len()
609        } else {
610            let d = (-delta) as usize % focusable.len();
611            (current + focusable.len() - d) % focusable.len()
612        };
613        self.set_focus_tuple(focusable[new_pos]);
614    }
615
616    fn set_focus_tuple(&mut self, (layer_idx, component_idx): (usize, usize)) {
617        self.unfocus_current();
618        self.focused_layer = layer_idx;
619        if self.layer_focus.len() <= layer_idx {
620            self.layer_focus.resize(layer_idx + 1, None);
621        }
622        self.layer_focus[layer_idx] = Some(component_idx);
623        if layer_idx < self.layers.len() &&
624            component_idx < self.layers[layer_idx].components.len() &&
625            let Some(f) = self.layers[layer_idx].components[component_idx].as_focusable_mut()
626        {
627            f.set_focused(true);
628        }
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::{
636        TestTerminal,
637        components::Text,
638        layer::{
639            Layer,
640            Shadow,
641        },
642        layout::{
643            Constraint,
644            Layout,
645        },
646    };
647
648    #[test]
649    fn tui_set_focus_invalid_index() {
650        let term = TestTerminal::new(80, 24);
651        let mut tui = TUI::new(Box::new(term));
652        tui.mount(Box::new(Text::new("a", 0, 0)));
653        tui.set_focus(5); // should not panic
654    }
655
656    #[test]
657    fn tui_handle_input_no_focus() {
658        let term = TestTerminal::new(80, 24);
659        let mut tui = TUI::new(Box::new(term));
660        tui.handle_input(&crate::events::Event::Resize(10, 10)); // should not panic
661    }
662
663    #[test]
664    fn tui_render_with_overlay() {
665        let term = TestTerminal::new(80, 24);
666        let mut tui = TUI::new(Box::new(term));
667        tui.mount(Box::new(Text::new("hello", 0, 0)));
668        let overlay = Overlay {
669            content: Box::new(Text::new("popup", 0, 0)),
670            position: OverlayPosition::Anchor(Anchor::Center),
671            constraints: OverlayConstraints {
672                min_width: 5,
673                max_height: 3,
674                margin: 1,
675                offset_x: 0,
676                offset_y: 0,
677                visible: None,
678            },
679        };
680        tui.overlays.push(overlay);
681        tui.render_frame().unwrap();
682    }
683
684    #[test]
685    fn tui_full_redraw_on_resize() {
686        let term = TestTerminal::new(80, 24);
687        let mut tui = TUI::new(Box::new(term));
688        tui.mount(Box::new(Text::new("hello", 0, 0)));
689        tui.render_frame().unwrap();
690        // Simulate resize by changing terminal size
691        let new_term = TestTerminal::new(100, 30);
692        tui.terminal = Box::new(new_term);
693        tui.render_frame().unwrap();
694    }
695
696    struct ImageComponent;
697    impl Component for ImageComponent {
698        fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
699            Ok(Rendered {
700                lines: vec!["img".into()],
701                cursor: None,
702                images: vec![crate::renderer::ImageCommand {
703                    id: 1,
704                    data: "data".into(),
705                    row: 0,
706                    col: 0,
707                }],
708            })
709        }
710    }
711
712    #[test]
713    fn tui_image_cleanup() {
714        let term = TestTerminal::new(80, 24);
715        let mut tui = TUI::new(Box::new(term));
716        tui.mount(Box::new(ImageComponent));
717        tui.render_frame().unwrap();
718        // Now replace with text component (no images)
719        tui.layers[0].components.clear();
720        tui.layers[0]
721            .components
722            .push(Box::new(Text::new("text", 0, 0)));
723        tui.render_frame().unwrap();
724        // Just verify no panic
725    }
726
727    #[test]
728    fn tui_hardware_cursor() {
729        // SAFETY: tests are single-threaded and no other code reads this
730        // environment variable concurrently.
731        unsafe {
732            std::env::set_var("PHOTON_UI_HARDWARE_CURSOR", "1");
733        }
734        let term = TestTerminal::new(80, 24);
735        let mut tui = TUI::new(Box::new(term));
736        tui.mount(Box::new(Text::new("hello", 0, 0)));
737        tui.render_frame().unwrap();
738        // SAFETY: tests are single-threaded.
739        unsafe {
740            std::env::remove_var("PHOTON_UI_HARDWARE_CURSOR");
741        }
742    }
743
744    #[test]
745    fn tui_tab_cycles_focus() {
746        let term = TestTerminal::new(80, 24);
747        let mut tui = TUI::new(Box::new(term));
748        tui.mount(Box::new(Text::new("a", 0, 0))); // not focusable
749        let list = crate::components::SelectList::new(vec!["x".into()], 1);
750        tui.mount(Box::new(list));
751        let input = crate::components::Input::new();
752        tui.mount(Box::new(input));
753
754        // First mounted component gets focus (Text at index 0)
755        assert_eq!(tui.layer_focus[0], Some(0));
756
757        // Tab moves to first focusable (SelectList at index 1)
758        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
759            crossterm::event::KeyCode::Tab,
760            crossterm::event::KeyModifiers::empty(),
761        )));
762        assert_eq!(tui.layer_focus[0], Some(1));
763
764        // Tab moves to next focusable (Input at index 2)
765        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
766            crossterm::event::KeyCode::Tab,
767            crossterm::event::KeyModifiers::empty(),
768        )));
769        assert_eq!(tui.layer_focus[0], Some(2));
770
771        // Tab wraps back to first focusable
772        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
773            crossterm::event::KeyCode::Tab,
774            crossterm::event::KeyModifiers::empty(),
775        )));
776        assert_eq!(tui.layer_focus[0], Some(1));
777    }
778
779    #[test]
780    fn tui_backtab_cycles_backward() {
781        let term = TestTerminal::new(80, 24);
782        let mut tui = TUI::new(Box::new(term));
783        let list = crate::components::SelectList::new(vec!["x".into()], 1);
784        tui.mount(Box::new(list));
785        let input = crate::components::Input::new();
786        tui.mount(Box::new(input));
787
788        // Start on SelectList (index 0)
789        assert_eq!(tui.layer_focus[0], Some(0));
790
791        // BackTab moves to previous focusable (wraps to Input)
792        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
793            crossterm::event::KeyCode::BackTab,
794            crossterm::event::KeyModifiers::empty(),
795        )));
796        assert_eq!(tui.layer_focus[0], Some(1));
797    }
798
799    #[test]
800    fn tui_cycle_focus_single_focusable() {
801        let term = TestTerminal::new(80, 24);
802        let mut tui = TUI::new(Box::new(term));
803        let list = crate::components::SelectList::new(vec!["x".into()], 1);
804        tui.mount(Box::new(list));
805
806        // Tab with only one focusable stays on it
807        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
808            crossterm::event::KeyCode::Tab,
809            crossterm::event::KeyModifiers::empty(),
810        )));
811        assert_eq!(tui.layer_focus[0], Some(0));
812    }
813
814    #[test]
815    fn tui_no_focusables_no_panic() {
816        let term = TestTerminal::new(80, 24);
817        let mut tui = TUI::new(Box::new(term));
818        tui.mount(Box::new(Text::new("hello", 0, 0))); // not focusable
819        // Tab with no focusables should not panic
820        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
821            crossterm::event::KeyCode::Tab,
822            crossterm::event::KeyModifiers::empty(),
823        )));
824    }
825
826    #[test]
827    fn tui_terminal_borrow() {
828        let term = TestTerminal::new(80, 24);
829        let tui = TUI::new(Box::new(term));
830        let _ = tui.terminal();
831    }
832
833    #[test]
834    fn tui_handle_input_fallthrough() {
835        let term = TestTerminal::new(80, 24);
836        let mut tui = TUI::new(Box::new(term));
837        // Add two text components (not focusable)
838        tui.mount(Box::new(Text::new("a", 0, 0)));
839        tui.mount(Box::new(Text::new("b", 0, 0)));
840        // A non-Tab key should fall through without panic
841        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
842            crossterm::event::KeyCode::Char('x'),
843            crossterm::event::KeyModifiers::empty(),
844        )));
845    }
846
847    #[test]
848    fn overlay_compute_position_all_anchors() {
849        let constraints = OverlayConstraints {
850            min_width: 5,
851            max_height: 3,
852            margin: 1,
853            offset_x: 0,
854            offset_y: 0,
855            visible: None,
856        };
857        let anchors = vec![
858            Anchor::Center,
859            Anchor::TopLeft,
860            Anchor::TopRight,
861            Anchor::BottomLeft,
862            Anchor::BottomRight,
863            Anchor::TopCenter,
864            Anchor::BottomCenter,
865            Anchor::LeftCenter,
866            Anchor::RightCenter,
867        ];
868        for anchor in anchors {
869            let overlay = Overlay {
870                content: Box::new(Text::new("test", 0, 0)),
871                position: OverlayPosition::Anchor(anchor),
872                constraints: constraints.clone(),
873            };
874            let rect = overlay.compute_position(80, 24, 10, 2);
875            assert!(rect.is_some(), "anchor {:?} should produce a rect", anchor);
876        }
877    }
878
879    #[test]
880    fn overlay_compute_position_at() {
881        let overlay = Overlay {
882            content: Box::new(Text::new("test", 0, 0)),
883            position: OverlayPosition::At(5, 10),
884            constraints: OverlayConstraints {
885                min_width: 5,
886                max_height: 3,
887                margin: 0,
888                offset_x: 0,
889                offset_y: 0,
890                visible: None,
891            },
892        };
893        let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
894        assert_eq!(rect.y, 5);
895        assert_eq!(rect.x, 10);
896    }
897
898    #[test]
899    fn overlay_compute_position_percent() {
900        let overlay = Overlay {
901            content: Box::new(Text::new("test", 0, 0)),
902            position: OverlayPosition::Percent("50%".into(), "25%".into()),
903            constraints: OverlayConstraints {
904                min_width: 5,
905                max_height: 3,
906                margin: 0,
907                offset_x: 0,
908                offset_y: 0,
909                visible: None,
910            },
911        };
912        let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
913        assert_eq!(rect.y, 10);
914        assert_eq!(rect.x, 50);
915    }
916
917    #[test]
918    fn overlay_compute_position_percent_invalid() {
919        let overlay = Overlay {
920            content: Box::new(Text::new("test", 0, 0)),
921            position: OverlayPosition::Percent("abc".into(), "xyz".into()),
922            constraints: OverlayConstraints {
923                min_width: 5,
924                max_height: 3,
925                margin: 0,
926                offset_x: 0,
927                offset_y: 0,
928                visible: None,
929            },
930        };
931        let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
932        assert_eq!(rect.y, 0);
933        assert_eq!(rect.x, 0);
934    }
935
936    #[test]
937    fn overlay_compute_position_visible_false() {
938        let overlay = Overlay {
939            content: Box::new(Text::new("test", 0, 0)),
940            position: OverlayPosition::Anchor(Anchor::Center),
941            constraints: OverlayConstraints {
942                min_width: 5,
943                max_height: 3,
944                margin: 0,
945                offset_x: 0,
946                offset_y: 0,
947                visible: Some(|_w, _h| false),
948            },
949        };
950        assert!(overlay.compute_position(80, 24, 10, 2).is_none());
951    }
952
953    #[test]
954    fn overlay_compute_position_with_offset() {
955        let overlay = Overlay {
956            content: Box::new(Text::new("test", 0, 0)),
957            position: OverlayPosition::At(10, 10),
958            constraints: OverlayConstraints {
959                min_width: 5,
960                max_height: 3,
961                margin: 0,
962                offset_x: 5,
963                offset_y: -3,
964                visible: None,
965            },
966        };
967        let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
968        assert_eq!(rect.y, 7);
969        assert_eq!(rect.x, 15);
970    }
971
972    #[test]
973    fn overlay_compute_position_negative_offset_clamped() {
974        let overlay = Overlay {
975            content: Box::new(Text::new("test", 0, 0)),
976            position: OverlayPosition::At(0, 0),
977            constraints: OverlayConstraints {
978                min_width: 5,
979                max_height: 3,
980                margin: 0,
981                offset_x: -5,
982                offset_y: -5,
983                visible: None,
984            },
985        };
986        let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
987        assert_eq!(rect.y, 0);
988        assert_eq!(rect.x, 0);
989    }
990
991    #[test]
992    fn overlay_compute_position_size_clamped() {
993        let overlay = Overlay {
994            content: Box::new(Text::new("test", 0, 0)),
995            position: OverlayPosition::At(70, 20),
996            constraints: OverlayConstraints {
997                min_width: 5,
998                max_height: 3,
999                margin: 0,
1000                offset_x: 0,
1001                offset_y: 0,
1002                visible: None,
1003            },
1004        };
1005        let rect = overlay.compute_position(80, 24, 20, 10).unwrap();
1006        // width should be min(term_w - col, w) = min(80-20, 20) = 20
1007        assert_eq!(rect.width, 20);
1008        // height: h = 10.min(3).max(1) = 3, then min(3, 24.saturating_sub(70)) = min(3,
1009        // 0) = 0
1010        assert_eq!(rect.height, 0);
1011    }
1012
1013    struct CursorComponent;
1014    impl Component for CursorComponent {
1015        fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
1016            Ok(Rendered {
1017                lines: vec!["cursor".into()],
1018                cursor: Some((0, 3)),
1019                images: vec![],
1020            })
1021        }
1022    }
1023
1024    #[test]
1025    fn tui_render_frame_with_cursor() {
1026        let term = TestTerminal::new(80, 24);
1027        let mut tui = TUI::new(Box::new(term));
1028        tui.mount(Box::new(CursorComponent));
1029        tui.render_frame().unwrap();
1030    }
1031
1032    #[test]
1033    fn tui_demo_layout_exact() {
1034        let term = TestTerminal::new(80, 24);
1035        let mut tui = TUI::new(Box::new(term));
1036
1037        tui.mount(Box::new(Text::new("Photon UI Demo", 2, 1)));
1038        tui.mount(Box::new(Text::new(
1039            "j/k = navigate list   Tab = switch focus   i = insert mode   Esc = normal mode   q = quit",
1040            2, 0,
1041        )));
1042        let list = crate::components::SelectList::new(
1043            vec![
1044                "Option 1: Hello world".into(),
1045                "Option 2: Foo bar baz".into(),
1046                "Option 3: Lorem ipsum".into(),
1047                "Option 4: Vim bindings".into(),
1048                "Option 5: Blazing fast".into(),
1049            ],
1050            3,
1051        );
1052        tui.mount(Box::new(list));
1053        let input = crate::components::Input::new();
1054        tui.mount(Box::new(input));
1055        tui.set_focus(2);
1056
1057        let screen = tui.compose_screen(80, 24);
1058
1059        // Expected layout (8 content lines padded to terminal height):
1060        // 0: blank (Text1 pad_y)
1061        // 1: Photon UI Demo
1062        // 2: blank (Text1 pad_y)
1063        // 3: keybindings text
1064        // 4: first list item (selected)
1065        // 5: second list item
1066        // 6: third list item
1067        // 7: input line
1068        assert_eq!(screen.lines.len(), 24, "expected 24 padded lines");
1069        assert_eq!(
1070            screen.lines[0].trim_end(),
1071            "",
1072            "row 0 should be blank from Text1 pad_y"
1073        );
1074        assert!(
1075            screen.lines[1].contains("Photon UI Demo"),
1076            "row 1 should contain header: got {:?}",
1077            screen.lines[1]
1078        );
1079        assert_eq!(
1080            screen.lines[2].trim_end(),
1081            "",
1082            "row 2 should be blank from Text1 pad_y"
1083        );
1084        assert!(
1085            screen.lines[3].contains("j/k = navigate"),
1086            "row 3 should contain keybindings: got {:?}",
1087            screen.lines[3]
1088        );
1089        assert!(
1090            screen.lines[4].contains("> Option 1"),
1091            "row 4 should be selected list item: got {:?}",
1092            screen.lines[4]
1093        );
1094        assert!(
1095            screen.lines[5].contains("  Option 2"),
1096            "row 5 should be unselected list item: got {:?}",
1097            screen.lines[5]
1098        );
1099        assert!(
1100            screen.lines[6].contains("  Option 3"),
1101            "row 6 should be unselected list item: got {:?}",
1102            screen.lines[6]
1103        );
1104        assert_eq!(
1105            screen.lines[7].trim_end(),
1106            "",
1107            "row 7 should be empty input line"
1108        );
1109    }
1110
1111    /// Regression: reset() must clear children, overlays, layout, focus,
1112    /// and schedule a FullRedraw so stale content doesn't bleed through.
1113    #[test]
1114    fn tui_reset_clears_all_and_schedules_redraw() {
1115        let term = TestTerminal::new(80, 24);
1116        let mut tui = TUI::new(Box::new(term));
1117
1118        tui.mount(Box::new(crate::components::Text::new("hello", 0, 0)));
1119        tui.set_focus(0);
1120        tui.add_overlay(Overlay {
1121            content: Box::new(crate::components::Text::new("popup", 0, 0)),
1122            position: OverlayPosition::Anchor(Anchor::Center),
1123            constraints: OverlayConstraints {
1124                min_width: 10,
1125                max_height: 3,
1126                margin: 2,
1127                offset_x: 0,
1128                offset_y: 0,
1129                visible: None,
1130            },
1131        });
1132        tui.set_layout(crate::layout::Layout::vertical([
1133            crate::layout::Constraint::Length(1),
1134        ]));
1135        tui.render_frame().unwrap();
1136
1137        // Verify preconditions: screen has content
1138        let screen_before = tui.compose_screen(80, 24);
1139        assert!(
1140            !screen_before.lines.is_empty(),
1141            "precondition: screen should have content"
1142        );
1143
1144        tui.reset();
1145
1146        // After reset, compose_screen should contain only empty padding.
1147        let screen = tui.compose_screen(80, 24);
1148        assert!(
1149            screen.lines.iter().all(|line| line.trim_end().is_empty()),
1150            "reset should clear all children"
1151        );
1152
1153        // render_frame should not panic after reset (FullRedraw is scheduled
1154        // internally)
1155        tui.render_frame().unwrap();
1156    }
1157
1158    #[test]
1159    fn tui_show_modal_captures_input() {
1160        let term = TestTerminal::new(80, 24);
1161        let mut tui = TUI::new(Box::new(term));
1162        tui.mount(Box::new(Text::new("background", 0, 0)));
1163        tui.set_focus(0);
1164
1165        let modal_content = Text::new("modal text", 0, 0);
1166        tui.show_modal(Box::new(modal_content));
1167        assert!(tui.modal_active());
1168
1169        // Esc should dismiss the modal
1170        tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
1171            crossterm::event::KeyCode::Esc,
1172            crossterm::event::KeyModifiers::empty(),
1173        )));
1174        assert!(!tui.modal_active());
1175    }
1176
1177    #[test]
1178    fn tui_modal_restores_focus_on_dismiss() {
1179        let term = TestTerminal::new(80, 24);
1180        let mut tui = TUI::new(Box::new(term));
1181        let list = crate::components::SelectList::new(vec!["x".into()], 1);
1182        tui.mount(Box::new(list));
1183        assert_eq!(tui.layer_focus[0], Some(0));
1184
1185        tui.show_modal(Box::new(Text::new("modal", 0, 0)));
1186        tui.dismiss_modal();
1187        assert_eq!(tui.layer_focus[0], Some(0));
1188    }
1189
1190    #[test]
1191    fn tui_modal_renders_without_panic() {
1192        let term = TestTerminal::new(80, 24);
1193        let mut tui = TUI::new(Box::new(term));
1194        tui.mount(Box::new(Text::new("background", 0, 0)));
1195
1196        let modal_content = crate::components::Modal::new(Box::new(Text::new("hello", 0, 0)));
1197        tui.show_modal(Box::new(modal_content));
1198        // render_frame should not panic with an active modal
1199        tui.render_frame().unwrap();
1200    }
1201
1202    #[test]
1203    fn tui_add_layer_count_and_mut() {
1204        let term = TestTerminal::new(80, 24);
1205        let mut tui = TUI::new(Box::new(term));
1206        assert_eq!(tui.layer_count(), 1);
1207
1208        let idx = tui.add_layer(Layer::with_component(Box::new(Text::new("l1", 0, 0))));
1209        assert_eq!(idx, 1);
1210        assert_eq!(tui.layer_count(), 2);
1211
1212        if let Some(layer) = tui.layer_mut(1) {
1213            layer.shadow = Shadow::Dim {
1214                style: "\x1b[2m".into(),
1215            };
1216        }
1217        assert!(tui.layer_mut(5).is_none());
1218    }
1219
1220    #[test]
1221    fn tui_insert_layer() {
1222        let term = TestTerminal::new(80, 24);
1223        let mut tui = TUI::new(Box::new(term));
1224        let list = crate::components::SelectList::new(vec!["x".into()], 1);
1225        tui.mount(Box::new(list));
1226        tui.set_focused_layer(0);
1227
1228        tui.insert_layer(
1229            0,
1230            Layer::with_component(Box::new(Text::new("inserted", 0, 0))),
1231        );
1232        assert_eq!(tui.layer_count(), 2);
1233        assert_eq!(tui.focused_layer, 1);
1234    }
1235
1236    #[test]
1237    fn tui_remove_layer() {
1238        let term = TestTerminal::new(80, 24);
1239        let mut tui = TUI::new(Box::new(term));
1240        tui.add_layer(Layer::with_component(Box::new(Text::new("top", 0, 0))));
1241
1242        let removed = tui.remove_layer(1);
1243        assert!(removed.is_some());
1244        assert_eq!(tui.layer_count(), 1);
1245        assert!(tui.remove_layer(5).is_none());
1246    }
1247
1248    #[test]
1249    fn tui_set_focused_layer() {
1250        let term = TestTerminal::new(80, 24);
1251        let mut tui = TUI::new(Box::new(term));
1252        let list = crate::components::SelectList::new(vec!["x".into()], 1);
1253        tui.add_layer(Layer::with_component(Box::new(list)));
1254
1255        tui.set_focused_layer(1);
1256        assert_eq!(tui.focused_layer, 1);
1257
1258        tui.set_focused_layer(99);
1259        assert_eq!(tui.focused_layer, 1);
1260    }
1261
1262    #[test]
1263    fn tui_clear_children() {
1264        let term = TestTerminal::new(80, 24);
1265        let mut tui = TUI::new(Box::new(term));
1266        tui.mount(Box::new(Text::new("child", 0, 0)));
1267        tui.clear_children();
1268        assert!(tui.layers[0].components.is_empty());
1269        assert_eq!(tui.layer_focus[0], None);
1270    }
1271
1272    #[test]
1273    fn tui_clear_overlays() {
1274        let term = TestTerminal::new(80, 24);
1275        let mut tui = TUI::new(Box::new(term));
1276        tui.add_overlay(Overlay {
1277            content: Box::new(Text::new("popup", 0, 0)),
1278            position: OverlayPosition::Anchor(Anchor::Center),
1279            constraints: OverlayConstraints {
1280                min_width: 5,
1281                max_height: 3,
1282                margin: 1,
1283                offset_x: 0,
1284                offset_y: 0,
1285                visible: None,
1286            },
1287        });
1288        tui.clear_overlays();
1289        assert!(tui.overlays.is_empty());
1290    }
1291
1292    #[test]
1293    fn tui_clear_layout() {
1294        let term = TestTerminal::new(80, 24);
1295        let mut tui = TUI::new(Box::new(term));
1296        tui.set_layout(Layout::vertical([Constraint::Length(1)]));
1297        tui.clear_layout();
1298        assert!(tui.layers[0].layout.is_none());
1299    }
1300
1301    #[test]
1302    fn tui_dismiss_focusable_modal() {
1303        let term = TestTerminal::new(80, 24);
1304        let mut tui = TUI::new(Box::new(term));
1305        let list = crate::components::SelectList::new(vec!["x".into()], 1);
1306        tui.show_modal(Box::new(list));
1307        assert!(tui.modal_active());
1308        tui.dismiss_modal();
1309        assert!(!tui.modal_active());
1310    }
1311}