pacsea/events/
utils.rs

1use crossterm::event::KeyEvent;
2use tokio::sync::mpsc;
3
4use crate::state::{AppState, PackageItem};
5use std::time::Instant;
6
7/// What: Check if a key event matches any chord in a list, handling Shift+char edge cases.
8///
9/// Inputs:
10/// - `ke`: Key event from terminal
11/// - `list`: List of configured key chords to match against
12///
13/// Output:
14/// - `true` if the key event matches any chord in the list, `false` otherwise
15///
16/// Details:
17/// - Treats Shift+<char> from config as equivalent to uppercase char without Shift from terminal.
18/// - Handles cases where terminals report Shift inconsistently.
19#[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                // Accept uppercase event regardless of SHIFT flag
32                if ev_ch == cfg_ch.to_ascii_uppercase() {
33                    return true;
34                }
35                // Accept lowercase char if terminal reports SHIFT in modifiers
36                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/// What: Return the number of Unicode scalar values (characters) in the input.
49///
50/// Input: `s` string to measure
51/// Output: Character count as `usize`
52///
53/// Details: Counts Unicode scalar values using `s.chars().count()`.
54#[must_use]
55pub fn char_count(s: &str) -> usize {
56    s.chars().count()
57}
58
59/// What: Convert a character index to a byte index for slicing.
60///
61/// Input: `s` source string; `ci` character index
62/// Output: Byte index into `s` corresponding to `ci`
63///
64/// Details: Returns 0 for `ci==0`; returns `s.len()` when `ci>=char_count(s)`; otherwise maps
65/// the character index to a byte offset via `char_indices()`.
66#[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
81/// What: Advance selection in the Recent pane to the next/previous match of the pane-find pattern.
82///
83/// Input: `app` mutable application state; `forward` when true searches downward, else upward
84/// Output: No return value; updates `history_state` selection when a match is found
85///
86/// Details: Searches within the filtered Recent indices and wraps around the list; matching is
87/// case-insensitive against the current pane-find pattern.
88pub 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
117/// What: Advance selection in the Install pane to the next/previous item matching the pane-find pattern.
118///
119/// Input: `app` mutable application state; `forward` when true searches downward, else upward
120/// Output: No return value; updates `install_state` selection when a match is found
121///
122/// Details: Operates on visible indices and tests case-insensitive matches against package name
123/// or description; wraps around the list.
124pub 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
156/// What: Ensure details reflect the currently selected result.
157///
158/// Input: `app` mutable application state; `details_tx` channel for details requests
159/// Output: No return value; uses cache or sends a details request
160///
161/// Details: If details for the selected item exist in the cache, they are applied immediately;
162/// otherwise, the item is sent over `details_tx` to be fetched asynchronously.
163pub 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        // Reset scroll when package changes
169        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
178/// Move news selection by delta, keeping it in view.
179pub 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
205/// Synchronize details URL and content with currently selected news item.
206/// Also triggers content fetching if channel is provided and content is not cached.
207pub 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        // Check if content is cached
213        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 pre-metadata version: force refresh
219            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            // Content not cached - set debounce timer to wait 0.5 seconds before fetching
230            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
242/// Request news content fetch if not cached or loading.
243/// Implements 0.5 second debounce - only requests after user stays on item for 0.5 seconds.
244pub fn maybe_request_news_content(
245    app: &mut AppState,
246    news_content_req_tx: &mpsc::UnboundedSender<String>,
247) {
248    // Only request if in news mode with a selected item that has a URL
249    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        // Check debounce timer - only request after 0.5 seconds of staying on the item
266        // 500ms balances user experience with server load: long enough to avoid excessive
267        // fetches during rapid navigation, short enough to feel responsive.
268        const DEBOUNCE_DELAY_MS: u64 = 500;
269        if let Some(timer) = app.news_content_debounce_timer {
270            // Safe to unwrap: elapsed will be small (well within u64)
271            #[allow(clippy::cast_possible_truncation)]
272            let elapsed = timer.elapsed().as_millis() as u64;
273            if elapsed < DEBOUNCE_DELAY_MS {
274                // Debounce not expired yet - wait longer
275                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            // Debounce expired - clear timer and proceed with request
285            app.news_content_debounce_timer = None;
286        } else {
287            // No debounce timer set - this shouldn't happen, but set it now
288            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
339/// What: Ensure details reflect the selected item in the Install pane.
340///
341/// Input: `app` mutable application state; `details_tx` channel for details requests
342/// Output: No return value; focuses details on the selected Install item and uses cache or requests fetch
343///
344/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
345/// cache when present; otherwise sends a request over `details_tx`.
346pub 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        // Reset scroll when package changes
360        app.details_scroll = 0;
361        // Focus details on the install selection
362        app.details_focus = Some(item.name.clone());
363
364        // Provide an immediate placeholder reflecting the selection
365        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
387/// What: Ensure details reflect the selected item in the Remove pane.
388///
389/// Input: `app` mutable application state; `details_tx` channel for details requests
390/// Output: No return value; focuses details on the selected Remove item and uses cache or requests fetch
391///
392/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
393/// cache when present; otherwise sends a request over `details_tx`.
394pub 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        // Reset scroll when package changes
403        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
426/// What: Ensure details reflect the selected item in the Downgrade pane.
427///
428/// Input: `app` mutable application state; `details_tx` channel for details requests
429/// Output: No return value; focuses details on the selected Downgrade item and uses cache or requests fetch
430///
431/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
432/// cache when present; otherwise sends a request over `details_tx`.
433pub 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        // Reset scroll when package changes
445        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    /// What: Produce a baseline `AppState` tailored for utils tests.
473    ///
474    /// Inputs:
475    /// - None; relies on `Default::default()` for deterministic state.
476    ///
477    /// Output:
478    /// - Fresh `AppState` instance for individual unit tests.
479    ///
480    /// Details:
481    /// - Centralizes setup so each test starts from a clean copy without repeated boilerplate.
482    fn new_app() -> AppState {
483        AppState::default()
484    }
485
486    #[test]
487    /// What: Ensure `char_count` returns the number of Unicode scalar values.
488    ///
489    /// Inputs:
490    /// - Strings `"abc"`, `"π"`, and `"aπb"`.
491    ///
492    /// Output:
493    /// - Counts `3`, `1`, and `3` respectively.
494    ///
495    /// Details:
496    /// - Demonstrates correct handling of multi-byte characters.
497    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    /// What: Verify `byte_index_for_char` translates character indices to UTF-8 byte offsets.
505    ///
506    /// Inputs:
507    /// - String `"aπb"` with char indices 0 through 3.
508    ///
509    /// Output:
510    /// - Returns byte offsets `0`, `1`, `3`, and `len`.
511    ///
512    /// Details:
513    /// - Confirms the function respects variable-width encoding.
514    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    /// What: Ensure `find_in_recent` cycles through entries matching the pane filter.
524    ///
525    /// Inputs:
526    /// - Recent list `alpha`, `beta`, `gamma` with filter `"a"`.
527    ///
528    /// Output:
529    /// - Selection rotates among matching entries without panicking.
530    ///
531    /// Details:
532    /// - Provides smoke coverage for the wrap-around logic inside the helper.
533    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    /// What: Check `find_in_install` advances selection to the next matching entry by name or description.
544    ///
545    /// Inputs:
546    /// - Install list with `ripgrep` and `fd`, filter term `"rip"` while selection starts on the second item.
547    ///
548    /// Output:
549    /// - Selection wraps to the first item containing the filter term.
550    ///
551    /// Details:
552    /// - Protects against regressions in forward search and wrap-around behaviour.
553    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        // Start from visible selection 1 so advancing wraps to 0 matching "ripgrep"
577        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    /// What: Ensure `refresh_selected_details` dispatches a fetch when cache misses occur.
584    ///
585    /// Inputs:
586    /// - Results list with a single entry and an empty details cache.
587    ///
588    /// Output:
589    /// - Sends the selected item through `details_tx`, confirming a fetch request.
590    ///
591    /// Details:
592    /// - Uses an unbounded channel to observe the request without performing actual I/O.
593    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}