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]
300pub fn compute_updates_modal_scroll_for_selection(
301 entry_line_starts: &[u16],
302 total_lines: u16,
303 content_rect: Option<(u16, u16, u16, u16)>,
304 selected: usize,
305 total_items: usize,
306 _current_scroll: u16,
307) -> u16 {
308 let selected_line = entry_line_starts
309 .get(selected)
310 .copied()
311 .unwrap_or_else(|| u16::try_from(selected).unwrap_or(u16::MAX));
312 let visible_lines = content_rect.map_or(1, |(_, _, _, h)| h.max(1));
313 let half = visible_lines / 2;
314 let ideal = selected_line.saturating_sub(half);
315
316 let fallback_total = u16::try_from(total_items).unwrap_or(u16::MAX);
317 let max_scroll = total_lines
318 .max(fallback_total)
319 .saturating_sub(visible_lines);
320 ideal.min(max_scroll)
321}
322
323#[must_use]
337pub fn compute_updates_filtered_indices(
338 entries: &[(String, String, String)],
339 query: &str,
340) -> Vec<usize> {
341 let normalized = query.trim();
342 if normalized.is_empty() {
343 return (0..entries.len()).collect();
344 }
345
346 let query_lower = normalized.to_lowercase();
347
348 entries
349 .iter()
350 .enumerate()
351 .filter_map(|(idx, (name, _, _))| {
352 let source_label = if crate::index::find_package_by_name(name).is_some() {
353 "pacman"
354 } else {
355 "aur"
356 };
357 let name_lower = name.to_lowercase();
358 let matches_name = crate::util::fuzzy_match_rank(&name_lower, &query_lower).is_some();
359 let matches_source =
360 crate::util::fuzzy_match_rank(source_label, &query_lower).is_some();
361 if matches_name || matches_source {
362 Some(idx)
363 } else {
364 None
365 }
366 })
367 .collect()
368}
369
370pub fn update_news_url(app: &mut AppState) {
373 if let Some(item) = app.news_results.get(app.news_selected)
374 && let Some(url) = &item.url
375 {
376 app.details.url.clone_from(url);
377 let mut cached = app.news_content_cache.get(url).cloned();
379 if let Some(ref c) = cached
380 && url.contains("://archlinux.org/packages/")
381 && !c.starts_with("Package Info:")
382 {
383 cached = None;
385 tracing::debug!(
386 url,
387 "news content cache missing package metadata; will refetch"
388 );
389 }
390 app.news_content = cached;
391 if app.news_content.is_some() {
392 tracing::debug!(url, "news content served from cache");
393 } else {
394 app.news_content_debounce_timer = Some(std::time::Instant::now());
396 tracing::debug!(url, "news content not cached, setting debounce timer");
397 }
398 app.news_content_scroll = 0;
399 } else {
400 app.details.url.clear();
401 app.news_content = None;
402 app.news_content_debounce_timer = None;
403 }
404 app.news_content_loading = false;
405}
406
407pub fn maybe_request_news_content(
410 app: &mut AppState,
411 news_content_req_tx: &mpsc::UnboundedSender<String>,
412) {
413 if !matches!(app.app_mode, crate::state::types::AppMode::News) {
415 tracing::trace!("news_content: skip request, not in news mode");
416 return;
417 }
418 if app.news_content_loading {
419 tracing::debug!(
420 selected = app.news_selected,
421 "news_content: skip request, already loading"
422 );
423 return;
424 }
425 if let Some(item) = app.news_results.get(app.news_selected)
426 && let Some(url) = &item.url
427 && app.news_content.is_none()
428 && !app.news_content_cache.contains_key(url)
429 {
430 const DEBOUNCE_DELAY_MS: u64 = 500;
434 if let Some(timer) = app.news_content_debounce_timer {
435 #[allow(clippy::cast_possible_truncation)]
437 let elapsed = timer.elapsed().as_millis() as u64;
438 if elapsed < DEBOUNCE_DELAY_MS {
439 tracing::trace!(
441 selected = app.news_selected,
442 url,
443 elapsed_ms = elapsed,
444 remaining_ms = DEBOUNCE_DELAY_MS - elapsed,
445 "news_content: debounce timer not expired, waiting"
446 );
447 return;
448 }
449 app.news_content_debounce_timer = None;
451 } else {
452 app.news_content_debounce_timer = Some(std::time::Instant::now());
454 tracing::debug!(
455 selected = app.news_selected,
456 url,
457 "news_content: no debounce timer, setting one now"
458 );
459 return;
460 }
461
462 app.news_content_loading = true;
463 app.news_content_loading_since = Some(Instant::now());
464 tracing::debug!(
465 selected = app.news_selected,
466 title = item.title,
467 url,
468 "news_content: requesting article content (debounce expired)"
469 );
470 if let Err(e) = news_content_req_tx.send(url.clone()) {
471 tracing::warn!(
472 error = %e,
473 selected = app.news_selected,
474 title = item.title,
475 url,
476 "news_content: failed to enqueue content request"
477 );
478 app.news_content_loading = false;
479 app.news_content_loading_since = None;
480 app.news_content = Some(format!("Failed to load content: {e}"));
481 app.toast_message = Some("News content request failed".to_string());
482 app.toast_expires_at = Some(Instant::now() + std::time::Duration::from_secs(3));
483 }
484 } else {
485 tracing::trace!(
486 selected = app.news_selected,
487 has_item = app.news_results.get(app.news_selected).is_some(),
488 has_url = app
489 .news_results
490 .get(app.news_selected)
491 .and_then(|it| it.url.as_ref())
492 .is_some(),
493 content_cached = app
494 .news_results
495 .get(app.news_selected)
496 .and_then(|it| it.url.as_ref())
497 .is_some_and(|u| app.news_content_cache.contains_key(u)),
498 has_content = app.news_content.is_some(),
499 "news_content: skip request (cached/absent URL/already loaded)"
500 );
501 }
502}
503
504pub fn refresh_install_details(
512 app: &mut AppState,
513 details_tx: &mpsc::UnboundedSender<PackageItem>,
514) {
515 let Some(vsel) = app.install_state.selected() else {
516 return;
517 };
518 let inds = crate::ui::helpers::filtered_install_indices(app);
519 if inds.is_empty() || vsel >= inds.len() {
520 return;
521 }
522 let i = inds[vsel];
523 if let Some(item) = app.install_list.get(i).cloned() {
524 app.details_scroll = 0;
526 app.details_focus = Some(item.name.clone());
528
529 app.details.name.clone_from(&item.name);
531 app.details.version.clone_from(&item.version);
532 app.details.description.clear();
533 match &item.source {
534 crate::state::Source::Official { repo, arch } => {
535 app.details.repository.clone_from(repo);
536 app.details.architecture.clone_from(arch);
537 }
538 crate::state::Source::Aur => {
539 app.details.repository = "AUR".to_string();
540 app.details.architecture = "any".to_string();
541 }
542 }
543
544 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
545 app.details = cached;
546 } else {
547 let _ = details_tx.send(item);
548 }
549 }
550}
551
552pub fn refresh_remove_details(app: &mut AppState, details_tx: &mpsc::UnboundedSender<PackageItem>) {
560 let Some(vsel) = app.remove_state.selected() else {
561 return;
562 };
563 if app.remove_list.is_empty() || vsel >= app.remove_list.len() {
564 return;
565 }
566 if let Some(item) = app.remove_list.get(vsel).cloned() {
567 app.details_scroll = 0;
569 app.details_focus = Some(item.name.clone());
570 app.details.name.clone_from(&item.name);
571 app.details.version.clone_from(&item.version);
572 app.details.description.clear();
573 match &item.source {
574 crate::state::Source::Official { repo, arch } => {
575 app.details.repository.clone_from(repo);
576 app.details.architecture.clone_from(arch);
577 }
578 crate::state::Source::Aur => {
579 app.details.repository = "AUR".to_string();
580 app.details.architecture = "any".to_string();
581 }
582 }
583 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
584 app.details = cached;
585 } else {
586 let _ = details_tx.send(item);
587 }
588 }
589}
590
591pub fn refresh_downgrade_details(
599 app: &mut AppState,
600 details_tx: &mpsc::UnboundedSender<PackageItem>,
601) {
602 let Some(vsel) = app.downgrade_state.selected() else {
603 return;
604 };
605 if app.downgrade_list.is_empty() || vsel >= app.downgrade_list.len() {
606 return;
607 }
608 if let Some(item) = app.downgrade_list.get(vsel).cloned() {
609 app.details_scroll = 0;
611 app.details_focus = Some(item.name.clone());
612 app.details.name.clone_from(&item.name);
613 app.details.version.clone_from(&item.version);
614 app.details.description.clear();
615 match &item.source {
616 crate::state::Source::Official { repo, arch } => {
617 app.details.repository.clone_from(repo);
618 app.details.architecture.clone_from(arch);
619 }
620 crate::state::Source::Aur => {
621 app.details.repository = "AUR".to_string();
622 app.details.architecture = "any".to_string();
623 }
624 }
625 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
626 app.details = cached;
627 } else {
628 let _ = details_tx.send(item);
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 fn new_app() -> AppState {
648 AppState::default()
649 }
650
651 #[test]
652 fn char_count_basic() {
663 assert_eq!(char_count("abc"), 3);
664 assert_eq!(char_count("π"), 1);
665 assert_eq!(char_count("aπb"), 3);
666 }
667
668 #[test]
669 fn byte_index_for_char_basic() {
680 let s = "aπb";
681 assert_eq!(byte_index_for_char(s, 0), 0);
682 assert_eq!(byte_index_for_char(s, 1), 1);
683 assert_eq!(byte_index_for_char(s, 2), 1 + "π".len());
684 assert_eq!(byte_index_for_char(s, 3), s.len());
685 }
686
687 #[test]
688 fn find_in_recent_basic() {
699 let mut app = new_app();
700 app.load_recent_items(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
701 app.pane_find = Some("a".into());
702 app.history_state.select(Some(0));
703 find_in_recent(&mut app, true);
704 assert!(app.history_state.selected().is_some());
705 }
706
707 #[test]
708 fn find_in_install_basic() {
719 let mut app = new_app();
720 app.install_list = vec![
721 crate::state::PackageItem {
722 name: "ripgrep".into(),
723 version: "1".into(),
724 description: "fast search".into(),
725 source: crate::state::Source::Aur,
726 popularity: None,
727 out_of_date: None,
728 orphaned: false,
729 },
730 crate::state::PackageItem {
731 name: "fd".into(),
732 version: "1".into(),
733 description: "find".into(),
734 source: crate::state::Source::Aur,
735 popularity: None,
736 out_of_date: None,
737 orphaned: false,
738 },
739 ];
740 app.pane_find = Some("rip".into());
741 app.install_state.select(Some(1));
743 find_in_install(&mut app, true);
744 assert_eq!(app.install_state.selected(), Some(0));
745 }
746
747 #[test]
748 fn refresh_selected_details_requests_when_missing() {
759 let mut app = new_app();
760 app.results = vec![crate::state::PackageItem {
761 name: "rg".into(),
762 version: "1".into(),
763 description: String::new(),
764 source: crate::state::Source::Aur,
765 popularity: None,
766 out_of_date: None,
767 orphaned: false,
768 }];
769 app.selected = 0;
770 let (tx, mut rx) = mpsc::unbounded_channel();
771 refresh_selected_details(&mut app, &tx);
772 let got = rx.try_recv().ok();
773 assert!(got.is_some());
774 }
775
776 #[test]
777 fn queue_vote_state_check_skips_when_lookup_unsupported() {
789 let mut app = new_app();
790 app.results = vec![crate::state::PackageItem {
791 name: "pacsea-bin".into(),
792 version: "1".into(),
793 description: String::new(),
794 source: crate::state::Source::Aur,
795 popularity: None,
796 out_of_date: None,
797 orphaned: false,
798 }];
799 app.selected = 0;
800 app.aur_vote_state_lookup_supported = false;
801 app.aur_vote_state_by_pkgbase.insert(
802 "pacsea-bin".into(),
803 crate::state::app_state::AurVoteStateUi::Voted,
804 );
805
806 queue_selected_aur_vote_state_check(&mut app);
807
808 assert!(app.pending_aur_vote_state_request.is_none());
809 assert!(matches!(
810 app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
811 Some(crate::state::app_state::AurVoteStateUi::Voted)
812 ));
813 }
814
815 #[test]
816 fn queue_vote_state_check_preserves_stable_cached_state() {
827 let mut app = new_app();
828 app.results = vec![crate::state::PackageItem {
829 name: "pacsea-bin".into(),
830 version: "1".into(),
831 description: String::new(),
832 source: crate::state::Source::Aur,
833 popularity: None,
834 out_of_date: None,
835 orphaned: false,
836 }];
837 app.selected = 0;
838 app.aur_vote_state_by_pkgbase.insert(
839 "pacsea-bin".into(),
840 crate::state::app_state::AurVoteStateUi::Voted,
841 );
842
843 queue_selected_aur_vote_state_check(&mut app);
844
845 assert_eq!(
846 app.pending_aur_vote_state_request,
847 Some("pacsea-bin".to_string())
848 );
849 assert!(matches!(
850 app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
851 Some(crate::state::app_state::AurVoteStateUi::Voted)
852 ));
853 }
854
855 #[test]
856 fn updates_scroll_fallback_visible_lines_when_rect_missing() {
867 let scroll = compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, None, 1, 3, 0);
868 assert_eq!(scroll, 3);
869 }
870
871 #[test]
872 fn updates_scroll_handles_tiny_viewport_heights() {
883 let height_one = Some((0, 0, 40, 1));
884 let scroll_one =
885 compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_one, 1, 3, 0);
886 assert_eq!(scroll_one, 3);
887
888 let height_two = Some((0, 0, 40, 2));
889 let scroll_two =
890 compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_two, 2, 3, 0);
891 assert_eq!(scroll_two, 4);
892 }
893
894 #[test]
895 fn updates_scroll_centers_selection_in_viewport() {
906 let starts: Vec<u16> = (0..10).collect();
907 let rect = Some((0, 0, 40, 5));
908 let scroll = compute_updates_modal_scroll_for_selection(&starts, 10, rect, 4, 10, 0);
909 assert_eq!(scroll, 2);
910 }
911
912 #[test]
913 fn updates_scroll_clamps_to_zero_when_viewport_exceeds_total() {
924 let large_rect = Some((0, 0, 40, 20));
925 let scroll =
926 compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, large_rect, 2, 3, 10);
927 assert_eq!(scroll, 0);
928 }
929
930 #[test]
931 fn updates_filter_returns_all_indices_for_empty_query() {
942 let entries = vec![
943 ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
944 ("fd".to_string(), "8".to_string(), "9".to_string()),
945 ("bat".to_string(), "1".to_string(), "2".to_string()),
946 ];
947 let indices = compute_updates_filtered_indices(&entries, "");
948 assert_eq!(indices, vec![0, 1, 2]);
949 }
950
951 #[test]
952 fn updates_filter_matches_package_name_fuzzy_case_insensitive() {
963 let entries = vec![
964 ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
965 ("fd".to_string(), "8".to_string(), "9".to_string()),
966 ];
967 let indices = compute_updates_filtered_indices(&entries, "RG");
968 assert_eq!(indices, vec![0]);
969 }
970
971 #[test]
972 fn updates_filter_matches_source_label() {
983 let entries = vec![
984 (
985 "pacsea-bin".to_string(),
986 "0.9".to_string(),
987 "1.0".to_string(),
988 ),
989 (
990 "pacsea-git".to_string(),
991 "0.9".to_string(),
992 "1.0".to_string(),
993 ),
994 ];
995 let indices = compute_updates_filtered_indices(&entries, "aur");
996 assert!(
997 !indices.is_empty(),
998 "expected at least one AUR package available in fixture"
999 );
1000 for idx in indices {
1001 let (name, _, _) = &entries[idx];
1002 assert!(crate::index::find_package_by_name(name).is_none());
1003 }
1004 }
1005}