diff --git a/Cargo.lock b/Cargo.lock index e3dac48..ba865da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -739,6 +739,7 @@ dependencies = [ "neet-core", "ratatui", "tempfile", + "unicode-segmentation", ] [[package]] diff --git a/crates/neet/Cargo.toml b/crates/neet/Cargo.toml index 4723510..5b65218 100644 --- a/crates/neet/Cargo.toml +++ b/crates/neet/Cargo.toml @@ -13,7 +13,8 @@ categories = ["command-line-utilities", "filesystem"] [dependencies] neet-core = { path = "../neet-core", version = "0.1.0" } -ratatui.workspace = true +ratatui = { workspace = true, features = ["unstable-rendered-line-info"] } +unicode-segmentation = "1" [lints] workspace = true diff --git a/crates/neet/src/ui/app.rs b/crates/neet/src/ui/app.rs index e074b37..45fb97c 100644 --- a/crates/neet/src/ui/app.rs +++ b/crates/neet/src/ui/app.rs @@ -7,8 +7,6 @@ use neet_core::disk::{self, DiskSpace}; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::Style; -use ratatui::text::Line; use super::clean::Estimate; use super::help::Help; @@ -198,8 +196,12 @@ impl App { } pub fn draw(&mut self, frame: &mut Frame) { + let hints = super::visual::hints(self.top().hints()); + let footer_height = + super::visual::wrapped_rows(std::slice::from_ref(&hints), frame.area().width); let [body, footer] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_height)]) + .areas(frame.area()); // Draw from the topmost full screen up, so overlays sit on what is below them. let base = self .stack @@ -215,9 +217,10 @@ impl App { for screen in &mut self.stack[base..] { screen.draw(frame, body, &context); } - let screen = self.top(); - let hints = Line::from(format!(" {}", screen.hints())).style(Style::new()); - frame.render_widget(hints, footer); + frame.render_widget( + ratatui::widgets::Paragraph::new(hints).wrap(ratatui::widgets::Wrap { trim: false }), + footer, + ); } pub fn handle_key(&mut self, key: KeyEvent) { diff --git a/crates/neet/src/ui/apps.rs b/crates/neet/src/ui/apps.rs index a1595f1..08ce460 100644 --- a/crates/neet/src/ui/apps.rs +++ b/crates/neet/src/ui/apps.rs @@ -13,9 +13,9 @@ use neet_core::safety::CleanupRoots; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::widgets::{Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; use super::app::{Action, Context, Screen}; use super::clean::{Planned, checkbox, display_path, skip_reason}; @@ -180,7 +180,13 @@ impl RemoveApp { Action::Open(Box::new(AppFiles::start(roots.clone(), app.clone()))) } - fn app_row(&self, app: &App, home: Option<&Path>, largest: u64) -> Row<'static> { + fn app_row( + &self, + app: &App, + home: Option<&Path>, + largest: u64, + columns: &super::visual::Columns<5>, + ) -> Row<'static> { let folder = app .path .parent() @@ -192,7 +198,7 @@ impl RemoveApp { let folder = if folder.starts_with('~') { Span::raw(folder).magenta() } else { - Span::raw(folder).blue() + Span::raw(folder).fg(super::visual::ACCENT) }; let id = app.bundle_id.clone().unwrap_or_default(); let (size, bar) = match self.sizes.get(&app.path) { @@ -200,30 +206,58 @@ impl RemoveApp { format::size_span(size, format::size(size)), size_bar(size, largest), ), - None if self.measuring.is_some() => (Span::raw("…").dark_gray(), Span::raw("")), + None if self.measuring.is_some() => { + (Span::raw("…").fg(super::visual::ACCENT), Span::raw("")) + } None => (Span::raw(""), Span::raw("")), }; match app.refused { - // Plain text, so the whole row stays gray - Some(refusal) => Row::new([ - Cell::from(app.name.clone()), - Cell::from(Line::from(size.content).right_aligned()), + // Keep the refusal readable even when the row cannot be opened. + Some(refusal) => columns.row([ + Cell::from( + Span::raw(format::shorten_middle( + &format!("{} [blocked]", app.name), + columns.width(0), + )) + .yellow(), + ), + Cell::from(Line::from(size).right_aligned()), Cell::from(""), Cell::from(folder.content), - Cell::from(Span::raw(format!("not removable: {refusal}")).italic()), - ]) - .dark_gray(), - None => Row::new([ - Cell::from(app.name.clone()), + Cell::from(Span::raw(format!("Blocked: {refusal}")).yellow()), + ]), + None => columns.row([ + Cell::from(format::shorten_middle(&app.name, columns.width(0))), Cell::from(Line::from(size).right_aligned()), Cell::from(bar), Cell::from(folder), - Cell::from(Span::raw(id).cyan()), + Cell::from(Span::raw(id).fg(super::visual::ACCENT)), ]), } } } +/// Keep the selected path and metadata available when secondary columns disappear. +fn draw_selected(frame: &mut Frame, area: Rect, details: Vec>) -> Rect { + if details.is_empty() { + return area; + } + let height = super::visual::wrapped_rows(&details, area.width.saturating_sub(4)) + .saturating_add(2) + .min(area.height.saturating_sub(6)); + let [table, detail] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(height)]).areas(area); + frame.render_widget( + Paragraph::new(details).wrap(Wrap { trim: false }).block( + super::visual::block() + .title(" Selected ") + .padding(Padding::horizontal(1)), + ), + detail, + ); + table +} + /// A bar of `size` against the largest app, colored like the size fn size_bar(size: u64, largest: u64) -> Span<'static> { let bar = Span::raw(format::bar(size, largest, APP_BAR)); @@ -239,7 +273,7 @@ fn size_bar(size: u64, largest: u64) -> Span<'static> { impl Screen for RemoveApp { fn draw(&mut self, frame: &mut Frame, area: Rect, _context: &Context) { self.poll(); - let block = Block::bordered().padding(Padding::horizontal(1)); + let block = super::visual::block().padding(Padding::horizontal(1)); if let Some(reason) = &self.failed { let block = block.title(" Remove App "); frame.render_widget(Paragraph::new(reason.clone()).red().block(block), area); @@ -256,20 +290,51 @@ impl Screen for RemoveApp { .filter_map(|app| self.sizes.get(&app.path)) .sum(); let largest = self.sizes.values().copied().max().unwrap_or(0); + let sizes = + format::column_width("Size", self.sizes.values().map(|&size| format::size(size))) + .max(4); + let columns = super::visual::Columns::new( + area.width, + [(18, 2), (sizes, 0), (12, 0), (16, 0), (18, 1)], + &[2, 4, 3], + true, + ); + let detail = self + .list + .selected() + .and_then(|index| self.apps.get(index)) + .map(|app| { + let status = app.refused.as_ref().map_or_else( + || app.bundle_id.clone().unwrap_or_default(), + |why| format!("Blocked: {why}"), + ); + let status = Span::raw(status); + Line::from(vec![ + Span::raw(app.path.display().to_string()).fg(super::visual::ACCENT), + Span::raw(" · "), + if app.refused.is_some() { + status.yellow() + } else { + status.fg(super::visual::ACCENT) + }, + ]) + }); + let area = draw_selected(frame, area, detail.into_iter().collect()); let rows: Vec = self .apps .iter() - .map(|app| self.app_row(app, home.as_deref(), largest)) + .map(|app| self.app_row(app, home.as_deref(), largest, &columns)) .collect(); - let header = Row::new([ - Cell::from("Name"), - Cell::from(Line::from("Size").right_aligned()), - Cell::from(""), - Cell::from("Folder"), - Cell::from("Bundle ID"), - ]) - .bold() - .bottom_margin(1); + let header = columns + .row([ + Cell::from("Name"), + Cell::from(Line::from("Size").right_aligned()), + Cell::from(""), + Cell::from("Folder"), + Cell::from("Bundle ID"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); let measuring = if self.measuring.is_some() { " · measuring…" } else { @@ -277,7 +342,7 @@ impl Screen for RemoveApp { }; let summary = Line::from(vec![ Span::raw(format!( - " {} apps you can remove take ", + " {} removable · ", format::count(u64::try_from(removable.len()).unwrap_or(u64::MAX)) )), format::size_span(total, format::size(total)).bold(), @@ -293,26 +358,17 @@ impl Screen for RemoveApp { }, |note| Line::from(format!(" {note} ")).yellow(), ); - let table = Table::new( - rows, - [ - Constraint::Fill(2), - Constraint::Length(9), - Constraint::Length(u16::try_from(APP_BAR).unwrap_or(u16::MAX)), - Constraint::Length(16), - Constraint::Fill(3), - ], - ) - .header(header) - .column_spacing(2) - .block( - block - .title(" Remove App ") - .title_bottom(summary) - .title_bottom(note.right_aligned()), - ) - .highlight_symbol("▸ ") - .row_highlight_style(Style::new().bold()); + let table = Table::new(rows, columns.widths()) + .header(header) + .column_spacing(2) + .block( + block + .title(" Remove App ") + .title_bottom(summary) + .title(note.right_aligned()), + ) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED); frame.render_stateful_widget(table, area, &mut self.list); } @@ -338,11 +394,11 @@ impl Screen for RemoveApp { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("g G", "Jump to the first or last app"), ("Enter → l", "List the app and its files"), ("s", "Sort by size or by name"), - ("Esc", "Go back to Home"), + ("Esc", "Back to Home"), ("q", "Quit"), ] } @@ -360,9 +416,6 @@ enum State { /// Width of the folder column, such as `~/Library/Application Support` const FOLDER_WIDTH: u16 = 30; -/// Width of the note column, such as `shared with other apps` -const NOTE_WIDTH: u16 = 22; - /// One app and the files that belong to it, each selected as SAFETY.md /// says. `Enter` opens the same review as Clean. pub struct AppFiles { @@ -448,22 +501,23 @@ fn explain(rule: &RulePlan) -> &'static str { .unwrap_or(Mark::None); match mark { Mark::None if rule.rule.name == "The app" => "The app itself.", - Mark::None => "Files the app makes again when it needs them. Selected from the start.", - Mark::MayBeYourData => { - "May hold things you made or saved in the app. Look inside before selecting it." - } - Mark::Settings => "The app's settings. Keep them if you may install the app again.", + Mark::None => "Recreated by the app. Selected by default.", + Mark::MayBeYourData => "May contain saved work. Inspect before selecting.", + Mark::Settings => "App settings. Keep for reinstallation.", Mark::SharedWithOtherApps => "Other apps from the same maker may use it too.", Mark::StartsOnItsOwn => "Starts a helper program on its own, such as when you log in.", - Mark::MatchedByName => { - "Found by the app's name, not its bundle ID, so it may belong to something else." - } + Mark::MatchedByName => "Matched by name only. Verify ownership before selecting.", } } /// One file as a table row. `current` is the row the arrow is on, whose name -/// turns white, so the checkbox and note keep their own colors. -fn file_row(rule: &RulePlan, home: &Path, current: bool, name_width: usize) -> Row<'static> { +/// turns bold, so the checkbox and note keep their own colors. +fn file_row( + rule: &RulePlan, + home: &Path, + current: bool, + columns: &super::visual::Columns<5>, +) -> Row<'static> { let (path, size, note) = match (rule.items.first(), rule.skipped.first()) { (Some(item), _) => ( display_path(home, item.path.path()), @@ -478,36 +532,36 @@ fn file_row(rule: &RulePlan, home: &Path, current: bool, name_width: usize) -> R (None, None) => (String::new(), Span::raw(""), String::new()), }; let (folder, name) = path.rsplit_once('/').unwrap_or(("", path.as_str())); - let name = Span::raw(format::shorten_middle(name, name_width)); - let name = if current { name.white().bold() } else { name }; - let row = Row::new([ + let name = Span::raw(format::shorten_middle(name, columns.width(2))); + let name = if current { name.bold() } else { name }; + columns.row([ Cell::from(checkbox(rule.selected, !rule.items.is_empty())), Cell::from(Line::from(size).right_aligned()), Cell::from(name), - Cell::from(Span::raw(format::shorten_path(folder, FOLDER_WIDTH.into())).blue()), + Cell::from( + Span::raw(format::shorten_path(folder, columns.width(3))).fg(super::visual::ACCENT), + ), Cell::from(Span::raw(note).yellow()), - ]); - if rule.items.is_empty() { - row.dark_gray() - } else { - row - } + ]) } impl Screen for AppFiles { fn draw(&mut self, frame: &mut Frame, area: Rect, _context: &Context) { self.poll(); - let title = format!(" {} ({}) ", self.name, self.bundle_id); - let block = Block::bordered() + let title = format!( + " {} ", + format::shorten_middle(&self.name, usize::from(area.width.saturating_sub(30))) + ); + let block = super::visual::block() .title(title) .padding(Padding::horizontal(1)); let planned = match &self.state { State::Planning { started, .. } => { Loading { title: &self.name, - doing: "Finding the app's files and measuring them", + doing: "Measuring app files", progress: format!("{}s", started.elapsed().as_secs()), - note: "Nothing is changed while neet looks.", + note: "Read-only scan.", } .draw(frame, area); return; @@ -536,61 +590,56 @@ impl Screen for AppFiles { } else { selected.green().bold() }) - .title_bottom( - Line::from(" Items go to the Trash, where Put Back works ").right_aligned(), - ); - let [list_area, why_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(area); - - // Checkbox, size, folder, and note are fixed; the name takes the rest. - let fixed = 2 + 2 + 2 + 3 + 9 + FOLDER_WIDTH + NOTE_WIDTH + 2 * 4; - let name_width = list_area.width.saturating_sub(fixed); + .title(Line::from(" Trash · Put Back restores ").right_aligned()); + let current = self.list.selected().unwrap_or(0); + let mut details = vec![Line::from(self.bundle_id.clone()).fg(super::visual::ACCENT)]; + if let Some(rule) = plan.rules.get(current) { + if let Some(path) = rule + .items + .first() + .map(|item| item.path.path()) + .or_else(|| rule.skipped.first().map(|item| item.path.as_path())) + { + details + .push(Line::from(display_path(&planned.home, path)).fg(super::visual::ACCENT)); + } + details.push(Line::from(explain(rule))); + } + let list_area = draw_selected(frame, area, details); + let sizes = format::column_width( + "Size", + plan.rules.iter().map(|rule| format::size(rule.size())), + ); + let columns = super::visual::Columns::new( + list_area.width, + [(3, 0), (sizes, 0), (16, 1), (FOLDER_WIDTH, 0), (18, 0)], + &[3], + true, + ); let current = self.list.selected().unwrap_or(0); let rows: Vec = plan .rules .iter() .enumerate() - .map(|(index, rule)| { - file_row( - rule, - &planned.home, - index == current, - usize::from(name_width), - ) - }) + .map(|(index, rule)| file_row(rule, &planned.home, index == current, &columns)) .collect(); - let header = Row::new([ - Cell::from(""), - Cell::from(Line::from("Size").right_aligned()), - Cell::from("Name"), - Cell::from("Folder"), - Cell::from("Note"), - ]) - .bold() - .bottom_margin(1); - let table = Table::new( - rows, - [ - Constraint::Length(3), - Constraint::Length(9), - Constraint::Length(name_width), - Constraint::Length(FOLDER_WIDTH), - Constraint::Length(NOTE_WIDTH), - ], - ) - .header(header) - .column_spacing(2) - .block(block) - .highlight_symbol("▸ "); + let header = columns + .row([ + Cell::from(""), + Cell::from(Line::from("Size").right_aligned()), + Cell::from("Name"), + Cell::from("Folder"), + Cell::from("Note"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); + let table = Table::new(rows, columns.widths()) + .header(header) + .column_spacing(2) + .block(block) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED); frame.render_stateful_widget(table, list_area, &mut self.list); - - if let Some(rule) = plan.rules.get(current) { - frame.render_widget( - Paragraph::new(Line::from(explain(rule))) - .block(Block::bordered().padding(Padding::horizontal(1))), - why_area, - ); - } } fn handle_key(&mut self, key: KeyEvent, _context: &Context) -> Action { @@ -616,10 +665,10 @@ impl Screen for AppFiles { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("Space", "Select or clear a file"), ("Enter", "Review every selected path"), - ("Esc", "Go back to the app list"), + ("Esc", "Back to the app list"), ("q", "Quit"), ] } @@ -704,7 +753,7 @@ mod tests { } #[test] - fn lists_apps_and_dims_refused_ones() { + fn lists_apps_and_labels_refused_ones() { let (_home, _shared, roots, apps) = setup(); let mut screen = RemoveApp::from_apps(roots, apps, |_| false); @@ -735,7 +784,7 @@ mod tests { let text = render(&mut screen); assert!(text.contains("6.0 GB")); assert!(text.contains("2.0 MB")); - assert!(text.contains("apps you can remove take 6.0 GB")); + assert!(text.contains("removable · 6.0 GB")); assert_eq!(screen.apps[0].name, "Big"); press(&mut screen, KeyCode::Down); @@ -804,4 +853,37 @@ mod tests { thread::sleep(std::time::Duration::from_millis(5)); } } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let scan = crate::ui::scan::ScanStatus::Failed(String::new()); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + let (_home, _shared, roots, apps) = setup(); + let plan = removal::plan(&roots, &apps[0]).unwrap(); + let mut files = AppFiles::ready( + "Example", + "com.example.app", + Planned { + plan, + errors: Vec::new(), + home: roots.home().to_path_buf(), + }, + ); + let mut screen = RemoveApp::from_apps(roots, apps, |_| false); + screen + .sizes + .insert(screen.apps[0].path.clone(), 6_000_000_000); + for (width, height) in view::SIZES { + let buffer = view::render("apps", &mut screen, &context, width, height); + view::aligned(&buffer, "Size", "6.0 GB"); + let buffer = view::render("app-files", &mut files, &context, width, height); + assert!(view::text(&buffer).contains("Example.app")); + assert!(view::text(&buffer).contains("may be your data")); + } + } } diff --git a/crates/neet/src/ui/clean.rs b/crates/neet/src/ui/clean.rs index 7f53e90..409f432 100644 --- a/crates/neet/src/ui/clean.rs +++ b/crates/neet/src/ui/clean.rs @@ -14,7 +14,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Clear, Padding, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::widgets::{Cell, Clear, Padding, Paragraph, Row, Table, TableState, Wrap}; use super::app::{Action, Context, Screen}; use super::format; @@ -315,18 +315,21 @@ impl Clean { Span::raw(rule.rule.id.clone()).bold(), Span::raw(" and press Enter to select it."), ]), - Line::from(format!("> {typed}█")).cyan(), + Line::from(format!("> {typed}█")).fg(super::visual::ACCENT), ]; - let [area] = Layout::vertical([Constraint::Length(7)]) - .flex(Flex::Center) - .areas(area); + let [area] = Layout::vertical([Constraint::Length( + super::visual::wrapped_rows(&lines, area.width.min(64).saturating_sub(4)) + .saturating_add(2), + )]) + .flex(Flex::Center) + .areas(area); let [area] = Layout::horizontal([Constraint::Length(64)]) .flex(Flex::Center) .areas(area); frame.render_widget(Clear, area); frame.render_widget( Paragraph::new(lines).wrap(Wrap { trim: false }).block( - Block::bordered() + super::visual::block() .title(" Select an expert rule ") .padding(Padding::horizontal(1)), ), @@ -361,9 +364,9 @@ fn tier_span(tier: Tier) -> Span<'static> { fn tier_meaning(tier: Tier) -> &'static str { match tier { - Tier::Safe => "The files come back, and the only cost is time. Selected from the start.", - Tier::Caution => "You may need to download, index, or sign in again. You select it.", - Tier::Expert => "The files may not exist anywhere else. You type its ID to select it.", + Tier::Safe => "Recreated as needed. Selected by default.", + Tier::Caution => "May require downloads, indexing, or sign-in. Select manually.", + Tier::Expert => "May contain unique files. Type the rule ID to select.", } } @@ -414,32 +417,33 @@ pub(super) fn checkbox(selected: bool, selectable: bool) -> Span<'static> { match (selectable, selected) { (false, _) => Span::raw(" "), (true, true) => Span::raw("[✓]").green().bold(), - (true, false) => Span::raw("[ ]").dark_gray(), + (true, false) => Span::raw("[ ]"), } } /// One rule as a table row. `current` is the row the arrow is on, whose -/// name turns white, so the checkbox and risk keep their own colors. -fn rule_row(rule: &RulePlan, current: bool) -> Row<'static> { +/// name turns bold, so the checkbox and risk keep their own colors. +fn rule_row(rule: &RulePlan, current: bool, columns: &super::visual::Columns<5>) -> Row<'static> { let items = rule.items.len(); let name = Span::raw(rule.rule.name.clone()); - let name = if current { name.white().bold() } else { name }; + let name = if current { name.bold() } else { name }; if items == 0 { - return Row::new([ + return columns.row([ Cell::from(checkbox(false, false)), Cell::from(name), Cell::from(tier_span(rule.rule.tier)), Cell::from(Line::from("none").right_aligned()), Cell::from(Line::from("·").right_aligned()), - ]) - .dark_gray(); + ]); } - Row::new([ + columns.row([ Cell::from(checkbox(rule.selected, true)), Cell::from(name), Cell::from(tier_span(rule.rule.tier)), Cell::from(Line::from(count(items)).right_aligned()), - Cell::from(Line::from(format::size(rule.size())).right_aligned()), + Cell::from( + Line::from(format::size_span(rule.size(), format::size(rule.size()))).right_aligned(), + ), ]) } @@ -451,26 +455,10 @@ fn field(label: &str, value: Span<'static>) -> Line<'static> { /// How many screen rows `lines` take when wrapped to `width` at spaces, as /// the details are pub(super) fn rows_used(lines: &[Line], width: usize) -> usize { - let width = width.max(1); - lines - .iter() - .map(|line| { - let text = line.to_string(); - let mut rows = 1; - let mut used = 0; - for word in text.split(' ') { - let length = word.chars().count(); - let needed = if used == 0 { length } else { used + 1 + length }; - if needed <= width { - used = needed; - } else { - rows += 1 + length.saturating_sub(1) / width; - used = length % width; - } - } - rows - }) - .sum() + usize::from(super::visual::wrapped_rows( + lines, + u16::try_from(width).unwrap_or(u16::MAX), + )) } /// The folder every path found or skipped is in, when they share one @@ -515,7 +503,7 @@ fn skipped_lines(rule: &RulePlan, home: &Path, width: usize) -> Vec9} {} {reason}", "", @@ -567,7 +555,7 @@ fn about(rule: &RulePlan, home: &Path) -> Vec> { }, |folder| display_path(home, folder), ); - lines.push(field("Folder", Span::raw(folder).blue())); + lines.push(field("Folder", Span::raw(folder).fg(super::visual::ACCENT))); lines } @@ -657,7 +645,7 @@ impl Clean { frame.render_widget( Paragraph::new(top).wrap(Wrap { trim: false }).block( - Block::bordered() + super::visual::block() .title(format!(" {} ", rule.rule.name)) .padding(Padding::horizontal(1)), ), @@ -676,11 +664,9 @@ impl Clean { }; frame.render_widget( Paragraph::new(found_lines(rule, home, room, width)).block( - Block::bordered() + super::visual::block() .title(Line::from(title).bold()) - .title_bottom( - Line::from(" Items go to the Trash, where Put Back works ").right_aligned(), - ) + .title_bottom(Line::from(" Trash · restore with Put Back ").right_aligned()) .padding(Padding::horizontal(1)), ), found_area, @@ -689,7 +675,7 @@ impl Clean { if !skipped.is_empty() { frame.render_widget( Paragraph::new(skipped).block( - Block::bordered() + super::visual::block() .title( Line::from(format!( " Skipped · {} left in place ", @@ -706,7 +692,7 @@ impl Clean { if chart_area.height >= 3 { frame.render_widget( Paragraph::new(chart).block( - Block::bordered() + super::visual::block() .title(Line::from(" Where the space is ").bold()) .padding(Padding::horizontal(1)), ), @@ -742,7 +728,7 @@ fn chart_lines(planned: &Planned, current: &RulePlan, width: usize) -> Vec9} ", format::size(rule.size()))), @@ -844,7 +830,7 @@ fn selection(planned: &Planned) -> Paragraph<'static> { .iter() .filter(|rule| rule.selected && !rule.items.is_empty()) .collect(); - let block = Block::bordered() + let block = super::visual::block() .title(" Selected ") .padding(Padding::horizontal(1)); if chosen.is_empty() { @@ -889,42 +875,62 @@ fn selection(planned: &Planned) -> Paragraph<'static> { } /// Every rule as a table, largest first, with `summary` on the bottom edge -fn rules_table(planned: &Planned, current: usize, summary: Line<'static>) -> Table<'static> { +fn rules_table( + planned: &Planned, + current: usize, + summary: Line<'static>, + width: u16, +) -> Table<'static> { + let counts = format::column_width( + "Items", + planned + .plan + .rules + .iter() + .map(|rule| count(rule.items.len())), + ); + let sizes = format::column_width( + "Size", + planned + .plan + .rules + .iter() + .map(|rule| format::size(rule.size())), + ); + let columns = super::visual::Columns::new( + width, + [(3, 0), (12, 1), (7, 0), (counts, 0), (sizes, 0)], + &[], + true, + ); let rows: Vec = planned .plan .rules .iter() .enumerate() - .map(|(index, rule)| rule_row(rule, index == current)) + .map(|(index, rule)| rule_row(rule, index == current, &columns)) .collect(); - let header = Row::new([ - Cell::from(""), - Cell::from("Rule"), - Cell::from("Risk"), - Cell::from(Line::from("Items").right_aligned()), - Cell::from(Line::from("Size").right_aligned()), - ]) - .bold() - .bottom_margin(1); - Table::new( - rows, - [ - Constraint::Length(3), - Constraint::Fill(1), - Constraint::Length(7), - Constraint::Length(8), - Constraint::Length(8), - ], - ) - .header(header) - .column_spacing(2) - .block( - Block::bordered() - .title(" Deep Clean ") - .title_bottom(summary) - .padding(Padding::horizontal(1)), - ) - .highlight_symbol("▸ ") + let header = columns + .row([ + Cell::from(""), + Cell::from("Rule"), + Cell::from("Risk"), + Cell::from(Line::from("Items").right_aligned()), + Cell::from(Line::from("Size").right_aligned()), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); + Table::new(rows, columns.widths()) + .header(header) + .column_spacing(2) + .block( + super::visual::block() + .title(" Deep Clean ") + .title_bottom(summary) + .padding(Padding::horizontal(1)), + ) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED) } impl Screen for Clean { @@ -940,7 +946,7 @@ impl Screen for Clean { title: "Deep Clean", doing: "Finding files the rules cover", progress: format!("{}s", started.elapsed().as_secs()), - note: "Nothing is changed while neet looks. This happens once, and again when you press r.", + note: "Read-only scan. This happens once, and again when you press r.", } .draw(frame, area); return; @@ -950,13 +956,13 @@ impl Screen for Clean { title: "Deep Clean", doing: "Finding files the rules cover", progress: format!("{}s", started.elapsed().as_secs()), - note: "Nothing is changed while neet looks.", + note: "Read-only scan.", } .draw(frame, area); return; } State::Failed(reason) => { - let block = Block::bordered() + let block = super::visual::block() .title(" Deep Clean ") .padding(Padding::horizontal(1)); frame.render_widget( @@ -992,7 +998,7 @@ impl Screen for Clean { }; let current = self.selected(); - let table = rules_table(planned, current, summary); + let table = rules_table(planned, current, summary, list_area.width); frame.render_stateful_widget(table, list_area, &mut self.list); let Some(rule) = planned.plan.rules.get(current) else { @@ -1071,7 +1077,7 @@ impl Screen for Clean { ), ("Enter", "Review every path the selected rules found"), ("r", "Look again, such as after removing files yourself"), - ("Esc", "Go back to Home"), + ("Esc", "Back to Home"), ("q", "Quit"), ] } @@ -1461,4 +1467,22 @@ mod tests { assert!(screen.contains("1 rule problems")); assert!(screen.contains("Not loaded:")); } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let scan = crate::ui::scan::ScanStatus::Failed(String::new()); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + let dir = fake_home(); + let mut screen = Clean::ready(planned(&dir)); + for (width, height) in view::SIZES { + let buffer = view::render("clean", &mut screen, &context, width, height); + view::aligned(&buffer, "Size", "4.1 KB"); + assert!(view::text(&buffer).contains("Risk")); + } + } } diff --git a/crates/neet/src/ui/disk.rs b/crates/neet/src/ui/disk.rs index 8eba7c4..207c9ee 100644 --- a/crates/neet/src/ui/disk.rs +++ b/crates/neet/src/ui/disk.rs @@ -7,10 +7,9 @@ use neet_core::tree::{NodeId, NodeKind, Tree}; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::Color; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, List, ListItem, ListState, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; use super::app::{Action, Context, Screen}; use super::format; @@ -52,7 +51,7 @@ impl Sort { struct Browser { current: NodeId, rows: Vec, - list: ListState, + list: TableState, sort: Sort, /// For saying whether the selected item can be cleaned roots: Option, @@ -63,7 +62,7 @@ impl Browser { let mut browser = Self { current: tree.root(), rows: Vec::new(), - list: ListState::default(), + list: TableState::default(), sort: Sort::Size, roots: CleanupRoots::new(&tree.path(tree.root())).ok(), }; @@ -160,29 +159,66 @@ pub(super) fn display_path(tree: &Tree, id: NodeId) -> String { } } -fn row(tree: &Tree, id: NodeId, parent_total: u64) -> ListItem<'static> { +fn columns(width: u16, tree: &Tree, rows: &[NodeId], selected: bool) -> super::visual::Columns<4> { + let sizes = format::column_width( + "Size", + rows.iter().map(|&id| format::size(tree.get(id).total_size)), + ) + .max(9); + super::visual::Columns::new( + width, + [(12, 0), (sizes, 0), (4, 0), (16, 1)], + &[0], + selected, + ) +} + +fn header(columns: &super::visual::Columns<4>) -> Row<'static> { + columns + .row([ + Cell::from(""), + Cell::from(Line::from("Size").right_aligned()), + Cell::from(Line::from("%").right_aligned()), + Cell::from("Name"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1) +} + +fn row( + tree: &Tree, + id: NodeId, + parent_total: u64, + columns: &super::visual::Columns<4>, +) -> Row<'static> { let node = tree.get(id); - let name = Span::raw(display_name(tree, id)); + let name = Span::raw(format::shorten_middle( + &display_name(tree, id), + columns.width(3), + )); let name = if node.kind == NodeKind::Directory { - name.blue().bold() + name.fg(super::visual::ACCENT).bold() } else { name }; - ListItem::new(Line::from(vec![ - Span::raw(format::bar(node.total_size, parent_total, BAR_WIDTH)) - .fg(bar_color(node.total_size)), - Span::raw(" "), - format::size_span( - node.total_size, - format!("{:>9}", format::size(node.total_size)), + columns.row([ + Cell::from(format::size_bar(node.total_size, parent_total, BAR_WIDTH)), + Cell::from( + Line::from(format::size_span( + node.total_size, + format::size(node.total_size), + )) + .right_aligned(), ), - Span::raw(" "), - Span::raw(format!( - "{:>3}% ", - format::percent(node.total_size, parent_total) - )), - name, - ])) + Cell::from( + Line::from(format!( + "{}%", + format::percent(node.total_size, parent_total) + )) + .right_aligned(), + ), + Cell::from(name), + ]) } fn draw_folder(frame: &mut Frame, area: Rect, tree: &Tree, browser: &mut Browser) { @@ -199,28 +235,31 @@ fn draw_folder(frame: &mut Frame, area: Rect, tree: &Tree, browser: &mut Browser }, browser.sort.label() ); - let block = Block::bordered() + let block = super::visual::block() .title(title) - .title(Line::from(summary).right_aligned()) + .title_bottom(Line::from(summary).right_aligned()) .padding(Padding::horizontal(1)); if browser.rows.is_empty() { frame.render_widget(Paragraph::new("Empty folder.").block(block), area); return; } + let columns = columns(area.width, tree, &browser.rows, true); let items = browser .rows .iter() - .map(|&id| row(tree, id, folder.total_size)); - let list = List::new(items) + .map(|&id| row(tree, id, folder.total_size, &columns)); + let table = Table::new(items, columns.widths()) + .header(header(&columns)) + .column_spacing(2) .block(block) .highlight_symbol("▸ ") - .highlight_style(Style::new().bold()); - frame.render_stateful_widget(list, area, &mut browser.list); + .row_highlight_style(super::visual::SELECTED); + frame.render_stateful_widget(table, area, &mut browser.list); } fn draw_preview(frame: &mut Frame, area: Rect, tree: &Tree, browser: &Browser) { - let block = Block::bordered().padding(Padding::horizontal(1)); + let block = super::visual::block().padding(Padding::horizontal(1)); let Some(id) = browser.selected() else { frame.render_widget(block, area); return; @@ -229,24 +268,16 @@ fn draw_preview(frame: &mut Frame, area: Rect, tree: &Tree, browser: &Browser) { let block = block.title( Line::from(format!(" {} ", display_name(tree, id))) .bold() - .cyan(), + .fg(super::visual::ACCENT), ); let inner = block.inner(area); frame.render_widget(block, area); - let header = about(tree, id, browser); - // Each header line wraps, so count how many rows it takes. - let width = usize::from(inner.width.max(1)); - let height: usize = header - .iter() - .map(|line| line.width().max(1).div_ceil(width)) - .sum(); - let [top, rest] = Layout::vertical([ - Constraint::Length(u16::try_from(height).unwrap_or(u16::MAX)), - Constraint::Fill(1), - ]) - .areas(inner); - frame.render_widget(Paragraph::new(header).wrap(Wrap { trim: false }), top); + let details = about(tree, id, browser); + let height = super::visual::wrapped_rows(&details, inner.width); + let [top, rest] = + Layout::vertical([Constraint::Length(height), Constraint::Fill(1)]).areas(inner); + frame.render_widget(Paragraph::new(details).wrap(Wrap { trim: false }), top); if node.kind != NodeKind::Directory || rest.height < 3 { return; @@ -263,19 +294,21 @@ fn draw_preview(frame: &mut Frame, area: Rect, tree: &Tree, browser: &Browser) { Paragraph::new(vec![Line::default(), Line::from(title)]), heading, ); - let items = rows.iter().map(|&child| row(tree, child, node.total_size)); - frame.render_widget(List::new(items), list); -} - -/// The bar color for an item of `bytes`, matching the size colors. -fn bar_color(bytes: u64) -> Color { - if bytes >= format::HUGE { - Color::Red - } else if bytes >= format::HUGE / 5 { - Color::Yellow - } else { - Color::Cyan - } + // This table is already inside the preview border and has no selection gutter. + let columns = columns(list.width.saturating_add(4), tree, &rows, false); + let items = rows + .iter() + .map(|&child| row(tree, child, node.total_size, &columns)); + frame.render_widget( + Table::new(items, columns.widths()) + .column_spacing(2) + .header(if list.height >= 4 { + header(&columns) + } else { + Row::default() + }), + list, + ); } /// What the selected item is, how big, and whether neet can clean it. @@ -327,25 +360,22 @@ fn about(tree: &Tree, id: NodeId, browser: &Browser) -> Vec> { /// Whether neet cleans an item, in one colored line. fn cleanable(path: &std::path::Path, roots: Option<&CleanupRoots>) -> Line<'static> { let Some(roots) = roots else { - return Line::from("neet could not read the home folder, so nothing can be cleaned."); + return Line::from("Home unavailable. Cleanup blocked."); }; match roots.validate_deletable(path) { Ok(_) => Line::from(vec![ Span::raw("✓ ").green().bold(), - Span::raw("In a folder neet cleans.").green(), + Span::raw("Inside cleanup folders.").green(), ]), Err(SafetyError::IsRoot) => Line::from(vec![ Span::raw("◆ ").yellow(), - Span::raw("A cleanup folder. neet cleans the items inside.").yellow(), + Span::raw("Cleanup root. Only contents can be moved.").yellow(), ]), Err(SafetyError::Protected) => Line::from(vec![ Span::raw("✗ ").red().bold(), - Span::raw("Protected. neet never moves anything in it.").red(), - ]), - Err(_) => Line::from(vec![ - Span::raw("· "), - Span::raw("Outside the folders neet cleans."), + Span::raw("Protected. Removal blocked.").red(), ]), + Err(_) => Line::from(vec![Span::raw("· "), Span::raw("Outside cleanup folders.")]), } } @@ -481,27 +511,20 @@ impl Screen for Disk { } ScanStatus::Running(progress) => progress, ScanStatus::Failed(reason) => { - let block = Block::bordered() + let block = super::visual::block() .title(" Disk ") .padding(Padding::horizontal(1)); frame.render_widget( - Paragraph::new(format!( - "The scan failed, so there is nothing to show. {reason}" - )) - .red() - .wrap(ratatui::widgets::Wrap { trim: true }) - .block(block), + Paragraph::new(format!("Scan failed: {reason}")) + .red() + .wrap(ratatui::widgets::Wrap { trim: true }) + .block(block), area, ); return; } }; - scanning( - "Disk", - "Your folders show here when the scan finishes.", - *progress, - ) - .draw(frame, area); + scanning("Disk", "Loading folders by size.", *progress).draw(frame, area); } fn handle_key(&mut self, key: KeyEvent, context: &Context) -> Action { @@ -529,12 +552,12 @@ impl Screen for Disk { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("→ Enter l", "Open the selected folder"), ("← h", "Go up to the parent folder"), ("s", "Sort by size, name, or items"), ("g G", "Jump to the first or last row"), - ("Esc", "Go back to Home"), + ("Esc", "Back to Home"), ("q", "Quit"), ] } @@ -698,7 +721,7 @@ mod tests { let screen = render(&mut Disk::new(), &scan, 80); - assert!(screen.contains("Scanning your home folder")); + assert!(screen.contains("Scanning home")); assert!(screen.contains("42 items · 2.0 MB so far")); } @@ -724,8 +747,8 @@ mod tests { .unwrap(); let buffer = terminal.backend().buffer(); - // The first row sits below the top border, after the padding. - let arrow = &buffer[(2, 1)]; + // The first row follows the border, header, and header spacing. + let arrow = &buffer[(2, 3)]; assert_eq!(arrow.symbol(), "▸"); assert!(arrow.modifier.contains(ratatui::style::Modifier::BOLD)); assert!(!arrow.modifier.contains(ratatui::style::Modifier::REVERSED)); @@ -741,10 +764,10 @@ mod tests { let roots = CleanupRoots::new(dir.path()).expect("roots should be made"); let status = |path: &str| cleanable(&dir.path().join(path), Some(&roots)).to_string(); - assert!(status("Library/Caches/app").contains("In a folder neet cleans")); - assert!(status("Library/Caches").contains("A cleanup folder")); + assert!(status("Library/Caches/app").contains("Inside cleanup folders")); + assert!(status("Library/Caches").contains("Cleanup root")); assert!(status("Documents").contains("Protected")); - assert!(status("notes").contains("Outside the folders neet cleans")); + assert!(status("notes").contains("Outside cleanup folders")); } #[test] @@ -759,4 +782,29 @@ mod tests { assert!(meaning(&tree, library).is_some()); assert_eq!(meaning(&tree, other), None); } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let scan = ScanStatus::Done { + scan: Scan { + tree: sample(), + errors: Vec::new(), + other_disks: Vec::new(), + }, + elapsed: std::time::Duration::ZERO, + }; + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + let mut screen = Disk::new(); + for (width, height) in view::SIZES { + let buffer = view::render("disk", &mut screen, &context, width, height); + view::aligned(&buffer, "Size", "100 B"); + view::aligned(&buffer, "%", "94%"); + assert!(view::text(&buffer).contains("apple.txt")); + } + } } diff --git a/crates/neet/src/ui/format.rs b/crates/neet/src/ui/format.rs index 89ed4ac..978dcb8 100644 --- a/crates/neet/src/ui/format.rs +++ b/crates/neet/src/ui/format.rs @@ -1,7 +1,8 @@ use std::time::Duration; use ratatui::style::Stylize; -use ratatui::text::Span; +use ratatui::text::{Line, Span}; +use unicode_segmentation::UnicodeSegmentation; /// Sizes from here are red, and from a fifth of it yellow. pub const HUGE: u64 = 5_000_000_000; @@ -95,52 +96,81 @@ pub fn percent(part: u64, total: u64) -> u64 { u64::try_from(u128::from(part) * 100 / u128::from(total)).unwrap_or(100) } -/// Shortens `text` to `width` characters by cutting out its middle, so a +/// Shortens `text` to `width` terminal cells by cutting out its middle, so a /// file name keeps its start and its extension. pub fn shorten_middle(text: &str, width: usize) -> String { - let chars: Vec = text.chars().collect(); - if chars.len() <= width { + if display_width(text) <= width { return text.to_string(); } if width == 0 { return String::new(); } let keep = width - 1; - let end = keep / 2; - let start = keep - end; - let head: String = chars[..start].iter().collect(); - let tail: String = chars[chars.len() - end..].iter().collect(); + let tail = suffix(text, keep / 2); + let mut head = String::new(); + let room = keep - display_width(&tail); + for part in text.graphemes(true) { + if display_width(&head) + display_width(part) > room { + break; + } + head.push_str(part); + } format!("{head}…{tail}") } -/// Shortens a folder path to `width` characters by cutting out its middle. -/// Keeps where it starts, such as `~/Library`, and the part nearest the file. +/// Terminal cells occupied by text, including wide and combining characters. +pub fn display_width(text: &str) -> usize { + Line::from(text).width() +} + +fn suffix(text: &str, width: usize) -> String { + let mut used = 0; + let parts: Vec<&str> = text + .graphemes(true) + .rev() + .take_while(|part| { + used += display_width(part); + used <= width + }) + .collect(); + parts.into_iter().rev().collect() +} + +/// Shortens a folder path without splitting a grapheme or exceeding its cells. pub fn shorten_path(path: &str, width: usize) -> String { - let chars: Vec = path.chars().collect(); - if chars.len() <= width { + if display_width(path) <= width { return path.to_string(); } if width == 0 { return String::new(); } - // The first two parts, such as `~/Library/`, when they leave room for the end let head: String = path.split_inclusive('/').take(2).collect(); - let head_len = head.chars().count(); - let head = if head_len < chars.len() && head_len + 1 + 12 <= width { + let head = if display_width(&head) + 13 <= width { head } else { String::new() }; - let keep = width - head.chars().count() - 1; - let tail: String = chars[chars.len() - keep..].iter().collect(); - // Start the end at a whole folder name when there is one + let tail = suffix(path, width - display_width(&head) - 1); let tail = match tail.find('/') { - Some(slash) if slash + 1 < tail.len() => tail[slash..].to_string(), - _ => tail, + Some(slash) if slash + 1 < tail.len() => &tail[slash..], + _ => &tail, }; format!("{head}…{tail}") } +/// Room for a header and the widest formatted value beneath it. +pub fn column_width(header: &str, values: impl IntoIterator) -> u16 { + u16::try_from( + values + .into_iter() + .map(|value| display_width(&value)) + .chain([display_width(header)]) + .max() + .unwrap_or(0), + ) + .unwrap_or(u16::MAX) +} + #[cfg(test)] mod tests { use super::{age, bar, count, percent, shorten_middle, shorten_path, size}; @@ -202,4 +232,26 @@ mod tests { assert_eq!(shorten_path("~/Library/Containers/Docker", 12), "…/Docker"); assert_eq!(shorten_path("anything", 1), "…"); } + + #[test] + fn shortening_respects_terminal_cells_and_graphemes() { + use super::display_width; + for text in [ + "日本語の長い名前.zip", + "cafe\u{301}-backup.tar", + "👩‍💻-project-backup.zip", + ] { + for width in 0..30 { + for shortened in [shorten_middle(text, width), shorten_path(text, width)] { + assert!( + display_width(&shortened) <= width, + "{shortened:?} exceeds {width}" + ); + assert!(!shortened.starts_with('\u{301}')); + assert!(!shortened.ends_with('\u{200d}')); + } + } + } + assert_eq!(shorten_middle("日本語.zip", 9), "日本….zip"); + } } diff --git a/crates/neet/src/ui/help.rs b/crates/neet/src/ui/help.rs index 71b72b6..9752dd5 100644 --- a/crates/neet/src/ui/help.rs +++ b/crates/neet/src/ui/help.rs @@ -3,7 +3,7 @@ use ratatui::crossterm::event::KeyEvent; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::widgets::{Block, Clear, Paragraph}; +use ratatui::widgets::{Clear, Paragraph}; use super::app::{Action, Context, Screen}; @@ -23,9 +23,15 @@ impl Screen for Help { let lines: Vec = self .keys .iter() - .map(|(key, action)| Line::from(vec![format!("{key:<14}").bold(), (*action).into()])) + .map(|(key, action)| { + Line::from(vec![ + format!("{key:<14}").fg(super::visual::ACCENT).bold(), + (*action).into(), + ]) + }) .collect(); - let height = u16::try_from(lines.len() + 2).unwrap_or(u16::MAX); + let height = super::visual::wrapped_rows(&lines, area.width.min(48).saturating_sub(2)) + .saturating_add(2); let [area] = Layout::vertical([Constraint::Length(height)]) .flex(Flex::Center) .areas(area); @@ -34,7 +40,9 @@ impl Screen for Help { .areas(area); frame.render_widget(Clear, area); frame.render_widget( - Paragraph::new(lines).block(Block::bordered().title(" Help ")), + Paragraph::new(lines) + .wrap(ratatui::widgets::Wrap { trim: false }) + .block(super::visual::block().title(" Help ")), area, ); } diff --git a/crates/neet/src/ui/home.rs b/crates/neet/src/ui/home.rs index 20b04b1..a61ab0e 100644 --- a/crates/neet/src/ui/home.rs +++ b/crates/neet/src/ui/home.rs @@ -1,9 +1,9 @@ use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, List, ListItem, ListState, Padding, Paragraph, Wrap}; +use ratatui::widgets::{List, ListItem, ListState, Padding, Paragraph, Wrap}; use super::app::{Action, Context, Screen}; use super::apps::RemoveApp; @@ -44,37 +44,37 @@ impl Entry { const ENTRIES: &[Entry] = &[ Entry { label: "Quick Clean", - about: "Start here: everything taking space that can be cleared, and how to clear it.", + about: "Find reclaimable space. Select a cleanup action.", target: Target::Screen(|| Box::new(QuickClean::new())), }, Entry { label: "Deep Clean", - about: "Every cache and log rule, one by one: choose what goes to the Trash, and review every path.", + about: "Select cache and log rules. Review paths before moving to Trash.", target: Target::Screen(|| Box::new(Clean::new())), }, Entry { label: "Remove App", - about: "Review an app and its files before removing it.", + about: "Select an app. Review its files before removal.", target: Target::Screen(|| Box::new(RemoveApp::new())), }, Entry { label: "Large Files", - about: "Find large and old files, and open them in Disk.", + about: "Find large or old files. Inspect them in Disk.", target: Target::Screen(|| Box::new(LargeFiles::new())), }, Entry { label: "Disk", - about: "Browse your folders by size.", + about: "Browse folders by size.", target: Target::Screen(|| Box::new(Disk::new())), }, Entry { label: "Startup", - about: "See programs that start on their own, and turn them off in a way you can undo.", + about: "Manage startup programs.", target: Target::Soon, }, Entry { label: "SSH", - about: "See hosts and key details, fix permissions, and manage agent keys and known hosts.", + about: "Manage hosts, keys, and permissions.", target: Target::Soon, }, Entry { @@ -89,7 +89,7 @@ const ENTRIES: &[Entry] = &[ }, Entry { label: "Settings", - about: "See what keeps the Mac awake, and change power and display settings.", + about: "Manage power and display settings.", target: Target::Soon, }, Entry { @@ -147,25 +147,26 @@ impl Home { _ if index >= 9 => " ".to_string(), _ => format!("{} ", index + 1), }; - let mut spans = vec![Span::raw(number), Span::raw(format!("{:<14}", entry.label))]; + let mut spans = vec![ + Span::raw(number).fg(super::visual::ACCENT), + Span::raw(format!("{:<14}", entry.label)), + ]; if let Some(summary) = summary(entry.label, context) { - spans.push(Span::raw(summary)); + spans.push(Span::raw(summary).fg(super::visual::ACCENT)); } if matches!(entry.target, Target::Soon) { - spans.push(Span::raw("soon").italic()); - ListItem::new(Line::from(spans)).dark_gray() - } else { - ListItem::new(Line::from(spans)) + spans.push(Span::raw("soon").yellow().italic()); } + ListItem::new(Line::from(spans)) }); let list = List::new(items) .block( - Block::bordered() + super::visual::block() .title(" neet ") .padding(Padding::horizontal(1)), ) .highlight_symbol("▸ ") - .highlight_style(Style::new().bold().white()); + .highlight_style(super::visual::SELECTED); frame.render_stateful_widget(list, area, &mut self.list); } @@ -174,7 +175,7 @@ impl Home { let disk = context.disk; let entry = &ENTRIES[self.selected()]; let mut lines = vec![ - Line::from(entry.label).bold(), + Line::from(entry.label).style(super::visual::HEADING), Line::from(entry.about), Line::default(), ]; @@ -184,7 +185,7 @@ impl Home { let text = Text::from(lines); let info = Paragraph::new(text) .wrap(Wrap { trim: true }) - .block(Block::bordered().padding(Padding::horizontal(1))); + .block(super::visual::block().padding(Padding::horizontal(1))); frame.render_widget(info, area); } } @@ -195,7 +196,7 @@ fn summary(label: &str, context: &Context) -> Option { "Disk" => context .disk .map(|disk| format!("{} used", format::size(disk.used()))), - "Quick Clean" => Some("start here".to_string()), + "Quick Clean" => Some("overview".to_string()), "Deep Clean" => Some(context.cleanable.map_or_else( || "finding…".to_string(), |size| format!("~{} found", format::size(size)), @@ -215,14 +216,14 @@ const MIN_PURGEABLE: u64 = 100_000_000; /// can clear space on its own, a last line says why Finder shows more free. fn disk_lines(disk: Option) -> Vec> { let Some(disk) = disk else { - return vec![Line::from("Disk space could not be read.")]; + return vec![Line::from("Disk space unavailable.")]; }; let used = format::percent(disk.used(), disk.total); let gauge = Span::raw(format::bar(disk.used(), disk.total, GAUGE_WIDTH)); let gauge = match used { 90.. => gauge.red(), 75..90 => gauge.yellow(), - _ => gauge.cyan(), + _ => gauge.fg(super::visual::ACCENT), }; let mut lines = vec![ Line::from(vec![gauge, Span::raw(format!(" {used}% used")).bold()]), @@ -234,7 +235,7 @@ fn disk_lines(disk: Option) -> Vec> { ]; if let Some(purgeable) = disk.purgeable.filter(|size| *size >= MIN_PURGEABLE) { lines.push(Line::from(format!( - "Finder shows {} free, counting {} macOS clears when needed.", + "Finder: {} free · {} purgeable by macOS.", format::size(disk.available.saturating_add(purgeable)), format::size(purgeable) ))); @@ -255,7 +256,7 @@ fn paths(count: usize) -> String { fn scan_lines(scan: &ScanStatus) -> Vec> { match scan { ScanStatus::Running(progress) => vec![ - Line::from("Scanning your home folder…").cyan(), + Line::from("Scanning home…").fg(super::visual::ACCENT), Line::from(format!( "{} items · {}", format::count(progress.entries), @@ -268,7 +269,7 @@ fn scan_lines(scan: &ScanStatus) -> Vec> { // An incomplete scan missed whatever it could not read. let at_least = if scan.is_complete() { "" } else { "at least " }; let mut lines = vec![Line::from(format!( - "Home folder: {at_least}{} in {} items, scanned in {}s.", + "Home: {at_least}{} · {} items · {}s", format::size(total), format::count(entries), elapsed.as_secs() @@ -281,25 +282,17 @@ fn scan_lines(scan: &ScanStatus) -> Vec> { let unreadable = scan.errors.len() - blocked; if blocked > 0 { lines.push( - Line::from(format!( - "Incomplete: macOS blocked {}. Press s to see how to allow them.", - paths(blocked) - )) - .yellow(), + Line::from(format!("Blocked: {} · s permissions", paths(blocked))).yellow(), ); } if unreadable > 0 { lines.push( - Line::from(format!( - "Incomplete: {} could not be read. Press s to see them.", - paths(unreadable) - )) - .yellow(), + Line::from(format!("Unreadable: {} · s details", paths(unreadable))).yellow(), ); } if !scan.other_disks.is_empty() { lines.push(Line::from(format!( - "Skipped {} folders on other disks. Press s to see them.", + "Other disks: {} folders skipped · s details", scan.other_disks.len() ))); } @@ -357,7 +350,7 @@ impl Screen for Home { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("Enter → l", "Open the selected screen"), ("1 to 9", "Open that row"), ("s", "See what the scan skipped"), @@ -457,7 +450,7 @@ mod tests { assert!(screen.contains("1,234 items · 5.0 MB")); assert!(screen.contains("80% used")); assert!(screen.contains("100.0 GB free of 500.0 GB")); - assert!(screen.contains("Finder shows 107.4 GB free, counting 7.4 GB")); + assert!(screen.contains("Finder: 107.4 GB free · 7.4 GB")); assert!(screen.contains("400.0 GB used")); assert!(screen.contains("~18.4 GB found")); } @@ -476,7 +469,7 @@ mod tests { .join("\n") }; - assert!(text(Some(7_400_000_000)).contains("counting 7.4 GB macOS clears when needed")); + assert!(text(Some(7_400_000_000)).contains("7.4 GB purgeable by macOS")); assert!(!text(Some(50_000_000)).contains("Finder")); assert!(!text(None).contains("Finder")); } @@ -507,8 +500,26 @@ mod tests { let text: Vec = scan_lines(&scan).iter().map(ToString::to_string).collect(); - assert!(text[0].starts_with("Home folder: at least ")); - assert!(text[1].contains("macOS blocked 2 paths")); - assert!(text[2].contains("1 path could not be read")); + assert!(text[0].starts_with("Home: at least ")); + assert!(text[1].contains("Blocked: 2 paths")); + assert!(text[2].contains("Unreadable: 1 path")); + } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let scan = crate::ui::scan::ScanStatus::Failed(String::new()); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + let mut screen = Home::new(); + for (width, height) in view::SIZES { + let buffer = view::render("home", &mut screen, &context, width, height); + assert!(view::text(&buffer).contains("Quick Clean")); + let (x, y) = view::position(&buffer, "soon"); + assert_eq!(buffer[(x, y)].fg, ratatui::style::Color::Yellow); + } } } diff --git a/crates/neet/src/ui/large.rs b/crates/neet/src/ui/large.rs index b26b81f..b662e1c 100644 --- a/crates/neet/src/ui/large.rs +++ b/crates/neet/src/ui/large.rs @@ -7,9 +7,9 @@ use neet_core::tree::{NodeId, Tree}; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::widgets::{Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; use super::app::{Action, Context, Screen}; use super::clean::rows_used; @@ -113,6 +113,31 @@ impl LargeFiles { /// The two filters, every choice shown and the current one marked, so /// `s` and `a` say what they do fn draw_filters(&self, frame: &mut Frame, area: Rect) { + if area.width < 100 { + let lines = vec![ + Line::from(vec![ + Span::raw("Size ≥ "), + Span::raw(SIZES[self.size].1) + .fg(super::visual::ACCENT) + .bold(), + Span::raw(" s change"), + ]), + Line::from(vec![ + Span::raw("Unchanged: "), + Span::raw(AGES[self.age].1).fg(super::visual::ACCENT).bold(), + Span::raw(" a change"), + ]), + ]; + frame.render_widget( + Paragraph::new(lines).block( + super::visual::block() + .title(" Large Files ") + .padding(Padding::horizontal(1)), + ), + area, + ); + return; + } let choices = |labels: Vec, current: usize, key: &'static str, label: &str| { let mut spans = vec![Span::raw(format!("{label:<11}")).bold()]; for (index, text) in labels.into_iter().enumerate() { @@ -147,7 +172,7 @@ impl LargeFiles { ]; frame.render_widget( Paragraph::new(lines).block( - Block::bordered() + super::visual::block() .title(" Large Files ") .padding(Padding::horizontal(1)), ), @@ -175,7 +200,7 @@ impl LargeFiles { format::count(u64::try_from(MAX_ROWS).unwrap_or(u64::MAX)) ))); } - let block = Block::bordered() + let block = super::visual::block() .title(" Files, largest first ") .title_bottom(Line::from(summary).right_aligned()) .padding(Padding::horizontal(1)); @@ -190,52 +215,40 @@ impl LargeFiles { return; } - // The name and folder share what is left after the fixed columns, - // the border, the padding, and the selection arrow. - let fixed = 2 + 2 + 2 + SIZE_WIDTH + BAR_WIDTH + AGE_WIDTH + GAP * 4; - let rest = area.width.saturating_sub(fixed); - let name = rest * 2 / 5; - let folder = rest - name; + let sizes = format::column_width( + "Size", + found.iter().map(|&id| format::size(tree.get(id).own_size)), + ) + .max(SIZE_WIDTH); + let columns = super::visual::Columns::new( + area.width, + [(sizes, 0), (BAR_WIDTH, 0), (AGE_WIDTH, 0), (18, 2), (18, 3)], + &[1, 4, 2], + true, + ); let largest = found.first().map_or(0, |&id| tree.get(id).own_size); let now = SystemTime::now(); let rows: Vec = found .iter() .take(MAX_ROWS) - .map(|&id| { - row( - tree, - id, - now, - largest, - usize::from(name), - usize::from(folder), - ) - }) + .map(|&id| row(tree, id, now, largest, &columns)) .collect(); - let header = Row::new([ - Cell::from(Line::from("Size").right_aligned()), - Cell::from(""), - Cell::from("Last changed"), - Cell::from("Name"), - Cell::from("Folder"), - ]) - .bold() - .bottom_margin(1); - let table = Table::new( - rows, - [ - Constraint::Length(SIZE_WIDTH), - Constraint::Length(BAR_WIDTH), - Constraint::Length(AGE_WIDTH), - Constraint::Length(name), - Constraint::Length(folder), - ], - ) - .header(header) - .column_spacing(GAP) - .block(block) - .highlight_symbol("▸ ") - .row_highlight_style(Style::new().bold()); + let header = columns + .row([ + Cell::from(Line::from("Size").right_aligned()), + Cell::from(""), + Cell::from("Last changed"), + Cell::from("Name"), + Cell::from("Folder"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); + let table = Table::new(rows, columns.widths()) + .header(header) + .column_spacing(GAP) + .block(block) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED); frame.render_stateful_widget(table, area, &mut self.table); } @@ -297,7 +310,7 @@ impl LargeFiles { Layout::vertical([Constraint::Length(height), Constraint::Fill(1)]).areas(area); frame.render_widget( Paragraph::new(lines).wrap(Wrap { trim: true }).block( - Block::bordered() + super::visual::block() .title(Line::from(format!(" {name} ")).bold()) .title_bottom(Line::from(" Enter shows it in Disk ").right_aligned()) .padding(Padding::horizontal(1)), @@ -309,7 +322,7 @@ impl LargeFiles { /// Where the files found are, by folder, largest first fn draw_where(frame: &mut Frame, area: Rect, tree: &Tree, found: &[NodeId]) { - let block = Block::bordered() + let block = super::visual::block() .title(" Where they are ") .padding(Padding::horizontal(1)); let groups = by_folder(tree, found); @@ -407,10 +420,9 @@ fn kind_of(name: &str) -> (&'static str, Option<&'static str>) { "Archive", Some("Often safe to delete once it has been unpacked."), ), - "mov" | "mp4" | "m4v" | "mkv" | "avi" | "webm" => ( - "Video", - Some("Your own media. Back it up before you remove it."), - ), + "mov" | "mp4" | "m4v" | "mkv" | "avi" | "webm" => { + ("Video", Some("Personal media. Back up before removal.")) + } "raw" | "img" | "vmdk" | "vdi" | "qcow2" | "sparseimage" | "sparsebundle" => ( "Virtual disk", Some("Used by a virtual machine or Docker. Free it from the app that made it."), @@ -442,8 +454,7 @@ fn row( id: NodeId, now: SystemTime, largest: u64, - name: usize, - folder: usize, + columns: &super::visual::Columns<5>, ) -> Row<'static> { let node = tree.get(id); let size = format::size_span(node.own_size, format::size(node.own_size)); @@ -461,7 +472,7 @@ fn row( let path = display_path(tree, id); let (parent, file) = path.rsplit_once('/').unwrap_or(("", path.as_str())); - Row::new([ + columns.row([ Cell::from(Line::from(size).right_aligned()), Cell::from(format::size_bar( node.own_size, @@ -469,8 +480,8 @@ fn row( usize::from(BAR_WIDTH), )), Cell::from(age), - Cell::from(format::shorten_middle(file, name)), - Cell::from(Span::raw(format::shorten_path(parent, folder))), + Cell::from(format::shorten_middle(file, columns.width(3))), + Cell::from(Span::raw(format::shorten_path(parent, columns.width(4)))), ]) } @@ -479,25 +490,18 @@ impl Screen for LargeFiles { let scan = match context.scan { ScanStatus::Done { scan, .. } => scan, ScanStatus::Running(progress) => { - scanning( - "Large Files", - "Large files show here when the scan finishes.", - *progress, - ) - .draw(frame, area); + scanning("Large Files", "Finding large files.", *progress).draw(frame, area); return; } ScanStatus::Failed(reason) => { - let block = Block::bordered() + let block = super::visual::block() .title(" Large Files ") .padding(Padding::horizontal(1)); frame.render_widget( - Paragraph::new(format!( - "The scan failed, so there is nothing to show. {reason}" - )) - .red() - .wrap(Wrap { trim: true }) - .block(block), + Paragraph::new(format!("Scan failed: {reason}")) + .red() + .wrap(Wrap { trim: true }) + .block(block), area, ); return; @@ -513,10 +517,43 @@ impl Screen for LargeFiles { } else { (area, None) }; + let (left, compact) = if side.is_none() && area.height >= 18 { + let [left, detail] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(left); + (left, Some(detail)) + } else { + (left, None) + }; let [filters, table] = Layout::vertical([Constraint::Length(4), Constraint::Fill(1)]).areas(left); self.draw_filters(frame, filters); self.draw_table(frame, table, tree, &found); + if let Some(compact) = compact { + let details = self + .table + .selected() + .and_then(|index| found.get(index)) + .map(|&id| { + let node = tree.get(id); + let age = node + .modified + .and_then(|time| SystemTime::now().duration_since(time).ok()) + .map_or_else(|| "unknown".to_string(), format::age); + vec![ + Line::from(display_path(tree, id)).fg(super::visual::ACCENT), + Line::from(format!("Changed: {age}")), + ] + }) + .unwrap_or_default(); + frame.render_widget( + Paragraph::new(details).wrap(Wrap { trim: false }).block( + super::visual::block() + .title(" Selected ") + .padding(Padding::horizontal(1)), + ), + compact, + ); + } if let Some(side) = side { self.draw_side(frame, side, tree, &found); } @@ -559,7 +596,7 @@ impl Screen for LargeFiles { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("g G", "Jump to the first or last file"), ("s", "Change the smallest size: 10 MB to 5 GB"), ("a", "Change how long files must be unchanged"), @@ -727,7 +764,7 @@ mod tests { let scan = ScanStatus::Running(neet_core::scan::Progress::default()); let screen = render(&mut LargeFiles::new(), &scan); - assert!(screen.contains("Large files show here when the scan finishes")); + assert!(screen.contains("Finding large files")); } #[test] @@ -801,4 +838,22 @@ mod tests { assert_eq!(kind_of("notes").0, "File"); assert_eq!(kind_of("notes").1, None); } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let scan = done(); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + let mut screen = LargeFiles::new(); + for (width, height) in view::SIZES { + let buffer = view::render("large", &mut screen, &context, width, height); + view::aligned(&buffer, "Size", "2.0 GB"); + assert!(view::text(&buffer).contains("backup.zip")); + assert!(view::text(&buffer).contains("Changed")); + } + } } diff --git a/crates/neet/src/ui/loading.rs b/crates/neet/src/ui/loading.rs index 82d6c39..0e0393a 100644 --- a/crates/neet/src/ui/loading.rs +++ b/crates/neet/src/ui/loading.rs @@ -5,7 +5,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Padding, Paragraph, Wrap}; use super::format; @@ -19,7 +19,7 @@ const WIDTH: u16 = 60; pub struct Loading<'a> { /// The screen's name, shown on the box's border pub title: &'a str, - /// What neet is doing, such as `Scanning your home folder` + /// What neet is doing, such as `Scanning home` pub doing: &'a str, /// How far it has got, such as `812,400 items · 48.2 GB` pub progress: String, @@ -31,30 +31,25 @@ impl Loading<'_> { /// Draws a small box in the middle of `area`. pub fn draw(&self, frame: &mut Frame, area: Rect) { let width = WIDTH.min(area.width); - // The note wraps inside the border and padding. - let inner = usize::from(width.saturating_sub(4)).max(1); - let note_lines = self.note.chars().count().div_ceil(inner).max(1); - // Border and padding, then doing, progress, a gap, and the note - let height = u16::try_from(4 + 3 + note_lines) - .unwrap_or(u16::MAX) - .min(area.height); - let [area] = Layout::vertical([Constraint::Length(height)]) - .flex(Flex::Center) - .areas(area); - let [area] = Layout::horizontal([Constraint::Length(width)]) - .flex(Flex::Center) - .areas(area); - let lines = vec![ Line::from(vec![ - Span::raw(format!("{} ", spinner())).cyan(), + Span::raw(format!("{} ", spinner())).fg(super::visual::ACCENT), Span::raw(self.doing).bold(), ]), - Line::from(format!(" {}", self.progress)).cyan(), + Line::from(format!(" {}", self.progress)).fg(super::visual::ACCENT), Line::default(), Line::from(self.note), ]; - let block = Block::bordered() + let height = super::visual::wrapped_rows(&lines, width.saturating_sub(4)) + .saturating_add(4) + .min(area.height); + let [area] = Layout::vertical([Constraint::Length(height)]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(area); + let block = super::visual::block() .title(format!(" {} ", self.title)) .padding(Padding::uniform(1)); frame.render_widget( @@ -70,7 +65,7 @@ impl Loading<'_> { pub fn scanning(title: &'static str, note: &'static str, progress: Progress) -> Loading<'static> { Loading { title, - doing: "Scanning your home folder", + doing: "Scanning home", progress: format!( "{} items · {} so far", format::count(progress.entries), @@ -88,3 +83,27 @@ fn spinner() -> &'static str { let frame = usize::try_from(millis / 100 % 10).unwrap_or(0); SPINNER[frame] } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{Terminal, backend::TestBackend}; + + #[test] + fn wrapped_progress_and_notes_keep_the_last_line_visible() { + for width in [40, 60, 80] { + let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); + let loading = Loading { + title: "Loading", + doing: "Measuring folders and checking cleanup permissions", + progress: "1,234,567 items · 123.4 GB · 日本語のフォルダ".into(), + note: "Wait for all folders to finish scanning. Read-only scan.", + }; + terminal + .draw(|frame| loading.draw(frame, frame.area())) + .unwrap(); + let text = crate::ui::visual::tests::text(terminal.backend().buffer()); + assert!(text.contains("Read-only scan."), "{text}"); + } + } +} diff --git a/crates/neet/src/ui/mod.rs b/crates/neet/src/ui/mod.rs index 17fef26..cecde99 100644 --- a/crates/neet/src/ui/mod.rs +++ b/crates/neet/src/ui/mod.rs @@ -14,6 +14,7 @@ mod review; mod scan; mod skipped; mod tools; +mod visual; use std::io; use std::path::PathBuf; diff --git a/crates/neet/src/ui/pick.rs b/crates/neet/src/ui/pick.rs index e6e4a88..cdce9f1 100644 --- a/crates/neet/src/ui/pick.rs +++ b/crates/neet/src/ui/pick.rs @@ -9,10 +9,10 @@ use neet_core::clutter::{self, Kind}; use neet_core::safety::CleanupRoots; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; -use ratatui::layout::{Constraint, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::layout::Rect; +use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::widgets::{Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; use super::app::{Action, Context, Screen}; use super::clean::{Planned, checkbox, display_path, skip_reason}; @@ -130,7 +130,13 @@ fn place(home: &Path, path: &Path) -> String { .unwrap_or_default() } -fn item_row(rule: &RulePlan, home: &Path, now: SystemTime, width: usize) -> Row<'static> { +fn item_row( + rule: &RulePlan, + home: &Path, + now: SystemTime, + columns: &super::visual::Columns<4>, +) -> Row<'static> { + let width = columns.width(2); let Some(item) = rule.items.first() else { let (path, why) = rule.skipped.first().map_or_else( || (String::new(), String::new()), @@ -141,13 +147,12 @@ fn item_row(rule: &RulePlan, home: &Path, now: SystemTime, width: usize) -> Row< ) }, ); - return Row::new([ + return columns.row([ Cell::from(checkbox(false, false)), Cell::from(""), Cell::from(format::shorten_path(&path, width)), Cell::from(Span::raw(why).yellow()), - ]) - .dark_gray(); + ]); }; let name = item .path @@ -156,7 +161,7 @@ fn item_row(rule: &RulePlan, home: &Path, now: SystemTime, width: usize) -> Row< .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default(); let folder = place(home, item.path.path()); - let room = width.saturating_sub(name.chars().count() + 1); + let room = width.saturating_sub(format::display_width(&name) + 1); let elapsed = now.duration_since(item.changed).unwrap_or_default(); // Changed this week: you may be working in it let age = if elapsed < RECENT { @@ -164,14 +169,15 @@ fn item_row(rule: &RulePlan, home: &Path, now: SystemTime, width: usize) -> Row< } else { Span::raw(format::age(elapsed)) }; - Row::new([ + columns.row([ Cell::from(checkbox(rule.selected, true)), Cell::from( Line::from(format::size_span(item.size, format::size(item.size))).right_aligned(), ), Cell::from(Line::from(vec![ - Span::raw(format!("{}/", format::shorten_path(&folder, room))).blue(), - Span::raw(name), + Span::raw(format!("{}/", format::shorten_path(&folder, room))) + .fg(super::visual::ACCENT), + Span::raw(format::shorten_middle(&name, width.saturating_sub(2))), ])), Cell::from(age), ]) @@ -180,16 +186,16 @@ fn item_row(rule: &RulePlan, home: &Path, now: SystemTime, width: usize) -> Row< impl Screen for Pick { fn draw(&mut self, frame: &mut Frame, area: Rect, _context: &Context) { self.poll(); - let block = Block::bordered() + let block = super::visual::block() .title(format!(" {} ", self.title)) .padding(Padding::horizontal(1)); let planned = match &self.state { State::Planning { started, .. } => { Loading { title: self.title, - doing: "Measuring each item and checking it is safe to move", + doing: "Measuring items and checking paths", progress: format!("{}s", started.elapsed().as_secs()), - note: "Nothing is changed while neet looks.", + note: "Read-only scan.", } .draw(frame, area); return; @@ -218,40 +224,42 @@ impl Screen for Pick { } else { selected.green().bold() }) - .title_bottom( - Line::from(" Everything goes to the Trash, where Put Back works ").right_aligned(), - ); + .title(Line::from(" Trash · Put Back restores ").right_aligned()); - // Checkbox, size, and age are fixed; the path takes the rest. - let path_width = usize::from(area.width.saturating_sub(4 + 2 + 3 + 9 + 14 + 2 * 3)); + let sizes = format::column_width( + "Size", + plan.rules + .iter() + .flat_map(|rule| &rule.items) + .map(|item| format::size(item.size)), + ); + let columns = super::visual::Columns::new( + area.width, + [(3, 0), (sizes, 0), (16, 1), (14, 0)], + &[], + true, + ); let now = SystemTime::now(); let rows: Vec = plan .rules .iter() - .map(|rule| item_row(rule, &planned.home, now, path_width)) + .map(|rule| item_row(rule, &planned.home, now, &columns)) .collect(); - let header = Row::new([ - Cell::from(""), - Cell::from(Line::from("Size").right_aligned()), - Cell::from("Path"), - Cell::from("Changed"), - ]) - .bold() - .bottom_margin(1); - let table = Table::new( - rows, - [ - Constraint::Length(3), - Constraint::Length(9), - Constraint::Fill(1), - Constraint::Length(14), - ], - ) - .header(header) - .column_spacing(2) - .block(block) - .highlight_symbol("▸ ") - .row_highlight_style(Style::new().bold()); + let header = columns + .row([ + Cell::from(""), + Cell::from(Line::from("Size").right_aligned()), + Cell::from("Path"), + Cell::from("Changed"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); + let table = Table::new(rows, columns.widths()) + .header(header) + .column_spacing(2) + .block(block) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED); frame.render_stateful_widget(table, area, &mut self.list); } @@ -282,16 +290,16 @@ impl Screen for Pick { } fn hints(&self) -> &'static str { - "↑↓ move · space select · a all or none · enter review · esc back · ? help" + "↑↓ move · space select · a toggle all · enter review · esc back · ? help" } fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("Space", "Select or clear an item"), ("a", "Select all, or clear all"), ("Enter", "Review every selected path"), - ("Esc", "Go back to Quick Clean"), + ("Esc", "Back to Quick Clean"), ("q", "Quit"), ] } @@ -367,6 +375,19 @@ mod tests { }, ); + let scan = ScanStatus::Failed(String::new()); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + for (width, height) in crate::ui::visual::tests::SIZES { + let buffer = + crate::ui::visual::tests::render("pick", &mut pick, &context, width, height); + assert!(crate::ui::visual::tests::text(&buffer).contains("node_modules")); + assert!(crate::ui::visual::tests::text(&buffer).contains("Selected: 2 items")); + } let text = render(&mut pick); assert!(text.contains("~/Documents/web/node_modules")); assert!(text.contains("Selected: 2 items")); diff --git a/crates/neet/src/ui/quick.rs b/crates/neet/src/ui/quick.rs index 168a07d..1049edb 100644 --- a/crates/neet/src/ui/quick.rs +++ b/crates/neet/src/ui/quick.rs @@ -9,9 +9,9 @@ use neet_core::tree::{NodeId, Tree}; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; -use ratatui::style::{Color, Style, Stylize}; +use ratatui::style::{Color, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::widgets::{Cell, Padding, Paragraph, Row, Table, TableState, Wrap}; use super::app::{Action, Context, Screen}; use super::clean::{Clean, display_path, rows_used}; @@ -120,7 +120,7 @@ fn who_span(who: Who) -> Span<'static> { Who::Neet => Span::raw("neet").green(), Who::Tool => Span::raw("neet, permanently").red(), Who::You => Span::raw("you").yellow(), - Who::Mac => Span::raw("macOS").blue(), + Who::Mac => Span::raw("macOS").fg(super::visual::ACCENT), } } @@ -135,17 +135,15 @@ fn about(item: Item) -> &'static str { "Disk images and installers in Downloads. Once an app is installed, its installer is rarely needed." } Item::Clutter(Kind::BuildFolders) => { - "node_modules folders, and Rust target folders, in your projects. They come back when you install or build again." + "Project node_modules and Rust target folders. Recreated on install or build." } Item::Clutter(Kind::DockerImage) => { "The disk image Docker Desktop keeps containers and images in. It does not shrink on its own." } Item::Clutter(Kind::SimulatorRuntimes) => { - "Simulator runtimes Xcode downloaded, which live outside your home folder, and the simulators on them." - } - Item::Clutter(Kind::TempFiles) => { - "Your temporary files and caches in /private/var/folders. Apps use them while they run." + "Xcode runtimes and their simulators, outside the home folder." } + Item::Clutter(Kind::TempFiles) => "Temporary app files in /private/var/folders.", } } @@ -153,33 +151,33 @@ fn about(item: Item) -> &'static str { fn steps(item: Item) -> &'static [&'static str] { match item { Item::Rules => &[ - "Press Enter to open Deep Clean.", - "Choose rules with Space, then review every path.", - "Confirm, and the files go to the Trash.", + "Enter: open Deep Clean.", + "Space: select rules. Review every path.", + "Confirm: move files to Trash.", ], Item::Clutter(Kind::BuildFolders) => &[ - "Press Enter to list every build folder.", - "All start selected. Clear any you are working in.", - "Review, confirm, and they go to the Trash.", + "Enter: list build folders.", + "All selected. Deselect active projects.", + "Review and confirm: move to Trash.", ], Item::Clutter(Kind::Installers) => &[ - "Press Enter to list every installer.", - "All start selected. Clear any you still need.", - "Review, confirm, and they go to the Trash.", + "Enter: list installers.", + "All selected. Deselect installers to keep.", + "Review and confirm: move to Trash.", ], Item::Clutter(Kind::Trash) => &[ "Empty the Trash in Finder, or right click it in the Dock.", "neet never empties the Trash, so Put Back always works.", ], Item::Clutter(Kind::DockerImage) => &[ - "Press Enter to see what Docker can free, and pick unused volumes.", - "neet runs docker system prune --all, after a red question.", - "Or press x there to reset Docker: its disk image goes to the Trash.", + "Enter: review Docker usage and unused volumes.", + "Confirm: run docker system prune --all.", + "x: reset Docker. Move its disk image to Trash.", ], Item::Clutter(Kind::SimulatorRuntimes) => &[ - "Press Enter to list runtimes, and simulators with none. None start selected.", - "neet removes each one you pick with xcrun simctl, simulators included.", - "It asks first, in red: this skips the Trash and cannot be undone.", + "Enter: list runtimes and orphaned simulators. None selected.", + "Select runtimes to remove with their simulators.", + "Confirm permanent removal. Skips Trash; cannot undo.", ], Item::Clutter(Kind::TempFiles) => &[ "macOS removes old ones on its own.", @@ -323,6 +321,7 @@ impl QuickClean { _ => 0, }; let largest = statuses.iter().map(size_of).max().unwrap_or(0); + let columns = quick_columns(&statuses, area.width); let mut neet_total = 0; let mut your_total = 0; let rows: Vec = ITEMS @@ -351,16 +350,14 @@ impl QuickClean { ) } Status::Looking => ( - Span::raw("looking…").dark_gray(), + Span::raw("scanning").fg(super::visual::ACCENT), Span::raw(""), String::new(), ), - Status::Nothing => { - (Span::raw("none").dark_gray(), Span::raw(""), String::new()) - } + Status::Nothing => (Span::raw("none"), Span::raw(""), String::new()), }; - Row::new([ - Cell::from(name(item)), + columns.row([ + Cell::from(format::shorten_middle(name(item), columns.width(0))), Cell::from(Line::from(size).right_aligned()), Cell::from(bar), Cell::from(Line::from(found).right_aligned()), @@ -368,64 +365,48 @@ impl QuickClean { ]) }) .collect(); - let header = Row::new([ - Cell::from("Item"), - Cell::from(Line::from("Size").right_aligned()), - Cell::from(""), - Cell::from(Line::from("Found").right_aligned()), - Cell::from("Cleared by"), - ]) - .bold() - .bottom_margin(1); + let header = columns + .row([ + Cell::from("Item"), + Cell::from(Line::from("Size").right_aligned()), + Cell::from(""), + Cell::from(Line::from("Found").right_aligned()), + Cell::from("Cleared by"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); let summary = Line::from(vec![ - Span::raw(" neet can clear "), + Span::raw(" Cleanup: "), Span::raw(format!("~{}", format::size(neet_total))) .green() .bold(), - Span::raw(" · you can free "), + Span::raw(" · Manual: "), Span::raw(format::size(your_total)).yellow().bold(), - Span::raw(" more "), + Span::raw(" "), ]); - let table = Table::new( - rows, - [ - Constraint::Length(24), - Constraint::Length(10), - Constraint::Length(u16::try_from(SHARE_BAR).unwrap_or(u16::MAX)), - Constraint::Length(7), - Constraint::Fill(1), - ], - ) - .header(header) - .column_spacing(2) - .block( - Block::bordered() - .title(" Quick Clean ") - .title_bottom(summary.right_aligned()) - .padding(Padding::horizontal(1)), - ) - .highlight_symbol("▸ ") - .row_highlight_style(Style::new().bold()); + let table = Table::new(rows, columns.widths()) + .header(header) + .column_spacing(2) + .block( + super::visual::block() + .title(" Quick Clean ") + .title_bottom(summary.right_aligned()) + .padding(Padding::horizontal(1)), + ) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED); frame.render_stateful_widget(table, area, &mut self.table); } /// What the selected row is and how to clear it, numbered fn draw_about(&self, frame: &mut Frame, area: Rect) { let item = self.selected(); - let mut lines = vec![ - Line::from(vec![ - Span::raw("Cleared by ").bold(), - who_span(who(item)).bold(), - ]), - Line::from(about(item)), - Line::default(), - Line::from("How").bold(), - ]; + let mut lines = Vec::new(); let color = match who(item) { Who::Neet => Color::Green, Who::Tool => Color::Red, Who::You => Color::Yellow, - Who::Mac => Color::Blue, + Who::Mac => super::visual::ACCENT, }; for (number, step) in steps(item).iter().enumerate() { lines.push(Line::from(vec![ @@ -433,9 +414,17 @@ impl QuickClean { Span::raw(*step), ])); } + let mut expanded = lines.clone(); + expanded.push(Line::default()); + expanded.push(Line::from(about(item))); + if super::visual::wrapped_rows(&expanded, area.width.saturating_sub(4)) + <= area.height.saturating_sub(2) + { + lines = expanded; + } frame.render_widget( Paragraph::new(lines).wrap(Wrap { trim: true }).block( - Block::bordered() + super::visual::block() .title(format!(" {} ", name(item))) .padding(Padding::horizontal(1)), ), @@ -505,7 +494,7 @@ impl QuickClean { format::count(u64::try_from(entries.len() - lines.len()).unwrap_or(u64::MAX)) ))); } - let block = Block::bordered() + let block = super::visual::block() .title(" Largest ") .padding(Padding::horizontal(1)); if lines.is_empty() { @@ -514,7 +503,7 @@ impl QuickClean { frame.render_widget(block, area); let why = Line::from(match item { Item::Clutter(Kind::SimulatorRuntimes | Kind::TempFiles) => { - "Outside your home folder, so there is no list to show." + "Outside home. No scan details." } _ => "Nothing to list.", }); @@ -532,31 +521,53 @@ impl QuickClean { } } +fn quick_columns(statuses: &[Status], width: u16) -> super::visual::Columns<5> { + let sizes = format::column_width( + "Size", + statuses.iter().map(|status| { + let size = format::size(match status { + Status::Found { size, .. } => *size, + _ => 0, + }); + format!("~{size}") + }), + ) + .max(8); + let counts = format::column_width( + "Found", + statuses.iter().filter_map(|status| match status { + Status::Found { + count: Some(count), .. + } => Some(format::count(u64::try_from(*count).unwrap_or(u64::MAX))), + _ => None, + }), + ); + super::visual::Columns::new( + width, + [(18, 1), (sizes, 0), (14, 0), (counts, 0), (17, 0)], + &[2], + true, + ) +} + impl Screen for QuickClean { fn draw(&mut self, frame: &mut Frame, area: Rect, context: &Context) { let tree = match context.scan { ScanStatus::Done { scan, .. } => &scan.tree, ScanStatus::Running(progress) => { - scanning( - "Quick Clean", - "What can be cleared shows here when the scan finishes.", - *progress, - ) - .draw(frame, area); + scanning("Quick Clean", "Finding cleanup candidates.", *progress).draw(frame, area); return; } ScanStatus::Failed(reason) => { frame.render_widget( - Paragraph::new(format!( - "The scan failed, so there is nothing to show. {reason}" - )) - .red() - .wrap(Wrap { trim: true }) - .block( - Block::bordered() - .title(" Quick Clean ") - .padding(Padding::horizontal(1)), - ), + Paragraph::new(format!("Scan failed: {reason}")) + .red() + .wrap(Wrap { trim: true }) + .block( + super::visual::block() + .title(" Quick Clean ") + .padding(Padding::horizontal(1)), + ), area, ); return; @@ -643,14 +654,14 @@ impl Screen for QuickClean { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("g G", "Jump to the first or last row"), ( "Enter → l", "Rows neet clears: pick what to remove. Trash: show in Disk", ), ("d", "Show the largest item in Disk"), - ("Esc", "Go back to Home"), + ("Esc", "Back to Home"), ("q", "Quit"), ] } @@ -739,11 +750,11 @@ mod tests { assert!(screen.contains("1.0 MB")); assert!(screen.contains("300.0 KB") || screen.contains("303.1 KB")); assert!(screen.contains("17.0 GB")); - assert!(screen.contains("looking…")); - assert!(screen.contains("you can free 2.0 MB more")); + assert!(screen.contains("scanning")); + assert!(screen.contains("Manual: 2.0 MB")); assert!(screen.contains("neet, permanently")); assert!(screen.contains("macOS")); - assert!(screen.contains("Press Enter to open Deep Clean")); + assert!(screen.contains("Enter: open Deep Clean")); } #[test] @@ -755,7 +766,7 @@ mod tests { press(&mut screen, &scan, KeyCode::Down); let text = render(&mut screen, &scan); assert!(text.contains("~/code/web/node_modules")); - assert!(text.contains("Press Enter to list every build folder")); + assert!(text.contains("Enter: list build folders")); assert!(matches!( press(&mut screen, &scan, KeyCode::Enter), Action::Open(_) @@ -802,4 +813,16 @@ mod tests { Action::Open(_) )); } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let (_dir, scan) = done(); + let mut screen = quick(); + for (width, height) in view::SIZES { + let buffer = view::render("quick", &mut screen, &context(&scan), width, height); + view::aligned(&buffer, "Size", "~8.6 GB"); + assert!(view::text(&buffer).contains("neet, permanently")); + assert!(view::text(&buffer).contains("macOS")); + } + } } diff --git a/crates/neet/src/ui/review.rs b/crates/neet/src/ui/review.rs index fb17c9e..48d5f71 100644 --- a/crates/neet/src/ui/review.rs +++ b/crates/neet/src/ui/review.rs @@ -14,7 +14,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Clear, Padding, Paragraph, Wrap}; use super::app::{Action, Context, Screen}; use super::clean::{Planned, count, display_path, items, skip_reason}; @@ -55,12 +55,21 @@ fn scrolling( lines: Vec>, scroll: &mut Scroll, ) { - let offset = scroll.clamp(lines.len(), area); - let body = Paragraph::new(lines).scroll((offset, 0)).block( - Block::bordered() - .title(title) - .padding(Padding::horizontal(1)), + let offset = scroll.clamp( + usize::from(super::visual::wrapped_rows( + &lines, + area.width.saturating_sub(4), + )), + area, ); + let body = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((offset, 0)) + .block( + super::visual::block() + .title(title) + .padding(Padding::horizontal(1)), + ); frame.render_widget(body, area); } @@ -106,7 +115,7 @@ impl Review { for (name, found) in self.groups() { let size = found.iter().map(|item| item.size).sum(); lines.push(Line::from(vec![ - Span::raw(name.to_string()).bold().cyan(), + Span::raw(name.to_string()).bold().fg(super::visual::ACCENT), Span::raw(format!(" {} · {}", items(found.len()), format::size(size))), ])); for item in found { @@ -138,7 +147,7 @@ impl Review { groups.sort_by_key(|&(_, size)| std::cmp::Reverse(size)); for (name, size) in groups { lines.push(Line::from(vec![ - Span::raw(format::bar(size, total, SUMMARY_BAR)).cyan(), + Span::raw(format::bar(size, total, SUMMARY_BAR)).fg(super::visual::ACCENT), Span::raw(" "), format::size_span(size, format!("{:>9}", format::size(size))), Span::raw(format!(" {name}")), @@ -149,7 +158,7 @@ impl Review { Line::from("What happens next").bold(), Line::from(vec![ Span::raw("1. "), - Span::raw("Enter asks you once more."), + Span::raw("Enter: confirm selection."), ]), Line::from(vec![ Span::raw("2. "), @@ -194,11 +203,9 @@ impl Screen for Review { Paragraph::new(self.summary()) .wrap(Wrap { trim: false }) .block( - Block::bordered() + super::visual::block() .title(" Summary ") - .title_bottom( - Line::from(" Nothing moves until you answer ").right_aligned(), - ) + .title_bottom(Line::from(" Review before confirming ").right_aligned()) .padding(Padding::horizontal(1)), ), side, @@ -222,8 +229,8 @@ impl Screen for Review { SCROLL_HELP[0], SCROLL_HELP[1], SCROLL_HELP[2], - ("Enter", "Go on to the question"), - ("Esc", "Go back to Clean"), + ("Enter", "Confirm selected paths"), + ("Esc", "Back to Clean"), ("q", "Quit"), ] } @@ -251,11 +258,11 @@ impl Screen for Confirm { )) .bold(), Line::default(), - Line::from("Finder moves them, so Put Back can restore each one."), - Line::from("Each item is checked again right before it moves."), + Line::from("Restore from Trash with Finder’s Put Back."), + Line::from("Paths rechecked before moving."), Line::default(), Line::from(vec![ - Span::raw("y").bold().cyan(), + Span::raw("y").bold().fg(super::visual::ACCENT), Span::raw(" move to the Trash "), Span::raw("n").bold(), Span::raw(" or "), @@ -263,16 +270,19 @@ impl Screen for Confirm { Span::raw(" go back"), ]), ]; - let [area] = Layout::vertical([Constraint::Length(8)]) - .flex(Flex::Center) - .areas(area); + let [area] = Layout::vertical([Constraint::Length( + super::visual::wrapped_rows(&lines, area.width.min(60).saturating_sub(4)) + .saturating_add(2), + )]) + .flex(Flex::Center) + .areas(area); let [area] = Layout::horizontal([Constraint::Length(60)]) .flex(Flex::Center) .areas(area); frame.render_widget(Clear, area); frame.render_widget( Paragraph::new(lines).wrap(Wrap { trim: false }).block( - Block::bordered() + super::visual::block() .title(" Move to the Trash ") .padding(Padding::horizontal(1)), ), @@ -295,7 +305,7 @@ impl Screen for Confirm { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ ("y", "Move the items to the Trash"), - ("n Esc", "Go back to the review"), + ("n Esc", "Back to the review"), ] } @@ -477,11 +487,16 @@ impl Screen for Cleanup { Line::default(), Line::from(reason.clone()), ]; - let box_area = centered(area, BOX_WIDTH, 7); + let height = super::visual::wrapped_rows( + &lines, + area.width.min(BOX_WIDTH).saturating_sub(4), + ) + .saturating_add(4); + let box_area = centered(area, BOX_WIDTH, height); frame.render_widget(Clear, box_area); frame.render_widget( Paragraph::new(lines).wrap(Wrap { trim: false }).block( - Block::bordered() + super::visual::block() .title(" Cleanup ") .padding(Padding::uniform(1)), ), @@ -492,9 +507,10 @@ impl Screen for Cleanup { // Border and padding take 4 columns and 4 rows. let width = BOX_WIDTH.min(area.width); let lines = self.result_lines(outcome, usize::from(width.saturating_sub(4))); - let wanted = u16::try_from(lines.len() + 4).unwrap_or(u16::MAX); + let rows = super::visual::wrapped_rows(&lines, width.saturating_sub(4)); + let wanted = rows.saturating_add(4); let box_area = centered(area, width, wanted); - let offset = self.scroll.clamp(lines.len() + 2, box_area); + let offset = self.scroll.clamp(usize::from(rows) + 2, box_area); let title = if outcome.moved.is_empty() { Line::from(" Cleanup done ").yellow() } else { @@ -502,12 +518,15 @@ impl Screen for Cleanup { }; frame.render_widget(Clear, box_area); frame.render_widget( - Paragraph::new(lines).scroll((offset, 0)).block( - Block::bordered() - .title(title) - .title_bottom(Line::from(" Enter or Esc for Home ").right_aligned()) - .padding(Padding::uniform(1)), - ), + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((offset, 0)) + .block( + super::visual::block() + .title(title) + .title_bottom(Line::from(" Enter or Esc for Home ").right_aligned()) + .padding(Padding::uniform(1)), + ), box_area, ); } @@ -535,7 +554,7 @@ impl Screen for Cleanup { fn hints(&self) -> &'static str { if self.is_moving() { - "moving to the Trash, please wait" + "Moving to Trash…" } else { "↑↓ scroll · enter or esc home · q quit" } @@ -546,7 +565,7 @@ impl Screen for Cleanup { SCROLL_HELP[0], SCROLL_HELP[1], SCROLL_HELP[2], - ("Enter Esc", "Go back to Home when done"), + ("Enter Esc", "Back to Home when done"), ("q", "Quit, when done"), ] } @@ -570,6 +589,28 @@ mod tests { use std::time::{Duration, Instant}; use tempfile::{TempDir, tempdir}; + #[test] + fn end_of_review_includes_wrapped_lines() { + let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap(); + let mut scroll = Scroll(u16::MAX); + let lines = vec![ + Line::from("a-long-folder-name/".repeat(20)), + Line::from("Last path"), + ]; + terminal + .draw(|frame| { + scrolling( + frame, + frame.area(), + " Review ".into(), + lines.clone(), + &mut scroll, + ); + }) + .unwrap(); + assert!(crate::ui::visual::tests::text(terminal.backend().buffer()).contains("Last path")); + } + fn planned() -> (TempDir, Arc) { let dir = tempdir().expect("temporary directory should be created"); for file in [ @@ -733,7 +774,7 @@ mod tests { assert!(screen.contains("Summary")); assert!(screen.contains("What happens next")); - assert!(screen.contains("Nothing moves until you answer")); + assert!(screen.contains("Review before confirming")); } #[test] @@ -753,4 +794,25 @@ mod tests { assert_eq!(screen.matches("HTTPStorages").count(), 2); assert!(screen.contains("HTTPStorages 2 items")); } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let scan = ScanStatus::Failed(String::new()); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + let (_dir, planned) = planned(); + let mut review = Review::new(Arc::clone(&planned)); + let mut confirm = Confirm::new(planned); + for (width, height) in view::SIZES { + let buffer = view::render("review", &mut review, &context, width, height); + assert!(view::text(&buffer).contains("DerivedData")); + let buffer = view::render("confirm", &mut confirm, &context, width, height); + assert!(view::text(&buffer).contains("Put Back")); + assert!(view::text(&buffer).contains("go back")); + } + } } diff --git a/crates/neet/src/ui/skipped.rs b/crates/neet/src/ui/skipped.rs index 8a8de78..1ce5ce3 100644 --- a/crates/neet/src/ui/skipped.rs +++ b/crates/neet/src/ui/skipped.rs @@ -6,7 +6,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Padding, Paragraph, Wrap}; use super::app::{Action, Context, Screen}; use super::format; @@ -68,7 +68,7 @@ fn allow_steps(terminal: Option<&str>) -> Vec> { Line::from("macOS blocked some folders, so their sizes are missing.") .yellow() .bold(), - Line::from("To let neet read them:"), + Line::from("Grant Full Disk Access:"), Line::from(" 1. Open System Settings, then Privacy & Security, then Full Disk Access."), Line::from(format!(" 2. Turn on {app}.")), Line::from(format!( @@ -152,20 +152,17 @@ impl Screen for Skipped { let text = match context.scan { ScanStatus::Done { scan, .. } => lines(scan), ScanStatus::Running(_) => { - vec![Line::from( - "The scan is still running. Check back when it finishes.", - )] + vec![Line::from("Scan in progress.")] } ScanStatus::Failed(reason) => vec![Line::from(format!("The scan failed. {reason}"))], }; // Keep the last line on screen when scrolling to the end. let visible = area.height.saturating_sub(2); - let last = u16::try_from(text.len()) - .unwrap_or(u16::MAX) + let last = super::visual::wrapped_rows(&text, area.width.saturating_sub(4)) .saturating_sub(visible); self.scroll = self.scroll.min(last); - let block = Block::bordered() + let block = super::visual::block() .title(" Skipped ") .padding(Padding::horizontal(1)); let body = Paragraph::new(text) @@ -198,7 +195,7 @@ impl Screen for Skipped { ("↑ ↓ j k", "Scroll"), ("PgUp PgDn", "Scroll a page"), ("g G", "Jump to the top or bottom"), - ("Esc", "Go back to Home"), + ("Esc", "Back to Home"), ("q", "Quit"), ] } diff --git a/crates/neet/src/ui/tools.rs b/crates/neet/src/ui/tools.rs index 20f2da9..c8921ee 100644 --- a/crates/neet/src/ui/tools.rs +++ b/crates/neet/src/ui/tools.rs @@ -10,7 +10,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Clear, Padding, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::widgets::{Cell, Clear, Padding, Paragraph, Row, Table, TableState, Wrap}; use super::app::{Action, Context, Screen}; use super::clean::checkbox; @@ -99,36 +99,7 @@ const MESSAGE_WIDTH: u16 = 76; /// How many rows `lines` take when wrapped at spaces to `width`, as the /// paragraph wraps them, so a box is never too short for its last line fn rows(lines: &[Line], width: u16) -> u16 { - let width = usize::from(width).max(1); - let rows: usize = lines - .iter() - .map(|line| { - let text: String = line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); - let mut rows = 1; - let mut used = 0; - for word in text.split(' ') { - let word = word.chars().count(); - let needed = if used == 0 { word } else { used + 1 + word }; - if needed <= width { - used = needed; - } else { - // A word longer than the row is broken across rows. - rows += 1; - used = word; - while used > width { - rows += 1; - used -= width; - } - } - } - rows - }) - .sum(); - u16::try_from(rows).unwrap_or(u16::MAX) + super::visual::wrapped_rows(lines, width) } /// A box in the middle of `area`, just big enough for `lines`, so short @@ -149,7 +120,7 @@ fn message(frame: &mut Frame, area: Rect, title: &str, lines: Vec> frame.render_widget(Clear, area); frame.render_widget( Paragraph::new(lines).wrap(Wrap { trim: false }).block( - Block::bordered() + super::visual::block() .border_style(Style::new().fg(color)) .title(Line::from(format!(" {title} ")).fg(color).bold()) .padding(Padding::new(2, 2, 1, 1)), @@ -181,12 +152,16 @@ fn ask( /// A key to press, as it shows in text fn key(name: &'static str) -> Span<'static> { - Span::raw(name).cyan().bold() + Span::raw(name).fg(super::visual::ACCENT).bold() } /// A numbered step fn step(number: usize, text: Vec>) -> Line<'static> { - let mut spans = vec![Span::raw(format!("{number} ")).cyan().bold()]; + let mut spans = vec![ + Span::raw(format!("{number} ")) + .fg(super::visual::ACCENT) + .bold(), + ]; spans.extend(text); Line::from(spans) } @@ -222,7 +197,7 @@ fn about_box(frame: &mut Frame, area: Rect, lines: Vec>) -> Rect { frame.render_widget( Paragraph::new(lines) .wrap(Wrap { trim: false }) - .block(Block::bordered().padding(Padding::horizontal(1))), + .block(super::visual::block().padding(Padding::horizontal(1))), about, ); rest @@ -280,8 +255,13 @@ fn size_of(devices: &[&Device]) -> u64 { } /// One runtime as a table row, with the simulators on it -fn runtime_row(runtime: &Runtime, on: &[&Device], chosen: bool) -> Row<'static> { - let row = Row::new([ +fn runtime_row( + runtime: &Runtime, + on: &[&Device], + chosen: bool, + columns: &super::visual::Columns<6>, +) -> Row<'static> { + columns.row([ Cell::from(checkbox(chosen, runtime.deletable)), Cell::from(runtime.name.clone()), Cell::from(runtime.build.clone()), @@ -302,14 +282,9 @@ fn runtime_row(runtime: &Runtime, on: &[&Device], chosen: bool) -> Row<'static> format::size(size_of(on)) )) } else { - Span::raw("simctl will not delete it").italic() + Span::raw("Removal blocked").yellow() }), - ]); - if runtime.deletable { - row - } else { - row.dark_gray() - } + ]) } /// What removing did @@ -394,16 +369,30 @@ impl Simulators { let Some(found) = self.stage.found() else { return; }; + let sizes = format::column_width( + "Size", + found + .runtimes + .iter() + .map(|runtime| format::size(runtime.size)), + ) + .max(9); + let columns = super::visual::Columns::new( + area.width, + [(3, 0), (16, 1), (8, 0), (sizes, 0), (11, 0), (18, 1)], + &[2, 4], + true, + ); let stranded = found.stranded(); let mut rows: Vec = found .runtimes .iter() .zip(&self.chosen) - .map(|(runtime, &chosen)| runtime_row(runtime, &found.on(runtime), chosen)) + .map(|(runtime, &chosen)| runtime_row(runtime, &found.on(runtime), chosen, &columns)) .collect(); if !stranded.is_empty() { let size = size_of(&stranded); - rows.push(Row::new([ + rows.push(columns.row([ Cell::from(checkbox( self.chosen.get(found.runtimes.len()) == Some(&true), true, @@ -418,16 +407,17 @@ impl Simulators { )), ])); } - let header = Row::new([ - Cell::from(""), - Cell::from("Runtime"), - Cell::from("Build"), - Cell::from(Line::from("Size").right_aligned()), - Cell::from("Last used"), - Cell::from("Simulators on it"), - ]) - .bold() - .bottom_margin(1); + let header = columns + .row([ + Cell::from(""), + Cell::from("Runtime"), + Cell::from("Build"), + Cell::from(Line::from("Size").right_aligned()), + Cell::from("Last used"), + Cell::from("Simulators on it"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); let total = found .runtimes .iter() @@ -436,31 +426,21 @@ impl Simulators { + size_of(&found.devices.iter().collect::>()); let picked = self.picked_size(); let selected = Span::raw(format!(" Selected: {} ", format::size(picked))); - let table = Table::new( - rows, - [ - Constraint::Length(3), - Constraint::Length(12), - Constraint::Length(8), - Constraint::Length(9), - Constraint::Length(11), - Constraint::Fill(1), - ], - ) - .header(header) - .column_spacing(2) - .block( - Block::bordered() - .title(format!(" Simulators · {} ", format::size(total))) - .title_bottom(if picked == 0 { - selected - } else { - selected.red().bold() - }) - .padding(Padding::horizontal(1)), - ) - .highlight_symbol("▸ ") - .row_highlight_style(Style::new().bold()); + let table = Table::new(rows, columns.widths()) + .header(header) + .column_spacing(2) + .block( + super::visual::block() + .title(format!(" Simulators · {} ", format::size(total))) + .title_bottom(if picked == 0 { + selected + } else { + selected.red().bold() + }) + .padding(Padding::horizontal(1)), + ) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED); frame.render_stateful_widget(table, area, &mut self.list); } @@ -468,7 +448,7 @@ impl Simulators { let mut lines = vec![ labeled( "Runtime", - Color::Cyan, + super::visual::ACCENT, vec![Span::raw( "lets Xcode run simulators of one iOS, watchOS, tvOS, or visionOS version.", )], @@ -481,6 +461,21 @@ impl Simulators { )], ), ]; + if let Some(runtime) = self + .stage + .found() + .and_then(|found| found.runtimes.get(self.list.selected().unwrap_or(0))) + { + lines.push( + Line::from(format!( + "{} · Build {} · Last used {}", + runtime.name, + runtime.build, + runtime.last_used.as_deref().map_or("never", day) + )) + .fg(super::visual::ACCENT), + ); + } if self .stage .found() @@ -658,9 +653,9 @@ impl Screen for Simulators { Stage::Looking(started, _) => { return Loading { title, - doing: "Asking xcrun simctl for runtimes and simulators", + doing: "Loading runtimes and simulators", progress: format!("{}s", started.elapsed().as_secs()), - note: "Nothing is changed while neet looks.", + note: "Read-only scan.", } .draw(frame, area); } @@ -782,7 +777,7 @@ impl Screen for Simulators { fn help(&self) -> &'static [(&'static str, &'static str)] { &[ - ("↑ ↓ j k", "Move the selection"), + ("↑ ↓ j k", "Move selection"), ("Space", "Select or clear a row"), ("Enter", "Ask before removing what is selected"), ("y", "In the question: remove it permanently"), @@ -899,7 +894,7 @@ impl DockerSpace { Color::Yellow, vec![ key("x"), - Span::raw(" stops Docker Desktop and moves its whole disk image, "), + Span::raw(": stop Docker; move disk image, "), Span::raw(format::size(image)).bold(), Span::raw(", to the Trash."), ], @@ -911,24 +906,14 @@ impl DockerSpace { Line::from("Docker Desktop is not running").yellow().bold(), Line::from("Docker can only say what it holds, and prune, while it runs."), Line::default(), - step( - 1, - vec![ - Span::raw("Press "), - key("o"), - Span::raw(" to open Docker Desktop"), - ], - ), + step(1, vec![key("o"), Span::raw(": open Docker Desktop")]), step( 2, vec![Span::raw( "Wait for the whale in the menu bar to stop moving", )], ), - step( - 3, - vec![Span::raw("Press "), key("r"), Span::raw(" to look again")], - ), + step(3, vec![key("r"), Span::raw(": refresh")]), ]; if let Some(image) = self.image() { lines.push(Line::default()); @@ -945,7 +930,7 @@ impl DockerSpace { } if let Some(note) = &self.note { lines.push(Line::default()); - lines.push(Line::from(note.clone()).cyan()); + lines.push(Line::from(note.clone()).fg(super::visual::ACCENT)); } lines } @@ -962,57 +947,47 @@ impl DockerSpace { let usage_height = u16::try_from(usage.len() + 4).unwrap_or(u16::MAX); let [table, rest] = Layout::vertical([Constraint::Length(usage_height), Constraint::Fill(1)]).areas(area); - frame.render_widget(usage_table(usage), table); + frame.render_widget(usage_table(usage, table.width), table); let rest = if volumes.is_empty() { rest } else { - let height = u16::try_from(volumes.len() + 2).unwrap_or(u16::MAX); + let height = u16::try_from(volumes.len() + 4) + .unwrap_or(u16::MAX) + .min(rest.height / 2); let [list, rest] = Layout::vertical([Constraint::Length(height), Constraint::Fill(1)]).areas(rest); self.chosen.resize(volumes.len(), false); let picked = self.picked_volumes().len(); frame.render_stateful_widget( - volume_table(&volumes, &self.chosen, picked), + volume_table(&volumes, &self.chosen, picked, list.width), list, &mut self.list, ); rest }; - let mut lines = vec![ - Line::from(vec![ - key("Enter"), - Span::raw(" runs "), - Span::raw("docker system prune --all").bold(), - ]), - Line::default(), + let mut lines = vec![Line::from("Permanently: prune skips Trash.").red().bold()]; + if let Some(image) = image { + lines.push(Self::reset_line(image)); + } + lines.extend([ labeled( "Removes", Color::Red, vec![Span::raw( - "stopped containers, unused networks, images no container uses, and the build cache", + "stopped containers, unused networks/images, build cache", )], ), labeled( "Keeps", Color::Green, - vec![Span::raw( - "running containers and their images, and volumes you do not select", - )], + vec![Span::raw("running containers/images; unselected volumes")], ), - ]; - if let Some(image) = image { - lines.push(Self::reset_line(image)); - } - lines.push(Line::default()); - lines.push(Line::from(vec![ - Span::raw("Permanently: ").red().bold(), - Span::raw("what the prune removes skips the Trash."), - ])); + ]); if let Some(note) = &self.note { lines.push(Line::default()); - lines.push(Line::from(note.clone()).cyan()); + lines.push(Line::from(note.clone()).fg(super::visual::ACCENT)); } about_box(frame, rest, lines); } @@ -1178,20 +1153,27 @@ impl DockerSpace { } /// The volumes no container uses, to pick for the prune -fn volume_table(volumes: &[DockerVolume], chosen: &[bool], picked: usize) -> Table<'static> { +fn volume_table( + volumes: &[DockerVolume], + chosen: &[bool], + picked: usize, + width: u16, +) -> Table<'static> { + let sizes = format::column_width("Size", volumes.iter().map(|volume| volume.size.clone())); + let columns = super::visual::Columns::new(width, [(3, 0), (sizes, 0), (16, 1)], &[], true); let rows: Vec = volumes .iter() .zip(chosen) .map(|(volume, &chosen)| { - Row::new([ + columns.row([ Cell::from(checkbox(chosen, true)), Cell::from(Line::from(Span::raw(volume.size.clone()).yellow()).right_aligned()), - Cell::from(volume.name.clone()), + Cell::from(format::shorten_middle(&volume.name, columns.width(2))), ]) }) .collect(); let bottom = if picked == 0 { - Span::raw(" Space selects one to remove with the prune ") + Span::raw(" Space: select volumes to remove ") } else { Span::raw(format!( " {} removed permanently, data included ", @@ -1200,27 +1182,43 @@ fn volume_table(volumes: &[DockerVolume], chosen: &[bool], picked: usize) -> Tab .red() .bold() }; - Table::new( - rows, - [ - Constraint::Length(3), - Constraint::Length(10), - Constraint::Fill(1), - ], - ) - .column_spacing(2) - .block( - Block::bordered() - .title(" Volumes no container uses ") - .title_bottom(bottom) - .padding(Padding::horizontal(1)), - ) - .highlight_symbol("▸ ") - .row_highlight_style(Style::new().bold()) + Table::new(rows, columns.widths()) + .header( + columns + .row([ + Cell::from(""), + Cell::from(Line::from("Size").right_aligned()), + Cell::from("Volume"), + ]) + .style(super::visual::HEADING) + .bottom_margin(1), + ) + .column_spacing(2) + .block( + super::visual::block() + .title(" Unused volumes ") + .title_bottom(bottom) + .padding(Padding::horizontal(1)), + ) + .highlight_symbol("▸ ") + .row_highlight_style(super::visual::SELECTED) } /// `docker system df`, as a table -fn usage_table(usage: &[DockerUsage]) -> Table<'static> { +fn usage_table(usage: &[DockerUsage], width: u16) -> Table<'static> { + let counts = format::column_width("Count", usage.iter().map(|line| line.total_count.clone())); + let active = format::column_width("In use", usage.iter().map(|line| line.active.clone())); + let sizes = format::column_width("Size", usage.iter().map(|line| line.size.clone())); + let reclaim = format::column_width( + "Reclaimable", + usage.iter().map(|line| line.reclaimable.clone()), + ); + let columns = super::visual::Columns::new( + width, + [(11, 1), (counts, 0), (active, 0), (sizes, 0), (reclaim, 0)], + &[], + false, + ); let rows: Vec = usage .iter() .map(|line| { @@ -1229,41 +1227,33 @@ fn usage_table(usage: &[DockerUsage]) -> Table<'static> { } else { Span::raw(line.reclaimable.clone()).yellow().bold() }; - Row::new([ + columns.row([ Cell::from(line.kind.clone()), Cell::from(Line::from(line.total_count.clone()).right_aligned()), Cell::from(Line::from(line.active.clone()).right_aligned()), Cell::from(Line::from(line.size.clone()).right_aligned()), - Cell::from(reclaim), + Cell::from(Line::from(reclaim).right_aligned()), ]) }) .collect(); - let header = Row::new([ - Cell::from("Kind"), - Cell::from(Line::from("Count").right_aligned()), - Cell::from(Line::from("In use").right_aligned()), - Cell::from(Line::from("Size").right_aligned()), - Cell::from("Can be freed"), - ]) - .bold() - .bottom_margin(1); - Table::new( - rows, - [ - Constraint::Length(14), - Constraint::Length(6), - Constraint::Length(7), - Constraint::Length(10), - Constraint::Fill(1), - ], - ) - .header(header) - .column_spacing(2) - .block( - Block::bordered() - .title(" Docker ") - .padding(Padding::horizontal(1)), - ) + let header = columns + .row([ + Cell::from("Kind"), + Cell::from(Line::from("Count").right_aligned()), + Cell::from(Line::from("In use").right_aligned()), + Cell::from(Line::from("Size").right_aligned()), + Cell::from(Line::from("Reclaimable").right_aligned()), + ]) + .style(super::visual::HEADING) + .bottom_margin(1); + Table::new(rows, columns.widths()) + .header(header) + .column_spacing(2) + .block( + super::visual::block() + .title(" Docker ") + .padding(Padding::horizontal(1)), + ) } impl Screen for DockerSpace { @@ -1275,7 +1265,7 @@ impl Screen for DockerSpace { title, doing: "Asking Docker what it holds", progress: format!("{}s", started.elapsed().as_secs()), - note: "Nothing is changed while neet looks.", + note: "Read-only scan.", } .draw(frame, area), Stage::Failed(reason) => message( @@ -1435,7 +1425,7 @@ impl Screen for DockerSpace { "y reset Docker · n or esc back" } Stage::Asking(_) => "y remove permanently · n or esc back", - Stage::Working(..) => "working, please wait", + Stage::Working(..) => "Working…", Stage::Done(_) => "enter home · r look again · esc home", Stage::Ready(found) => match (&found.docker, found.volumes.is_empty(), reset) { (Docker::Usage(_), false, true) => { @@ -1663,7 +1653,7 @@ mod tests { let text = render(&mut screen); assert!(text.contains("1.105GB (61%)")); assert!(text.contains("influxdb-storage")); - assert!(text.contains("volumes you do not select")); + assert!(text.contains("unselected volumes")); // Volumes are kept unless picked. press(&mut screen, KeyCode::Enter); @@ -1687,7 +1677,7 @@ mod tests { let mut screen = docker(Docker::NotRunning, Vec::new(), None); let text = render(&mut screen); - assert!(text.contains("Press o to open Docker Desktop")); + assert!(text.contains("o: open Docker Desktop")); assert!(!text.contains("reset Docker")); press(&mut screen, KeyCode::Enter); press(&mut screen, KeyCode::Char('x')); @@ -1733,4 +1723,52 @@ mod tests { assert!(render(&mut screen).contains("Press r to look again.")); } + #[test] + fn layout_stays_readable_across_terminal_sizes() { + use crate::ui::visual::tests as view; + let scan = ScanStatus::Failed(String::new()); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + let mut sims = Simulators::with(Stage::Ready(SimulatorsFound { + runtimes: vec![runtime("iOS 18.6", 8_800_000_000)], + devices: vec![device("iPhone 16 Pro", "iOS 18.6", true, 2_100_000_000)], + })); + let usage = DockerUsage { + kind: "Images".into(), + total_count: "1234567".into(), + active: "2".into(), + size: "1.808GB".into(), + reclaimable: "1.105GB (61%)".into(), + }; + let mut docker = docker( + Docker::Usage(vec![usage]), + vec![DockerVolume { + name: "influxdb-storage".into(), + size: "2.812GB".into(), + }], + Some(9_400_000_000), + ); + for (width, height) in view::SIZES { + let buffer = view::render("simulators", &mut sims, &context, width, height); + view::aligned(&buffer, "Size", "8.8 GB"); + assert!(view::text(&buffer).contains("Permanently")); + let buffer = view::render("docker", &mut docker, &context, width, height); + view::aligned(&buffer, "Count", "1234567"); + view::aligned(&buffer, "Reclaimable", "1.105GB (61%)"); + assert!(view::text(&buffer).contains("influxdb-storage")); + } + press(&mut sims, KeyCode::Char(' ')); + press(&mut sims, KeyCode::Enter); + press(&mut docker, KeyCode::Enter); + for (width, height) in view::SIZES { + let buffer = view::render("simulator-confirm", &mut sims, &context, width, height); + assert!(view::text(&buffer).contains("remove permanently")); + let buffer = view::render("docker-confirm", &mut docker, &context, width, height); + assert!(view::text(&buffer).contains("remove permanently")); + } + } } diff --git a/crates/neet/src/ui/visual.rs b/crates/neet/src/ui/visual.rs new file mode 100644 index 0000000..6f81046 --- /dev/null +++ b/crates/neet/src/ui/visual.rs @@ -0,0 +1,237 @@ +//! Shared presentation rules; the Home artwork uses its own styles. + +use ratatui::layout::Constraint; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Cell, Paragraph, Row, Wrap}; + +/// Match the existing Home artwork without changing its renderer. +pub const ACCENT: Color = Color::Rgb(0x82, 0xaa, 0xff); +pub const HEADING: Style = Style::new().fg(ACCENT).add_modifier(Modifier::BOLD); +pub const SELECTED: Style = Style::new().add_modifier(Modifier::BOLD); + +pub fn block<'a>() -> Block<'a> { + Block::bordered().title_style(HEADING) +} + +/// Use the same wrapping engine to measure and render paragraphs. +pub fn wrapped_rows(lines: &[Line<'_>], width: u16) -> u16 { + u16::try_from( + Paragraph::new(lines.to_vec()) + .wrap(Wrap { trim: false }) + .line_count(width.max(1)), + ) + .unwrap_or(u16::MAX) +} + +/// Keys remain distinct from their actions, including when the footer wraps. +pub fn hints(text: &str) -> Line<'static> { + let mut spans = Vec::new(); + for (index, hint) in text.split(" · ").enumerate() { + if index > 0 { + spans.push(Span::raw(" · ")); + } + let (key, action) = hint.split_once(' ').unwrap_or((hint, "")); + spans.push(Span::styled(key.to_string(), HEADING)); + spans.push(Span::raw(format!(" {action}"))); + } + Line::from(spans) +} + +/// Shared column geometry for headers and rows. Widths include neither the +/// border/padding nor the selection gutter. Optional columns disappear in +/// order before flexible name/path columns lose their minimum width. +pub struct Columns { + sizes: [u16; N], + visible: [bool; N], +} + +impl Columns { + pub fn new(width: u16, specs: [(u16, u16); N], optional: &[usize], selected: bool) -> Self { + let room = width.saturating_sub(4 + if selected { 2 } else { 0 }); + let mut visible = [true; N]; + let needed = |visible: &[bool; N]| -> u16 { + let count = visible.iter().filter(|&&show| show).count(); + specs + .iter() + .zip(visible) + .filter(|(_, show)| **show) + .map(|(&(min, _), _)| min) + .sum::() + .saturating_add(u16::try_from(count.saturating_sub(1) * 2).unwrap_or(u16::MAX)) + }; + for &index in optional { + if needed(&visible) <= room { + break; + } + visible[index] = false; + } + let mut extra = room.saturating_sub(needed(&visible)); + let mut weight: u16 = specs + .iter() + .zip(visible) + .filter(|(_, show)| *show) + .map(|(&(_, weight), _)| weight) + .sum(); + let mut sizes = std::array::from_fn(|index| { + if !visible[index] { + return 0; + } + let (min, share) = specs[index]; + let add = if weight == 0 { + 0 + } else { + u16::try_from(u32::from(extra) * u32::from(share) / u32::from(weight)) + .unwrap_or(extra) + }; + extra -= add; + weight -= share; + min + add + }); + let mut deficit = needed(&visible).saturating_sub(room); + for (index, &(_, weight)) in specs.iter().enumerate() { + if visible[index] && weight > 0 { + let reduce = deficit.min(sizes[index].saturating_sub(4)); + sizes[index] -= reduce; + deficit -= reduce; + } + } + Self { sizes, visible } + } + + pub fn width(&self, index: usize) -> usize { + usize::from(self.sizes[index]) + } + + pub fn widths(&self) -> Vec { + self.sizes + .iter() + .zip(self.visible) + .filter(|(_, show)| *show) + .map(|(&width, _)| Constraint::Length(width)) + .collect() + } + + pub fn row<'a>(&self, cells: [Cell<'a>; N]) -> Row<'a> { + Row::new( + cells + .into_iter() + .zip(self.visible) + .filter(|(_, show)| *show) + .map(|(cell, _)| cell), + ) + } +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use crate::ui::app::{Context, Screen}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui::layout::Layout; + + pub const SIZES: [(u16, u16); 4] = [(60, 20), (80, 24), (100, 30), (140, 40)]; + + /// Render the real screen with the same footer geometry as the app. + pub fn render( + name: &str, + screen: &mut dyn Screen, + context: &Context, + width: u16, + height: u16, + ) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + let hints = hints(screen.hints()); + let footer_height = wrapped_rows(std::slice::from_ref(&hints), width); + let [body, footer] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_height)]) + .areas(frame.area()); + screen.draw(frame, body, context); + frame.render_widget(Paragraph::new(hints).wrap(Wrap { trim: false }), footer); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + if name != "home" { + for cell in buffer.content() { + assert!(!matches!(cell.fg, Color::Gray | Color::DarkGray)); + assert!(!cell.modifier.contains(Modifier::DIM)); + } + } + // Opt-in text previews make every tested size inspectable without running cleanup. + if std::env::var_os("NEET_TUI_PREVIEW").is_some() { + println!("\n{name} {width}x{height}\n{}", text(&buffer)); + } + buffer + } + + pub fn text(buffer: &Buffer) -> String { + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + pub fn position(buffer: &Buffer, value: &str) -> (u16, u16) { + let mut found = Vec::new(); + for y in 0..buffer.area.height { + let line: String = (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect(); + if let Some(offset) = line.find(value) { + found.push(( + u16::try_from(crate::ui::format::display_width(&line[..offset])).unwrap(), + y, + )); + } + } + found + .into_iter() + .min() + .unwrap_or_else(|| panic!("{value:?} missing:\n{}", text(buffer))) + } + + pub fn aligned(buffer: &Buffer, header: &str, value: &str) { + let (header_end, header_y) = (0..buffer.area.height) + .find_map(|y| { + let line: String = (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect(); + let offset = line.find(header)?; + ["Name", "Rule", "Item", "Runtime", "Kind"] + .iter() + .any(|label| line.contains(label)) + .then(|| { + ( + crate::ui::format::display_width(&line[..offset]) + header.len(), + y, + ) + }) + }) + .expect("table header"); + assert_eq!( + buffer[(u16::try_from(header_end - 1).unwrap(), header_y)].fg, + ACCENT + ); + let aligned = (header_y + 1..buffer.area.height).any(|y| { + let line: String = (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect(); + line.match_indices(value).any(|(offset, _)| { + crate::ui::format::display_width(&line[..offset]) + value.len() == header_end + }) + }); + assert!( + aligned, + "{header} and {value} must share a right edge\n{}", + text(buffer) + ); + } +} diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index 4035b9c..6f08403 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -20,13 +20,18 @@ ## Every Screen * Run `neet` to open it. It opens on Home and starts scanning your home folder in the background. -* Each screen fills the terminal. The bottom row always lists the keys for the current screen. -* The row under the arrow turns bold and keeps its colors, so sizes, bars, and labels read the same as the rows around it. Text is kept in the normal color; gray is only for rows you cannot pick, such as features not built yet. +* Each screen fills the terminal. The footer lists keys for the current screen and wraps when needed. +* The row under the arrow turns bold and keeps its colors, so sizes, bars, and labels read the same as the rows around it. Body text uses the terminal foreground. The artwork’s moonlight blue (`#82aaff`) marks headings, keys, and progress; green marks selection and success; yellow marks caution; red marks errors and permanent removal. Labels and symbols carry the same meaning as the color. Gray and dim styles are reserved for the unchanged Home artwork. * Screens stack: each one opens on top of the last, and `Esc` goes back one step. * Boxes, such as help and questions, open in the middle of the screen, on top of it. * The screen redraws about four times a second, so progress stays current. * While a screen waits for slow work, such as the scan, it shows a small box in the middle: what neet is doing, a spinner, how far it has got, and what shows when it is done. +* Numeric headers and values share a right edge. Names and paths align left. Column widths account for the border, padding, and selection arrow; numeric widths grow to fit formatted values. +* At narrow widths, decorative bars disappear first, followed by secondary metadata. Names, sizes, checkboxes, and risk labels stay visible. Selected details retain hidden metadata. Names and paths shorten by terminal cells without splitting Unicode graphemes. +* Dialogs and loading boxes use the same word wrapping for measurement and rendering. Review, results, and skipped paths scroll through wrapped lines. +* Screen copy uses short labels and commands. Confirmations still name the action, whether removal is permanent, and how to restore items sent to Trash. + | Key | Action | | --- | --- | | Arrow keys, or `h` `j` `k` `l` | Move. | @@ -62,7 +67,7 @@ 2. Quick Clean comes first, marked `start here`, and is selected when neet opens. 3. Deep Clean shows `finding...`, then the total that every rule found. This is worked out again after each cleanup, and when you press `r` in Deep Clean. 4. Disk shows how much of the disk is used. - 5. Features not built yet are dimmed and marked `soon`. The selection skips them. + 5. Features not built yet remain readable and have a yellow `soon` label. The selection skips them. * **Right, bottom:** details for the selected row: 1. What the feature does. 2. A 16 character gauge of how full the disk is, then free and total space on the next line. The gauge turns yellow at 75% and red at 90%. @@ -101,13 +106,13 @@ +---------------------------------------------------------+ +--------------------------+ ``` -* **Left, 55% of the width:** the current folder, largest first. Each row shows a 12 character bar and a percent for its share of the folder, its size, and its name. Folders end in `/`, links in `@`. +* **Left, 55% of the width:** the current folder, largest first. A table aligns Size, percent, and Name beneath their headers, with a 12 character bar when space allows. Folders end in `/`, links in `@`. * Sizes and bars of 5 GB or more are red, and of 1 GB or more yellow, as in Large Files. * The title shows the folder's path on the left, and its size, item count, and sort order on the right. -* **Right:** the selected row, titled with its name in bold white. The preview is hidden when the terminal is narrower than 100 columns. +* **Right:** the selected row, titled with its name in bold moonlight blue. The preview is hidden when the terminal is narrower than 100 columns. 1. Its path, and for well known folders, such as `~/Library/Caches` or `.npm`, what they hold in plain words. 2. Its size and share of the current folder, its item count, and when it last changed. - 3. Whether neet cleans it: a green `✓` inside a folder neet cleans, a yellow `◆` for a cleanup folder whose items neet cleans, a red `✗` for a protected folder, and a gray `·` for anywhere else. + 3. Whether neet cleans it: a green `✓` inside a folder neet cleans, a yellow `◆` for a cleanup folder whose items neet cleans, a red `✗` for a protected folder, and a normal foreground `·` for anywhere else. 4. For a folder, what is inside, in the same order and colors as the left. * Until the scan finishes, the screen shows the loading box, with how many items and how much space the scan has counted. * The selected row is bold, with an arrow in front. @@ -152,12 +157,13 @@ 2. Project build folders and installers in Downloads, from the scan. neet moves these to the Trash after you pick and review them. 3. The Trash, from the scan, which you empty yourself. 4. The Docker disk image, from the scan, and simulators, asked of `xcrun simctl` when there are simulators on the Mac: runtimes, and simulators left without one. neet asks their own tools to remove them, which is permanent. - 5. Temporary files, measured in `/private/var/folders`, which macOS clears. Simulators and temporary files are worked out in the background and show `looking…` until done. -* **Columns:** the size and a bar of it against the largest row, both red from 5 GB and yellow from 1 GB, how many items, and who clears it: `neet` in green, `neet, permanently` in red, `you` in yellow, or `macOS` in blue. Temporary files are left to macOS, since deleting them by hand can break running apps. + 5. Temporary files, measured in `/private/var/folders`, which macOS clears. Simulators and temporary files are worked out in the background and show moonlight blue `scanning` until done. +* **Columns:** the size and a bar of it against the largest row, both red from 5 GB and yellow from 1 GB, how many items, and who clears it: `neet` in green, `neet, permanently` in red, `you` in yellow, or `macOS` in moonlight blue. Temporary files are left to macOS, since deleting them by hand can break running apps. * The bottom edge adds up what neet can clear and what you can free yourself. -* **Below the table:** what the selected row is, and numbered steps to clear it. +* **Below the table:** numbered actions for the selected row, followed by its description when space allows. * **Right:** the largest items of the selected row, as many as fit: rules for Caches and logs, folders and installers by path, and the largest item for the Trash and Docker. When there is no list, the reason sits in the middle of the box. It is hidden when the terminal is narrower than 130 columns. * Build folders and installers that are empty, or already gone since the scan, are left out. +* Below 120 columns, the selected path and age sit below the file table when at least 18 rows are available. * Until the scan finishes, the screen shows the loading box. * Nothing on this screen changes a file. @@ -182,7 +188,7 @@ * Lists every build folder, or every installer, largest first, each with a checkbox, its size, its path with the folder in blue, and when anything inside last changed. A change in the last 7 days is yellow, since you may be working in it. * Everything starts selected. Measuring and checking each item shows the loading box first. -* An item the check refuses is dimmed, with the reason. What is allowed is in [SAFETY.md](SAFETY.md#build-folders-and-installers). +* An item the check refuses remains readable, with the reason in yellow. What is allowed is in [SAFETY.md](SAFETY.md#build-folders-and-installers). | Key | Action | | --- | --- | @@ -236,7 +242,7 @@ * neet looks for everything the rules cover once, in the background, as soon as it opens. Deep Clean opens on that result, so going back and opening it again does not look again. If Deep Clean opens before the look is done, the loading box shows a timer. Nothing changes while it looks. * neet looks again after a cleanup, and when you press `r`, such as after removing files yourself. -* **Left, top, 55% of the width:** a table of rules, largest first, with a checkbox, the rule's name, its risk level, how many items it found, and their size. Rules that found nothing are listed last, dimmed, with no checkbox. The bottom edge shows what every rule found together. +* **Left, top, 55% of the width:** a table of rules, largest first, with a checkbox, the rule's name, its risk level, how many items it found, and their size. Rules that found nothing are listed last with no checkbox. The bottom edge shows what every rule found together. * **Left, bottom:** the Selected box. Each selected rule with its size, then the total. Before anything is selected, it says how selecting works. * **Right:** the rule the arrow is on, in boxes that fit what they hold: 1. **Top,** titled with the rule's name: what it removes, its risk level and what that means on one line, apps to close first, how recent files it keeps, and the folder it looks in, in blue. @@ -245,7 +251,7 @@ 4. **Where the space is:** every rule that found something, largest first, with a bar, green when selected. The rule the arrow is on is bold. The last line adds up everything found and what is selected. It takes whatever room is left, and is left out when there is none. 5. If a list is too long, its last line says how many more there are. * When the terminal is narrower than 100 columns, the details go under the list, and the list's bottom edge shows the selected total instead. -* A selected checkbox is a green `[✓]`. The arrow's row shows its name in bold white, so the checkbox and risk keep their colors. +* A selected checkbox is a green `[✓]`. The arrow's row shows its name in bold, so the checkbox and risk keep their colors. * A note at the top of the details explains when a rule was not selected, or could not be. Rules that failed to load are listed there too. * `safe` rules start selected, unless their app is open. @@ -290,7 +296,7 @@ ``` * Large Files only shows files. Nothing is moved from here: `Enter` shows the file in Disk. -* **Top:** both filters, with every choice listed and the current one green in brackets, so `s` and `a` show what they change. Starts at 100 MB and any age. +* **Top:** both filters, with every choice listed and the current one green in brackets. Below 100 columns of panel width, show only the active size and age with `s` and `a` to change them. Starts at 100 MB and any age. * **Files, largest first:** a table of files from the scan. Folders are not listed. 1. Its size, and a bar of it against the largest file, both red from 5 GB and yellow from 1 GB. 2. When it last changed. Files unchanged for a year or more have a magenta date. @@ -298,6 +304,7 @@ 4. The bottom edge shows how many files match and their total size. Up to 1,000 are listed, and the edge says when only the largest are shown. 5. When no file matches, the box says so in the middle, with the keys to widen the filters. * **Right, from 120 columns:** the selected file, titled with its name: its folder, size, share of the files found, when it changed, its type from the extension, and a plain hint for common types, such as installers, archives, videos, and virtual disks. Below it, **Where they are** adds the files up by folder, largest first. `~/Library` is split one level further, since most large files are there. +* Below 120 columns, the selected path and age sit below the file table when at least 18 rows are available. * Until the scan finishes, the screen shows the loading box. | Key | Action | @@ -333,12 +340,12 @@ +--------------------------------------------------------------------------------------------+ ``` -* **App list:** a table of each app in `/Applications` and `~/Applications`, with its name, its size, a bar of its size against the largest app, its folder, and its bundle ID (the name macOS uses to identify it). Apps neet can remove come first. Apps it will not remove are listed after them, dimmed, with the reason, such as `Apple app` or `link`. -* **Colors:** sizes and bars are red from 5 GB and yellow from 1 GB, and bars are green below that. `/Applications` is blue, `~/Applications` magenta, and bundle IDs cyan. +* **App list:** a table of each app in `/Applications` and `~/Applications`, with its name, its size, a bar of its size against the largest app, its folder, and its bundle ID (the name macOS uses to identify it). Apps neet can remove come first. Apps it will not remove are listed after them, with a yellow refusal reason, such as `Apple app` or `link`. +* **Colors:** sizes and bars are red from 5 GB and yellow from 1 GB, and bars are green below that. `/Applications` and bundle IDs use moonlight blue; `~/Applications` is magenta. * Sizes are measured in the background, one app after another, and show `…` until then. Press `s` to list the largest first, and again to go back to names. -* The bottom edge shows how many apps can be removed and how much space they take, then the sort order, or a note, such as why an app cannot be opened. +* The bottom edge shows how many apps can be removed and how much space they take, with the sort order or a note on the top edge. A Selected box keeps the current app’s path and bundle ID or refusal visible. * **App files:** opening an app finds and measures its files, with the loading box and a timer. Then a table lists the app and each file, with a checkbox, size, name, folder, and a note on anything left unselected, such as `may be your data`. -* The box under the table explains the file the arrow is on: why it starts selected or not, and what to check first. +* The box under the table shows the bundle ID and selected path, then explains the file the arrow is on: why it starts selected or not, and what to check first. * The bottom edge shows the selected total, in green once something is selected. * What is found, and what starts selected, is in [SAFETY.md](SAFETY.md#app-removal). @@ -358,7 +365,7 @@ | Screen | Layout | Keys | | --- | --- | --- | | Space Breakdown | A list of parts that add up to the disk's used space: the scan, skipped folders, apps, macOS, snapshots, purgeable space, and anything unexplained. Opened with `b` on Home. | `Enter` opens a folder in Disk. | -| Projects | A list of build folders in code projects, with project, last change, folder, and size. Folders that cannot be cleaned are dimmed with the reason. | `Space` selects, `s` sorts, `Enter` reviews. | +| Projects | A list of build folders in code projects, with project, last change, folder, and size. Folders that cannot be cleaned show the reason. | `Space` selects, `s` sorts, `Enter` reviews. | | Startup | Sections for login items, background items, launch agents, and launch daemons. Each row shows the program, whether it runs, and whether it is signed. | Turn off, turn back on. | | SSH | Sections for hosts, keys, agent keys, known hosts, and permission problems. | Fix permissions, add or remove agent keys, remove known hosts. | | Dotfiles | Settings files grouped by shell, Git, SSH, editors, and terminal. | Edit with a backup, export. |