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#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum Anchor {
30 Center,
32 TopLeft,
34 TopRight,
36 BottomLeft,
38 BottomRight,
40 TopCenter,
42 BottomCenter,
44 LeftCenter,
46 RightCenter,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum OverlayPosition {
53 Anchor(Anchor),
55 At(u16, u16),
57 Percent(String, String),
59}
60
61#[derive(Debug, Clone)]
63pub struct OverlayConstraints {
64 pub min_width: u16,
66 pub max_height: u16,
68 pub margin: u16,
70 pub offset_x: i16,
72 pub offset_y: i16,
74 pub visible: Option<fn(u16, u16) -> bool>,
76}
77
78pub use crate::layout::Rect;
79
80pub struct Overlay {
82 pub content: Box<dyn Component>,
84 pub position: OverlayPosition,
86 pub constraints: OverlayConstraints,
88}
89
90impl Overlay {
91 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
159pub struct TUI {
179 terminal: Box<dyn Terminal>,
180 layers: Vec<Layer>,
184 layer_focus: Vec<Option<usize>>,
186 focused_layer: usize,
188 pre_modal_layer: Option<usize>,
190 overlays: Vec<Overlay>,
192 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 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 pub fn terminal(&self) -> &dyn Terminal {
220 &*self.terminal
221 }
222
223 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 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 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 pub fn add_overlay(&mut self, overlay: Overlay) {
276 self.overlays.push(overlay);
277 }
278
279 pub fn clear_overlays(&mut self) {
281 self.overlays.clear();
282 }
283
284 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 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 pub fn modal_active(&self) -> bool {
316 self.modal.is_some()
317 }
318
319 pub fn set_layout(&mut self, layout: Layout) {
322 self.layers[0].set_layout(layout);
323 }
324
325 pub fn clear_layout(&mut self) {
327 self.layers[0].layout = None;
328 }
329
330 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 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 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 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 pub fn layer_mut(&mut self, index: usize) -> Option<&mut Layer> {
385 self.layers.get_mut(index)
386 }
387
388 pub fn layer_count(&self) -> usize {
390 self.layers.len()
391 }
392
393 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 pub fn stop(&mut self) -> io::Result<()> {
404 self.terminal.stop()
405 }
406
407 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 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 if let Some(ref modal) = self.modal &&
458 let Ok(rendered) = modal.render(width)
459 {
460 let modal_h = rendered.lines.len() as u16;
461 let modal_w =
462 crate::utils::visible_width(rendered.lines.first().unwrap_or(&String::new()))
463 as u16;
464 let row = (height.saturating_sub(modal_h)) / 2;
465 let col = (width.saturating_sub(modal_w)) / 2;
466 rendered.blit_onto(&mut screen, row, col);
467 }
468
469 let current_ids: std::collections::HashSet<u32> =
470 screen.images.iter().map(|i| i.id).collect();
471 for id in &self.previous_image_ids {
472 if !current_ids.contains(id) {
473 try_io!(self.terminal.write(&delete_kitty_image(*id)));
474 }
475 }
476 self.previous_image_ids = current_ids;
477
478 try_io!(self.renderer.render(&mut *self.terminal, &screen));
479
480 if let Some((row, col)) = screen.cursor {
481 try_io!(self.terminal.move_cursor(row as u16, col as u16));
482 if self.hardware_cursor {
483 try_io!(self.terminal.show_cursor());
484 } else {
485 try_io!(self.terminal.hide_cursor());
486 }
487 }
488
489 Ok(())
490 }
491
492 #[cfg(test)]
495 fn compose_screen(&self, width: u16, height: u16) -> crate::renderer::Rendered {
496 let mut compositor = Compositor::new(width, height);
497 for (i, layer) in self.layers.iter().enumerate().rev() {
498 let focused = self.layer_focus.get(i).copied().flatten();
499 let rendered = layer.render(width, height, focused);
500 compositor.add_layer(&rendered, &layer.shadow);
501 }
502 compositor.finalize()
503 }
504
505 pub fn handle_input(&mut self, event: &crate::events::Event) {
510 if let Some(ref mut _modal) = self.modal &&
513 let crate::events::Event::Key(key) = event &&
514 key.code == crossterm::event::KeyCode::Esc
515 {
516 self.dismiss_modal();
517 return;
518 }
519 if let Some(ref mut modal) = self.modal {
520 modal.handle_input(event);
521 return;
522 }
523
524 if let crate::events::Event::Key(key) = event {
527 if key.code == crossterm::event::KeyCode::Tab {
528 if self.try_handle_focused(event) {
529 return;
530 }
531 self.cycle_focus(1);
532 return;
533 }
534 if key.code == crossterm::event::KeyCode::BackTab {
535 if self.try_handle_focused(event) {
536 return;
537 }
538 self.cycle_focus(-1);
539 return;
540 }
541 }
542
543 if self.try_handle_focused(event) {
544 return;
545 }
546
547 let focused_layer = self.focused_layer;
549 for (layer_idx, layer) in self.layers.iter_mut().enumerate() {
550 for (component_idx, child) in layer.components.iter_mut().enumerate() {
551 if layer_idx == focused_layer &&
552 Some(component_idx) == self.layer_focus.get(layer_idx).copied().flatten()
553 {
554 continue;
555 }
556 let result = child.handle_input(event);
557 if !matches!(result, crate::InputResult::Ignored) {
558 return;
559 }
560 }
561 }
562 }
563
564 fn try_handle_focused(&mut self, event: &crate::events::Event) -> bool {
565 if let Some(layer_idx) = self.focused_layer_safe() &&
566 let Some(component_idx) = self.layer_focus.get(layer_idx).copied().flatten() &&
567 component_idx < self.layers[layer_idx].components.len()
568 {
569 let result = self.layers[layer_idx].components[component_idx].handle_input(event);
570 if !matches!(result, crate::InputResult::Ignored) {
571 return true;
572 }
573 }
574 false
575 }
576
577 fn cycle_focus(&mut self, delta: isize) {
579 let mut focusable: Vec<(usize, usize)> = Vec::new();
580 for (layer_idx, layer) in self.layers.iter().enumerate() {
581 for (component_idx, component) in layer.components.iter().enumerate() {
582 if component.as_focusable().is_some() {
583 focusable.push((layer_idx, component_idx));
584 }
585 }
586 }
587 if focusable.is_empty() {
588 return;
589 }
590
591 let current_layer = self.focused_layer_safe();
592 let current_component =
593 current_layer.and_then(|l| self.layer_focus.get(l).copied().flatten());
594 let current = match current_component.and_then(|c| {
595 focusable
596 .iter()
597 .position(|&(l, comp)| Some(l) == current_layer && comp == c)
598 }) {
599 | Some(pos) => pos,
600 | None => {
601 self.set_focus_tuple(focusable[0]);
602 return;
603 },
604 };
605
606 let new_pos = if delta >= 0 {
607 (current + delta as usize) % focusable.len()
608 } else {
609 let d = (-delta) as usize % focusable.len();
610 (current + focusable.len() - d) % focusable.len()
611 };
612 self.set_focus_tuple(focusable[new_pos]);
613 }
614
615 fn set_focus_tuple(&mut self, (layer_idx, component_idx): (usize, usize)) {
616 self.unfocus_current();
617 self.focused_layer = layer_idx;
618 if self.layer_focus.len() <= layer_idx {
619 self.layer_focus.resize(layer_idx + 1, None);
620 }
621 self.layer_focus[layer_idx] = Some(component_idx);
622 if layer_idx < self.layers.len() &&
623 component_idx < self.layers[layer_idx].components.len() &&
624 let Some(f) = self.layers[layer_idx].components[component_idx].as_focusable_mut()
625 {
626 f.set_focused(true);
627 }
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use crate::{
635 TestTerminal,
636 components::Text,
637 layer::{
638 Layer,
639 Shadow,
640 },
641 layout::{
642 Constraint,
643 Layout,
644 },
645 };
646
647 #[test]
648 fn tui_set_focus_invalid_index() {
649 let term = TestTerminal::new(80, 24);
650 let mut tui = TUI::new(Box::new(term));
651 tui.mount(Box::new(Text::new("a", 0, 0)));
652 tui.set_focus(5); }
654
655 #[test]
656 fn tui_handle_input_no_focus() {
657 let term = TestTerminal::new(80, 24);
658 let mut tui = TUI::new(Box::new(term));
659 tui.handle_input(&crate::events::Event::Resize(10, 10)); }
661
662 #[test]
663 fn tui_render_with_overlay() {
664 let term = TestTerminal::new(80, 24);
665 let mut tui = TUI::new(Box::new(term));
666 tui.mount(Box::new(Text::new("hello", 0, 0)));
667 let overlay = Overlay {
668 content: Box::new(Text::new("popup", 0, 0)),
669 position: OverlayPosition::Anchor(Anchor::Center),
670 constraints: OverlayConstraints {
671 min_width: 5,
672 max_height: 3,
673 margin: 1,
674 offset_x: 0,
675 offset_y: 0,
676 visible: None,
677 },
678 };
679 tui.overlays.push(overlay);
680 tui.render_frame().unwrap();
681 }
682
683 #[test]
684 fn tui_full_redraw_on_resize() {
685 let term = TestTerminal::new(80, 24);
686 let mut tui = TUI::new(Box::new(term));
687 tui.mount(Box::new(Text::new("hello", 0, 0)));
688 tui.render_frame().unwrap();
689 let new_term = TestTerminal::new(100, 30);
691 tui.terminal = Box::new(new_term);
692 tui.render_frame().unwrap();
693 }
694
695 struct ImageComponent;
696 impl Component for ImageComponent {
697 fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
698 Ok(Rendered {
699 lines: vec!["img".into()],
700 cursor: None,
701 images: vec![crate::renderer::ImageCommand {
702 id: 1,
703 data: "data".into(),
704 row: 0,
705 col: 0,
706 }],
707 })
708 }
709 }
710
711 #[test]
712 fn tui_image_cleanup() {
713 let term = TestTerminal::new(80, 24);
714 let mut tui = TUI::new(Box::new(term));
715 tui.mount(Box::new(ImageComponent));
716 tui.render_frame().unwrap();
717 tui.layers[0].components.clear();
719 tui.layers[0]
720 .components
721 .push(Box::new(Text::new("text", 0, 0)));
722 tui.render_frame().unwrap();
723 }
725
726 #[test]
727 fn tui_hardware_cursor() {
728 unsafe {
731 std::env::set_var("PHOTON_UI_HARDWARE_CURSOR", "1");
732 }
733 let term = TestTerminal::new(80, 24);
734 let mut tui = TUI::new(Box::new(term));
735 tui.mount(Box::new(Text::new("hello", 0, 0)));
736 tui.render_frame().unwrap();
737 unsafe {
739 std::env::remove_var("PHOTON_UI_HARDWARE_CURSOR");
740 }
741 }
742
743 #[test]
744 fn tui_tab_cycles_focus() {
745 let term = TestTerminal::new(80, 24);
746 let mut tui = TUI::new(Box::new(term));
747 tui.mount(Box::new(Text::new("a", 0, 0))); let list = crate::components::SelectList::new(vec!["x".into()], 1);
749 tui.mount(Box::new(list));
750 let input = crate::components::Input::new();
751 tui.mount(Box::new(input));
752
753 assert_eq!(tui.layer_focus[0], Some(0));
755
756 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
758 crossterm::event::KeyCode::Tab,
759 crossterm::event::KeyModifiers::empty(),
760 )));
761 assert_eq!(tui.layer_focus[0], Some(1));
762
763 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
765 crossterm::event::KeyCode::Tab,
766 crossterm::event::KeyModifiers::empty(),
767 )));
768 assert_eq!(tui.layer_focus[0], Some(2));
769
770 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
772 crossterm::event::KeyCode::Tab,
773 crossterm::event::KeyModifiers::empty(),
774 )));
775 assert_eq!(tui.layer_focus[0], Some(1));
776 }
777
778 #[test]
779 fn tui_backtab_cycles_backward() {
780 let term = TestTerminal::new(80, 24);
781 let mut tui = TUI::new(Box::new(term));
782 let list = crate::components::SelectList::new(vec!["x".into()], 1);
783 tui.mount(Box::new(list));
784 let input = crate::components::Input::new();
785 tui.mount(Box::new(input));
786
787 assert_eq!(tui.layer_focus[0], Some(0));
789
790 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
792 crossterm::event::KeyCode::BackTab,
793 crossterm::event::KeyModifiers::empty(),
794 )));
795 assert_eq!(tui.layer_focus[0], Some(1));
796 }
797
798 #[test]
799 fn tui_cycle_focus_single_focusable() {
800 let term = TestTerminal::new(80, 24);
801 let mut tui = TUI::new(Box::new(term));
802 let list = crate::components::SelectList::new(vec!["x".into()], 1);
803 tui.mount(Box::new(list));
804
805 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
807 crossterm::event::KeyCode::Tab,
808 crossterm::event::KeyModifiers::empty(),
809 )));
810 assert_eq!(tui.layer_focus[0], Some(0));
811 }
812
813 #[test]
814 fn tui_no_focusables_no_panic() {
815 let term = TestTerminal::new(80, 24);
816 let mut tui = TUI::new(Box::new(term));
817 tui.mount(Box::new(Text::new("hello", 0, 0))); tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
820 crossterm::event::KeyCode::Tab,
821 crossterm::event::KeyModifiers::empty(),
822 )));
823 }
824
825 #[test]
826 fn tui_terminal_borrow() {
827 let term = TestTerminal::new(80, 24);
828 let tui = TUI::new(Box::new(term));
829 let _ = tui.terminal();
830 }
831
832 #[test]
833 fn tui_handle_input_fallthrough() {
834 let term = TestTerminal::new(80, 24);
835 let mut tui = TUI::new(Box::new(term));
836 tui.mount(Box::new(Text::new("a", 0, 0)));
838 tui.mount(Box::new(Text::new("b", 0, 0)));
839 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
841 crossterm::event::KeyCode::Char('x'),
842 crossterm::event::KeyModifiers::empty(),
843 )));
844 }
845
846 #[test]
847 fn overlay_compute_position_all_anchors() {
848 let constraints = OverlayConstraints {
849 min_width: 5,
850 max_height: 3,
851 margin: 1,
852 offset_x: 0,
853 offset_y: 0,
854 visible: None,
855 };
856 let anchors = vec![
857 Anchor::Center,
858 Anchor::TopLeft,
859 Anchor::TopRight,
860 Anchor::BottomLeft,
861 Anchor::BottomRight,
862 Anchor::TopCenter,
863 Anchor::BottomCenter,
864 Anchor::LeftCenter,
865 Anchor::RightCenter,
866 ];
867 for anchor in anchors {
868 let overlay = Overlay {
869 content: Box::new(Text::new("test", 0, 0)),
870 position: OverlayPosition::Anchor(anchor),
871 constraints: constraints.clone(),
872 };
873 let rect = overlay.compute_position(80, 24, 10, 2);
874 assert!(rect.is_some(), "anchor {:?} should produce a rect", anchor);
875 }
876 }
877
878 #[test]
879 fn overlay_compute_position_at() {
880 let overlay = Overlay {
881 content: Box::new(Text::new("test", 0, 0)),
882 position: OverlayPosition::At(5, 10),
883 constraints: OverlayConstraints {
884 min_width: 5,
885 max_height: 3,
886 margin: 0,
887 offset_x: 0,
888 offset_y: 0,
889 visible: None,
890 },
891 };
892 let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
893 assert_eq!(rect.y, 5);
894 assert_eq!(rect.x, 10);
895 }
896
897 #[test]
898 fn overlay_compute_position_percent() {
899 let overlay = Overlay {
900 content: Box::new(Text::new("test", 0, 0)),
901 position: OverlayPosition::Percent("50%".into(), "25%".into()),
902 constraints: OverlayConstraints {
903 min_width: 5,
904 max_height: 3,
905 margin: 0,
906 offset_x: 0,
907 offset_y: 0,
908 visible: None,
909 },
910 };
911 let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
912 assert_eq!(rect.y, 10);
913 assert_eq!(rect.x, 50);
914 }
915
916 #[test]
917 fn overlay_compute_position_percent_invalid() {
918 let overlay = Overlay {
919 content: Box::new(Text::new("test", 0, 0)),
920 position: OverlayPosition::Percent("abc".into(), "xyz".into()),
921 constraints: OverlayConstraints {
922 min_width: 5,
923 max_height: 3,
924 margin: 0,
925 offset_x: 0,
926 offset_y: 0,
927 visible: None,
928 },
929 };
930 let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
931 assert_eq!(rect.y, 0);
932 assert_eq!(rect.x, 0);
933 }
934
935 #[test]
936 fn overlay_compute_position_visible_false() {
937 let overlay = Overlay {
938 content: Box::new(Text::new("test", 0, 0)),
939 position: OverlayPosition::Anchor(Anchor::Center),
940 constraints: OverlayConstraints {
941 min_width: 5,
942 max_height: 3,
943 margin: 0,
944 offset_x: 0,
945 offset_y: 0,
946 visible: Some(|_w, _h| false),
947 },
948 };
949 assert!(overlay.compute_position(80, 24, 10, 2).is_none());
950 }
951
952 #[test]
953 fn overlay_compute_position_with_offset() {
954 let overlay = Overlay {
955 content: Box::new(Text::new("test", 0, 0)),
956 position: OverlayPosition::At(10, 10),
957 constraints: OverlayConstraints {
958 min_width: 5,
959 max_height: 3,
960 margin: 0,
961 offset_x: 5,
962 offset_y: -3,
963 visible: None,
964 },
965 };
966 let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
967 assert_eq!(rect.y, 7);
968 assert_eq!(rect.x, 15);
969 }
970
971 #[test]
972 fn overlay_compute_position_negative_offset_clamped() {
973 let overlay = Overlay {
974 content: Box::new(Text::new("test", 0, 0)),
975 position: OverlayPosition::At(0, 0),
976 constraints: OverlayConstraints {
977 min_width: 5,
978 max_height: 3,
979 margin: 0,
980 offset_x: -5,
981 offset_y: -5,
982 visible: None,
983 },
984 };
985 let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
986 assert_eq!(rect.y, 0);
987 assert_eq!(rect.x, 0);
988 }
989
990 #[test]
991 fn overlay_compute_position_size_clamped() {
992 let overlay = Overlay {
993 content: Box::new(Text::new("test", 0, 0)),
994 position: OverlayPosition::At(70, 20),
995 constraints: OverlayConstraints {
996 min_width: 5,
997 max_height: 3,
998 margin: 0,
999 offset_x: 0,
1000 offset_y: 0,
1001 visible: None,
1002 },
1003 };
1004 let rect = overlay.compute_position(80, 24, 20, 10).unwrap();
1005 assert_eq!(rect.width, 20);
1007 assert_eq!(rect.height, 0);
1010 }
1011
1012 struct CursorComponent;
1013 impl Component for CursorComponent {
1014 fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
1015 Ok(Rendered {
1016 lines: vec!["cursor".into()],
1017 cursor: Some((0, 3)),
1018 images: vec![],
1019 })
1020 }
1021 }
1022
1023 #[test]
1024 fn tui_render_frame_with_cursor() {
1025 let term = TestTerminal::new(80, 24);
1026 let mut tui = TUI::new(Box::new(term));
1027 tui.mount(Box::new(CursorComponent));
1028 tui.render_frame().unwrap();
1029 }
1030
1031 #[test]
1032 fn tui_demo_layout_exact() {
1033 let term = TestTerminal::new(80, 24);
1034 let mut tui = TUI::new(Box::new(term));
1035
1036 tui.mount(Box::new(Text::new("Photon UI Demo", 2, 1)));
1037 tui.mount(Box::new(Text::new(
1038 "j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit",
1039 2, 0,
1040 )));
1041 let list = crate::components::SelectList::new(
1042 vec![
1043 "Option 1: Hello world".into(),
1044 "Option 2: Foo bar baz".into(),
1045 "Option 3: Lorem ipsum".into(),
1046 "Option 4: Vim bindings".into(),
1047 "Option 5: Blazing fast".into(),
1048 ],
1049 3,
1050 );
1051 tui.mount(Box::new(list));
1052 let input = crate::components::Input::new();
1053 tui.mount(Box::new(input));
1054 tui.set_focus(2);
1055
1056 let screen = tui.compose_screen(80, 24);
1057
1058 assert_eq!(screen.lines.len(), 24, "expected 24 padded lines");
1068 assert_eq!(
1069 screen.lines[0].trim_end(),
1070 "",
1071 "row 0 should be blank from Text1 pad_y"
1072 );
1073 assert!(
1074 screen.lines[1].contains("Photon UI Demo"),
1075 "row 1 should contain header: got {:?}",
1076 screen.lines[1]
1077 );
1078 assert_eq!(
1079 screen.lines[2].trim_end(),
1080 "",
1081 "row 2 should be blank from Text1 pad_y"
1082 );
1083 assert!(
1084 screen.lines[3].contains("j/k = navigate"),
1085 "row 3 should contain keybindings: got {:?}",
1086 screen.lines[3]
1087 );
1088 assert!(
1089 screen.lines[4].contains("> Option 1"),
1090 "row 4 should be selected list item: got {:?}",
1091 screen.lines[4]
1092 );
1093 assert!(
1094 screen.lines[5].contains(" Option 2"),
1095 "row 5 should be unselected list item: got {:?}",
1096 screen.lines[5]
1097 );
1098 assert!(
1099 screen.lines[6].contains(" Option 3"),
1100 "row 6 should be unselected list item: got {:?}",
1101 screen.lines[6]
1102 );
1103 assert_eq!(
1104 screen.lines[7].trim_end(),
1105 "",
1106 "row 7 should be empty input line"
1107 );
1108 }
1109
1110 #[test]
1113 fn tui_reset_clears_all_and_schedules_redraw() {
1114 let term = TestTerminal::new(80, 24);
1115 let mut tui = TUI::new(Box::new(term));
1116
1117 tui.mount(Box::new(crate::components::Text::new("hello", 0, 0)));
1118 tui.set_focus(0);
1119 tui.add_overlay(Overlay {
1120 content: Box::new(crate::components::Text::new("popup", 0, 0)),
1121 position: OverlayPosition::Anchor(Anchor::Center),
1122 constraints: OverlayConstraints {
1123 min_width: 10,
1124 max_height: 3,
1125 margin: 2,
1126 offset_x: 0,
1127 offset_y: 0,
1128 visible: None,
1129 },
1130 });
1131 tui.set_layout(crate::layout::Layout::vertical([
1132 crate::layout::Constraint::Length(1),
1133 ]));
1134 tui.render_frame().unwrap();
1135
1136 let screen_before = tui.compose_screen(80, 24);
1138 assert!(
1139 !screen_before.lines.is_empty(),
1140 "precondition: screen should have content"
1141 );
1142
1143 tui.reset();
1144
1145 let screen = tui.compose_screen(80, 24);
1147 assert!(
1148 screen.lines.iter().all(|line| line.trim_end().is_empty()),
1149 "reset should clear all children"
1150 );
1151
1152 tui.render_frame().unwrap();
1155 }
1156
1157 #[test]
1158 fn tui_show_modal_captures_input() {
1159 let term = TestTerminal::new(80, 24);
1160 let mut tui = TUI::new(Box::new(term));
1161 tui.mount(Box::new(Text::new("background", 0, 0)));
1162 tui.set_focus(0);
1163
1164 let modal_content = Text::new("modal text", 0, 0);
1165 tui.show_modal(Box::new(modal_content));
1166 assert!(tui.modal_active());
1167
1168 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
1170 crossterm::event::KeyCode::Esc,
1171 crossterm::event::KeyModifiers::empty(),
1172 )));
1173 assert!(!tui.modal_active());
1174 }
1175
1176 #[test]
1177 fn tui_modal_restores_focus_on_dismiss() {
1178 let term = TestTerminal::new(80, 24);
1179 let mut tui = TUI::new(Box::new(term));
1180 let list = crate::components::SelectList::new(vec!["x".into()], 1);
1181 tui.mount(Box::new(list));
1182 assert_eq!(tui.layer_focus[0], Some(0));
1183
1184 tui.show_modal(Box::new(Text::new("modal", 0, 0)));
1185 tui.dismiss_modal();
1186 assert_eq!(tui.layer_focus[0], Some(0));
1187 }
1188
1189 #[test]
1190 fn tui_modal_renders_without_panic() {
1191 let term = TestTerminal::new(80, 24);
1192 let mut tui = TUI::new(Box::new(term));
1193 tui.mount(Box::new(Text::new("background", 0, 0)));
1194
1195 let modal_content = crate::components::Modal::new(Box::new(Text::new("hello", 0, 0)));
1196 tui.show_modal(Box::new(modal_content));
1197 tui.render_frame().unwrap();
1199 }
1200
1201 #[test]
1202 fn tui_add_layer_count_and_mut() {
1203 let term = TestTerminal::new(80, 24);
1204 let mut tui = TUI::new(Box::new(term));
1205 assert_eq!(tui.layer_count(), 1);
1206
1207 let idx = tui.add_layer(Layer::with_component(Box::new(Text::new("l1", 0, 0))));
1208 assert_eq!(idx, 1);
1209 assert_eq!(tui.layer_count(), 2);
1210
1211 if let Some(layer) = tui.layer_mut(1) {
1212 layer.shadow = Shadow::Dim {
1213 style: "\x1b[2m".into(),
1214 };
1215 }
1216 assert!(tui.layer_mut(5).is_none());
1217 }
1218
1219 #[test]
1220 fn tui_insert_layer() {
1221 let term = TestTerminal::new(80, 24);
1222 let mut tui = TUI::new(Box::new(term));
1223 let list = crate::components::SelectList::new(vec!["x".into()], 1);
1224 tui.mount(Box::new(list));
1225 tui.set_focused_layer(0);
1226
1227 tui.insert_layer(
1228 0,
1229 Layer::with_component(Box::new(Text::new("inserted", 0, 0))),
1230 );
1231 assert_eq!(tui.layer_count(), 2);
1232 assert_eq!(tui.focused_layer, 1);
1233 }
1234
1235 #[test]
1236 fn tui_remove_layer() {
1237 let term = TestTerminal::new(80, 24);
1238 let mut tui = TUI::new(Box::new(term));
1239 tui.add_layer(Layer::with_component(Box::new(Text::new("top", 0, 0))));
1240
1241 let removed = tui.remove_layer(1);
1242 assert!(removed.is_some());
1243 assert_eq!(tui.layer_count(), 1);
1244 assert!(tui.remove_layer(5).is_none());
1245 }
1246
1247 #[test]
1248 fn tui_set_focused_layer() {
1249 let term = TestTerminal::new(80, 24);
1250 let mut tui = TUI::new(Box::new(term));
1251 let list = crate::components::SelectList::new(vec!["x".into()], 1);
1252 tui.add_layer(Layer::with_component(Box::new(list)));
1253
1254 tui.set_focused_layer(1);
1255 assert_eq!(tui.focused_layer, 1);
1256
1257 tui.set_focused_layer(99);
1258 assert_eq!(tui.focused_layer, 1);
1259 }
1260
1261 #[test]
1262 fn tui_clear_children() {
1263 let term = TestTerminal::new(80, 24);
1264 let mut tui = TUI::new(Box::new(term));
1265 tui.mount(Box::new(Text::new("child", 0, 0)));
1266 tui.clear_children();
1267 assert!(tui.layers[0].components.is_empty());
1268 assert_eq!(tui.layer_focus[0], None);
1269 }
1270
1271 #[test]
1272 fn tui_clear_overlays() {
1273 let term = TestTerminal::new(80, 24);
1274 let mut tui = TUI::new(Box::new(term));
1275 tui.add_overlay(Overlay {
1276 content: Box::new(Text::new("popup", 0, 0)),
1277 position: OverlayPosition::Anchor(Anchor::Center),
1278 constraints: OverlayConstraints {
1279 min_width: 5,
1280 max_height: 3,
1281 margin: 1,
1282 offset_x: 0,
1283 offset_y: 0,
1284 visible: None,
1285 },
1286 });
1287 tui.clear_overlays();
1288 assert!(tui.overlays.is_empty());
1289 }
1290
1291 #[test]
1292 fn tui_clear_layout() {
1293 let term = TestTerminal::new(80, 24);
1294 let mut tui = TUI::new(Box::new(term));
1295 tui.set_layout(Layout::vertical([Constraint::Length(1)]));
1296 tui.clear_layout();
1297 assert!(tui.layers[0].layout.is_none());
1298 }
1299
1300 #[test]
1301 fn tui_dismiss_focusable_modal() {
1302 let term = TestTerminal::new(80, 24);
1303 let mut tui = TUI::new(Box::new(term));
1304 let list = crate::components::SelectList::new(vec!["x".into()], 1);
1305 tui.show_modal(Box::new(list));
1306 assert!(tui.modal_active());
1307 tui.dismiss_modal();
1308 assert!(!tui.modal_active());
1309 }
1310}