open_sesame/app/
state.rs

1//! Application state machine
2//!
3//! Pure state transitions with no side effects. All state is explicit,
4//! all transitions are through handle_event(), all side effects are
5//! returned as Actions to be executed by the caller.
6
7use crate::config::Config;
8use crate::core::{HintMatcher, MatchResult, WindowHint};
9use crate::util::TimeoutTracker;
10use smithay_client_toolkit::seat::keyboard::Keysym;
11use std::time::{Duration, Instant};
12
13/// Application lifecycle state
14#[derive(Debug, Clone)]
15pub enum AppState {
16    /// Border-only phase, waiting for overlay_delay
17    BorderOnly {
18        /// When the border phase started
19        start_time: Instant,
20        /// Number of frames rendered in this phase
21        frame_count: u32,
22    },
23
24    /// Full overlay visible with window list
25    FullOverlay {
26        /// Index into original hints array (NOT filtered)
27        selected_hint_index: usize,
28        /// User input buffer for hint matching
29        input: String,
30    },
31
32    /// Exact hint match, waiting for activation_delay timeout
33    PendingActivation {
34        /// Index of the matched hint
35        hint_index: usize,
36        /// Current input buffer
37        input: String,
38        /// Timeout tracker for activation delay
39        timeout: TimeoutTracker,
40    },
41
42    /// Application is exiting with a result
43    Exiting {
44        /// The activation result
45        result: ActivationResult,
46    },
47}
48
49/// Result of the application session
50#[derive(Debug, Clone)]
51pub enum ActivationResult {
52    /// Activate window at hint index
53    Window(usize),
54    /// Quick Alt+Tab - activate previous window
55    QuickSwitch,
56    /// Launch app for key (no matching window)
57    Launch(String),
58    /// User cancelled
59    Cancelled,
60}
61
62/// Events that can trigger state transitions
63#[derive(Debug, Clone)]
64pub enum Event {
65    /// Key pressed
66    KeyPress {
67        keysym: Keysym,
68        shift: bool,
69    },
70    /// Alt modifier released
71    AltReleased,
72    /// Timer tick for checking timeouts
73    Tick,
74    /// Frame callback received - safe to render
75    FrameCallback,
76    /// IPC signal to cycle selection
77    CycleForward,
78    CycleBackward,
79    /// Surface configured with dimensions
80    Configure {
81        width: u32,
82        height: u32,
83    },
84}
85
86/// Actions to be executed after state transition
87#[derive(Debug, Clone, PartialEq)]
88pub enum Action {
89    /// Schedule a redraw on next frame
90    ScheduleRedraw,
91    /// Exit the event loop
92    Exit,
93}
94
95/// State transition result
96pub struct Transition {
97    pub new_state: AppState,
98    pub actions: Vec<Action>,
99}
100
101impl AppState {
102    /// Creates initial state based on launcher mode.
103    ///
104    /// Launcher mode initializes with FullOverlay state and selects the previous window
105    /// from MRU tracking, ensuring quick Alt+Space release behavior matches quick Alt+Tab.
106    pub fn initial(
107        launcher_mode: bool,
108        hints: &[WindowHint],
109        previous_window_id: Option<&str>,
110    ) -> Self {
111        if launcher_mode {
112            // Find index of previous window for default selection
113            let selected_index = previous_window_id
114                .and_then(|prev_id| hints.iter().position(|h| h.window_id.as_str() == prev_id))
115                .unwrap_or(0);
116
117            tracing::info!(
118                "FullOverlay initial: selected_index={} (previous_window_id={:?})",
119                selected_index,
120                previous_window_id
121            );
122
123            AppState::FullOverlay {
124                selected_hint_index: selected_index,
125                input: String::new(),
126            }
127        } else {
128            AppState::BorderOnly {
129                start_time: Instant::now(),
130                frame_count: 0,
131            }
132        }
133    }
134
135    /// Processes an event and returns new state with actions.
136    pub fn handle_event(
137        &self,
138        event: Event,
139        config: &Config,
140        hints: &[WindowHint],
141        previous_window_id: Option<&str>,
142    ) -> Transition {
143        match (self, event) {
144            // === BorderOnly transitions ===
145
146            // Frame rendered in border phase increments counter
147            (
148                AppState::BorderOnly {
149                    start_time,
150                    frame_count,
151                },
152                Event::FrameCallback,
153            ) => Transition {
154                new_state: AppState::BorderOnly {
155                    start_time: *start_time,
156                    frame_count: frame_count + 1,
157                },
158                actions: vec![],
159            },
160
161            // Phase transition checked on tick event
162            (
163                AppState::BorderOnly {
164                    start_time,
165                    frame_count,
166                },
167                Event::Tick,
168            ) => {
169                let elapsed = start_time.elapsed();
170                let delay = Duration::from_millis(config.settings.overlay_delay);
171
172                // Transition requires both time elapsed and minimum frames rendered
173                if elapsed >= delay && *frame_count >= 2 {
174                    // Find index of previous window for default selection
175                    let selected_index = previous_window_id
176                        .and_then(|prev_id| {
177                            hints.iter().position(|h| h.window_id.as_str() == prev_id)
178                        })
179                        .unwrap_or(0);
180
181                    Transition {
182                        new_state: AppState::FullOverlay {
183                            selected_hint_index: selected_index,
184                            input: String::new(),
185                        },
186                        actions: vec![Action::ScheduleRedraw],
187                    }
188                } else {
189                    Transition {
190                        new_state: self.clone(),
191                        actions: vec![],
192                    }
193                }
194            }
195
196            // Alt released in border phase triggers quick switch
197            (AppState::BorderOnly { start_time, .. }, Event::AltReleased) => {
198                let elapsed = start_time.elapsed();
199                let threshold = Duration::from_millis(config.settings.quick_switch_threshold);
200
201                let result = if elapsed < threshold {
202                    // Quick Alt+Tab attempts to activate previous window
203                    if let Some(prev_id) = previous_window_id {
204                        if let Some((idx, _)) = hints
205                            .iter()
206                            .enumerate()
207                            .find(|(_, h)| h.window_id.as_str() == prev_id)
208                        {
209                            ActivationResult::Window(idx)
210                        } else {
211                            // Previous window not found, defaults to first window
212                            ActivationResult::Window(0)
213                        }
214                    } else {
215                        ActivationResult::QuickSwitch
216                    }
217                } else {
218                    // Non-quick release activates first window
219                    ActivationResult::Window(0)
220                };
221
222                Transition {
223                    new_state: AppState::Exiting { result },
224                    actions: vec![Action::Exit],
225                }
226            }
227
228            // Tab in border phase cycles selection and transitions to full overlay
229            (AppState::BorderOnly { .. }, Event::KeyPress { keysym, shift }) => {
230                if is_tab(keysym) {
231                    let idx = if shift {
232                        hints.len().saturating_sub(1)
233                    } else {
234                        1.min(hints.len().saturating_sub(1))
235                    };
236                    Transition {
237                        new_state: AppState::FullOverlay {
238                            selected_hint_index: idx,
239                            input: String::new(),
240                        },
241                        actions: vec![Action::ScheduleRedraw],
242                    }
243                } else if keysym == Keysym::Escape {
244                    Transition {
245                        new_state: AppState::Exiting {
246                            result: ActivationResult::Cancelled,
247                        },
248                        actions: vec![Action::Exit],
249                    }
250                } else if let Some(c) = keysym_to_char(keysym) {
251                    // Character key transitions to full overlay with character preserved
252                    // Ensures first keypress captured during border-only to full overlay transition
253                    let input = c.to_string();
254                    let matcher = HintMatcher::new(hints);
255                    match matcher.match_input(&input) {
256                        MatchResult::Exact { index, .. } => {
257                            // Exact match transitions to pending activation state
258                            let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
259                            timeout.start();
260                            Transition {
261                                new_state: AppState::PendingActivation {
262                                    hint_index: index,
263                                    input,
264                                    timeout,
265                                },
266                                actions: vec![Action::ScheduleRedraw],
267                            }
268                        }
269                        MatchResult::Partial(_) => {
270                            // Partial match shows full overlay with current input
271                            Transition {
272                                new_state: AppState::FullOverlay {
273                                    selected_hint_index: 0,
274                                    input,
275                                },
276                                actions: vec![Action::ScheduleRedraw],
277                            }
278                        }
279                        MatchResult::None => {
280                            // No match checks for launch configuration
281                            let key_str = c.to_string();
282                            if config.launch_config(&key_str).is_some() {
283                                Transition {
284                                    new_state: AppState::Exiting {
285                                        result: ActivationResult::Launch(key_str),
286                                    },
287                                    actions: vec![Action::Exit],
288                                }
289                            } else {
290                                // Invalid key ignored, shows full overlay with empty input
291                                Transition {
292                                    new_state: AppState::FullOverlay {
293                                        selected_hint_index: 0,
294                                        input: String::new(),
295                                    },
296                                    actions: vec![Action::ScheduleRedraw],
297                                }
298                            }
299                        }
300                    }
301                } else {
302                    // Non-character key shows full overlay without input modification
303                    Transition {
304                        new_state: AppState::FullOverlay {
305                            selected_hint_index: 0,
306                            input: String::new(),
307                        },
308                        actions: vec![Action::ScheduleRedraw],
309                    }
310                }
311            }
312
313            // IPC cycle in border phase transitions to full overlay
314            (AppState::BorderOnly { .. }, Event::CycleForward) => Transition {
315                new_state: AppState::FullOverlay {
316                    selected_hint_index: 1.min(hints.len().saturating_sub(1)),
317                    input: String::new(),
318                },
319                actions: vec![Action::ScheduleRedraw],
320            },
321
322            (AppState::BorderOnly { .. }, Event::CycleBackward) => Transition {
323                new_state: AppState::FullOverlay {
324                    selected_hint_index: hints.len().saturating_sub(1),
325                    input: String::new(),
326                },
327                actions: vec![Action::ScheduleRedraw],
328            },
329
330            // === FullOverlay transitions ===
331
332            // Tab cycles selection forward/backward
333            (
334                AppState::FullOverlay {
335                    selected_hint_index,
336                    input,
337                },
338                Event::KeyPress { keysym, shift },
339            ) if is_tab(keysym) => {
340                let new_idx = cycle_index(*selected_hint_index, hints.len(), !shift);
341                Transition {
342                    new_state: AppState::FullOverlay {
343                        selected_hint_index: new_idx,
344                        input: input.clone(),
345                    },
346                    actions: vec![Action::ScheduleRedraw],
347                }
348            }
349
350            // Arrow keys cycle selection
351            (
352                AppState::FullOverlay {
353                    selected_hint_index,
354                    input,
355                },
356                Event::KeyPress { keysym, .. },
357            ) if keysym == Keysym::Down || keysym == Keysym::KP_Down => {
358                let new_idx = cycle_index(*selected_hint_index, hints.len(), true);
359                Transition {
360                    new_state: AppState::FullOverlay {
361                        selected_hint_index: new_idx,
362                        input: input.clone(),
363                    },
364                    actions: vec![Action::ScheduleRedraw],
365                }
366            }
367
368            (
369                AppState::FullOverlay {
370                    selected_hint_index,
371                    input,
372                },
373                Event::KeyPress { keysym, .. },
374            ) if keysym == Keysym::Up || keysym == Keysym::KP_Up => {
375                let new_idx = cycle_index(*selected_hint_index, hints.len(), false);
376                Transition {
377                    new_state: AppState::FullOverlay {
378                        selected_hint_index: new_idx,
379                        input: input.clone(),
380                    },
381                    actions: vec![Action::ScheduleRedraw],
382                }
383            }
384
385            // Enter activates selected window
386            (
387                AppState::FullOverlay {
388                    selected_hint_index,
389                    ..
390                },
391                Event::KeyPress { keysym, .. },
392            ) if keysym == Keysym::Return || keysym == Keysym::KP_Enter => Transition {
393                new_state: AppState::Exiting {
394                    result: ActivationResult::Window(*selected_hint_index),
395                },
396                actions: vec![Action::Exit],
397            },
398
399            // Escape cancels operation
400            (AppState::FullOverlay { .. }, Event::KeyPress { keysym, .. })
401                if keysym == Keysym::Escape =>
402            {
403                Transition {
404                    new_state: AppState::Exiting {
405                        result: ActivationResult::Cancelled,
406                    },
407                    actions: vec![Action::Exit],
408                }
409            }
410
411            // Backspace removes last character from input
412            (
413                AppState::FullOverlay {
414                    selected_hint_index,
415                    input,
416                },
417                Event::KeyPress { keysym, .. },
418            ) if keysym == Keysym::BackSpace => {
419                let mut new_input = input.clone();
420                new_input.pop();
421                Transition {
422                    new_state: AppState::FullOverlay {
423                        selected_hint_index: *selected_hint_index,
424                        input: new_input,
425                    },
426                    actions: vec![Action::ScheduleRedraw],
427                }
428            }
429
430            // Character input performs hint matching
431            (
432                AppState::FullOverlay {
433                    selected_hint_index,
434                    input,
435                },
436                Event::KeyPress { keysym, .. },
437            ) => {
438                if let Some(c) = keysym_to_char(keysym) {
439                    let mut new_input = input.clone();
440                    new_input.push(c);
441
442                    let matcher = HintMatcher::new(hints);
443                    match matcher.match_input(&new_input) {
444                        MatchResult::Exact { index, .. } => {
445                            // Exact match starts pending activation timeout
446                            let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
447                            timeout.start();
448                            Transition {
449                                new_state: AppState::PendingActivation {
450                                    hint_index: index,
451                                    input: new_input,
452                                    timeout,
453                                },
454                                actions: vec![Action::ScheduleRedraw],
455                            }
456                        }
457                        MatchResult::Partial(_) => {
458                            // Partial match updates input while preserving selection
459                            Transition {
460                                new_state: AppState::FullOverlay {
461                                    selected_hint_index: *selected_hint_index,
462                                    input: new_input,
463                                },
464                                actions: vec![Action::ScheduleRedraw],
465                            }
466                        }
467                        MatchResult::None => {
468                            // No match checks for launch configuration
469                            let key_str = c.to_string();
470                            if config.launch_config(&key_str).is_some() {
471                                Transition {
472                                    new_state: AppState::Exiting {
473                                        result: ActivationResult::Launch(key_str),
474                                    },
475                                    actions: vec![Action::Exit],
476                                }
477                            } else {
478                                // Invalid input preserves current state
479                                Transition {
480                                    new_state: AppState::FullOverlay {
481                                        selected_hint_index: *selected_hint_index,
482                                        input: input.clone(),
483                                    },
484                                    actions: vec![],
485                                }
486                            }
487                        }
488                    }
489                } else {
490                    // Non-character key ignored
491                    Transition {
492                        new_state: self.clone(),
493                        actions: vec![],
494                    }
495                }
496            }
497
498            // Alt released activates current selection
499            (
500                AppState::FullOverlay {
501                    selected_hint_index,
502                    ..
503                },
504                Event::AltReleased,
505            ) => Transition {
506                new_state: AppState::Exiting {
507                    result: ActivationResult::Window(*selected_hint_index),
508                },
509                actions: vec![Action::Exit],
510            },
511
512            // IPC cycle
513            (
514                AppState::FullOverlay {
515                    selected_hint_index,
516                    input,
517                },
518                Event::CycleForward,
519            ) => {
520                let new_idx = cycle_index(*selected_hint_index, hints.len(), true);
521                Transition {
522                    new_state: AppState::FullOverlay {
523                        selected_hint_index: new_idx,
524                        input: input.clone(),
525                    },
526                    actions: vec![Action::ScheduleRedraw],
527                }
528            }
529
530            (
531                AppState::FullOverlay {
532                    selected_hint_index,
533                    input,
534                },
535                Event::CycleBackward,
536            ) => {
537                let new_idx = cycle_index(*selected_hint_index, hints.len(), false);
538                Transition {
539                    new_state: AppState::FullOverlay {
540                        selected_hint_index: new_idx,
541                        input: input.clone(),
542                    },
543                    actions: vec![Action::ScheduleRedraw],
544                }
545            }
546
547            // === PendingActivation transitions ===
548
549            // Tick checks activation timeout
550            (
551                AppState::PendingActivation {
552                    hint_index,
553                    timeout,
554                    ..
555                },
556                Event::Tick,
557            ) => {
558                if timeout.has_elapsed() {
559                    Transition {
560                        new_state: AppState::Exiting {
561                            result: ActivationResult::Window(*hint_index),
562                        },
563                        actions: vec![Action::Exit],
564                    }
565                } else {
566                    Transition {
567                        new_state: self.clone(),
568                        actions: vec![],
569                    }
570                }
571            }
572
573            // Additional character while pending may change match state
574            (
575                AppState::PendingActivation {
576                    hint_index, input, ..
577                },
578                Event::KeyPress { keysym, .. },
579            ) => {
580                if let Some(c) = keysym_to_char(keysym) {
581                    let mut new_input = input.clone();
582                    new_input.push(c);
583
584                    let matcher = HintMatcher::new(hints);
585                    match matcher.match_input(&new_input) {
586                        MatchResult::Exact { index, .. } => {
587                            // New exact match restarts timeout
588                            let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
589                            timeout.start();
590                            Transition {
591                                new_state: AppState::PendingActivation {
592                                    hint_index: index,
593                                    input: new_input,
594                                    timeout,
595                                },
596                                actions: vec![Action::ScheduleRedraw],
597                            }
598                        }
599                        MatchResult::Partial(_) => {
600                            // Partial match returns to full overlay state
601                            Transition {
602                                new_state: AppState::FullOverlay {
603                                    selected_hint_index: *hint_index,
604                                    input: new_input,
605                                },
606                                actions: vec![Action::ScheduleRedraw],
607                            }
608                        }
609                        MatchResult::None => {
610                            // Invalid input preserves pending state
611                            Transition {
612                                new_state: self.clone(),
613                                actions: vec![],
614                            }
615                        }
616                    }
617                } else if keysym == Keysym::Escape {
618                    Transition {
619                        new_state: AppState::Exiting {
620                            result: ActivationResult::Cancelled,
621                        },
622                        actions: vec![Action::Exit],
623                    }
624                } else if keysym == Keysym::BackSpace {
625                    // Backspace cancels pending and returns to full overlay
626                    let mut new_input = input.clone();
627                    new_input.pop();
628                    Transition {
629                        new_state: AppState::FullOverlay {
630                            selected_hint_index: *hint_index,
631                            input: new_input,
632                        },
633                        actions: vec![Action::ScheduleRedraw],
634                    }
635                } else {
636                    Transition {
637                        new_state: self.clone(),
638                        actions: vec![],
639                    }
640                }
641            }
642
643            // Alt released during pending activates immediately
644            (AppState::PendingActivation { hint_index, .. }, Event::AltReleased) => Transition {
645                new_state: AppState::Exiting {
646                    result: ActivationResult::Window(*hint_index),
647                },
648                actions: vec![Action::Exit],
649            },
650
651            // === Default: stay in current state ===
652            _ => Transition {
653                new_state: self.clone(),
654                actions: vec![],
655            },
656        }
657    }
658
659    /// Returns the selected hint index for rendering.
660    pub fn selected_hint_index(&self) -> usize {
661        match self {
662            AppState::FullOverlay {
663                selected_hint_index,
664                ..
665            } => *selected_hint_index,
666            AppState::PendingActivation { hint_index, .. } => *hint_index,
667            _ => 0,
668        }
669    }
670
671    /// Returns the current input string for rendering.
672    pub fn input(&self) -> &str {
673        match self {
674            AppState::FullOverlay { input, .. } => input,
675            AppState::PendingActivation { input, .. } => input,
676            _ => "",
677        }
678    }
679
680    /// Returns whether full overlay is displayed (vs border only).
681    pub fn is_full_overlay(&self) -> bool {
682        matches!(
683            self,
684            AppState::FullOverlay { .. } | AppState::PendingActivation { .. }
685        )
686    }
687
688    /// Returns whether the application is exiting.
689    pub fn is_exiting(&self) -> bool {
690        matches!(self, AppState::Exiting { .. })
691    }
692
693    /// Returns the activation result if exiting, None otherwise.
694    pub fn activation_result(&self) -> Option<&ActivationResult> {
695        match self {
696            AppState::Exiting { result } => Some(result),
697            _ => None,
698        }
699    }
700}
701
702// === Helper functions ===
703
704fn is_tab(keysym: Keysym) -> bool {
705    keysym == Keysym::Tab
706        || keysym == Keysym::ISO_Left_Tab
707        || keysym.raw() == 0xff09
708        || keysym.raw() == 0xfe20
709}
710
711fn cycle_index(current: usize, len: usize, forward: bool) -> usize {
712    if len == 0 {
713        return 0;
714    }
715    if forward {
716        (current + 1) % len
717    } else if current == 0 {
718        len - 1
719    } else {
720        current - 1
721    }
722}
723
724fn keysym_to_char(keysym: Keysym) -> Option<char> {
725    let raw = keysym.raw();
726    // ASCII printable characters
727    if (0x20..=0x7e).contains(&raw) {
728        Some(raw as u8 as char)
729    } else {
730        None
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::core::{HintSequence, WindowId};
738
739    // ==========================================================================
740    // TEST FIXTURES
741    // ==========================================================================
742
743    fn make_test_config() -> Config {
744        let mut config = Config::default();
745        config.settings.overlay_delay = 500;
746        config.settings.activation_delay = 200;
747        config.settings.quick_switch_threshold = 250;
748        config
749    }
750
751    /// Creates test hints with sequential letter assignments starting from 'a'.
752    fn make_hints(count: usize) -> Vec<WindowHint> {
753        (0..count)
754            .map(|i| WindowHint {
755                hint: HintSequence::new((b'a' + i as u8) as char, 1),
756                app_id: format!("app{}", i),
757                window_id: WindowId::new(format!("window{}", i)),
758                title: format!("Window {}", i),
759                index: i,
760            })
761            .collect()
762    }
763
764    /// Creates realistic test hints matching real application configuration.
765    fn make_realistic_hints() -> Vec<WindowHint> {
766        vec![
767            WindowHint {
768                hint: HintSequence::new('e', 1),
769                app_id: "microsoft-edge".to_string(),
770                window_id: WindowId::new("win-edge-abc123"),
771                title: "Microsoft Edge".to_string(),
772                index: 0,
773            },
774            WindowHint {
775                hint: HintSequence::new('f', 1),
776                app_id: "firefox".to_string(),
777                window_id: WindowId::new("win-firefox-def456"),
778                title: "Mozilla Firefox".to_string(),
779                index: 1,
780            },
781            WindowHint {
782                hint: HintSequence::new('g', 1),
783                app_id: "com.mitchellh.ghostty".to_string(),
784                window_id: WindowId::new("win-ghostty-ghi789"),
785                title: "ghostty".to_string(),
786                index: 2,
787            },
788        ]
789    }
790
791    // ==========================================================================
792    // INITIAL STATE TESTS
793    // ==========================================================================
794
795    #[test]
796    fn test_initial_state_switcher_mode() {
797        let hints = make_hints(3);
798        let state = AppState::initial(false, &hints, None);
799        assert!(
800            matches!(state, AppState::BorderOnly { .. }),
801            "Switcher mode starts in BorderOnly state"
802        );
803    }
804
805    #[test]
806    fn test_initial_state_launcher_mode() {
807        let hints = make_hints(3);
808        let state = AppState::initial(true, &hints, None);
809        match state {
810            AppState::FullOverlay {
811                selected_hint_index,
812                input,
813            } => {
814                assert_eq!(
815                    selected_hint_index, 0,
816                    "Starts with first item selected when no previous window"
817                );
818                assert!(input.is_empty(), "Starts with empty input");
819            }
820            _ => panic!("Launcher mode starts in FullOverlay state"),
821        }
822    }
823
824    #[test]
825    fn test_initial_state_launcher_mode_with_previous() {
826        let hints = make_realistic_hints();
827        // Previous window (firefox) at index 1
828        let state = AppState::initial(true, &hints, Some("win-firefox-def456"));
829        match state {
830            AppState::FullOverlay {
831                selected_hint_index,
832                input,
833            } => {
834                assert_eq!(
835                    selected_hint_index, 1,
836                    "Starts with previous window selected"
837                );
838                assert!(input.is_empty(), "Starts with empty input");
839            }
840            _ => panic!("Launcher mode starts in FullOverlay state"),
841        }
842    }
843
844    #[test]
845    fn test_initial_state_launcher_mode_with_invalid_previous() {
846        let hints = make_realistic_hints();
847        // Previous window does not exist
848        let state = AppState::initial(true, &hints, Some("nonexistent-window"));
849        match state {
850            AppState::FullOverlay {
851                selected_hint_index,
852                input,
853            } => {
854                assert_eq!(
855                    selected_hint_index, 0,
856                    "Falls back to index 0 when previous window not found"
857                );
858                assert!(input.is_empty(), "Starts with empty input");
859            }
860            _ => panic!("Launcher mode starts in FullOverlay state"),
861        }
862    }
863
864    // ==========================================================================
865    // HELPER FUNCTION TESTS
866    // ==========================================================================
867
868    #[test]
869    fn test_cycle_forward() {
870        assert_eq!(cycle_index(0, 3, true), 1);
871        assert_eq!(cycle_index(1, 3, true), 2);
872        assert_eq!(cycle_index(2, 3, true), 0, "Wraps to 0");
873    }
874
875    #[test]
876    fn test_cycle_backward() {
877        assert_eq!(cycle_index(2, 3, false), 1);
878        assert_eq!(cycle_index(1, 3, false), 0);
879        assert_eq!(cycle_index(0, 3, false), 2, "Wraps to last");
880    }
881
882    #[test]
883    fn test_cycle_empty() {
884        assert_eq!(cycle_index(0, 0, true), 0, "Empty list returns 0");
885        assert_eq!(cycle_index(0, 0, false), 0);
886    }
887
888    #[test]
889    fn test_keysym_to_char_letters() {
890        // Lowercase letter keysym values equal ASCII codes
891        assert_eq!(keysym_to_char(Keysym::from(0x67)), Some('g'));
892        assert_eq!(keysym_to_char(Keysym::from(0x66)), Some('f'));
893        assert_eq!(keysym_to_char(Keysym::from(0x65)), Some('e'));
894    }
895
896    #[test]
897    fn test_keysym_to_char_non_printable() {
898        assert_eq!(keysym_to_char(Keysym::Tab), None);
899        assert_eq!(keysym_to_char(Keysym::Escape), None);
900        assert_eq!(keysym_to_char(Keysym::Return), None);
901    }
902
903    #[test]
904    fn test_is_tab() {
905        assert!(is_tab(Keysym::Tab));
906        assert!(is_tab(Keysym::ISO_Left_Tab));
907        assert!(is_tab(Keysym::from(0xff09)));
908        assert!(is_tab(Keysym::from(0xfe20)));
909        assert!(!is_tab(Keysym::Return));
910        assert!(!is_tab(Keysym::from(0x67)));
911    }
912
913    // ==========================================================================
914    // BORDER ONLY STATE TESTS
915    // ==========================================================================
916
917    #[test]
918    fn test_border_only_tick_before_delay() {
919        let config = make_test_config();
920        let hints = make_realistic_hints();
921
922        let state = AppState::BorderOnly {
923            start_time: Instant::now(),
924            frame_count: 5,
925        };
926
927        let transition = state.handle_event(Event::Tick, &config, &hints, None);
928
929        assert!(
930            matches!(transition.new_state, AppState::BorderOnly { .. }),
931            "Remains in BorderOnly state before delay elapsed"
932        );
933        assert!(
934            transition.actions.is_empty(),
935            "No redraw scheduled before delay elapsed"
936        );
937    }
938
939    #[test]
940    fn test_border_only_tick_after_delay_transitions() {
941        let config = make_test_config();
942        let hints = make_realistic_hints();
943
944        // State created with elapsed start time
945        let state = AppState::BorderOnly {
946            start_time: Instant::now() - Duration::from_millis(600),
947            frame_count: 5,
948        };
949
950        let transition = state.handle_event(Event::Tick, &config, &hints, None);
951
952        match transition.new_state {
953            AppState::FullOverlay {
954                selected_hint_index,
955                input,
956            } => {
957                assert_eq!(selected_hint_index, 0);
958                assert!(input.is_empty());
959            }
960            _ => panic!("Transitions to FullOverlay after delay"),
961        }
962        assert!(transition.actions.contains(&Action::ScheduleRedraw));
963    }
964
965    #[test]
966    fn test_border_only_requires_minimum_frames() {
967        let config = make_test_config();
968        let hints = make_realistic_hints();
969
970        // Elapsed start time but insufficient frames rendered
971        let state = AppState::BorderOnly {
972            start_time: Instant::now() - Duration::from_millis(600),
973            frame_count: 1, // Less than 2
974        };
975
976        let transition = state.handle_event(Event::Tick, &config, &hints, None);
977
978        assert!(
979            matches!(transition.new_state, AppState::BorderOnly { .. }),
980            "Does not transition without minimum frames rendered"
981        );
982    }
983
984    #[test]
985    fn test_border_only_frame_callback_increments_counter() {
986        let config = make_test_config();
987        let hints = make_realistic_hints();
988
989        let state = AppState::BorderOnly {
990            start_time: Instant::now(),
991            frame_count: 0,
992        };
993
994        let transition = state.handle_event(Event::FrameCallback, &config, &hints, None);
995
996        match transition.new_state {
997            AppState::BorderOnly { frame_count, .. } => {
998                assert_eq!(frame_count, 1, "Frame count increments");
999            }
1000            _ => panic!("Remains in BorderOnly state"),
1001        }
1002    }
1003
1004    #[test]
1005    fn test_border_only_quick_alt_release_with_previous_window() {
1006        let config = make_test_config();
1007        let hints = make_realistic_hints();
1008
1009        // Quick release before threshold
1010        let state = AppState::BorderOnly {
1011            start_time: Instant::now(),
1012            frame_count: 0,
1013        };
1014
1015        let transition = state.handle_event(
1016            Event::AltReleased,
1017            &config,
1018            &hints,
1019            Some("win-firefox-def456"),
1020        );
1021
1022        match transition.new_state {
1023            AppState::Exiting {
1024                result: ActivationResult::Window(idx),
1025            } => {
1026                assert_eq!(idx, 1, "Activates firefox at index 1");
1027            }
1028            _ => panic!("Exits with window activation result"),
1029        }
1030        assert!(transition.actions.contains(&Action::Exit));
1031    }
1032
1033    #[test]
1034    fn test_border_only_quick_alt_release_no_previous() {
1035        let config = make_test_config();
1036        let hints = make_realistic_hints();
1037
1038        let state = AppState::BorderOnly {
1039            start_time: Instant::now(),
1040            frame_count: 0,
1041        };
1042
1043        let transition = state.handle_event(Event::AltReleased, &config, &hints, None);
1044
1045        match transition.new_state {
1046            AppState::Exiting {
1047                result: ActivationResult::QuickSwitch,
1048            } => {}
1049            _ => panic!("Exits with QuickSwitch when no previous window"),
1050        }
1051    }
1052
1053    #[test]
1054    fn test_border_only_slow_alt_release_activates_first() {
1055        let config = make_test_config();
1056        let hints = make_realistic_hints();
1057
1058        // Slow release after threshold
1059        let state = AppState::BorderOnly {
1060            start_time: Instant::now() - Duration::from_millis(300),
1061            frame_count: 0,
1062        };
1063
1064        let transition = state.handle_event(Event::AltReleased, &config, &hints, None);
1065
1066        match transition.new_state {
1067            AppState::Exiting {
1068                result: ActivationResult::Window(idx),
1069            } => {
1070                assert_eq!(idx, 0, "Activates first window");
1071            }
1072            _ => panic!("Exits with window 0 activation"),
1073        }
1074    }
1075
1076    #[test]
1077    fn test_border_only_tab_transitions_to_full() {
1078        let config = make_test_config();
1079        let hints = make_realistic_hints();
1080
1081        let state = AppState::BorderOnly {
1082            start_time: Instant::now(),
1083            frame_count: 0,
1084        };
1085
1086        let transition = state.handle_event(
1087            Event::KeyPress {
1088                keysym: Keysym::Tab,
1089                shift: false,
1090            },
1091            &config,
1092            &hints,
1093            None,
1094        );
1095
1096        match transition.new_state {
1097            AppState::FullOverlay {
1098                selected_hint_index,
1099                ..
1100            } => {
1101                assert_eq!(selected_hint_index, 1, "Tab selects index 1");
1102            }
1103            _ => panic!("Tab transitions to FullOverlay"),
1104        }
1105    }
1106
1107    #[test]
1108    fn test_border_only_shift_tab_selects_last() {
1109        let config = make_test_config();
1110        let hints = make_realistic_hints();
1111
1112        let state = AppState::BorderOnly {
1113            start_time: Instant::now(),
1114            frame_count: 0,
1115        };
1116
1117        let transition = state.handle_event(
1118            Event::KeyPress {
1119                keysym: Keysym::Tab,
1120                shift: true,
1121            },
1122            &config,
1123            &hints,
1124            None,
1125        );
1126
1127        match transition.new_state {
1128            AppState::FullOverlay {
1129                selected_hint_index,
1130                ..
1131            } => {
1132                assert_eq!(selected_hint_index, 2, "Shift+Tab selects last");
1133            }
1134            _ => panic!("Shift+Tab transitions to FullOverlay"),
1135        }
1136    }
1137
1138    #[test]
1139    fn test_border_only_character_key_goes_to_pending_on_exact_match() {
1140        let config = make_test_config();
1141        let hints = make_realistic_hints();
1142
1143        let state = AppState::BorderOnly {
1144            start_time: Instant::now(),
1145            frame_count: 0,
1146        };
1147
1148        // Press 'g' matches ghostty exactly
1149        let transition = state.handle_event(
1150            Event::KeyPress {
1151                keysym: Keysym::from(0x67), // 'g'
1152                shift: false,
1153            },
1154            &config,
1155            &hints,
1156            None,
1157        );
1158
1159        match transition.new_state {
1160            AppState::PendingActivation {
1161                hint_index, input, ..
1162            } => {
1163                assert_eq!(hint_index, 2, "Matches ghostty at index 2");
1164                assert_eq!(input, "g");
1165            }
1166            _ => panic!(
1167                "Character key with exact match transitions to PendingActivation, got {:?}",
1168                transition.new_state
1169            ),
1170        }
1171    }
1172
1173    #[test]
1174    fn test_border_only_escape_cancels() {
1175        let config = make_test_config();
1176        let hints = make_realistic_hints();
1177
1178        let state = AppState::BorderOnly {
1179            start_time: Instant::now(),
1180            frame_count: 0,
1181        };
1182
1183        let transition = state.handle_event(
1184            Event::KeyPress {
1185                keysym: Keysym::Escape,
1186                shift: false,
1187            },
1188            &config,
1189            &hints,
1190            None,
1191        );
1192
1193        assert!(matches!(
1194            transition.new_state,
1195            AppState::Exiting {
1196                result: ActivationResult::Cancelled
1197            }
1198        ));
1199    }
1200
1201    // ==========================================================================
1202    // FULL OVERLAY STATE TESTS
1203    // ==========================================================================
1204
1205    #[test]
1206    fn test_full_overlay_tab_cycles_selection() {
1207        let config = make_test_config();
1208        let hints = make_hints(3);
1209        let state = AppState::FullOverlay {
1210            selected_hint_index: 0,
1211            input: String::new(),
1212        };
1213
1214        let transition = state.handle_event(
1215            Event::KeyPress {
1216                keysym: Keysym::Tab,
1217                shift: false,
1218            },
1219            &config,
1220            &hints,
1221            None,
1222        );
1223
1224        match transition.new_state {
1225            AppState::FullOverlay {
1226                selected_hint_index,
1227                ..
1228            } => {
1229                assert_eq!(selected_hint_index, 1);
1230            }
1231            _ => panic!("Expected FullOverlay"),
1232        }
1233    }
1234
1235    #[test]
1236    fn test_full_overlay_down_arrow_cycles() {
1237        let config = make_test_config();
1238        let hints = make_hints(3);
1239        let state = AppState::FullOverlay {
1240            selected_hint_index: 0,
1241            input: String::new(),
1242        };
1243
1244        let transition = state.handle_event(
1245            Event::KeyPress {
1246                keysym: Keysym::Down,
1247                shift: false,
1248            },
1249            &config,
1250            &hints,
1251            None,
1252        );
1253
1254        match transition.new_state {
1255            AppState::FullOverlay {
1256                selected_hint_index,
1257                ..
1258            } => {
1259                assert_eq!(selected_hint_index, 1);
1260            }
1261            _ => panic!("Down arrow should cycle selection"),
1262        }
1263    }
1264
1265    #[test]
1266    fn test_full_overlay_up_arrow_cycles() {
1267        let config = make_test_config();
1268        let hints = make_hints(3);
1269        let state = AppState::FullOverlay {
1270            selected_hint_index: 1,
1271            input: String::new(),
1272        };
1273
1274        let transition = state.handle_event(
1275            Event::KeyPress {
1276                keysym: Keysym::Up,
1277                shift: false,
1278            },
1279            &config,
1280            &hints,
1281            None,
1282        );
1283
1284        match transition.new_state {
1285            AppState::FullOverlay {
1286                selected_hint_index,
1287                ..
1288            } => {
1289                assert_eq!(selected_hint_index, 0);
1290            }
1291            _ => panic!("Up arrow should cycle selection"),
1292        }
1293    }
1294
1295    #[test]
1296    fn test_full_overlay_enter_activates_selected() {
1297        let config = make_test_config();
1298        let hints = make_hints(3);
1299        let state = AppState::FullOverlay {
1300            selected_hint_index: 2,
1301            input: String::new(),
1302        };
1303
1304        let transition = state.handle_event(
1305            Event::KeyPress {
1306                keysym: Keysym::Return,
1307                shift: false,
1308            },
1309            &config,
1310            &hints,
1311            None,
1312        );
1313
1314        match transition.new_state {
1315            AppState::Exiting {
1316                result: ActivationResult::Window(idx),
1317            } => {
1318                assert_eq!(idx, 2);
1319            }
1320            _ => panic!("Enter should activate selected window"),
1321        }
1322    }
1323
1324    #[test]
1325    fn test_full_overlay_escape_cancels() {
1326        let config = make_test_config();
1327        let hints = make_hints(3);
1328        let state = AppState::FullOverlay {
1329            selected_hint_index: 0,
1330            input: String::new(),
1331        };
1332
1333        let transition = state.handle_event(
1334            Event::KeyPress {
1335                keysym: Keysym::Escape,
1336                shift: false,
1337            },
1338            &config,
1339            &hints,
1340            None,
1341        );
1342
1343        assert!(matches!(
1344            transition.new_state,
1345            AppState::Exiting {
1346                result: ActivationResult::Cancelled
1347            }
1348        ));
1349    }
1350
1351    #[test]
1352    fn test_full_overlay_backspace_removes_char() {
1353        let config = make_test_config();
1354        let hints = make_hints(3);
1355        let state = AppState::FullOverlay {
1356            selected_hint_index: 0,
1357            input: "ab".to_string(),
1358        };
1359
1360        let transition = state.handle_event(
1361            Event::KeyPress {
1362                keysym: Keysym::BackSpace,
1363                shift: false,
1364            },
1365            &config,
1366            &hints,
1367            None,
1368        );
1369
1370        match transition.new_state {
1371            AppState::FullOverlay { input, .. } => {
1372                assert_eq!(input, "a");
1373            }
1374            _ => panic!("Backspace should stay in FullOverlay"),
1375        }
1376    }
1377
1378    #[test]
1379    fn test_full_overlay_alt_released_activates() {
1380        let config = make_test_config();
1381        let hints = make_hints(3);
1382        let state = AppState::FullOverlay {
1383            selected_hint_index: 1,
1384            input: String::new(),
1385        };
1386
1387        let transition = state.handle_event(Event::AltReleased, &config, &hints, None);
1388
1389        match transition.new_state {
1390            AppState::Exiting {
1391                result: ActivationResult::Window(idx),
1392            } => {
1393                assert_eq!(idx, 1);
1394            }
1395            _ => panic!("Alt release should activate selected window"),
1396        }
1397    }
1398
1399    #[test]
1400    fn test_full_overlay_character_exact_match_goes_pending() {
1401        let config = make_test_config();
1402        let hints = make_realistic_hints();
1403        let state = AppState::FullOverlay {
1404            selected_hint_index: 0,
1405            input: String::new(),
1406        };
1407
1408        // Press 'f' which should match firefox exactly
1409        let transition = state.handle_event(
1410            Event::KeyPress {
1411                keysym: Keysym::from(0x66), // 'f'
1412                shift: false,
1413            },
1414            &config,
1415            &hints,
1416            None,
1417        );
1418
1419        match transition.new_state {
1420            AppState::PendingActivation {
1421                hint_index, input, ..
1422            } => {
1423                assert_eq!(hint_index, 1, "Should match firefox");
1424                assert_eq!(input, "f");
1425            }
1426            _ => panic!("Exact match should go to PendingActivation"),
1427        }
1428    }
1429
1430    #[test]
1431    fn test_full_overlay_ipc_cycle_forward() {
1432        let config = make_test_config();
1433        let hints = make_hints(3);
1434        let state = AppState::FullOverlay {
1435            selected_hint_index: 0,
1436            input: String::new(),
1437        };
1438
1439        let transition = state.handle_event(Event::CycleForward, &config, &hints, None);
1440
1441        match transition.new_state {
1442            AppState::FullOverlay {
1443                selected_hint_index,
1444                ..
1445            } => {
1446                assert_eq!(selected_hint_index, 1);
1447            }
1448            _ => panic!("CycleForward should update selection"),
1449        }
1450    }
1451
1452    #[test]
1453    fn test_full_overlay_ipc_cycle_backward() {
1454        let config = make_test_config();
1455        let hints = make_hints(3);
1456        let state = AppState::FullOverlay {
1457            selected_hint_index: 1,
1458            input: String::new(),
1459        };
1460
1461        let transition = state.handle_event(Event::CycleBackward, &config, &hints, None);
1462
1463        match transition.new_state {
1464            AppState::FullOverlay {
1465                selected_hint_index,
1466                ..
1467            } => {
1468                assert_eq!(selected_hint_index, 0);
1469            }
1470            _ => panic!("CycleBackward should update selection"),
1471        }
1472    }
1473
1474    // ==========================================================================
1475    // PENDING ACTIVATION STATE TESTS
1476    // ==========================================================================
1477
1478    #[test]
1479    fn test_pending_activation_timeout_activates() {
1480        let config = make_test_config();
1481        let hints = make_realistic_hints();
1482
1483        // Create pending state with old timeout
1484        let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
1485        timeout.start();
1486        // Simulate elapsed timeout by creating state and sleeping
1487        let state = AppState::PendingActivation {
1488            hint_index: 2,
1489            input: "g".to_string(),
1490            timeout,
1491        };
1492        // Sleep to ensure timeout has elapsed
1493        std::thread::sleep(Duration::from_millis(250));
1494
1495        let transition = state.handle_event(Event::Tick, &config, &hints, None);
1496
1497        match transition.new_state {
1498            AppState::Exiting {
1499                result: ActivationResult::Window(idx),
1500            } => {
1501                assert_eq!(idx, 2);
1502            }
1503            _ => panic!("Timeout should activate window"),
1504        }
1505    }
1506
1507    #[test]
1508    fn test_pending_activation_no_timeout_yet() {
1509        let config = make_test_config();
1510        let hints = make_realistic_hints();
1511
1512        let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
1513        timeout.start();
1514        let state = AppState::PendingActivation {
1515            hint_index: 2,
1516            input: "g".to_string(),
1517            timeout,
1518        };
1519
1520        let transition = state.handle_event(Event::Tick, &config, &hints, None);
1521
1522        assert!(
1523            matches!(transition.new_state, AppState::PendingActivation { .. }),
1524            "Should stay pending before timeout"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_pending_activation_escape_cancels() {
1530        let config = make_test_config();
1531        let hints = make_realistic_hints();
1532
1533        let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
1534        timeout.start();
1535        let state = AppState::PendingActivation {
1536            hint_index: 2,
1537            input: "g".to_string(),
1538            timeout,
1539        };
1540
1541        let transition = state.handle_event(
1542            Event::KeyPress {
1543                keysym: Keysym::Escape,
1544                shift: false,
1545            },
1546            &config,
1547            &hints,
1548            None,
1549        );
1550
1551        assert!(matches!(
1552            transition.new_state,
1553            AppState::Exiting {
1554                result: ActivationResult::Cancelled
1555            }
1556        ));
1557    }
1558
1559    #[test]
1560    fn test_pending_activation_backspace_returns_to_full() {
1561        let config = make_test_config();
1562        let hints = make_realistic_hints();
1563
1564        let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
1565        timeout.start();
1566        let state = AppState::PendingActivation {
1567            hint_index: 2,
1568            input: "g".to_string(),
1569            timeout,
1570        };
1571
1572        let transition = state.handle_event(
1573            Event::KeyPress {
1574                keysym: Keysym::BackSpace,
1575                shift: false,
1576            },
1577            &config,
1578            &hints,
1579            None,
1580        );
1581
1582        match transition.new_state {
1583            AppState::FullOverlay { input, .. } => {
1584                assert!(input.is_empty(), "Backspace should remove char");
1585            }
1586            _ => panic!("Backspace should return to FullOverlay"),
1587        }
1588    }
1589
1590    #[test]
1591    fn test_pending_activation_alt_release_activates_immediately() {
1592        let config = make_test_config();
1593        let hints = make_realistic_hints();
1594
1595        let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
1596        timeout.start();
1597        let state = AppState::PendingActivation {
1598            hint_index: 1,
1599            input: "f".to_string(),
1600            timeout,
1601        };
1602
1603        let transition = state.handle_event(Event::AltReleased, &config, &hints, None);
1604
1605        match transition.new_state {
1606            AppState::Exiting {
1607                result: ActivationResult::Window(idx),
1608            } => {
1609                assert_eq!(idx, 1);
1610            }
1611            _ => panic!("Alt release should activate immediately"),
1612        }
1613    }
1614
1615    // ==========================================================================
1616    // FULL LIFECYCLE SCENARIO TESTS
1617    // ==========================================================================
1618
1619    #[test]
1620    fn test_scenario_launcher_type_g_wait_activate() {
1621        let config = make_test_config();
1622        let hints = make_realistic_hints();
1623
1624        // Start in launcher mode
1625        let mut state = AppState::initial(true, &hints, None);
1626        assert!(
1627            matches!(state, AppState::FullOverlay { .. }),
1628            "Launcher starts in FullOverlay"
1629        );
1630
1631        // Type 'g'
1632        let trans = state.handle_event(
1633            Event::KeyPress {
1634                keysym: Keysym::from(0x67),
1635                shift: false,
1636            },
1637            &config,
1638            &hints,
1639            None,
1640        );
1641        state = trans.new_state;
1642        assert!(
1643            matches!(state, AppState::PendingActivation { hint_index: 2, .. }),
1644            "Should match ghostty (index 2)"
1645        );
1646
1647        // Sleep to ensure timeout elapses
1648        std::thread::sleep(Duration::from_millis(250));
1649
1650        // Tick should trigger activation
1651        let trans = state.handle_event(Event::Tick, &config, &hints, None);
1652        state = trans.new_state;
1653
1654        match state {
1655            AppState::Exiting {
1656                result: ActivationResult::Window(idx),
1657            } => {
1658                assert_eq!(idx, 2, "Should activate ghostty");
1659            }
1660            _ => panic!("Should exit with window activation"),
1661        }
1662    }
1663
1664    #[test]
1665    fn test_scenario_switcher_quick_alt_tab() {
1666        let config = make_test_config();
1667        let hints = make_realistic_hints();
1668
1669        // Start in switcher mode
1670        let state = AppState::initial(false, &hints, None);
1671        assert!(matches!(state, AppState::BorderOnly { .. }));
1672
1673        // Quick Alt release with previous window set
1674        let trans = state.handle_event(
1675            Event::AltReleased,
1676            &config,
1677            &hints,
1678            Some("win-firefox-def456"),
1679        );
1680
1681        match trans.new_state {
1682            AppState::Exiting {
1683                result: ActivationResult::Window(idx),
1684            } => {
1685                assert_eq!(idx, 1, "Should switch to previous window (firefox)");
1686            }
1687            _ => panic!("Quick Alt+Tab should activate previous window"),
1688        }
1689    }
1690
1691    #[test]
1692    fn test_scenario_switcher_hold_and_tab() {
1693        let config = make_test_config();
1694        let hints = make_realistic_hints();
1695
1696        // Start in switcher mode
1697        let mut state = AppState::initial(false, &hints, None);
1698
1699        // Tab to cycle
1700        let trans = state.handle_event(
1701            Event::KeyPress {
1702                keysym: Keysym::Tab,
1703                shift: false,
1704            },
1705            &config,
1706            &hints,
1707            None,
1708        );
1709        state = trans.new_state;
1710
1711        match &state {
1712            AppState::FullOverlay {
1713                selected_hint_index,
1714                ..
1715            } => {
1716                assert_eq!(*selected_hint_index, 1);
1717            }
1718            _ => panic!("Tab should transition to FullOverlay"),
1719        }
1720
1721        // Tab again
1722        let trans = state.handle_event(
1723            Event::KeyPress {
1724                keysym: Keysym::Tab,
1725                shift: false,
1726            },
1727            &config,
1728            &hints,
1729            None,
1730        );
1731        state = trans.new_state;
1732
1733        match &state {
1734            AppState::FullOverlay {
1735                selected_hint_index,
1736                ..
1737            } => {
1738                assert_eq!(*selected_hint_index, 2);
1739            }
1740            _ => panic!("Second Tab should update selection"),
1741        }
1742
1743        // Release Alt
1744        let trans = state.handle_event(Event::AltReleased, &config, &hints, None);
1745
1746        match trans.new_state {
1747            AppState::Exiting {
1748                result: ActivationResult::Window(idx),
1749            } => {
1750                assert_eq!(idx, 2, "Should activate final selection");
1751            }
1752            _ => panic!("Alt release should activate"),
1753        }
1754    }
1755
1756    #[test]
1757    fn test_scenario_launcher_arrow_navigate_enter() {
1758        let config = make_test_config();
1759        let hints = make_realistic_hints();
1760
1761        // Start in launcher mode
1762        let mut state = AppState::initial(true, &hints, None);
1763
1764        // Navigate down twice
1765        let trans = state.handle_event(
1766            Event::KeyPress {
1767                keysym: Keysym::Down,
1768                shift: false,
1769            },
1770            &config,
1771            &hints,
1772            None,
1773        );
1774        state = trans.new_state;
1775
1776        let trans = state.handle_event(
1777            Event::KeyPress {
1778                keysym: Keysym::Down,
1779                shift: false,
1780            },
1781            &config,
1782            &hints,
1783            None,
1784        );
1785        state = trans.new_state;
1786
1787        // Press Enter
1788        let trans = state.handle_event(
1789            Event::KeyPress {
1790                keysym: Keysym::Return,
1791                shift: false,
1792            },
1793            &config,
1794            &hints,
1795            None,
1796        );
1797
1798        match trans.new_state {
1799            AppState::Exiting {
1800                result: ActivationResult::Window(idx),
1801            } => {
1802                assert_eq!(idx, 2, "Should activate third item (down, down from 0)");
1803            }
1804            _ => panic!("Enter should activate"),
1805        }
1806    }
1807
1808    #[test]
1809    fn test_scenario_escape_at_any_stage() {
1810        let config = make_test_config();
1811        let hints = make_realistic_hints();
1812
1813        // Test escape from each state
1814        let mut timeout = TimeoutTracker::new(config.settings.activation_delay);
1815        timeout.start();
1816        let states = vec![
1817            AppState::initial(false, &hints, None), // BorderOnly
1818            AppState::initial(true, &hints, None),  // FullOverlay
1819            AppState::PendingActivation {
1820                hint_index: 0,
1821                input: "e".to_string(),
1822                timeout,
1823            },
1824        ];
1825
1826        for state in states {
1827            let trans = state.handle_event(
1828                Event::KeyPress {
1829                    keysym: Keysym::Escape,
1830                    shift: false,
1831                },
1832                &config,
1833                &hints,
1834                None,
1835            );
1836
1837            assert!(
1838                matches!(
1839                    trans.new_state,
1840                    AppState::Exiting {
1841                        result: ActivationResult::Cancelled
1842                    }
1843                ),
1844                "Escape should cancel from any state"
1845            );
1846        }
1847    }
1848
1849    // ==========================================================================
1850    // STATE ACCESSOR TESTS
1851    // ==========================================================================
1852
1853    #[test]
1854    fn test_selected_hint_index() {
1855        assert_eq!(
1856            AppState::FullOverlay {
1857                selected_hint_index: 5,
1858                input: String::new()
1859            }
1860            .selected_hint_index(),
1861            5
1862        );
1863        let mut timeout = TimeoutTracker::new(200);
1864        timeout.start();
1865        assert_eq!(
1866            AppState::PendingActivation {
1867                hint_index: 3,
1868                input: "x".to_string(),
1869                timeout
1870            }
1871            .selected_hint_index(),
1872            3
1873        );
1874        assert_eq!(
1875            AppState::BorderOnly {
1876                start_time: Instant::now(),
1877                frame_count: 0
1878            }
1879            .selected_hint_index(),
1880            0
1881        );
1882    }
1883
1884    #[test]
1885    fn test_input_accessor() {
1886        assert_eq!(
1887            AppState::FullOverlay {
1888                selected_hint_index: 0,
1889                input: "abc".to_string()
1890            }
1891            .input(),
1892            "abc"
1893        );
1894        let mut timeout = TimeoutTracker::new(200);
1895        timeout.start();
1896        assert_eq!(
1897            AppState::PendingActivation {
1898                hint_index: 0,
1899                input: "xyz".to_string(),
1900                timeout
1901            }
1902            .input(),
1903            "xyz"
1904        );
1905        assert_eq!(
1906            AppState::BorderOnly {
1907                start_time: Instant::now(),
1908                frame_count: 0
1909            }
1910            .input(),
1911            ""
1912        );
1913    }
1914
1915    #[test]
1916    fn test_is_full_overlay() {
1917        assert!(
1918            !AppState::BorderOnly {
1919                start_time: Instant::now(),
1920                frame_count: 0
1921            }
1922            .is_full_overlay()
1923        );
1924        assert!(
1925            AppState::FullOverlay {
1926                selected_hint_index: 0,
1927                input: String::new()
1928            }
1929            .is_full_overlay()
1930        );
1931        let mut timeout = TimeoutTracker::new(200);
1932        timeout.start();
1933        assert!(
1934            AppState::PendingActivation {
1935                hint_index: 0,
1936                input: String::new(),
1937                timeout
1938            }
1939            .is_full_overlay()
1940        );
1941        assert!(
1942            !AppState::Exiting {
1943                result: ActivationResult::Cancelled
1944            }
1945            .is_full_overlay()
1946        );
1947    }
1948
1949    #[test]
1950    fn test_is_exiting() {
1951        assert!(
1952            !AppState::BorderOnly {
1953                start_time: Instant::now(),
1954                frame_count: 0
1955            }
1956            .is_exiting()
1957        );
1958        assert!(
1959            !AppState::FullOverlay {
1960                selected_hint_index: 0,
1961                input: String::new()
1962            }
1963            .is_exiting()
1964        );
1965        assert!(
1966            AppState::Exiting {
1967                result: ActivationResult::Cancelled
1968            }
1969            .is_exiting()
1970        );
1971    }
1972
1973    #[test]
1974    fn test_activation_result_accessor() {
1975        assert!(
1976            AppState::BorderOnly {
1977                start_time: Instant::now(),
1978                frame_count: 0
1979            }
1980            .activation_result()
1981            .is_none()
1982        );
1983
1984        let state = AppState::Exiting {
1985            result: ActivationResult::Window(5),
1986        };
1987        match state.activation_result() {
1988            Some(ActivationResult::Window(idx)) => assert_eq!(*idx, 5),
1989            _ => panic!("Should return Window(5)"),
1990        }
1991    }
1992}