use std::io;
use crate::{
Component,
layout::layout::Layout,
renderer::{
RenderStrategy,
Rendered,
Renderer,
},
terminal::Terminal,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Anchor {
Center,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
TopCenter,
BottomCenter,
LeftCenter,
RightCenter,
}
#[derive(Debug, Clone, PartialEq)]
pub enum OverlayPosition {
Anchor(Anchor),
At(u16, u16),
Percent(String, String),
}
#[derive(Debug, Clone)]
pub struct OverlayConstraints {
pub min_width: u16,
pub max_height: u16,
pub margin: u16,
pub offset_x: i16,
pub offset_y: i16,
pub visible: Option<fn(u16, u16) -> bool>,
}
pub use crate::layout::Rect;
pub struct Overlay {
pub content: Box<dyn Component>,
pub position: OverlayPosition,
pub constraints: OverlayConstraints,
}
impl Overlay {
pub fn compute_position(
&self,
term_w: u16,
term_h: u16,
content_w: u16,
content_h: u16,
) -> Option<Rect> {
let w = content_w.max(self.constraints.min_width);
let h = content_h.min(self.constraints.max_height).max(1);
if let Some(vis) = self.constraints.visible {
if !vis(term_w, term_h) {
return None;
}
}
let (row, col) = match &self.position {
| OverlayPosition::Anchor(anchor) => {
let r = match anchor {
| Anchor::Center | Anchor::LeftCenter | Anchor::RightCenter => {
(term_h.saturating_sub(h)) / 2
},
| Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
self.constraints.margin
},
| Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
term_h.saturating_sub(h + self.constraints.margin)
},
};
let c = match anchor {
| Anchor::Center | Anchor::TopCenter | Anchor::BottomCenter => {
(term_w.saturating_sub(w)) / 2
},
| Anchor::TopLeft | Anchor::BottomLeft | Anchor::LeftCenter => {
self.constraints.margin
},
| Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter => {
term_w.saturating_sub(w + self.constraints.margin)
},
};
(r, c)
},
| OverlayPosition::At(r, c) => (*r, *c),
| OverlayPosition::Percent(px, py) => {
let parse_pct = |s: &str| -> u16 {
s.trim_end_matches('%').parse::<f64>().unwrap_or(0.0) as u16
};
let pct_x = parse_pct(px);
let pct_y = parse_pct(py);
let r = (term_h as f64 * pct_y as f64 / 100.0) as u16;
let c = (term_w as f64 * pct_x as f64 / 100.0) as u16;
(r, c)
},
};
Some(Rect {
y: (row as i16 + self.constraints.offset_y).max(0) as u16,
x: (col as i16 + self.constraints.offset_x).max(0) as u16,
width: w.min(term_w.saturating_sub(col)),
height: h.min(term_h.saturating_sub(row)),
})
}
}
pub struct TUI {
terminal: Box<dyn Terminal>,
children: Vec<Box<dyn Component>>,
overlays: Vec<Overlay>,
modal: Option<Box<dyn Component>>,
focused_index: Option<usize>,
pre_modal_focus: Option<usize>,
renderer: Renderer,
size: (u16, u16),
previous_image_ids: std::collections::HashSet<u32>,
hardware_cursor: bool,
layout: Option<Layout>,
}
impl TUI {
pub fn new(terminal: Box<dyn Terminal>) -> Self {
Self {
terminal,
children: Vec::new(),
overlays: Vec::new(),
modal: None,
focused_index: None,
pre_modal_focus: None,
renderer: Renderer::new(),
size: (80, 24),
previous_image_ids: std::collections::HashSet::new(),
hardware_cursor: std::env::var("PHOTON_UI_HARDWARE_CURSOR").is_ok(),
layout: None,
}
}
pub fn terminal(&self) -> &dyn Terminal {
&*self.terminal
}
pub fn mount(&mut self, component: Box<dyn Component>) {
let idx = self.children.len();
self.children.push(component);
if self.focused_index.is_none() {
self.set_focus(idx);
}
}
pub fn set_focus(&mut self, index: usize) {
if let Some(old) = self.focused_index {
if old < self.children.len() {
if let Some(f) = self.children[old].as_focusable_mut() {
f.set_focused(false);
}
}
}
self.focused_index = Some(index);
if index < self.children.len() {
if let Some(f) = self.children[index].as_focusable_mut() {
f.set_focused(true);
}
}
}
pub fn clear_children(&mut self) {
self.children.clear();
self.focused_index = None;
}
pub fn add_overlay(&mut self, overlay: Overlay) {
self.overlays.push(overlay);
}
pub fn clear_overlays(&mut self) {
self.overlays.clear();
}
pub fn show_modal(&mut self, modal: Box<dyn Component>) {
self.pre_modal_focus = self.focused_index;
self.modal = Some(modal);
if let Some(ref mut m) = self.modal {
if let Some(f) = m.as_focusable_mut() {
f.set_focused(true);
}
}
}
pub fn dismiss_modal(&mut self) {
if let Some(ref mut m) = self.modal {
if let Some(f) = m.as_focusable_mut() {
f.set_focused(false);
}
}
self.modal = None;
if let Some(idx) = self.pre_modal_focus {
if idx < self.children.len() {
self.set_focus(idx);
}
}
self.pre_modal_focus = None;
}
pub fn modal_active(&self) -> bool {
self.modal.is_some()
}
pub fn set_layout(&mut self, layout: Layout) {
self.layout = Some(layout);
}
pub fn clear_layout(&mut self) {
self.layout = None;
}
pub fn reset(&mut self) {
self.children.clear();
self.focused_index = None;
self.pre_modal_focus = None;
self.overlays.clear();
self.modal = None;
self.layout = None;
self.renderer
.set_strategy(crate::renderer::RenderStrategy::FullRedraw);
}
pub fn stop(&mut self) -> io::Result<()> {
self.terminal.stop()
}
pub fn render_frame(&mut self) -> io::Result<()> {
let (width, height) = self.terminal.size()?;
let size_changed = self.size != (width, height);
self.size = (width, height);
if self.renderer.previous().is_none() {
self.renderer.set_strategy(RenderStrategy::FirstRender);
} else if size_changed {
self.renderer.set_strategy(RenderStrategy::FullRedraw);
} else {
self.renderer.set_strategy(RenderStrategy::Diff);
}
let mut screen = Rendered::empty();
let term_rect = Rect::new(0, 0, width, height);
if let Some(layout) = &self.layout {
let areas = layout.split(term_rect);
for (child, area) in self.children.iter().zip(areas.iter()) {
if let Ok(rendered) = child.render_rect(*area) {
rendered.blit_into_rect(&mut screen, *area);
}
}
} else {
let mut row = 0usize;
for child in &self.children {
if let Ok(rendered) = child.render(width) {
for line in &rendered.lines {
screen.lines.push(line.clone());
}
if let Some((r, c)) = rendered.cursor {
screen.cursor = Some((row + r, c));
}
screen.images.extend(rendered.images);
row += rendered.lines.len();
}
}
}
if !self.overlays.is_empty() {
while screen.lines.len() < height as usize {
screen.lines.push("".to_string());
}
}
for overlay in &self.overlays {
if let Ok(rendered) = overlay.content.render(width) {
if let Some(rect) =
overlay.compute_position(width, height, rendered.lines.len() as u16, 1)
{
rendered.blit_onto(&mut screen, rect.y, rect.x);
}
}
}
if let Some(ref modal) = self.modal {
if let Ok(rendered) = modal.render(width) {
let modal_h = rendered.lines.len() as u16;
let modal_w =
crate::utils::visible_width(rendered.lines.first().unwrap_or(&String::new()))
as u16;
let row = (height.saturating_sub(modal_h)) / 2;
let col = (width.saturating_sub(modal_w)) / 2;
rendered.blit_onto(&mut screen, row, col);
}
}
let current_ids: std::collections::HashSet<u32> =
screen.images.iter().map(|i| i.id).collect();
for id in &self.previous_image_ids {
if !current_ids.contains(id) {
self.terminal
.write(&format!("\x1b_Ga=d,d=I,i={}\x1b\\", id))?;
}
}
self.previous_image_ids = current_ids;
self.renderer.render(&mut *self.terminal, &screen)?;
if let Some((row, col)) = screen.cursor {
self.terminal.move_cursor(row as u16, col as u16)?;
if self.hardware_cursor {
self.terminal.show_cursor()?;
} else {
self.terminal.hide_cursor()?;
}
}
Ok(())
}
#[cfg(test)]
fn compose_screen(&self, width: u16, height: u16) -> crate::renderer::Rendered {
let mut screen = crate::renderer::Rendered::empty();
let term_rect = Rect::new(0, 0, width, height);
if let Some(layout) = &self.layout {
let areas = layout.split(term_rect);
for (child, area) in self.children.iter().zip(areas.iter()) {
if let Ok(rendered) = child.render_rect(*area) {
rendered.blit_into_rect(&mut screen, *area);
}
}
} else {
let mut row = 0usize;
for child in &self.children {
if let Ok(rendered) = child.render(width) {
for line in &rendered.lines {
screen.lines.push(line.clone());
}
if let Some((r, c)) = rendered.cursor {
screen.cursor = Some((row + r, c));
}
row += rendered.lines.len();
}
}
}
screen
}
pub fn handle_input(&mut self, event: &crate::events::Event) {
if let Some(ref mut modal) = self.modal {
if let crate::events::Event::Key(key) = event {
if key.code == crossterm::event::KeyCode::Esc {
self.dismiss_modal();
return;
}
}
modal.handle_input(event);
return;
}
if let crate::events::Event::Key(key) = event {
if key.code == crossterm::event::KeyCode::Tab {
if let Some(idx) = self.focused_index {
if idx < self.children.len() {
let result = self.children[idx].handle_input(event);
if !matches!(result, crate::InputResult::Ignored) {
return;
}
}
}
self.cycle_focus(1);
return;
}
if key.code == crossterm::event::KeyCode::BackTab {
if let Some(idx) = self.focused_index {
if idx < self.children.len() {
let result = self.children[idx].handle_input(event);
if !matches!(result, crate::InputResult::Ignored) {
return;
}
}
}
self.cycle_focus(-1);
return;
}
}
if let Some(idx) = self.focused_index {
if idx < self.children.len() {
let result = self.children[idx].handle_input(event);
if !matches!(result, crate::InputResult::Ignored) {
return;
}
}
}
for (i, child) in self.children.iter_mut().enumerate() {
if Some(i) == self.focused_index {
continue;
}
let result = child.handle_input(event);
if !matches!(result, crate::InputResult::Ignored) {
return;
}
}
}
fn cycle_focus(&mut self, delta: isize) {
let focusable: Vec<usize> = self
.children
.iter()
.enumerate()
.filter(|(_, c)| c.as_focusable().is_some())
.map(|(i, _)| i)
.collect();
if focusable.is_empty() {
return;
}
let current = match self
.focused_index
.and_then(|idx| focusable.iter().position(|&i| i == idx))
{
| Some(pos) => pos,
| None => {
self.set_focus(focusable[0]);
return;
},
};
let new_pos = if delta >= 0 {
(current + delta as usize) % focusable.len()
} else {
let d = (-delta) as usize % focusable.len();
(current + focusable.len() - d) % focusable.len()
};
self.set_focus(focusable[new_pos]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
TestTerminal,
components::Text,
};
#[test]
fn tui_set_focus_invalid_index() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("a", 0, 0)));
tui.set_focus(5); }
#[test]
fn tui_handle_input_no_focus() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.handle_input(&crate::events::Event::Resize(10, 10)); }
#[test]
fn tui_render_with_overlay() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("hello", 0, 0)));
let overlay = Overlay {
content: Box::new(Text::new("popup", 0, 0)),
position: OverlayPosition::Anchor(Anchor::Center),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 1,
offset_x: 0,
offset_y: 0,
visible: None,
},
};
tui.overlays.push(overlay);
tui.render_frame().unwrap();
}
#[test]
fn tui_full_redraw_on_resize() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("hello", 0, 0)));
tui.render_frame().unwrap();
let new_term = TestTerminal::new(100, 30);
tui.terminal = Box::new(new_term);
tui.render_frame().unwrap();
}
struct ImageComponent;
impl Component for ImageComponent {
fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
Ok(Rendered {
lines: vec!["img".into()],
cursor: None,
images: vec![crate::renderer::ImageCommand {
id: 1,
data: "data".into(),
}],
})
}
}
#[test]
fn tui_image_cleanup() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(ImageComponent));
tui.render_frame().unwrap();
tui.children.clear();
tui.children.push(Box::new(Text::new("text", 0, 0)));
tui.render_frame().unwrap();
}
#[test]
fn tui_hardware_cursor() {
unsafe {
std::env::set_var("PHOTON_UI_HARDWARE_CURSOR", "1");
}
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("hello", 0, 0)));
tui.render_frame().unwrap();
unsafe {
std::env::remove_var("PHOTON_UI_HARDWARE_CURSOR");
}
}
#[test]
fn tui_tab_cycles_focus() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("a", 0, 0))); let list = crate::components::SelectList::new(vec!["x".into()], 1);
tui.mount(Box::new(list));
let input = crate::components::Input::new();
tui.mount(Box::new(input));
assert_eq!(tui.focused_index, Some(0));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.focused_index, Some(1));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.focused_index, Some(2));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.focused_index, Some(1));
}
#[test]
fn tui_backtab_cycles_backward() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
let list = crate::components::SelectList::new(vec!["x".into()], 1);
tui.mount(Box::new(list));
let input = crate::components::Input::new();
tui.mount(Box::new(input));
assert_eq!(tui.focused_index, Some(0));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::BackTab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.focused_index, Some(1));
}
#[test]
fn tui_cycle_focus_single_focusable() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
let list = crate::components::SelectList::new(vec!["x".into()], 1);
tui.mount(Box::new(list));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.focused_index, Some(0));
}
#[test]
fn tui_no_focusables_no_panic() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("hello", 0, 0))); tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
)));
}
#[test]
fn tui_terminal_borrow() {
let term = TestTerminal::new(80, 24);
let tui = TUI::new(Box::new(term));
let _ = tui.terminal();
}
#[test]
fn tui_handle_input_fallthrough() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("a", 0, 0)));
tui.mount(Box::new(Text::new("b", 0, 0)));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::empty(),
)));
}
#[test]
fn overlay_compute_position_all_anchors() {
let constraints = OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 1,
offset_x: 0,
offset_y: 0,
visible: None,
};
let anchors = vec![
Anchor::Center,
Anchor::TopLeft,
Anchor::TopRight,
Anchor::BottomLeft,
Anchor::BottomRight,
Anchor::TopCenter,
Anchor::BottomCenter,
Anchor::LeftCenter,
Anchor::RightCenter,
];
for anchor in anchors {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::Anchor(anchor),
constraints: constraints.clone(),
};
let rect = overlay.compute_position(80, 24, 10, 2);
assert!(rect.is_some(), "anchor {:?} should produce a rect", anchor);
}
}
#[test]
fn overlay_compute_position_at() {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::At(5, 10),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 0,
offset_x: 0,
offset_y: 0,
visible: None,
},
};
let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
assert_eq!(rect.y, 5);
assert_eq!(rect.x, 10);
}
#[test]
fn overlay_compute_position_percent() {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::Percent("50%".into(), "25%".into()),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 0,
offset_x: 0,
offset_y: 0,
visible: None,
},
};
let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
assert_eq!(rect.y, 10);
assert_eq!(rect.x, 50);
}
#[test]
fn overlay_compute_position_percent_invalid() {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::Percent("abc".into(), "xyz".into()),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 0,
offset_x: 0,
offset_y: 0,
visible: None,
},
};
let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
assert_eq!(rect.y, 0);
assert_eq!(rect.x, 0);
}
#[test]
fn overlay_compute_position_visible_false() {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::Anchor(Anchor::Center),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 0,
offset_x: 0,
offset_y: 0,
visible: Some(|_w, _h| false),
},
};
assert!(overlay.compute_position(80, 24, 10, 2).is_none());
}
#[test]
fn overlay_compute_position_with_offset() {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::At(10, 10),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 0,
offset_x: 5,
offset_y: -3,
visible: None,
},
};
let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
assert_eq!(rect.y, 7);
assert_eq!(rect.x, 15);
}
#[test]
fn overlay_compute_position_negative_offset_clamped() {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::At(0, 0),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 0,
offset_x: -5,
offset_y: -5,
visible: None,
},
};
let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
assert_eq!(rect.y, 0);
assert_eq!(rect.x, 0);
}
#[test]
fn overlay_compute_position_size_clamped() {
let overlay = Overlay {
content: Box::new(Text::new("test", 0, 0)),
position: OverlayPosition::At(70, 20),
constraints: OverlayConstraints {
min_width: 5,
max_height: 3,
margin: 0,
offset_x: 0,
offset_y: 0,
visible: None,
},
};
let rect = overlay.compute_position(80, 24, 20, 10).unwrap();
assert_eq!(rect.width, 20);
assert_eq!(rect.height, 0);
}
struct CursorComponent;
impl Component for CursorComponent {
fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
Ok(Rendered {
lines: vec!["cursor".into()],
cursor: Some((0, 3)),
images: vec![],
})
}
}
#[test]
fn tui_render_frame_with_cursor() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(CursorComponent));
tui.render_frame().unwrap();
}
#[test]
fn tui_demo_layout_exact() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("Photon UI Demo", 2, 1)));
tui.mount(Box::new(Text::new(
"j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit",
2, 0,
)));
let list = crate::components::SelectList::new(
vec![
"Option 1: Hello world".into(),
"Option 2: Foo bar baz".into(),
"Option 3: Lorem ipsum".into(),
"Option 4: Vim bindings".into(),
"Option 5: Blazing fast".into(),
],
3,
);
tui.mount(Box::new(list));
let input = crate::components::Input::new();
tui.mount(Box::new(input));
tui.set_focus(2);
let screen = tui.compose_screen(80, 24);
assert_eq!(
screen.lines.len(),
8,
"expected 8 content lines, got {}",
screen.lines.len()
);
assert_eq!(
screen.lines[0].trim_end(),
"",
"row 0 should be blank from Text1 pad_y"
);
assert!(
screen.lines[1].contains("Photon UI Demo"),
"row 1 should contain header: got {:?}",
screen.lines[1]
);
assert_eq!(
screen.lines[2].trim_end(),
"",
"row 2 should be blank from Text1 pad_y"
);
assert!(
screen.lines[3].contains("j/k = navigate"),
"row 3 should contain keybindings: got {:?}",
screen.lines[3]
);
assert!(
screen.lines[4].contains("> Option 1"),
"row 4 should be selected list item: got {:?}",
screen.lines[4]
);
assert!(
screen.lines[5].contains(" Option 2"),
"row 5 should be unselected list item: got {:?}",
screen.lines[5]
);
assert!(
screen.lines[6].contains(" Option 3"),
"row 6 should be unselected list item: got {:?}",
screen.lines[6]
);
assert_eq!(
screen.lines[7].trim_end(),
"",
"row 7 should be empty input line"
);
}
#[test]
fn tui_reset_clears_all_and_schedules_redraw() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(crate::components::Text::new("hello", 0, 0)));
tui.set_focus(0);
tui.add_overlay(Overlay {
content: Box::new(crate::components::Text::new("popup", 0, 0)),
position: OverlayPosition::Anchor(Anchor::Center),
constraints: OverlayConstraints {
min_width: 10,
max_height: 3,
margin: 2,
offset_x: 0,
offset_y: 0,
visible: None,
},
});
tui.set_layout(crate::layout::layout::Layout::vertical([
crate::layout::Constraint::Length(1),
]));
tui.render_frame().unwrap();
let screen_before = tui.compose_screen(80, 24);
assert!(
!screen_before.lines.is_empty(),
"precondition: screen should have content"
);
tui.reset();
let screen = tui.compose_screen(80, 24);
assert!(screen.lines.is_empty(), "reset should clear all children");
tui.render_frame().unwrap();
}
#[test]
fn tui_show_modal_captures_input() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("background", 0, 0)));
tui.set_focus(0);
let modal_content = Text::new("modal text", 0, 0);
tui.show_modal(Box::new(modal_content));
assert!(tui.modal_active());
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
)));
assert!(!tui.modal_active());
}
#[test]
fn tui_modal_restores_focus_on_dismiss() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
let list = crate::components::SelectList::new(vec!["x".into()], 1);
tui.mount(Box::new(list));
assert_eq!(tui.focused_index, Some(0));
tui.show_modal(Box::new(Text::new("modal", 0, 0)));
tui.dismiss_modal();
assert_eq!(tui.focused_index, Some(0));
}
#[test]
fn tui_modal_renders_without_panic() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("background", 0, 0)));
let modal_content = crate::components::Modal::new(Box::new(Text::new("hello", 0, 0)));
tui.show_modal(Box::new(modal_content));
tui.render_frame().unwrap();
}
}