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);
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, HitId};
44use ftui_style::Style;
45use std::sync::atomic::{AtomicU64, Ordering};
46
47use crate::modal::{BackdropConfig, ModalSizeConstraints};
48use crate::set_style_area;
49
50/// Base z-index for modal layer.
51const BASE_MODAL_Z: u32 = 1000;
52
53/// Z-index increment between modals (leaves room for internal layers).
54const Z_INCREMENT: u32 = 10;
55
56/// Global counter for unique modal IDs.
57static MODAL_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
58
59/// Unique identifier for a modal in the stack.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub struct ModalId(u64);
62
63impl ModalId {
64    /// Create a new unique modal ID.
65    fn new() -> Self {
66        Self(MODAL_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
67    }
68
69    /// Get the raw ID value.
70    #[inline]
71    pub const fn id(self) -> u64 {
72        self.0
73    }
74}
75
76/// Result returned when a modal is closed.
77#[derive(Debug, Clone)]
78pub struct ModalResult {
79    /// The modal ID that was closed.
80    pub id: ModalId,
81    /// Optional result data from the modal.
82    pub data: Option<ModalResultData>,
83    /// Focus group ID if one was associated (for calling `FocusManager::pop_trap`).
84    pub focus_group_id: Option<u32>,
85}
86
87/// Modal result data variants.
88#[derive(Debug, Clone)]
89pub enum ModalResultData {
90    /// Dialog was dismissed (escaped or cancelled).
91    Dismissed,
92    /// Dialog was confirmed.
93    Confirmed,
94    /// Dialog returned a custom value.
95    Custom(String),
96}
97
98/// A FocusId alias for modal focus management.
99pub type ModalFocusId = u64;
100
101/// Trait for modal content that can be managed in the stack.
102///
103/// This trait abstracts over different modal implementations (Dialog, custom modals)
104/// so they can all be managed by the same stack.
105///
106/// # Focus Management (bd-39vx.5)
107///
108/// Modals can optionally participate in focus management by providing:
109/// - `focusable_ids()`: List of focusable widget IDs within the modal
110/// - `aria_modal()`: Whether this modal should be treated as an ARIA modal
111///
112/// When focus management is enabled, the caller should:
113/// 1. Create a focus group from `focusable_ids()` when the modal opens
114/// 2. Push a focus trap to constrain Tab navigation within the modal
115/// 3. Auto-focus the first focusable widget
116/// 4. Restore previous focus when the modal closes
117pub trait StackModal: Send {
118    /// Render the modal content at the given area.
119    fn render_content(&self, area: Rect, frame: &mut Frame);
120
121    /// Handle an event, returning true if the modal should close.
122    fn handle_event(&mut self, event: &Event, hit_id: HitId) -> Option<ModalResultData>;
123
124    /// Get the modal's size constraints.
125    fn size_constraints(&self) -> ModalSizeConstraints;
126
127    /// Get the backdrop configuration.
128    fn backdrop_config(&self) -> BackdropConfig;
129
130    /// Whether this modal can be closed by pressing Escape.
131    fn close_on_escape(&self) -> bool {
132        true
133    }
134
135    /// Whether this modal can be closed by clicking the backdrop.
136    fn close_on_backdrop(&self) -> bool {
137        true
138    }
139
140    /// Whether this modal is an ARIA modal (accessibility semantic).
141    ///
142    /// ARIA modals:
143    /// - Trap focus within the modal (Tab cannot escape)
144    /// - Announce modal semantics to screen readers
145    /// - Block interaction with content behind the modal
146    ///
147    /// Default: `true` for accessibility compliance.
148    ///
149    /// # Invariants
150    /// - When `aria_modal()` returns true, focus MUST be trapped within the modal.
151    /// - Screen readers should announce modal state changes.
152    ///
153    /// # Failure Modes
154    /// - If focus trap is not configured, Tab may escape (accessibility violation).
155    fn aria_modal(&self) -> bool {
156        true
157    }
158
159    /// Get the IDs of focusable widgets within this modal.
160    ///
161    /// These IDs are used to create a focus group when the modal opens.
162    /// The first ID in the list receives auto-focus.
163    ///
164    /// Returns `None` if focus management is not needed (e.g., non-interactive modals).
165    ///
166    /// # Example
167    /// ```ignore
168    /// fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
169    ///     Some(vec![
170    ///         self.input_field_id,
171    ///         self.confirm_button_id,
172    ///         self.cancel_button_id,
173    ///     ])
174    /// }
175    /// ```
176    fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
177        None
178    }
179}
180
181/// An active modal in the stack.
182struct ActiveModal {
183    /// Unique identifier for this modal.
184    id: ModalId,
185    /// Z-index for layering (reserved for future compositor integration).
186    #[allow(dead_code)]
187    z_index: u32,
188    /// The modal content.
189    modal: Box<dyn StackModal>,
190    /// Hit ID for this modal's hit regions.
191    hit_id: HitId,
192    /// Focus group ID for focus trap integration.
193    focus_group_id: Option<u32>,
194}
195
196/// Stack of active modals with z-ordering and input routing.
197///
198/// # Invariants
199///
200/// - `modals` is ordered by z_index (lowest to highest).
201/// - `next_z` always produces a z_index greater than any existing modal.
202/// - Input is only routed to the top modal (last in the vec).
203pub struct ModalStack {
204    /// Active modals in z-order (bottom to top).
205    modals: Vec<ActiveModal>,
206    /// Next z-index to assign.
207    next_z: u32,
208    /// Next hit ID to assign.
209    next_hit_id: u32,
210}
211
212impl Default for ModalStack {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl ModalStack {
219    /// Create an empty modal stack.
220    pub fn new() -> Self {
221        Self {
222            modals: Vec::new(),
223            next_z: 0,
224            next_hit_id: 1000, // Start hit IDs high to avoid conflicts
225        }
226    }
227
228    // --- Stack Operations ---
229
230    /// Push a modal onto the stack.
231    ///
232    /// Returns the unique `ModalId` for the pushed modal.
233    pub fn push(&mut self, modal: Box<dyn StackModal>) -> ModalId {
234        self.push_with_focus(modal, None)
235    }
236
237    /// Push a modal with an associated focus group ID.
238    ///
239    /// The focus group ID is used to integrate with `FocusManager`:
240    /// 1. Before calling this, create a focus group with `focus_manager.create_group(id, members)`
241    /// 2. Then call `focus_manager.push_trap(id)` to trap focus within the modal
242    /// 3. When the modal closes, call `focus_manager.pop_trap()` to restore focus
243    ///
244    /// Returns the unique `ModalId` for the pushed modal.
245    pub fn push_with_focus(
246        &mut self,
247        modal: Box<dyn StackModal>,
248        focus_group_id: Option<u32>,
249    ) -> ModalId {
250        let id = ModalId::new();
251        let z_index = BASE_MODAL_Z + self.next_z;
252        self.next_z += Z_INCREMENT;
253
254        let hit_id = HitId::new(self.next_hit_id);
255        self.next_hit_id += 1;
256
257        self.modals.push(ActiveModal {
258            id,
259            z_index,
260            modal,
261            hit_id,
262            focus_group_id,
263        });
264
265        id
266    }
267
268    /// Get the focus group ID for a modal.
269    ///
270    /// Returns `None` if the modal doesn't exist or has no focus group.
271    pub fn focus_group_id(&self, modal_id: ModalId) -> Option<u32> {
272        self.modals
273            .iter()
274            .find(|m| m.id == modal_id)
275            .and_then(|m| m.focus_group_id)
276    }
277
278    /// Get the focus group ID for the top modal.
279    ///
280    /// Useful for checking if focus trap should be active.
281    pub fn top_focus_group_id(&self) -> Option<u32> {
282        self.modals.last().and_then(|m| m.focus_group_id)
283    }
284
285    /// Pop the top modal from the stack.
286    ///
287    /// Returns the result if a modal was popped, or `None` if the stack is empty.
288    /// If the modal had a focus group, the caller should call `FocusManager::pop_trap()`.
289    pub fn pop(&mut self) -> Option<ModalResult> {
290        self.modals.pop().map(|m| ModalResult {
291            id: m.id,
292            data: None,
293            focus_group_id: m.focus_group_id,
294        })
295    }
296
297    /// Pop a specific modal by ID.
298    ///
299    /// Returns the result if the modal was found and removed, or `None` if not found.
300    /// Note: This breaks strict LIFO ordering but is sometimes needed.
301    /// If the modal had a focus group, the caller should handle focus restoration.
302    pub fn pop_id(&mut self, id: ModalId) -> Option<ModalResult> {
303        let idx = self.modals.iter().position(|m| m.id == id)?;
304        let modal = self.modals.remove(idx);
305        Some(ModalResult {
306            id: modal.id,
307            data: None,
308            focus_group_id: modal.focus_group_id,
309        })
310    }
311
312    /// Pop all modals from the stack.
313    ///
314    /// Returns results in LIFO order (top first).
315    pub fn pop_all(&mut self) -> Vec<ModalResult> {
316        let mut results = Vec::with_capacity(self.modals.len());
317        while let Some(result) = self.pop() {
318            results.push(result);
319        }
320        results
321    }
322
323    /// Get a reference to the top modal.
324    pub fn top(&self) -> Option<&(dyn StackModal + 'static)> {
325        self.modals.last().map(|m| &*m.modal)
326    }
327
328    /// Get a mutable reference to the top modal.
329    pub fn top_mut(&mut self) -> Option<&mut (dyn StackModal + 'static)> {
330        match self.modals.last_mut() {
331            Some(m) => Some(m.modal.as_mut()),
332            None => None,
333        }
334    }
335
336    // --- State Queries ---
337
338    /// Check if the stack is empty.
339    #[inline]
340    pub fn is_empty(&self) -> bool {
341        self.modals.is_empty()
342    }
343
344    /// Get the number of modals in the stack.
345    #[inline]
346    pub fn depth(&self) -> usize {
347        self.modals.len()
348    }
349
350    /// Check if a modal with the given ID exists in the stack.
351    pub fn contains(&self, id: ModalId) -> bool {
352        self.modals.iter().any(|m| m.id == id)
353    }
354
355    /// Get the ID of the top modal, if any.
356    pub fn top_id(&self) -> Option<ModalId> {
357        self.modals.last().map(|m| m.id)
358    }
359
360    // --- Event Handling ---
361
362    /// Handle an event, routing it to the top modal only.
363    ///
364    /// Returns `Some(ModalResult)` if the top modal closed, otherwise `None`.
365    /// If the result contains a `focus_group_id`, the caller should call
366    /// `FocusManager::pop_trap()` to restore focus.
367    pub fn handle_event(&mut self, event: &Event) -> Option<ModalResult> {
368        let top = self.modals.last_mut()?;
369        let hit_id = top.hit_id;
370        let id = top.id;
371        let focus_group_id = top.focus_group_id;
372
373        if let Some(data) = top.modal.handle_event(event, hit_id) {
374            // Modal wants to close
375            self.modals.pop();
376            return Some(ModalResult {
377                id,
378                data: Some(data),
379                focus_group_id,
380            });
381        }
382
383        None
384    }
385
386    // --- Rendering ---
387
388    /// Render all modals in z-order.
389    ///
390    /// Modals are rendered from bottom to top. Lower modals have reduced
391    /// backdrop opacity to create a visual depth effect.
392    pub fn render(&self, frame: &mut Frame, screen: Rect) {
393        if self.modals.is_empty() {
394            return;
395        }
396
397        let modal_count = self.modals.len();
398
399        for (i, modal) in self.modals.iter().enumerate() {
400            let is_top = i == modal_count - 1;
401
402            // Calculate backdrop opacity with depth dimming
403            let base_opacity = modal.modal.backdrop_config().opacity;
404            let opacity = if is_top {
405                base_opacity
406            } else {
407                // Reduce opacity for lower modals (50% of configured)
408                base_opacity * 0.5
409            };
410
411            // Render backdrop
412            if opacity > 0.0 {
413                let bg_color = modal.modal.backdrop_config().color.with_opacity(opacity);
414                set_style_area(&mut frame.buffer, screen, Style::new().bg(bg_color));
415            }
416
417            // Calculate modal content area
418            let constraints = modal.modal.size_constraints();
419            let available = ftui_core::geometry::Size::new(screen.width, screen.height);
420            let size = constraints.clamp(available);
421
422            if size.width == 0 || size.height == 0 {
423                continue;
424            }
425
426            // Center the modal
427            let x = screen.x + (screen.width.saturating_sub(size.width)) / 2;
428            let y = screen.y + (screen.height.saturating_sub(size.height)) / 2;
429            let content_area = Rect::new(x, y, size.width, size.height);
430
431            // Render modal content
432            modal.modal.render_content(content_area, frame);
433        }
434    }
435}
436
437/// A simple modal entry that wraps any Widget.
438pub struct WidgetModalEntry<W> {
439    widget: W,
440    size: ModalSizeConstraints,
441    backdrop: BackdropConfig,
442    close_on_escape: bool,
443    close_on_backdrop: bool,
444    aria_modal: bool,
445    focusable_ids: Option<Vec<ModalFocusId>>,
446}
447
448impl<W> WidgetModalEntry<W> {
449    /// Create a new modal entry with a widget.
450    pub fn new(widget: W) -> Self {
451        Self {
452            widget,
453            size: ModalSizeConstraints::new()
454                .min_width(30)
455                .max_width(60)
456                .min_height(10)
457                .max_height(20),
458            backdrop: BackdropConfig::default(),
459            close_on_escape: true,
460            close_on_backdrop: true,
461            aria_modal: true,
462            focusable_ids: None,
463        }
464    }
465
466    /// Set size constraints.
467    pub fn size(mut self, size: ModalSizeConstraints) -> Self {
468        self.size = size;
469        self
470    }
471
472    /// Set backdrop configuration.
473    pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
474        self.backdrop = backdrop;
475        self
476    }
477
478    /// Set whether Escape closes the modal.
479    pub fn close_on_escape(mut self, close: bool) -> Self {
480        self.close_on_escape = close;
481        self
482    }
483
484    /// Set whether backdrop click closes the modal.
485    pub fn close_on_backdrop(mut self, close: bool) -> Self {
486        self.close_on_backdrop = close;
487        self
488    }
489
490    /// Set whether this modal is an ARIA modal.
491    ///
492    /// ARIA modals trap focus and announce semantics to screen readers.
493    /// Default is `true` for accessibility compliance.
494    pub fn with_aria_modal(mut self, aria_modal: bool) -> Self {
495        self.aria_modal = aria_modal;
496        self
497    }
498
499    /// Set the focusable widget IDs for focus trap integration.
500    ///
501    /// When provided, these IDs will be used to:
502    /// 1. Create a focus group constraining Tab navigation
503    /// 2. Auto-focus the first focusable widget when modal opens
504    /// 3. Restore focus to the previous element when modal closes
505    pub fn with_focusable_ids(mut self, ids: Vec<ModalFocusId>) -> Self {
506        self.focusable_ids = Some(ids);
507        self
508    }
509}
510
511impl<W: crate::Widget + Send> StackModal for WidgetModalEntry<W> {
512    fn render_content(&self, area: Rect, frame: &mut Frame) {
513        self.widget.render(area, frame);
514    }
515
516    fn handle_event(&mut self, event: &Event, _hit_id: HitId) -> Option<ModalResultData> {
517        use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind};
518
519        // Handle escape to close
520        if self.close_on_escape
521            && let Event::Key(KeyEvent {
522                code: KeyCode::Escape,
523                kind: KeyEventKind::Press,
524                ..
525            }) = event
526        {
527            return Some(ModalResultData::Dismissed);
528        }
529
530        None
531    }
532
533    fn size_constraints(&self) -> ModalSizeConstraints {
534        self.size
535    }
536
537    fn backdrop_config(&self) -> BackdropConfig {
538        self.backdrop
539    }
540
541    fn close_on_escape(&self) -> bool {
542        self.close_on_escape
543    }
544
545    fn close_on_backdrop(&self) -> bool {
546        self.close_on_backdrop
547    }
548
549    fn aria_modal(&self) -> bool {
550        self.aria_modal
551    }
552
553    fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
554        self.focusable_ids.clone()
555    }
556}
557
558// =========================================================================
559// Modal Focus Integration Helper (bd-39vx.5)
560// =========================================================================
561
562/// Helper for integrating `ModalStack` with `FocusManager`.
563///
564/// This struct provides a convenient API for:
565/// - Pushing modals with automatic focus trap setup
566/// - Popping modals with focus restoration
567/// - Managing focus groups for nested modals
568///
569/// # Example
570/// ```ignore
571/// use ftui_widgets::modal::{ModalStack, ModalFocusIntegration};
572/// use ftui_widgets::focus::FocusManager;
573///
574/// let mut stack = ModalStack::new();
575/// let mut focus = FocusManager::new();
576/// let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
577///
578/// // Push a modal with focus management
579/// let modal_id = integrator.push_with_focus(dialog);
580///
581/// // ... handle events ...
582///
583/// // Pop modal and restore focus
584/// integrator.pop_with_focus();
585/// ```
586#[allow(dead_code)]
587pub struct ModalFocusIntegration<'a> {
588    stack: &'a mut ModalStack,
589    focus: &'a mut crate::focus::FocusManager,
590    next_group_id: u32,
591}
592
593impl<'a> ModalFocusIntegration<'a> {
594    /// Create a new integration helper.
595    pub fn new(stack: &'a mut ModalStack, focus: &'a mut crate::focus::FocusManager) -> Self {
596        Self {
597            stack,
598            focus,
599            next_group_id: 1000, // Start high to avoid conflicts
600        }
601    }
602
603    /// Push a modal with automatic focus management.
604    ///
605    /// 1. Creates a focus group from `modal.focusable_ids()` (if provided)
606    /// 2. Pushes a focus trap to constrain Tab navigation
607    /// 3. Auto-focuses the first focusable widget
608    /// 4. Stores the previous focus for restoration on close
609    ///
610    /// Returns the modal ID.
611    pub fn push_with_focus(&mut self, modal: Box<dyn StackModal>) -> ModalId {
612        let focusable_ids = modal.focusable_ids();
613        let is_aria_modal = modal.aria_modal();
614
615        let focus_group_id = if is_aria_modal {
616            if let Some(ids) = focusable_ids {
617                let group_id = self.next_group_id;
618                self.next_group_id += 1;
619
620                // Convert ModalFocusId (u64) to FocusId (u64) for the focus manager
621                let focus_ids: Vec<crate::focus::FocusId> = ids.into_iter().collect();
622
623                // Create focus group and trap
624                self.focus.create_group(group_id, focus_ids);
625                self.focus.push_trap(group_id);
626
627                Some(group_id)
628            } else {
629                None
630            }
631        } else {
632            None
633        };
634
635        self.stack.push_with_focus(modal, focus_group_id)
636    }
637
638    /// Pop the top modal with focus restoration.
639    ///
640    /// If the modal had a focus group, the trap is popped and focus
641    /// is restored to the element that was focused before the modal opened.
642    ///
643    /// Returns the modal result.
644    pub fn pop_with_focus(&mut self) -> Option<ModalResult> {
645        let result = self.stack.pop();
646
647        if let Some(ref res) = result
648            && res.focus_group_id.is_some()
649        {
650            self.focus.pop_trap();
651        }
652
653        result
654    }
655
656    /// Handle an event with automatic focus trap popping.
657    ///
658    /// If the event causes the modal to close, the focus trap is popped.
659    pub fn handle_event(&mut self, event: &Event) -> Option<ModalResult> {
660        let result = self.stack.handle_event(event);
661
662        if let Some(ref res) = result
663            && res.focus_group_id.is_some()
664        {
665            self.focus.pop_trap();
666        }
667
668        result
669    }
670
671    /// Check if focus is currently trapped in a modal.
672    pub fn is_focus_trapped(&self) -> bool {
673        self.focus.is_trapped()
674    }
675
676    /// Get a reference to the underlying modal stack.
677    pub fn stack(&self) -> &ModalStack {
678        self.stack
679    }
680
681    /// Get a mutable reference to the underlying modal stack.
682    pub fn stack_mut(&mut self) -> &mut ModalStack {
683        self.stack
684    }
685
686    /// Get a reference to the underlying focus manager.
687    pub fn focus(&self) -> &crate::focus::FocusManager {
688        self.focus
689    }
690
691    /// Get a mutable reference to the underlying focus manager.
692    pub fn focus_mut(&mut self) -> &mut crate::focus::FocusManager {
693        self.focus
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::Widget;
701    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
702    use ftui_render::cell::PackedRgba;
703    use ftui_render::grapheme_pool::GraphemePool;
704
705    #[derive(Debug, Clone)]
706    struct StubWidget;
707
708    impl Widget for StubWidget {
709        fn render(&self, _area: Rect, _frame: &mut Frame) {}
710    }
711
712    #[test]
713    fn empty_stack() {
714        let stack = ModalStack::new();
715        assert!(stack.is_empty());
716        assert_eq!(stack.depth(), 0);
717        assert!(stack.top().is_none());
718        assert!(stack.top_id().is_none());
719    }
720
721    #[test]
722    fn push_increases_depth() {
723        let mut stack = ModalStack::new();
724        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
725        assert_eq!(stack.depth(), 1);
726        assert!(!stack.is_empty());
727        assert!(stack.contains(id1));
728
729        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
730        assert_eq!(stack.depth(), 2);
731        assert!(stack.contains(id2));
732        assert_eq!(stack.top_id(), Some(id2));
733    }
734
735    #[test]
736    fn pop_lifo_order() {
737        let mut stack = ModalStack::new();
738        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
739        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
740        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
741
742        let result = stack.pop();
743        assert_eq!(result.map(|r| r.id), Some(id3));
744        assert_eq!(stack.depth(), 2);
745
746        let result = stack.pop();
747        assert_eq!(result.map(|r| r.id), Some(id2));
748        assert_eq!(stack.depth(), 1);
749
750        let result = stack.pop();
751        assert_eq!(result.map(|r| r.id), Some(id1));
752        assert!(stack.is_empty());
753    }
754
755    #[test]
756    fn pop_empty_returns_none() {
757        let mut stack = ModalStack::new();
758        assert!(stack.pop().is_none());
759    }
760
761    #[test]
762    fn pop_by_id() {
763        let mut stack = ModalStack::new();
764        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
765        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
766        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
767
768        // Pop middle modal
769        let result = stack.pop_id(id2);
770        assert_eq!(result.map(|r| r.id), Some(id2));
771        assert_eq!(stack.depth(), 2);
772        assert!(!stack.contains(id2));
773        assert!(stack.contains(id1));
774        assert!(stack.contains(id3));
775    }
776
777    #[test]
778    fn pop_by_nonexistent_id() {
779        let mut stack = ModalStack::new();
780        let _id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
781
782        // Try to pop non-existent ID
783        let fake_id = ModalId(999999);
784        assert!(stack.pop_id(fake_id).is_none());
785        assert_eq!(stack.depth(), 1);
786    }
787
788    #[test]
789    fn pop_all() {
790        let mut stack = ModalStack::new();
791        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
792        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
793        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
794
795        let results = stack.pop_all();
796        assert_eq!(results.len(), 3);
797        // LIFO order: id3, id2, id1
798        assert_eq!(results[0].id, id3);
799        assert_eq!(results[1].id, id2);
800        assert_eq!(results[2].id, id1);
801        assert!(stack.is_empty());
802    }
803
804    #[test]
805    fn z_order_increasing() {
806        let mut stack = ModalStack::new();
807
808        // Push multiple modals
809        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
810        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
811        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
812
813        // Verify z-order is increasing
814        let z_indices: Vec<u32> = stack.modals.iter().map(|m| m.z_index).collect();
815        for i in 1..z_indices.len() {
816            assert!(
817                z_indices[i] > z_indices[i - 1],
818                "z_index should be strictly increasing"
819            );
820        }
821    }
822
823    #[test]
824    fn escape_closes_top_modal() {
825        let mut stack = ModalStack::new();
826        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
827        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
828
829        let escape = Event::Key(KeyEvent {
830            code: KeyCode::Escape,
831            modifiers: Modifiers::empty(),
832            kind: KeyEventKind::Press,
833        });
834
835        // Escape should close top modal (id2)
836        let result = stack.handle_event(&escape);
837        assert!(result.is_some());
838        assert_eq!(result.unwrap().id, id2);
839        assert_eq!(stack.depth(), 1);
840        assert_eq!(stack.top_id(), Some(id1));
841    }
842
843    #[test]
844    fn render_does_not_panic() {
845        let mut stack = ModalStack::new();
846        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
847        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
848
849        let mut pool = GraphemePool::new();
850        let mut frame = Frame::new(80, 24, &mut pool);
851        let screen = Rect::new(0, 0, 80, 24);
852
853        // Should not panic
854        stack.render(&mut frame, screen);
855    }
856
857    #[test]
858    fn render_empty_stack_no_op() {
859        let stack = ModalStack::new();
860        let mut pool = GraphemePool::new();
861        let mut frame = Frame::new(80, 24, &mut pool);
862        let screen = Rect::new(0, 0, 80, 24);
863
864        // Should be a no-op
865        stack.render(&mut frame, screen);
866    }
867
868    #[test]
869    fn contains_after_pop() {
870        let mut stack = ModalStack::new();
871        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
872
873        assert!(stack.contains(id1));
874        stack.pop();
875        assert!(!stack.contains(id1));
876    }
877
878    #[test]
879    fn unique_modal_ids() {
880        let mut stack = ModalStack::new();
881        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
882        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
883        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
884
885        assert_ne!(id1, id2);
886        assert_ne!(id2, id3);
887        assert_ne!(id1, id3);
888    }
889
890    #[test]
891    fn widget_modal_entry_builder() {
892        let entry = WidgetModalEntry::new(StubWidget)
893            .size(ModalSizeConstraints::new().min_width(40).max_width(80))
894            .backdrop(BackdropConfig::new(PackedRgba::rgb(0, 0, 0), 0.8))
895            .close_on_escape(false)
896            .close_on_backdrop(false);
897
898        assert!(!entry.close_on_escape);
899        assert!(!entry.close_on_backdrop);
900        assert_eq!(entry.size.min_width, Some(40));
901        assert_eq!(entry.size.max_width, Some(80));
902    }
903
904    #[test]
905    fn escape_disabled_does_not_close() {
906        let mut stack = ModalStack::new();
907        stack.push(Box::new(
908            WidgetModalEntry::new(StubWidget).close_on_escape(false),
909        ));
910
911        let escape = Event::Key(KeyEvent {
912            code: KeyCode::Escape,
913            modifiers: Modifiers::empty(),
914            kind: KeyEventKind::Press,
915        });
916
917        // Escape should NOT close the modal
918        let result = stack.handle_event(&escape);
919        assert!(result.is_none());
920        assert_eq!(stack.depth(), 1);
921    }
922
923    // --- Focus group integration tests ---
924
925    #[test]
926    fn push_with_focus_tracks_group_id() {
927        let mut stack = ModalStack::new();
928        let modal_id = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(42));
929
930        assert_eq!(stack.focus_group_id(modal_id), Some(42));
931        assert_eq!(stack.top_focus_group_id(), Some(42));
932    }
933
934    #[test]
935    fn pop_returns_focus_group_id() {
936        let mut stack = ModalStack::new();
937        stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(99));
938
939        let result = stack.pop();
940        assert!(result.is_some());
941        assert_eq!(result.unwrap().focus_group_id, Some(99));
942    }
943
944    #[test]
945    fn pop_id_returns_focus_group_id() {
946        let mut stack = ModalStack::new();
947        let id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(10));
948        let _id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(20));
949
950        let result = stack.pop_id(id1);
951        assert!(result.is_some());
952        assert_eq!(result.unwrap().focus_group_id, Some(10));
953    }
954
955    #[test]
956    fn handle_event_returns_focus_group_id() {
957        let mut stack = ModalStack::new();
958        stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(77));
959
960        let escape = Event::Key(KeyEvent {
961            code: KeyCode::Escape,
962            modifiers: Modifiers::empty(),
963            kind: KeyEventKind::Press,
964        });
965
966        let result = stack.handle_event(&escape);
967        assert!(result.is_some());
968        assert_eq!(result.unwrap().focus_group_id, Some(77));
969    }
970
971    #[test]
972    fn push_without_focus_has_none_group_id() {
973        let mut stack = ModalStack::new();
974        let modal_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
975
976        assert_eq!(stack.focus_group_id(modal_id), None);
977        assert_eq!(stack.top_focus_group_id(), None);
978    }
979
980    #[test]
981    fn nested_focus_groups_track_correctly() {
982        let mut stack = ModalStack::new();
983        let _id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(1));
984        let id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(2));
985        let _id3 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(3));
986
987        // Top should be group 3
988        assert_eq!(stack.top_focus_group_id(), Some(3));
989
990        // Pop top, now group 2 is on top
991        stack.pop();
992        assert_eq!(stack.top_focus_group_id(), Some(2));
993
994        // Query specific modal
995        assert_eq!(stack.focus_group_id(id2), Some(2));
996    }
997
998    // --- ARIA modal tests (bd-39vx.5) ---
999
1000    #[test]
1001    fn default_aria_modal_is_true() {
1002        let entry = WidgetModalEntry::new(StubWidget);
1003        assert!(entry.aria_modal);
1004    }
1005
1006    #[test]
1007    fn aria_modal_builder() {
1008        let entry = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1009        assert!(!entry.aria_modal);
1010    }
1011
1012    #[test]
1013    fn focusable_ids_builder() {
1014        let entry = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2, 3]);
1015        assert_eq!(entry.focusable_ids, Some(vec![1, 2, 3]));
1016    }
1017
1018    #[test]
1019    fn stack_modal_aria_modal_trait() {
1020        let entry = WidgetModalEntry::new(StubWidget);
1021        assert!(StackModal::aria_modal(&entry)); // Default true
1022
1023        let entry_non_aria = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1024        assert!(!StackModal::aria_modal(&entry_non_aria));
1025    }
1026
1027    #[test]
1028    fn stack_modal_focusable_ids_trait() {
1029        let entry = WidgetModalEntry::new(StubWidget);
1030        assert!(StackModal::focusable_ids(&entry).is_none()); // Default none
1031
1032        let entry_with_ids = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 20]);
1033        assert_eq!(
1034            StackModal::focusable_ids(&entry_with_ids),
1035            Some(vec![10, 20])
1036        );
1037    }
1038
1039    // --- ModalFocusIntegration tests ---
1040
1041    #[test]
1042    fn focus_integration_push_creates_trap() {
1043        use crate::focus::{FocusManager, FocusNode};
1044        use ftui_core::geometry::Rect;
1045
1046        let mut stack = ModalStack::new();
1047        let mut focus = FocusManager::new();
1048
1049        // Register focusable nodes
1050        focus
1051            .graph_mut()
1052            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1053        focus
1054            .graph_mut()
1055            .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1056        focus
1057            .graph_mut()
1058            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); // Outside modal
1059
1060        // Focus outside modal initially
1061        focus.focus(100);
1062        assert_eq!(focus.current(), Some(100));
1063
1064        {
1065            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1066
1067            // Push modal with focusable IDs
1068            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1069            let _modal_id = integrator.push_with_focus(Box::new(modal));
1070
1071            // Focus should now be trapped
1072            assert!(integrator.is_focus_trapped());
1073
1074            // Focus should move to first focusable in modal
1075            assert_eq!(integrator.focus().current(), Some(1));
1076        }
1077    }
1078
1079    #[test]
1080    fn focus_integration_pop_restores_focus() {
1081        use crate::focus::{FocusManager, FocusNode};
1082        use ftui_core::geometry::Rect;
1083
1084        let mut stack = ModalStack::new();
1085        let mut focus = FocusManager::new();
1086
1087        // Register focusable nodes
1088        focus
1089            .graph_mut()
1090            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1091        focus
1092            .graph_mut()
1093            .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1094        focus
1095            .graph_mut()
1096            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); // Trigger element
1097
1098        // Focus the trigger element before opening modal
1099        focus.focus(100);
1100        assert_eq!(focus.current(), Some(100));
1101
1102        {
1103            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1104
1105            // Push modal
1106            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1107            integrator.push_with_focus(Box::new(modal));
1108
1109            // Focus is in modal
1110            assert!(integrator.is_focus_trapped());
1111
1112            // Pop modal
1113            let result = integrator.pop_with_focus();
1114            assert!(result.is_some());
1115
1116            // Focus should be restored to trigger element
1117            assert!(!integrator.is_focus_trapped());
1118            assert_eq!(integrator.focus().current(), Some(100));
1119        }
1120    }
1121
1122    #[test]
1123    fn focus_integration_escape_restores_focus() {
1124        use crate::focus::{FocusManager, FocusNode};
1125        use ftui_core::geometry::Rect;
1126
1127        let mut stack = ModalStack::new();
1128        let mut focus = FocusManager::new();
1129
1130        focus
1131            .graph_mut()
1132            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1133        focus
1134            .graph_mut()
1135            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1136
1137        focus.focus(100);
1138
1139        {
1140            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1141
1142            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
1143            integrator.push_with_focus(Box::new(modal));
1144
1145            assert!(integrator.is_focus_trapped());
1146
1147            // Simulate Escape key
1148            let escape = Event::Key(KeyEvent {
1149                code: KeyCode::Escape,
1150                modifiers: Modifiers::empty(),
1151                kind: KeyEventKind::Press,
1152            });
1153            let result = integrator.handle_event(&escape);
1154
1155            assert!(result.is_some());
1156            assert!(!integrator.is_focus_trapped());
1157            assert_eq!(integrator.focus().current(), Some(100));
1158        }
1159    }
1160
1161    #[test]
1162    fn focus_integration_non_aria_modal_no_trap() {
1163        use crate::focus::{FocusManager, FocusNode};
1164        use ftui_core::geometry::Rect;
1165
1166        let mut stack = ModalStack::new();
1167        let mut focus = FocusManager::new();
1168
1169        focus
1170            .graph_mut()
1171            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1172        focus
1173            .graph_mut()
1174            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1175
1176        focus.focus(100);
1177
1178        {
1179            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1180
1181            // Push non-ARIA modal (aria_modal = false)
1182            let modal = WidgetModalEntry::new(StubWidget)
1183                .with_aria_modal(false)
1184                .with_focusable_ids(vec![1]);
1185            integrator.push_with_focus(Box::new(modal));
1186
1187            // Focus should NOT be trapped for non-ARIA modals
1188            assert!(!integrator.is_focus_trapped());
1189        }
1190    }
1191
1192    #[test]
1193    fn focus_integration_nested_modals() {
1194        use crate::focus::{FocusManager, FocusNode};
1195        use ftui_core::geometry::Rect;
1196
1197        let mut stack = ModalStack::new();
1198        let mut focus = FocusManager::new();
1199
1200        // Register nodes for both modals and background
1201        focus
1202            .graph_mut()
1203            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1204        focus
1205            .graph_mut()
1206            .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1207        focus
1208            .graph_mut()
1209            .insert(FocusNode::new(10, Rect::new(0, 5, 10, 1)));
1210        focus
1211            .graph_mut()
1212            .insert(FocusNode::new(11, Rect::new(0, 6, 10, 1)));
1213        focus
1214            .graph_mut()
1215            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1216
1217        focus.focus(100);
1218
1219        {
1220            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1221
1222            // Push first modal
1223            let modal1 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1224            integrator.push_with_focus(Box::new(modal1));
1225            assert_eq!(integrator.focus().current(), Some(1));
1226
1227            // Push second modal (nested)
1228            let modal2 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 11]);
1229            integrator.push_with_focus(Box::new(modal2));
1230            assert_eq!(integrator.focus().current(), Some(10));
1231
1232            // Pop second modal - should restore to first modal's focus
1233            integrator.pop_with_focus();
1234            assert_eq!(integrator.focus().current(), Some(1));
1235
1236            // Pop first modal - should restore to original focus
1237            integrator.pop_with_focus();
1238            assert_eq!(integrator.focus().current(), Some(100));
1239        }
1240    }
1241}