putzen_cli/caches/tui/
runtime.rs1use 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 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 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 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 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 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 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 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 break;
225 }
226 Ok(_) => break,
228 Err(e) => return Err(e),
229 },
230 Ok(false) => {
231 if animating {
232 state = step(state, Msg::Tick, &runner);
234 break;
235 }
236 continue;
238 }
239 Err(e) => return Err(e),
240 }
241 }
242 }
243
244 let total = state.total_freed;
245 Ok((state, total))
246}