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