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 }
176}
177
178pub fn move_news_selection(app: &mut AppState, delta: isize) {
180 if app.news_results.is_empty() {
181 app.news_selected = 0;
182 app.news_list_state.select(None);
183 app.details.url.clear();
184 return;
185 }
186 let len = app.news_results.len();
187 if app.news_selected >= len {
188 app.news_selected = len.saturating_sub(1);
189 }
190 app.news_list_state.select(Some(app.news_selected));
191 let steps = delta.unsigned_abs();
192 for _ in 0..steps {
193 if delta.is_negative() {
194 app.news_list_state.select_previous();
195 } else {
196 app.news_list_state.select_next();
197 }
198 }
199 let sel = app.news_list_state.selected().unwrap_or(0);
200 app.news_selected = std::cmp::min(sel, len.saturating_sub(1));
201 app.news_list_state.select(Some(app.news_selected));
202 update_news_url(app);
203}
204
205pub fn update_news_url(app: &mut AppState) {
208 if let Some(item) = app.news_results.get(app.news_selected)
209 && let Some(url) = &item.url
210 {
211 app.details.url.clone_from(url);
212 let mut cached = app.news_content_cache.get(url).cloned();
214 if let Some(ref c) = cached
215 && url.contains("://archlinux.org/packages/")
216 && !c.starts_with("Package Info:")
217 {
218 cached = None;
220 tracing::debug!(
221 url,
222 "news content cache missing package metadata; will refetch"
223 );
224 }
225 app.news_content = cached;
226 if app.news_content.is_some() {
227 tracing::debug!(url, "news content served from cache");
228 } else {
229 app.news_content_debounce_timer = Some(std::time::Instant::now());
231 tracing::debug!(url, "news content not cached, setting debounce timer");
232 }
233 app.news_content_scroll = 0;
234 } else {
235 app.details.url.clear();
236 app.news_content = None;
237 app.news_content_debounce_timer = None;
238 }
239 app.news_content_loading = false;
240}
241
242pub fn maybe_request_news_content(
245 app: &mut AppState,
246 news_content_req_tx: &mpsc::UnboundedSender<String>,
247) {
248 if !matches!(app.app_mode, crate::state::types::AppMode::News) {
250 tracing::trace!("news_content: skip request, not in news mode");
251 return;
252 }
253 if app.news_content_loading {
254 tracing::debug!(
255 selected = app.news_selected,
256 "news_content: skip request, already loading"
257 );
258 return;
259 }
260 if let Some(item) = app.news_results.get(app.news_selected)
261 && let Some(url) = &item.url
262 && app.news_content.is_none()
263 && !app.news_content_cache.contains_key(url)
264 {
265 const DEBOUNCE_DELAY_MS: u64 = 500;
269 if let Some(timer) = app.news_content_debounce_timer {
270 #[allow(clippy::cast_possible_truncation)]
272 let elapsed = timer.elapsed().as_millis() as u64;
273 if elapsed < DEBOUNCE_DELAY_MS {
274 tracing::trace!(
276 selected = app.news_selected,
277 url,
278 elapsed_ms = elapsed,
279 remaining_ms = DEBOUNCE_DELAY_MS - elapsed,
280 "news_content: debounce timer not expired, waiting"
281 );
282 return;
283 }
284 app.news_content_debounce_timer = None;
286 } else {
287 app.news_content_debounce_timer = Some(std::time::Instant::now());
289 tracing::debug!(
290 selected = app.news_selected,
291 url,
292 "news_content: no debounce timer, setting one now"
293 );
294 return;
295 }
296
297 app.news_content_loading = true;
298 app.news_content_loading_since = Some(Instant::now());
299 tracing::debug!(
300 selected = app.news_selected,
301 title = item.title,
302 url,
303 "news_content: requesting article content (debounce expired)"
304 );
305 if let Err(e) = news_content_req_tx.send(url.clone()) {
306 tracing::warn!(
307 error = %e,
308 selected = app.news_selected,
309 title = item.title,
310 url,
311 "news_content: failed to enqueue content request"
312 );
313 app.news_content_loading = false;
314 app.news_content_loading_since = None;
315 app.news_content = Some(format!("Failed to load content: {e}"));
316 app.toast_message = Some("News content request failed".to_string());
317 app.toast_expires_at = Some(Instant::now() + std::time::Duration::from_secs(3));
318 }
319 } else {
320 tracing::trace!(
321 selected = app.news_selected,
322 has_item = app.news_results.get(app.news_selected).is_some(),
323 has_url = app
324 .news_results
325 .get(app.news_selected)
326 .and_then(|it| it.url.as_ref())
327 .is_some(),
328 content_cached = app
329 .news_results
330 .get(app.news_selected)
331 .and_then(|it| it.url.as_ref())
332 .is_some_and(|u| app.news_content_cache.contains_key(u)),
333 has_content = app.news_content.is_some(),
334 "news_content: skip request (cached/absent URL/already loaded)"
335 );
336 }
337}
338
339pub fn refresh_install_details(
347 app: &mut AppState,
348 details_tx: &mpsc::UnboundedSender<PackageItem>,
349) {
350 let Some(vsel) = app.install_state.selected() else {
351 return;
352 };
353 let inds = crate::ui::helpers::filtered_install_indices(app);
354 if inds.is_empty() || vsel >= inds.len() {
355 return;
356 }
357 let i = inds[vsel];
358 if let Some(item) = app.install_list.get(i).cloned() {
359 app.details_scroll = 0;
361 app.details_focus = Some(item.name.clone());
363
364 app.details.name.clone_from(&item.name);
366 app.details.version.clone_from(&item.version);
367 app.details.description.clear();
368 match &item.source {
369 crate::state::Source::Official { repo, arch } => {
370 app.details.repository.clone_from(repo);
371 app.details.architecture.clone_from(arch);
372 }
373 crate::state::Source::Aur => {
374 app.details.repository = "AUR".to_string();
375 app.details.architecture = "any".to_string();
376 }
377 }
378
379 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
380 app.details = cached;
381 } else {
382 let _ = details_tx.send(item);
383 }
384 }
385}
386
387pub fn refresh_remove_details(app: &mut AppState, details_tx: &mpsc::UnboundedSender<PackageItem>) {
395 let Some(vsel) = app.remove_state.selected() else {
396 return;
397 };
398 if app.remove_list.is_empty() || vsel >= app.remove_list.len() {
399 return;
400 }
401 if let Some(item) = app.remove_list.get(vsel).cloned() {
402 app.details_scroll = 0;
404 app.details_focus = Some(item.name.clone());
405 app.details.name.clone_from(&item.name);
406 app.details.version.clone_from(&item.version);
407 app.details.description.clear();
408 match &item.source {
409 crate::state::Source::Official { repo, arch } => {
410 app.details.repository.clone_from(repo);
411 app.details.architecture.clone_from(arch);
412 }
413 crate::state::Source::Aur => {
414 app.details.repository = "AUR".to_string();
415 app.details.architecture = "any".to_string();
416 }
417 }
418 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
419 app.details = cached;
420 } else {
421 let _ = details_tx.send(item);
422 }
423 }
424}
425
426pub fn refresh_downgrade_details(
434 app: &mut AppState,
435 details_tx: &mpsc::UnboundedSender<PackageItem>,
436) {
437 let Some(vsel) = app.downgrade_state.selected() else {
438 return;
439 };
440 if app.downgrade_list.is_empty() || vsel >= app.downgrade_list.len() {
441 return;
442 }
443 if let Some(item) = app.downgrade_list.get(vsel).cloned() {
444 app.details_scroll = 0;
446 app.details_focus = Some(item.name.clone());
447 app.details.name.clone_from(&item.name);
448 app.details.version.clone_from(&item.version);
449 app.details.description.clear();
450 match &item.source {
451 crate::state::Source::Official { repo, arch } => {
452 app.details.repository.clone_from(repo);
453 app.details.architecture.clone_from(arch);
454 }
455 crate::state::Source::Aur => {
456 app.details.repository = "AUR".to_string();
457 app.details.architecture = "any".to_string();
458 }
459 }
460 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
461 app.details = cached;
462 } else {
463 let _ = details_tx.send(item);
464 }
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 fn new_app() -> AppState {
483 AppState::default()
484 }
485
486 #[test]
487 fn char_count_basic() {
498 assert_eq!(char_count("abc"), 3);
499 assert_eq!(char_count("π"), 1);
500 assert_eq!(char_count("aπb"), 3);
501 }
502
503 #[test]
504 fn byte_index_for_char_basic() {
515 let s = "aπb";
516 assert_eq!(byte_index_for_char(s, 0), 0);
517 assert_eq!(byte_index_for_char(s, 1), 1);
518 assert_eq!(byte_index_for_char(s, 2), 1 + "π".len());
519 assert_eq!(byte_index_for_char(s, 3), s.len());
520 }
521
522 #[test]
523 fn find_in_recent_basic() {
534 let mut app = new_app();
535 app.load_recent_items(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
536 app.pane_find = Some("a".into());
537 app.history_state.select(Some(0));
538 find_in_recent(&mut app, true);
539 assert!(app.history_state.selected().is_some());
540 }
541
542 #[test]
543 fn find_in_install_basic() {
554 let mut app = new_app();
555 app.install_list = vec![
556 crate::state::PackageItem {
557 name: "ripgrep".into(),
558 version: "1".into(),
559 description: "fast search".into(),
560 source: crate::state::Source::Aur,
561 popularity: None,
562 out_of_date: None,
563 orphaned: false,
564 },
565 crate::state::PackageItem {
566 name: "fd".into(),
567 version: "1".into(),
568 description: "find".into(),
569 source: crate::state::Source::Aur,
570 popularity: None,
571 out_of_date: None,
572 orphaned: false,
573 },
574 ];
575 app.pane_find = Some("rip".into());
576 app.install_state.select(Some(1));
578 find_in_install(&mut app, true);
579 assert_eq!(app.install_state.selected(), Some(0));
580 }
581
582 #[test]
583 fn refresh_selected_details_requests_when_missing() {
594 let mut app = new_app();
595 app.results = vec![crate::state::PackageItem {
596 name: "rg".into(),
597 version: "1".into(),
598 description: String::new(),
599 source: crate::state::Source::Aur,
600 popularity: None,
601 out_of_date: None,
602 orphaned: false,
603 }];
604 app.selected = 0;
605 let (tx, mut rx) = mpsc::unbounded_channel();
606 refresh_selected_details(&mut app, &tx);
607 let got = rx.try_recv().ok();
608 assert!(got.is_some());
609 }
610}