1use 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;
13mod install;
15mod modals;
16mod mouse;
17mod preflight;
18mod recent;
20mod search;
21pub mod utils;
23
24pub use search::open_preflight_modal;
26
27pub use preflight::start_execution;
29
30#[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 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 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; }
89 return false;
91 }
92
93 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 if matches!(app.modal, crate::state::Modal::Preflight { .. }) {
102 return preflight::handle_preflight_key(*ke, app);
103 }
104
105 if modals::handle_modal_key(*ke, app, add_tx) {
107 return false;
108 }
109
110 if !matches!(app.modal, crate::state::Modal::None) {
112 return false;
113 }
114
115 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 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 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 return false;
145 }
146
147 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 fn ui_options_update_system_enter_triggers_xfce4_args_shape() {
186 let _guard = crate::global_test_mutex_lock();
187 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 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 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 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 let command_idx = lines.iter().rposition(|&l| l == "--command");
291 if command_idx.is_none() {
292 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 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 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 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 SetupAppResult = (AppState, AppChannels);
418
419 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 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 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 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 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 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 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 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 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 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 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 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}