Skip to main content

ftui_widgets/modal/
stack.rs

1#![forbid(unsafe_code)]
2
3//! Modal stack for managing nested modals with proper z-ordering.
4//!
5//! The `ModalStack` manages multiple open modals in a LIFO (stack) order.
6//! Only the topmost modal receives input events, while all modals are
7//! rendered from bottom to top with appropriate backdrop dimming.
8//!
9//! # Invariants
10//!
11//! - Z-order is strictly increasing: later modals are always on top.
12//! - Only the top modal receives input events.
13//! - Close ordering is LIFO by default; pop-by-id removes from any position.
14//! - Backdrop opacity is reduced for lower modals to create depth effect.
15//!
16//! # Failure Modes
17//!
18//! - `pop()` on empty stack returns `None` (no panic).
19//! - `pop_id()` for non-existent ID returns `None`.
20//! - `get()` / `get_mut()` for non-existent ID returns `None`.
21//!
22//! # Example
23//!
24//! ```ignore
25//! let mut stack = ModalStack::new();
26//!
27//! // Push modals
28//! let id1 = stack.push(ModalEntry::new(dialog1));
29//! let id2 = stack.push(ModalEntry::new(dialog2));
30//!
31//! // Only top modal (id2) receives events
32//! stack.handle_event(&event, None);
33//!
34//! // Render all modals in z-order
35//! stack.render(frame, screen_area);
36//!
37//! // Pop top modal
38//! let result = stack.pop(); // Returns id2's entry
39//! ```
40
41use 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
57/// Base z-index for modal layer.
58const BASE_MODAL_Z: u32 = 1000;
59
60/// Z-index increment between modals (leaves room for internal layers).
61const Z_INCREMENT: u32 = 10;
62
63/// Global counter for unique modal IDs.
64static MODAL_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
65
66/// Unique identifier for a modal in the stack.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub struct ModalId(u64);
69
70impl ModalId {
71    /// Create a new unique modal ID.
72    fn new() -> Self {
73        Self(MODAL_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
74    }
75
76    /// Get the raw ID value.
77    #[inline]
78    pub const fn id(self) -> u64 {
79        self.0
80    }
81}
82
83/// Result returned when a modal is closed.
84#[derive(Debug, Clone)]
85pub struct ModalResult {
86    /// The modal ID that was closed.
87    pub id: ModalId,
88    /// Optional result data from the modal.
89    pub data: Option<ModalResultData>,
90    /// Focus group ID if one was associated (for calling `FocusManager::pop_trap`).
91    pub focus_group_id: Option<u32>,
92}
93
94/// Modal result data variants.
95#[derive(Debug, Clone)]
96pub enum ModalResultData {
97    /// Dialog was dismissed (escaped or cancelled).
98    Dismissed,
99    /// Dialog was confirmed.
100    Confirmed,
101    /// Dialog returned a custom value.
102    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
111/// A FocusId alias for modal focus management.
112pub type ModalFocusId = u64;
113
114/// Trait for modal content that can be managed in the stack.
115///
116/// This trait abstracts over different modal implementations (Dialog, custom modals)
117/// so they can all be managed by the same stack.
118///
119/// # Focus Management (bd-39vx.5)
120///
121/// Modals can optionally participate in focus management by providing:
122/// - `focusable_ids()`: List of focusable widget IDs within the modal
123/// - `aria_modal()`: Whether this modal should be treated as an ARIA modal
124///
125/// When focus management is enabled, the caller should:
126/// 1. Create a focus group from `focusable_ids()` when the modal opens
127/// 2. Push a focus trap to constrain Tab navigation within the modal
128/// 3. Auto-focus the first focusable widget
129/// 4. Restore previous focus when the modal closes
130pub trait StackModal: Send {
131    /// A stable-ish label for this modal type, used for tracing/logging.
132    ///
133    /// Default: the Rust type name of the concrete modal implementation.
134    fn modal_type(&self) -> &'static str {
135        std::any::type_name::<Self>()
136    }
137
138    /// Render the modal content at the given area.
139    fn render_content(&self, area: Rect, frame: &mut Frame);
140
141    /// Handle an event, returning true if the modal should close.
142    ///
143    /// `hit` should be the last rendered hit-test result for the pointer location,
144    /// if one exists. `hit_id` is the stack-assigned ID for this modal.
145    fn handle_event(
146        &mut self,
147        event: &Event,
148        hit: Option<(HitId, HitRegion, HitData)>,
149        hit_id: HitId,
150    ) -> Option<ModalResultData>;
151
152    /// Get the modal's size constraints.
153    fn size_constraints(&self) -> ModalSizeConstraints;
154
155    /// Get the backdrop configuration.
156    fn backdrop_config(&self) -> BackdropConfig;
157
158    /// Whether this modal can be closed by pressing Escape.
159    fn close_on_escape(&self) -> bool {
160        true
161    }
162
163    /// Whether this modal can be closed by clicking the backdrop.
164    fn close_on_backdrop(&self) -> bool {
165        true
166    }
167
168    /// Whether this modal is an ARIA modal (accessibility semantic).
169    ///
170    /// ARIA modals:
171    /// - Trap focus within the modal (Tab cannot escape)
172    /// - Announce modal semantics to screen readers
173    /// - Block interaction with content behind the modal
174    ///
175    /// Default: `true` for accessibility compliance.
176    ///
177    /// # Invariants
178    /// - When `aria_modal()` returns true, focus MUST be trapped within the modal.
179    /// - Screen readers should announce modal state changes.
180    ///
181    /// # Failure Modes
182    /// - If focus trap is not configured, Tab may escape (accessibility violation).
183    fn aria_modal(&self) -> bool {
184        true
185    }
186
187    /// Get the IDs of focusable widgets within this modal.
188    ///
189    /// These IDs are used to create a focus group when the modal opens.
190    /// The first ID in the list receives auto-focus.
191    ///
192    /// Returns `None` if focus management is not needed (e.g., non-interactive modals).
193    ///
194    /// # Example
195    /// ```ignore
196    /// fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
197    ///     Some(vec![
198    ///         self.input_field_id,
199    ///         self.confirm_button_id,
200    ///         self.cancel_button_id,
201    ///     ])
202    /// }
203    /// ```
204    fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
205        None
206    }
207}
208
209/// An active modal in the stack.
210struct ActiveModal {
211    /// Unique identifier for this modal.
212    id: ModalId,
213    /// Z-index for layering (reserved for future compositor integration).
214    #[allow(dead_code)]
215    z_index: u32,
216    /// The modal content.
217    modal: Box<dyn StackModal>,
218    /// Hit ID for this modal's hit regions.
219    hit_id: HitId,
220    /// Focus group ID for focus trap integration.
221    focus_group_id: Option<u32>,
222    /// Focus target that should be restored when this modal closes.
223    focus_return_focus: Option<FocusId>,
224}
225
226/// Stack of active modals with z-ordering and input routing.
227///
228/// # Invariants
229///
230/// - `modals` is ordered by z_index (lowest to highest).
231/// - `next_z` always produces a z_index greater than any existing modal.
232/// - Input is only routed to the top modal (last in the vec).
233pub struct ModalStack {
234    /// Active modals in z-order (bottom to top).
235    modals: Vec<ActiveModal>,
236    /// Next z-index to assign.
237    next_z: u32,
238    /// Next hit ID to assign.
239    next_hit_id: u32,
240}
241
242impl Default for ModalStack {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248impl ModalStack {
249    /// Create an empty modal stack.
250    pub fn new() -> Self {
251        Self {
252            modals: Vec::new(),
253            next_z: 0,
254            next_hit_id: 1000, // Start hit IDs high to avoid conflicts
255        }
256    }
257
258    // --- Stack Operations ---
259
260    /// Push a modal onto the stack.
261    ///
262    /// Returns the unique `ModalId` for the pushed modal.
263    pub fn push(&mut self, modal: Box<dyn StackModal>) -> ModalId {
264        self.push_with_focus(modal, None)
265    }
266
267    /// Push a modal with an associated focus group ID.
268    ///
269    /// The focus group ID is used to integrate with `FocusManager`:
270    /// 1. Before calling this, create a focus group with `focus_manager.create_group(id, members)`
271    /// 2. Then call `focus_manager.push_trap(id)` to trap focus within the modal
272    /// 3. When the modal closes, call `focus_manager.pop_trap()` to restore focus
273    ///
274    /// Returns the unique `ModalId` for the pushed modal.
275    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    /// Get the focus group ID for a modal.
314    ///
315    /// Returns `None` if the modal doesn't exist or has no focus group.
316    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    /// Get the focus group ID for the top modal.
324    ///
325    /// Useful for checking if focus trap should be active.
326    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    /// Pop the top modal from the stack.
338    ///
339    /// Returns the result if a modal was popped, or `None` if the stack is empty.
340    /// If the modal had a focus group, the caller should call `FocusManager::pop_trap()`.
341    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    /// Pop a specific modal by ID.
364    ///
365    /// Returns the result if the modal was found and removed, or `None` if not found.
366    /// Note: This breaks strict LIFO ordering but is sometimes needed.
367    /// If the modal had a focus group, the caller should handle focus restoration.
368    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    /// Pop all modals from the stack.
409    ///
410    /// Returns results in LIFO order (top first).
411    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    /// Get a reference to the top modal.
420    pub fn top(&self) -> Option<&(dyn StackModal + 'static)> {
421        self.modals.last().map(|m| &*m.modal)
422    }
423
424    /// Get a mutable reference to the top modal.
425    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    // --- State Queries ---
433
434    /// Check if the stack is empty.
435    #[inline]
436    pub fn is_empty(&self) -> bool {
437        self.modals.is_empty()
438    }
439
440    /// Get the number of modals in the stack.
441    #[inline]
442    pub fn depth(&self) -> usize {
443        self.modals.len()
444    }
445
446    /// Check if a modal with the given ID exists in the stack.
447    pub fn contains(&self, id: ModalId) -> bool {
448        self.modals.iter().any(|m| m.id == id)
449    }
450
451    /// Get the ID of the top modal, if any.
452    pub fn top_id(&self) -> Option<ModalId> {
453        self.modals.last().map(|m| m.id)
454    }
455
456    /// Get focus group IDs for active modals in stack order (bottom to top).
457    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    // --- Event Handling ---
497
498    /// Handle an event, routing it to the top modal only.
499    ///
500    /// Returns `Some(ModalResult)` if the top modal closed, otherwise `None`.
501    /// If the result contains a `focus_group_id`, the caller should call
502    /// `FocusManager::pop_trap()` to restore focus.
503    ///
504    /// For mouse interactions, pass the provenance-aware result from
505    /// [`Frame::hit_test_detailed`]. Plain `(HitId, HitRegion, HitData)` tuples
506    /// do not carry enough ownership information for layered modal routing.
507    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            // Modal wants to close
527            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    // --- Rendering ---
550
551    /// Render all modals in z-order.
552    ///
553    /// Modals are rendered from bottom to top. Lower modals have reduced
554    /// backdrop opacity to create a visual depth effect.
555    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            // Calculate backdrop opacity with depth dimming
566            let base_opacity = modal.modal.backdrop_config().opacity;
567            let opacity = if is_top {
568                base_opacity
569            } else {
570                // Reduce opacity for lower modals (50% of configured)
571                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            // Render backdrop
588            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                // Register backdrop hits even when the modal content clamps to zero.
595                // A zero-sized modal can still present a visible overlay and should
596                // still receive backdrop clicks.
597                if !screen.is_empty() {
598                    frame.register_hit(screen, modal.hit_id, MODAL_HIT_BACKDROP, 0);
599                }
600
601                // Calculate modal content area
602                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                // Center the modal
611                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                // Register hit regions for backdrop and content so that
616                // close_on_backdrop and custom mouse dispatch can distinguish clicks.
617                if !content_area.is_empty() {
618                    frame.register_hit(content_area, modal.hit_id, MODAL_HIT_CONTENT, 0);
619                }
620
621                // Render modal content
622                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
634/// A simple modal entry that wraps any Widget.
635pub 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    /// Create a new modal entry with a widget.
647    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    /// Set size constraints.
664    #[must_use]
665    pub fn size(mut self, size: ModalSizeConstraints) -> Self {
666        self.size = size;
667        self
668    }
669
670    /// Set backdrop configuration.
671    #[must_use]
672    pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
673        self.backdrop = backdrop;
674        self
675    }
676
677    /// Set whether Escape closes the modal.
678    #[must_use]
679    pub fn close_on_escape(mut self, close: bool) -> Self {
680        self.close_on_escape = close;
681        self
682    }
683
684    /// Set whether backdrop click closes the modal.
685    #[must_use]
686    pub fn close_on_backdrop(mut self, close: bool) -> Self {
687        self.close_on_backdrop = close;
688        self
689    }
690
691    /// Set whether this modal is an ARIA modal.
692    ///
693    /// ARIA modals trap focus and announce semantics to screen readers.
694    /// Default is `true` for accessibility compliance.
695    #[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    /// Set the focusable widget IDs for focus trap integration.
702    ///
703    /// When provided, these IDs will be used to:
704    /// 1. Create a focus group constraining Tab navigation
705    /// 2. Auto-focus the first focusable widget when modal opens
706    /// 3. Restore focus to the previous element when modal closes
707    #[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        // Handle escape to close
740        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// =========================================================================
779// Modal Focus Integration Helper (bd-39vx.5)
780// =========================================================================
781
782/// Helper for integrating `ModalStack` with `FocusManager`.
783///
784/// This struct provides a convenient API for:
785/// - Pushing modals with automatic focus trap setup
786/// - Popping modals with focus restoration
787/// - Managing focus groups for nested modals
788///
789/// # Example
790/// ```ignore
791/// use ftui_widgets::modal::{ModalStack, ModalFocusIntegration};
792/// use ftui_widgets::focus::FocusManager;
793///
794/// let mut stack = ModalStack::new();
795/// let mut focus = FocusManager::new();
796/// let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
797///
798/// // Push a modal with focus management
799/// let modal_id = integrator.push_with_focus(dialog);
800///
801/// // ... handle events ...
802///
803/// // Pop modal and restore focus
804/// integrator.pop_with_focus();
805/// ```
806#[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    /// Create a new integration helper.
818    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    /// Push a modal with automatic focus management.
828    ///
829    /// 1. Creates a focus group from `modal.focusable_ids()` (if provided)
830    /// 2. Pushes a focus trap to constrain Tab navigation
831    /// 3. Auto-focuses the first focusable widget
832    /// 4. Stores the previous focus for restoration on close
833    ///
834    /// Returns the modal ID.
835    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    /// Pop the top modal with focus restoration.
843    ///
844    /// If the modal had a focus group, the trap is popped and focus
845    /// is restored to the element that was focused before the modal opened.
846    ///
847    /// Returns the modal result.
848    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    /// Pop a specific modal with focus restoration/rebuild.
853    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    /// Pop all modals with focus restoration/rebuild.
858    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    /// Handle an event with automatic focus trap popping.
863    ///
864    /// If the event causes the modal to close, the focus trap is popped.
865    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    /// Check if focus is currently trapped in a modal.
875    pub fn is_focus_trapped(&self) -> bool {
876        self.focus.is_trapped()
877    }
878
879    /// Get a reference to the underlying modal stack.
880    pub fn stack(&self) -> &ModalStack {
881        self.stack
882    }
883
884    /// Get a mutable reference to the underlying modal stack.
885    ///
886    /// **Warning**: Direct manipulation may desync focus state. Call
887    /// `resync_focus_state()` after mutating the stack directly.
888    pub fn stack_mut(&mut self) -> &mut ModalStack {
889        self.stack
890    }
891
892    /// Get a reference to the underlying focus manager.
893    pub fn focus(&self) -> &crate::focus::FocusManager {
894        self.focus
895    }
896
897    /// Get a mutable reference to the underlying focus manager.
898    ///
899    /// **Warning**: Direct manipulation may desync modal focus restoration.
900    /// Call `resync_focus_state()` after mutating traps, groups, or graph state directly.
901    pub fn focus_mut(&mut self) -> &mut crate::focus::FocusManager {
902        self.focus
903    }
904
905    /// Rebuild modal focus state after direct mutation via `stack_mut()` or `focus_mut()`.
906    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        // Pop middle modal
1283        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        // Try to pop non-existent ID
1297        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        // LIFO order: id3, id2, id1
1312        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        // Push multiple modals
1323        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        // Verify z-order is increasing
1328        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        // Escape should close top modal (id2)
1350        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        // Should not panic
1368        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        // Should be a no-op
1379        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        // Escape should NOT close the modal
1432        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    // --- Focus group integration tests ---
1635
1636    #[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        // Top should be group 3
1699        assert_eq!(stack.top_focus_group_id(), Some(3));
1700
1701        // Pop top, now group 2 is on top
1702        stack.pop();
1703        assert_eq!(stack.top_focus_group_id(), Some(2));
1704
1705        // Query specific modal
1706        assert_eq!(stack.focus_group_id(id2), Some(2));
1707    }
1708
1709    // --- ARIA modal tests (bd-39vx.5) ---
1710
1711    #[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)); // Default true
1733
1734        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()); // Default none
1742
1743        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    // --- ModalFocusIntegration tests ---
1751
1752    #[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        // Register focusable nodes
1761        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))); // Outside modal
1770
1771        // Focus outside modal initially
1772        focus.focus(100);
1773        assert_eq!(focus.current(), Some(100));
1774
1775        {
1776            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1777
1778            // Push modal with focusable IDs
1779            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1780            let _modal_id = integrator.push_with_focus(Box::new(modal));
1781
1782            // Focus should now be trapped
1783            assert!(integrator.is_focus_trapped());
1784
1785            // Focus should move to first focusable in modal
1786            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        // Register focusable nodes
1799        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))); // Trigger element
1808
1809        // Focus the trigger element before opening modal
1810        focus.focus(100);
1811        assert_eq!(focus.current(), Some(100));
1812
1813        {
1814            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1815
1816            // Push modal
1817            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1818            integrator.push_with_focus(Box::new(modal));
1819
1820            // Focus is in modal
1821            assert!(integrator.is_focus_trapped());
1822
1823            // Pop modal
1824            let result = integrator.pop_with_focus();
1825            assert!(result.is_some());
1826
1827            // Focus should be restored to trigger element
1828            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            // Simulate Escape key
2085            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            // Push non-ARIA modal (aria_modal = false)
2151            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            // Focus should NOT be trapped for non-ARIA modals
2157            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        // Register nodes for both modals and background
2274        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            // Push first modal
2296            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            // Push second modal (nested)
2301            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            // Pop second modal - should restore to first modal's focus
2306            integrator.pop_with_focus();
2307            assert_eq!(integrator.focus().current(), Some(1));
2308
2309            // Pop first modal - should restore to original focus
2310            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}