Skip to main content

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        crate::logic::clear_stale_pkgbuild_checks_for_selection(app, item.name.as_str());
50
51        // Update details pane immediately with a placeholder reflecting the selection
52        app.details.name.clone_from(&item.name);
53        app.details.version.clone_from(&item.version);
54        app.details.description.clear();
55        match &item.source {
56            crate::state::Source::Official { repo, arch } => {
57                app.details.repository.clone_from(repo);
58                app.details.architecture.clone_from(arch);
59            }
60            crate::state::Source::Aur => {
61                app.details.repository = "AUR".to_string();
62                app.details.architecture = "any".to_string();
63            }
64        }
65
66        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
67            app.details = cached;
68        } else {
69            let _ = details_tx.send(item.clone());
70        }
71
72        // Auto-reload PKGBUILD if visible and for a different package (with debounce)
73        if app.pkgb_visible {
74            let needs_reload = app.pkgb_package_name.as_deref() != Some(item.name.as_str());
75            if needs_reload {
76                // Instead of immediately loading, schedule a debounced reload
77                app.pkgb_reload_requested_at = Some(std::time::Instant::now());
78                app.pkgb_reload_requested_for = Some(item.name.clone());
79                app.pkgb_text = None; // Clear old PKGBUILD while loading
80                // Drop the last-successful package id so we never treat "same name as before" as
81                // "already showing this row" when `pkgb_text` is still empty because a stale
82                // in-flight response was ignored (e.g. official fetch completes after the user
83                // moved back to an AUR row).
84                app.pkgb_package_name = None;
85            }
86        }
87
88        // Auto-update comments if visible and for a different package (only for AUR packages)
89        if app.comments_visible && matches!(item.source, crate::state::Source::Aur) {
90            let needs_update = app
91                .comments_package_name
92                .as_deref()
93                .is_none_or(|cached_name| cached_name != item.name.as_str());
94            if needs_update {
95                // Check if we have cached comments for this package
96                if app
97                    .comments_package_name
98                    .as_ref()
99                    .is_some_and(|cached_name| {
100                        cached_name == &item.name && !app.comments.is_empty()
101                    })
102                {
103                    // Use cached comments, just reset scroll
104                    app.comments_scroll = 0;
105                } else {
106                    // Request new comments
107                    app.comments.clear();
108                    app.comments_package_name = None;
109                    app.comments_fetched_at = None;
110                    app.comments_scroll = 0;
111                    app.comments_loading = true;
112                    app.comments_error = None;
113                    let _ = comments_tx.send(item.name.clone());
114                }
115            }
116        }
117    }
118
119    // Debounce ring prefetch when scrolling fast (>5 items cumulatively)
120    let abs_delta_usize: usize = if delta < 0 {
121        usize::try_from(-delta).unwrap_or(0)
122    } else {
123        usize::try_from(delta).unwrap_or(0)
124    };
125    if abs_delta_usize > 0 {
126        let add = u32::try_from(abs_delta_usize.min(u32::MAX as usize))
127            .expect("value is bounded by u32::MAX");
128        app.scroll_moves = app.scroll_moves.saturating_add(add);
129    }
130    if app.need_ring_prefetch {
131        // tighten allowed set to only current selection during fast scroll
132        crate::logic::set_allowed_only_selected(app);
133        app.ring_resume_at =
134            Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
135        return;
136    }
137    if app.scroll_moves > 5 {
138        app.need_ring_prefetch = true;
139        crate::logic::set_allowed_only_selected(app);
140        app.ring_resume_at =
141            Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
142        return;
143    }
144
145    // For small/slow scrolls, allow ring and prefetch immediately
146    crate::logic::set_allowed_ring(app, 30);
147    crate::logic::ring_prefetch_from_selected(app, details_tx);
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn item_official(name: &str, repo: &str) -> crate::state::PackageItem {
155        crate::state::PackageItem {
156            name: name.to_string(),
157            version: "1.0".to_string(),
158            description: format!("{name} desc"),
159            source: crate::state::Source::Official {
160                repo: repo.to_string(),
161                arch: "x86_64".to_string(),
162            },
163            popularity: None,
164            out_of_date: None,
165            orphaned: false,
166        }
167    }
168
169    #[tokio::test]
170    /// What: Move selection with bounds, placeholder details, and request flow.
171    ///
172    /// Inputs:
173    /// - `app`: Results list seeded with one AUR and one official package, initial selection at index 0.
174    /// - `tx`: Unbounded channel capturing detail fetch requests while deltas of +1, -100, and 0 are applied.
175    ///
176    /// Output:
177    /// - Mutates `app` so indices clamp within bounds, details placeholders reflect the active selection, and a fetch request emits when switching to the official entry.
178    ///
179    /// Details:
180    /// - Uses a timeout on the receiver to assert the async request is produced and verifies placeholder data resets when returning to the AUR result.
181    async fn move_sel_cached_clamps_and_requests_details() {
182        let mut app = crate::state::AppState {
183            results: vec![
184                crate::state::PackageItem {
185                    name: "aur1".into(),
186                    version: "1".into(),
187                    description: String::new(),
188                    source: crate::state::Source::Aur,
189                    popularity: None,
190                    out_of_date: None,
191                    orphaned: false,
192                },
193                item_official("pkg2", "core"),
194            ],
195            selected: 0,
196            ..Default::default()
197        };
198
199        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
200        let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
201        move_sel_cached(&mut app, 1, &tx, &comments_tx);
202        assert_eq!(app.selected, 1);
203        assert_eq!(app.details.repository.to_lowercase(), "core");
204        assert_eq!(app.details.architecture.to_lowercase(), "x86_64");
205        let got = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv())
206            .await
207            .ok()
208            .flatten();
209        assert!(got.is_some());
210        move_sel_cached(&mut app, -100, &tx, &comments_tx);
211        assert_eq!(app.selected, 0);
212        move_sel_cached(&mut app, 0, &tx, &comments_tx);
213        assert_eq!(app.details.repository, "AUR");
214        assert_eq!(app.details.architecture, "any");
215    }
216
217    #[tokio::test]
218    /// What: When the PKGBUILD pane is open, switching rows clears `pkgb_package_name` so a later
219    /// return to the same AUR row still schedules a fetch if prior responses were dropped.
220    ///
221    /// Inputs:
222    /// - `AppState` with two results (AUR then official), PKGBUILD visible and loaded for the AUR row.
223    ///
224    /// Output:
225    /// - After moving to the official row, `pkgb_text` and `pkgb_package_name` are cleared and a
226    ///   debounced reload targets the official package name.
227    ///
228    /// Details:
229    /// - Without clearing `pkgb_package_name`, an ignored stale response can leave `pkgb_text`
230    ///   empty while the name still matches the AUR row, so `needs_reload` stays false forever.
231    async fn move_sel_cached_clears_pkgb_package_name_when_switching_with_viewer_open() {
232        let mut app = crate::state::AppState {
233            results: vec![
234                crate::state::PackageItem {
235                    name: "aur1".into(),
236                    version: "1".into(),
237                    description: String::new(),
238                    source: crate::state::Source::Aur,
239                    popularity: None,
240                    out_of_date: None,
241                    orphaned: false,
242                },
243                item_official("linux", "core"),
244            ],
245            selected: 0,
246            pkgb_visible: true,
247            pkgb_text: Some("# pkgbuild\n".into()),
248            pkgb_package_name: Some("aur1".into()),
249            ..Default::default()
250        };
251        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
252        let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
253        move_sel_cached(&mut app, 1, &tx, &comments_tx);
254        assert_eq!(app.selected, 1);
255        assert!(app.pkgb_text.is_none());
256        assert!(
257            app.pkgb_package_name.is_none(),
258            "pkgb_package_name must not outlive cleared text; stale ignores would strand loading state"
259        );
260        assert_eq!(app.pkgb_reload_requested_for.as_deref(), Some("linux"));
261    }
262
263    #[tokio::test]
264    /// What: Ensure cached details suppress additional fetch requests.
265    ///
266    /// Inputs:
267    /// - Results containing the cached package and an existing entry in `details_cache`.
268    ///
269    /// Output:
270    /// - No message emitted on the channel and `app.details` populated from the cache.
271    ///
272    /// Details:
273    /// - Confirms `move_sel_cached` short-circuits when cache contains the selected package.
274    async fn move_sel_cached_uses_details_cache() {
275        let mut app = crate::state::AppState::default();
276        let pkg = item_official("pkg", "core");
277        app.results = vec![pkg.clone()];
278        app.details_cache.insert(
279            pkg.name.clone(),
280            crate::state::PackageDetails {
281                repository: "core".into(),
282                name: pkg.name.clone(),
283                version: pkg.version.clone(),
284                architecture: "x86_64".into(),
285                ..Default::default()
286            },
287        );
288        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
289        let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
290        move_sel_cached(&mut app, 0, &tx, &comments_tx);
291        let none = tokio::time::timeout(std::time::Duration::from_millis(30), rx.recv())
292            .await
293            .ok()
294            .flatten();
295        assert!(none.is_none());
296        assert_eq!(app.details.name, "pkg");
297    }
298
299    #[test]
300    /// What: Verify fast-scroll gating requests ring prefetch and locks selection.
301    ///
302    /// Inputs:
303    /// - `app`: Populated results list with selection moved near the end to trigger fast-scroll logic.
304    ///
305    /// Output:
306    /// - `need_ring_prefetch` flag set, `ring_resume_at` populated, and allowed set restricted to the
307    ///   selected package.
308    ///
309    /// Details:
310    /// - Simulates a large positive index jump and ensures gating functions mark the correct state and
311    ///   enforce selection-only access.
312    fn fast_scroll_sets_gating_and_defers_ring() {
313        let mut app = crate::state::AppState {
314            results: vec![
315                item_official("a", "core"),
316                item_official("b", "extra"),
317                item_official("c", "extra"),
318                item_official("d", "extra"),
319                item_official("e", "extra"),
320                item_official("f", "extra"),
321                item_official("g", "extra"),
322            ],
323            ..Default::default()
324        };
325        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<crate::state::PackageItem>();
326        let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
327        move_sel_cached(&mut app, 6, &tx, &comments_tx);
328        assert!(app.need_ring_prefetch);
329        assert!(app.ring_resume_at.is_some());
330        crate::logic::set_allowed_only_selected(&app);
331        assert!(crate::logic::is_allowed(&app.results[app.selected].name));
332    }
333}