use crossterm::event::KeyEvent;
use tokio::sync::mpsc;
use crate::state::{AppState, PackageItem};
use std::time::Instant;
#[must_use]
pub fn matches_any(ke: &KeyEvent, list: &[crate::theme::KeyChord]) -> bool {
list.iter().any(|c| {
if (c.code, c.mods) == (ke.code, ke.modifiers) {
return true;
}
match (c.code, ke.code) {
(crossterm::event::KeyCode::Char(cfg_ch), crossterm::event::KeyCode::Char(ev_ch)) => {
let cfg_has_shift = c.mods.contains(crossterm::event::KeyModifiers::SHIFT);
if !cfg_has_shift {
return false;
}
if ev_ch == cfg_ch.to_ascii_uppercase() {
return true;
}
if ke.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
&& ev_ch.to_ascii_lowercase() == cfg_ch
{
return true;
}
false
}
_ => false,
}
})
}
#[must_use]
pub fn char_count(s: &str) -> usize {
s.chars().count()
}
#[must_use]
pub fn byte_index_for_char(s: &str, ci: usize) -> usize {
let cc = char_count(s);
if ci == 0 {
return 0;
}
if ci >= cc {
return s.len();
}
s.char_indices()
.map(|(i, _)| i)
.nth(ci)
.map_or(s.len(), |i| i)
}
pub fn find_in_recent(app: &mut AppState, forward: bool) {
let Some(pattern) = app.pane_find.clone() else {
return;
};
let inds = crate::ui::helpers::filtered_recent_indices(app);
if inds.is_empty() {
return;
}
let start = app.history_state.selected().unwrap_or(0);
let mut vi = start;
let n = inds.len();
for _ in 0..n {
vi = if forward {
(vi + 1) % n
} else if vi == 0 {
n - 1
} else {
vi - 1
};
let i = inds[vi];
if let Some(s) = app.recent_value_at(i)
&& s.to_lowercase().contains(&pattern.to_lowercase())
{
app.history_state.select(Some(vi));
break;
}
}
}
pub fn find_in_install(app: &mut AppState, forward: bool) {
let Some(pattern) = app.pane_find.clone() else {
return;
};
let inds = crate::ui::helpers::filtered_install_indices(app);
if inds.is_empty() {
return;
}
let start = app.install_state.selected().unwrap_or(0);
let mut vi = start;
let n = inds.len();
for _ in 0..n {
vi = if forward {
(vi + 1) % n
} else if vi == 0 {
n - 1
} else {
vi - 1
};
let i = inds[vi];
if let Some(p) = app.install_list.get(i)
&& (p.name.to_lowercase().contains(&pattern.to_lowercase())
|| p.description
.to_lowercase()
.contains(&pattern.to_lowercase()))
{
app.install_state.select(Some(vi));
break;
}
}
}
pub fn refresh_selected_details(
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
) {
if let Some(item) = app.results.get(app.selected).cloned() {
app.details_scroll = 0;
if let Some(cached) = app.details_cache.get(&item.name).cloned() {
app.details = cached;
} else {
let _ = details_tx.send(item);
}
queue_selected_aur_vote_state_check(app);
}
}
pub fn queue_selected_aur_vote_state_check(app: &mut AppState) {
let settings = crate::theme::settings();
if !settings.aur_vote_enabled {
return;
}
if !app.aur_vote_state_lookup_supported {
return;
}
let Some(item) = app.results.get(app.selected) else {
return;
};
if !matches!(item.source, crate::state::Source::Aur) {
return;
}
let pkgbase = item.name.clone();
if let Some(previous) = app.pending_aur_vote_state_request.replace(pkgbase.clone())
&& previous != pkgbase
&& matches!(
app.aur_vote_state_by_pkgbase.get(&previous),
Some(crate::state::app_state::AurVoteStateUi::Loading)
)
{
app.aur_vote_state_by_pkgbase
.insert(previous, crate::state::app_state::AurVoteStateUi::Unknown);
}
let should_mark_loading = !matches!(
app.aur_vote_state_by_pkgbase.get(&pkgbase),
Some(
crate::state::app_state::AurVoteStateUi::Voted
| crate::state::app_state::AurVoteStateUi::NotVoted
)
);
if should_mark_loading {
app.aur_vote_state_by_pkgbase
.insert(pkgbase, crate::state::app_state::AurVoteStateUi::Loading);
}
}
pub fn move_sel_cached_with_vote_state(
app: &mut AppState,
delta: isize,
details_tx: &mpsc::UnboundedSender<PackageItem>,
comments_tx: &mpsc::UnboundedSender<String>,
) {
crate::logic::move_sel_cached(app, delta, details_tx, comments_tx);
queue_selected_aur_vote_state_check(app);
}
pub fn move_news_selection(app: &mut AppState, delta: isize) {
if app.news_results.is_empty() {
app.news_selected = 0;
app.news_list_state.select(None);
app.details.url.clear();
return;
}
let len = app.news_results.len();
if app.news_selected >= len {
app.news_selected = len.saturating_sub(1);
}
app.news_list_state.select(Some(app.news_selected));
let steps = delta.unsigned_abs();
for _ in 0..steps {
if delta.is_negative() {
app.news_list_state.select_previous();
} else {
app.news_list_state.select_next();
}
}
let sel = app.news_list_state.selected().unwrap_or(0);
app.news_selected = std::cmp::min(sel, len.saturating_sub(1));
app.news_list_state.select(Some(app.news_selected));
update_news_url(app);
}
#[must_use]
pub fn compute_updates_modal_scroll_for_selection(
entry_line_starts: &[u16],
total_lines: u16,
content_rect: Option<(u16, u16, u16, u16)>,
selected: usize,
total_items: usize,
current_scroll: u16,
) -> u16 {
let selected_line = entry_line_starts
.get(selected)
.copied()
.unwrap_or_else(|| u16::try_from(selected).unwrap_or(u16::MAX));
let visible_lines = content_rect.map_or(1, |(_, _, _, h)| h.max(1));
let mut scroll = current_scroll;
if selected_line < scroll {
scroll = selected_line;
} else if selected_line >= scroll.saturating_add(visible_lines) {
scroll = selected_line.saturating_sub(visible_lines.saturating_sub(1));
}
let fallback_total = u16::try_from(total_items).unwrap_or(u16::MAX);
let max_scroll = total_lines
.max(fallback_total)
.saturating_sub(visible_lines);
scroll.min(max_scroll)
}
#[must_use]
pub fn compute_updates_filtered_indices(
entries: &[(String, String, String)],
query: &str,
) -> Vec<usize> {
let normalized = query.trim();
if normalized.is_empty() {
return (0..entries.len()).collect();
}
let query_lower = normalized.to_lowercase();
entries
.iter()
.enumerate()
.filter_map(|(idx, (name, _, _))| {
let source_label = if crate::index::find_package_by_name(name).is_some() {
"pacman"
} else {
"aur"
};
let name_lower = name.to_lowercase();
let matches_name = crate::util::fuzzy_match_rank(&name_lower, &query_lower).is_some();
let matches_source =
crate::util::fuzzy_match_rank(source_label, &query_lower).is_some();
if matches_name || matches_source {
Some(idx)
} else {
None
}
})
.collect()
}
pub fn update_news_url(app: &mut AppState) {
if let Some(item) = app.news_results.get(app.news_selected)
&& let Some(url) = &item.url
{
app.details.url.clone_from(url);
let mut cached = app.news_content_cache.get(url).cloned();
if let Some(ref c) = cached
&& url.contains("://archlinux.org/packages/")
&& !c.starts_with("Package Info:")
{
cached = None;
tracing::debug!(
url,
"news content cache missing package metadata; will refetch"
);
}
app.news_content = cached;
if app.news_content.is_some() {
tracing::debug!(url, "news content served from cache");
} else {
app.news_content_debounce_timer = Some(std::time::Instant::now());
tracing::debug!(url, "news content not cached, setting debounce timer");
}
app.news_content_scroll = 0;
} else {
app.details.url.clear();
app.news_content = None;
app.news_content_debounce_timer = None;
}
app.news_content_loading = false;
}
pub fn maybe_request_news_content(
app: &mut AppState,
news_content_req_tx: &mpsc::UnboundedSender<String>,
) {
if !matches!(app.app_mode, crate::state::types::AppMode::News) {
tracing::trace!("news_content: skip request, not in news mode");
return;
}
if app.news_content_loading {
tracing::debug!(
selected = app.news_selected,
"news_content: skip request, already loading"
);
return;
}
if let Some(item) = app.news_results.get(app.news_selected)
&& let Some(url) = &item.url
&& app.news_content.is_none()
&& !app.news_content_cache.contains_key(url)
{
const DEBOUNCE_DELAY_MS: u64 = 500;
if let Some(timer) = app.news_content_debounce_timer {
#[allow(clippy::cast_possible_truncation)]
let elapsed = timer.elapsed().as_millis() as u64;
if elapsed < DEBOUNCE_DELAY_MS {
tracing::trace!(
selected = app.news_selected,
url,
elapsed_ms = elapsed,
remaining_ms = DEBOUNCE_DELAY_MS - elapsed,
"news_content: debounce timer not expired, waiting"
);
return;
}
app.news_content_debounce_timer = None;
} else {
app.news_content_debounce_timer = Some(std::time::Instant::now());
tracing::debug!(
selected = app.news_selected,
url,
"news_content: no debounce timer, setting one now"
);
return;
}
app.news_content_loading = true;
app.news_content_loading_since = Some(Instant::now());
tracing::debug!(
selected = app.news_selected,
title = item.title,
url,
"news_content: requesting article content (debounce expired)"
);
if let Err(e) = news_content_req_tx.send(url.clone()) {
tracing::warn!(
error = %e,
selected = app.news_selected,
title = item.title,
url,
"news_content: failed to enqueue content request"
);
app.news_content_loading = false;
app.news_content_loading_since = None;
app.news_content = Some(format!("Failed to load content: {e}"));
app.toast_message = Some("News content request failed".to_string());
app.toast_expires_at = Some(Instant::now() + std::time::Duration::from_secs(3));
}
} else {
tracing::trace!(
selected = app.news_selected,
has_item = app.news_results.get(app.news_selected).is_some(),
has_url = app
.news_results
.get(app.news_selected)
.and_then(|it| it.url.as_ref())
.is_some(),
content_cached = app
.news_results
.get(app.news_selected)
.and_then(|it| it.url.as_ref())
.is_some_and(|u| app.news_content_cache.contains_key(u)),
has_content = app.news_content.is_some(),
"news_content: skip request (cached/absent URL/already loaded)"
);
}
}
pub fn refresh_install_details(
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
) {
let Some(vsel) = app.install_state.selected() else {
return;
};
let inds = crate::ui::helpers::filtered_install_indices(app);
if inds.is_empty() || vsel >= inds.len() {
return;
}
let i = inds[vsel];
if let Some(item) = app.install_list.get(i).cloned() {
app.details_scroll = 0;
app.details_focus = Some(item.name.clone());
app.details.name.clone_from(&item.name);
app.details.version.clone_from(&item.version);
app.details.description.clear();
match &item.source {
crate::state::Source::Official { repo, arch } => {
app.details.repository.clone_from(repo);
app.details.architecture.clone_from(arch);
}
crate::state::Source::Aur => {
app.details.repository = "AUR".to_string();
app.details.architecture = "any".to_string();
}
}
if let Some(cached) = app.details_cache.get(&item.name).cloned() {
app.details = cached;
} else {
let _ = details_tx.send(item);
}
}
}
pub fn refresh_remove_details(app: &mut AppState, details_tx: &mpsc::UnboundedSender<PackageItem>) {
let Some(vsel) = app.remove_state.selected() else {
return;
};
if app.remove_list.is_empty() || vsel >= app.remove_list.len() {
return;
}
if let Some(item) = app.remove_list.get(vsel).cloned() {
app.details_scroll = 0;
app.details_focus = Some(item.name.clone());
app.details.name.clone_from(&item.name);
app.details.version.clone_from(&item.version);
app.details.description.clear();
match &item.source {
crate::state::Source::Official { repo, arch } => {
app.details.repository.clone_from(repo);
app.details.architecture.clone_from(arch);
}
crate::state::Source::Aur => {
app.details.repository = "AUR".to_string();
app.details.architecture = "any".to_string();
}
}
if let Some(cached) = app.details_cache.get(&item.name).cloned() {
app.details = cached;
} else {
let _ = details_tx.send(item);
}
}
}
pub fn refresh_downgrade_details(
app: &mut AppState,
details_tx: &mpsc::UnboundedSender<PackageItem>,
) {
let Some(vsel) = app.downgrade_state.selected() else {
return;
};
if app.downgrade_list.is_empty() || vsel >= app.downgrade_list.len() {
return;
}
if let Some(item) = app.downgrade_list.get(vsel).cloned() {
app.details_scroll = 0;
app.details_focus = Some(item.name.clone());
app.details.name.clone_from(&item.name);
app.details.version.clone_from(&item.version);
app.details.description.clear();
match &item.source {
crate::state::Source::Official { repo, arch } => {
app.details.repository.clone_from(repo);
app.details.architecture.clone_from(arch);
}
crate::state::Source::Aur => {
app.details.repository = "AUR".to_string();
app.details.architecture = "any".to_string();
}
}
if let Some(cached) = app.details_cache.get(&item.name).cloned() {
app.details = cached;
} else {
let _ = details_tx.send(item);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn new_app() -> AppState {
AppState::default()
}
#[test]
fn char_count_basic() {
assert_eq!(char_count("abc"), 3);
assert_eq!(char_count("Ï€"), 1);
assert_eq!(char_count("aπb"), 3);
}
#[test]
fn byte_index_for_char_basic() {
let s = "aπb";
assert_eq!(byte_index_for_char(s, 0), 0);
assert_eq!(byte_index_for_char(s, 1), 1);
assert_eq!(byte_index_for_char(s, 2), 1 + "Ï€".len());
assert_eq!(byte_index_for_char(s, 3), s.len());
}
#[test]
fn find_in_recent_basic() {
let mut app = new_app();
app.load_recent_items(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
app.pane_find = Some("a".into());
app.history_state.select(Some(0));
find_in_recent(&mut app, true);
assert!(app.history_state.selected().is_some());
}
#[test]
fn find_in_install_basic() {
let mut app = new_app();
app.install_list = vec![
crate::state::PackageItem {
name: "ripgrep".into(),
version: "1".into(),
description: "fast search".into(),
source: crate::state::Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
},
crate::state::PackageItem {
name: "fd".into(),
version: "1".into(),
description: "find".into(),
source: crate::state::Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
},
];
app.pane_find = Some("rip".into());
app.install_state.select(Some(1));
find_in_install(&mut app, true);
assert_eq!(app.install_state.selected(), Some(0));
}
#[test]
fn refresh_selected_details_requests_when_missing() {
let mut app = new_app();
app.results = vec![crate::state::PackageItem {
name: "rg".into(),
version: "1".into(),
description: String::new(),
source: crate::state::Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
}];
app.selected = 0;
let (tx, mut rx) = mpsc::unbounded_channel();
refresh_selected_details(&mut app, &tx);
let got = rx.try_recv().ok();
assert!(got.is_some());
}
#[test]
fn queue_vote_state_check_skips_when_lookup_unsupported() {
let mut app = new_app();
app.results = vec![crate::state::PackageItem {
name: "pacsea-bin".into(),
version: "1".into(),
description: String::new(),
source: crate::state::Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
}];
app.selected = 0;
app.aur_vote_state_lookup_supported = false;
app.aur_vote_state_by_pkgbase.insert(
"pacsea-bin".into(),
crate::state::app_state::AurVoteStateUi::Voted,
);
queue_selected_aur_vote_state_check(&mut app);
assert!(app.pending_aur_vote_state_request.is_none());
assert!(matches!(
app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
Some(crate::state::app_state::AurVoteStateUi::Voted)
));
}
#[test]
fn queue_vote_state_check_preserves_stable_cached_state() {
let mut app = new_app();
app.results = vec![crate::state::PackageItem {
name: "pacsea-bin".into(),
version: "1".into(),
description: String::new(),
source: crate::state::Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
}];
app.selected = 0;
app.aur_vote_state_by_pkgbase.insert(
"pacsea-bin".into(),
crate::state::app_state::AurVoteStateUi::Voted,
);
queue_selected_aur_vote_state_check(&mut app);
assert_eq!(
app.pending_aur_vote_state_request,
Some("pacsea-bin".to_string())
);
assert!(matches!(
app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
Some(crate::state::app_state::AurVoteStateUi::Voted)
));
}
#[test]
fn updates_scroll_fallback_visible_lines_when_rect_missing() {
let scroll = compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, None, 1, 3, 0);
assert_eq!(scroll, 3);
}
#[test]
fn updates_scroll_handles_tiny_viewport_heights() {
let height_one = Some((0, 0, 40, 1));
let scroll_one =
compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_one, 1, 3, 0);
assert_eq!(scroll_one, 3);
let height_two = Some((0, 0, 40, 2));
let scroll_two =
compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_two, 2, 3, 0);
assert_eq!(scroll_two, 4);
}
#[test]
fn updates_scroll_clamps_to_zero_when_viewport_exceeds_total() {
let large_rect = Some((0, 0, 40, 20));
let scroll =
compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, large_rect, 2, 3, 10);
assert_eq!(scroll, 0);
}
#[test]
fn updates_filter_returns_all_indices_for_empty_query() {
let entries = vec![
("ripgrep".to_string(), "13".to_string(), "14".to_string()),
("fd".to_string(), "8".to_string(), "9".to_string()),
("bat".to_string(), "1".to_string(), "2".to_string()),
];
let indices = compute_updates_filtered_indices(&entries, "");
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn updates_filter_matches_package_name_fuzzy_case_insensitive() {
let entries = vec![
("ripgrep".to_string(), "13".to_string(), "14".to_string()),
("fd".to_string(), "8".to_string(), "9".to_string()),
];
let indices = compute_updates_filtered_indices(&entries, "RG");
assert_eq!(indices, vec![0]);
}
#[test]
fn updates_filter_matches_source_label() {
let entries = vec![
(
"pacsea-bin".to_string(),
"0.9".to_string(),
"1.0".to_string(),
),
(
"pacsea-git".to_string(),
"0.9".to_string(),
"1.0".to_string(),
),
];
let indices = compute_updates_filtered_indices(&entries, "aur");
assert!(
!indices.is_empty(),
"expected at least one AUR package available in fixture"
);
for idx in indices {
let (name, _, _) = &entries[idx];
assert!(crate::index::find_package_by_name(name).is_none());
}
}
}