pacsea/logic/
selection.rs

1//! Selection movement and detail coordination during navigation.
2
3use tokio::sync::mpsc;
4
5use crate::state::{AppState, PackageItem};
6
7/// What: Move the selection by `delta` and coordinate detail loading policies.
8///
9/// Inputs:
10/// - `app`: Mutable application state (results, selection, caches, scroll heuristics).
11/// - `delta`: Signed offset to apply to the current selection index.
12/// - `details_tx`: Channel used to request lazy loading of package details.
13/// - `comments_tx`: Channel used to request AUR package comments.
14///
15/// Output:
16/// - Updates selection-related state, potentially sends detail requests, and adjusts gating flags.
17///
18/// # Panics
19/// - Panics if `abs_delta_usize` exceeds `u32::MAX` when converting to `u32`
20/// - May panic if `app.list_state.select` is called with an invalid index (depends on the list state implementation)
21///
22/// Details:
23/// - Clamps the selection to valid bounds, refreshes placeholder metadata, and reuses cached entries.
24/// - Schedules PKGBUILD reloads when necessary and tracks scroll velocity to throttle prefetching.
25/// - Updates comments when package changes and comments are visible (only for AUR packages).
26/// - Switches between selected-only gating during fast scrolls and wide ring prefetch for slower navigation.
27pub fn move_sel_cached(
28    app: &mut AppState,
29    delta: isize,
30    details_tx: &mpsc::UnboundedSender<PackageItem>,
31    comments_tx: &mpsc::UnboundedSender<String>,
32) {
33    if app.results.is_empty() {
34        return;
35    }
36    let len = isize::try_from(app.results.len()).unwrap_or(isize::MAX);
37    let mut idx = isize::try_from(app.selected).unwrap_or(0) + delta;
38    if idx < 0 {
39        idx = 0;
40    }
41    if idx >= len {
42        idx = len - 1;
43    }
44    app.selected = usize::try_from(idx).unwrap_or(0);
45    app.list_state.select(Some(app.selected));
46    if let Some(item) = app.results.get(app.selected).cloned() {
47        // Focus details on the currently selected item only
48        app.details_focus = Some(item.name.clone());
49
50        // Update details pane immediately with a placeholder reflecting the selection
51        app.details.name.clone_from(&item.name);
52        app.details.version.clone_from(&item.version);
53        app.details.description.clear();
54        match &item.source {
55            crate::state::Source::Official { repo, arch } => {
56                app.details.repository.clone_from(repo);
57                app.details.architecture.clone_from(arch);
58            }
59            crate::state::Source::Aur => {
60                app.details.repository = "AUR".to_string();
61                app.details.architecture = "any".to_string();
62            }
63        }
64
65        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
66            app.details = cached;
67        } else {
68            let _ = details_tx.send(item.clone());
69        }
70
71        // Auto-reload PKGBUILD if visible and for a different package (with debounce)
72        if app.pkgb_visible {
73            let needs_reload = app.pkgb_package_name.as_deref() != Some(item.name.as_str());
74            if needs_reload {
75                // Instead of immediately loading, schedule a debounced reload
76                app.pkgb_reload_requested_at = Some(std::time::Instant::now());
77                app.pkgb_reload_requested_for = Some(item.name.clone());
78                app.pkgb_text = None; // Clear old PKGBUILD while loading
79            }
80        }
81
82        // Auto-update comments if visible and for a different package (only for AUR packages)
83        if app.comments_visible && matches!(item.source, crate::state::Source::Aur) {
84            let needs_update = app
85                .comments_package_name
86                .as_deref()
87                .is_none_or(|cached_name| cached_name != item.name.as_str());
88            if needs_update {
89                // Check if we have cached comments for this package
90                if app
91                    .comments_package_name
92                    .as_ref()
93                    .is_some_and(|cached_name| {
94                        cached_name == &item.name && !app.comments.is_empty()
95                    })
96                {
97                    // Use cached comments, just reset scroll
98                    app.comments_scroll = 0;
99                } else {
100                    // Request new comments
101                    app.comments.clear();
102                    app.comments_package_name = None;
103                    app.comments_fetched_at = None;
104                    app.comments_scroll = 0;
105                    app.comments_loading = true;
106                    app.comments_error = None;
107                    let _ = comments_tx.send(item.name.clone());
108                }
109            }
110        }
111    }
112
113    // Debounce ring prefetch when scrolling fast (>5 items cumulatively)
114    let abs_delta_usize: usize = if delta < 0 {
115        usize::try_from(-delta).unwrap_or(0)
116    } else {
117        usize::try_from(delta).unwrap_or(0)
118    };
119    if abs_delta_usize > 0 {
120        let add = u32::try_from(abs_delta_usize.min(u32::MAX as usize))
121            .expect("value is bounded by u32::MAX");
122        app.scroll_moves = app.scroll_moves.saturating_add(add);
123    }
124    if app.need_ring_prefetch {
125        // tighten allowed set to only current selection during fast scroll
126        crate::logic::set_allowed_only_selected(app);
127        app.ring_resume_at =
128            Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
129        return;
130    }
131    if app.scroll_moves > 5 {
132        app.need_ring_prefetch = true;
133        crate::logic::set_allowed_only_selected(app);
134        app.ring_resume_at =
135            Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
136        return;
137    }
138
139    // For small/slow scrolls, allow ring and prefetch immediately
140    crate::logic::set_allowed_ring(app, 30);
141    crate::logic::ring_prefetch_from_selected(app, details_tx);
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn item_official(name: &str, repo: &str) -> crate::state::PackageItem {
149        crate::state::PackageItem {
150            name: name.to_string(),
151            version: "1.0".to_string(),
152            description: format!("{name} desc"),
153            source: crate::state::Source::Official {
154                repo: repo.to_string(),
155                arch: "x86_64".to_string(),
156            },
157            popularity: None,
158            out_of_date: None,
159            orphaned: false,
160        }
161    }
162
163    #[tokio::test]
164    /// What: Move selection with bounds, placeholder details, and request flow.
165    ///
166    /// Inputs:
167    /// - `app`: Results list seeded with one AUR and one official package, initial selection at index 0.
168    /// - `tx`: Unbounded channel capturing detail fetch requests while deltas of +1, -100, and 0 are applied.
169    ///
170    /// Output:
171    /// - Mutates `app` so indices clamp within bounds, details placeholders reflect the active selection, and a fetch request emits when switching to the official entry.
172    ///
173    /// Details:
174    /// - Uses a timeout on the receiver to assert the async request is produced and verifies placeholder data resets when returning to the AUR result.
175    async fn move_sel_cached_clamps_and_requests_details() {
176        let mut app = crate::state::AppState {
177            results: vec![
178                crate::state::PackageItem {
179                    name: "aur1".into(),
180                    version: "1".into(),
181                    description: String::new(),
182                    source: crate::state::Source::Aur,
183                    popularity: None,
184                    out_of_date: None,
185                    orphaned: false,
186                },
187                item_official("pkg2", "core"),
188            ],
189            selected: 0,
190            ..Default::default()
191        };
192
193        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
194        let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
195        move_sel_cached(&mut app, 1, &tx, &comments_tx);
196        assert_eq!(app.selected, 1);
197        assert_eq!(app.details.repository.to_lowercase(), "core");
198        assert_eq!(app.details.architecture.to_lowercase(), "x86_64");
199        let got = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv())
200            .await
201            .ok()
202            .flatten();
203        assert!(got.is_some());
204        move_sel_cached(&mut app, -100, &tx, &comments_tx);
205        assert_eq!(app.selected, 0);
206        move_sel_cached(&mut app, 0, &tx, &comments_tx);
207        assert_eq!(app.details.repository, "AUR");
208        assert_eq!(app.details.architecture, "any");
209    }
210
211    #[tokio::test]
212    /// What: Ensure cached details suppress additional fetch requests.
213    ///
214    /// Inputs:
215    /// - Results containing the cached package and an existing entry in `details_cache`.
216    ///
217    /// Output:
218    /// - No message emitted on the channel and `app.details` populated from the cache.
219    ///
220    /// Details:
221    /// - Confirms `move_sel_cached` short-circuits when cache contains the selected package.
222    async fn move_sel_cached_uses_details_cache() {
223        let mut app = crate::state::AppState::default();
224        let pkg = item_official("pkg", "core");
225        app.results = vec![pkg.clone()];
226        app.details_cache.insert(
227            pkg.name.clone(),
228            crate::state::PackageDetails {
229                repository: "core".into(),
230                name: pkg.name.clone(),
231                version: pkg.version.clone(),
232                architecture: "x86_64".into(),
233                ..Default::default()
234            },
235        );
236        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
237        let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
238        move_sel_cached(&mut app, 0, &tx, &comments_tx);
239        let none = tokio::time::timeout(std::time::Duration::from_millis(30), rx.recv())
240            .await
241            .ok()
242            .flatten();
243        assert!(none.is_none());
244        assert_eq!(app.details.name, "pkg");
245    }
246
247    #[test]
248    /// What: Verify fast-scroll gating requests ring prefetch and locks selection.
249    ///
250    /// Inputs:
251    /// - `app`: Populated results list with selection moved near the end to trigger fast-scroll logic.
252    ///
253    /// Output:
254    /// - `need_ring_prefetch` flag set, `ring_resume_at` populated, and allowed set restricted to the
255    ///   selected package.
256    ///
257    /// Details:
258    /// - Simulates a large positive index jump and ensures gating functions mark the correct state and
259    ///   enforce selection-only access.
260    fn fast_scroll_sets_gating_and_defers_ring() {
261        let mut app = crate::state::AppState {
262            results: vec![
263                item_official("a", "core"),
264                item_official("b", "extra"),
265                item_official("c", "extra"),
266                item_official("d", "extra"),
267                item_official("e", "extra"),
268                item_official("f", "extra"),
269                item_official("g", "extra"),
270            ],
271            ..Default::default()
272        };
273        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<crate::state::PackageItem>();
274        let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
275        move_sel_cached(&mut app, 6, &tx, &comments_tx);
276        assert!(app.need_ring_prefetch);
277        assert!(app.ring_resume_at.is_some());
278        crate::logic::set_allowed_only_selected(&app);
279        assert!(crate::logic::is_allowed(&app.results[app.selected].name));
280    }
281}