1#![forbid(unsafe_code)]
2
3use ftui_core::event::Event;
42use ftui_core::geometry::Rect;
43use ftui_render::frame::{Frame, HitData, HitId, HitRegion, HitTestResult};
44use ftui_style::Style;
45use std::sync::atomic::{AtomicU64, Ordering};
46
47use crate::focus::FocusId;
48use crate::modal::{BackdropConfig, MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT, ModalSizeConstraints};
49use crate::set_style_area;
50
51#[cfg(test)]
52use super::focus_integration::{ModalFocusCoordinator, next_focus_group_id};
53
54#[cfg(feature = "tracing")]
55use web_time::Instant;
56
57const BASE_MODAL_Z: u32 = 1000;
59
60const Z_INCREMENT: u32 = 10;
62
63static MODAL_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub struct ModalId(u64);
69
70impl ModalId {
71 fn new() -> Self {
73 Self(MODAL_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
74 }
75
76 #[inline]
78 pub const fn id(self) -> u64 {
79 self.0
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct ModalResult {
86 pub id: ModalId,
88 pub data: Option<ModalResultData>,
90 pub focus_group_id: Option<u32>,
92}
93
94#[derive(Debug, Clone)]
96pub enum ModalResultData {
97 Dismissed,
99 Confirmed,
101 Custom(String),
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub(super) struct FocusTrapSpec {
107 pub group_id: u32,
108 pub return_focus: Option<FocusId>,
109}
110
111pub type ModalFocusId = u64;
113
114pub trait StackModal: Send {
131 fn modal_type(&self) -> &'static str {
135 std::any::type_name::<Self>()
136 }
137
138 fn render_content(&self, area: Rect, frame: &mut Frame);
140
141 fn handle_event(
146 &mut self,
147 event: &Event,
148 hit: Option<(HitId, HitRegion, HitData)>,
149 hit_id: HitId,
150 ) -> Option<ModalResultData>;
151
152 fn size_constraints(&self) -> ModalSizeConstraints;
154
155 fn backdrop_config(&self) -> BackdropConfig;
157
158 fn close_on_escape(&self) -> bool {
160 true
161 }
162
163 fn close_on_backdrop(&self) -> bool {
165 true
166 }
167
168 fn aria_modal(&self) -> bool {
184 true
185 }
186
187 fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
205 None
206 }
207}
208
209struct ActiveModal {
211 id: ModalId,
213 #[allow(dead_code)]
215 z_index: u32,
216 modal: Box<dyn StackModal>,
218 hit_id: HitId,
220 focus_group_id: Option<u32>,
222 focus_return_focus: Option<FocusId>,
224}
225
226pub struct ModalStack {
234 modals: Vec<ActiveModal>,
236 next_z: u32,
238 next_hit_id: u32,
240}
241
242impl Default for ModalStack {
243 fn default() -> Self {
244 Self::new()
245 }
246}
247
248impl ModalStack {
249 pub fn new() -> Self {
251 Self {
252 modals: Vec::new(),
253 next_z: 0,
254 next_hit_id: 1000, }
256 }
257
258 pub fn push(&mut self, modal: Box<dyn StackModal>) -> ModalId {
264 self.push_with_focus(modal, None)
265 }
266
267 pub fn push_with_focus(
276 &mut self,
277 modal: Box<dyn StackModal>,
278 focus_group_id: Option<u32>,
279 ) -> ModalId {
280 #[cfg(feature = "tracing")]
281 let modal_type = modal.modal_type();
282 #[cfg(feature = "tracing")]
283 let focus_trapped = focus_group_id.is_some() && modal.aria_modal();
284
285 let id = ModalId::new();
286 let z_index = BASE_MODAL_Z + self.next_z;
287 self.next_z += Z_INCREMENT;
288
289 let hit_id = HitId::new(self.next_hit_id);
290 self.next_hit_id += 1;
291
292 self.modals.push(ActiveModal {
293 id,
294 z_index,
295 modal,
296 hit_id,
297 focus_group_id,
298 focus_return_focus: None,
299 });
300
301 #[cfg(feature = "tracing")]
302 tracing::debug!(
303 modal_id = id.id(),
304 modal_type,
305 focus_trapped,
306 depth = self.modals.len(),
307 "modal opened"
308 );
309
310 id
311 }
312
313 pub fn focus_group_id(&self, modal_id: ModalId) -> Option<u32> {
317 self.modals
318 .iter()
319 .find(|m| m.id == modal_id)
320 .and_then(|m| m.focus_group_id)
321 }
322
323 pub fn top_focus_group_id(&self) -> Option<u32> {
327 self.modals.last().and_then(|m| m.focus_group_id)
328 }
329
330 pub(super) fn next_focus_modal_after(&self, modal_id: ModalId) -> Option<(ModalId, u32)> {
331 let idx = self.modals.iter().position(|modal| modal.id == modal_id)?;
332 self.modals[idx + 1..]
333 .iter()
334 .find_map(|modal| modal.focus_group_id.map(|group_id| (modal.id, group_id)))
335 }
336
337 pub fn pop(&mut self) -> Option<ModalResult> {
342 let modal = self.modals.pop()?;
343 #[cfg(feature = "tracing")]
344 let modal_type = modal.modal.modal_type();
345
346 let result = ModalResult {
347 id: modal.id,
348 data: None,
349 focus_group_id: modal.focus_group_id,
350 };
351
352 #[cfg(feature = "tracing")]
353 tracing::debug!(
354 modal_id = result.id.id(),
355 modal_type,
356 depth = self.modals.len(),
357 "modal closed"
358 );
359
360 Some(result)
361 }
362
363 pub fn pop_id(&mut self, id: ModalId) -> Option<ModalResult> {
369 self.pop_id_with_restore_retarget(id, true)
370 }
371
372 pub(super) fn pop_id_with_restore_retarget(
373 &mut self,
374 id: ModalId,
375 retarget_upper_return_focus: bool,
376 ) -> Option<ModalResult> {
377 let idx = self.modals.iter().position(|m| m.id == id)?;
378 let modal = self.modals.remove(idx);
379 #[cfg(feature = "tracing")]
380 let modal_type = modal.modal.modal_type();
381
382 if retarget_upper_return_focus
383 && modal.focus_group_id.is_some()
384 && let Some(upper_modal) = self.modals[idx..]
385 .iter_mut()
386 .find(|candidate| candidate.focus_group_id.is_some())
387 {
388 upper_modal.focus_return_focus = modal.focus_return_focus;
389 }
390
391 let result = ModalResult {
392 id: modal.id,
393 data: None,
394 focus_group_id: modal.focus_group_id,
395 };
396
397 #[cfg(feature = "tracing")]
398 tracing::debug!(
399 modal_id = result.id.id(),
400 modal_type,
401 depth = self.modals.len(),
402 "modal closed (pop_id)"
403 );
404
405 Some(result)
406 }
407
408 pub fn pop_all(&mut self) -> Vec<ModalResult> {
412 let mut results = Vec::with_capacity(self.modals.len());
413 while let Some(result) = self.pop() {
414 results.push(result);
415 }
416 results
417 }
418
419 pub fn top(&self) -> Option<&(dyn StackModal + 'static)> {
421 self.modals.last().map(|m| &*m.modal)
422 }
423
424 pub fn top_mut(&mut self) -> Option<&mut (dyn StackModal + 'static)> {
426 match self.modals.last_mut() {
427 Some(m) => Some(m.modal.as_mut()),
428 None => None,
429 }
430 }
431
432 #[inline]
436 pub fn is_empty(&self) -> bool {
437 self.modals.is_empty()
438 }
439
440 #[inline]
442 pub fn depth(&self) -> usize {
443 self.modals.len()
444 }
445
446 pub fn contains(&self, id: ModalId) -> bool {
448 self.modals.iter().any(|m| m.id == id)
449 }
450
451 pub fn top_id(&self) -> Option<ModalId> {
453 self.modals.last().map(|m| m.id)
454 }
455
456 pub fn focus_group_ids_in_order(&self) -> Vec<u32> {
458 self.modals
459 .iter()
460 .filter_map(|m| m.focus_group_id)
461 .collect()
462 }
463
464 pub(super) fn focus_modal_specs_in_order(&self) -> Vec<(ModalId, FocusTrapSpec)> {
465 self.modals
466 .iter()
467 .filter_map(|modal| {
468 modal.focus_group_id.map(|group_id| {
469 (
470 modal.id,
471 FocusTrapSpec {
472 group_id,
473 return_focus: modal.focus_return_focus,
474 },
475 )
476 })
477 })
478 .collect()
479 }
480
481 pub(super) fn set_focus_return_focus(
482 &mut self,
483 modal_id: ModalId,
484 return_focus: Option<FocusId>,
485 ) -> bool {
486 let Some(modal) = self.modals.iter_mut().find(|modal| modal.id == modal_id) else {
487 return false;
488 };
489 if modal.focus_group_id.is_none() {
490 return false;
491 }
492 modal.focus_return_focus = return_focus;
493 true
494 }
495
496 pub fn handle_event(
508 &mut self,
509 event: &Event,
510 hit: Option<HitTestResult>,
511 ) -> Option<ModalResult> {
512 let top_index = self.modals.len().checked_sub(1)?;
513 let top_owner = self.modals[top_index].id.id();
514 let hit_id = self.modals[top_index].hit_id;
515 let filtered_hit = hit.filter(|hit| hit.owner == Some(top_owner));
516 let top = &mut self.modals[top_index];
517 let id = top.id;
518 let focus_group_id = top.focus_group_id;
519 #[cfg(feature = "tracing")]
520 let modal_type = top.modal.modal_type();
521
522 if let Some(data) =
523 top.modal
524 .handle_event(event, filtered_hit.map(HitTestResult::into_tuple), hit_id)
525 {
526 self.modals.pop();
528 let result = ModalResult {
529 id,
530 data: Some(data),
531 focus_group_id,
532 };
533
534 #[cfg(feature = "tracing")]
535 tracing::debug!(
536 modal_id = result.id.id(),
537 modal_type,
538 result_data = ?result.data,
539 depth = self.modals.len(),
540 "modal closed (event)"
541 );
542
543 return Some(result);
544 }
545
546 None
547 }
548
549 pub fn render(&self, frame: &mut Frame, screen: Rect) {
556 if self.modals.is_empty() {
557 return;
558 }
559
560 let modal_count = self.modals.len();
561
562 for (i, modal) in self.modals.iter().enumerate() {
563 let is_top = i == modal_count - 1;
564
565 let base_opacity = modal.modal.backdrop_config().opacity;
567 let opacity = if is_top {
568 base_opacity
569 } else {
570 base_opacity * 0.5
572 };
573
574 #[cfg(feature = "tracing")]
575 let render_start = Instant::now();
576 #[cfg(feature = "tracing")]
577 let render_span = tracing::debug_span!(
578 "modal.render",
579 modal_type = modal.modal.modal_type(),
580 focus_trapped = (modal.focus_group_id.is_some() && modal.modal.aria_modal()),
581 backdrop_active = (opacity > 0.0),
582 render_duration_us = tracing::field::Empty,
583 );
584 #[cfg(feature = "tracing")]
585 let _render_guard = render_span.enter();
586
587 if opacity > 0.0 {
589 let bg_color = modal.modal.backdrop_config().color.with_opacity(opacity);
590 set_style_area(&mut frame.buffer, screen, Style::new().bg(bg_color));
591 }
592
593 frame.with_hit_owner(modal.id.id(), |frame| {
594 if !screen.is_empty() {
598 frame.register_hit(screen, modal.hit_id, MODAL_HIT_BACKDROP, 0);
599 }
600
601 let constraints = modal.modal.size_constraints();
603 let available = ftui_core::geometry::Size::new(screen.width, screen.height);
604 let size = constraints.clamp(available);
605
606 if size.width == 0 || size.height == 0 {
607 return;
608 }
609
610 let x = screen.x + (screen.width.saturating_sub(size.width)) / 2;
612 let y = screen.y + (screen.height.saturating_sub(size.height)) / 2;
613 let content_area = Rect::new(x, y, size.width, size.height);
614
615 if !content_area.is_empty() {
618 frame.register_hit(content_area, modal.hit_id, MODAL_HIT_CONTENT, 0);
619 }
620
621 modal.modal.render_content(content_area, frame);
623 });
624
625 #[cfg(feature = "tracing")]
626 {
627 let elapsed = render_start.elapsed();
628 render_span.record("render_duration_us", elapsed.as_micros() as u64);
629 }
630 }
631 }
632}
633
634pub struct WidgetModalEntry<W> {
636 widget: W,
637 size: ModalSizeConstraints,
638 backdrop: BackdropConfig,
639 close_on_escape: bool,
640 close_on_backdrop: bool,
641 aria_modal: bool,
642 focusable_ids: Option<Vec<ModalFocusId>>,
643}
644
645impl<W> WidgetModalEntry<W> {
646 pub fn new(widget: W) -> Self {
648 Self {
649 widget,
650 size: ModalSizeConstraints::new()
651 .min_width(30)
652 .max_width(60)
653 .min_height(10)
654 .max_height(20),
655 backdrop: BackdropConfig::default(),
656 close_on_escape: true,
657 close_on_backdrop: true,
658 aria_modal: true,
659 focusable_ids: None,
660 }
661 }
662
663 #[must_use]
665 pub fn size(mut self, size: ModalSizeConstraints) -> Self {
666 self.size = size;
667 self
668 }
669
670 #[must_use]
672 pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
673 self.backdrop = backdrop;
674 self
675 }
676
677 #[must_use]
679 pub fn close_on_escape(mut self, close: bool) -> Self {
680 self.close_on_escape = close;
681 self
682 }
683
684 #[must_use]
686 pub fn close_on_backdrop(mut self, close: bool) -> Self {
687 self.close_on_backdrop = close;
688 self
689 }
690
691 #[must_use]
696 pub fn with_aria_modal(mut self, aria_modal: bool) -> Self {
697 self.aria_modal = aria_modal;
698 self
699 }
700
701 #[must_use]
708 pub fn with_focusable_ids(mut self, ids: Vec<ModalFocusId>) -> Self {
709 self.focusable_ids = Some(ids);
710 self
711 }
712}
713
714impl<W: crate::Widget + Send> StackModal for WidgetModalEntry<W> {
715 fn render_content(&self, area: Rect, frame: &mut Frame) {
716 self.widget.render(area, frame);
717 }
718
719 fn handle_event(
720 &mut self,
721 event: &Event,
722 hit: Option<(HitId, HitRegion, HitData)>,
723 hit_id: HitId,
724 ) -> Option<ModalResultData> {
725 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind};
726
727 if self.close_on_backdrop
728 && let Event::Mouse(ftui_core::event::MouseEvent {
729 kind: ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
730 ..
731 }) = event
732 && let Some((id, region, _)) = hit
733 && id == hit_id
734 && region == MODAL_HIT_BACKDROP
735 {
736 return Some(ModalResultData::Dismissed);
737 }
738
739 if self.close_on_escape
741 && let Event::Key(KeyEvent {
742 code: KeyCode::Escape,
743 kind: KeyEventKind::Press,
744 ..
745 }) = event
746 {
747 return Some(ModalResultData::Dismissed);
748 }
749
750 None
751 }
752
753 fn size_constraints(&self) -> ModalSizeConstraints {
754 self.size
755 }
756
757 fn backdrop_config(&self) -> BackdropConfig {
758 self.backdrop
759 }
760
761 fn close_on_escape(&self) -> bool {
762 self.close_on_escape
763 }
764
765 fn close_on_backdrop(&self) -> bool {
766 self.close_on_backdrop
767 }
768
769 fn aria_modal(&self) -> bool {
770 self.aria_modal
771 }
772
773 fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
774 self.focusable_ids.clone()
775 }
776}
777
778#[cfg(test)]
807#[allow(dead_code)]
808pub struct ModalFocusIntegration<'a> {
809 stack: &'a mut ModalStack,
810 focus: &'a mut crate::focus::FocusManager,
811 base_focus: Option<Option<crate::focus::FocusId>>,
812}
813
814#[cfg(test)]
815#[allow(dead_code)]
816impl<'a> ModalFocusIntegration<'a> {
817 pub fn new(stack: &'a mut ModalStack, focus: &'a mut crate::focus::FocusManager) -> Self {
819 let base_focus = focus.base_trap_return_focus();
820 Self {
821 stack,
822 focus,
823 base_focus,
824 }
825 }
826
827 pub fn push_with_focus(&mut self, modal: Box<dyn StackModal>) -> ModalId {
836 let focusable_ids = modal.focusable_ids();
837 let is_aria_modal = modal.aria_modal();
838 ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus)
839 .push_modal_with_trap(modal, focusable_ids, is_aria_modal, next_focus_group_id)
840 }
841
842 pub fn pop_with_focus(&mut self) -> Option<ModalResult> {
849 ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus).pop_modal()
850 }
851
852 pub fn pop_id_with_focus(&mut self, id: ModalId) -> Option<ModalResult> {
854 ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus).pop_modal_by_id(id)
855 }
856
857 pub fn pop_all_with_focus(&mut self) -> Vec<ModalResult> {
859 ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus).pop_all_modals()
860 }
861
862 pub fn handle_event(
866 &mut self,
867 event: &Event,
868 hit: Option<HitTestResult>,
869 ) -> Option<ModalResult> {
870 ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus)
871 .handle_modal_event(event, hit)
872 }
873
874 pub fn is_focus_trapped(&self) -> bool {
876 self.focus.is_trapped()
877 }
878
879 pub fn stack(&self) -> &ModalStack {
881 self.stack
882 }
883
884 pub fn stack_mut(&mut self) -> &mut ModalStack {
889 self.stack
890 }
891
892 pub fn focus(&self) -> &crate::focus::FocusManager {
894 self.focus
895 }
896
897 pub fn focus_mut(&mut self) -> &mut crate::focus::FocusManager {
902 self.focus
903 }
904
905 pub fn resync_focus_state(&mut self) {
907 let mut coordinator =
908 ModalFocusCoordinator::new(self.stack, self.focus, &mut self.base_focus);
909 coordinator.rebuild_focus_traps();
910 coordinator.refresh_inactive_modal_return_focus_targets();
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use crate::Widget;
918 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
919 use ftui_render::cell::PackedRgba;
920 use ftui_render::grapheme_pool::GraphemePool;
921 #[cfg(feature = "tracing")]
922 use std::sync::{Arc, Mutex};
923
924 #[cfg(feature = "tracing")]
925 use tracing::Subscriber;
926 #[cfg(feature = "tracing")]
927 use tracing_subscriber::Layer;
928 #[cfg(feature = "tracing")]
929 use tracing_subscriber::layer::{Context, SubscriberExt};
930
931 #[derive(Debug, Clone)]
932 struct StubWidget;
933
934 impl Widget for StubWidget {
935 fn render(&self, _area: Rect, _frame: &mut Frame) {}
936 }
937
938 #[derive(Debug, Default)]
939 struct CloseOnAnyHitModal;
940
941 #[derive(Debug, Default)]
942 struct CloseOnBackdropHitModal;
943
944 #[derive(Debug, Default)]
945 struct CloseOnInnerHitModal;
946
947 #[derive(Debug, Default)]
948 struct CloseOnCollidingInnerHitModal;
949
950 impl StackModal for CloseOnAnyHitModal {
951 fn render_content(&self, _area: Rect, _frame: &mut Frame) {}
952
953 fn handle_event(
954 &mut self,
955 _event: &Event,
956 hit: Option<(HitId, HitRegion, HitData)>,
957 _hit_id: HitId,
958 ) -> Option<ModalResultData> {
959 hit.map(|_| ModalResultData::Dismissed)
960 }
961
962 fn size_constraints(&self) -> ModalSizeConstraints {
963 ModalSizeConstraints::new()
964 .min_width(10)
965 .max_width(10)
966 .min_height(3)
967 .max_height(3)
968 }
969
970 fn backdrop_config(&self) -> BackdropConfig {
971 BackdropConfig::default()
972 }
973
974 fn close_on_backdrop(&self) -> bool {
975 false
976 }
977 }
978
979 impl StackModal for CloseOnBackdropHitModal {
980 fn render_content(&self, _area: Rect, _frame: &mut Frame) {}
981
982 fn handle_event(
983 &mut self,
984 event: &Event,
985 hit: Option<(HitId, HitRegion, HitData)>,
986 hit_id: HitId,
987 ) -> Option<ModalResultData> {
988 if let Event::Mouse(ftui_core::event::MouseEvent {
989 kind: ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
990 ..
991 }) = event
992 && let Some((id, region, _)) = hit
993 && id == hit_id
994 && region == MODAL_HIT_BACKDROP
995 {
996 return Some(ModalResultData::Dismissed);
997 }
998
999 None
1000 }
1001
1002 fn size_constraints(&self) -> ModalSizeConstraints {
1003 ModalSizeConstraints::new()
1004 .min_width(10)
1005 .max_width(10)
1006 .min_height(3)
1007 .max_height(3)
1008 }
1009
1010 fn backdrop_config(&self) -> BackdropConfig {
1011 BackdropConfig::default()
1012 }
1013
1014 fn close_on_backdrop(&self) -> bool {
1015 false
1016 }
1017 }
1018
1019 impl StackModal for CloseOnInnerHitModal {
1020 fn render_content(&self, area: Rect, frame: &mut Frame) {
1021 if !area.is_empty() {
1022 frame.register_hit(area, HitId::new(4242), HitRegion::Custom(99), 0);
1023 }
1024 }
1025
1026 fn handle_event(
1027 &mut self,
1028 _event: &Event,
1029 hit: Option<(HitId, HitRegion, HitData)>,
1030 _hit_id: HitId,
1031 ) -> Option<ModalResultData> {
1032 if let Some((id, region, _)) = hit
1033 && id == HitId::new(4242)
1034 && region == HitRegion::Custom(99)
1035 {
1036 return Some(ModalResultData::Dismissed);
1037 }
1038
1039 None
1040 }
1041
1042 fn size_constraints(&self) -> ModalSizeConstraints {
1043 ModalSizeConstraints::new()
1044 .min_width(10)
1045 .max_width(10)
1046 .min_height(3)
1047 .max_height(3)
1048 }
1049
1050 fn backdrop_config(&self) -> BackdropConfig {
1051 BackdropConfig::default()
1052 }
1053
1054 fn close_on_backdrop(&self) -> bool {
1055 false
1056 }
1057 }
1058
1059 impl StackModal for CloseOnCollidingInnerHitModal {
1060 fn render_content(&self, area: Rect, frame: &mut Frame) {
1061 if !area.is_empty() {
1062 frame.register_hit(area, HitId::new(1000), HitRegion::Custom(100), 0);
1063 }
1064 }
1065
1066 fn handle_event(
1067 &mut self,
1068 _event: &Event,
1069 hit: Option<(HitId, HitRegion, HitData)>,
1070 _hit_id: HitId,
1071 ) -> Option<ModalResultData> {
1072 if let Some((id, region, _)) = hit
1073 && id == HitId::new(1000)
1074 && region == HitRegion::Custom(100)
1075 {
1076 return Some(ModalResultData::Dismissed);
1077 }
1078
1079 None
1080 }
1081
1082 fn size_constraints(&self) -> ModalSizeConstraints {
1083 ModalSizeConstraints::new()
1084 .min_width(10)
1085 .max_width(10)
1086 .min_height(3)
1087 .max_height(3)
1088 }
1089
1090 fn backdrop_config(&self) -> BackdropConfig {
1091 BackdropConfig::default()
1092 }
1093
1094 fn close_on_backdrop(&self) -> bool {
1095 false
1096 }
1097 }
1098
1099 #[cfg(feature = "tracing")]
1100 #[derive(Debug, Default)]
1101 struct TraceState {
1102 modal_render_seen: bool,
1103 modal_render_has_modal_type: bool,
1104 modal_render_has_focus_trapped: bool,
1105 modal_render_has_backdrop_active: bool,
1106 modal_render_duration_recorded: bool,
1107 focus_change_count: usize,
1108 trap_push_count: usize,
1109 trap_pop_count: usize,
1110 }
1111
1112 #[cfg(feature = "tracing")]
1113 struct TraceCapture {
1114 state: Arc<Mutex<TraceState>>,
1115 }
1116
1117 #[cfg(feature = "tracing")]
1118 impl<S> Layer<S> for TraceCapture
1119 where
1120 S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
1121 {
1122 fn on_new_span(
1123 &self,
1124 attrs: &tracing::span::Attributes<'_>,
1125 _id: &tracing::Id,
1126 _ctx: Context<'_, S>,
1127 ) {
1128 if attrs.metadata().name() != "modal.render" {
1129 return;
1130 }
1131 let fields = attrs.metadata().fields();
1132 let mut state = self.state.lock().expect("trace state lock");
1133 state.modal_render_seen = true;
1134 state.modal_render_has_modal_type |= fields.field("modal_type").is_some();
1135 state.modal_render_has_focus_trapped |= fields.field("focus_trapped").is_some();
1136 state.modal_render_has_backdrop_active |= fields.field("backdrop_active").is_some();
1137 }
1138
1139 fn on_record(
1140 &self,
1141 id: &tracing::Id,
1142 values: &tracing::span::Record<'_>,
1143 ctx: Context<'_, S>,
1144 ) {
1145 let Some(span) = ctx.span(id) else {
1146 return;
1147 };
1148 if span.metadata().name() != "modal.render" {
1149 return;
1150 }
1151
1152 struct DurationVisitor {
1153 saw_duration: bool,
1154 }
1155
1156 impl tracing::field::Visit for DurationVisitor {
1157 fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
1158 if field.name() == "render_duration_us" {
1159 self.saw_duration = true;
1160 }
1161 }
1162
1163 fn record_debug(
1164 &mut self,
1165 field: &tracing::field::Field,
1166 _value: &dyn std::fmt::Debug,
1167 ) {
1168 if field.name() == "render_duration_us" {
1169 self.saw_duration = true;
1170 }
1171 }
1172 }
1173
1174 let mut visitor = DurationVisitor {
1175 saw_duration: false,
1176 };
1177 values.record(&mut visitor);
1178 if visitor.saw_duration {
1179 self.state
1180 .lock()
1181 .expect("trace state lock")
1182 .modal_render_duration_recorded = true;
1183 }
1184 }
1185
1186 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
1187 struct MessageVisitor {
1188 message: Option<String>,
1189 }
1190
1191 impl tracing::field::Visit for MessageVisitor {
1192 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1193 if field.name() == "message" {
1194 self.message = Some(value.to_owned());
1195 }
1196 }
1197
1198 fn record_debug(
1199 &mut self,
1200 field: &tracing::field::Field,
1201 value: &dyn std::fmt::Debug,
1202 ) {
1203 if field.name() == "message" {
1204 self.message = Some(format!("{value:?}").trim_matches('"').to_owned());
1205 }
1206 }
1207 }
1208
1209 let mut visitor = MessageVisitor { message: None };
1210 event.record(&mut visitor);
1211
1212 let Some(message) = visitor.message else {
1213 return;
1214 };
1215
1216 let mut state = self.state.lock().expect("trace state lock");
1217 match message.as_str() {
1218 "focus.change" => state.focus_change_count += 1,
1219 "focus.trap_push" => state.trap_push_count += 1,
1220 "focus.trap_pop" => state.trap_pop_count += 1,
1221 _ => {}
1222 }
1223 }
1224 }
1225
1226 #[test]
1227 fn empty_stack() {
1228 let stack = ModalStack::new();
1229 assert!(stack.is_empty());
1230 assert_eq!(stack.depth(), 0);
1231 assert!(stack.top().is_none());
1232 assert!(stack.top_id().is_none());
1233 }
1234
1235 #[test]
1236 fn push_increases_depth() {
1237 let mut stack = ModalStack::new();
1238 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1239 assert_eq!(stack.depth(), 1);
1240 assert!(!stack.is_empty());
1241 assert!(stack.contains(id1));
1242
1243 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1244 assert_eq!(stack.depth(), 2);
1245 assert!(stack.contains(id2));
1246 assert_eq!(stack.top_id(), Some(id2));
1247 }
1248
1249 #[test]
1250 fn pop_lifo_order() {
1251 let mut stack = ModalStack::new();
1252 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1253 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1254 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1255
1256 let result = stack.pop();
1257 assert_eq!(result.map(|r| r.id), Some(id3));
1258 assert_eq!(stack.depth(), 2);
1259
1260 let result = stack.pop();
1261 assert_eq!(result.map(|r| r.id), Some(id2));
1262 assert_eq!(stack.depth(), 1);
1263
1264 let result = stack.pop();
1265 assert_eq!(result.map(|r| r.id), Some(id1));
1266 assert!(stack.is_empty());
1267 }
1268
1269 #[test]
1270 fn pop_empty_returns_none() {
1271 let mut stack = ModalStack::new();
1272 assert!(stack.pop().is_none());
1273 }
1274
1275 #[test]
1276 fn pop_by_id() {
1277 let mut stack = ModalStack::new();
1278 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1279 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1280 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1281
1282 let result = stack.pop_id(id2);
1284 assert_eq!(result.map(|r| r.id), Some(id2));
1285 assert_eq!(stack.depth(), 2);
1286 assert!(!stack.contains(id2));
1287 assert!(stack.contains(id1));
1288 assert!(stack.contains(id3));
1289 }
1290
1291 #[test]
1292 fn pop_by_nonexistent_id() {
1293 let mut stack = ModalStack::new();
1294 let _id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1295
1296 let fake_id = ModalId(999999);
1298 assert!(stack.pop_id(fake_id).is_none());
1299 assert_eq!(stack.depth(), 1);
1300 }
1301
1302 #[test]
1303 fn pop_all() {
1304 let mut stack = ModalStack::new();
1305 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1306 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1307 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1308
1309 let results = stack.pop_all();
1310 assert_eq!(results.len(), 3);
1311 assert_eq!(results[0].id, id3);
1313 assert_eq!(results[1].id, id2);
1314 assert_eq!(results[2].id, id1);
1315 assert!(stack.is_empty());
1316 }
1317
1318 #[test]
1319 fn z_order_increasing() {
1320 let mut stack = ModalStack::new();
1321
1322 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1324 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1325 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1326
1327 let z_indices: Vec<u32> = stack.modals.iter().map(|m| m.z_index).collect();
1329 for i in 1..z_indices.len() {
1330 assert!(
1331 z_indices[i] > z_indices[i - 1],
1332 "z_index should be strictly increasing"
1333 );
1334 }
1335 }
1336
1337 #[test]
1338 fn escape_closes_top_modal() {
1339 let mut stack = ModalStack::new();
1340 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1341 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1342
1343 let escape = Event::Key(KeyEvent {
1344 code: KeyCode::Escape,
1345 modifiers: Modifiers::empty(),
1346 kind: KeyEventKind::Press,
1347 });
1348
1349 let result = stack.handle_event(&escape, None);
1351 assert!(result.is_some());
1352 assert_eq!(result.unwrap().id, id2);
1353 assert_eq!(stack.depth(), 1);
1354 assert_eq!(stack.top_id(), Some(id1));
1355 }
1356
1357 #[test]
1358 fn render_does_not_panic() {
1359 let mut stack = ModalStack::new();
1360 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1361 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1362
1363 let mut pool = GraphemePool::new();
1364 let mut frame = Frame::new(80, 24, &mut pool);
1365 let screen = Rect::new(0, 0, 80, 24);
1366
1367 stack.render(&mut frame, screen);
1369 }
1370
1371 #[test]
1372 fn render_empty_stack_no_op() {
1373 let stack = ModalStack::new();
1374 let mut pool = GraphemePool::new();
1375 let mut frame = Frame::new(80, 24, &mut pool);
1376 let screen = Rect::new(0, 0, 80, 24);
1377
1378 stack.render(&mut frame, screen);
1380 }
1381
1382 #[test]
1383 fn contains_after_pop() {
1384 let mut stack = ModalStack::new();
1385 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1386
1387 assert!(stack.contains(id1));
1388 stack.pop();
1389 assert!(!stack.contains(id1));
1390 }
1391
1392 #[test]
1393 fn unique_modal_ids() {
1394 let mut stack = ModalStack::new();
1395 let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1396 let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1397 let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1398
1399 assert_ne!(id1, id2);
1400 assert_ne!(id2, id3);
1401 assert_ne!(id1, id3);
1402 }
1403
1404 #[test]
1405 fn widget_modal_entry_builder() {
1406 let entry = WidgetModalEntry::new(StubWidget)
1407 .size(ModalSizeConstraints::new().min_width(40).max_width(80))
1408 .backdrop(BackdropConfig::new(PackedRgba::rgb(0, 0, 0), 0.8))
1409 .close_on_escape(false)
1410 .close_on_backdrop(false);
1411
1412 assert!(!entry.close_on_escape);
1413 assert!(!entry.close_on_backdrop);
1414 assert_eq!(entry.size.min_width, Some(40));
1415 assert_eq!(entry.size.max_width, Some(80));
1416 }
1417
1418 #[test]
1419 fn escape_disabled_does_not_close() {
1420 let mut stack = ModalStack::new();
1421 stack.push(Box::new(
1422 WidgetModalEntry::new(StubWidget).close_on_escape(false),
1423 ));
1424
1425 let escape = Event::Key(KeyEvent {
1426 code: KeyCode::Escape,
1427 modifiers: Modifiers::empty(),
1428 kind: KeyEventKind::Press,
1429 });
1430
1431 let result = stack.handle_event(&escape, None);
1433 assert!(result.is_none());
1434 assert_eq!(stack.depth(), 1);
1435 }
1436
1437 #[test]
1438 fn backdrop_click_closes_top_modal() {
1439 let mut stack = ModalStack::new();
1440 let top_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1441
1442 let click = Event::Mouse(ftui_core::event::MouseEvent::new(
1443 ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
1444 0,
1445 0,
1446 ));
1447 let hit = Some(HitTestResult::new(
1448 HitId::new(1000),
1449 MODAL_HIT_BACKDROP,
1450 0,
1451 Some(top_id.id()),
1452 ));
1453
1454 let result = stack.handle_event(&click, hit);
1455 assert!(result.is_some());
1456 let result = result.unwrap();
1457 assert_eq!(result.id, top_id);
1458 assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
1459 assert!(stack.is_empty());
1460 }
1461
1462 #[test]
1463 fn content_click_does_not_close_top_modal() {
1464 let mut stack = ModalStack::new();
1465 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1466
1467 let click = Event::Mouse(ftui_core::event::MouseEvent::new(
1468 ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
1469 5,
1470 5,
1471 ));
1472 let hit = Some(HitTestResult::new(
1473 HitId::new(1000),
1474 MODAL_HIT_CONTENT,
1475 0,
1476 Some(stack.top_id().unwrap().id()),
1477 ));
1478
1479 let result = stack.handle_event(&click, hit);
1480 assert!(result.is_none());
1481 assert_eq!(stack.depth(), 1);
1482 }
1483
1484 #[test]
1485 fn custom_modal_receives_backdrop_hit_without_builtin_auto_close() {
1486 let mut stack = ModalStack::new();
1487 let top_id = stack.push(Box::new(CloseOnBackdropHitModal));
1488
1489 let click = Event::Mouse(ftui_core::event::MouseEvent::new(
1490 ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
1491 0,
1492 0,
1493 ));
1494 let hit = Some(HitTestResult::new(
1495 HitId::new(1000),
1496 MODAL_HIT_BACKDROP,
1497 0,
1498 Some(top_id.id()),
1499 ));
1500
1501 let result = stack.handle_event(&click, hit);
1502 assert!(result.is_some());
1503 let result = result.unwrap();
1504 assert_eq!(result.id, top_id);
1505 assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
1506 assert!(stack.is_empty());
1507 }
1508
1509 #[test]
1510 fn custom_modal_receives_inner_widget_hit() {
1511 let mut stack = ModalStack::new();
1512 let top_id = stack.push(Box::new(CloseOnInnerHitModal));
1513 let top_hit_id = stack.modals.last().unwrap().hit_id;
1514
1515 let mut pool = GraphemePool::new();
1516 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
1517 let screen = Rect::new(0, 0, 20, 10);
1518 stack.render(&mut frame, screen);
1519
1520 let hit = frame.hit_test_detailed(10, 4);
1521 assert_eq!(
1522 hit,
1523 Some(HitTestResult::new(
1524 HitId::new(4242),
1525 HitRegion::Custom(99),
1526 0,
1527 Some(top_id.id()),
1528 ))
1529 );
1530 assert_ne!(hit.unwrap().id, top_hit_id);
1531
1532 let click = Event::Mouse(ftui_core::event::MouseEvent::new(
1533 ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
1534 10,
1535 4,
1536 ));
1537
1538 let result = stack.handle_event(&click, hit);
1539 assert!(result.is_some());
1540 let result = result.unwrap();
1541 assert_eq!(result.id, top_id);
1542 assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
1543 assert!(stack.is_empty());
1544 }
1545
1546 #[test]
1547 fn custom_modal_receives_inner_widget_hit_even_when_hit_id_collides_with_lower_modal() {
1548 let mut stack = ModalStack::new();
1549 let _lower_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1550 let top_id = stack.push(Box::new(CloseOnCollidingInnerHitModal));
1551 let top_hit_id = stack.modals.last().unwrap().hit_id;
1552
1553 let mut pool = GraphemePool::new();
1554 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
1555 let screen = Rect::new(0, 0, 20, 10);
1556 stack.render(&mut frame, screen);
1557
1558 let hit = frame.hit_test_detailed(10, 4);
1559 assert_eq!(
1560 hit,
1561 Some(HitTestResult::new(
1562 HitId::new(1000),
1563 HitRegion::Custom(100),
1564 0,
1565 Some(top_id.id()),
1566 ))
1567 );
1568 assert_ne!(hit.unwrap().id, top_hit_id);
1569
1570 let click = Event::Mouse(ftui_core::event::MouseEvent::new(
1571 ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
1572 10,
1573 4,
1574 ));
1575
1576 let result = stack.handle_event(&click, hit);
1577 assert!(result.is_some());
1578 let result = result.unwrap();
1579 assert_eq!(result.id, top_id);
1580 assert!(matches!(result.data, Some(ModalResultData::Dismissed)));
1581 assert_eq!(stack.depth(), 1);
1582 }
1583
1584 #[test]
1585 fn zero_sized_modal_still_registers_backdrop_hit() {
1586 let mut stack = ModalStack::new();
1587 stack.push(Box::new(
1588 WidgetModalEntry::new(StubWidget)
1589 .size(ModalSizeConstraints::new().max_width(0).max_height(0)),
1590 ));
1591
1592 let hit_id = stack.modals.last().unwrap().hit_id;
1593 let mut pool = GraphemePool::new();
1594 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
1595 let screen = Rect::new(0, 0, 20, 10);
1596
1597 stack.render(&mut frame, screen);
1598
1599 assert_eq!(
1600 frame.hit_test_detailed(0, 0),
1601 Some(HitTestResult::new(
1602 hit_id,
1603 MODAL_HIT_BACKDROP,
1604 0,
1605 Some(stack.top_id().unwrap().id()),
1606 ))
1607 );
1608 }
1609
1610 #[test]
1611 fn foreign_lower_modal_hit_is_not_routed_to_top_modal() {
1612 let mut stack = ModalStack::new();
1613 let _lower_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1614 let top_id = stack.push(Box::new(CloseOnAnyHitModal));
1615
1616 let click = Event::Mouse(ftui_core::event::MouseEvent::new(
1617 ftui_core::event::MouseEventKind::Down(ftui_core::event::MouseButton::Left),
1618 0,
1619 0,
1620 ));
1621 let lower_backdrop_hit = Some(HitTestResult::new(
1622 HitId::new(1000),
1623 MODAL_HIT_BACKDROP,
1624 0,
1625 Some(_lower_id.id()),
1626 ));
1627
1628 let result = stack.handle_event(&click, lower_backdrop_hit);
1629 assert!(result.is_none());
1630 assert_eq!(stack.depth(), 2);
1631 assert_eq!(stack.top_id(), Some(top_id));
1632 }
1633
1634 #[test]
1637 fn push_with_focus_tracks_group_id() {
1638 let mut stack = ModalStack::new();
1639 let modal_id = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(42));
1640
1641 assert_eq!(stack.focus_group_id(modal_id), Some(42));
1642 assert_eq!(stack.top_focus_group_id(), Some(42));
1643 }
1644
1645 #[test]
1646 fn pop_returns_focus_group_id() {
1647 let mut stack = ModalStack::new();
1648 stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(99));
1649
1650 let result = stack.pop();
1651 assert!(result.is_some());
1652 assert_eq!(result.unwrap().focus_group_id, Some(99));
1653 }
1654
1655 #[test]
1656 fn pop_id_returns_focus_group_id() {
1657 let mut stack = ModalStack::new();
1658 let id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(10));
1659 let _id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(20));
1660
1661 let result = stack.pop_id(id1);
1662 assert!(result.is_some());
1663 assert_eq!(result.unwrap().focus_group_id, Some(10));
1664 }
1665
1666 #[test]
1667 fn handle_event_returns_focus_group_id() {
1668 let mut stack = ModalStack::new();
1669 stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(77));
1670
1671 let escape = Event::Key(KeyEvent {
1672 code: KeyCode::Escape,
1673 modifiers: Modifiers::empty(),
1674 kind: KeyEventKind::Press,
1675 });
1676
1677 let result = stack.handle_event(&escape, None);
1678 assert!(result.is_some());
1679 assert_eq!(result.unwrap().focus_group_id, Some(77));
1680 }
1681
1682 #[test]
1683 fn push_without_focus_has_none_group_id() {
1684 let mut stack = ModalStack::new();
1685 let modal_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
1686
1687 assert_eq!(stack.focus_group_id(modal_id), None);
1688 assert_eq!(stack.top_focus_group_id(), None);
1689 }
1690
1691 #[test]
1692 fn nested_focus_groups_track_correctly() {
1693 let mut stack = ModalStack::new();
1694 let _id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(1));
1695 let id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(2));
1696 let _id3 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(3));
1697
1698 assert_eq!(stack.top_focus_group_id(), Some(3));
1700
1701 stack.pop();
1703 assert_eq!(stack.top_focus_group_id(), Some(2));
1704
1705 assert_eq!(stack.focus_group_id(id2), Some(2));
1707 }
1708
1709 #[test]
1712 fn default_aria_modal_is_true() {
1713 let entry = WidgetModalEntry::new(StubWidget);
1714 assert!(entry.aria_modal);
1715 }
1716
1717 #[test]
1718 fn aria_modal_builder() {
1719 let entry = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1720 assert!(!entry.aria_modal);
1721 }
1722
1723 #[test]
1724 fn focusable_ids_builder() {
1725 let entry = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2, 3]);
1726 assert_eq!(entry.focusable_ids, Some(vec![1, 2, 3]));
1727 }
1728
1729 #[test]
1730 fn stack_modal_aria_modal_trait() {
1731 let entry = WidgetModalEntry::new(StubWidget);
1732 assert!(StackModal::aria_modal(&entry)); let entry_non_aria = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1735 assert!(!StackModal::aria_modal(&entry_non_aria));
1736 }
1737
1738 #[test]
1739 fn stack_modal_focusable_ids_trait() {
1740 let entry = WidgetModalEntry::new(StubWidget);
1741 assert!(StackModal::focusable_ids(&entry).is_none()); let entry_with_ids = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 20]);
1744 assert_eq!(
1745 StackModal::focusable_ids(&entry_with_ids),
1746 Some(vec![10, 20])
1747 );
1748 }
1749
1750 #[test]
1753 fn focus_integration_push_creates_trap() {
1754 use crate::focus::{FocusManager, FocusNode};
1755 use ftui_core::geometry::Rect;
1756
1757 let mut stack = ModalStack::new();
1758 let mut focus = FocusManager::new();
1759
1760 focus
1762 .graph_mut()
1763 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1764 focus
1765 .graph_mut()
1766 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1767 focus
1768 .graph_mut()
1769 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); focus.focus(100);
1773 assert_eq!(focus.current(), Some(100));
1774
1775 {
1776 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1777
1778 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1780 let _modal_id = integrator.push_with_focus(Box::new(modal));
1781
1782 assert!(integrator.is_focus_trapped());
1784
1785 assert_eq!(integrator.focus().current(), Some(1));
1787 }
1788 }
1789
1790 #[test]
1791 fn focus_integration_pop_restores_focus() {
1792 use crate::focus::{FocusManager, FocusNode};
1793 use ftui_core::geometry::Rect;
1794
1795 let mut stack = ModalStack::new();
1796 let mut focus = FocusManager::new();
1797
1798 focus
1800 .graph_mut()
1801 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1802 focus
1803 .graph_mut()
1804 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1805 focus
1806 .graph_mut()
1807 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); focus.focus(100);
1811 assert_eq!(focus.current(), Some(100));
1812
1813 {
1814 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1815
1816 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1818 integrator.push_with_focus(Box::new(modal));
1819
1820 assert!(integrator.is_focus_trapped());
1822
1823 let result = integrator.pop_with_focus();
1825 assert!(result.is_some());
1826
1827 assert!(!integrator.is_focus_trapped());
1829 assert_eq!(integrator.focus().current(), Some(100));
1830 }
1831 }
1832
1833 #[test]
1834 fn focus_integration_pop_id_with_focus_preserves_top_trap_and_restores_base_after_last_pop() {
1835 use crate::focus::{FocusManager, FocusNode};
1836 use ftui_core::geometry::Rect;
1837
1838 let mut stack = ModalStack::new();
1839 let mut focus = FocusManager::new();
1840
1841 focus
1842 .graph_mut()
1843 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1844 focus
1845 .graph_mut()
1846 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1847 focus
1848 .graph_mut()
1849 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1850 focus.focus(100);
1851
1852 {
1853 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1854 let lower = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
1855 let lower_id = integrator.push_with_focus(Box::new(lower));
1856 let top = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
1857 integrator.push_with_focus(Box::new(top));
1858
1859 let removed = integrator.pop_id_with_focus(lower_id);
1860 assert!(removed.is_some());
1861 assert!(integrator.is_focus_trapped());
1862 assert_eq!(integrator.focus().current(), Some(2));
1863
1864 let final_result = integrator.pop_with_focus();
1865 assert!(final_result.is_some());
1866 assert!(!integrator.is_focus_trapped());
1867 assert_eq!(integrator.focus().current(), Some(100));
1868 }
1869 }
1870
1871 #[test]
1872 fn focus_integration_pop_id_with_focus_preserves_unfocused_base_across_helper_instances() {
1873 use crate::focus::{FocusManager, FocusNode};
1874 use ftui_core::geometry::Rect;
1875
1876 let mut stack = ModalStack::new();
1877 let mut focus = FocusManager::new();
1878
1879 focus
1880 .graph_mut()
1881 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1882 focus
1883 .graph_mut()
1884 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1885
1886 let lower_id;
1887 let upper_id;
1888 {
1889 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1890 let lower = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
1891 let upper = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
1892 lower_id = integrator.push_with_focus(Box::new(lower));
1893 upper_id = integrator.push_with_focus(Box::new(upper));
1894 assert_eq!(integrator.focus().current(), Some(2));
1895 }
1896
1897 {
1898 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1899 let removed = integrator.pop_id_with_focus(lower_id);
1900 assert_eq!(removed.map(|result| result.id), Some(lower_id));
1901 assert_eq!(integrator.focus().current(), Some(2));
1902 assert!(integrator.is_focus_trapped());
1903
1904 let closed = integrator.pop_with_focus();
1905 assert_eq!(closed.map(|result| result.id), Some(upper_id));
1906 }
1907
1908 assert_eq!(focus.current(), None);
1909 assert!(!focus.is_trapped());
1910 }
1911
1912 #[test]
1913 fn focus_integration_resync_focus_state_recovers_after_manual_stack_mutation() {
1914 use crate::focus::{FocusManager, FocusNode};
1915 use ftui_core::geometry::Rect;
1916
1917 let mut stack = ModalStack::new();
1918 let mut focus = FocusManager::new();
1919
1920 focus
1921 .graph_mut()
1922 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1923 focus
1924 .graph_mut()
1925 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1926 focus
1927 .graph_mut()
1928 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1929 focus.focus(100);
1930
1931 {
1932 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1933 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1934 integrator.push_with_focus(Box::new(modal));
1935 assert!(integrator.is_focus_trapped());
1936 assert_eq!(integrator.focus().current(), Some(1));
1937
1938 let result = integrator.stack_mut().pop();
1939 assert!(result.is_some());
1940 assert!(integrator.is_focus_trapped());
1941
1942 integrator.resync_focus_state();
1943 assert!(!integrator.is_focus_trapped());
1944 assert_eq!(integrator.focus().current(), Some(100));
1945 }
1946 }
1947
1948 #[test]
1949 fn focus_integration_resync_updates_inactive_modal_restore_targets_after_manual_focus_change() {
1950 use crate::focus::{FocusManager, FocusNode};
1951 use ftui_core::geometry::Rect;
1952
1953 let mut stack = ModalStack::new();
1954 let mut focus = FocusManager::new();
1955
1956 for id in 1..=4 {
1957 focus
1958 .graph_mut()
1959 .insert(FocusNode::new(id, Rect::new(0, 0, 10, 1)).with_tab_index(id as i32));
1960 }
1961
1962 focus.focus(1);
1963
1964 let upper_id;
1965 {
1966 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1967 let lower = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2, 3]);
1968 integrator.push_with_focus(Box::new(lower));
1969 integrator.focus_mut().focus(3);
1970
1971 let upper = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![4]);
1972 upper_id = integrator.push_with_focus(Box::new(upper));
1973
1974 let _ = integrator.focus_mut().graph_mut().remove(4);
1975 integrator.resync_focus_state();
1976 assert_eq!(integrator.focus().current(), Some(3));
1977
1978 integrator.focus_mut().focus(2);
1979 integrator.resync_focus_state();
1980 assert_eq!(integrator.focus().current(), Some(2));
1981
1982 integrator
1983 .focus_mut()
1984 .graph_mut()
1985 .insert(FocusNode::new(4, Rect::new(0, 0, 10, 1)).with_tab_index(4));
1986 integrator.resync_focus_state();
1987 assert_eq!(integrator.focus().current(), Some(4));
1988
1989 let result = integrator.pop_id_with_focus(upper_id);
1990 assert_eq!(result.map(|closed| closed.id), Some(upper_id));
1991 assert_eq!(integrator.focus().current(), Some(2));
1992 assert!(integrator.is_focus_trapped());
1993 }
1994 }
1995
1996 #[test]
1997 fn focus_integration_pop_skips_closed_modal_focus_ids_when_background_focus_disappears() {
1998 use crate::focus::{FocusManager, FocusNode};
1999 use ftui_core::geometry::Rect;
2000
2001 let mut stack = ModalStack::new();
2002 let mut focus = FocusManager::new();
2003
2004 focus
2005 .graph_mut()
2006 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2007 focus
2008 .graph_mut()
2009 .insert(FocusNode::new(50, Rect::new(0, 1, 10, 1)));
2010 focus
2011 .graph_mut()
2012 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
2013 focus.focus(100);
2014
2015 {
2016 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2017 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
2018 integrator.push_with_focus(Box::new(modal));
2019 let _ = integrator.focus_mut().graph_mut().remove(100);
2020
2021 let result = integrator.pop_with_focus();
2022 assert!(result.is_some());
2023 assert_eq!(integrator.focus().current(), Some(50));
2024 assert!(!integrator.is_focus_trapped());
2025 }
2026 }
2027
2028 #[test]
2029 fn focus_integration_pop_removes_closed_modal_focus_group() {
2030 use crate::focus::{FocusManager, FocusNode};
2031 use ftui_core::geometry::Rect;
2032
2033 let mut stack = ModalStack::new();
2034 let mut focus = FocusManager::new();
2035
2036 focus
2037 .graph_mut()
2038 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2039 focus
2040 .graph_mut()
2041 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
2042
2043 focus.focus(1);
2044
2045 {
2046 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2047 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
2048 integrator.push_with_focus(Box::new(modal));
2049
2050 let result = integrator.pop_with_focus().unwrap();
2051 let group_id = result.focus_group_id.unwrap();
2052
2053 assert!(!integrator.focus_mut().push_trap(group_id));
2054 assert!(!integrator.is_focus_trapped());
2055 assert_eq!(integrator.focus().current(), Some(1));
2056 }
2057 }
2058
2059 #[test]
2060 fn focus_integration_escape_restores_focus() {
2061 use crate::focus::{FocusManager, FocusNode};
2062 use ftui_core::geometry::Rect;
2063
2064 let mut stack = ModalStack::new();
2065 let mut focus = FocusManager::new();
2066
2067 focus
2068 .graph_mut()
2069 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2070 focus
2071 .graph_mut()
2072 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
2073
2074 focus.focus(100);
2075
2076 {
2077 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2078
2079 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
2080 integrator.push_with_focus(Box::new(modal));
2081
2082 assert!(integrator.is_focus_trapped());
2083
2084 let escape = Event::Key(KeyEvent {
2086 code: KeyCode::Escape,
2087 modifiers: Modifiers::empty(),
2088 kind: KeyEventKind::Press,
2089 });
2090 let result = integrator.handle_event(&escape, None);
2091
2092 assert!(result.is_some());
2093 assert!(!integrator.is_focus_trapped());
2094 assert_eq!(integrator.focus().current(), Some(100));
2095 }
2096 }
2097
2098 #[test]
2099 fn focus_integration_applies_host_focus_events() {
2100 use crate::focus::{FocusManager, FocusNode};
2101 use ftui_core::geometry::Rect;
2102
2103 let mut stack = ModalStack::new();
2104 let mut focus = FocusManager::new();
2105
2106 focus
2107 .graph_mut()
2108 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2109 focus
2110 .graph_mut()
2111 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
2112 focus.focus(2);
2113
2114 {
2115 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2116 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
2117 integrator.push_with_focus(Box::new(modal));
2118 assert_eq!(integrator.focus().current(), Some(1));
2119
2120 let blur = Event::Focus(false);
2121 assert!(integrator.handle_event(&blur, None).is_none());
2122 assert_eq!(integrator.focus().current(), None);
2123
2124 let gain = Event::Focus(true);
2125 assert!(integrator.handle_event(&gain, None).is_none());
2126 assert_eq!(integrator.focus().current(), Some(1));
2127 }
2128 }
2129
2130 #[test]
2131 fn focus_integration_non_aria_modal_no_trap() {
2132 use crate::focus::{FocusManager, FocusNode};
2133 use ftui_core::geometry::Rect;
2134
2135 let mut stack = ModalStack::new();
2136 let mut focus = FocusManager::new();
2137
2138 focus
2139 .graph_mut()
2140 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2141 focus
2142 .graph_mut()
2143 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
2144
2145 focus.focus(100);
2146
2147 {
2148 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2149
2150 let modal = WidgetModalEntry::new(StubWidget)
2152 .with_aria_modal(false)
2153 .with_focusable_ids(vec![1]);
2154 integrator.push_with_focus(Box::new(modal));
2155
2156 assert!(!integrator.is_focus_trapped());
2158 }
2159 }
2160
2161 #[test]
2162 fn focus_integration_rejected_empty_trap_does_not_leave_focus_group_behind() {
2163 use crate::focus::{FocusManager, FocusNode};
2164 use ftui_core::geometry::Rect;
2165
2166 let mut stack = ModalStack::new();
2167 let mut focus = FocusManager::new();
2168
2169 focus
2170 .graph_mut()
2171 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2172 focus.focus(1);
2173
2174 {
2175 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2176 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![]);
2177 integrator.push_with_focus(Box::new(modal));
2178
2179 assert!(!integrator.is_focus_trapped());
2180 assert!(!integrator.focus_mut().push_trap(1));
2181 assert_eq!(integrator.focus().current(), Some(1));
2182 }
2183 }
2184
2185 #[test]
2186 fn recreated_focus_integration_does_not_reuse_live_group_ids() {
2187 use crate::focus::{FocusManager, FocusNode};
2188 use ftui_core::geometry::Rect;
2189
2190 let mut stack = ModalStack::new();
2191 let mut focus = FocusManager::new();
2192
2193 focus
2194 .graph_mut()
2195 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2196 focus
2197 .graph_mut()
2198 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
2199 focus
2200 .graph_mut()
2201 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
2202
2203 focus.focus(100);
2204
2205 let first_group_id = {
2206 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2207 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
2208 let modal_id = integrator.push_with_focus(Box::new(modal));
2209 integrator.stack().focus_group_id(modal_id).unwrap()
2210 };
2211
2212 let second_group_id = {
2213 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2214 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![2]);
2215 let modal_id = integrator.push_with_focus(Box::new(modal));
2216 integrator.stack().focus_group_id(modal_id).unwrap()
2217 };
2218
2219 assert_ne!(first_group_id, second_group_id);
2220
2221 {
2222 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2223 let top = integrator.pop_with_focus().unwrap();
2224 assert_eq!(top.focus_group_id, Some(second_group_id));
2225 assert!(integrator.is_focus_trapped());
2226 assert_eq!(integrator.focus().current(), Some(1));
2227
2228 let lower = integrator.pop_with_focus().unwrap();
2229 assert_eq!(lower.focus_group_id, Some(first_group_id));
2230 assert!(!integrator.is_focus_trapped());
2231 assert_eq!(integrator.focus().current(), Some(100));
2232 }
2233 }
2234
2235 #[test]
2236 fn focus_integration_does_not_collide_with_existing_group_ids() {
2237 use crate::focus::{FocusManager, FocusNode};
2238 use ftui_core::geometry::Rect;
2239
2240 let mut stack = ModalStack::new();
2241 let mut focus = FocusManager::new();
2242
2243 focus
2244 .graph_mut()
2245 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2246 focus
2247 .graph_mut()
2248 .insert(FocusNode::new(99, Rect::new(0, 1, 10, 1)));
2249 focus
2250 .graph_mut()
2251 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
2252 focus.create_group(1000, vec![99]);
2253 focus.focus(100);
2254
2255 {
2256 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2257 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
2258 integrator.push_with_focus(Box::new(modal));
2259 let _ = integrator.pop_with_focus().unwrap();
2260 assert!(integrator.focus_mut().push_trap(1000));
2261 assert_eq!(integrator.focus().current(), Some(99));
2262 }
2263 }
2264
2265 #[test]
2266 fn focus_integration_nested_modals() {
2267 use crate::focus::{FocusManager, FocusNode};
2268 use ftui_core::geometry::Rect;
2269
2270 let mut stack = ModalStack::new();
2271 let mut focus = FocusManager::new();
2272
2273 focus
2275 .graph_mut()
2276 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2277 focus
2278 .graph_mut()
2279 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
2280 focus
2281 .graph_mut()
2282 .insert(FocusNode::new(10, Rect::new(0, 5, 10, 1)));
2283 focus
2284 .graph_mut()
2285 .insert(FocusNode::new(11, Rect::new(0, 6, 10, 1)));
2286 focus
2287 .graph_mut()
2288 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
2289
2290 focus.focus(100);
2291
2292 {
2293 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2294
2295 let modal1 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
2297 integrator.push_with_focus(Box::new(modal1));
2298 assert_eq!(integrator.focus().current(), Some(1));
2299
2300 let modal2 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 11]);
2302 integrator.push_with_focus(Box::new(modal2));
2303 assert_eq!(integrator.focus().current(), Some(10));
2304
2305 integrator.pop_with_focus();
2307 assert_eq!(integrator.focus().current(), Some(1));
2308
2309 integrator.pop_with_focus();
2311 assert_eq!(integrator.focus().current(), Some(100));
2312 }
2313 }
2314
2315 #[cfg(feature = "tracing")]
2316 #[test]
2317 fn tracing_modal_render_span_has_required_fields() {
2318 let state = Arc::new(Mutex::new(TraceState::default()));
2319 let _trace_test_guard = crate::tracing_test_support::acquire();
2320 let subscriber = tracing_subscriber::registry().with(TraceCapture {
2321 state: Arc::clone(&state),
2322 });
2323 let _guard = tracing::subscriber::set_default(subscriber);
2324
2325 tracing::callsite::rebuild_interest_cache();
2326 let mut stack = ModalStack::new();
2327 stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
2328 let mut pool = GraphemePool::new();
2329 let mut frame = Frame::new(80, 24, &mut pool);
2330 stack.render(&mut frame, Rect::new(0, 0, 80, 24));
2331 tracing::callsite::rebuild_interest_cache();
2332
2333 let snapshot = state.lock().expect("trace state lock");
2334 assert!(snapshot.modal_render_seen, "expected modal.render span");
2335 assert!(
2336 snapshot.modal_render_has_modal_type,
2337 "modal.render missing modal_type field"
2338 );
2339 assert!(
2340 snapshot.modal_render_has_focus_trapped,
2341 "modal.render missing focus_trapped field"
2342 );
2343 assert!(
2344 snapshot.modal_render_has_backdrop_active,
2345 "modal.render missing backdrop_active field"
2346 );
2347 assert!(
2348 snapshot.modal_render_duration_recorded,
2349 "modal.render did not record render_duration_us"
2350 );
2351 }
2352
2353 #[cfg(feature = "tracing")]
2354 #[test]
2355 fn tracing_focus_change_and_trap_events_emitted_for_modal_lifecycle() {
2356 use crate::focus::{FocusManager, FocusNode};
2357
2358 let state = Arc::new(Mutex::new(TraceState::default()));
2359 let _trace_test_guard = crate::tracing_test_support::acquire();
2360 let subscriber = tracing_subscriber::registry().with(TraceCapture {
2361 state: Arc::clone(&state),
2362 });
2363 let _guard = tracing::subscriber::set_default(subscriber);
2364
2365 let mut stack = ModalStack::new();
2366 let mut focus = FocusManager::new();
2367 focus
2368 .graph_mut()
2369 .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
2370 focus
2371 .graph_mut()
2372 .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
2373 focus
2374 .graph_mut()
2375 .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
2376 focus.focus(100);
2377
2378 tracing::callsite::rebuild_interest_cache();
2379 {
2380 let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
2381 let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
2382 tracing::callsite::rebuild_interest_cache();
2383 integrator.push_with_focus(Box::new(modal));
2384
2385 let escape = Event::Key(KeyEvent {
2386 code: KeyCode::Escape,
2387 modifiers: Modifiers::empty(),
2388 kind: KeyEventKind::Press,
2389 });
2390 tracing::callsite::rebuild_interest_cache();
2391 let _ = integrator.handle_event(&escape, None);
2392 }
2393 tracing::callsite::rebuild_interest_cache();
2394
2395 let snapshot = state.lock().expect("trace state lock");
2396 assert!(
2397 snapshot.focus_change_count >= 2,
2398 "expected focus.change events for trap lifecycle, got {}",
2399 snapshot.focus_change_count
2400 );
2401 assert!(
2402 snapshot.trap_push_count >= 1,
2403 "expected focus.trap_push event"
2404 );
2405 assert!(
2406 snapshot.trap_pop_count >= 1,
2407 "expected focus.trap_pop event"
2408 );
2409 }
2410}