Skip to main content

putzen_cli/caches/tui/
state.rs

1//! TUI application state.
2
3use super::filter::Filter;
4use crate::caches::model::{Cache, FloorPolicy, MarkSet, Sort};
5use std::path::PathBuf;
6use std::time::SystemTime;
7
8/// Frames of the loading spinner glyph, advanced once per event-loop idle tick.
9pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
11/// Transient overlay shown after a delete pass completes. Dismissed
12/// automatically after 2 s via `Effect::EmitAfter`.
13pub struct Overlay {
14    pub outcome: RunOutcome,
15}
16
17/// Outcome of a real or dry-run cache deletion pass.
18pub struct RunOutcome {
19    pub freed: u64,
20    pub deleted: usize,
21    /// Items the cleaner returned an `Err` for. `0` on dry runs.
22    pub failed: usize,
23    pub dry_run: bool,
24}
25
26/// Visual state of a background scan in progress.
27pub struct Loading {
28    /// Human label of the cache being scanned — shown in the spinner modal.
29    pub label: String,
30    /// Spinner animation frame index into `SPINNER_FRAMES`.
31    pub frame: usize,
32    /// When the scan started; used to render elapsed time when no per-task
33    /// progress signal is available.
34    pub started: std::time::Instant,
35    /// `Some(n)` when the worker streams a folder-count via `ScanProgress`
36    /// (the LoadSeeds startup scan). `None` for spinners that don't carry a
37    /// progress signal, in which case the view falls back to elapsed time.
38    pub folders: Option<usize>,
39}
40
41impl Loading {
42    /// Advance the spinner one frame, wrapping at the end of the glyph cycle.
43    pub fn update_frame(&mut self) {
44        self.frame = (self.frame + 1) % SPINNER_FRAMES.len();
45    }
46
47    /// Current spinner glyph.
48    pub fn glyph(&self) -> &'static str {
49        SPINNER_FRAMES[self.frame]
50    }
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub enum Modal {
55    #[default]
56    None,
57    DeleteConfirm,
58    ActiveMark(Vec<usize>),
59    FilterEdit,
60}
61
62pub struct State {
63    pub now: SystemTime,
64    pub all: Vec<Cache>,
65    pub sort: Sort,
66    pub marks: MarkSet,
67    pub cursor: usize,
68    pub files_cursor: usize,
69    pub floor: FloorPolicy,
70    pub focus_right: bool,
71    pub stack: Vec<Vec<Cache>>, // drill-in: parent levels saved here
72    pub stack_labels: Vec<String>,
73    pub quit: bool,
74    pub modal: Modal,
75    pub dry_run: bool,
76    pub yes_mode: bool,
77    /// Bytes freed across all deletion passes in this session.
78    pub total_freed: u64,
79    /// When `Some`, a less/vim-style `/` filter is active (possibly being
80    /// edited). When `None`, no filter is applied.
81    pub filter: Option<Filter>,
82    /// `Some` while a background drill-in scan is running; drives the
83    /// spinner modal.
84    pub loading: Option<Loading>,
85    /// `Some` for ~2 s after a delete pass completes; draws the result
86    /// overlay until `Msg::OverlayDismiss` is received.
87    pub overlay: Option<Overlay>,
88    /// Set to true whenever something was successfully deleted at the
89    /// current drill level. Reset on drill in/out. When we drill out and
90    /// this was true, the parent's row for the cache we're leaving is
91    /// re-scanned to reflect the smaller size.
92    pub level_dirty: bool,
93    /// Path stack parallel to `stack` so we know which entry in the
94    /// restored parent corresponds to the cache we just drilled out of.
95    /// Pushed on `drill_into`, popped on `drill_out`.
96    pub drill_paths: Vec<PathBuf>,
97    /// Cursor positions parallel to `stack`. On `drill_into` we save the
98    /// current cursor; on `drill_out` we restore it (then clamp), so the
99    /// user lands back on the row they were on instead of at the top.
100    pub cursor_stack: Vec<usize>,
101}
102
103impl State {
104    pub fn sorted_indices(&self) -> Vec<usize> {
105        let mut idx: Vec<usize> = (0..self.all.len()).collect();
106        if let Some(f) = &self.filter {
107            idx.retain(|&i| f.is_visible(&self.all[i].path));
108        }
109        match self.sort {
110            Sort::Score => idx.sort_by(|&a, &b| {
111                self.all[b]
112                    .score(self.now)
113                    .partial_cmp(&self.all[a].score(self.now))
114                    .unwrap()
115            }),
116            Sort::Size => idx.sort_by(|&a, &b| self.all[b].size_bytes.cmp(&self.all[a].size_bytes)),
117            Sort::Age => idx.sort_by(|&a, &b| {
118                let aa = self.all[a].age(self.now).map(|d| d.as_secs());
119                let bb = self.all[b].age(self.now).map(|d| d.as_secs());
120                match (aa, bb) {
121                    (Some(x), Some(y)) => y.cmp(&x), // descending: older first
122                    (Some(_), None) => std::cmp::Ordering::Less,
123                    (None, Some(_)) => std::cmp::Ordering::Greater,
124                    (None, None) => std::cmp::Ordering::Equal,
125                }
126            }),
127        }
128        idx
129    }
130
131    pub(crate) fn clamp_cursor_to_visible(&mut self) {
132        let n = self.sorted_indices().len();
133        if n == 0 {
134            self.cursor = 0;
135        } else if self.cursor >= n {
136            self.cursor = n - 1;
137        }
138    }
139
140    pub fn drill_into(&mut self, children: Vec<Cache>) {
141        let parent = std::mem::replace(&mut self.all, children);
142        self.cursor_stack.push(self.cursor);
143        self.stack.push(parent);
144        self.cursor = 0;
145        self.marks.clear(); // marks are index-keyed; reset on level change
146        self.level_dirty = false;
147    }
148
149    pub fn drill_out(&mut self) {
150        let _ = self.drill_out_with_path();
151    }
152
153    /// Same as `drill_out` but also returns the path of the cache we just
154    /// left (for the event loop to trigger a refresh). Returns `None`
155    /// when already at the top level.
156    pub fn drill_out_with_path(&mut self) -> Option<PathBuf> {
157        // Only pop drill_paths / cursor_stack when we actually pop the
158        // stack — otherwise calling drill_out twice at the top level would
159        // silently desync them from stack.len().
160        if let Some(parent) = self.stack.pop() {
161            self.all = parent;
162            // Restore the cursor the user had when they drilled in. Clamp
163            // against the new visible set in case a refresh shifted things.
164            self.cursor = self.cursor_stack.pop().unwrap_or(0);
165            self.marks.clear();
166            self.stack_labels.pop();
167            self.level_dirty = false;
168            let popped = self.drill_paths.pop();
169            self.clamp_cursor_to_visible();
170            popped
171        } else {
172            None
173        }
174    }
175}