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