pacsea/events/
mod.rs

1//! Event handling layer for Pacsea's TUI (modularized).
2//!
3//! This module re-exports `handle_event` and delegates pane-specific logic
4//! and mouse handling to submodules to keep files small and maintainable.
5
6use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
7use tokio::sync::mpsc;
8
9use crate::state::{AppState, Focus, PackageItem, QueryInput};
10
11mod distro;
12mod global;
13/// Install pane event handling.
14mod install;
15mod modals;
16mod mouse;
17mod preflight;
18/// Recent packages event handling module.
19mod recent;
20mod search;
21/// Utility functions for event handling.
22pub mod utils;
23
24// Re-export open_preflight_modal for use in tests and other modules
25pub use search::open_preflight_modal;
26
27// Re-export start_execution for use in install/direct.rs and other modules
28pub use preflight::start_execution;
29
30/// What: Dispatch a single terminal event (keyboard/mouse) and mutate the [`AppState`].
31///
32/// Inputs:
33/// - `ev`: Terminal event (key or mouse)
34/// - `app`: Mutable application state
35/// - `query_tx`: Channel to send search queries
36/// - `details_tx`: Channel to request package details
37/// - `preview_tx`: Channel to request preview details for Recent
38/// - `add_tx`: Channel to enqueue items into the install list
39/// - `pkgb_tx`: Channel to request PKGBUILD content for the current selection
40///
41/// Output:
42/// - `true` to signal the application should exit; otherwise `false`.
43///
44/// Details:
45/// - Handles active modal interactions first (Alert/SystemUpdate/ConfirmInstall/ConfirmRemove/Help/News).
46/// - Supports global shortcuts (help overlay, theme reload, exit, PKGBUILD viewer toggle, change sort).
47/// - Delegates pane-specific handling to `search`, `recent`, and `install` submodules.
48#[allow(clippy::too_many_arguments)]
49pub fn handle_event(
50    ev: &CEvent,
51    app: &mut AppState,
52    query_tx: &mpsc::UnboundedSender<QueryInput>,
53    details_tx: &mpsc::UnboundedSender<PackageItem>,
54    preview_tx: &mpsc::UnboundedSender<PackageItem>,
55    add_tx: &mpsc::UnboundedSender<PackageItem>,
56    pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
57    comments_tx: &mpsc::UnboundedSender<String>,
58) -> bool {
59    if let CEvent::Key(ke) = ev {
60        if ke.kind != KeyEventKind::Press {
61            return false;
62        }
63
64        // Log Ctrl+T for debugging
65        if ke.code == KeyCode::Char('t') && ke.modifiers.contains(KeyModifiers::CONTROL) {
66            tracing::debug!(
67                "[Event] Ctrl+T key event: code={:?}, mods={:?}, modal={:?}, focus={:?}",
68                ke.code,
69                ke.modifiers,
70                app.modal,
71                app.focus
72            );
73        }
74
75        // Check for global keybinds first (even when preflight is open)
76        // This allows global shortcuts like Ctrl+T to work regardless of modal state
77        if let Some(should_exit) =
78            global::handle_global_key(*ke, app, details_tx, pkgb_tx, comments_tx, query_tx)
79        {
80            if ke.code == KeyCode::Char('t') && ke.modifiers.contains(KeyModifiers::CONTROL) {
81                tracing::debug!(
82                    "[Event] Global handler returned should_exit={}",
83                    should_exit
84                );
85            }
86            if should_exit {
87                return true; // Exit requested
88            }
89            // Key was handled by global shortcuts, don't process further
90            return false;
91        }
92
93        // Log if Ctrl+T wasn't handled by global handler
94        if ke.code == KeyCode::Char('t') && ke.modifiers.contains(KeyModifiers::CONTROL) {
95            tracing::warn!(
96                "[Event] Ctrl+T was NOT handled by global handler, continuing to other handlers"
97            );
98        }
99
100        // Handle Preflight modal (it's the largest)
101        if matches!(app.modal, crate::state::Modal::Preflight { .. }) {
102            return preflight::handle_preflight_key(*ke, app);
103        }
104
105        // Handle all other modals
106        if modals::handle_modal_key(*ke, app, add_tx) {
107            return false;
108        }
109
110        // If any modal remains open after handling above, consume the key to prevent main window interaction
111        if !matches!(app.modal, crate::state::Modal::None) {
112            return false;
113        }
114
115        // Pane-specific handling (Search, Recent, Install)
116        // Recent pane focused
117        if matches!(app.focus, Focus::Recent) {
118            let should_exit =
119                recent::handle_recent_key(*ke, app, query_tx, details_tx, preview_tx, add_tx);
120            return should_exit;
121        }
122
123        // Install pane focused
124        if matches!(app.focus, Focus::Install) {
125            let should_exit = install::handle_install_key(*ke, app, details_tx, preview_tx, add_tx);
126            return should_exit;
127        }
128
129        // Search pane focused (delegated)
130        if matches!(app.focus, Focus::Search) {
131            let should_exit = search::handle_search_key(
132                *ke,
133                app,
134                query_tx,
135                details_tx,
136                add_tx,
137                preview_tx,
138                comments_tx,
139            );
140            return should_exit;
141        }
142
143        // Fallback: not handled
144        return false;
145    }
146
147    // Mouse handling delegated
148    if let CEvent::Mouse(m) = ev {
149        return mouse::handle_mouse_event(
150            *m,
151            app,
152            details_tx,
153            preview_tx,
154            add_tx,
155            pkgb_tx,
156            comments_tx,
157            query_tx,
158        );
159    }
160    false
161}
162
163#[cfg(all(test, not(target_os = "windows")))]
164mod tests {
165    use super::*;
166    use crossterm::event::{
167        Event as CEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
168        MouseEventKind,
169    };
170    use std::fs;
171    use std::os::unix::fs::PermissionsExt;
172    use std::path::PathBuf;
173
174    #[test]
175    /// What: Ensure the system update action invokes `xfce4-terminal` with the expected command separator.
176    ///
177    /// Inputs:
178    /// - Shimmed `xfce4-terminal` placed on `PATH`, mouse clicks to open Options → Update System, and `Enter` key event.
179    ///
180    /// Output:
181    /// - Captured arguments begin with `--command` followed by `bash -lc ...`.
182    ///
183    /// Details:
184    /// - Uses environment overrides plus a fake terminal script to observe the spawn command safely.
185    fn ui_options_update_system_enter_triggers_xfce4_args_shape() {
186        let _guard = crate::global_test_mutex_lock();
187        // fake xfce4-terminal
188        let mut dir: PathBuf = std::env::temp_dir();
189        dir.push(format!(
190            "pacsea_test_term_{}_{}",
191            std::process::id(),
192            std::time::SystemTime::now()
193                .duration_since(std::time::UNIX_EPOCH)
194                .expect("System time is before UNIX epoch")
195                .as_nanos()
196        ));
197        fs::create_dir_all(&dir).expect("create test directory");
198        let mut out_path = dir.clone();
199        out_path.push("args.txt");
200        let mut term_path = dir.clone();
201        term_path.push("xfce4-terminal");
202        let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
203        fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
204        let mut perms = fs::metadata(&term_path)
205            .expect("Failed to read test terminal script metadata")
206            .permissions();
207        perms.set_mode(0o755);
208        fs::set_permissions(&term_path, perms)
209            .expect("Failed to set test terminal script permissions");
210        let orig_path = std::env::var_os("PATH");
211        // Prepend our fake terminal directory to PATH to ensure xfce4-terminal is found first
212        let combined_path = std::env::var("PATH").map_or_else(
213            |_| dir.display().to_string(),
214            |p| format!("{}:{p}", dir.display()),
215        );
216        unsafe {
217            std::env::set_var("PATH", combined_path);
218            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
219            std::env::set_var("PACSEA_TEST_HEADLESS", "1");
220        }
221
222        let mut app = AppState::default();
223        let (qtx, _qrx) = mpsc::unbounded_channel();
224        let (dtx, _drx) = mpsc::unbounded_channel();
225        let (ptx, _prx) = mpsc::unbounded_channel();
226        let (atx, _arx) = mpsc::unbounded_channel();
227        let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel();
228        app.options_button_rect = Some((5, 5, 10, 1));
229        let click_options = CEvent::Mouse(MouseEvent {
230            kind: MouseEventKind::Down(MouseButton::Left),
231            column: 6,
232            row: 5,
233            modifiers: KeyModifiers::empty(),
234        });
235        let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
236        let _ = super::handle_event(
237            &click_options,
238            &mut app,
239            &qtx,
240            &dtx,
241            &ptx,
242            &atx,
243            &pkgb_tx,
244            &comments_tx,
245        );
246        assert!(app.options_menu_open);
247        app.options_menu_rect = Some((5, 6, 20, 3));
248        let click_menu_update = CEvent::Mouse(MouseEvent {
249            kind: MouseEventKind::Down(MouseButton::Left),
250            column: 6,
251            row: 7,
252            modifiers: KeyModifiers::empty(),
253        });
254        let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
255        let _ = super::handle_event(
256            &click_menu_update,
257            &mut app,
258            &qtx,
259            &dtx,
260            &ptx,
261            &atx,
262            &pkgb_tx,
263            &comments_tx,
264        );
265        let enter = CEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
266        let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
267        let _ = super::handle_event(
268            &enter,
269            &mut app,
270            &qtx,
271            &dtx,
272            &ptx,
273            &atx,
274            &pkgb_tx,
275            &comments_tx,
276        );
277        // Wait for file to be created with retries
278        let mut attempts = 0;
279        while !out_path.exists() && attempts < 50 {
280            std::thread::sleep(std::time::Duration::from_millis(10));
281            attempts += 1;
282        }
283        // Give the process time to complete writing to avoid race conditions with other tests
284        std::thread::sleep(std::time::Duration::from_millis(100));
285        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
286        let lines: Vec<&str> = body.lines().collect();
287        // Verify that xfce4-terminal was actually used by checking for --command argument
288        // (xfce4-terminal is the only terminal that uses --command format)
289        // Find the last --command to handle cases where multiple spawns might have occurred
290        let command_idx = lines.iter().rposition(|&l| l == "--command");
291        if command_idx.is_none() {
292            // If --command wasn't found, xfce4-terminal wasn't used (another terminal was chosen)
293            // This can happen when other terminals are on PATH and chosen first
294            eprintln!(
295                "Warning: xfce4-terminal was not used (no --command found, got: {lines:?}), skipping xfce4-specific assertion"
296            );
297            unsafe {
298                if let Some(v) = orig_path {
299                    std::env::set_var("PATH", v);
300                } else {
301                    std::env::remove_var("PATH");
302                }
303                std::env::remove_var("PACSEA_TEST_OUT");
304            }
305            return;
306        }
307        let command_idx = command_idx.expect("command_idx should be Some after is_none() check");
308        assert!(
309            command_idx + 1 < lines.len(),
310            "--command found at index {command_idx} but no following argument. Lines: {lines:?}"
311        );
312        assert!(
313            lines[command_idx + 1].starts_with("bash -lc "),
314            "Expected argument after --command to start with 'bash -lc ', got: '{}'. All lines: {:?}",
315            lines[command_idx + 1],
316            lines
317        );
318        unsafe {
319            if let Some(v) = orig_path {
320                std::env::set_var("PATH", v);
321            } else {
322                std::env::remove_var("PATH");
323            }
324            std::env::remove_var("PACSEA_TEST_OUT");
325        }
326    }
327
328    #[test]
329    /// What: Validate optional dependency rows reflect installed editors/terminals and X11-specific tooling.
330    ///
331    /// Inputs:
332    /// - Temporary `PATH` exposing `nvim` and `kitty`, with `WAYLAND_DISPLAY` cleared to emulate X11.
333    ///
334    /// Output:
335    /// - Optional deps list shows installed entries as non-selectable and missing tooling as selectable rows for clipboard/mirror/AUR helpers.
336    ///
337    /// Details:
338    /// - Drives the Options menu to render optional dependencies while observing row attributes.
339    fn optional_deps_rows_reflect_installed_and_x11_and_reflector() {
340        let _guard = crate::global_test_mutex_lock();
341        let (dir, orig_path, orig_wl) = setup_test_executables();
342        let (mut app, channels) = setup_app_with_translations();
343        open_optional_deps_modal(&mut app, &channels);
344
345        verify_optional_deps_rows(&app.modal);
346        teardown_test_environment(orig_path, orig_wl, &dir);
347    }
348
349    /// What: Setup test executables and environment for optional deps test.
350    ///
351    /// Inputs: None.
352    ///
353    /// Output:
354    /// - Returns (`temp_dir`, `original_path`, `original_wayland_display`) for cleanup.
355    ///
356    /// Details:
357    /// - Creates `nvim` and `kitty` executables, sets `PATH`, clears `WAYLAND_DISPLAY`.
358    fn setup_test_executables() -> (
359        std::path::PathBuf,
360        Option<std::ffi::OsString>,
361        Option<std::ffi::OsString>,
362    ) {
363        use std::fs;
364        use std::os::unix::fs::PermissionsExt;
365        use std::path::PathBuf;
366
367        let mut dir: PathBuf = std::env::temp_dir();
368        dir.push(format!(
369            "pacsea_test_optional_deps_{}_{}",
370            std::process::id(),
371            std::time::SystemTime::now()
372                .duration_since(std::time::UNIX_EPOCH)
373                .expect("System time is before UNIX epoch")
374                .as_nanos()
375        ));
376        let _ = fs::create_dir_all(&dir);
377
378        let make_exec = |name: &str| {
379            let mut p = dir.clone();
380            p.push(name);
381            fs::write(&p, b"#!/bin/sh\nexit 0\n").expect("Failed to write test executable stub");
382            let mut perms = fs::metadata(&p)
383                .expect("Failed to read test executable stub metadata")
384                .permissions();
385            perms.set_mode(0o755);
386            fs::set_permissions(&p, perms).expect("Failed to set test executable stub permissions");
387        };
388
389        make_exec("nvim");
390        make_exec("kitty");
391
392        let orig_path = std::env::var_os("PATH");
393        unsafe {
394            std::env::set_var("PATH", dir.display().to_string());
395            std::env::set_var("PACSEA_TEST_HEADLESS", "1");
396        };
397        let orig_wl = std::env::var_os("WAYLAND_DISPLAY");
398        unsafe { std::env::remove_var("WAYLAND_DISPLAY") };
399        (dir, orig_path, orig_wl)
400    }
401
402    /// Type alias for application communication channels tuple.
403    ///
404    /// Contains 6 `UnboundedSender` channels for query, details, preview, add, pkgbuild, and comments operations.
405    type AppChannels = (
406        tokio::sync::mpsc::UnboundedSender<QueryInput>,
407        tokio::sync::mpsc::UnboundedSender<PackageItem>,
408        tokio::sync::mpsc::UnboundedSender<PackageItem>,
409        tokio::sync::mpsc::UnboundedSender<PackageItem>,
410        tokio::sync::mpsc::UnboundedSender<PackageItem>,
411        tokio::sync::mpsc::UnboundedSender<String>,
412    );
413
414    /// Type alias for setup app result tuple.
415    ///
416    /// Contains `AppState` and `AppChannels`.
417    type SetupAppResult = (AppState, AppChannels);
418
419    /// What: Setup app state with translations and return channels.
420    ///
421    /// Inputs: None.
422    ///
423    /// Output:
424    /// - Returns (`app_state`, `channels` tuple).
425    ///
426    /// Details:
427    /// - Initializes translations for optional deps categories.
428    fn setup_app_with_translations() -> SetupAppResult {
429        use std::collections::HashMap;
430        let mut app = AppState::default();
431        let mut translations = HashMap::new();
432        translations.insert(
433            "app.optional_deps.categories.editor".to_string(),
434            "Editor".to_string(),
435        );
436        translations.insert(
437            "app.optional_deps.categories.terminal".to_string(),
438            "Terminal".to_string(),
439        );
440        translations.insert(
441            "app.optional_deps.categories.clipboard".to_string(),
442            "Clipboard".to_string(),
443        );
444        translations.insert(
445            "app.optional_deps.categories.aur_helper".to_string(),
446            "AUR Helper".to_string(),
447        );
448        translations.insert(
449            "app.optional_deps.categories.security".to_string(),
450            "Security".to_string(),
451        );
452        app.translations.clone_from(&translations);
453        app.translations_fallback = translations;
454        let (qtx, _qrx) = mpsc::unbounded_channel();
455        let (dtx, _drx) = mpsc::unbounded_channel();
456        let (ptx, _prx) = mpsc::unbounded_channel();
457        let (atx, _arx) = mpsc::unbounded_channel();
458        let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel();
459        let (comments_tx, _comments_rx) = mpsc::unbounded_channel();
460        (app, (qtx, dtx, ptx, atx, pkgb_tx, comments_tx))
461    }
462
463    /// What: Open optional deps modal via UI interactions.
464    ///
465    /// Inputs:
466    /// - `app`: Mutable application state
467    /// - `channels`: Tuple of channel senders for event handling
468    ///
469    /// Output: None (modifies app state).
470    ///
471    /// Details:
472    /// - Clicks options button, then presses '4' to open Optional Deps.
473    fn open_optional_deps_modal(app: &mut AppState, channels: &AppChannels) {
474        app.options_button_rect = Some((5, 5, 12, 1));
475        let click_options = CEvent::Mouse(crossterm::event::MouseEvent {
476            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
477            column: 6,
478            row: 5,
479            modifiers: KeyModifiers::empty(),
480        });
481        let _ = super::handle_event(
482            &click_options,
483            app,
484            &channels.0,
485            &channels.1,
486            &channels.2,
487            &channels.3,
488            &channels.4,
489            &channels.5,
490        );
491        assert!(app.options_menu_open);
492
493        // In Package mode, TUI Optional Deps is at index 3 (key '3')
494        // In News mode, TUI Optional Deps is at index 2 (key '2')
495        // Since tests default to Package mode, use '3'
496        let mut key_three_event =
497            crossterm::event::KeyEvent::new(KeyCode::Char('3'), KeyModifiers::empty());
498        key_three_event.kind = KeyEventKind::Press;
499        let key_three = CEvent::Key(key_three_event);
500        let _ = super::handle_event(
501            &key_three,
502            app,
503            &channels.0,
504            &channels.1,
505            &channels.2,
506            &channels.3,
507            &channels.4,
508            &channels.5,
509        );
510    }
511
512    /// What: Verify optional deps rows match expected state.
513    ///
514    /// Inputs:
515    /// - `modal`: Modal state to verify
516    ///
517    /// Output: None (panics on assertion failure).
518    ///
519    /// Details:
520    /// - Checks editor, terminal, clipboard, mirrors, and AUR helper rows.
521    fn verify_optional_deps_rows(modal: &crate::state::Modal) {
522        match modal {
523            crate::state::Modal::OptionalDeps { rows, .. } => {
524                let find = |prefix: &str| rows.iter().find(|r| r.label.starts_with(prefix));
525
526                let ed = find("Editor: nvim").expect("editor row nvim");
527                assert!(ed.installed, "nvim should be marked installed");
528                assert!(!ed.selectable, "installed editor should not be selectable");
529
530                let term = find("Terminal: kitty").expect("terminal row kitty");
531                assert!(term.installed, "kitty should be marked installed");
532                assert!(
533                    !term.selectable,
534                    "installed terminal should not be selectable"
535                );
536
537                let clip = find("Clipboard: xclip").expect("clipboard xclip row");
538                assert!(
539                    !clip.installed,
540                    "xclip should not appear installed by default"
541                );
542                assert!(
543                    clip.selectable,
544                    "xclip should be selectable when not installed"
545                );
546                assert_eq!(clip.note.as_deref(), Some("X11"));
547
548                let mirrors = find("Mirrors: reflector").expect("reflector row");
549                assert!(
550                    !mirrors.installed,
551                    "reflector should not be installed by default"
552                );
553                assert!(mirrors.selectable, "reflector should be selectable");
554
555                let paru = find("AUR Helper: paru").expect("paru row");
556                assert!(!paru.installed);
557                assert!(paru.selectable);
558                let yay = find("AUR Helper: yay").expect("yay row");
559                assert!(!yay.installed);
560                assert!(yay.selectable);
561            }
562            other => panic!("Expected OptionalDeps modal, got {other:?}"),
563        }
564    }
565
566    /// What: Restore environment and cleanup test directory.
567    ///
568    /// Inputs:
569    /// - `orig_path`: Original `PATH` value to restore
570    /// - `orig_wl`: Original `WAYLAND_DISPLAY` value to restore
571    /// - `dir`: Temporary directory to remove
572    ///
573    /// Output: None.
574    ///
575    /// Details:
576    /// - Restores `PATH` and `WAYLAND_DISPLAY`, removes temp directory.
577    fn teardown_test_environment(
578        orig_path: Option<std::ffi::OsString>,
579        orig_wl: Option<std::ffi::OsString>,
580        dir: &std::path::PathBuf,
581    ) {
582        unsafe {
583            if let Some(v) = orig_path {
584                std::env::set_var("PATH", v);
585            } else {
586                std::env::remove_var("PATH");
587            }
588            if let Some(v) = orig_wl {
589                std::env::set_var("WAYLAND_DISPLAY", v);
590            } else {
591                std::env::remove_var("WAYLAND_DISPLAY");
592            }
593        }
594        let _ = std::fs::remove_dir_all(dir);
595    }
596
597    #[test]
598    /// What: Optional Deps shows Wayland clipboard (`wl-clipboard`) when `WAYLAND_DISPLAY` is set
599    ///
600    /// - Setup: Empty PATH; set `WAYLAND_DISPLAY`
601    /// - Expect: A row "Clipboard: wl-clipboard" with note "Wayland", not installed and selectable
602    fn optional_deps_rows_wayland_shows_wl_clipboard() {
603        use std::collections::HashMap;
604        use std::fs;
605        use std::path::PathBuf;
606        let _guard = crate::global_test_mutex_lock();
607
608        // Temp PATH directory (empty)
609        let mut dir: PathBuf = std::env::temp_dir();
610        dir.push(format!(
611            "pacsea_test_optional_deps_wl_{}_{}",
612            std::process::id(),
613            std::time::SystemTime::now()
614                .duration_since(std::time::UNIX_EPOCH)
615                .expect("System time is before UNIX epoch")
616                .as_nanos()
617        ));
618        let _ = fs::create_dir_all(&dir);
619
620        let orig_path = std::env::var_os("PATH");
621        unsafe {
622            std::env::set_var("PATH", dir.display().to_string());
623            std::env::set_var("PACSEA_TEST_HEADLESS", "1");
624        };
625        let orig_wl = std::env::var_os("WAYLAND_DISPLAY");
626        unsafe { std::env::set_var("WAYLAND_DISPLAY", "1") };
627
628        let mut app = AppState::default();
629        // Initialize i18n translations for optional deps
630        let mut translations = HashMap::new();
631        translations.insert(
632            "app.optional_deps.categories.editor".to_string(),
633            "Editor".to_string(),
634        );
635        translations.insert(
636            "app.optional_deps.categories.terminal".to_string(),
637            "Terminal".to_string(),
638        );
639        translations.insert(
640            "app.optional_deps.categories.clipboard".to_string(),
641            "Clipboard".to_string(),
642        );
643        translations.insert(
644            "app.optional_deps.categories.aur_helper".to_string(),
645            "AUR Helper".to_string(),
646        );
647        translations.insert(
648            "app.optional_deps.categories.security".to_string(),
649            "Security".to_string(),
650        );
651        app.translations.clone_from(&translations);
652        app.translations_fallback = translations;
653        let (qtx, _qrx) = mpsc::unbounded_channel();
654        let (dtx, _drx) = mpsc::unbounded_channel();
655        let (ptx, _prx) = mpsc::unbounded_channel();
656        let (atx, _arx) = mpsc::unbounded_channel();
657        let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel();
658
659        // Open Options via click
660        app.options_button_rect = Some((5, 5, 12, 1));
661        let click_options = CEvent::Mouse(crossterm::event::MouseEvent {
662            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
663            column: 6,
664            row: 5,
665            modifiers: KeyModifiers::empty(),
666        });
667        let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
668        let _ = super::handle_event(
669            &click_options,
670            &mut app,
671            &qtx,
672            &dtx,
673            &ptx,
674            &atx,
675            &pkgb_tx,
676            &comments_tx,
677        );
678        assert!(app.options_menu_open);
679
680        // Press '3' to open Optional Deps (Package mode: List installed=1, Update system=2, TUI Optional Deps=3, News management=4)
681        let mut key_three_event =
682            crossterm::event::KeyEvent::new(KeyCode::Char('3'), KeyModifiers::empty());
683        key_three_event.kind = KeyEventKind::Press;
684        let key_three = CEvent::Key(key_three_event);
685        let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
686        let _ = super::handle_event(
687            &key_three,
688            &mut app,
689            &qtx,
690            &dtx,
691            &ptx,
692            &atx,
693            &pkgb_tx,
694            &comments_tx,
695        );
696
697        match &app.modal {
698            crate::state::Modal::OptionalDeps { rows, .. } => {
699                let clip = rows
700                    .iter()
701                    .find(|r| r.label.starts_with("Clipboard: wl-clipboard"))
702                    .expect("wl-clipboard row");
703                assert_eq!(clip.note.as_deref(), Some("Wayland"));
704                assert!(!clip.installed);
705                assert!(clip.selectable);
706                // Ensure xclip is not presented when Wayland is active
707                assert!(
708                    !rows.iter().any(|r| r.label.starts_with("Clipboard: xclip")),
709                    "xclip should not be listed on Wayland"
710                );
711            }
712            other => panic!("Expected OptionalDeps modal, got {other:?}"),
713        }
714
715        // Restore env and cleanup
716        unsafe {
717            if let Some(v) = orig_path {
718                std::env::set_var("PATH", v);
719            } else {
720                std::env::remove_var("PATH");
721            }
722            if let Some(v) = orig_wl {
723                std::env::set_var("WAYLAND_DISPLAY", v);
724            } else {
725                std::env::remove_var("WAYLAND_DISPLAY");
726            }
727        }
728        let _ = fs::remove_dir_all(&dir);
729    }
730}