1use crossterm::event::KeyEvent;
2use tokio::sync::mpsc;
3
4use crate::state::{AppState, PackageItem};
5use std::time::Instant;
6
7#[must_use]
20pub fn matches_any(ke: &KeyEvent, list: &[crate::theme::KeyChord]) -> bool {
21 list.iter().any(|c| {
22 if (c.code, c.mods) == (ke.code, ke.modifiers) {
23 return true;
24 }
25 match (c.code, ke.code) {
26 (crossterm::event::KeyCode::Char(cfg_ch), crossterm::event::KeyCode::Char(ev_ch)) => {
27 let cfg_has_shift = c.mods.contains(crossterm::event::KeyModifiers::SHIFT);
28 if !cfg_has_shift {
29 return false;
30 }
31 if ev_ch == cfg_ch.to_ascii_uppercase() {
33 return true;
34 }
35 if ke.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
37 && ev_ch.to_ascii_lowercase() == cfg_ch
38 {
39 return true;
40 }
41 false
42 }
43 _ => false,
44 }
45 })
46}
47
48#[must_use]
55pub fn char_count(s: &str) -> usize {
56 s.chars().count()
57}
58
59#[must_use]
67pub fn byte_index_for_char(s: &str, ci: usize) -> usize {
68 let cc = char_count(s);
69 if ci == 0 {
70 return 0;
71 }
72 if ci >= cc {
73 return s.len();
74 }
75 s.char_indices()
76 .map(|(i, _)| i)
77 .nth(ci)
78 .map_or(s.len(), |i| i)
79}
80
81pub fn find_in_recent(app: &mut AppState, forward: bool) {
89 let Some(pattern) = app.pane_find.clone() else {
90 return;
91 };
92 let inds = crate::ui::helpers::filtered_recent_indices(app);
93 if inds.is_empty() {
94 return;
95 }
96 let start = app.history_state.selected().unwrap_or(0);
97 let mut vi = start;
98 let n = inds.len();
99 for _ in 0..n {
100 vi = if forward {
101 (vi + 1) % n
102 } else if vi == 0 {
103 n - 1
104 } else {
105 vi - 1
106 };
107 let i = inds[vi];
108 if let Some(s) = app.recent_value_at(i)
109 && s.to_lowercase().contains(&pattern.to_lowercase())
110 {
111 app.history_state.select(Some(vi));
112 break;
113 }
114 }
115}
116
117pub fn find_in_install(app: &mut AppState, forward: bool) {
125 let Some(pattern) = app.pane_find.clone() else {
126 return;
127 };
128 let inds = crate::ui::helpers::filtered_install_indices(app);
129 if inds.is_empty() {
130 return;
131 }
132 let start = app.install_state.selected().unwrap_or(0);
133 let mut vi = start;
134 let n = inds.len();
135 for _ in 0..n {
136 vi = if forward {
137 (vi + 1) % n
138 } else if vi == 0 {
139 n - 1
140 } else {
141 vi - 1
142 };
143 let i = inds[vi];
144 if let Some(p) = app.install_list.get(i)
145 && (p.name.to_lowercase().contains(&pattern.to_lowercase())
146 || p.description
147 .to_lowercase()
148 .contains(&pattern.to_lowercase()))
149 {
150 app.install_state.select(Some(vi));
151 break;
152 }
153 }
154}
155
156pub fn refresh_selected_details(
164 app: &mut AppState,
165 details_tx: &mpsc::UnboundedSender<PackageItem>,
166) {
167 if let Some(item) = app.results.get(app.selected).cloned() {
168 app.details_scroll = 0;
170 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
171 app.details = cached;
172 } else {
173 let _ = details_tx.send(item);
174 }
175 queue_selected_aur_vote_state_check(app);
176 }
177}
178
179pub fn queue_selected_aur_vote_state_check(app: &mut AppState) {
192 let settings = crate::theme::settings();
193 if !settings.aur_vote_enabled {
194 return;
195 }
196 if !app.aur_vote_state_lookup_supported {
197 return;
198 }
199 let Some(item) = app.results.get(app.selected) else {
200 return;
201 };
202 if !matches!(item.source, crate::state::Source::Aur) {
203 return;
204 }
205
206 let pkgbase = item.name.clone();
207 if let Some(previous) = app.pending_aur_vote_state_request.replace(pkgbase.clone())
208 && previous != pkgbase
209 && matches!(
210 app.aur_vote_state_by_pkgbase.get(&previous),
211 Some(crate::state::app_state::AurVoteStateUi::Loading)
212 )
213 {
214 app.aur_vote_state_by_pkgbase
215 .insert(previous, crate::state::app_state::AurVoteStateUi::Unknown);
216 }
217 let should_mark_loading = !matches!(
218 app.aur_vote_state_by_pkgbase.get(&pkgbase),
219 Some(
220 crate::state::app_state::AurVoteStateUi::Voted
221 | crate::state::app_state::AurVoteStateUi::NotVoted
222 )
223 );
224 if should_mark_loading {
225 app.aur_vote_state_by_pkgbase
226 .insert(pkgbase, crate::state::app_state::AurVoteStateUi::Loading);
227 }
228}
229
230pub fn move_sel_cached_with_vote_state(
245 app: &mut AppState,
246 delta: isize,
247 details_tx: &mpsc::UnboundedSender<PackageItem>,
248 comments_tx: &mpsc::UnboundedSender<String>,
249) {
250 crate::logic::move_sel_cached(app, delta, details_tx, comments_tx);
251 queue_selected_aur_vote_state_check(app);
252}
253
254pub fn move_news_selection(app: &mut AppState, delta: isize) {
256 if app.news_results.is_empty() {
257 app.news_selected = 0;
258 app.news_list_state.select(None);
259 app.details.url.clear();
260 return;
261 }
262 let len = app.news_results.len();
263 if app.news_selected >= len {
264 app.news_selected = len.saturating_sub(1);
265 }
266 app.news_list_state.select(Some(app.news_selected));
267 let steps = delta.unsigned_abs();
268 for _ in 0..steps {
269 if delta.is_negative() {
270 app.news_list_state.select_previous();
271 } else {
272 app.news_list_state.select_next();
273 }
274 }
275 let sel = app.news_list_state.selected().unwrap_or(0);
276 app.news_selected = std::cmp::min(sel, len.saturating_sub(1));
277 app.news_list_state.select(Some(app.news_selected));
278 update_news_url(app);
279}
280
281#[must_use]
299pub fn compute_updates_modal_scroll_for_selection(
300 entry_line_starts: &[u16],
301 total_lines: u16,
302 content_rect: Option<(u16, u16, u16, u16)>,
303 selected: usize,
304 total_items: usize,
305 current_scroll: u16,
306) -> u16 {
307 let selected_line = entry_line_starts
308 .get(selected)
309 .copied()
310 .unwrap_or_else(|| u16::try_from(selected).unwrap_or(u16::MAX));
311 let visible_lines = content_rect.map_or(1, |(_, _, _, h)| h.max(1));
312 let mut scroll = current_scroll;
313
314 if selected_line < scroll {
315 scroll = selected_line;
316 } else if selected_line >= scroll.saturating_add(visible_lines) {
317 scroll = selected_line.saturating_sub(visible_lines.saturating_sub(1));
318 }
319
320 let fallback_total = u16::try_from(total_items).unwrap_or(u16::MAX);
321 let max_scroll = total_lines
322 .max(fallback_total)
323 .saturating_sub(visible_lines);
324 scroll.min(max_scroll)
325}
326
327#[must_use]
341pub fn compute_updates_filtered_indices(
342 entries: &[(String, String, String)],
343 query: &str,
344) -> Vec<usize> {
345 let normalized = query.trim();
346 if normalized.is_empty() {
347 return (0..entries.len()).collect();
348 }
349
350 let query_lower = normalized.to_lowercase();
351
352 entries
353 .iter()
354 .enumerate()
355 .filter_map(|(idx, (name, _, _))| {
356 let source_label = if crate::index::find_package_by_name(name).is_some() {
357 "pacman"
358 } else {
359 "aur"
360 };
361 let name_lower = name.to_lowercase();
362 let matches_name = crate::util::fuzzy_match_rank(&name_lower, &query_lower).is_some();
363 let matches_source =
364 crate::util::fuzzy_match_rank(source_label, &query_lower).is_some();
365 if matches_name || matches_source {
366 Some(idx)
367 } else {
368 None
369 }
370 })
371 .collect()
372}
373
374pub fn update_news_url(app: &mut AppState) {
377 if let Some(item) = app.news_results.get(app.news_selected)
378 && let Some(url) = &item.url
379 {
380 app.details.url.clone_from(url);
381 let mut cached = app.news_content_cache.get(url).cloned();
383 if let Some(ref c) = cached
384 && url.contains("://archlinux.org/packages/")
385 && !c.starts_with("Package Info:")
386 {
387 cached = None;
389 tracing::debug!(
390 url,
391 "news content cache missing package metadata; will refetch"
392 );
393 }
394 app.news_content = cached;
395 if app.news_content.is_some() {
396 tracing::debug!(url, "news content served from cache");
397 } else {
398 app.news_content_debounce_timer = Some(std::time::Instant::now());
400 tracing::debug!(url, "news content not cached, setting debounce timer");
401 }
402 app.news_content_scroll = 0;
403 } else {
404 app.details.url.clear();
405 app.news_content = None;
406 app.news_content_debounce_timer = None;
407 }
408 app.news_content_loading = false;
409}
410
411pub fn maybe_request_news_content(
414 app: &mut AppState,
415 news_content_req_tx: &mpsc::UnboundedSender<String>,
416) {
417 if !matches!(app.app_mode, crate::state::types::AppMode::News) {
419 tracing::trace!("news_content: skip request, not in news mode");
420 return;
421 }
422 if app.news_content_loading {
423 tracing::debug!(
424 selected = app.news_selected,
425 "news_content: skip request, already loading"
426 );
427 return;
428 }
429 if let Some(item) = app.news_results.get(app.news_selected)
430 && let Some(url) = &item.url
431 && app.news_content.is_none()
432 && !app.news_content_cache.contains_key(url)
433 {
434 const DEBOUNCE_DELAY_MS: u64 = 500;
438 if let Some(timer) = app.news_content_debounce_timer {
439 #[allow(clippy::cast_possible_truncation)]
441 let elapsed = timer.elapsed().as_millis() as u64;
442 if elapsed < DEBOUNCE_DELAY_MS {
443 tracing::trace!(
445 selected = app.news_selected,
446 url,
447 elapsed_ms = elapsed,
448 remaining_ms = DEBOUNCE_DELAY_MS - elapsed,
449 "news_content: debounce timer not expired, waiting"
450 );
451 return;
452 }
453 app.news_content_debounce_timer = None;
455 } else {
456 app.news_content_debounce_timer = Some(std::time::Instant::now());
458 tracing::debug!(
459 selected = app.news_selected,
460 url,
461 "news_content: no debounce timer, setting one now"
462 );
463 return;
464 }
465
466 app.news_content_loading = true;
467 app.news_content_loading_since = Some(Instant::now());
468 tracing::debug!(
469 selected = app.news_selected,
470 title = item.title,
471 url,
472 "news_content: requesting article content (debounce expired)"
473 );
474 if let Err(e) = news_content_req_tx.send(url.clone()) {
475 tracing::warn!(
476 error = %e,
477 selected = app.news_selected,
478 title = item.title,
479 url,
480 "news_content: failed to enqueue content request"
481 );
482 app.news_content_loading = false;
483 app.news_content_loading_since = None;
484 app.news_content = Some(format!("Failed to load content: {e}"));
485 app.toast_message = Some("News content request failed".to_string());
486 app.toast_expires_at = Some(Instant::now() + std::time::Duration::from_secs(3));
487 }
488 } else {
489 tracing::trace!(
490 selected = app.news_selected,
491 has_item = app.news_results.get(app.news_selected).is_some(),
492 has_url = app
493 .news_results
494 .get(app.news_selected)
495 .and_then(|it| it.url.as_ref())
496 .is_some(),
497 content_cached = app
498 .news_results
499 .get(app.news_selected)
500 .and_then(|it| it.url.as_ref())
501 .is_some_and(|u| app.news_content_cache.contains_key(u)),
502 has_content = app.news_content.is_some(),
503 "news_content: skip request (cached/absent URL/already loaded)"
504 );
505 }
506}
507
508pub fn refresh_install_details(
516 app: &mut AppState,
517 details_tx: &mpsc::UnboundedSender<PackageItem>,
518) {
519 let Some(vsel) = app.install_state.selected() else {
520 return;
521 };
522 let inds = crate::ui::helpers::filtered_install_indices(app);
523 if inds.is_empty() || vsel >= inds.len() {
524 return;
525 }
526 let i = inds[vsel];
527 if let Some(item) = app.install_list.get(i).cloned() {
528 app.details_scroll = 0;
530 app.details_focus = Some(item.name.clone());
532
533 app.details.name.clone_from(&item.name);
535 app.details.version.clone_from(&item.version);
536 app.details.description.clear();
537 match &item.source {
538 crate::state::Source::Official { repo, arch } => {
539 app.details.repository.clone_from(repo);
540 app.details.architecture.clone_from(arch);
541 }
542 crate::state::Source::Aur => {
543 app.details.repository = "AUR".to_string();
544 app.details.architecture = "any".to_string();
545 }
546 }
547
548 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
549 app.details = cached;
550 } else {
551 let _ = details_tx.send(item);
552 }
553 }
554}
555
556pub fn refresh_remove_details(app: &mut AppState, details_tx: &mpsc::UnboundedSender<PackageItem>) {
564 let Some(vsel) = app.remove_state.selected() else {
565 return;
566 };
567 if app.remove_list.is_empty() || vsel >= app.remove_list.len() {
568 return;
569 }
570 if let Some(item) = app.remove_list.get(vsel).cloned() {
571 app.details_scroll = 0;
573 app.details_focus = Some(item.name.clone());
574 app.details.name.clone_from(&item.name);
575 app.details.version.clone_from(&item.version);
576 app.details.description.clear();
577 match &item.source {
578 crate::state::Source::Official { repo, arch } => {
579 app.details.repository.clone_from(repo);
580 app.details.architecture.clone_from(arch);
581 }
582 crate::state::Source::Aur => {
583 app.details.repository = "AUR".to_string();
584 app.details.architecture = "any".to_string();
585 }
586 }
587 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
588 app.details = cached;
589 } else {
590 let _ = details_tx.send(item);
591 }
592 }
593}
594
595pub fn refresh_downgrade_details(
603 app: &mut AppState,
604 details_tx: &mpsc::UnboundedSender<PackageItem>,
605) {
606 let Some(vsel) = app.downgrade_state.selected() else {
607 return;
608 };
609 if app.downgrade_list.is_empty() || vsel >= app.downgrade_list.len() {
610 return;
611 }
612 if let Some(item) = app.downgrade_list.get(vsel).cloned() {
613 app.details_scroll = 0;
615 app.details_focus = Some(item.name.clone());
616 app.details.name.clone_from(&item.name);
617 app.details.version.clone_from(&item.version);
618 app.details.description.clear();
619 match &item.source {
620 crate::state::Source::Official { repo, arch } => {
621 app.details.repository.clone_from(repo);
622 app.details.architecture.clone_from(arch);
623 }
624 crate::state::Source::Aur => {
625 app.details.repository = "AUR".to_string();
626 app.details.architecture = "any".to_string();
627 }
628 }
629 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
630 app.details = cached;
631 } else {
632 let _ = details_tx.send(item);
633 }
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 fn new_app() -> AppState {
652 AppState::default()
653 }
654
655 #[test]
656 fn char_count_basic() {
667 assert_eq!(char_count("abc"), 3);
668 assert_eq!(char_count("π"), 1);
669 assert_eq!(char_count("aπb"), 3);
670 }
671
672 #[test]
673 fn byte_index_for_char_basic() {
684 let s = "aπb";
685 assert_eq!(byte_index_for_char(s, 0), 0);
686 assert_eq!(byte_index_for_char(s, 1), 1);
687 assert_eq!(byte_index_for_char(s, 2), 1 + "π".len());
688 assert_eq!(byte_index_for_char(s, 3), s.len());
689 }
690
691 #[test]
692 fn find_in_recent_basic() {
703 let mut app = new_app();
704 app.load_recent_items(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
705 app.pane_find = Some("a".into());
706 app.history_state.select(Some(0));
707 find_in_recent(&mut app, true);
708 assert!(app.history_state.selected().is_some());
709 }
710
711 #[test]
712 fn find_in_install_basic() {
723 let mut app = new_app();
724 app.install_list = vec![
725 crate::state::PackageItem {
726 name: "ripgrep".into(),
727 version: "1".into(),
728 description: "fast search".into(),
729 source: crate::state::Source::Aur,
730 popularity: None,
731 out_of_date: None,
732 orphaned: false,
733 },
734 crate::state::PackageItem {
735 name: "fd".into(),
736 version: "1".into(),
737 description: "find".into(),
738 source: crate::state::Source::Aur,
739 popularity: None,
740 out_of_date: None,
741 orphaned: false,
742 },
743 ];
744 app.pane_find = Some("rip".into());
745 app.install_state.select(Some(1));
747 find_in_install(&mut app, true);
748 assert_eq!(app.install_state.selected(), Some(0));
749 }
750
751 #[test]
752 fn refresh_selected_details_requests_when_missing() {
763 let mut app = new_app();
764 app.results = vec![crate::state::PackageItem {
765 name: "rg".into(),
766 version: "1".into(),
767 description: String::new(),
768 source: crate::state::Source::Aur,
769 popularity: None,
770 out_of_date: None,
771 orphaned: false,
772 }];
773 app.selected = 0;
774 let (tx, mut rx) = mpsc::unbounded_channel();
775 refresh_selected_details(&mut app, &tx);
776 let got = rx.try_recv().ok();
777 assert!(got.is_some());
778 }
779
780 #[test]
781 fn queue_vote_state_check_skips_when_lookup_unsupported() {
793 let mut app = new_app();
794 app.results = vec![crate::state::PackageItem {
795 name: "pacsea-bin".into(),
796 version: "1".into(),
797 description: String::new(),
798 source: crate::state::Source::Aur,
799 popularity: None,
800 out_of_date: None,
801 orphaned: false,
802 }];
803 app.selected = 0;
804 app.aur_vote_state_lookup_supported = false;
805 app.aur_vote_state_by_pkgbase.insert(
806 "pacsea-bin".into(),
807 crate::state::app_state::AurVoteStateUi::Voted,
808 );
809
810 queue_selected_aur_vote_state_check(&mut app);
811
812 assert!(app.pending_aur_vote_state_request.is_none());
813 assert!(matches!(
814 app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
815 Some(crate::state::app_state::AurVoteStateUi::Voted)
816 ));
817 }
818
819 #[test]
820 fn queue_vote_state_check_preserves_stable_cached_state() {
831 let mut app = new_app();
832 app.results = vec![crate::state::PackageItem {
833 name: "pacsea-bin".into(),
834 version: "1".into(),
835 description: String::new(),
836 source: crate::state::Source::Aur,
837 popularity: None,
838 out_of_date: None,
839 orphaned: false,
840 }];
841 app.selected = 0;
842 app.aur_vote_state_by_pkgbase.insert(
843 "pacsea-bin".into(),
844 crate::state::app_state::AurVoteStateUi::Voted,
845 );
846
847 queue_selected_aur_vote_state_check(&mut app);
848
849 assert_eq!(
850 app.pending_aur_vote_state_request,
851 Some("pacsea-bin".to_string())
852 );
853 assert!(matches!(
854 app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
855 Some(crate::state::app_state::AurVoteStateUi::Voted)
856 ));
857 }
858
859 #[test]
860 fn updates_scroll_fallback_visible_lines_when_rect_missing() {
871 let scroll = compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, None, 1, 3, 0);
872 assert_eq!(scroll, 3);
873 }
874
875 #[test]
876 fn updates_scroll_handles_tiny_viewport_heights() {
887 let height_one = Some((0, 0, 40, 1));
888 let scroll_one =
889 compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_one, 1, 3, 0);
890 assert_eq!(scroll_one, 3);
891
892 let height_two = Some((0, 0, 40, 2));
893 let scroll_two =
894 compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_two, 2, 3, 0);
895 assert_eq!(scroll_two, 4);
896 }
897
898 #[test]
899 fn updates_scroll_clamps_to_zero_when_viewport_exceeds_total() {
910 let large_rect = Some((0, 0, 40, 20));
911 let scroll =
912 compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, large_rect, 2, 3, 10);
913 assert_eq!(scroll, 0);
914 }
915
916 #[test]
917 fn updates_filter_returns_all_indices_for_empty_query() {
928 let entries = vec![
929 ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
930 ("fd".to_string(), "8".to_string(), "9".to_string()),
931 ("bat".to_string(), "1".to_string(), "2".to_string()),
932 ];
933 let indices = compute_updates_filtered_indices(&entries, "");
934 assert_eq!(indices, vec![0, 1, 2]);
935 }
936
937 #[test]
938 fn updates_filter_matches_package_name_fuzzy_case_insensitive() {
949 let entries = vec![
950 ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
951 ("fd".to_string(), "8".to_string(), "9".to_string()),
952 ];
953 let indices = compute_updates_filtered_indices(&entries, "RG");
954 assert_eq!(indices, vec![0]);
955 }
956
957 #[test]
958 fn updates_filter_matches_source_label() {
969 let entries = vec![
970 (
971 "pacsea-bin".to_string(),
972 "0.9".to_string(),
973 "1.0".to_string(),
974 ),
975 (
976 "pacsea-git".to_string(),
977 "0.9".to_string(),
978 "1.0".to_string(),
979 ),
980 ];
981 let indices = compute_updates_filtered_indices(&entries, "aur");
982 assert!(
983 !indices.is_empty(),
984 "expected at least one AUR package available in fixture"
985 );
986 for idx in indices {
987 let (name, _, _) = &entries[idx];
988 assert!(crate::index::find_package_by_name(name).is_none());
989 }
990 }
991}