use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
use crate::app::{apply_settings_to_app_state, initialize_locale_system};
use crate::events::mouse::menus::{handle_mode_toggle, handle_news_age_toggle};
use crate::events::utils;
use crate::state::{AppState, PackageItem, PkgbuildCheckRequest};
use crate::theme::{reload_theme, settings};
#[allow(clippy::missing_const_for_fn)]
fn close_all_dropdowns(app: &mut AppState) -> bool {
let any_open = app.sort_menu_open
|| app.options_menu_open
|| app.panels_menu_open
|| app.config_menu_open
|| app.artix_filter_menu_open
|| app.custom_repos_filter_menu_open;
if any_open {
app.sort_menu_open = false;
app.sort_menu_auto_close_at = None;
app.options_menu_open = false;
app.panels_menu_open = false;
app.config_menu_open = false;
app.artix_filter_menu_open = false;
app.custom_repos_filter_menu_open = false;
true
} else {
false
}
}
fn handle_options_installed_only_toggle(
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
) {
use std::collections::HashSet;
if app.installed_only_mode {
if let Some(prev) = app.results_backup_for_toggle.take() {
app.all_results = prev;
}
app.installed_only_mode = false;
app.right_pane_focus = crate::state::RightPaneFocus::Install;
crate::logic::apply_filters_and_sort_preserve_selection(app);
utils::refresh_selected_details(app, details_tx);
} else {
app.results_backup_for_toggle = Some(app.all_results.clone());
let explicit = crate::index::explicit_names();
let mut items: Vec<crate::state::PackageItem> = crate::index::all_official()
.into_iter()
.filter(|p| explicit.contains(&p.name))
.collect();
let official_names: HashSet<String> = items.iter().map(|p| p.name.clone()).collect();
for name in explicit {
if !official_names.contains(&name) {
let is_eos = crate::index::is_eos_name(&name);
let src = if is_eos {
crate::state::Source::Official {
repo: "EOS".to_string(),
arch: String::new(),
}
} else {
crate::state::Source::Aur
};
items.push(crate::state::PackageItem {
name: name.clone(),
version: String::new(),
description: String::new(),
source: src,
popularity: None,
out_of_date: None,
orphaned: false,
});
}
}
app.all_results = items;
app.installed_only_mode = true;
app.right_pane_focus = crate::state::RightPaneFocus::Remove;
crate::logic::apply_filters_and_sort_preserve_selection(app);
utils::refresh_selected_details(app, details_tx);
let path = crate::theme::config_dir().join("installed_packages.txt");
let names = crate::index::query_explicit_packages_sync(app.installed_packages_mode);
let body = names.join("\n");
let _ = std::fs::write(path, body);
}
}
fn handle_options_system_update(app: &mut AppState) {
let countries = vec![
"Worldwide".to_string(),
"Germany".to_string(),
"United States".to_string(),
"United Kingdom".to_string(),
"France".to_string(),
"Netherlands".to_string(),
"Sweden".to_string(),
"Canada".to_string(),
"Australia".to_string(),
"Japan".to_string(),
];
let prefs = crate::theme::settings();
let initial_country_idx = {
let sel = prefs
.selected_countries
.split(',')
.next()
.map_or_else(|| "Worldwide".to_string(), |s| s.trim().to_string());
countries.iter().position(|c| c == &sel).unwrap_or(0)
};
app.modal = crate::state::Modal::SystemUpdate {
do_mirrors: false,
do_pacman: true,
force_sync: false,
do_aur: true,
do_cache: false,
country_idx: initial_country_idx,
countries,
mirror_count: prefs.mirror_count,
cursor: 0,
};
}
fn handle_options_optional_deps(app: &mut AppState) {
let rows = crate::events::mouse::menu_options::build_optional_deps_rows(app);
app.modal = crate::state::Modal::OptionalDeps {
rows,
selected: 0,
selected_pkg_names: std::collections::HashSet::new(),
};
let ssh_command = crate::theme::settings().aur_vote_ssh_command;
app.pending_aur_ssh_help_check_result = Some(
crate::logic::ssh_setup::spawn_aur_ssh_help_check(ssh_command),
);
}
fn handle_options_repositories(app: &mut AppState) {
#[cfg(not(target_os = "linux"))]
{
app.modal = crate::state::Modal::Alert {
message: crate::i18n::t(app, "app.modals.repositories.unsupported_platform"),
};
return;
}
#[cfg(target_os = "linux")]
{
let (rows, repos_conf_error, pacman_warnings) =
crate::logic::repos::build_repositories_modal_fields_default();
app.modal = crate::state::Modal::Repositories {
rows,
selected: 0,
scroll: 0,
repos_conf_error,
pacman_warnings,
};
}
}
fn handle_panels_menu_selection(idx: usize, app: &mut AppState) {
let news_mode = matches!(app.app_mode, crate::state::types::AppMode::News);
if news_mode {
match idx {
0 => {
app.show_news_history_pane = !app.show_news_history_pane;
if !app.show_news_history_pane && matches!(app.focus, crate::state::Focus::Recent) {
app.focus = crate::state::Focus::Search;
}
}
1 => {
app.show_news_bookmarks_pane = !app.show_news_bookmarks_pane;
if !app.show_news_bookmarks_pane
&& matches!(app.focus, crate::state::Focus::Install)
{
app.focus = crate::state::Focus::Search;
}
}
2 => {
app.show_keybinds_footer = !app.show_keybinds_footer;
crate::theme::save_show_keybinds_footer(app.show_keybinds_footer);
}
_ => {}
}
} else {
match idx {
0 => {
app.show_recent_pane = !app.show_recent_pane;
if !app.show_recent_pane && matches!(app.focus, crate::state::Focus::Recent) {
app.focus = crate::state::Focus::Search;
}
crate::theme::save_show_recent_pane(app.show_recent_pane);
}
1 => {
app.show_install_pane = !app.show_install_pane;
if !app.show_install_pane && matches!(app.focus, crate::state::Focus::Install) {
app.focus = crate::state::Focus::Search;
}
crate::theme::save_show_install_pane(app.show_install_pane);
}
2 => {
app.show_keybinds_footer = !app.show_keybinds_footer;
crate::theme::save_show_keybinds_footer(app.show_keybinds_footer);
}
_ => {}
}
}
}
const fn normalize_key_modifiers(ke: &KeyEvent) -> KeyModifiers {
if matches!(ke.code, KeyCode::BackTab) {
KeyModifiers::empty()
} else {
ke.modifiers
}
}
const fn create_key_chord(ke: &KeyEvent) -> (KeyCode, KeyModifiers) {
(ke.code, normalize_key_modifiers(ke))
}
fn matches_keybind(ke: &KeyEvent, chords: &[crate::theme::KeyChord]) -> bool {
let chord = create_key_chord(ke);
chords.iter().any(|c| (c.code, c.mods) == chord)
}
fn handle_escape(app: &mut AppState) -> Option<bool> {
if close_all_dropdowns(app) {
Some(false)
} else {
None
}
}
fn handle_help_overlay(app: &mut AppState) -> bool {
app.modal = crate::state::Modal::Help;
false
}
fn handle_reload_config(
app: &mut AppState,
query_tx: &mpsc::UnboundedSender<crate::state::QueryInput>,
) -> bool {
let mut errors = Vec::new();
let new_settings = settings();
let old_locale = app.locale.clone();
if let Err(msg) = reload_theme() {
errors.push(format!("Theme reload failed: {msg}"));
}
crate::logic::repos::load_repos_config_into_app(app, crate::theme::resolve_repos_config_path());
let old_installed_mode = app.installed_packages_mode;
apply_settings_to_app_state(app, &new_settings);
if new_settings.locale != old_locale {
initialize_locale_system(app, &new_settings.locale, &new_settings);
}
if app.installed_packages_mode != old_installed_mode {
let new_mode = app.installed_packages_mode;
tracing::info!(
"[Config] installed_packages_mode changed from {:?} to {:?}, refreshing cache",
old_installed_mode,
new_mode
);
let id = app.next_query_id;
app.next_query_id += 1;
app.latest_query_id = id;
let query_input = crate::state::QueryInput {
id,
text: app.input.clone(),
fuzzy: app.fuzzy_search_enabled,
};
let query_tx_clone = query_tx.clone();
tokio::spawn(async move {
crate::index::refresh_explicit_cache(new_mode).await;
let _ = query_tx_clone.send(query_input);
});
}
if errors.is_empty() {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.config_reloaded"));
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
} else {
app.modal = crate::state::Modal::Alert {
message: errors.join("\n"),
};
}
false
}
const fn handle_exit() -> bool {
true
}
fn handle_toggle_pkgbuild(
app: &mut AppState,
pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
) -> bool {
if app.pkgb_visible {
app.pkgb_visible = false;
app.pkgb_text = None;
app.pkgb_package_name = None;
app.pkgb_scroll = 0;
app.pkgb_section_cycle = 0;
app.pkgb_rect = None;
} else {
app.pkgb_visible = true;
app.pkgb_text = None;
app.pkgb_package_name = None;
if let Some(item) = app.results.get(app.selected).cloned() {
let _ = pkgb_tx.send(item);
}
}
false
}
fn handle_run_pkgbuild_checks(
app: &mut AppState,
pkgb_check_tx: &mpsc::UnboundedSender<PkgbuildCheckRequest>,
) -> bool {
let Some(text) = app.pkgb_text.clone() else {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.pkgbuild_not_loaded"));
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
return false;
};
let package_name = app
.results
.get(app.selected)
.map_or_else(String::new, |item| item.name.clone());
app.pkgb_check_last_package_name = Some(package_name.clone());
app.pkgb_check_status = crate::state::app_state::PkgbuildCheckStatus::Running;
app.pkgb_check_findings.clear();
app.pkgb_check_raw_results.clear();
app.pkgb_check_missing_tools.clear();
app.pkgb_check_last_error = None;
app.pkgb_check_scroll = 0;
app.pkgb_check_raw_scroll = 0;
app.pkgb_scroll = u16::MAX;
app.toast_message = Some("Running PKGBUILD checks...".to_string());
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(2));
if let Err(err) = pkgb_check_tx.send(PkgbuildCheckRequest {
package_name,
pkgbuild_text: text,
dry_run: app.dry_run,
}) {
app.pkgb_check_status = crate::state::app_state::PkgbuildCheckStatus::Complete;
app.pkgb_check_last_error = Some(format!("failed to queue PKGBUILD checks: {err}"));
app.toast_message = Some("Failed to start PKGBUILD checks".to_string());
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
}
false
}
fn handle_cycle_pkgbuild_sections(app: &mut AppState) -> bool {
if !app.pkgb_visible {
return false;
}
crate::ui::cycle_pkgbuild_view_section(app);
false
}
fn handle_toggle_comments(app: &mut AppState, comments_tx: &mpsc::UnboundedSender<String>) -> bool {
let is_aur = app
.results
.get(app.selected)
.is_some_and(|item| matches!(item.source, crate::state::Source::Aur));
if !is_aur {
return false;
}
if app.comments_visible {
app.comments_visible = false;
app.comments.clear();
app.comments_package_name = None;
app.comments_fetched_at = None;
app.comments_scroll = 0;
app.comments_rect = None;
app.comments_loading = false;
app.comments_error = None;
} else {
app.comments_visible = true;
app.comments_scroll = 0;
app.comments_error = None;
if let Some(item) = app.results.get(app.selected) {
if app
.comments_package_name
.as_ref()
.is_some_and(|cached_name| cached_name == &item.name && !app.comments.is_empty())
{
app.comments_loading = false;
return false;
}
app.comments.clear();
app.comments_package_name = None;
app.comments_fetched_at = None;
app.comments_loading = true;
let _ = comments_tx.send(item.name.clone());
}
}
false
}
fn handle_change_sort(app: &mut AppState, details_tx: &mpsc::UnboundedSender<PackageItem>) -> bool {
if matches!(app.app_mode, crate::state::types::AppMode::News) {
use crate::state::types::NewsSortMode;
app.news_sort_mode = match app.news_sort_mode {
NewsSortMode::DateDesc => NewsSortMode::DateAsc,
NewsSortMode::DateAsc => NewsSortMode::Title,
NewsSortMode::Title => NewsSortMode::SourceThenTitle,
NewsSortMode::SourceThenTitle => NewsSortMode::SeverityThenDate,
NewsSortMode::SeverityThenDate => NewsSortMode::UnreadThenDate,
NewsSortMode::UnreadThenDate => NewsSortMode::DateDesc,
};
app.refresh_news_results();
} else {
app.sort_mode = match app.sort_mode {
crate::state::SortMode::RepoThenName => {
crate::state::SortMode::AurPopularityThenOfficial
}
crate::state::SortMode::AurPopularityThenOfficial => {
crate::state::SortMode::BestMatches
}
crate::state::SortMode::BestMatches => crate::state::SortMode::RepoThenName,
};
crate::theme::save_sort_mode(app.sort_mode);
crate::logic::sort_results_preserve_selection(app);
if app.results.is_empty() {
app.list_state.select(None);
} else {
app.selected = 0;
app.list_state.select(Some(0));
utils::refresh_selected_details(app, details_tx);
}
}
app.sort_menu_open = true;
false
}
fn handle_options_menu_numeric(
idx: usize,
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
) -> Option<bool> {
let news_mode = matches!(app.app_mode, crate::state::types::AppMode::News);
let handled = if news_mode {
match idx {
0 => {
handle_options_system_update(app);
true
}
1 => {
handle_options_optional_deps(app);
true
}
2 => {
handle_options_repositories(app);
true
}
3 => {
handle_mode_toggle(app, details_tx);
true
}
4 => {
handle_news_age_toggle(app);
true
}
_ => false,
}
} else {
match idx {
0 => {
handle_options_installed_only_toggle(app, details_tx);
true
}
1 => {
handle_options_system_update(app);
true
}
2 => {
handle_options_optional_deps(app);
true
}
3 => {
handle_options_repositories(app);
true
}
4 => {
handle_mode_toggle(app, details_tx);
true
}
_ => false,
}
};
if handled {
app.options_menu_open = false;
Some(false)
} else {
None
}
}
fn handle_panels_menu_numeric(idx: usize, app: &mut AppState) -> bool {
handle_panels_menu_selection(idx, app);
false
}
fn handle_config_menu_numeric(idx: usize, app: &mut AppState) -> bool {
handle_config_menu_selection(idx, app);
false
}
fn handle_menu_numeric_selection(
ch: char,
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
) -> Option<bool> {
let idx = (ch as u8 - b'1') as usize; if app.options_menu_open {
handle_options_menu_numeric(idx, app, details_tx)
} else if app.panels_menu_open {
Some(handle_panels_menu_numeric(idx, app))
} else if app.config_menu_open {
Some(handle_config_menu_numeric(idx, app))
} else {
None
}
}
fn handle_global_keybinds(
ke: &KeyEvent,
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
comments_tx: &mpsc::UnboundedSender<String>,
query_tx: &mpsc::UnboundedSender<crate::state::QueryInput>,
pkgb_check_tx: &mpsc::UnboundedSender<PkgbuildCheckRequest>,
) -> Option<bool> {
let km = &app.keymap;
if matches_keybind(ke, &km.exit) {
return Some(handle_exit());
}
if !matches!(
app.modal,
crate::state::Modal::None | crate::state::Modal::Preflight { .. }
) {
return None; }
if ke.code == KeyCode::Char('t') && ke.modifiers.contains(KeyModifiers::CONTROL) {
tracing::debug!(
"[Keybind] Ctrl+T detected: code={:?}, mods={:?}, keybind_match={}, comments_toggle_keybinds={:?}",
ke.code,
ke.modifiers,
matches_keybind(ke, &km.comments_toggle),
km.comments_toggle
);
}
if matches_keybind(ke, &km.comments_toggle) {
tracing::debug!("[Keybind] Comments toggle matched, calling handle_toggle_comments");
return Some(handle_toggle_comments(app, comments_tx));
}
if !matches!(app.modal, crate::state::Modal::Preflight { .. })
&& matches_keybind(ke, &km.help_overlay)
{
return Some(handle_help_overlay(app));
}
if matches!(app.modal, crate::state::Modal::None) && matches_keybind(ke, &km.reload_config) {
return Some(handle_reload_config(app, query_tx));
}
if matches_keybind(ke, &km.exit) {
return Some(handle_exit());
}
if matches!(app.modal, crate::state::Modal::None) && matches_keybind(ke, &km.show_pkgbuild) {
return Some(handle_toggle_pkgbuild(app, pkgb_tx));
}
if matches!(app.modal, crate::state::Modal::None)
&& matches_keybind(ke, &km.run_pkgbuild_checks)
{
return Some(handle_run_pkgbuild_checks(app, pkgb_check_tx));
}
if matches!(app.modal, crate::state::Modal::None)
&& matches_keybind(ke, &km.cycle_pkgbuild_sections)
{
return Some(handle_cycle_pkgbuild_sections(app));
}
if matches!(app.modal, crate::state::Modal::None) && matches_keybind(ke, &km.change_sort) {
return Some(handle_change_sort(app, details_tx));
}
None
}
fn handle_config_menu_selection(idx: usize, app: &mut AppState) {
let settings_path = crate::theme::config_dir().join("settings.conf");
let theme_path = crate::theme::config_dir().join("theme.conf");
let keybinds_path = crate::theme::config_dir().join("keybinds.conf");
let repos_path = crate::theme::config_dir().join("repos.conf");
let target = match idx {
0 => settings_path,
1 => theme_path,
2 => keybinds_path,
3 => repos_path,
_ => {
app.config_menu_open = false;
app.artix_filter_menu_open = false;
app.custom_repos_filter_menu_open = false;
return;
}
};
#[cfg(target_os = "windows")]
{
crate::util::open_file(&target);
}
#[cfg(not(target_os = "windows"))]
{
let editor_cmd = crate::install::editor_open_config_command(&target);
let cmds = vec![editor_cmd];
std::thread::spawn(move || {
crate::install::spawn_shell_commands_in_terminal(&cmds);
});
}
app.config_menu_open = false;
app.artix_filter_menu_open = false;
app.custom_repos_filter_menu_open = false;
}
pub(super) fn handle_global_key(
ke: KeyEvent,
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
comments_tx: &mpsc::UnboundedSender<String>,
query_tx: &mpsc::UnboundedSender<crate::state::QueryInput>,
pkgb_check_tx: &mpsc::UnboundedSender<PkgbuildCheckRequest>,
) -> Option<bool> {
if ke.code == KeyCode::Esc
&& let Some(result) = handle_escape(app)
{
return Some(result);
}
if let Some(result) = handle_global_keybinds(
&ke,
app,
details_tx,
pkgb_tx,
comments_tx,
query_tx,
pkgb_check_tx,
) {
return Some(result);
}
if let KeyCode::Char(ch) = ke.code
&& ch.is_ascii_digit()
&& ch != '0'
&& let Some(result) = handle_menu_numeric_selection(ch, app, details_tx)
{
return Some(result);
}
None }
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn new_app() -> AppState {
AppState::default()
}
#[test]
fn global_escape_closes_dropdowns() {
let mut app = new_app();
app.sort_menu_open = true;
app.options_menu_open = true;
app.panels_menu_open = true;
app.config_menu_open = true;
let (details_tx, _details_rx) = mpsc::unbounded_channel::<PackageItem>();
let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel::<PackageItem>();
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let (query_tx, _query_rx) = mpsc::unbounded_channel::<crate::state::QueryInput>();
let (pkgb_check_tx, _pkgb_check_rx) =
mpsc::unbounded_channel::<crate::state::PkgbuildCheckRequest>();
let exit = handle_global_key(
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
&mut app,
&details_tx,
&pkgb_tx,
&comments_tx,
&query_tx,
&pkgb_check_tx,
);
assert_eq!(exit, Some(false));
assert!(!app.sort_menu_open);
assert!(!app.options_menu_open);
assert!(!app.panels_menu_open);
assert!(!app.config_menu_open);
}
#[test]
fn global_help_overlay_opens_modal() {
let mut app = new_app();
let (details_tx, _details_rx) = mpsc::unbounded_channel::<PackageItem>();
let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel::<PackageItem>();
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let (query_tx, _query_rx) = mpsc::unbounded_channel::<crate::state::QueryInput>();
let (pkgb_check_tx, _pkgb_check_rx) =
mpsc::unbounded_channel::<crate::state::PkgbuildCheckRequest>();
let exit = handle_global_key(
KeyEvent::new(KeyCode::F(1), KeyModifiers::empty()),
&mut app,
&details_tx,
&pkgb_tx,
&comments_tx,
&query_tx,
&pkgb_check_tx,
);
assert_eq!(exit, Some(false));
assert!(matches!(app.modal, crate::state::Modal::Help));
}
#[test]
fn global_show_pkgbuild_requests_content() {
let mut app = new_app();
app.results = vec![PackageItem {
name: "ripgrep".into(),
version: "14.0".into(),
description: "fast search".into(),
source: crate::state::Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
}];
app.selected = 0;
let (details_tx, _details_rx) = mpsc::unbounded_channel::<PackageItem>();
let (pkgb_tx, mut pkgb_rx) = mpsc::unbounded_channel::<PackageItem>();
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let (query_tx, _query_rx) = mpsc::unbounded_channel::<crate::state::QueryInput>();
let (pkgb_check_tx, _pkgb_check_rx) =
mpsc::unbounded_channel::<crate::state::PkgbuildCheckRequest>();
let exit = handle_global_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
&mut app,
&details_tx,
&pkgb_tx,
&comments_tx,
&query_tx,
&pkgb_check_tx,
);
assert_eq!(exit, Some(false));
assert!(app.pkgb_visible);
let sent = pkgb_rx.try_recv().expect("pkgb request dispatched");
assert_eq!(sent.name, "ripgrep");
}
#[test]
fn global_exit_chord_requests_shutdown() {
let mut app = new_app();
let (details_tx, _details_rx) = mpsc::unbounded_channel::<PackageItem>();
let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel::<PackageItem>();
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let (query_tx, _query_rx) = mpsc::unbounded_channel::<crate::state::QueryInput>();
let (pkgb_check_tx, _pkgb_check_rx) =
mpsc::unbounded_channel::<crate::state::PkgbuildCheckRequest>();
let exit = handle_global_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut app,
&details_tx,
&pkgb_tx,
&comments_tx,
&query_tx,
&pkgb_check_tx,
);
assert_eq!(exit, Some(true));
}
}