Skip to main content

putzen_cli/caches/tui/
runtime.rs

1//! Event loop, effect runner, terminal lifecycle.
2
3use std::io;
4use std::time::{Duration, Instant};
5
6use ratatui::backend::CrosstermBackend;
7use ratatui::crossterm::{
8    execute,
9    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use ratatui::Terminal;
12
13use super::effect::Effect;
14use super::keys;
15use super::msg::Msg;
16use super::state::{Modal, State};
17use super::update::update;
18use super::view;
19
20pub type Term = Terminal<CrosstermBackend<io::Stdout>>;
21
22pub fn enter_tui() -> io::Result<Term> {
23    enable_raw_mode()?;
24    let mut out = io::stdout();
25    // No EnableMouseCapture: we don't consume mouse events, and capturing
26    // them would block the terminal's native text selection — which is how
27    // the user copies the cache path out of the details pane.
28    execute!(out, EnterAlternateScreen)?;
29    Terminal::new(CrosstermBackend::new(out))
30}
31
32pub fn leave_tui(term: &mut Term) -> io::Result<()> {
33    let _ = disable_raw_mode();
34    let _ = execute!(term.backend_mut(), LeaveAlternateScreen);
35    let _ = term.show_cursor();
36    Ok(())
37}
38
39struct EffectRunner {
40    msg_tx: std::sync::mpsc::Sender<Msg>,
41}
42
43impl EffectRunner {
44    fn run(&self, eff: Effect) {
45        let tx = self.msg_tx.clone();
46        match eff {
47            Effect::SpawnScan {
48                parent_label,
49                parent_path,
50            } => {
51                std::thread::spawn(move || {
52                    // Same throttled folder-count stream as LoadSeeds so the
53                    // spinner reads identically for drill-in scans.
54                    const PROGRESS_EVERY: usize = 200;
55                    let mut total = 0usize;
56                    let progress_tx = tx.clone();
57                    let children = crate::caches::scan::enumerate_seed_with_progress(
58                        &parent_path,
59                        &mut || {
60                            total += 1;
61                            if total.is_multiple_of(PROGRESS_EVERY) {
62                                let _ = progress_tx.send(Msg::ScanProgress { folders: total });
63                            }
64                        },
65                    );
66                    let _ = tx.send(Msg::ScanProgress { folders: total });
67                    let _ = tx.send(Msg::ScanCompleted {
68                        parent_label,
69                        parent_path,
70                        children,
71                    });
72                });
73            }
74            Effect::SpawnRefresh { path } => {
75                std::thread::spawn(move || {
76                    let cache = crate::caches::scan::stat_dir(&path);
77                    let _ = tx.send(Msg::RefreshCompleted { path, cache });
78                });
79            }
80            Effect::SpawnDelete { items, dry_run } => {
81                std::thread::spawn(move || {
82                    use crate::cleaner::{Clean, DoCleanUp, DryRunCleaner, ProperCleaner};
83                    let cleaner: Box<dyn DoCleanUp> = if dry_run {
84                        Box::new(DryRunCleaner)
85                    } else {
86                        Box::new(ProperCleaner)
87                    };
88                    let mut freed = 0u64;
89                    let mut deleted_count = 0usize;
90                    let mut failed_count = 0usize;
91                    let mut deleted_indices: Vec<usize> = Vec::new();
92                    for (idx, path, size) in &items {
93                        match cleaner.do_cleanup(path) {
94                            Ok(Clean::Cleaned) => {
95                                freed += *size;
96                                deleted_count += 1;
97                                deleted_indices.push(*idx);
98                            }
99                            Ok(Clean::NotCleaned) => {
100                                freed += *size;
101                                deleted_count += 1;
102                            }
103                            Err(_) => {
104                                failed_count += 1;
105                            }
106                        }
107                    }
108                    let _ = tx.send(Msg::DeleteCompleted {
109                        freed,
110                        deleted_count,
111                        failed_count,
112                        deleted_indices,
113                    });
114                });
115            }
116            Effect::EmitAfter { dur, msg } => {
117                std::thread::spawn(move || {
118                    std::thread::sleep(dur);
119                    let _ = tx.send(msg);
120                });
121            }
122            Effect::LoadSeeds { seeds } => {
123                std::thread::spawn(move || {
124                    // Stream folder counts back so the spinner shows progress
125                    // instead of just elapsed time.  Throttle to roughly once
126                    // per 200 directories so we don't spam the channel.
127                    const PROGRESS_EVERY: usize = 200;
128                    let mut total = 0usize;
129                    let progress_tx = tx.clone();
130                    let caches = crate::caches::scan::collect_with_progress(&seeds, &mut || {
131                        total += 1;
132                        if total.is_multiple_of(PROGRESS_EVERY) {
133                            let _ = progress_tx.send(Msg::ScanProgress { folders: total });
134                        }
135                    });
136                    // Flush the final count and the completed list.
137                    let _ = tx.send(Msg::ScanProgress { folders: total });
138                    let _ = tx.send(Msg::SeedsLoaded { caches });
139                });
140            }
141        }
142    }
143}
144
145fn step(state: State, msg: Msg, runner: &EffectRunner) -> State {
146    let (mut state, mut cmd) = update(state, msg);
147    while !cmd.events.is_empty() {
148        let ev = cmd.events.remove(0);
149        let (next, more) = update(state, ev);
150        state = next;
151        cmd = cmd.and(more);
152    }
153    for eff in cmd.effects {
154        runner.run(eff);
155    }
156    state
157}
158
159pub fn run_loop(
160    term: &mut Term,
161    mut state: State,
162    initial_effects: Vec<Effect>,
163) -> io::Result<(State, u64)> {
164    const FRAME_BUDGET: Duration = Duration::from_millis(33);
165
166    let (msg_tx, msg_rx) = std::sync::mpsc::channel::<Msg>();
167    let runner = EffectRunner {
168        msg_tx: msg_tx.clone(),
169    };
170
171    // Dispatch any boot-time effects (e.g. the initial seed scan) before the
172    // first render so their workers are already running while the spinner
173    // shows up.
174    for eff in initial_effects {
175        runner.run(eff);
176    }
177
178    loop {
179        let frame_start = Instant::now();
180        term.draw(|f| view::render(&mut state, f.area(), f.buffer_mut()))?;
181        if state.quit {
182            break;
183        }
184
185        let deadline = frame_start + FRAME_BUDGET;
186        let animating = state.loading.is_some() || state.overlay.is_some();
187
188        loop {
189            // Drain any queued background msgs eagerly without rendering between them.
190            if let Ok(m) = msg_rx.try_recv() {
191                state = step(state, m, &runner);
192                if state.quit {
193                    break;
194                }
195                continue;
196            }
197
198            // Animating: stay inside the frame budget so the spinner ticks at 30 fps.
199            // Idle: long blocking poll, no redundant renders.
200            let poll_for = if animating {
201                deadline.saturating_duration_since(Instant::now())
202            } else {
203                Duration::from_millis(250)
204            };
205
206            match ratatui::crossterm::event::poll(poll_for) {
207                Ok(true) => match ratatui::crossterm::event::read() {
208                    Ok(ratatui::crossterm::event::Event::Key(k))
209                        if k.kind == ratatui::crossterm::event::KeyEventKind::Press =>
210                    {
211                        let modal = match &state.modal {
212                            Modal::DeleteConfirm => keys::ModalKind::DeleteConfirm,
213                            Modal::ActiveMark(_) => keys::ModalKind::ActiveMark,
214                            Modal::FilterEdit => keys::ModalKind::FilterEdit,
215                            Modal::None => keys::ModalKind::None,
216                        };
217                        if let Some(msg) = keys::key_to_msg(k, modal, state.focus_right) {
218                            state = step(state, msg, &runner);
219                            if state.quit {
220                                break;
221                            }
222                        }
223                        // Re-render after every keypress for snappy input → cursor latency.
224                        break;
225                    }
226                    // Mouse / resize / focus events: re-render so layout updates apply.
227                    Ok(_) => break,
228                    Err(e) => return Err(e),
229                },
230                Ok(false) => {
231                    if animating {
232                        // Advance the spinner and render the next frame.
233                        state = step(state, Msg::Tick, &runner);
234                        break;
235                    }
236                    // Idle timeout, nothing changed — keep waiting; no re-render.
237                    continue;
238                }
239                Err(e) => return Err(e),
240            }
241        }
242    }
243
244    let total = state.total_freed;
245    Ok((state, total))
246}