1use std::io;
2
3use crate::{
4 Component,
5 layout::layout::Layout,
6 renderer::{
7 RenderStrategy,
8 Rendered,
9 Renderer,
10 },
11 terminal::Terminal,
12};
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum Anchor {
17 Center,
19 TopLeft,
21 TopRight,
23 BottomLeft,
25 BottomRight,
27 TopCenter,
29 BottomCenter,
31 LeftCenter,
33 RightCenter,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub enum OverlayPosition {
40 Anchor(Anchor),
42 At(u16, u16),
44 Percent(String, String),
46}
47
48#[derive(Debug, Clone)]
50pub struct OverlayConstraints {
51 pub min_width: u16,
53 pub max_height: u16,
55 pub margin: u16,
57 pub offset_x: i16,
59 pub offset_y: i16,
61 pub visible: Option<fn(u16, u16) -> bool>,
63}
64
65pub use crate::layout::Rect;
66
67pub struct Overlay {
69 pub content: Box<dyn Component>,
71 pub position: OverlayPosition,
73 pub constraints: OverlayConstraints,
75}
76
77impl Overlay {
78 pub fn compute_position(
83 &self,
84 term_w: u16,
85 term_h: u16,
86 content_w: u16,
87 content_h: u16,
88 ) -> Option<Rect> {
89 let w = content_w.max(self.constraints.min_width);
90 let h = content_h.min(self.constraints.max_height).max(1);
91
92 if let Some(vis) = self.constraints.visible {
93 if !vis(term_w, term_h) {
94 return None;
95 }
96 }
97
98 let (row, col) = match &self.position {
99 | OverlayPosition::Anchor(anchor) => {
100 let r = match anchor {
101 | Anchor::Center | Anchor::LeftCenter | Anchor::RightCenter => {
102 (term_h.saturating_sub(h)) / 2
103 },
104 | Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
105 self.constraints.margin
106 },
107 | Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
108 term_h.saturating_sub(h + self.constraints.margin)
109 },
110 };
111 let c = match anchor {
112 | Anchor::Center | Anchor::TopCenter | Anchor::BottomCenter => {
113 (term_w.saturating_sub(w)) / 2
114 },
115 | Anchor::TopLeft | Anchor::BottomLeft | Anchor::LeftCenter => {
116 self.constraints.margin
117 },
118 | Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter => {
119 term_w.saturating_sub(w + self.constraints.margin)
120 },
121 };
122 (r, c)
123 },
124 | OverlayPosition::At(r, c) => (*r, *c),
125 | OverlayPosition::Percent(px, py) => {
126 let parse_pct = |s: &str| -> u16 {
127 s.trim_end_matches('%').parse::<f64>().unwrap_or(0.0) as u16
128 };
129 let pct_x = parse_pct(px);
130 let pct_y = parse_pct(py);
131 let r = (term_h as f64 * pct_y as f64 / 100.0) as u16;
132 let c = (term_w as f64 * pct_x as f64 / 100.0) as u16;
133 (r, c)
134 },
135 };
136
137 Some(Rect {
138 y: (row as i16 + self.constraints.offset_y).max(0) as u16,
139 x: (col as i16 + self.constraints.offset_x).max(0) as u16,
140 width: w.min(term_w.saturating_sub(col)),
141 height: h.min(term_h.saturating_sub(row)),
142 })
143 }
144}
145
146pub struct TUI {
166 terminal: Box<dyn Terminal>,
167 children: Vec<Box<dyn Component>>,
168 overlays: Vec<Overlay>,
169 modal: Option<Box<dyn Component>>,
170 focused_index: Option<usize>,
171 pre_modal_focus: Option<usize>,
172 renderer: Renderer,
173 size: (u16, u16),
174 previous_image_ids: std::collections::HashSet<u32>,
175 hardware_cursor: bool,
176 layout: Option<Layout>,
177}
178
179impl TUI {
180 pub fn new(terminal: Box<dyn Terminal>) -> Self {
182 Self {
183 terminal,
184 children: Vec::new(),
185 overlays: Vec::new(),
186 modal: None,
187 focused_index: None,
188 pre_modal_focus: None,
189 renderer: Renderer::new(),
190 size: (80, 24),
191 previous_image_ids: std::collections::HashSet::new(),
192 hardware_cursor: std::env::var("PHOTON_UI_HARDWARE_CURSOR").is_ok(),
193 layout: None,
194 }
195 }
196
197 pub fn terminal(&self) -> &dyn Terminal {
199 &*self.terminal
200 }
201
202 pub fn mount(&mut self, component: Box<dyn Component>) {
207 let idx = self.children.len();
208 self.children.push(component);
209 if self.focused_index.is_none() {
210 self.set_focus(idx);
211 }
212 }
213
214 pub fn set_focus(&mut self, index: usize) {
218 if let Some(old) = self.focused_index {
219 if old < self.children.len() {
220 if let Some(f) = self.children[old].as_focusable_mut() {
221 f.set_focused(false);
222 }
223 }
224 }
225 self.focused_index = Some(index);
226 if index < self.children.len() {
227 if let Some(f) = self.children[index].as_focusable_mut() {
228 f.set_focused(true);
229 }
230 }
231 }
232
233 pub fn clear_children(&mut self) {
235 self.children.clear();
236 self.focused_index = None;
237 }
238
239 pub fn add_overlay(&mut self, overlay: Overlay) {
241 self.overlays.push(overlay);
242 }
243
244 pub fn clear_overlays(&mut self) {
246 self.overlays.clear();
247 }
248
249 pub fn show_modal(&mut self, modal: Box<dyn Component>) {
255 self.pre_modal_focus = self.focused_index;
256 self.modal = Some(modal);
257 if let Some(ref mut m) = self.modal {
258 if let Some(f) = m.as_focusable_mut() {
259 f.set_focused(true);
260 }
261 }
262 }
263
264 pub fn dismiss_modal(&mut self) {
266 if let Some(ref mut m) = self.modal {
267 if let Some(f) = m.as_focusable_mut() {
268 f.set_focused(false);
269 }
270 }
271 self.modal = None;
272 if let Some(idx) = self.pre_modal_focus {
273 if idx < self.children.len() {
274 self.set_focus(idx);
275 }
276 }
277 self.pre_modal_focus = None;
278 }
279
280 pub fn modal_active(&self) -> bool {
282 self.modal.is_some()
283 }
284
285 pub fn set_layout(&mut self, layout: Layout) {
287 self.layout = Some(layout);
288 }
289
290 pub fn clear_layout(&mut self) {
292 self.layout = None;
293 }
294
295 pub fn reset(&mut self) {
300 self.children.clear();
301 self.focused_index = None;
302 self.pre_modal_focus = None;
303 self.overlays.clear();
304 self.modal = None;
305 self.layout = None;
306 self.renderer
307 .set_strategy(crate::renderer::RenderStrategy::FullRedraw);
308 }
309
310 pub fn stop(&mut self) -> io::Result<()> {
313 self.terminal.stop()
314 }
315
316 pub fn render_frame(&mut self) -> io::Result<()> {
326 let (width, height) = self.terminal.size()?;
327 let size_changed = self.size != (width, height);
328 self.size = (width, height);
329
330 if self.renderer.previous().is_none() {
331 self.renderer.set_strategy(RenderStrategy::FirstRender);
332 } else if size_changed {
333 self.renderer.set_strategy(RenderStrategy::FullRedraw);
334 } else {
335 self.renderer.set_strategy(RenderStrategy::Diff);
336 }
337
338 let mut screen = Rendered::empty();
340 let term_rect = Rect::new(0, 0, width, height);
341
342 if let Some(layout) = &self.layout {
343 let areas = layout.split(term_rect);
344 for (child, area) in self.children.iter().zip(areas.iter()) {
345 if let Ok(rendered) = child.render_rect(*area) {
346 rendered.blit_into_rect(&mut screen, *area);
347 }
348 }
349 } else {
350 let mut row = 0usize;
352 for child in &self.children {
353 if let Ok(rendered) = child.render(width) {
354 for line in &rendered.lines {
355 screen.lines.push(line.clone());
356 }
357 if let Some((r, c)) = rendered.cursor {
358 screen.cursor = Some((row + r, c));
359 }
360 screen.images.extend(rendered.images);
361 row += rendered.lines.len();
362 }
363 }
364 }
365
366 if !self.overlays.is_empty() {
368 while screen.lines.len() < height as usize {
369 screen.lines.push("".to_string());
370 }
371 }
372
373 for overlay in &self.overlays {
374 if let Ok(rendered) = overlay.content.render(width) {
375 if let Some(rect) =
376 overlay.compute_position(width, height, rendered.lines.len() as u16, 1)
377 {
378 rendered.blit_onto(&mut screen, rect.y, rect.x);
379 }
380 }
381 }
382
383 if let Some(ref modal) = self.modal {
385 if let Ok(rendered) = modal.render(width) {
386 let modal_h = rendered.lines.len() as u16;
387 let modal_w =
388 crate::utils::visible_width(rendered.lines.first().unwrap_or(&String::new()))
389 as u16;
390 let row = (height.saturating_sub(modal_h)) / 2;
391 let col = (width.saturating_sub(modal_w)) / 2;
392 rendered.blit_onto(&mut screen, row, col);
393 }
394 }
395
396 let current_ids: std::collections::HashSet<u32> =
397 screen.images.iter().map(|i| i.id).collect();
398 for id in &self.previous_image_ids {
399 if !current_ids.contains(id) {
400 self.terminal
401 .write(&format!("\x1b_Ga=d,d=I,i={}\x1b\\", id))?;
402 }
403 }
404 self.previous_image_ids = current_ids;
405
406 self.renderer.render(&mut *self.terminal, &screen)?;
407
408 if let Some((row, col)) = screen.cursor {
409 self.terminal.move_cursor(row as u16, col as u16)?;
410 if self.hardware_cursor {
411 self.terminal.show_cursor()?;
412 } else {
413 self.terminal.hide_cursor()?;
414 }
415 }
416
417 Ok(())
418 }
419
420 #[cfg(test)]
423 fn compose_screen(&self, width: u16, height: u16) -> crate::renderer::Rendered {
424 let mut screen = crate::renderer::Rendered::empty();
425 let term_rect = Rect::new(0, 0, width, height);
426
427 if let Some(layout) = &self.layout {
428 let areas = layout.split(term_rect);
429 for (child, area) in self.children.iter().zip(areas.iter()) {
430 if let Ok(rendered) = child.render_rect(*area) {
431 rendered.blit_into_rect(&mut screen, *area);
432 }
433 }
434 } else {
435 let mut row = 0usize;
436 for child in &self.children {
437 if let Ok(rendered) = child.render(width) {
438 for line in &rendered.lines {
439 screen.lines.push(line.clone());
440 }
441 if let Some((r, c)) = rendered.cursor {
442 screen.cursor = Some((row + r, c));
443 }
444 row += rendered.lines.len();
445 }
446 }
447 }
448 screen
449 }
450
451 pub fn handle_input(&mut self, event: &crate::events::Event) {
456 if let Some(ref mut modal) = self.modal {
459 if let crate::events::Event::Key(key) = event {
460 if key.code == crossterm::event::KeyCode::Esc {
461 self.dismiss_modal();
462 return;
463 }
464 }
465 modal.handle_input(event);
466 return;
467 }
468
469 if let crate::events::Event::Key(key) = event {
472 if key.code == crossterm::event::KeyCode::Tab {
473 if let Some(idx) = self.focused_index {
474 if idx < self.children.len() {
475 let result = self.children[idx].handle_input(event);
476 if !matches!(result, crate::InputResult::Ignored) {
477 return;
478 }
479 }
480 }
481 self.cycle_focus(1);
482 return;
483 }
484 if key.code == crossterm::event::KeyCode::BackTab {
485 if let Some(idx) = self.focused_index {
486 if idx < self.children.len() {
487 let result = self.children[idx].handle_input(event);
488 if !matches!(result, crate::InputResult::Ignored) {
489 return;
490 }
491 }
492 }
493 self.cycle_focus(-1);
494 return;
495 }
496 }
497
498 if let Some(idx) = self.focused_index {
500 if idx < self.children.len() {
501 let result = self.children[idx].handle_input(event);
502 if !matches!(result, crate::InputResult::Ignored) {
503 return;
504 }
505 }
506 }
507
508 for (i, child) in self.children.iter_mut().enumerate() {
510 if Some(i) == self.focused_index {
511 continue;
512 }
513 let result = child.handle_input(event);
514 if !matches!(result, crate::InputResult::Ignored) {
515 return;
516 }
517 }
518 }
519
520 fn cycle_focus(&mut self, delta: isize) {
522 let focusable: Vec<usize> = self
523 .children
524 .iter()
525 .enumerate()
526 .filter(|(_, c)| c.as_focusable().is_some())
527 .map(|(i, _)| i)
528 .collect();
529 if focusable.is_empty() {
530 return;
531 }
532
533 let current = match self
534 .focused_index
535 .and_then(|idx| focusable.iter().position(|&i| i == idx))
536 {
537 | Some(pos) => pos,
538 | None => {
539 self.set_focus(focusable[0]);
540 return;
541 },
542 };
543
544 let new_pos = if delta >= 0 {
545 (current + delta as usize) % focusable.len()
546 } else {
547 let d = (-delta) as usize % focusable.len();
548 (current + focusable.len() - d) % focusable.len()
549 };
550 self.set_focus(focusable[new_pos]);
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::{
558 TestTerminal,
559 components::Text,
560 };
561
562 #[test]
563 fn tui_set_focus_invalid_index() {
564 let term = TestTerminal::new(80, 24);
565 let mut tui = TUI::new(Box::new(term));
566 tui.mount(Box::new(Text::new("a", 0, 0)));
567 tui.set_focus(5); }
569
570 #[test]
571 fn tui_handle_input_no_focus() {
572 let term = TestTerminal::new(80, 24);
573 let mut tui = TUI::new(Box::new(term));
574 tui.handle_input(&crate::events::Event::Resize(10, 10)); }
576
577 #[test]
578 fn tui_render_with_overlay() {
579 let term = TestTerminal::new(80, 24);
580 let mut tui = TUI::new(Box::new(term));
581 tui.mount(Box::new(Text::new("hello", 0, 0)));
582 let overlay = Overlay {
583 content: Box::new(Text::new("popup", 0, 0)),
584 position: OverlayPosition::Anchor(Anchor::Center),
585 constraints: OverlayConstraints {
586 min_width: 5,
587 max_height: 3,
588 margin: 1,
589 offset_x: 0,
590 offset_y: 0,
591 visible: None,
592 },
593 };
594 tui.overlays.push(overlay);
595 tui.render_frame().unwrap();
596 }
597
598 #[test]
599 fn tui_full_redraw_on_resize() {
600 let term = TestTerminal::new(80, 24);
601 let mut tui = TUI::new(Box::new(term));
602 tui.mount(Box::new(Text::new("hello", 0, 0)));
603 tui.render_frame().unwrap();
604 let new_term = TestTerminal::new(100, 30);
606 tui.terminal = Box::new(new_term);
607 tui.render_frame().unwrap();
608 }
609
610 struct ImageComponent;
611 impl Component for ImageComponent {
612 fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
613 Ok(Rendered {
614 lines: vec!["img".into()],
615 cursor: None,
616 images: vec![crate::renderer::ImageCommand {
617 id: 1,
618 data: "data".into(),
619 }],
620 })
621 }
622 }
623
624 #[test]
625 fn tui_image_cleanup() {
626 let term = TestTerminal::new(80, 24);
627 let mut tui = TUI::new(Box::new(term));
628 tui.mount(Box::new(ImageComponent));
629 tui.render_frame().unwrap();
630 tui.children.clear();
632 tui.children.push(Box::new(Text::new("text", 0, 0)));
633 tui.render_frame().unwrap();
634 }
636
637 #[test]
638 fn tui_hardware_cursor() {
639 unsafe {
640 std::env::set_var("PHOTON_UI_HARDWARE_CURSOR", "1");
641 }
642 let term = TestTerminal::new(80, 24);
643 let mut tui = TUI::new(Box::new(term));
644 tui.mount(Box::new(Text::new("hello", 0, 0)));
645 tui.render_frame().unwrap();
646 unsafe {
647 std::env::remove_var("PHOTON_UI_HARDWARE_CURSOR");
648 }
649 }
650
651 #[test]
652 fn tui_tab_cycles_focus() {
653 let term = TestTerminal::new(80, 24);
654 let mut tui = TUI::new(Box::new(term));
655 tui.mount(Box::new(Text::new("a", 0, 0))); let list = crate::components::SelectList::new(vec!["x".into()], 1);
657 tui.mount(Box::new(list));
658 let input = crate::components::Input::new();
659 tui.mount(Box::new(input));
660
661 assert_eq!(tui.focused_index, Some(0));
663
664 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
666 crossterm::event::KeyCode::Tab,
667 crossterm::event::KeyModifiers::empty(),
668 )));
669 assert_eq!(tui.focused_index, Some(1));
670
671 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
673 crossterm::event::KeyCode::Tab,
674 crossterm::event::KeyModifiers::empty(),
675 )));
676 assert_eq!(tui.focused_index, Some(2));
677
678 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
680 crossterm::event::KeyCode::Tab,
681 crossterm::event::KeyModifiers::empty(),
682 )));
683 assert_eq!(tui.focused_index, Some(1));
684 }
685
686 #[test]
687 fn tui_backtab_cycles_backward() {
688 let term = TestTerminal::new(80, 24);
689 let mut tui = TUI::new(Box::new(term));
690 let list = crate::components::SelectList::new(vec!["x".into()], 1);
691 tui.mount(Box::new(list));
692 let input = crate::components::Input::new();
693 tui.mount(Box::new(input));
694
695 assert_eq!(tui.focused_index, Some(0));
697
698 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
700 crossterm::event::KeyCode::BackTab,
701 crossterm::event::KeyModifiers::empty(),
702 )));
703 assert_eq!(tui.focused_index, Some(1));
704 }
705
706 #[test]
707 fn tui_cycle_focus_single_focusable() {
708 let term = TestTerminal::new(80, 24);
709 let mut tui = TUI::new(Box::new(term));
710 let list = crate::components::SelectList::new(vec!["x".into()], 1);
711 tui.mount(Box::new(list));
712
713 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
715 crossterm::event::KeyCode::Tab,
716 crossterm::event::KeyModifiers::empty(),
717 )));
718 assert_eq!(tui.focused_index, Some(0));
719 }
720
721 #[test]
722 fn tui_no_focusables_no_panic() {
723 let term = TestTerminal::new(80, 24);
724 let mut tui = TUI::new(Box::new(term));
725 tui.mount(Box::new(Text::new("hello", 0, 0))); tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
728 crossterm::event::KeyCode::Tab,
729 crossterm::event::KeyModifiers::empty(),
730 )));
731 }
732
733 #[test]
734 fn tui_terminal_borrow() {
735 let term = TestTerminal::new(80, 24);
736 let tui = TUI::new(Box::new(term));
737 let _ = tui.terminal();
738 }
739
740 #[test]
741 fn tui_handle_input_fallthrough() {
742 let term = TestTerminal::new(80, 24);
743 let mut tui = TUI::new(Box::new(term));
744 tui.mount(Box::new(Text::new("a", 0, 0)));
746 tui.mount(Box::new(Text::new("b", 0, 0)));
747 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
749 crossterm::event::KeyCode::Char('x'),
750 crossterm::event::KeyModifiers::empty(),
751 )));
752 }
753
754 #[test]
755 fn overlay_compute_position_all_anchors() {
756 let constraints = OverlayConstraints {
757 min_width: 5,
758 max_height: 3,
759 margin: 1,
760 offset_x: 0,
761 offset_y: 0,
762 visible: None,
763 };
764 let anchors = vec![
765 Anchor::Center,
766 Anchor::TopLeft,
767 Anchor::TopRight,
768 Anchor::BottomLeft,
769 Anchor::BottomRight,
770 Anchor::TopCenter,
771 Anchor::BottomCenter,
772 Anchor::LeftCenter,
773 Anchor::RightCenter,
774 ];
775 for anchor in anchors {
776 let overlay = Overlay {
777 content: Box::new(Text::new("test", 0, 0)),
778 position: OverlayPosition::Anchor(anchor),
779 constraints: constraints.clone(),
780 };
781 let rect = overlay.compute_position(80, 24, 10, 2);
782 assert!(rect.is_some(), "anchor {:?} should produce a rect", anchor);
783 }
784 }
785
786 #[test]
787 fn overlay_compute_position_at() {
788 let overlay = Overlay {
789 content: Box::new(Text::new("test", 0, 0)),
790 position: OverlayPosition::At(5, 10),
791 constraints: OverlayConstraints {
792 min_width: 5,
793 max_height: 3,
794 margin: 0,
795 offset_x: 0,
796 offset_y: 0,
797 visible: None,
798 },
799 };
800 let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
801 assert_eq!(rect.y, 5);
802 assert_eq!(rect.x, 10);
803 }
804
805 #[test]
806 fn overlay_compute_position_percent() {
807 let overlay = Overlay {
808 content: Box::new(Text::new("test", 0, 0)),
809 position: OverlayPosition::Percent("50%".into(), "25%".into()),
810 constraints: OverlayConstraints {
811 min_width: 5,
812 max_height: 3,
813 margin: 0,
814 offset_x: 0,
815 offset_y: 0,
816 visible: None,
817 },
818 };
819 let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
820 assert_eq!(rect.y, 10);
821 assert_eq!(rect.x, 50);
822 }
823
824 #[test]
825 fn overlay_compute_position_percent_invalid() {
826 let overlay = Overlay {
827 content: Box::new(Text::new("test", 0, 0)),
828 position: OverlayPosition::Percent("abc".into(), "xyz".into()),
829 constraints: OverlayConstraints {
830 min_width: 5,
831 max_height: 3,
832 margin: 0,
833 offset_x: 0,
834 offset_y: 0,
835 visible: None,
836 },
837 };
838 let rect = overlay.compute_position(100, 40, 10, 2).unwrap();
839 assert_eq!(rect.y, 0);
840 assert_eq!(rect.x, 0);
841 }
842
843 #[test]
844 fn overlay_compute_position_visible_false() {
845 let overlay = Overlay {
846 content: Box::new(Text::new("test", 0, 0)),
847 position: OverlayPosition::Anchor(Anchor::Center),
848 constraints: OverlayConstraints {
849 min_width: 5,
850 max_height: 3,
851 margin: 0,
852 offset_x: 0,
853 offset_y: 0,
854 visible: Some(|_w, _h| false),
855 },
856 };
857 assert!(overlay.compute_position(80, 24, 10, 2).is_none());
858 }
859
860 #[test]
861 fn overlay_compute_position_with_offset() {
862 let overlay = Overlay {
863 content: Box::new(Text::new("test", 0, 0)),
864 position: OverlayPosition::At(10, 10),
865 constraints: OverlayConstraints {
866 min_width: 5,
867 max_height: 3,
868 margin: 0,
869 offset_x: 5,
870 offset_y: -3,
871 visible: None,
872 },
873 };
874 let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
875 assert_eq!(rect.y, 7);
876 assert_eq!(rect.x, 15);
877 }
878
879 #[test]
880 fn overlay_compute_position_negative_offset_clamped() {
881 let overlay = Overlay {
882 content: Box::new(Text::new("test", 0, 0)),
883 position: OverlayPosition::At(0, 0),
884 constraints: OverlayConstraints {
885 min_width: 5,
886 max_height: 3,
887 margin: 0,
888 offset_x: -5,
889 offset_y: -5,
890 visible: None,
891 },
892 };
893 let rect = overlay.compute_position(80, 24, 10, 2).unwrap();
894 assert_eq!(rect.y, 0);
895 assert_eq!(rect.x, 0);
896 }
897
898 #[test]
899 fn overlay_compute_position_size_clamped() {
900 let overlay = Overlay {
901 content: Box::new(Text::new("test", 0, 0)),
902 position: OverlayPosition::At(70, 20),
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(80, 24, 20, 10).unwrap();
913 assert_eq!(rect.width, 20);
915 assert_eq!(rect.height, 0);
918 }
919
920 struct CursorComponent;
921 impl Component for CursorComponent {
922 fn render(&self, _width: u16) -> Result<Rendered, crate::RenderError> {
923 Ok(Rendered {
924 lines: vec!["cursor".into()],
925 cursor: Some((0, 3)),
926 images: vec![],
927 })
928 }
929 }
930
931 #[test]
932 fn tui_render_frame_with_cursor() {
933 let term = TestTerminal::new(80, 24);
934 let mut tui = TUI::new(Box::new(term));
935 tui.mount(Box::new(CursorComponent));
936 tui.render_frame().unwrap();
937 }
938
939 #[test]
940 fn tui_demo_layout_exact() {
941 let term = TestTerminal::new(80, 24);
942 let mut tui = TUI::new(Box::new(term));
943
944 tui.mount(Box::new(Text::new("Photon UI Demo", 2, 1)));
945 tui.mount(Box::new(Text::new(
946 "j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit",
947 2, 0,
948 )));
949 let list = crate::components::SelectList::new(
950 vec![
951 "Option 1: Hello world".into(),
952 "Option 2: Foo bar baz".into(),
953 "Option 3: Lorem ipsum".into(),
954 "Option 4: Vim bindings".into(),
955 "Option 5: Blazing fast".into(),
956 ],
957 3,
958 );
959 tui.mount(Box::new(list));
960 let input = crate::components::Input::new();
961 tui.mount(Box::new(input));
962 tui.set_focus(2);
963
964 let screen = tui.compose_screen(80, 24);
965
966 assert_eq!(
976 screen.lines.len(),
977 8,
978 "expected 8 content lines, got {}",
979 screen.lines.len()
980 );
981 assert_eq!(
982 screen.lines[0].trim_end(),
983 "",
984 "row 0 should be blank from Text1 pad_y"
985 );
986 assert!(
987 screen.lines[1].contains("Photon UI Demo"),
988 "row 1 should contain header: got {:?}",
989 screen.lines[1]
990 );
991 assert_eq!(
992 screen.lines[2].trim_end(),
993 "",
994 "row 2 should be blank from Text1 pad_y"
995 );
996 assert!(
997 screen.lines[3].contains("j/k = navigate"),
998 "row 3 should contain keybindings: got {:?}",
999 screen.lines[3]
1000 );
1001 assert!(
1002 screen.lines[4].contains("> Option 1"),
1003 "row 4 should be selected list item: got {:?}",
1004 screen.lines[4]
1005 );
1006 assert!(
1007 screen.lines[5].contains(" Option 2"),
1008 "row 5 should be unselected list item: got {:?}",
1009 screen.lines[5]
1010 );
1011 assert!(
1012 screen.lines[6].contains(" Option 3"),
1013 "row 6 should be unselected list item: got {:?}",
1014 screen.lines[6]
1015 );
1016 assert_eq!(
1017 screen.lines[7].trim_end(),
1018 "",
1019 "row 7 should be empty input line"
1020 );
1021 }
1022
1023 #[test]
1026 fn tui_reset_clears_all_and_schedules_redraw() {
1027 let term = TestTerminal::new(80, 24);
1028 let mut tui = TUI::new(Box::new(term));
1029
1030 tui.mount(Box::new(crate::components::Text::new("hello", 0, 0)));
1031 tui.set_focus(0);
1032 tui.add_overlay(Overlay {
1033 content: Box::new(crate::components::Text::new("popup", 0, 0)),
1034 position: OverlayPosition::Anchor(Anchor::Center),
1035 constraints: OverlayConstraints {
1036 min_width: 10,
1037 max_height: 3,
1038 margin: 2,
1039 offset_x: 0,
1040 offset_y: 0,
1041 visible: None,
1042 },
1043 });
1044 tui.set_layout(crate::layout::layout::Layout::vertical([
1045 crate::layout::Constraint::Length(1),
1046 ]));
1047 tui.render_frame().unwrap();
1048
1049 let screen_before = tui.compose_screen(80, 24);
1051 assert!(
1052 !screen_before.lines.is_empty(),
1053 "precondition: screen should have content"
1054 );
1055
1056 tui.reset();
1057
1058 let screen = tui.compose_screen(80, 24);
1060 assert!(screen.lines.is_empty(), "reset should clear all children");
1061
1062 tui.render_frame().unwrap();
1065 }
1066
1067 #[test]
1068 fn tui_show_modal_captures_input() {
1069 let term = TestTerminal::new(80, 24);
1070 let mut tui = TUI::new(Box::new(term));
1071 tui.mount(Box::new(Text::new("background", 0, 0)));
1072 tui.set_focus(0);
1073
1074 let modal_content = Text::new("modal text", 0, 0);
1075 tui.show_modal(Box::new(modal_content));
1076 assert!(tui.modal_active());
1077
1078 tui.handle_input(&crate::events::Event::Key(crossterm::event::KeyEvent::new(
1080 crossterm::event::KeyCode::Esc,
1081 crossterm::event::KeyModifiers::empty(),
1082 )));
1083 assert!(!tui.modal_active());
1084 }
1085
1086 #[test]
1087 fn tui_modal_restores_focus_on_dismiss() {
1088 let term = TestTerminal::new(80, 24);
1089 let mut tui = TUI::new(Box::new(term));
1090 let list = crate::components::SelectList::new(vec!["x".into()], 1);
1091 tui.mount(Box::new(list));
1092 assert_eq!(tui.focused_index, Some(0));
1093
1094 tui.show_modal(Box::new(Text::new("modal", 0, 0)));
1095 tui.dismiss_modal();
1096 assert_eq!(tui.focused_index, Some(0));
1097 }
1098
1099 #[test]
1100 fn tui_modal_renders_without_panic() {
1101 let term = TestTerminal::new(80, 24);
1102 let mut tui = TUI::new(Box::new(term));
1103 tui.mount(Box::new(Text::new("background", 0, 0)));
1104
1105 let modal_content = crate::components::Modal::new(Box::new(Text::new("hello", 0, 0)));
1106 tui.show_modal(Box::new(modal_content));
1107 tui.render_frame().unwrap();
1109 }
1110}