use std::io;
macro_rules! try_io {
($expr:expr) => {
match $expr {
| Ok(v) => v,
| Err(e) => return Err(e),
}
};
}
#[cfg(test)]
use crate::renderer::Rendered;
use crate::{
Component,
compositor::Compositor,
image::delete_kitty_image,
layer::Layer,
layout::Layout,
renderer::{
RenderStrategy,
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 &&
!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>,
layers: Vec<Layer>,
layer_focus: Vec<Option<usize>>,
focused_layer: usize,
pre_modal_layer: Option<usize>,
overlays: Vec<Overlay>,
modal: Option<Box<dyn Component>>,
renderer: Renderer,
size: (u16, u16),
previous_image_ids: std::collections::HashSet<u32>,
hardware_cursor: bool,
}
impl TUI {
pub fn new(terminal: Box<dyn Terminal>) -> Self {
Self {
terminal,
layers: vec![Layer::new()],
layer_focus: vec![None],
focused_layer: 0,
pre_modal_layer: None,
overlays: Vec::new(),
modal: 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(),
}
}
pub fn terminal(&self) -> &dyn Terminal {
&*self.terminal
}
pub fn mount(&mut self, component: Box<dyn Component>) {
let idx = self.layers[0].components.len();
let is_focusable = component.as_focusable().is_some();
self.layers[0].mount(component);
if self.layer_focus[0].is_none() && is_focusable {
self.set_focus(idx);
}
}
pub fn set_focus(&mut self, index: usize) {
self.unfocus_current();
self.focused_layer = 0;
self.layer_focus[0] = Some(index);
if index < self.layers[0].components.len() &&
let Some(f) = self.layers[0].components[index].as_focusable_mut()
{
f.set_focused(true);
}
}
pub fn clear_children(&mut self) {
self.layers[0].components.clear();
self.layer_focus[0] = None;
self.focused_layer = 0;
}
fn unfocus_current(&mut self) {
if let Some(layer_idx) = self.focused_layer_safe() &&
let Some(component_idx) = self.layer_focus.get(layer_idx).copied().flatten() &&
component_idx < self.layers[layer_idx].components.len() &&
let Some(f) = self.layers[layer_idx].components[component_idx].as_focusable_mut()
{
f.set_focused(false);
}
}
fn focused_layer_safe(&self) -> Option<usize> {
if self.focused_layer < self.layers.len() {
Some(self.focused_layer)
} else {
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_layer = Some(self.focused_layer);
self.modal = Some(modal);
if let Some(ref mut m) = self.modal &&
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 &&
let Some(f) = m.as_focusable_mut()
{
f.set_focused(false);
}
self.modal = None;
if let Some(idx) = self.pre_modal_layer {
let clamped = idx.min(self.layers.len().saturating_sub(1));
self.focused_layer = clamped;
}
self.pre_modal_layer = None;
}
pub fn modal_active(&self) -> bool {
self.modal.is_some()
}
pub fn set_layout(&mut self, layout: Layout) {
self.layers[0].set_layout(layout);
}
pub fn clear_layout(&mut self) {
self.layers[0].layout = None;
}
pub fn reset(&mut self) {
self.layers.clear();
self.layer_focus.clear();
self.layers.push(Layer::new());
self.layer_focus.push(None);
self.focused_layer = 0;
self.pre_modal_layer = None;
self.overlays.clear();
self.modal = None;
self.renderer
.set_strategy(crate::renderer::RenderStrategy::FullRedraw);
}
pub fn add_layer(&mut self, layer: Layer) -> usize {
let idx = self.layers.len();
self.layers.push(layer);
self.layer_focus.push(None);
idx
}
pub fn insert_layer(&mut self, index: usize, layer: Layer) {
if index > self.layers.len() {
return;
}
self.layers.insert(index, layer);
self.layer_focus.insert(index, None);
if self.focused_layer >= index {
self.focused_layer += 1;
}
}
pub fn remove_layer(&mut self, index: usize) -> Option<Layer> {
if index >= self.layers.len() {
return None;
}
if self.focused_layer == index {
self.focused_layer = index
.saturating_sub(1)
.min(self.layers.len().saturating_sub(2));
} else if self.focused_layer > index {
self.focused_layer -= 1;
}
self.layer_focus.remove(index);
Some(self.layers.remove(index))
}
pub fn layer_mut(&mut self, index: usize) -> Option<&mut Layer> {
self.layers.get_mut(index)
}
pub fn layer_count(&self) -> usize {
self.layers.len()
}
pub fn set_focused_layer(&mut self, index: usize) {
if index < self.layers.len() {
self.unfocus_current();
self.focused_layer = index;
}
}
pub fn stop(&mut self) -> io::Result<()> {
self.terminal.stop()
}
pub fn render_frame(&mut self) -> io::Result<()> {
let (width, height) = try_io!(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 compositor = Compositor::new(width, height);
for (i, layer) in self.layers.iter().enumerate().rev() {
let focused = self.layer_focus.get(i).copied().flatten();
let rendered = layer.render(width, height, focused);
compositor.add_layer(&rendered, &layer.shadow);
}
let mut screen = compositor.finalize();
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) &&
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 &&
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) {
try_io!(self.terminal.write(&delete_kitty_image(*id)));
self.renderer.forget_image(*id);
}
}
self.previous_image_ids = current_ids;
try_io!(self.renderer.render(&mut *self.terminal, &screen));
if let Some((row, col)) = screen.cursor {
try_io!(self.terminal.move_cursor(row as u16, col as u16));
if self.hardware_cursor {
try_io!(self.terminal.show_cursor());
} else {
try_io!(self.terminal.hide_cursor());
}
}
Ok(())
}
#[cfg(test)]
fn compose_screen(&self, width: u16, height: u16) -> crate::renderer::Rendered {
let mut compositor = Compositor::new(width, height);
for (i, layer) in self.layers.iter().enumerate().rev() {
let focused = self.layer_focus.get(i).copied().flatten();
let rendered = layer.render(width, height, focused);
compositor.add_layer(&rendered, &layer.shadow);
}
compositor.finalize()
}
pub fn handle_input(&mut self, event: &crate::events::Event) {
if let Some(ref mut _modal) = self.modal &&
let crate::events::Event::Key(key) = event &&
key.code == crossterm::event::KeyCode::Esc
{
self.dismiss_modal();
return;
}
if let Some(ref mut modal) = self.modal {
modal.handle_input(event);
return;
}
if let crate::events::Event::Key(key) = event {
if key.code == crossterm::event::KeyCode::Tab {
if self.try_handle_focused(event) {
return;
}
self.cycle_focus(1);
return;
}
if key.code == crossterm::event::KeyCode::BackTab {
if self.try_handle_focused(event) {
return;
}
self.cycle_focus(-1);
return;
}
}
if self.try_handle_focused(event) {
return;
}
let focused_layer = self.focused_layer;
for (layer_idx, layer) in self.layers.iter_mut().enumerate() {
for (component_idx, child) in layer.components.iter_mut().enumerate() {
if layer_idx == focused_layer &&
Some(component_idx) == self.layer_focus.get(layer_idx).copied().flatten()
{
continue;
}
let result = child.handle_input(event);
if !matches!(result, crate::InputResult::Ignored) {
return;
}
}
}
}
fn try_handle_focused(&mut self, event: &crate::events::Event) -> bool {
if let Some(layer_idx) = self.focused_layer_safe() &&
let Some(component_idx) = self.layer_focus.get(layer_idx).copied().flatten() &&
component_idx < self.layers[layer_idx].components.len()
{
let result = self.layers[layer_idx].components[component_idx].handle_input(event);
if !matches!(result, crate::InputResult::Ignored) {
return true;
}
}
false
}
fn cycle_focus(&mut self, delta: isize) {
let mut focusable: Vec<(usize, usize)> = Vec::new();
for (layer_idx, layer) in self.layers.iter().enumerate() {
for (component_idx, component) in layer.components.iter().enumerate() {
if component.as_focusable().is_some() {
focusable.push((layer_idx, component_idx));
}
}
}
if focusable.is_empty() {
return;
}
let current_layer = self.focused_layer_safe();
let current_component =
current_layer.and_then(|l| self.layer_focus.get(l).copied().flatten());
let current = match current_component.and_then(|c| {
focusable
.iter()
.position(|&(l, comp)| Some(l) == current_layer && comp == c)
}) {
| Some(pos) => pos,
| None => {
self.set_focus_tuple(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_tuple(focusable[new_pos]);
}
fn set_focus_tuple(&mut self, (layer_idx, component_idx): (usize, usize)) {
self.unfocus_current();
self.focused_layer = layer_idx;
if self.layer_focus.len() <= layer_idx {
self.layer_focus.resize(layer_idx + 1, None);
}
self.layer_focus[layer_idx] = Some(component_idx);
if layer_idx < self.layers.len() &&
component_idx < self.layers[layer_idx].components.len() &&
let Some(f) = self.layers[layer_idx].components[component_idx].as_focusable_mut()
{
f.set_focused(true);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
TestTerminal,
components::Text,
layer::{
Layer,
Shadow,
},
layout::{
Constraint,
Layout,
},
};
#[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(),
row: 0,
col: 0,
}],
})
}
}
#[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.layers[0].components.clear();
tui.layers[0]
.components
.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.layer_focus[0], Some(1));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.layer_focus[0], Some(2));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.layer_focus[0], 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.layer_focus[0], Some(0));
tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::BackTab,
crossterm::event::KeyModifiers::empty(),
)));
assert_eq!(tui.layer_focus[0], 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.layer_focus[0], 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(), 24, "expected 24 padded lines");
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::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.iter().all(|line| line.trim_end().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.layer_focus[0], Some(0));
tui.show_modal(Box::new(Text::new("modal", 0, 0)));
tui.dismiss_modal();
assert_eq!(tui.layer_focus[0], 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();
}
#[test]
fn tui_add_layer_count_and_mut() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
assert_eq!(tui.layer_count(), 1);
let idx = tui.add_layer(Layer::with_component(Box::new(Text::new("l1", 0, 0))));
assert_eq!(idx, 1);
assert_eq!(tui.layer_count(), 2);
if let Some(layer) = tui.layer_mut(1) {
layer.shadow = Shadow::Dim {
style: "\x1b[2m".into(),
};
}
assert!(tui.layer_mut(5).is_none());
}
#[test]
fn tui_insert_layer() {
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.set_focused_layer(0);
tui.insert_layer(
0,
Layer::with_component(Box::new(Text::new("inserted", 0, 0))),
);
assert_eq!(tui.layer_count(), 2);
assert_eq!(tui.focused_layer, 1);
}
#[test]
fn tui_remove_layer() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.add_layer(Layer::with_component(Box::new(Text::new("top", 0, 0))));
let removed = tui.remove_layer(1);
assert!(removed.is_some());
assert_eq!(tui.layer_count(), 1);
assert!(tui.remove_layer(5).is_none());
}
#[test]
fn tui_set_focused_layer() {
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.add_layer(Layer::with_component(Box::new(list)));
tui.set_focused_layer(1);
assert_eq!(tui.focused_layer, 1);
tui.set_focused_layer(99);
assert_eq!(tui.focused_layer, 1);
}
#[test]
fn tui_clear_children() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.mount(Box::new(Text::new("child", 0, 0)));
tui.clear_children();
assert!(tui.layers[0].components.is_empty());
assert_eq!(tui.layer_focus[0], None);
}
#[test]
fn tui_clear_overlays() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.add_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.clear_overlays();
assert!(tui.overlays.is_empty());
}
#[test]
fn tui_clear_layout() {
let term = TestTerminal::new(80, 24);
let mut tui = TUI::new(Box::new(term));
tui.set_layout(Layout::vertical([Constraint::Length(1)]));
tui.clear_layout();
assert!(tui.layers[0].layout.is_none());
}
#[test]
fn tui_dismiss_focusable_modal() {
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.show_modal(Box::new(list));
assert!(tui.modal_active());
tui.dismiss_modal();
assert!(!tui.modal_active());
}
}