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