1use ratatui::{
4 buffer::Buffer,
5 layout::{Alignment, Constraint, Direction, Layout, Rect},
6 style::{Modifier, Style},
7 text::{Line, Span},
8 widgets::{
9 Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
10 ScrollbarState, StatefulWidget, Widget,
11 },
12};
13
14use super::widgets::Theme;
15use super::{Modal, RunOutcome, State};
16use crate::caches::format::{
17 human_age, human_count, human_date, human_int, human_size, human_size_parts, pluralize,
18 tildify, truncate_with_ellipsis,
19};
20
21const THEME: Theme = Theme::GRUVBOX;
22
23pub(super) const ACTIVITY_BUCKETS: [u64; 8] = [
26 86_400,
27 604_800,
28 2_592_000,
29 7_776_000,
30 15_552_000,
31 31_536_000,
32 94_608_000,
33 u64::MAX,
34];
35pub(super) const SPARKS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
37
38pub fn render(state: &mut State, area: Rect, buf: &mut Buffer) {
39 let outer = Layout::default()
42 .direction(Direction::Vertical)
43 .constraints([Constraint::Min(5), Constraint::Length(1)])
44 .split(area);
45
46 let body = Layout::default()
49 .direction(Direction::Horizontal)
50 .constraints([Constraint::Ratio(7, 10), Constraint::Ratio(3, 10)])
51 .split(outer[0]);
52
53 render_left(state, body[0], buf);
54 render_right(state, body[1], buf);
55 render_footer_keys(&*state, outer[1], buf);
56 render_modal(&*state, area, buf);
57 render_active_mark_modal(&*state, area, buf);
58 render_loading_modal(&*state, area, buf);
59 if let Some(ov) = state.overlay.as_ref() {
60 draw_result(&ov.outcome, area, buf);
61 }
62}
63
64fn render_loading_modal(state: &State, area: Rect, buf: &mut Buffer) {
65 let Some(l) = state.loading.as_ref() else {
66 return;
67 };
68 let body_style = THEME.modal_body_style();
69 let block_style = THEME.modal_block_style();
70
71 let spinner = format!("{} {}", l.glyph(), l.label);
72 let detail_line = match l.folders {
73 Some(n) => format!(
74 "scanned {} {}",
75 human_int(n as u64),
76 pluralize(n as u64, "folder", "folders")
77 ),
78 None => {
79 let s = l.started.elapsed().as_secs();
80 if s > 0 {
81 format!("{s}s elapsed")
82 } else {
83 String::new()
84 }
85 }
86 };
87
88 let mut lines = vec![
89 Line::from(Span::raw("")),
90 Line::from(Span::styled(
91 spinner,
92 body_style.add_modifier(Modifier::BOLD),
93 )),
94 ];
95 if !detail_line.is_empty() {
96 lines.push(Line::from(Span::raw("")));
97 lines.push(Line::from(Span::styled(detail_line, THEME.dim_style())));
98 }
99
100 let h = (lines.len() as u16 + 2).min(area.height).max(5);
101 let w = area.width.min(60);
102 let x = area.x + (area.width.saturating_sub(w)) / 2;
103 let y = area.y + (area.height.saturating_sub(h)) / 2;
104 let modal = Rect {
105 x,
106 y,
107 width: w,
108 height: h,
109 };
110
111 Clear.render(modal, buf);
112 Paragraph::new(lines)
113 .style(body_style)
114 .alignment(Alignment::Center)
115 .block(
116 Block::default()
117 .borders(Borders::ALL)
118 .border_style(block_style)
119 .style(body_style)
120 .title(Span::styled(
121 " Loading ",
122 block_style.add_modifier(Modifier::BOLD),
123 ))
124 .title_alignment(Alignment::Center),
125 )
126 .render(modal, buf);
127}
128
129pub fn draw_result(outcome: &RunOutcome, area: Rect, buf: &mut Buffer) {
132 let w = area.width.min(60);
133 let h = area.height.min(7);
134 let x = area.x + (area.width.saturating_sub(w)) / 2;
135 let y = area.y + (area.height.saturating_sub(h)) / 2;
136 let modal = Rect {
137 x,
138 y,
139 width: w,
140 height: h,
141 };
142
143 let body_style = THEME.modal_body_style();
144 let block_style = THEME.modal_block_style();
145
146 let title_text = if outcome.dry_run {
147 " Dry run "
148 } else {
149 " Done "
150 };
151 let failed_suffix = if outcome.failed > 0 {
152 format!(" ({} failed)", outcome.failed)
153 } else {
154 String::new()
155 };
156 let noun = pluralize(outcome.deleted as u64, "folder", "folders");
157 let line = if outcome.dry_run {
158 format!(
159 "Would free {} across {} {noun}{failed_suffix}",
160 human_size(outcome.freed),
161 outcome.deleted,
162 )
163 } else {
164 format!(
165 "Freed {} across {} {noun}{failed_suffix}",
166 human_size(outcome.freed),
167 outcome.deleted,
168 )
169 };
170
171 Clear.render(modal, buf);
172 Paragraph::new(vec![
173 Line::from(Span::raw("")),
174 Line::from(Span::styled(line, body_style.add_modifier(Modifier::BOLD))),
175 ])
176 .style(body_style)
177 .alignment(Alignment::Center)
178 .block(
179 Block::default()
180 .borders(Borders::ALL)
181 .border_style(block_style)
182 .style(body_style)
183 .title(Span::styled(
184 title_text,
185 block_style.add_modifier(Modifier::BOLD),
186 ))
187 .title_alignment(Alignment::Center),
188 )
189 .render(modal, buf);
190}
191
192fn render_active_mark_modal(state: &State, area: Rect, buf: &mut Buffer) {
193 if !matches!(state.modal, Modal::ActiveMark(_)) {
194 return;
195 }
196 let w = area.width.min(64);
197 let h = area.height.min(9);
198 let x = area.x + (area.width.saturating_sub(w)) / 2;
199 let y = area.y + (area.height.saturating_sub(h)) / 2;
200 let modal = Rect {
201 x,
202 y,
203 width: w,
204 height: h,
205 };
206
207 let body_style = THEME.modal_body_style();
208 let block_style = THEME.modal_block_style();
209 let key_style = THEME.gutter_active_style();
210
211 let n_days = state.floor.floor.as_secs() / 86_400;
212
213 let lines = vec![
214 Line::from(Span::raw("")),
215 Line::from(Span::styled(
216 format!("The cache folder age is < {n_days} days,"),
217 body_style,
218 )),
219 Line::from(Span::styled(
220 "so that cache seems to be active.",
221 body_style,
222 )),
223 Line::from(Span::styled(
224 "Sure marking it for deletion?",
225 body_style.add_modifier(Modifier::BOLD),
226 )),
227 Line::from(Span::raw("")),
228 Line::from(vec![
229 Span::styled(" [", body_style),
230 Span::styled("y", key_style),
231 Span::styled("] yes ", body_style),
232 Span::styled("[", body_style),
233 Span::styled("N", key_style),
234 Span::styled("] cancel", body_style),
235 ]),
236 ];
237
238 Clear.render(modal, buf);
239 Paragraph::new(lines)
240 .style(body_style)
241 .alignment(Alignment::Center)
242 .block(
243 Block::default()
244 .borders(Borders::ALL)
245 .border_style(block_style)
246 .style(body_style)
247 .title(Span::styled(
248 " Confirm marking active cache ",
249 block_style.add_modifier(Modifier::BOLD),
250 ))
251 .title_alignment(Alignment::Center),
252 )
253 .render(modal, buf);
254}
255
256fn render_modal(state: &State, area: Rect, buf: &mut Buffer) {
257 if !matches!(state.modal, Modal::DeleteConfirm) {
258 return;
259 }
260
261 let body_style = THEME.modal_body_style();
262 let dim_style = THEME.dim_style();
263 let block_style = THEME.modal_block_style();
264 let key_style = THEME.gutter_active_style();
265
266 let total: u64 = state
267 .marks
268 .marked
269 .iter()
270 .filter_map(|&i| state.all.get(i).map(|c| c.size_bytes))
271 .sum();
272 let count = state.marks.count();
273 const MAX_LIST: usize = 3;
274
275 let mut lines: Vec<Line> = vec![Line::from(Span::raw(""))];
276
277 if count <= MAX_LIST {
278 for &i in state.marks.marked.iter() {
280 if let Some(c) = state.all.get(i) {
281 lines.push(Line::from(vec![
282 Span::styled(format!("{} ", c.label), body_style),
283 Span::styled(human_size(c.size_bytes), dim_style),
284 ]));
285 }
286 }
287 lines.push(Line::from(Span::raw("")));
288 lines.push(Line::from(vec![
289 Span::styled("Total: ", body_style),
290 Span::styled(human_size(total), body_style.add_modifier(Modifier::BOLD)),
291 ]));
292 } else {
293 lines.push(Line::from(vec![
295 Span::styled(
296 format!(
297 "{count} {} · ",
298 pluralize(count as u64, "folder", "folders")
299 ),
300 body_style,
301 ),
302 Span::styled(human_size(total), body_style.add_modifier(Modifier::BOLD)),
303 ]));
304 }
305
306 lines.push(Line::from(Span::raw("")));
307 lines.push(Line::from(vec![
309 Span::styled("[", body_style),
310 Span::styled("Y", key_style),
311 Span::styled("] yes ", body_style),
312 Span::styled("[", body_style),
313 Span::styled("n", key_style),
314 Span::styled("] cancel", body_style),
315 ]));
316 if state.dry_run {
317 lines.push(Line::from(Span::styled(
318 "no files will be touched",
319 dim_style,
320 )));
321 }
322
323 let h = (lines.len() as u16 + 2).min(area.height).max(5);
325 let w = area.width.min(60);
326 let x = area.x + (area.width.saturating_sub(w)) / 2;
327 let y = area.y + (area.height.saturating_sub(h)) / 2;
328 let modal = Rect {
329 x,
330 y,
331 width: w,
332 height: h,
333 };
334
335 let noun = pluralize(count as u64, "folder", "folders");
336 let title_text = if state.dry_run {
337 format!(" Delete {count} {noun}? (dry run) ")
338 } else {
339 format!(" Delete {count} {noun}? ")
340 };
341
342 Clear.render(modal, buf);
343 Paragraph::new(lines)
344 .style(body_style)
345 .alignment(Alignment::Center)
346 .block(
347 Block::default()
348 .borders(Borders::ALL)
349 .border_style(block_style)
350 .style(body_style)
351 .title(Span::styled(
352 title_text,
353 block_style.add_modifier(Modifier::BOLD),
354 ))
355 .title_alignment(Alignment::Center),
356 )
357 .render(modal, buf);
358}
359
360pub(super) const LEFT_PANEL_RIGHT_PAD: u16 = 1;
363
364pub(super) fn col_widths(area_width: u16) -> (usize, usize, usize, usize) {
365 let inner = (area_width as usize).saturating_sub(2 + LEFT_PANEL_RIGHT_PAD as usize);
368 let budget = inner.saturating_sub(2 + 3);
372
373 let size_w = 8;
377 let age_w = 5;
378
379 const SCORE_TARGET: usize = 30;
388 const NAME_FLOOR: usize = 8;
389 const SCORE_FLOOR: usize = 4;
390 let after_pinned = budget.saturating_sub(size_w + age_w);
391 let max_score = after_pinned.saturating_sub(NAME_FLOOR);
392 let score = SCORE_TARGET
393 .min(max_score)
394 .max(SCORE_FLOOR.min(after_pinned));
395 let name = after_pinned.saturating_sub(score).max(1);
396 (name, score, size_w, age_w)
397}
398
399fn render_left(state: &mut State, area: Rect, buf: &mut Buffer) {
400 let (name_w, score_w, size_w, age_w) = col_widths(area.width);
401
402 let indices = state.sorted_indices();
403 let header_style = THEME.header_style();
404 let body_style = THEME.body_style();
405 let active_style = THEME.gutter_active_style();
406 let marked_style = THEME.gutter_marked_style();
407
408 let max_score = indices
409 .iter()
410 .map(|&i| state.all[i].score(state.now))
411 .fold(0f64, f64::max)
412 .max(1e-9);
413
414 let header_line = Line::styled(
415 format!(
416 " {:<nw$} {:<sw$} {:>zw$} {:>aw$}",
417 "NAME",
418 "SCORE",
419 "SIZE",
420 "AGE",
421 nw = name_w,
422 sw = score_w,
423 zw = size_w,
424 aw = age_w,
425 ),
426 header_style,
427 );
428
429 let items: Vec<ListItem> = indices
430 .iter()
431 .enumerate()
432 .map(|(visible_row, &idx)| {
433 let c = &state.all[idx];
434 let active = visible_row == state.cursor;
435 let marked = state.marks.is_marked(idx);
436 let gutter = match (marked, active) {
440 (true, true) => Span::styled("● ", active_style),
441 (true, false) => Span::styled("● ", marked_style),
442 (false, true) => Span::styled("┃ ", active_style),
443 (false, false) => Span::raw(" "),
444 };
445 let age = c
446 .age(state.now)
447 .map(human_age)
448 .unwrap_or_else(|| "—".into());
449 let (size_num, size_unit) = human_size_parts(c.size_bytes);
453 let size_num_w = 4;
454 let size_unit_w = 3;
455 let size_str = format!(
456 "{:>nw$} {:>uw$}",
457 size_num,
458 size_unit,
459 nw = size_num_w,
460 uw = size_unit_w
461 );
462
463 let size_extra = size_str.chars().count().saturating_sub(size_w);
467 let age_extra = age.chars().count().saturating_sub(age_w);
468 let score_eff = score_w.saturating_sub(size_extra + age_extra).max(1);
469
470 let score = c.score(state.now);
471 let cells = if c.newest_mtime.is_none() || score <= 0.0 {
472 0
473 } else {
474 let raw = ((score / max_score) * score_eff as f64).round() as usize;
477 raw.max(1).min(score_eff)
478 };
479 let bar = "█".repeat(cells);
480 let bar_t = if cells == 0 { 0.0 } else { score / max_score };
485 let bar_style = Style::default().fg(THEME.score_color(bar_t));
486
487 let text_style = if active {
491 Style::default().fg(THEME.gutter_active)
492 } else {
493 body_style
494 };
495
496 let label = truncate_with_ellipsis(&c.label, name_w);
499 ListItem::new(Line::from(vec![
500 gutter,
501 Span::styled(format!("{label:<nw$} ", nw = name_w), text_style),
502 Span::styled(format!("{:<sw$} ", bar, sw = score_eff), bar_style),
503 Span::styled(format!("{:>zw$} ", size_str, zw = size_w), text_style),
507 Span::styled(format!("{:>aw$}", age, aw = age_w), text_style),
508 ]))
509 })
510 .collect();
511
512 let title = if state.stack_labels.is_empty() {
513 " putzen caches — ranked ".to_string()
514 } else {
515 format!(
516 " putzen caches — ranked — {} ",
517 state.stack_labels.join(" > ")
518 )
519 };
520
521 let border_style = if !state.focus_right {
526 Style::default().fg(THEME.gutter_active).bg(THEME.bg)
527 } else {
528 THEME.block_style()
529 };
530 let dim_style = THEME.dim_style();
536 let marked_style = THEME.gutter_marked_style().add_modifier(Modifier::BOLD);
537 let marks_count = state.marks.count();
538 let mut bottom_titles: Vec<Line> = Vec::new();
539 if marks_count > 0 {
540 let total: u64 = state
541 .marks
542 .marked
543 .iter()
544 .filter_map(|&i| state.all.get(i).map(|c| c.size_bytes))
545 .sum();
546 bottom_titles.push(
547 Line::from(Span::styled(
548 format!(" {marks_count} marked · {} ready ", human_size(total)),
549 marked_style,
550 ))
551 .left_aligned(),
552 );
553 }
554 let total_caches = state.all.len();
555 let visible_count = indices.len();
556 let mut right_text = if visible_count == total_caches {
557 format!(
558 " {total_caches} {} ",
559 pluralize(total_caches as u64, "folder", "folders")
560 )
561 } else {
562 format!(" {visible_count}/{total_caches} folders ")
563 };
564 if let Some(f) = state.filter.as_ref() {
565 if !f.input.is_empty() {
566 right_text = format!(" {} · filter: {} ", right_text.trim(), f.input);
567 }
568 }
569 bottom_titles.push(Line::from(Span::styled(right_text, dim_style)).right_aligned());
570
571 let mut block = Block::default()
572 .borders(Borders::ALL)
573 .border_style(border_style)
574 .style(body_style)
575 .padding(ratatui::widgets::Padding::right(LEFT_PANEL_RIGHT_PAD))
576 .title(Span::styled(title, THEME.title_style()));
577 for t in bottom_titles {
578 block = block.title_bottom(t);
579 }
580 let inner = block.inner(area);
581 block.render(area, buf);
582
583 let filter_present = state.filter.is_some();
585 let constraints: Vec<Constraint> = if filter_present {
586 vec![
587 Constraint::Length(1),
588 Constraint::Min(1),
589 Constraint::Length(1),
590 ]
591 } else {
592 vec![Constraint::Length(1), Constraint::Min(1)]
593 };
594 let chunks = Layout::default()
595 .direction(Direction::Vertical)
596 .constraints(constraints)
597 .split(inner);
598
599 Paragraph::new(header_line)
600 .style(body_style)
601 .render(chunks[0], buf);
602
603 if filter_present {
604 render_filter_strip(state, chunks[2], buf);
605 }
606
607 let mut left_ls = ratatui::widgets::ListState::default();
609 left_ls.select(Some(state.cursor));
610
611 let visible_len = state.sorted_indices().len();
615
616 let list = List::new(items).highlight_style(Style::default().bg(THEME.bg_sel));
620 StatefulWidget::render(list, chunks[1], buf, &mut left_ls);
621
622 if visible_len > chunks[1].height as usize {
627 let mut sb_state = ScrollbarState::new(visible_len)
628 .position(state.cursor.min(visible_len.saturating_sub(1)));
629 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
630 .begin_symbol(None)
631 .end_symbol(None)
632 .track_symbol(None)
633 .thumb_symbol("█")
634 .thumb_style(Style::default().fg(THEME.gutter_active));
635 let sb_area = Rect {
636 x: area.x,
637 y: chunks[1].y,
638 width: area.width,
639 height: chunks[1].height,
640 };
641 StatefulWidget::render(scrollbar, sb_area, buf, &mut sb_state);
642 }
643}
644
645fn activity_sparkline_rows(
650 c: &crate::caches::model::Cache,
651 now: std::time::SystemTime,
652) -> [Vec<Span<'static>>; 2] {
653 let mut counts = [0u32; 8];
654 for tf in &c.top_files {
655 let Some(m) = tf.mtime else { continue };
656 let age = now.duration_since(m).unwrap_or_default().as_secs();
657 for (i, &upper) in ACTIVITY_BUCKETS.iter().enumerate() {
658 if age < upper {
659 counts[i] += 1;
660 break;
661 }
662 }
663 }
664 counts.reverse();
666 let max = counts.iter().copied().max().unwrap_or(0);
667 let bar_style = Style::default().fg(THEME.hot);
668 let dim = THEME.dim_style();
669 if max == 0 {
670 return [vec![], vec![Span::styled("no mtime data", dim)]];
671 }
672 let mut top: Vec<Span<'static>> = Vec::with_capacity(16);
673 let mut bot: Vec<Span<'static>> = Vec::with_capacity(16);
674 for &n in &counts {
675 let h = ((n as u64 * 16 / max as u64) as usize).min(16);
677 let (top_glyph, bot_glyph) = if h == 0 {
678 (" ", " ")
679 } else if h <= 8 {
680 (" ", SPARKS[h - 1])
681 } else {
682 (SPARKS[h - 9], SPARKS[7])
683 };
684 top.push(Span::styled(top_glyph, bar_style));
685 top.push(Span::raw(" "));
686 bot.push(Span::styled(bot_glyph, bar_style));
687 bot.push(Span::raw(" "));
688 }
689 [top, bot]
690}
691
692fn render_right(state: &mut State, area: Rect, buf: &mut Buffer) {
693 let indices = state.sorted_indices();
694 let body_style = THEME.body_style();
695 let dim_style = THEME.dim_style();
696 let header_style = THEME.header_style();
697
698 let border_style = if state.focus_right {
703 Style::default().fg(THEME.gutter_active).bg(THEME.bg)
704 } else {
705 THEME.block_style()
706 };
707 let block = Block::default()
708 .borders(Borders::ALL)
709 .border_style(border_style)
710 .style(body_style)
711 .padding(ratatui::widgets::Padding::uniform(1))
712 .title(Span::styled(" details ", THEME.title_style()));
713 let inner = block.inner(area);
714 block.render(area, buf);
715
716 let Some(&idx) = indices.get(state.cursor) else {
717 Paragraph::new(Line::from(Span::styled("no folders", dim_style)))
718 .style(body_style)
719 .render(inner, buf);
720 return;
721 };
722
723 let c = &state.all[idx];
724 let age = c
725 .age(state.now)
726 .map(human_age)
727 .unwrap_or_else(|| "—".into());
728 let touched = c.newest_mtime.map(human_date).unwrap_or_else(|| "—".into());
729
730 let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
731 let path_display = tildify(&c.path, home.as_deref());
732 let mut header_lines = vec![
733 Line::from(Span::styled(c.label.clone(), THEME.title_style())),
734 Line::from(Span::styled(path_display, dim_style)),
735 Line::from(Span::raw("")),
736 Line::from(vec![
737 Span::styled("Size ", dim_style),
738 Span::styled(human_size(c.size_bytes), body_style),
739 ]),
740 Line::from(vec![
741 Span::styled("Age ", dim_style),
742 Span::styled(age, body_style),
743 ]),
744 Line::from(vec![
745 Span::styled("Score ", dim_style),
746 Span::styled(human_count(c.score(state.now)), body_style),
747 ]),
748 Line::from(vec![
749 Span::styled("Files ", dim_style),
750 Span::styled(human_int(c.file_count), body_style),
751 ]),
752 Line::from(vec![
753 Span::styled("Dirs ", dim_style),
754 Span::styled(human_int(c.dir_count), body_style),
755 ]),
756 Line::from(vec![
757 Span::styled("Last touched ", dim_style),
758 Span::styled(touched, body_style),
759 ]),
760 ];
761
762 if c.unreadable > 0 {
763 header_lines.push(Line::from(Span::styled(
764 format!(
765 "partial: {} {} unreadable",
766 c.unreadable,
767 pluralize(c.unreadable, "entry", "entries")
768 ),
769 dim_style,
770 )));
771 }
772
773 header_lines.push(Line::from(Span::raw("")));
774 header_lines.push(Line::from(Span::styled("Activity", header_style)));
775 let [top_row, bot_row] = activity_sparkline_rows(c, state.now);
776 if !top_row.is_empty() {
777 header_lines.push(Line::from(top_row));
778 }
779 header_lines.push(Line::from(bot_row));
780 header_lines.push(Line::from(Span::styled(
785 "3y+ 3y 1y 6mo 3mo 1mo 1w 1d ",
786 dim_style,
787 )));
788
789 header_lines.push(Line::from(Span::raw("")));
790 header_lines.push(Line::from(Span::styled("Files (by size)", header_style)));
791
792 let header_h = header_lines.len() as u16;
793
794 let chunks = Layout::default()
796 .direction(Direction::Vertical)
797 .constraints([Constraint::Length(header_h), Constraint::Min(0)])
798 .split(inner);
799
800 Paragraph::new(header_lines)
801 .style(body_style)
802 .render(chunks[0], buf);
803
804 let inner_w = inner.width as usize;
806 let size_w = 9usize;
807 let name_w = inner_w.saturating_sub(size_w + 1).max(8);
808
809 let items: Vec<ListItem> = c
810 .top_files
811 .iter()
812 .map(|tf| {
813 let mut name = tf.name.clone();
814 if name.chars().count() > name_w {
815 let truncated: String = name.chars().take(name_w.saturating_sub(1)).collect();
816 name = format!("{truncated}…");
817 }
818 let size = human_size(tf.size_bytes);
819 ListItem::new(Line::from(vec![
820 Span::styled(format!("{:<nw$} ", name, nw = name_w), body_style),
821 Span::styled(format!("{:>sw$}", size, sw = size_w), dim_style),
822 ]))
823 })
824 .collect();
825
826 let (highlight_style, highlight_symbol) = if state.focus_right {
830 (
831 Style::default().fg(THEME.gutter_active).bg(THEME.bg_sel),
832 "┃ ",
833 )
834 } else {
835 (Style::default(), " ")
836 };
837 let list = List::new(items)
838 .highlight_style(highlight_style)
839 .highlight_symbol(highlight_symbol);
840 let mut right_ls = ratatui::widgets::ListState::default();
841 right_ls.select(Some(state.files_cursor));
842 StatefulWidget::render(list, chunks[1], buf, &mut right_ls);
843}
844
845fn render_filter_strip(state: &State, area: Rect, buf: &mut Buffer) {
846 let Some(f) = state.filter.as_ref() else {
847 return;
848 };
849 let dim = THEME.dim_style();
850 let active = Style::default().fg(THEME.gutter_active);
851 let body = THEME.body_style();
852
853 let spans: Vec<Span> = if matches!(state.modal, Modal::FilterEdit) {
854 vec![
855 Span::styled("/", active),
856 Span::styled(f.input.clone(), body),
857 Span::styled("█ ", active),
858 Span::styled("(Enter to apply, Esc to cancel)", dim),
859 ]
860 } else {
861 let n = state.sorted_indices().len();
862 vec![
863 Span::styled("/", active),
864 Span::styled(f.input.clone(), body),
865 Span::styled(" ", body),
866 Span::styled(format!("({n} matches · "), dim),
867 Span::styled("[*]", active),
868 Span::styled(" mark all · ", dim),
869 Span::styled("[/]", active),
870 Span::styled(" edit)", dim),
871 ]
872 };
873 Paragraph::new(Line::from(spans)).render(area, buf);
874}
875
876fn render_footer_keys(state: &State, area: Rect, buf: &mut Buffer) {
877 let dim = THEME.dim_style();
878 let editing = matches!(state.modal, Modal::FilterEdit);
879 let text = if editing {
880 "[Enter] apply filter [Esc] cancel [Backspace] erase"
881 } else if state.focus_right {
882 "[↑↓/jk] scroll files [Tab/Esc] back to list [q] quit"
883 } else if state.filter.is_some() {
884 "[↑↓/jk] move [/] edit filter [*] mark all [Space] mark [m] mark-to [s] sort [d] delete [q] quit"
885 } else {
886 "[↑↓/jk] move [Tab] focus [/] filter [Space] mark [m] mark-to [s] sort [→/l/Enter] drill [←/h/Esc] back [d] delete [q] quit"
887 };
888 Paragraph::new(Line::from(Span::styled(text, dim)))
889 .style(Style::default())
890 .render(area, buf);
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use crate::caches::model::{Cache, FloorPolicy, MarkSet, Sort};
897 use crate::caches::tui::State;
898 use ratatui::backend::TestBackend;
899 use ratatui::Terminal;
900 use std::path::PathBuf;
901 use std::time::{Duration, SystemTime};
902
903 fn fixture() -> State {
904 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100 * 86_400);
905 State {
906 now,
907 all: vec![
908 Cache {
909 label: "alpha".into(),
910 path: PathBuf::from("/x/alpha"),
911 size_bytes: 2_000_000_000,
912 newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(0)),
913 file_count: 10,
914 dir_count: 0,
915 top_files: Vec::new(),
916 unreadable: 0,
917 },
918 Cache {
919 label: "beta".into(),
920 path: PathBuf::from("/x/beta"),
921 size_bytes: 50_000_000,
922 newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(90 * 86_400)),
923 file_count: 4,
924 dir_count: 1,
925 top_files: Vec::new(),
926 unreadable: 0,
927 },
928 ],
929 sort: Sort::Score,
930 marks: MarkSet::default(),
931 cursor: 0,
932 files_cursor: 0,
933 floor: FloorPolicy {
934 floor: Duration::from_secs(7 * 86_400),
935 },
936 focus_right: false,
937 stack: Vec::new(),
938 stack_labels: Vec::new(),
939 quit: false,
940 modal: crate::caches::tui::Modal::None,
941 dry_run: false,
942 yes_mode: false,
943 total_freed: 0,
944 filter: None,
945 loading: None,
946 overlay: None,
947 level_dirty: false,
948 drill_paths: Vec::new(),
949 cursor_stack: Vec::new(),
950 }
951 }
952
953 fn buffer_to_string(buf: &Buffer) -> String {
954 let mut out = String::new();
955 for y in 0..buf.area().height {
956 for x in 0..buf.area().width {
957 out.push_str(buf[(x, y)].symbol());
958 }
959 out.push('\n');
960 }
961 out
962 }
963
964 #[test]
965 fn col_widths_typical_pane_gives_name_the_slack() {
966 let (name, score, size, age) = col_widths(70);
970 assert_eq!(size, 8);
971 assert_eq!(age, 5);
972 assert_eq!(score, 30, "score caps at its target on wide panes");
973 assert_eq!(name, 19, "name absorbs everything score doesn't take");
974 assert_eq!(name + score + size + age, 62);
975 }
976
977 #[test]
978 fn col_widths_narrow_pane_shrinks_score_to_protect_name() {
979 let (name, score, _size, _age) = col_widths(27);
985 assert_eq!(score, 4, "score yields cells until it hits its hard floor");
986 assert!(
987 name >= 1,
988 "name keeps at least one column even on a tiny pane"
989 );
990 }
991
992 #[test]
993 fn col_widths_medium_pane_keeps_score_target() {
994 let (name, score, _, _) = col_widths(59);
998 assert_eq!(score, 30);
999 assert!(name >= 8);
1000 }
1001
1002 #[test]
1003 fn render_shows_both_entries_and_active_gutter() {
1004 let backend = TestBackend::new(80, 20);
1005 let mut term = Terminal::new(backend).unwrap();
1006 let mut state = fixture();
1007 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1008 .unwrap();
1009 let buf = term.backend().buffer().clone();
1010 let dump = buffer_to_string(&buf);
1011 assert!(dump.contains("alpha"), "alpha row missing:\n{}", dump);
1012 assert!(dump.contains("beta"), "beta row missing:\n{}", dump);
1013 assert!(dump.contains("┃ alpha"), "active gutter missing:\n{}", dump);
1014 }
1015
1016 #[test]
1017 fn render_includes_score_bar_for_positive_score() {
1018 let backend = TestBackend::new(120, 20);
1019 let mut term = Terminal::new(backend).unwrap();
1020 let mut state = fixture();
1021 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1022 .unwrap();
1023 let dump = buffer_to_string(term.backend().buffer());
1024 assert!(
1025 dump.contains("█"),
1026 "expected at least one bar cell `█`:\n{}",
1027 dump
1028 );
1029 }
1030
1031 #[test]
1032 fn render_status_reflects_marks() {
1033 let backend = TestBackend::new(80, 20);
1034 let mut term = Terminal::new(backend).unwrap();
1035 let mut state = fixture();
1036 state.marks.toggle(0);
1037 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1038 .unwrap();
1039 let dump = buffer_to_string(term.backend().buffer());
1040 assert!(dump.contains("1 marked"), "status missing:\n{}", dump);
1041 assert!(
1042 dump.contains("ready"),
1043 "marked-size summary missing:\n{}",
1044 dump
1045 );
1046 }
1047
1048 #[test]
1049 fn right_pane_shows_score_row() {
1050 let backend = TestBackend::new(80, 20);
1051 let mut term = Terminal::new(backend).unwrap();
1052 let mut state = fixture();
1053 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1054 .unwrap();
1055 let dump = buffer_to_string(term.backend().buffer());
1056 assert!(dump.contains("Score"), "Score row missing:\n{}", dump);
1057 }
1058
1059 #[test]
1060 fn modal_shows_dry_run_hints() {
1061 let backend = TestBackend::new(80, 20);
1062 let mut term = Terminal::new(backend).unwrap();
1063 let mut state = fixture();
1064 state.marks.toggle(0);
1065 state.modal = crate::caches::tui::Modal::DeleteConfirm;
1066 state.dry_run = true;
1067 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1068 .unwrap();
1069 let dump = buffer_to_string(term.backend().buffer());
1070 assert!(
1071 dump.contains("dry run"),
1072 "dry-run title hint missing:\n{}",
1073 dump
1074 );
1075 assert!(
1076 dump.contains("no files will be touched"),
1077 "dry-run footer missing:\n{}",
1078 dump
1079 );
1080 }
1081
1082 #[test]
1083 fn right_pane_shows_top_files() {
1084 let backend = TestBackend::new(120, 30);
1085 let mut term = Terminal::new(backend).unwrap();
1086 let mut state = fixture();
1087 state.all[0].top_files = vec![
1088 crate::caches::model::TopFile {
1089 name: "blob.bin".into(),
1090 size_bytes: 1_500_000_000,
1091 mtime: None,
1092 },
1093 crate::caches::model::TopFile {
1094 name: "data.tar".into(),
1095 size_bytes: 50_000_000,
1096 mtime: None,
1097 },
1098 ];
1099 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1100 .unwrap();
1101 let dump = buffer_to_string(term.backend().buffer());
1102 assert!(dump.contains("Files (by size)"), "files header missing");
1103 assert!(dump.contains("blob.bin"), "biggest file missing");
1104 }
1105
1106 #[test]
1107 fn right_pane_shows_partial_footnote() {
1108 let backend = TestBackend::new(120, 30);
1109 let mut term = Terminal::new(backend).unwrap();
1110 let mut state = fixture();
1111 state.all[0].unreadable = 7;
1112 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1113 .unwrap();
1114 let dump = buffer_to_string(term.backend().buffer());
1115 assert!(
1116 dump.contains("partial: 7 entries unreadable"),
1117 "partial counter missing:\n{}",
1118 dump
1119 );
1120 }
1121
1122 #[test]
1123 fn draw_result_shows_freed_summary() {
1124 let backend = TestBackend::new(80, 20);
1125 let mut term = Terminal::new(backend).unwrap();
1126 let outcome = RunOutcome {
1127 freed: 1_500_000_000,
1128 deleted: 3,
1129 failed: 0,
1130 dry_run: false,
1131 };
1132 term.draw(|f| draw_result(&outcome, f.area(), f.buffer_mut()))
1133 .unwrap();
1134 let dump = buffer_to_string(term.backend().buffer());
1135 assert!(dump.contains("Freed"), "result summary missing:\n{}", dump);
1136 assert!(
1137 dump.contains("3 folders"),
1138 "deleted count missing:\n{}",
1139 dump
1140 );
1141 }
1142
1143 #[test]
1144 fn draw_result_shows_failed_suffix_when_failures() {
1145 let backend = TestBackend::new(80, 20);
1146 let mut term = Terminal::new(backend).unwrap();
1147 let outcome = RunOutcome {
1148 freed: 1_000,
1149 deleted: 2,
1150 failed: 1,
1151 dry_run: false,
1152 };
1153 term.draw(|f| draw_result(&outcome, f.area(), f.buffer_mut()))
1154 .unwrap();
1155 let dump = buffer_to_string(term.backend().buffer());
1156 assert!(
1157 dump.contains("1 failed"),
1158 "failed suffix missing:\n{}",
1159 dump
1160 );
1161 }
1162
1163 #[test]
1164 fn draw_result_dry_run_shows_would_free() {
1165 let backend = TestBackend::new(80, 20);
1166 let mut term = Terminal::new(backend).unwrap();
1167 let outcome = RunOutcome {
1168 freed: 1_000,
1169 deleted: 1,
1170 failed: 0,
1171 dry_run: true,
1172 };
1173 term.draw(|f| draw_result(&outcome, f.area(), f.buffer_mut()))
1174 .unwrap();
1175 let dump = buffer_to_string(term.backend().buffer());
1176 assert!(
1177 dump.contains("Would free"),
1178 "dry-run wording missing:\n{}",
1179 dump
1180 );
1181 }
1182
1183 #[test]
1184 fn footer_status_shows_total_count_when_no_filter() {
1185 let backend = TestBackend::new(120, 20);
1186 let mut term = Terminal::new(backend).unwrap();
1187 let mut state = fixture();
1188 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1189 .unwrap();
1190 let dump = buffer_to_string(term.backend().buffer());
1191 assert!(
1192 dump.contains("2 folders"),
1193 "total folder count missing:\n{}",
1194 dump
1195 );
1196 assert!(
1197 !dump.contains("filter:"),
1198 "filter label leaks when no filter is set:\n{}",
1199 dump
1200 );
1201 }
1202
1203 #[test]
1204 fn footer_status_shows_filter_substring_and_visible_count() {
1205 use crate::caches::tui::Filter;
1206 let backend = TestBackend::new(120, 20);
1207 let mut term = Terminal::new(backend).unwrap();
1208 let mut state = fixture();
1209 state.filter = Some(Filter {
1210 input: "alp".into(),
1211 });
1212 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1213 .unwrap();
1214 let dump = buffer_to_string(term.backend().buffer());
1215 assert!(
1216 dump.contains("1/2 folders"),
1217 "visible/total missing:\n{}",
1218 dump
1219 );
1220 assert!(
1221 dump.contains("filter: alp"),
1222 "filter substring missing:\n{}",
1223 dump
1224 );
1225 }
1226
1227 #[test]
1228 fn footer_status_hides_left_half_when_no_marks() {
1229 let backend = TestBackend::new(120, 20);
1230 let mut term = Terminal::new(backend).unwrap();
1231 let mut state = fixture();
1232 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1233 .unwrap();
1234 let dump = buffer_to_string(term.backend().buffer());
1235 assert!(
1236 !dump.contains("marked"),
1237 "marks label should be absent when count is zero:\n{}",
1238 dump
1239 );
1240 }
1241
1242 #[test]
1243 fn breadcrumb_reflects_drill_stack() {
1244 let backend = TestBackend::new(120, 20);
1245 let mut term = Terminal::new(backend).unwrap();
1246 let mut state = fixture();
1247 state.stack_labels.push("Library/Caches".into());
1248 state.stack_labels.push("Homebrew".into());
1249 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1250 .unwrap();
1251 let dump = buffer_to_string(term.backend().buffer());
1252 assert!(
1253 dump.contains("Library/Caches > Homebrew"),
1254 "breadcrumb missing:\n{}",
1255 dump
1256 );
1257 }
1258
1259 #[test]
1260 fn right_pane_shows_activity_sparkline() {
1261 let backend = TestBackend::new(120, 30);
1262 let mut term = Terminal::new(backend).unwrap();
1263 let mut state = fixture();
1264 state.all[0].top_files = vec![crate::caches::model::TopFile {
1265 name: "a".into(),
1266 size_bytes: 1,
1267 mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(90 * 86_400)),
1268 }];
1269 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1270 .unwrap();
1271 let dump = buffer_to_string(term.backend().buffer());
1272 assert!(
1273 dump.contains("Activity"),
1274 "activity header missing:\n{}",
1275 dump
1276 );
1277 assert!(
1278 SPARKS.iter().any(|&s| dump.contains(s)),
1279 "no spark char visible:\n{}",
1280 dump
1281 );
1282 }
1283
1284 #[test]
1285 fn render_active_mark_modal_shows_floor_days() {
1286 let backend = TestBackend::new(120, 30);
1287 let mut term = Terminal::new(backend).unwrap();
1288 let mut state = fixture();
1289 state.modal = crate::caches::tui::Modal::ActiveMark(vec![0]);
1290 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1291 .unwrap();
1292 let dump = buffer_to_string(term.backend().buffer());
1293 assert!(
1294 dump.contains("seems to be active"),
1295 "active modal text missing:\n{}",
1296 dump
1297 );
1298 assert!(
1299 dump.contains("< 7 days"),
1300 "floor wording missing:\n{}",
1301 dump
1302 );
1303 }
1304
1305 #[test]
1306 fn modal_renders_when_delete_requested() {
1307 let backend = TestBackend::new(80, 20);
1308 let mut term = Terminal::new(backend).unwrap();
1309 let mut state = fixture();
1310 state.marks.toggle(0);
1311 state.modal = crate::caches::tui::Modal::DeleteConfirm;
1312 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1313 .unwrap();
1314 let dump = buffer_to_string(term.backend().buffer());
1315 assert!(
1316 dump.contains("Delete 1 folder?"),
1317 "modal title missing:\n{}",
1318 dump
1319 );
1320 assert!(dump.contains("[Y] yes"), "modal Y default prompt missing");
1321 assert!(dump.contains("[n] cancel"), "modal n prompt missing");
1322 }
1323
1324 #[test]
1325 fn render_loading_modal_shows_spinner() {
1326 let backend = TestBackend::new(80, 20);
1327 let mut term = Terminal::new(backend).unwrap();
1328 let mut state = fixture();
1329 state.loading = Some(crate::caches::tui::Loading {
1330 label: "huggingface".into(),
1331 frame: 0,
1332 started: std::time::Instant::now(),
1333 folders: None,
1334 });
1335 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1336 .unwrap();
1337 let dump = buffer_to_string(term.backend().buffer());
1338 assert!(dump.contains("Loading"), "loading title missing:\n{}", dump);
1339 assert!(
1340 dump.contains("huggingface"),
1341 "loading label missing:\n{}",
1342 dump
1343 );
1344 }
1345
1346 #[test]
1347 fn render_loading_modal_shows_folder_count_when_set() {
1348 let backend = TestBackend::new(80, 20);
1349 let mut term = Terminal::new(backend).unwrap();
1350 let mut state = fixture();
1351 state.loading = Some(crate::caches::tui::Loading {
1352 label: "scanning caches".into(),
1353 frame: 0,
1354 started: std::time::Instant::now(),
1355 folders: Some(12_345),
1356 });
1357 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1358 .unwrap();
1359 let dump = buffer_to_string(term.backend().buffer());
1360 assert!(
1361 dump.contains("scanned 12.345 folders"),
1362 "expected folder-count line; got:\n{}",
1363 dump
1364 );
1365 assert!(
1366 !dump.contains("elapsed"),
1367 "elapsed should not appear when folder count is set:\n{}",
1368 dump
1369 );
1370 }
1371
1372 #[test]
1373 fn many_rows_renders_without_panic_at_cursor_50() {
1374 use crate::caches::tui::Msg;
1375 let mut state = fixture();
1376 state.all = (0..100u64)
1378 .map(|i| Cache {
1379 label: format!("c{i:03}"),
1380 path: PathBuf::from(format!("/x/c{i:03}")),
1381 size_bytes: 1024,
1382 newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(i * 100)),
1383 file_count: 1,
1384 dir_count: 0,
1385 top_files: Vec::new(),
1386 unreadable: 0,
1387 })
1388 .collect();
1389
1390 for _ in 0..50 {
1391 state = crate::caches::tui::update(state, Msg::MoveDown).0;
1392 }
1393 assert_eq!(state.cursor, 50);
1394
1395 let backend = TestBackend::new(80, 10);
1397 let mut term = Terminal::new(backend).unwrap();
1398 term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1399 .unwrap();
1400 let dump = buffer_to_string(term.backend().buffer());
1401 assert!(dump.contains("c050"), "row c050 not rendered:\n{dump}");
1402 }
1403}