Skip to main content

ftui_widgets/modal/
focus_integration.rs

1#![forbid(unsafe_code)]
2
3//! Focus-aware modal integration for automatic focus trap management.
4//!
5//! This module provides `FocusAwareModalStack`, which combines [`ModalStack`]
6//! with [`FocusManager`] integration for automatic focus trapping when modals
7//! are opened and focus restoration when they close.
8//!
9//! # Invariants
10//!
11//! 1. **Auto-focus**: When a modal opens with a focus group, focus moves to the
12//!    first focusable element in that group.
13//! 2. **Focus trap**: Tab navigation is constrained to the modal's focus group.
14//! 3. **Focus restoration**: When a modal closes, focus returns to where it was
15//!    before the modal opened.
16//! 4. **LIFO ordering**: Focus traps follow modal stack ordering (nested modals
17//!    restore to the correct previous state).
18//!
19//! # Failure Modes
20//!
21//! - If the focus group has no focusable members, focus remains unchanged.
22//! - If the original focus target is removed during modal display, focus moves
23//!   to the first available focusable element.
24//! - Focus trap with an empty group allows focus to escape (graceful degradation).
25//!
26//! # Example
27//!
28//! ```ignore
29//! use ftui_widgets::focus::FocusManager;
30//! use ftui_widgets::modal::{ModalStack, WidgetModalEntry};
31//! use ftui_widgets::modal::focus_integration::FocusAwareModalStack;
32//!
33//! let mut modals = FocusAwareModalStack::new();
34//!
35//! // Push modal with focus group members
36//! let focus_ids = vec![ok_button_id, cancel_button_id];
37//! let modal_id = modals.push_with_trap(
38//!     Box::new(WidgetModalEntry::new(dialog)),
39//!     focus_ids,
40//! );
41//!
42//! // Handle event (focus trap active, Escape closes and restores focus)
43//! if let Some(result) = modals.handle_event(&event) {
44//!     // Modal closed, focus already restored
45//! }
46//! ```
47
48use std::sync::atomic::{AtomicU32, Ordering};
49
50use ftui_core::event::Event;
51use ftui_core::geometry::Rect;
52use ftui_render::frame::Frame;
53
54use crate::focus::{FocusId, FocusManager};
55use crate::modal::{ModalId, ModalResult, ModalStack, StackModal};
56
57/// Global counter for unique focus group IDs.
58static FOCUS_GROUP_COUNTER: AtomicU32 = AtomicU32::new(1_000_000);
59
60/// Generate a unique focus group ID.
61fn next_focus_group_id() -> u32 {
62    FOCUS_GROUP_COUNTER.fetch_add(1, Ordering::Relaxed)
63}
64
65/// Modal stack with integrated focus management.
66///
67/// This wrapper provides automatic focus trapping when modals open and
68/// focus restoration when they close. It manages both the modal stack
69/// and focus manager in a coordinated way.
70///
71/// # Invariants
72///
73/// - Focus trap stack depth equals the number of modals with focus groups.
74/// - Each modal's focus group ID is unique and not reused.
75/// - Pop operations always call `pop_trap` for modals with focus groups.
76pub struct FocusAwareModalStack {
77    stack: ModalStack,
78    focus_manager: FocusManager,
79}
80
81impl Default for FocusAwareModalStack {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl FocusAwareModalStack {
88    /// Create a new focus-aware modal stack.
89    pub fn new() -> Self {
90        Self {
91            stack: ModalStack::new(),
92            focus_manager: FocusManager::new(),
93        }
94    }
95
96    /// Create from existing stack and focus manager.
97    ///
98    /// Use this when you already have a `FocusManager` in your application
99    /// and want to integrate modal focus trapping.
100    pub fn with_focus_manager(focus_manager: FocusManager) -> Self {
101        Self {
102            stack: ModalStack::new(),
103            focus_manager,
104        }
105    }
106
107    // --- Modal Stack Delegation ---
108
109    /// Push a modal without focus trapping.
110    ///
111    /// The modal will be rendered and receive events, but focus is not managed.
112    pub fn push(&mut self, modal: Box<dyn StackModal>) -> ModalId {
113        self.stack.push(modal)
114    }
115
116    /// Push a modal with automatic focus trapping.
117    ///
118    /// # Parameters
119    /// - `modal`: The modal content
120    /// - `focusable_ids`: The focus IDs of elements inside the modal
121    ///
122    /// # Behavior
123    /// 1. Creates a focus group with the provided IDs
124    /// 2. Pushes a focus trap (saving current focus)
125    /// 3. Moves focus to the first element in the group
126    pub fn push_with_trap(
127        &mut self,
128        modal: Box<dyn StackModal>,
129        focusable_ids: Vec<FocusId>,
130    ) -> ModalId {
131        let group_id = next_focus_group_id();
132
133        // Create focus group and push trap
134        self.focus_manager.create_group(group_id, focusable_ids);
135        self.focus_manager.push_trap(group_id);
136
137        // Push modal with focus group tracking
138        self.stack.push_with_focus(modal, Some(group_id))
139    }
140
141    /// Pop the top modal.
142    ///
143    /// If the modal had a focus group, the focus trap is popped and
144    /// focus is restored to where it was before the modal opened.
145    pub fn pop(&mut self) -> Option<ModalResult> {
146        let result = self.stack.pop()?;
147        if result.focus_group_id.is_some() {
148            self.focus_manager.pop_trap();
149        }
150        Some(result)
151    }
152
153    /// Pop a specific modal by ID.
154    ///
155    /// **Warning**: Popping a non-top modal with a focus group will NOT restore
156    /// focus correctly. The focus trap stack is LIFO, so only the top modal's
157    /// trap can be safely popped. Prefer using `pop()` for correct focus handling.
158    ///
159    /// # Behavior
160    /// - If the modal is the top modal and has a focus group, `pop_trap()` is called
161    /// - If the modal is NOT the top modal, the focus trap is NOT popped (would corrupt state)
162    pub fn pop_id(&mut self, id: ModalId) -> Option<ModalResult> {
163        // Check if this is the top modal BEFORE popping
164        let is_top = self.stack.top_id() == Some(id);
165
166        let result = self.stack.pop_id(id)?;
167
168        // Only pop the focus trap if this was the top modal
169        // Popping a non-top modal's trap would corrupt the LIFO focus trap stack
170        if is_top && result.focus_group_id.is_some() {
171            self.focus_manager.pop_trap();
172        }
173
174        Some(result)
175    }
176
177    /// Pop all modals, restoring focus to the original state.
178    pub fn pop_all(&mut self) -> Vec<ModalResult> {
179        let results = self.stack.pop_all();
180        for result in &results {
181            if result.focus_group_id.is_some() {
182                self.focus_manager.pop_trap();
183            }
184        }
185        results
186    }
187
188    /// Handle an event, routing to the top modal.
189    ///
190    /// If the modal closes (via Escape, backdrop click, etc.), the focus
191    /// trap is automatically popped and focus is restored.
192    pub fn handle_event(&mut self, event: &Event) -> Option<ModalResult> {
193        let result = self.stack.handle_event(event)?;
194        if result.focus_group_id.is_some() {
195            self.focus_manager.pop_trap();
196        }
197        Some(result)
198    }
199
200    /// Render all modals.
201    pub fn render(&self, frame: &mut Frame, screen: Rect) {
202        self.stack.render(frame, screen);
203    }
204
205    // --- State Queries ---
206
207    /// Check if the modal stack is empty.
208    #[inline]
209    pub fn is_empty(&self) -> bool {
210        self.stack.is_empty()
211    }
212
213    /// Get the number of open modals.
214    #[inline]
215    pub fn depth(&self) -> usize {
216        self.stack.depth()
217    }
218
219    /// Check if focus is currently trapped in a modal.
220    #[inline]
221    pub fn is_focus_trapped(&self) -> bool {
222        self.focus_manager.is_trapped()
223    }
224
225    /// Get a reference to the underlying modal stack.
226    pub fn stack(&self) -> &ModalStack {
227        &self.stack
228    }
229
230    /// Get a mutable reference to the underlying modal stack.
231    ///
232    /// **Warning**: Direct manipulation may desync focus state.
233    pub fn stack_mut(&mut self) -> &mut ModalStack {
234        &mut self.stack
235    }
236
237    /// Get a reference to the focus manager.
238    pub fn focus_manager(&self) -> &FocusManager {
239        &self.focus_manager
240    }
241
242    /// Get a mutable reference to the focus manager.
243    pub fn focus_manager_mut(&mut self) -> &mut FocusManager {
244        &mut self.focus_manager
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::Widget;
252    use crate::focus::FocusNode;
253    use crate::modal::WidgetModalEntry;
254    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
255    use ftui_core::geometry::Rect;
256
257    #[derive(Debug, Clone)]
258    struct StubWidget;
259
260    impl Widget for StubWidget {
261        fn render(&self, _area: Rect, _frame: &mut Frame) {}
262    }
263
264    fn make_focus_node(id: FocusId) -> FocusNode {
265        FocusNode::new(id, Rect::new(0, 0, 10, 3)).with_tab_index(id as i32)
266    }
267
268    #[test]
269    fn push_with_trap_creates_focus_trap() {
270        let mut modals = FocusAwareModalStack::new();
271
272        // Add focusable nodes
273        modals
274            .focus_manager_mut()
275            .graph_mut()
276            .insert(make_focus_node(1));
277        modals
278            .focus_manager_mut()
279            .graph_mut()
280            .insert(make_focus_node(2));
281        modals
282            .focus_manager_mut()
283            .graph_mut()
284            .insert(make_focus_node(3));
285
286        // Focus node 3 before opening modal
287        modals.focus_manager_mut().focus(3);
288        assert_eq!(modals.focus_manager().current(), Some(3));
289
290        // Push modal with trap containing nodes 1 and 2
291        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![1, 2]);
292
293        // Focus should now be on node 1 (first in group)
294        assert!(modals.is_focus_trapped());
295        assert_eq!(modals.focus_manager().current(), Some(1));
296    }
297
298    #[test]
299    fn pop_restores_focus() {
300        let mut modals = FocusAwareModalStack::new();
301
302        // Add focusable nodes
303        modals
304            .focus_manager_mut()
305            .graph_mut()
306            .insert(make_focus_node(1));
307        modals
308            .focus_manager_mut()
309            .graph_mut()
310            .insert(make_focus_node(2));
311        modals
312            .focus_manager_mut()
313            .graph_mut()
314            .insert(make_focus_node(3));
315
316        // Focus node 3 before opening modal
317        modals.focus_manager_mut().focus(3);
318
319        // Push modal with trap
320        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![1, 2]);
321        assert_eq!(modals.focus_manager().current(), Some(1));
322
323        // Pop modal - focus should return to node 3
324        modals.pop();
325        assert!(!modals.is_focus_trapped());
326        assert_eq!(modals.focus_manager().current(), Some(3));
327    }
328
329    #[test]
330    fn nested_modals_restore_correctly() {
331        let mut modals = FocusAwareModalStack::new();
332
333        // Add focusable nodes
334        for id in 1..=6 {
335            modals
336                .focus_manager_mut()
337                .graph_mut()
338                .insert(make_focus_node(id));
339        }
340
341        // Initial focus
342        modals.focus_manager_mut().focus(1);
343
344        // First modal traps to nodes 2, 3
345        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![2, 3]);
346        assert_eq!(modals.focus_manager().current(), Some(2));
347
348        // Second modal traps to nodes 4, 5, 6
349        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![4, 5, 6]);
350        assert_eq!(modals.focus_manager().current(), Some(4));
351
352        // Pop second modal - back to first modal's focus (node 2)
353        modals.pop();
354        assert_eq!(modals.focus_manager().current(), Some(2));
355
356        // Pop first modal - back to original focus (node 1)
357        modals.pop();
358        assert_eq!(modals.focus_manager().current(), Some(1));
359        assert!(!modals.is_focus_trapped());
360    }
361
362    #[test]
363    fn handle_event_escape_restores_focus() {
364        let mut modals = FocusAwareModalStack::new();
365
366        // Add focusable nodes
367        modals
368            .focus_manager_mut()
369            .graph_mut()
370            .insert(make_focus_node(1));
371        modals
372            .focus_manager_mut()
373            .graph_mut()
374            .insert(make_focus_node(2));
375
376        // Focus node 2
377        modals.focus_manager_mut().focus(2);
378
379        // Push modal
380        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![1]);
381        assert_eq!(modals.focus_manager().current(), Some(1));
382
383        // Escape closes modal
384        let escape = Event::Key(KeyEvent {
385            code: KeyCode::Escape,
386            modifiers: Modifiers::empty(),
387            kind: KeyEventKind::Press,
388        });
389
390        let result = modals.handle_event(&escape);
391        assert!(result.is_some());
392        assert_eq!(modals.focus_manager().current(), Some(2));
393    }
394
395    #[test]
396    fn push_without_trap_no_focus_change() {
397        let mut modals = FocusAwareModalStack::new();
398
399        // Add focusable nodes
400        modals
401            .focus_manager_mut()
402            .graph_mut()
403            .insert(make_focus_node(1));
404        modals
405            .focus_manager_mut()
406            .graph_mut()
407            .insert(make_focus_node(2));
408
409        // Focus node 2
410        modals.focus_manager_mut().focus(2);
411
412        // Push modal without trap
413        modals.push(Box::new(WidgetModalEntry::new(StubWidget)));
414
415        // Focus should not change
416        assert!(!modals.is_focus_trapped());
417        assert_eq!(modals.focus_manager().current(), Some(2));
418    }
419
420    #[test]
421    fn pop_all_restores_all_focus() {
422        let mut modals = FocusAwareModalStack::new();
423
424        // Add focusable nodes
425        for id in 1..=4 {
426            modals
427                .focus_manager_mut()
428                .graph_mut()
429                .insert(make_focus_node(id));
430        }
431
432        // Initial focus
433        modals.focus_manager_mut().focus(1);
434
435        // Push multiple modals
436        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![2]);
437        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![3]);
438        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![4]);
439
440        assert_eq!(modals.depth(), 3);
441        assert_eq!(modals.focus_manager().current(), Some(4));
442
443        // Pop all
444        let results = modals.pop_all();
445        assert_eq!(results.len(), 3);
446        assert!(modals.is_empty());
447        assert!(!modals.is_focus_trapped());
448        assert_eq!(modals.focus_manager().current(), Some(1));
449    }
450
451    #[test]
452    fn tab_navigation_trapped_in_modal() {
453        let mut modals = FocusAwareModalStack::new();
454
455        // Add focusable nodes
456        for id in 1..=5 {
457            modals
458                .focus_manager_mut()
459                .graph_mut()
460                .insert(make_focus_node(id));
461        }
462
463        // Push modal with nodes 2 and 3
464        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![2, 3]);
465
466        // Focus should be on 2
467        assert_eq!(modals.focus_manager().current(), Some(2));
468
469        // Tab forward should go to 3
470        modals.focus_manager_mut().focus_next();
471        assert_eq!(modals.focus_manager().current(), Some(3));
472
473        // Tab forward should wrap to 2 (trapped)
474        modals.focus_manager_mut().focus_next();
475        assert_eq!(modals.focus_manager().current(), Some(2));
476
477        // Attempt to focus outside trap should fail
478        assert!(modals.focus_manager_mut().focus(5).is_none());
479        assert_eq!(modals.focus_manager().current(), Some(2));
480    }
481
482    #[test]
483    fn empty_focus_group_no_panic() {
484        let mut modals = FocusAwareModalStack::new();
485
486        // Push modal with empty focus group (edge case)
487        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![]);
488
489        // Should not panic, just have no focused element
490        assert!(modals.is_focus_trapped());
491
492        // Pop should still work
493        modals.pop();
494        assert!(!modals.is_focus_trapped());
495    }
496
497    #[test]
498    fn pop_id_non_top_modal_does_not_corrupt_focus() {
499        let mut modals = FocusAwareModalStack::new();
500
501        // Add focusable nodes
502        for id in 1..=6 {
503            modals
504                .focus_manager_mut()
505                .graph_mut()
506                .insert(make_focus_node(id));
507        }
508
509        // Initial focus
510        modals.focus_manager_mut().focus(1);
511
512        // Push three modals with focus traps
513        // Trap stack will be: [(group1, return=1), (group2, return=2), (group3, return=3)]
514        let id1 = modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![2]);
515        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![3]);
516        let _id3 = modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![4]);
517
518        // Focus should be on node 4 (top modal)
519        assert_eq!(modals.focus_manager().current(), Some(4));
520
521        // Pop the BOTTOM modal (id1) by ID - this is non-LIFO
522        // This should NOT pop a focus trap since it's not the top
523        // The trap for id1 becomes "orphaned" but doesn't corrupt the stack
524        modals.pop_id(id1);
525
526        // Focus should still be trapped (trap stack still has 3 traps, 2 modals remain)
527        assert!(modals.is_focus_trapped());
528        // Focus should still be on node 4 (top modal unchanged)
529        assert_eq!(modals.focus_manager().current(), Some(4));
530        assert_eq!(modals.depth(), 2);
531
532        // Pop remaining modals normally
533        modals.pop(); // Pops modal3, pops group3's trap, restores to 3
534        assert_eq!(modals.focus_manager().current(), Some(3));
535
536        modals.pop(); // Pops modal2, pops group2's trap, restores to 2
537        // Note: We restore to 2, not 1, because group1's trap is orphaned
538        assert_eq!(modals.focus_manager().current(), Some(2));
539
540        // Stack is empty but there's still an orphaned trap
541        assert!(modals.is_empty());
542        // The orphaned trap means focus is still "trapped" to group1
543        // This is a known limitation of using pop_id with focus groups
544        assert!(modals.is_focus_trapped());
545    }
546
547    #[test]
548    fn pop_id_top_modal_restores_focus_correctly() {
549        let mut modals = FocusAwareModalStack::new();
550
551        // Add focusable nodes
552        for id in 1..=4 {
553            modals
554                .focus_manager_mut()
555                .graph_mut()
556                .insert(make_focus_node(id));
557        }
558
559        // Initial focus
560        modals.focus_manager_mut().focus(1);
561
562        // Push two modals
563        modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![2]);
564        let id2 = modals.push_with_trap(Box::new(WidgetModalEntry::new(StubWidget)), vec![3]);
565
566        assert_eq!(modals.focus_manager().current(), Some(3));
567
568        // Pop the TOP modal by ID - this should work correctly
569        modals.pop_id(id2);
570
571        // Focus should restore to modal1's focus (2)
572        assert_eq!(modals.focus_manager().current(), Some(2));
573        assert!(modals.is_focus_trapped()); // Still in modal1's trap
574
575        // Pop the last modal
576        modals.pop();
577        assert_eq!(modals.focus_manager().current(), Some(1));
578        assert!(!modals.is_focus_trapped());
579    }
580}