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 #[must_use]
468 pub fn size(mut self, size: ModalSizeConstraints) -> Self {
469 self.size = size;
470 self
471 }
472
473 #[must_use]
475 pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
476 self.backdrop = backdrop;
477 self
478 }
479
480 #[must_use]
482 pub fn close_on_escape(mut self, close: bool) -> Self {
483 self.close_on_escape = close;
484 self
485 }
486
487 #[must_use]
489 pub fn close_on_backdrop(mut self, close: bool) -> Self {
490 self.close_on_backdrop = close;
491 self
492 }
493
494 #[must_use]
499 pub fn with_aria_modal(mut self, aria_modal: bool) -> Self {
500 self.aria_modal = aria_modal;
501 self
502 }
503
504 #[must_use]
511 pub fn with_focusable_ids(mut self, ids: Vec<ModalFocusId>) -> Self {
512 self.focusable_ids = Some(ids);
513 self
514 }
515}
516
517impl<W: crate::Widget + Send> StackModal for WidgetModalEntry<W> {
518 fn render_content(&self, area: Rect, frame: &mut Frame) {
519 self.widget.render(area, frame);
520 }
521
522 fn handle_event(&mut self, event: &Event, _hit_id: HitId) -> Option<ModalResultData> {
523 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind};
524
525 if self.close_on_escape
527 && let Event::Key(KeyEvent {
528 code: KeyCode::Escape,
529 kind: KeyEventKind::Press,
530 ..
531 }) = event
532 {
533 return Some(ModalResultData::Dismissed);
534 }
535
536 None
537 }
538
539 fn size_constraints(&self) -> ModalSizeConstraints {
540 self.size
541 }
542
543 fn backdrop_config(&self) -> BackdropConfig {
544 self.backdrop
545 }
546
547 fn close_on_escape(&self) -> bool {
548 self.close_on_escape
549 }
550
551 fn close_on_backdrop(&self) -> bool {
552 self.close_on_backdrop
553 }
554
555 fn aria_modal(&self) -> bool {
556 self.aria_modal
557 }
558
559 fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
560 self.focusable_ids.clone()
561 }
562}
563
564#[allow(dead_code)]
593pub struct ModalFocusIntegration<'a> {
594 stack: &'a mut ModalStack,
595 focus: &'a mut crate::focus::FocusManager,
596 next_group_id: u32,
597}
598
599impl<'a> ModalFocusIntegration<'a> {
600 pub fn new(stack: &'a mut ModalStack, focus: &'a mut crate::focus::FocusManager) -> Self {
602 Self {
603 stack,
604 focus,
605 next_group_id: 1000, }
607 }
608
609 pub fn push_with_focus(&mut self, modal: Box<dyn StackModal>) -> ModalId {
618 let focusable_ids = modal.focusable_ids();
619 let is_aria_modal = modal.aria_modal();
620
621 let focus_group_id = if is_aria_modal {
622 if let Some(ids) = focusable_ids {
623 let group_id = self.next_group_id;
624 self.next_group_id += 1;
625
626 let focus_ids: Vec<crate::focus::FocusId> = ids.into_iter().collect();
628
629 self.focus.create_group(group_id, focus_ids);
631 self.focus.push_trap(group_id);
632
633 Some(group_id)
634 } else {
635 None
636 }
637 } else {
638 None
639 };
640
641 self.stack.push_with_focus(modal, focus_group_id)
642 }
643
644 pub fn pop_with_focus(&mut self) -> Option<ModalResult> {
651 let result = self.stack.pop();
652
653 if let Some(ref res) = result
654 && res.focus_group_id.is_some()
655 {
656 self.focus.pop_trap();
657 }
658
659 result
660 }
661
662 pub fn handle_event(&mut self, event: &Event) -> Option<ModalResult> {
666 let result = self.stack.handle_event(event);
667
668 if let Some(ref res) = result
669 && res.focus_group_id.is_some()
670 {
671 self.focus.pop_trap();
672 }
673
674 result
675 }
676
677 pub fn is_focus_trapped(&self) -> bool {
679 self.focus.is_trapped()
680 }
681
682 pub fn stack(&self) -> &ModalStack {
684 self.stack
685 }
686
687 pub fn stack_mut(&mut self) -> &mut ModalStack {
689 self.stack
690 }
691
692 pub fn focus(&self) -> &crate::focus::FocusManager {
694 self.focus
695 }
696
697 pub fn focus_mut(&mut self) -> &mut crate::focus::FocusManager {
699 self.focus
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::Widget;
707 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
708 use ftui_render::cell::PackedRgba;
709 use ftui_render::grapheme_pool::GraphemePool;
710
711 #[derive(Debug, Clone)]
712 struct StubWidget;
713
714 impl Widget for StubWidget {
715 fn render(&self, _area: Rect, _frame: &mut Frame) {}
716 }
717
718 #[test]
719 fn empty_stack() {
720 let stack = ModalStack::new();
721 assert!(stack.is_empty());
722 assert_eq!(stack.depth(), 0);
723 assert!(stack.top().is_none());
724 assert!(stack.top_id().is_none());
725 }
726
727 #[test]
728 fn push_increases_depth() {
729 let mut stack = ModalStack::new();
730 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
731 assert_eq!(stack.depth(), 1);
732 assert!(!stack.is_empty());
733 assert!(stack.contains(id1));
734
735 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
736 assert_eq!(stack.depth(), 2);
737 assert!(stack.contains(id2));
738 assert_eq!(stack.top_id(), Some(id2));
739 }
740
741 #[test]
742 fn pop_lifo_order() {
743 let mut stack = ModalStack::new();
744 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
745 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
746 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
747
748 let result = stack.pop();
749 assert_eq!(result.map(|r| r.id), Some(id3));
750 assert_eq!(stack.depth(), 2);
751
752 let result = stack.pop();
753 assert_eq!(result.map(|r| r.id), Some(id2));
754 assert_eq!(stack.depth(), 1);
755
756 let result = stack.pop();
757 assert_eq!(result.map(|r| r.id), Some(id1));
758 assert!(stack.is_empty());
759 }
760
761 #[test]
762 fn pop_empty_returns_none() {
763 let mut stack = ModalStack::new();
764 assert!(stack.pop().is_none());
765 }
766
767 #[test]
768 fn pop_by_id() {
769 let mut stack = ModalStack::new();
770 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
771 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
772 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
773
774 let result = stack.pop_id(id2);
776 assert_eq!(result.map(|r| r.id), Some(id2));
777 assert_eq!(stack.depth(), 2);
778 assert!(!stack.contains(id2));
779 assert!(stack.contains(id1));
780 assert!(stack.contains(id3));
781 }
782
783 #[test]
784 fn pop_by_nonexistent_id() {
785 let mut stack = ModalStack::new();
786 let _id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
787
788 let fake_id = ModalId(999999);
790 assert!(stack.pop_id(fake_id).is_none());
791 assert_eq!(stack.depth(), 1);
792 }
793
794 #[test]
795 fn pop_all() {
796 let mut stack = ModalStack::new();
797 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
798 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
799 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
800
801 let results = stack.pop_all();
802 assert_eq!(results.len(), 3);
803 assert_eq!(results[0].id, id3);
805 assert_eq!(results[1].id, id2);
806 assert_eq!(results[2].id, id1);
807 assert!(stack.is_empty());
808 }
809
810 #[test]
811 fn z_order_increasing() {
812 let mut stack = ModalStack::new();
813
814 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
816 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
817 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
818
819 let z_indices: Vec<u32> = stack.modals.iter().map(|m| m.z_index).collect();
821 for i in 1..z_indices.len() {
822 assert!(
823 z_indices[i] > z_indices[i - 1],
824 "z_index should be strictly increasing"
825 );
826 }
827 }
828
829 #[test]
830 fn escape_closes_top_modal() {
831 let mut stack = ModalStack::new();
832 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
833 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
834
835 let escape = Event::Key(KeyEvent {
836 code: KeyCode::Escape,
837 modifiers: Modifiers::empty(),
838 kind: KeyEventKind::Press,
839 });
840
841 let result = stack.handle_event(&escape);
843 assert!(result.is_some());
844 assert_eq!(result.unwrap().id, id2);
845 assert_eq!(stack.depth(), 1);
846 assert_eq!(stack.top_id(), Some(id1));
847 }
848
849 #[test]
850 fn render_does_not_panic() {
851 let mut stack = ModalStack::new();
852 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
853 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
854
855 let mut pool = GraphemePool::new();
856 let mut frame = Frame::new(80, 24, &mut pool);
857 let screen = Rect::new(0, 0, 80, 24);
858
859 stack.render(&mut frame, screen);
861 }
862
863 #[test]
864 fn render_empty_stack_no_op() {
865 let stack = ModalStack::new();
866 let mut pool = GraphemePool::new();
867 let mut frame = Frame::new(80, 24, &mut pool);
868 let screen = Rect::new(0, 0, 80, 24);
869
870 stack.render(&mut frame, screen);
872 }
873
874 #[test]
875 fn contains_after_pop() {
876 let mut stack = ModalStack::new();
877 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
878
879 assert!(stack.contains(id1));
880 stack.pop();
881 assert!(!stack.contains(id1));
882 }
883
884 #[test]
885 fn unique_modal_ids() {
886 let mut stack = ModalStack::new();
887 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
888 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
889 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
890
891 assert_ne!(id1, id2);
892 assert_ne!(id2, id3);
893 assert_ne!(id1, id3);
894 }
895
896 #[test]
897 fn widget_modal_entry_builder() {
898 let entry = WidgetModalEntry::new(StubWidget)
899 .size(ModalSizeConstraints::new().min_width(40).max_width(80))
900 .backdrop(BackdropConfig::new(PackedRgba::rgb(0, 0, 0), 0.8))
901 .close_on_escape(false)
902 .close_on_backdrop(false);
903
904 assert!(!entry.close_on_escape);
905 assert!(!entry.close_on_backdrop);
906 assert_eq!(entry.size.min_width, Some(40));
907 assert_eq!(entry.size.max_width, Some(80));
908 }
909
910 #[test]
911 fn escape_disabled_does_not_close() {
912 let mut stack = ModalStack::new();
913 stack.push(Box::new(
914 WidgetModalEntry::new(StubWidget).close_on_escape(false),
915 ));
916
917 let escape = Event::Key(KeyEvent {
918 code: KeyCode::Escape,
919 modifiers: Modifiers::empty(),
920 kind: KeyEventKind::Press,
921 });
922
923 let result = stack.handle_event(&escape);
925 assert!(result.is_none());
926 assert_eq!(stack.depth(), 1);
927 }
928
929 #[test]
932 fn push_with_focus_tracks_group_id() {
933 let mut stack = ModalStack::new();
934 let modal_id = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(42));
935
936 assert_eq!(stack.focus_group_id(modal_id), Some(42));
937 assert_eq!(stack.top_focus_group_id(), Some(42));
938 }
939
940 #[test]
941 fn pop_returns_focus_group_id() {
942 let mut stack = ModalStack::new();
943 stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(99));
944
945 let result = stack.pop();
946 assert!(result.is_some());
947 assert_eq!(result.unwrap().focus_group_id, Some(99));
948 }
949
950 #[test]
951 fn pop_id_returns_focus_group_id() {
952 let mut stack = ModalStack::new();
953 let id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(10));
954 let _id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(20));
955
956 let result = stack.pop_id(id1);
957 assert!(result.is_some());
958 assert_eq!(result.unwrap().focus_group_id, Some(10));
959 }
960
961 #[test]
962 fn handle_event_returns_focus_group_id() {
963 let mut stack = ModalStack::new();
964 stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(77));
965
966 let escape = Event::Key(KeyEvent {
967 code: KeyCode::Escape,
968 modifiers: Modifiers::empty(),
969 kind: KeyEventKind::Press,
970 });
971
972 let result = stack.handle_event(&escape);
973 assert!(result.is_some());
974 assert_eq!(result.unwrap().focus_group_id, Some(77));
975 }
976
977 #[test]
978 fn push_without_focus_has_none_group_id() {
979 let mut stack = ModalStack::new();
980 let modal_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
981
982 assert_eq!(stack.focus_group_id(modal_id), None);
983 assert_eq!(stack.top_focus_group_id(), None);
984 }
985
986 #[test]
987 fn nested_focus_groups_track_correctly() {
988 let mut stack = ModalStack::new();
989 let _id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(1));
990 let id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(2));
991 let _id3 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(3));
992
993 assert_eq!(stack.top_focus_group_id(), Some(3));
995
996 stack.pop();
998 assert_eq!(stack.top_focus_group_id(), Some(2));
999
1000 assert_eq!(stack.focus_group_id(id2), Some(2));
1002 }
1003
1004 #[test]
1007 fn default_aria_modal_is_true() {
1008 let entry = WidgetModalEntry::new(StubWidget);
1009 assert!(entry.aria_modal);
1010 }
1011
1012 #[test]
1013 fn aria_modal_builder() {
1014 let entry = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1015 assert!(!entry.aria_modal);
1016 }
1017
1018 #[test]
1019 fn focusable_ids_builder() {
1020 let entry = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2, 3]);
1021 assert_eq!(entry.focusable_ids, Some(vec![1, 2, 3]));
1022 }
1023
1024 #[test]
1025 fn stack_modal_aria_modal_trait() {
1026 let entry = WidgetModalEntry::new(StubWidget);
1027 assert!(StackModal::aria_modal(&entry)); let entry_non_aria = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1030 assert!(!StackModal::aria_modal(&entry_non_aria));
1031 }
1032
1033 #[test]
1034 fn stack_modal_focusable_ids_trait() {
1035 let entry = WidgetModalEntry::new(StubWidget);
1036 assert!(StackModal::focusable_ids(&entry).is_none()); let entry_with_ids = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 20]);
1039 assert_eq!(
1040 StackModal::focusable_ids(&entry_with_ids),
1041 Some(vec![10, 20])
1042 );
1043 }
1044
1045 #[test]
1048 fn focus_integration_push_creates_trap() {
1049 use crate::focus::{FocusManager, FocusNode};
1050 use ftui_core::geometry::Rect;
1051
1052 let mut stack = ModalStack::new();
1053 let mut focus = FocusManager::new();
1054
1055 focus
1057 .graph_mut()
1058 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1059 focus
1060 .graph_mut()
1061 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1062 focus
1063 .graph_mut()
1064 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); focus.focus(100);
1068 assert_eq!(focus.current(), Some(100));
1069
1070 {
1071 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1072
1073 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1075 let _modal_id = integrator.push_with_focus(Box::new(modal));
1076
1077 assert!(integrator.is_focus_trapped());
1079
1080 assert_eq!(integrator.focus().current(), Some(1));
1082 }
1083 }
1084
1085 #[test]
1086 fn focus_integration_pop_restores_focus() {
1087 use crate::focus::{FocusManager, FocusNode};
1088 use ftui_core::geometry::Rect;
1089
1090 let mut stack = ModalStack::new();
1091 let mut focus = FocusManager::new();
1092
1093 focus
1095 .graph_mut()
1096 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1097 focus
1098 .graph_mut()
1099 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1100 focus
1101 .graph_mut()
1102 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); focus.focus(100);
1106 assert_eq!(focus.current(), Some(100));
1107
1108 {
1109 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1110
1111 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1113 integrator.push_with_focus(Box::new(modal));
1114
1115 assert!(integrator.is_focus_trapped());
1117
1118 let result = integrator.pop_with_focus();
1120 assert!(result.is_some());
1121
1122 assert!(!integrator.is_focus_trapped());
1124 assert_eq!(integrator.focus().current(), Some(100));
1125 }
1126 }
1127
1128 #[test]
1129 fn focus_integration_escape_restores_focus() {
1130 use crate::focus::{FocusManager, FocusNode};
1131 use ftui_core::geometry::Rect;
1132
1133 let mut stack = ModalStack::new();
1134 let mut focus = FocusManager::new();
1135
1136 focus
1137 .graph_mut()
1138 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1139 focus
1140 .graph_mut()
1141 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1142
1143 focus.focus(100);
1144
1145 {
1146 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1147
1148 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
1149 integrator.push_with_focus(Box::new(modal));
1150
1151 assert!(integrator.is_focus_trapped());
1152
1153 let escape = Event::Key(KeyEvent {
1155 code: KeyCode::Escape,
1156 modifiers: Modifiers::empty(),
1157 kind: KeyEventKind::Press,
1158 });
1159 let result = integrator.handle_event(&escape);
1160
1161 assert!(result.is_some());
1162 assert!(!integrator.is_focus_trapped());
1163 assert_eq!(integrator.focus().current(), Some(100));
1164 }
1165 }
1166
1167 #[test]
1168 fn focus_integration_non_aria_modal_no_trap() {
1169 use crate::focus::{FocusManager, FocusNode};
1170 use ftui_core::geometry::Rect;
1171
1172 let mut stack = ModalStack::new();
1173 let mut focus = FocusManager::new();
1174
1175 focus
1176 .graph_mut()
1177 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1178 focus
1179 .graph_mut()
1180 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1181
1182 focus.focus(100);
1183
1184 {
1185 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1186
1187 let modal = WidgetModalEntry::new(StubWidget)
1189 .with_aria_modal(false)
1190 .with_focusable_ids(vec![1]);
1191 integrator.push_with_focus(Box::new(modal));
1192
1193 assert!(!integrator.is_focus_trapped());
1195 }
1196 }
1197
1198 #[test]
1199 fn focus_integration_nested_modals() {
1200 use crate::focus::{FocusManager, FocusNode};
1201 use ftui_core::geometry::Rect;
1202
1203 let mut stack = ModalStack::new();
1204 let mut focus = FocusManager::new();
1205
1206 focus
1208 .graph_mut()
1209 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1210 focus
1211 .graph_mut()
1212 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1213 focus
1214 .graph_mut()
1215 .insert(FocusNode::new(10, Rect::new(0, 5, 10, 1)));
1216 focus
1217 .graph_mut()
1218 .insert(FocusNode::new(11, Rect::new(0, 6, 10, 1)));
1219 focus
1220 .graph_mut()
1221 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1222
1223 focus.focus(100);
1224
1225 {
1226 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1227
1228 let modal1 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1230 integrator.push_with_focus(Box::new(modal1));
1231 assert_eq!(integrator.focus().current(), Some(1));
1232
1233 let modal2 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 11]);
1235 integrator.push_with_focus(Box::new(modal2));
1236 assert_eq!(integrator.focus().current(), Some(10));
1237
1238 integrator.pop_with_focus();
1240 assert_eq!(integrator.focus().current(), Some(1));
1241
1242 integrator.pop_with_focus();
1244 assert_eq!(integrator.focus().current(), Some(100));
1245 }
1246 }
1247}