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}