Skip to main content

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