1#![forbid(unsafe_code)]
2
3use ftui_core::event::Event;
42use ftui_core::geometry::Rect;
43use ftui_render::frame::{Frame, HitId};
44use ftui_style::Style;
45use std::sync::atomic::{AtomicU64, Ordering};
46
47use crate::modal::{BackdropConfig, ModalSizeConstraints};
48use crate::set_style_area;
49
50const BASE_MODAL_Z: u32 = 1000;
52
53const Z_INCREMENT: u32 = 10;
55
56static MODAL_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub struct ModalId(u64);
62
63impl ModalId {
64 fn new() -> Self {
66 Self(MODAL_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
67 }
68
69 #[inline]
71 pub const fn id(self) -> u64 {
72 self.0
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct ModalResult {
79 pub id: ModalId,
81 pub data: Option<ModalResultData>,
83 pub focus_group_id: Option<u32>,
85}
86
87#[derive(Debug, Clone)]
89pub enum ModalResultData {
90 Dismissed,
92 Confirmed,
94 Custom(String),
96}
97
98pub type ModalFocusId = u64;
100
101pub trait StackModal: Send {
118 fn render_content(&self, area: Rect, frame: &mut Frame);
120
121 fn handle_event(&mut self, event: &Event, hit_id: HitId) -> Option<ModalResultData>;
123
124 fn size_constraints(&self) -> ModalSizeConstraints;
126
127 fn backdrop_config(&self) -> BackdropConfig;
129
130 fn close_on_escape(&self) -> bool {
132 true
133 }
134
135 fn close_on_backdrop(&self) -> bool {
137 true
138 }
139
140 fn aria_modal(&self) -> bool {
156 true
157 }
158
159 fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
177 None
178 }
179}
180
181struct ActiveModal {
183 id: ModalId,
185 #[allow(dead_code)]
187 z_index: u32,
188 modal: Box<dyn StackModal>,
190 hit_id: HitId,
192 focus_group_id: Option<u32>,
194}
195
196pub struct ModalStack {
204 modals: Vec<ActiveModal>,
206 next_z: u32,
208 next_hit_id: u32,
210}
211
212impl Default for ModalStack {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218impl ModalStack {
219 pub fn new() -> Self {
221 Self {
222 modals: Vec::new(),
223 next_z: 0,
224 next_hit_id: 1000, }
226 }
227
228 pub fn push(&mut self, modal: Box<dyn StackModal>) -> ModalId {
234 self.push_with_focus(modal, None)
235 }
236
237 pub fn push_with_focus(
246 &mut self,
247 modal: Box<dyn StackModal>,
248 focus_group_id: Option<u32>,
249 ) -> ModalId {
250 let id = ModalId::new();
251 let z_index = BASE_MODAL_Z + self.next_z;
252 self.next_z += Z_INCREMENT;
253
254 let hit_id = HitId::new(self.next_hit_id);
255 self.next_hit_id += 1;
256
257 self.modals.push(ActiveModal {
258 id,
259 z_index,
260 modal,
261 hit_id,
262 focus_group_id,
263 });
264
265 id
266 }
267
268 pub fn focus_group_id(&self, modal_id: ModalId) -> Option<u32> {
272 self.modals
273 .iter()
274 .find(|m| m.id == modal_id)
275 .and_then(|m| m.focus_group_id)
276 }
277
278 pub fn top_focus_group_id(&self) -> Option<u32> {
282 self.modals.last().and_then(|m| m.focus_group_id)
283 }
284
285 pub fn pop(&mut self) -> Option<ModalResult> {
290 self.modals.pop().map(|m| ModalResult {
291 id: m.id,
292 data: None,
293 focus_group_id: m.focus_group_id,
294 })
295 }
296
297 pub fn pop_id(&mut self, id: ModalId) -> Option<ModalResult> {
303 let idx = self.modals.iter().position(|m| m.id == id)?;
304 let modal = self.modals.remove(idx);
305 Some(ModalResult {
306 id: modal.id,
307 data: None,
308 focus_group_id: modal.focus_group_id,
309 })
310 }
311
312 pub fn pop_all(&mut self) -> Vec<ModalResult> {
316 let mut results = Vec::with_capacity(self.modals.len());
317 while let Some(result) = self.pop() {
318 results.push(result);
319 }
320 results
321 }
322
323 pub fn top(&self) -> Option<&(dyn StackModal + 'static)> {
325 self.modals.last().map(|m| &*m.modal)
326 }
327
328 pub fn top_mut(&mut self) -> Option<&mut (dyn StackModal + 'static)> {
330 match self.modals.last_mut() {
331 Some(m) => Some(m.modal.as_mut()),
332 None => None,
333 }
334 }
335
336 #[inline]
340 pub fn is_empty(&self) -> bool {
341 self.modals.is_empty()
342 }
343
344 #[inline]
346 pub fn depth(&self) -> usize {
347 self.modals.len()
348 }
349
350 pub fn contains(&self, id: ModalId) -> bool {
352 self.modals.iter().any(|m| m.id == id)
353 }
354
355 pub fn top_id(&self) -> Option<ModalId> {
357 self.modals.last().map(|m| m.id)
358 }
359
360 pub fn handle_event(&mut self, event: &Event) -> Option<ModalResult> {
368 let top = self.modals.last_mut()?;
369 let hit_id = top.hit_id;
370 let id = top.id;
371 let focus_group_id = top.focus_group_id;
372
373 if let Some(data) = top.modal.handle_event(event, hit_id) {
374 self.modals.pop();
376 return Some(ModalResult {
377 id,
378 data: Some(data),
379 focus_group_id,
380 });
381 }
382
383 None
384 }
385
386 pub fn render(&self, frame: &mut Frame, screen: Rect) {
393 if self.modals.is_empty() {
394 return;
395 }
396
397 let modal_count = self.modals.len();
398
399 for (i, modal) in self.modals.iter().enumerate() {
400 let is_top = i == modal_count - 1;
401
402 let base_opacity = modal.modal.backdrop_config().opacity;
404 let opacity = if is_top {
405 base_opacity
406 } else {
407 base_opacity * 0.5
409 };
410
411 if opacity > 0.0 {
413 let bg_color = modal.modal.backdrop_config().color.with_opacity(opacity);
414 set_style_area(&mut frame.buffer, screen, Style::new().bg(bg_color));
415 }
416
417 let constraints = modal.modal.size_constraints();
419 let available = ftui_core::geometry::Size::new(screen.width, screen.height);
420 let size = constraints.clamp(available);
421
422 if size.width == 0 || size.height == 0 {
423 continue;
424 }
425
426 let x = screen.x + (screen.width.saturating_sub(size.width)) / 2;
428 let y = screen.y + (screen.height.saturating_sub(size.height)) / 2;
429 let content_area = Rect::new(x, y, size.width, size.height);
430
431 modal.modal.render_content(content_area, frame);
433 }
434 }
435}
436
437pub struct WidgetModalEntry<W> {
439 widget: W,
440 size: ModalSizeConstraints,
441 backdrop: BackdropConfig,
442 close_on_escape: bool,
443 close_on_backdrop: bool,
444 aria_modal: bool,
445 focusable_ids: Option<Vec<ModalFocusId>>,
446}
447
448impl<W> WidgetModalEntry<W> {
449 pub fn new(widget: W) -> Self {
451 Self {
452 widget,
453 size: ModalSizeConstraints::new()
454 .min_width(30)
455 .max_width(60)
456 .min_height(10)
457 .max_height(20),
458 backdrop: BackdropConfig::default(),
459 close_on_escape: true,
460 close_on_backdrop: true,
461 aria_modal: true,
462 focusable_ids: None,
463 }
464 }
465
466 pub fn size(mut self, size: ModalSizeConstraints) -> Self {
468 self.size = size;
469 self
470 }
471
472 pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
474 self.backdrop = backdrop;
475 self
476 }
477
478 pub fn close_on_escape(mut self, close: bool) -> Self {
480 self.close_on_escape = close;
481 self
482 }
483
484 pub fn close_on_backdrop(mut self, close: bool) -> Self {
486 self.close_on_backdrop = close;
487 self
488 }
489
490 pub fn with_aria_modal(mut self, aria_modal: bool) -> Self {
495 self.aria_modal = aria_modal;
496 self
497 }
498
499 pub fn with_focusable_ids(mut self, ids: Vec<ModalFocusId>) -> Self {
506 self.focusable_ids = Some(ids);
507 self
508 }
509}
510
511impl<W: crate::Widget + Send> StackModal for WidgetModalEntry<W> {
512 fn render_content(&self, area: Rect, frame: &mut Frame) {
513 self.widget.render(area, frame);
514 }
515
516 fn handle_event(&mut self, event: &Event, _hit_id: HitId) -> Option<ModalResultData> {
517 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind};
518
519 if self.close_on_escape
521 && let Event::Key(KeyEvent {
522 code: KeyCode::Escape,
523 kind: KeyEventKind::Press,
524 ..
525 }) = event
526 {
527 return Some(ModalResultData::Dismissed);
528 }
529
530 None
531 }
532
533 fn size_constraints(&self) -> ModalSizeConstraints {
534 self.size
535 }
536
537 fn backdrop_config(&self) -> BackdropConfig {
538 self.backdrop
539 }
540
541 fn close_on_escape(&self) -> bool {
542 self.close_on_escape
543 }
544
545 fn close_on_backdrop(&self) -> bool {
546 self.close_on_backdrop
547 }
548
549 fn aria_modal(&self) -> bool {
550 self.aria_modal
551 }
552
553 fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
554 self.focusable_ids.clone()
555 }
556}
557
558#[allow(dead_code)]
587pub struct ModalFocusIntegration<'a> {
588 stack: &'a mut ModalStack,
589 focus: &'a mut crate::focus::FocusManager,
590 next_group_id: u32,
591}
592
593impl<'a> ModalFocusIntegration<'a> {
594 pub fn new(stack: &'a mut ModalStack, focus: &'a mut crate::focus::FocusManager) -> Self {
596 Self {
597 stack,
598 focus,
599 next_group_id: 1000, }
601 }
602
603 pub fn push_with_focus(&mut self, modal: Box<dyn StackModal>) -> ModalId {
612 let focusable_ids = modal.focusable_ids();
613 let is_aria_modal = modal.aria_modal();
614
615 let focus_group_id = if is_aria_modal {
616 if let Some(ids) = focusable_ids {
617 let group_id = self.next_group_id;
618 self.next_group_id += 1;
619
620 let focus_ids: Vec<crate::focus::FocusId> = ids.into_iter().collect();
622
623 self.focus.create_group(group_id, focus_ids);
625 self.focus.push_trap(group_id);
626
627 Some(group_id)
628 } else {
629 None
630 }
631 } else {
632 None
633 };
634
635 self.stack.push_with_focus(modal, focus_group_id)
636 }
637
638 pub fn pop_with_focus(&mut self) -> Option<ModalResult> {
645 let result = self.stack.pop();
646
647 if let Some(ref res) = result
648 && res.focus_group_id.is_some()
649 {
650 self.focus.pop_trap();
651 }
652
653 result
654 }
655
656 pub fn handle_event(&mut self, event: &Event) -> Option<ModalResult> {
660 let result = self.stack.handle_event(event);
661
662 if let Some(ref res) = result
663 && res.focus_group_id.is_some()
664 {
665 self.focus.pop_trap();
666 }
667
668 result
669 }
670
671 pub fn is_focus_trapped(&self) -> bool {
673 self.focus.is_trapped()
674 }
675
676 pub fn stack(&self) -> &ModalStack {
678 self.stack
679 }
680
681 pub fn stack_mut(&mut self) -> &mut ModalStack {
683 self.stack
684 }
685
686 pub fn focus(&self) -> &crate::focus::FocusManager {
688 self.focus
689 }
690
691 pub fn focus_mut(&mut self) -> &mut crate::focus::FocusManager {
693 self.focus
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::Widget;
701 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
702 use ftui_render::cell::PackedRgba;
703 use ftui_render::grapheme_pool::GraphemePool;
704
705 #[derive(Debug, Clone)]
706 struct StubWidget;
707
708 impl Widget for StubWidget {
709 fn render(&self, _area: Rect, _frame: &mut Frame) {}
710 }
711
712 #[test]
713 fn empty_stack() {
714 let stack = ModalStack::new();
715 assert!(stack.is_empty());
716 assert_eq!(stack.depth(), 0);
717 assert!(stack.top().is_none());
718 assert!(stack.top_id().is_none());
719 }
720
721 #[test]
722 fn push_increases_depth() {
723 let mut stack = ModalStack::new();
724 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
725 assert_eq!(stack.depth(), 1);
726 assert!(!stack.is_empty());
727 assert!(stack.contains(id1));
728
729 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
730 assert_eq!(stack.depth(), 2);
731 assert!(stack.contains(id2));
732 assert_eq!(stack.top_id(), Some(id2));
733 }
734
735 #[test]
736 fn pop_lifo_order() {
737 let mut stack = ModalStack::new();
738 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
739 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
740 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
741
742 let result = stack.pop();
743 assert_eq!(result.map(|r| r.id), Some(id3));
744 assert_eq!(stack.depth(), 2);
745
746 let result = stack.pop();
747 assert_eq!(result.map(|r| r.id), Some(id2));
748 assert_eq!(stack.depth(), 1);
749
750 let result = stack.pop();
751 assert_eq!(result.map(|r| r.id), Some(id1));
752 assert!(stack.is_empty());
753 }
754
755 #[test]
756 fn pop_empty_returns_none() {
757 let mut stack = ModalStack::new();
758 assert!(stack.pop().is_none());
759 }
760
761 #[test]
762 fn pop_by_id() {
763 let mut stack = ModalStack::new();
764 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
765 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
766 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
767
768 let result = stack.pop_id(id2);
770 assert_eq!(result.map(|r| r.id), Some(id2));
771 assert_eq!(stack.depth(), 2);
772 assert!(!stack.contains(id2));
773 assert!(stack.contains(id1));
774 assert!(stack.contains(id3));
775 }
776
777 #[test]
778 fn pop_by_nonexistent_id() {
779 let mut stack = ModalStack::new();
780 let _id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
781
782 let fake_id = ModalId(999999);
784 assert!(stack.pop_id(fake_id).is_none());
785 assert_eq!(stack.depth(), 1);
786 }
787
788 #[test]
789 fn pop_all() {
790 let mut stack = ModalStack::new();
791 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
792 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
793 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
794
795 let results = stack.pop_all();
796 assert_eq!(results.len(), 3);
797 assert_eq!(results[0].id, id3);
799 assert_eq!(results[1].id, id2);
800 assert_eq!(results[2].id, id1);
801 assert!(stack.is_empty());
802 }
803
804 #[test]
805 fn z_order_increasing() {
806 let mut stack = ModalStack::new();
807
808 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
810 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
811 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
812
813 let z_indices: Vec<u32> = stack.modals.iter().map(|m| m.z_index).collect();
815 for i in 1..z_indices.len() {
816 assert!(
817 z_indices[i] > z_indices[i - 1],
818 "z_index should be strictly increasing"
819 );
820 }
821 }
822
823 #[test]
824 fn escape_closes_top_modal() {
825 let mut stack = ModalStack::new();
826 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
827 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
828
829 let escape = Event::Key(KeyEvent {
830 code: KeyCode::Escape,
831 modifiers: Modifiers::empty(),
832 kind: KeyEventKind::Press,
833 });
834
835 let result = stack.handle_event(&escape);
837 assert!(result.is_some());
838 assert_eq!(result.unwrap().id, id2);
839 assert_eq!(stack.depth(), 1);
840 assert_eq!(stack.top_id(), Some(id1));
841 }
842
843 #[test]
844 fn render_does_not_panic() {
845 let mut stack = ModalStack::new();
846 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
847 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
848
849 let mut pool = GraphemePool::new();
850 let mut frame = Frame::new(80, 24, &mut pool);
851 let screen = Rect::new(0, 0, 80, 24);
852
853 stack.render(&mut frame, screen);
855 }
856
857 #[test]
858 fn render_empty_stack_no_op() {
859 let stack = ModalStack::new();
860 let mut pool = GraphemePool::new();
861 let mut frame = Frame::new(80, 24, &mut pool);
862 let screen = Rect::new(0, 0, 80, 24);
863
864 stack.render(&mut frame, screen);
866 }
867
868 #[test]
869 fn contains_after_pop() {
870 let mut stack = ModalStack::new();
871 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
872
873 assert!(stack.contains(id1));
874 stack.pop();
875 assert!(!stack.contains(id1));
876 }
877
878 #[test]
879 fn unique_modal_ids() {
880 let mut stack = ModalStack::new();
881 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
882 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
883 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
884
885 assert_ne!(id1, id2);
886 assert_ne!(id2, id3);
887 assert_ne!(id1, id3);
888 }
889
890 #[test]
891 fn widget_modal_entry_builder() {
892 let entry = WidgetModalEntry::new(StubWidget)
893 .size(ModalSizeConstraints::new().min_width(40).max_width(80))
894 .backdrop(BackdropConfig::new(PackedRgba::rgb(0, 0, 0), 0.8))
895 .close_on_escape(false)
896 .close_on_backdrop(false);
897
898 assert!(!entry.close_on_escape);
899 assert!(!entry.close_on_backdrop);
900 assert_eq!(entry.size.min_width, Some(40));
901 assert_eq!(entry.size.max_width, Some(80));
902 }
903
904 #[test]
905 fn escape_disabled_does_not_close() {
906 let mut stack = ModalStack::new();
907 stack.push(Box::new(
908 WidgetModalEntry::new(StubWidget).close_on_escape(false),
909 ));
910
911 let escape = Event::Key(KeyEvent {
912 code: KeyCode::Escape,
913 modifiers: Modifiers::empty(),
914 kind: KeyEventKind::Press,
915 });
916
917 let result = stack.handle_event(&escape);
919 assert!(result.is_none());
920 assert_eq!(stack.depth(), 1);
921 }
922
923 #[test]
926 fn push_with_focus_tracks_group_id() {
927 let mut stack = ModalStack::new();
928 let modal_id = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(42));
929
930 assert_eq!(stack.focus_group_id(modal_id), Some(42));
931 assert_eq!(stack.top_focus_group_id(), Some(42));
932 }
933
934 #[test]
935 fn pop_returns_focus_group_id() {
936 let mut stack = ModalStack::new();
937 stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(99));
938
939 let result = stack.pop();
940 assert!(result.is_some());
941 assert_eq!(result.unwrap().focus_group_id, Some(99));
942 }
943
944 #[test]
945 fn pop_id_returns_focus_group_id() {
946 let mut stack = ModalStack::new();
947 let id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(10));
948 let _id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(20));
949
950 let result = stack.pop_id(id1);
951 assert!(result.is_some());
952 assert_eq!(result.unwrap().focus_group_id, Some(10));
953 }
954
955 #[test]
956 fn handle_event_returns_focus_group_id() {
957 let mut stack = ModalStack::new();
958 stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(77));
959
960 let escape = Event::Key(KeyEvent {
961 code: KeyCode::Escape,
962 modifiers: Modifiers::empty(),
963 kind: KeyEventKind::Press,
964 });
965
966 let result = stack.handle_event(&escape);
967 assert!(result.is_some());
968 assert_eq!(result.unwrap().focus_group_id, Some(77));
969 }
970
971 #[test]
972 fn push_without_focus_has_none_group_id() {
973 let mut stack = ModalStack::new();
974 let modal_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
975
976 assert_eq!(stack.focus_group_id(modal_id), None);
977 assert_eq!(stack.top_focus_group_id(), None);
978 }
979
980 #[test]
981 fn nested_focus_groups_track_correctly() {
982 let mut stack = ModalStack::new();
983 let _id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(1));
984 let id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(2));
985 let _id3 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(3));
986
987 assert_eq!(stack.top_focus_group_id(), Some(3));
989
990 stack.pop();
992 assert_eq!(stack.top_focus_group_id(), Some(2));
993
994 assert_eq!(stack.focus_group_id(id2), Some(2));
996 }
997
998 #[test]
1001 fn default_aria_modal_is_true() {
1002 let entry = WidgetModalEntry::new(StubWidget);
1003 assert!(entry.aria_modal);
1004 }
1005
1006 #[test]
1007 fn aria_modal_builder() {
1008 let entry = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1009 assert!(!entry.aria_modal);
1010 }
1011
1012 #[test]
1013 fn focusable_ids_builder() {
1014 let entry = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2, 3]);
1015 assert_eq!(entry.focusable_ids, Some(vec![1, 2, 3]));
1016 }
1017
1018 #[test]
1019 fn stack_modal_aria_modal_trait() {
1020 let entry = WidgetModalEntry::new(StubWidget);
1021 assert!(StackModal::aria_modal(&entry)); let entry_non_aria = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1024 assert!(!StackModal::aria_modal(&entry_non_aria));
1025 }
1026
1027 #[test]
1028 fn stack_modal_focusable_ids_trait() {
1029 let entry = WidgetModalEntry::new(StubWidget);
1030 assert!(StackModal::focusable_ids(&entry).is_none()); let entry_with_ids = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 20]);
1033 assert_eq!(
1034 StackModal::focusable_ids(&entry_with_ids),
1035 Some(vec![10, 20])
1036 );
1037 }
1038
1039 #[test]
1042 fn focus_integration_push_creates_trap() {
1043 use crate::focus::{FocusManager, FocusNode};
1044 use ftui_core::geometry::Rect;
1045
1046 let mut stack = ModalStack::new();
1047 let mut focus = FocusManager::new();
1048
1049 focus
1051 .graph_mut()
1052 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1053 focus
1054 .graph_mut()
1055 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1056 focus
1057 .graph_mut()
1058 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); focus.focus(100);
1062 assert_eq!(focus.current(), Some(100));
1063
1064 {
1065 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1066
1067 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1069 let _modal_id = integrator.push_with_focus(Box::new(modal));
1070
1071 assert!(integrator.is_focus_trapped());
1073
1074 assert_eq!(integrator.focus().current(), Some(1));
1076 }
1077 }
1078
1079 #[test]
1080 fn focus_integration_pop_restores_focus() {
1081 use crate::focus::{FocusManager, FocusNode};
1082 use ftui_core::geometry::Rect;
1083
1084 let mut stack = ModalStack::new();
1085 let mut focus = FocusManager::new();
1086
1087 focus
1089 .graph_mut()
1090 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1091 focus
1092 .graph_mut()
1093 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1094 focus
1095 .graph_mut()
1096 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); focus.focus(100);
1100 assert_eq!(focus.current(), Some(100));
1101
1102 {
1103 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1104
1105 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1107 integrator.push_with_focus(Box::new(modal));
1108
1109 assert!(integrator.is_focus_trapped());
1111
1112 let result = integrator.pop_with_focus();
1114 assert!(result.is_some());
1115
1116 assert!(!integrator.is_focus_trapped());
1118 assert_eq!(integrator.focus().current(), Some(100));
1119 }
1120 }
1121
1122 #[test]
1123 fn focus_integration_escape_restores_focus() {
1124 use crate::focus::{FocusManager, FocusNode};
1125 use ftui_core::geometry::Rect;
1126
1127 let mut stack = ModalStack::new();
1128 let mut focus = FocusManager::new();
1129
1130 focus
1131 .graph_mut()
1132 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1133 focus
1134 .graph_mut()
1135 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1136
1137 focus.focus(100);
1138
1139 {
1140 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1141
1142 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
1143 integrator.push_with_focus(Box::new(modal));
1144
1145 assert!(integrator.is_focus_trapped());
1146
1147 let escape = Event::Key(KeyEvent {
1149 code: KeyCode::Escape,
1150 modifiers: Modifiers::empty(),
1151 kind: KeyEventKind::Press,
1152 });
1153 let result = integrator.handle_event(&escape);
1154
1155 assert!(result.is_some());
1156 assert!(!integrator.is_focus_trapped());
1157 assert_eq!(integrator.focus().current(), Some(100));
1158 }
1159 }
1160
1161 #[test]
1162 fn focus_integration_non_aria_modal_no_trap() {
1163 use crate::focus::{FocusManager, FocusNode};
1164 use ftui_core::geometry::Rect;
1165
1166 let mut stack = ModalStack::new();
1167 let mut focus = FocusManager::new();
1168
1169 focus
1170 .graph_mut()
1171 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1172 focus
1173 .graph_mut()
1174 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1175
1176 focus.focus(100);
1177
1178 {
1179 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1180
1181 let modal = WidgetModalEntry::new(StubWidget)
1183 .with_aria_modal(false)
1184 .with_focusable_ids(vec![1]);
1185 integrator.push_with_focus(Box::new(modal));
1186
1187 assert!(!integrator.is_focus_trapped());
1189 }
1190 }
1191
1192 #[test]
1193 fn focus_integration_nested_modals() {
1194 use crate::focus::{FocusManager, FocusNode};
1195 use ftui_core::geometry::Rect;
1196
1197 let mut stack = ModalStack::new();
1198 let mut focus = FocusManager::new();
1199
1200 focus
1202 .graph_mut()
1203 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1204 focus
1205 .graph_mut()
1206 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1207 focus
1208 .graph_mut()
1209 .insert(FocusNode::new(10, Rect::new(0, 5, 10, 1)));
1210 focus
1211 .graph_mut()
1212 .insert(FocusNode::new(11, Rect::new(0, 6, 10, 1)));
1213 focus
1214 .graph_mut()
1215 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1216
1217 focus.focus(100);
1218
1219 {
1220 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1221
1222 let modal1 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1224 integrator.push_with_focus(Box::new(modal1));
1225 assert_eq!(integrator.focus().current(), Some(1));
1226
1227 let modal2 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 11]);
1229 integrator.push_with_focus(Box::new(modal2));
1230 assert_eq!(integrator.focus().current(), Some(10));
1231
1232 integrator.pop_with_focus();
1234 assert_eq!(integrator.focus().current(), Some(1));
1235
1236 integrator.pop_with_focus();
1238 assert_eq!(integrator.focus().current(), Some(100));
1239 }
1240 }
1241}