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    #[must_use]
468    pub fn size(mut self, size: ModalSizeConstraints) -> Self {
469        self.size = size;
470        self
471    }
472
473    /// Set backdrop configuration.
474    #[must_use]
475    pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
476        self.backdrop = backdrop;
477        self
478    }
479
480    /// Set whether Escape closes the modal.
481    #[must_use]
482    pub fn close_on_escape(mut self, close: bool) -> Self {
483        self.close_on_escape = close;
484        self
485    }
486
487    /// Set whether backdrop click closes the modal.
488    #[must_use]
489    pub fn close_on_backdrop(mut self, close: bool) -> Self {
490        self.close_on_backdrop = close;
491        self
492    }
493
494    /// Set whether this modal is an ARIA modal.
495    ///
496    /// ARIA modals trap focus and announce semantics to screen readers.
497    /// Default is `true` for accessibility compliance.
498    #[must_use]
499    pub fn with_aria_modal(mut self, aria_modal: bool) -> Self {
500        self.aria_modal = aria_modal;
501        self
502    }
503
504    /// Set the focusable widget IDs for focus trap integration.
505    ///
506    /// When provided, these IDs will be used to:
507    /// 1. Create a focus group constraining Tab navigation
508    /// 2. Auto-focus the first focusable widget when modal opens
509    /// 3. Restore focus to the previous element when modal closes
510    #[must_use]
511    pub fn with_focusable_ids(mut self, ids: Vec<ModalFocusId>) -> Self {
512        self.focusable_ids = Some(ids);
513        self
514    }
515}
516
517impl<W: crate::Widget + Send> StackModal for WidgetModalEntry<W> {
518    fn render_content(&self, area: Rect, frame: &mut Frame) {
519        self.widget.render(area, frame);
520    }
521
522    fn handle_event(&mut self, event: &Event, _hit_id: HitId) -> Option<ModalResultData> {
523        use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind};
524
525        // Handle escape to close
526        if self.close_on_escape
527            && let Event::Key(KeyEvent {
528                code: KeyCode::Escape,
529                kind: KeyEventKind::Press,
530                ..
531            }) = event
532        {
533            return Some(ModalResultData::Dismissed);
534        }
535
536        None
537    }
538
539    fn size_constraints(&self) -> ModalSizeConstraints {
540        self.size
541    }
542
543    fn backdrop_config(&self) -> BackdropConfig {
544        self.backdrop
545    }
546
547    fn close_on_escape(&self) -> bool {
548        self.close_on_escape
549    }
550
551    fn close_on_backdrop(&self) -> bool {
552        self.close_on_backdrop
553    }
554
555    fn aria_modal(&self) -> bool {
556        self.aria_modal
557    }
558
559    fn focusable_ids(&self) -> Option<Vec<ModalFocusId>> {
560        self.focusable_ids.clone()
561    }
562}
563
564// =========================================================================
565// Modal Focus Integration Helper (bd-39vx.5)
566// =========================================================================
567
568/// Helper for integrating `ModalStack` with `FocusManager`.
569///
570/// This struct provides a convenient API for:
571/// - Pushing modals with automatic focus trap setup
572/// - Popping modals with focus restoration
573/// - Managing focus groups for nested modals
574///
575/// # Example
576/// ```ignore
577/// use ftui_widgets::modal::{ModalStack, ModalFocusIntegration};
578/// use ftui_widgets::focus::FocusManager;
579///
580/// let mut stack = ModalStack::new();
581/// let mut focus = FocusManager::new();
582/// let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
583///
584/// // Push a modal with focus management
585/// let modal_id = integrator.push_with_focus(dialog);
586///
587/// // ... handle events ...
588///
589/// // Pop modal and restore focus
590/// integrator.pop_with_focus();
591/// ```
592#[allow(dead_code)]
593pub struct ModalFocusIntegration<'a> {
594    stack: &'a mut ModalStack,
595    focus: &'a mut crate::focus::FocusManager,
596    next_group_id: u32,
597}
598
599impl<'a> ModalFocusIntegration<'a> {
600    /// Create a new integration helper.
601    pub fn new(stack: &'a mut ModalStack, focus: &'a mut crate::focus::FocusManager) -> Self {
602        Self {
603            stack,
604            focus,
605            next_group_id: 1000, // Start high to avoid conflicts
606        }
607    }
608
609    /// Push a modal with automatic focus management.
610    ///
611    /// 1. Creates a focus group from `modal.focusable_ids()` (if provided)
612    /// 2. Pushes a focus trap to constrain Tab navigation
613    /// 3. Auto-focuses the first focusable widget
614    /// 4. Stores the previous focus for restoration on close
615    ///
616    /// Returns the modal ID.
617    pub fn push_with_focus(&mut self, modal: Box<dyn StackModal>) -> ModalId {
618        let focusable_ids = modal.focusable_ids();
619        let is_aria_modal = modal.aria_modal();
620
621        let focus_group_id = if is_aria_modal {
622            if let Some(ids) = focusable_ids {
623                let group_id = self.next_group_id;
624                self.next_group_id += 1;
625
626                // Convert ModalFocusId (u64) to FocusId (u64) for the focus manager
627                let focus_ids: Vec<crate::focus::FocusId> = ids.into_iter().collect();
628
629                // Create focus group and trap
630                self.focus.create_group(group_id, focus_ids);
631                self.focus.push_trap(group_id);
632
633                Some(group_id)
634            } else {
635                None
636            }
637        } else {
638            None
639        };
640
641        self.stack.push_with_focus(modal, focus_group_id)
642    }
643
644    /// Pop the top modal with focus restoration.
645    ///
646    /// If the modal had a focus group, the trap is popped and focus
647    /// is restored to the element that was focused before the modal opened.
648    ///
649    /// Returns the modal result.
650    pub fn pop_with_focus(&mut self) -> Option<ModalResult> {
651        let result = self.stack.pop();
652
653        if let Some(ref res) = result
654            && res.focus_group_id.is_some()
655        {
656            self.focus.pop_trap();
657        }
658
659        result
660    }
661
662    /// Handle an event with automatic focus trap popping.
663    ///
664    /// If the event causes the modal to close, the focus trap is popped.
665    pub fn handle_event(&mut self, event: &Event) -> Option<ModalResult> {
666        let result = self.stack.handle_event(event);
667
668        if let Some(ref res) = result
669            && res.focus_group_id.is_some()
670        {
671            self.focus.pop_trap();
672        }
673
674        result
675    }
676
677    /// Check if focus is currently trapped in a modal.
678    pub fn is_focus_trapped(&self) -> bool {
679        self.focus.is_trapped()
680    }
681
682    /// Get a reference to the underlying modal stack.
683    pub fn stack(&self) -> &ModalStack {
684        self.stack
685    }
686
687    /// Get a mutable reference to the underlying modal stack.
688    pub fn stack_mut(&mut self) -> &mut ModalStack {
689        self.stack
690    }
691
692    /// Get a reference to the underlying focus manager.
693    pub fn focus(&self) -> &crate::focus::FocusManager {
694        self.focus
695    }
696
697    /// Get a mutable reference to the underlying focus manager.
698    pub fn focus_mut(&mut self) -> &mut crate::focus::FocusManager {
699        self.focus
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::Widget;
707    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
708    use ftui_render::cell::PackedRgba;
709    use ftui_render::grapheme_pool::GraphemePool;
710
711    #[derive(Debug, Clone)]
712    struct StubWidget;
713
714    impl Widget for StubWidget {
715        fn render(&self, _area: Rect, _frame: &mut Frame) {}
716    }
717
718    #[test]
719    fn empty_stack() {
720        let stack = ModalStack::new();
721        assert!(stack.is_empty());
722        assert_eq!(stack.depth(), 0);
723        assert!(stack.top().is_none());
724        assert!(stack.top_id().is_none());
725    }
726
727    #[test]
728    fn push_increases_depth() {
729        let mut stack = ModalStack::new();
730        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
731        assert_eq!(stack.depth(), 1);
732        assert!(!stack.is_empty());
733        assert!(stack.contains(id1));
734
735        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
736        assert_eq!(stack.depth(), 2);
737        assert!(stack.contains(id2));
738        assert_eq!(stack.top_id(), Some(id2));
739    }
740
741    #[test]
742    fn pop_lifo_order() {
743        let mut stack = ModalStack::new();
744        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
745        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
746        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
747
748        let result = stack.pop();
749        assert_eq!(result.map(|r| r.id), Some(id3));
750        assert_eq!(stack.depth(), 2);
751
752        let result = stack.pop();
753        assert_eq!(result.map(|r| r.id), Some(id2));
754        assert_eq!(stack.depth(), 1);
755
756        let result = stack.pop();
757        assert_eq!(result.map(|r| r.id), Some(id1));
758        assert!(stack.is_empty());
759    }
760
761    #[test]
762    fn pop_empty_returns_none() {
763        let mut stack = ModalStack::new();
764        assert!(stack.pop().is_none());
765    }
766
767    #[test]
768    fn pop_by_id() {
769        let mut stack = ModalStack::new();
770        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
771        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
772        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
773
774        // Pop middle modal
775        let result = stack.pop_id(id2);
776        assert_eq!(result.map(|r| r.id), Some(id2));
777        assert_eq!(stack.depth(), 2);
778        assert!(!stack.contains(id2));
779        assert!(stack.contains(id1));
780        assert!(stack.contains(id3));
781    }
782
783    #[test]
784    fn pop_by_nonexistent_id() {
785        let mut stack = ModalStack::new();
786        let _id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
787
788        // Try to pop non-existent ID
789        let fake_id = ModalId(999999);
790        assert!(stack.pop_id(fake_id).is_none());
791        assert_eq!(stack.depth(), 1);
792    }
793
794    #[test]
795    fn pop_all() {
796        let mut stack = ModalStack::new();
797        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
798        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
799        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
800
801        let results = stack.pop_all();
802        assert_eq!(results.len(), 3);
803        // LIFO order: id3, id2, id1
804        assert_eq!(results[0].id, id3);
805        assert_eq!(results[1].id, id2);
806        assert_eq!(results[2].id, id1);
807        assert!(stack.is_empty());
808    }
809
810    #[test]
811    fn z_order_increasing() {
812        let mut stack = ModalStack::new();
813
814        // Push multiple modals
815        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
816        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
817        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
818
819        // Verify z-order is increasing
820        let z_indices: Vec<u32> = stack.modals.iter().map(|m| m.z_index).collect();
821        for i in 1..z_indices.len() {
822            assert!(
823                z_indices[i] > z_indices[i - 1],
824                "z_index should be strictly increasing"
825            );
826        }
827    }
828
829    #[test]
830    fn escape_closes_top_modal() {
831        let mut stack = ModalStack::new();
832        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
833        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
834
835        let escape = Event::Key(KeyEvent {
836            code: KeyCode::Escape,
837            modifiers: Modifiers::empty(),
838            kind: KeyEventKind::Press,
839        });
840
841        // Escape should close top modal (id2)
842        let result = stack.handle_event(&escape);
843        assert!(result.is_some());
844        assert_eq!(result.unwrap().id, id2);
845        assert_eq!(stack.depth(), 1);
846        assert_eq!(stack.top_id(), Some(id1));
847    }
848
849    #[test]
850    fn render_does_not_panic() {
851        let mut stack = ModalStack::new();
852        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
853        stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
854
855        let mut pool = GraphemePool::new();
856        let mut frame = Frame::new(80, 24, &mut pool);
857        let screen = Rect::new(0, 0, 80, 24);
858
859        // Should not panic
860        stack.render(&mut frame, screen);
861    }
862
863    #[test]
864    fn render_empty_stack_no_op() {
865        let stack = ModalStack::new();
866        let mut pool = GraphemePool::new();
867        let mut frame = Frame::new(80, 24, &mut pool);
868        let screen = Rect::new(0, 0, 80, 24);
869
870        // Should be a no-op
871        stack.render(&mut frame, screen);
872    }
873
874    #[test]
875    fn contains_after_pop() {
876        let mut stack = ModalStack::new();
877        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
878
879        assert!(stack.contains(id1));
880        stack.pop();
881        assert!(!stack.contains(id1));
882    }
883
884    #[test]
885    fn unique_modal_ids() {
886        let mut stack = ModalStack::new();
887        let id1 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
888        let id2 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
889        let id3 = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
890
891        assert_ne!(id1, id2);
892        assert_ne!(id2, id3);
893        assert_ne!(id1, id3);
894    }
895
896    #[test]
897    fn widget_modal_entry_builder() {
898        let entry = WidgetModalEntry::new(StubWidget)
899            .size(ModalSizeConstraints::new().min_width(40).max_width(80))
900            .backdrop(BackdropConfig::new(PackedRgba::rgb(0, 0, 0), 0.8))
901            .close_on_escape(false)
902            .close_on_backdrop(false);
903
904        assert!(!entry.close_on_escape);
905        assert!(!entry.close_on_backdrop);
906        assert_eq!(entry.size.min_width, Some(40));
907        assert_eq!(entry.size.max_width, Some(80));
908    }
909
910    #[test]
911    fn escape_disabled_does_not_close() {
912        let mut stack = ModalStack::new();
913        stack.push(Box::new(
914            WidgetModalEntry::new(StubWidget).close_on_escape(false),
915        ));
916
917        let escape = Event::Key(KeyEvent {
918            code: KeyCode::Escape,
919            modifiers: Modifiers::empty(),
920            kind: KeyEventKind::Press,
921        });
922
923        // Escape should NOT close the modal
924        let result = stack.handle_event(&escape);
925        assert!(result.is_none());
926        assert_eq!(stack.depth(), 1);
927    }
928
929    // --- Focus group integration tests ---
930
931    #[test]
932    fn push_with_focus_tracks_group_id() {
933        let mut stack = ModalStack::new();
934        let modal_id = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(42));
935
936        assert_eq!(stack.focus_group_id(modal_id), Some(42));
937        assert_eq!(stack.top_focus_group_id(), Some(42));
938    }
939
940    #[test]
941    fn pop_returns_focus_group_id() {
942        let mut stack = ModalStack::new();
943        stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(99));
944
945        let result = stack.pop();
946        assert!(result.is_some());
947        assert_eq!(result.unwrap().focus_group_id, Some(99));
948    }
949
950    #[test]
951    fn pop_id_returns_focus_group_id() {
952        let mut stack = ModalStack::new();
953        let id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(10));
954        let _id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(20));
955
956        let result = stack.pop_id(id1);
957        assert!(result.is_some());
958        assert_eq!(result.unwrap().focus_group_id, Some(10));
959    }
960
961    #[test]
962    fn handle_event_returns_focus_group_id() {
963        let mut stack = ModalStack::new();
964        stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(77));
965
966        let escape = Event::Key(KeyEvent {
967            code: KeyCode::Escape,
968            modifiers: Modifiers::empty(),
969            kind: KeyEventKind::Press,
970        });
971
972        let result = stack.handle_event(&escape);
973        assert!(result.is_some());
974        assert_eq!(result.unwrap().focus_group_id, Some(77));
975    }
976
977    #[test]
978    fn push_without_focus_has_none_group_id() {
979        let mut stack = ModalStack::new();
980        let modal_id = stack.push(Box::new(WidgetModalEntry::new(StubWidget)));
981
982        assert_eq!(stack.focus_group_id(modal_id), None);
983        assert_eq!(stack.top_focus_group_id(), None);
984    }
985
986    #[test]
987    fn nested_focus_groups_track_correctly() {
988        let mut stack = ModalStack::new();
989        let _id1 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(1));
990        let id2 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(2));
991        let _id3 = stack.push_with_focus(Box::new(WidgetModalEntry::new(StubWidget)), Some(3));
992
993        // Top should be group 3
994        assert_eq!(stack.top_focus_group_id(), Some(3));
995
996        // Pop top, now group 2 is on top
997        stack.pop();
998        assert_eq!(stack.top_focus_group_id(), Some(2));
999
1000        // Query specific modal
1001        assert_eq!(stack.focus_group_id(id2), Some(2));
1002    }
1003
1004    // --- ARIA modal tests (bd-39vx.5) ---
1005
1006    #[test]
1007    fn default_aria_modal_is_true() {
1008        let entry = WidgetModalEntry::new(StubWidget);
1009        assert!(entry.aria_modal);
1010    }
1011
1012    #[test]
1013    fn aria_modal_builder() {
1014        let entry = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1015        assert!(!entry.aria_modal);
1016    }
1017
1018    #[test]
1019    fn focusable_ids_builder() {
1020        let entry = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2, 3]);
1021        assert_eq!(entry.focusable_ids, Some(vec![1, 2, 3]));
1022    }
1023
1024    #[test]
1025    fn stack_modal_aria_modal_trait() {
1026        let entry = WidgetModalEntry::new(StubWidget);
1027        assert!(StackModal::aria_modal(&entry)); // Default true
1028
1029        let entry_non_aria = WidgetModalEntry::new(StubWidget).with_aria_modal(false);
1030        assert!(!StackModal::aria_modal(&entry_non_aria));
1031    }
1032
1033    #[test]
1034    fn stack_modal_focusable_ids_trait() {
1035        let entry = WidgetModalEntry::new(StubWidget);
1036        assert!(StackModal::focusable_ids(&entry).is_none()); // Default none
1037
1038        let entry_with_ids = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 20]);
1039        assert_eq!(
1040            StackModal::focusable_ids(&entry_with_ids),
1041            Some(vec![10, 20])
1042        );
1043    }
1044
1045    // --- ModalFocusIntegration tests ---
1046
1047    #[test]
1048    fn focus_integration_push_creates_trap() {
1049        use crate::focus::{FocusManager, FocusNode};
1050        use ftui_core::geometry::Rect;
1051
1052        let mut stack = ModalStack::new();
1053        let mut focus = FocusManager::new();
1054
1055        // Register focusable nodes
1056        focus
1057            .graph_mut()
1058            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1059        focus
1060            .graph_mut()
1061            .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1062        focus
1063            .graph_mut()
1064            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); // Outside modal
1065
1066        // Focus outside modal initially
1067        focus.focus(100);
1068        assert_eq!(focus.current(), Some(100));
1069
1070        {
1071            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1072
1073            // Push modal with focusable IDs
1074            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1075            let _modal_id = integrator.push_with_focus(Box::new(modal));
1076
1077            // Focus should now be trapped
1078            assert!(integrator.is_focus_trapped());
1079
1080            // Focus should move to first focusable in modal
1081            assert_eq!(integrator.focus().current(), Some(1));
1082        }
1083    }
1084
1085    #[test]
1086    fn focus_integration_pop_restores_focus() {
1087        use crate::focus::{FocusManager, FocusNode};
1088        use ftui_core::geometry::Rect;
1089
1090        let mut stack = ModalStack::new();
1091        let mut focus = FocusManager::new();
1092
1093        // Register focusable nodes
1094        focus
1095            .graph_mut()
1096            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1097        focus
1098            .graph_mut()
1099            .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1100        focus
1101            .graph_mut()
1102            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1))); // Trigger element
1103
1104        // Focus the trigger element before opening modal
1105        focus.focus(100);
1106        assert_eq!(focus.current(), Some(100));
1107
1108        {
1109            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1110
1111            // Push modal
1112            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1113            integrator.push_with_focus(Box::new(modal));
1114
1115            // Focus is in modal
1116            assert!(integrator.is_focus_trapped());
1117
1118            // Pop modal
1119            let result = integrator.pop_with_focus();
1120            assert!(result.is_some());
1121
1122            // Focus should be restored to trigger element
1123            assert!(!integrator.is_focus_trapped());
1124            assert_eq!(integrator.focus().current(), Some(100));
1125        }
1126    }
1127
1128    #[test]
1129    fn focus_integration_escape_restores_focus() {
1130        use crate::focus::{FocusManager, FocusNode};
1131        use ftui_core::geometry::Rect;
1132
1133        let mut stack = ModalStack::new();
1134        let mut focus = FocusManager::new();
1135
1136        focus
1137            .graph_mut()
1138            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1139        focus
1140            .graph_mut()
1141            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1142
1143        focus.focus(100);
1144
1145        {
1146            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1147
1148            let modal = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1]);
1149            integrator.push_with_focus(Box::new(modal));
1150
1151            assert!(integrator.is_focus_trapped());
1152
1153            // Simulate Escape key
1154            let escape = Event::Key(KeyEvent {
1155                code: KeyCode::Escape,
1156                modifiers: Modifiers::empty(),
1157                kind: KeyEventKind::Press,
1158            });
1159            let result = integrator.handle_event(&escape);
1160
1161            assert!(result.is_some());
1162            assert!(!integrator.is_focus_trapped());
1163            assert_eq!(integrator.focus().current(), Some(100));
1164        }
1165    }
1166
1167    #[test]
1168    fn focus_integration_non_aria_modal_no_trap() {
1169        use crate::focus::{FocusManager, FocusNode};
1170        use ftui_core::geometry::Rect;
1171
1172        let mut stack = ModalStack::new();
1173        let mut focus = FocusManager::new();
1174
1175        focus
1176            .graph_mut()
1177            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1178        focus
1179            .graph_mut()
1180            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1181
1182        focus.focus(100);
1183
1184        {
1185            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1186
1187            // Push non-ARIA modal (aria_modal = false)
1188            let modal = WidgetModalEntry::new(StubWidget)
1189                .with_aria_modal(false)
1190                .with_focusable_ids(vec![1]);
1191            integrator.push_with_focus(Box::new(modal));
1192
1193            // Focus should NOT be trapped for non-ARIA modals
1194            assert!(!integrator.is_focus_trapped());
1195        }
1196    }
1197
1198    #[test]
1199    fn focus_integration_nested_modals() {
1200        use crate::focus::{FocusManager, FocusNode};
1201        use ftui_core::geometry::Rect;
1202
1203        let mut stack = ModalStack::new();
1204        let mut focus = FocusManager::new();
1205
1206        // Register nodes for both modals and background
1207        focus
1208            .graph_mut()
1209            .insert(FocusNode::new(1, Rect::new(0, 0, 10, 1)));
1210        focus
1211            .graph_mut()
1212            .insert(FocusNode::new(2, Rect::new(0, 1, 10, 1)));
1213        focus
1214            .graph_mut()
1215            .insert(FocusNode::new(10, Rect::new(0, 5, 10, 1)));
1216        focus
1217            .graph_mut()
1218            .insert(FocusNode::new(11, Rect::new(0, 6, 10, 1)));
1219        focus
1220            .graph_mut()
1221            .insert(FocusNode::new(100, Rect::new(0, 10, 10, 1)));
1222
1223        focus.focus(100);
1224
1225        {
1226            let mut integrator = ModalFocusIntegration::new(&mut stack, &mut focus);
1227
1228            // Push first modal
1229            let modal1 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![1, 2]);
1230            integrator.push_with_focus(Box::new(modal1));
1231            assert_eq!(integrator.focus().current(), Some(1));
1232
1233            // Push second modal (nested)
1234            let modal2 = WidgetModalEntry::new(StubWidget).with_focusable_ids(vec![10, 11]);
1235            integrator.push_with_focus(Box::new(modal2));
1236            assert_eq!(integrator.focus().current(), Some(10));
1237
1238            // Pop second modal - should restore to first modal's focus
1239            integrator.pop_with_focus();
1240            assert_eq!(integrator.focus().current(), Some(1));
1241
1242            // Pop first modal - should restore to original focus
1243            integrator.pop_with_focus();
1244            assert_eq!(integrator.focus().current(), Some(100));
1245        }
1246    }
1247}