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