diff --git a/README.md b/README.md index baf83c6..cf308d2 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ It will be much faster the next time as it no longer needs to fetch the entire r After some time you will see something like this: -![Screenshot of redu showing the contents of a repo with some marks active](screenshot.png) +![Screenshot of redu showing the contents of a repo](screenshot_start.png) You can navigate using the **arrow keys** or **hjkl**. Going right enters a directory and going left leaves back to the parent. @@ -68,16 +68,32 @@ where it is the biggest. The bars indicate the relative size of the item compared to everything else in the current location. +By pressing **Enter** you can make a small window visible that shows some details +about the currently highlighted item: +- The latest snapshot where it has maximum size +- The earliest date and snapshot where this item appears +- The latest date and snapshot where this item appears + +![Screenshot of redu showing the contents of a repo with details open](screenshot_details.png) + +You can keep navigating with the details window open and it will update as you +browse around. + ### Marking files You can mark files and directories to build up your list of things to exclude. Keybinds - **m**: mark selected file/directory - **u**: unmark selected file/directory -- **c**: clear all marks +- **c**: clear all marks (this will prompt you for confirmation) The marks are persistent across runs of redu (they are saved in the cache file), so feel free to mark a few files and just quit and come back later. +The marks are shown with an asterik at the beginning of the line +and you can see how many total marks you have on the bar at the bottom. + +![Screenshot of redu showing the contents of a repo with some marks](screenshot_marks.png) + ### Generating the excludes Press **g** to exit redu and generate a list with all of your marks in alphabetic order to stdout. diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index 2c67f7b..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/screenshot_details.png b/screenshot_details.png new file mode 100644 index 0000000..5cfbeb6 Binary files /dev/null and b/screenshot_details.png differ diff --git a/screenshot_marks.png b/screenshot_marks.png new file mode 100644 index 0000000..df3a957 Binary files /dev/null and b/screenshot_marks.png differ diff --git a/screenshot_start.png b/screenshot_start.png new file mode 100644 index 0000000..38b8a03 Binary files /dev/null and b/screenshot_start.png differ diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 898fed2..3dd65e8 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,7 +1,7 @@ use std::{collections::HashSet, path::Path}; use camino::{Utf8Path, Utf8PathBuf}; -use chrono::DateTime; +use chrono::{DateTime, Utc}; use log::trace; use rusqlite::{ functions::FunctionFlags, params, types::FromSqlError, Connection, @@ -62,9 +62,7 @@ impl Cache { .query_and_then([], |row| Ok(Snapshot { id: row.get("hash")?, - time: DateTime::from_timestamp_micros(row.get("time")?) - .map(Ok) - .unwrap_or(Err(Error::ExhaustedTimestampPrecision))?, + time: timestamp_to_datetime(row.get("time")?)?, parent: row.get("parent")?, tree: row.get("tree")?, paths: serde_json::from_str(row.get_ref("paths")?.as_str()?)?, @@ -122,7 +120,7 @@ impl Cache { /// This returns the children files/directories of the given path. /// Each entry's size is the largest size of that file/directory across /// all snapshots. - pub fn get_max_file_sizes( + pub fn get_entries( &self, path_id: Option, ) -> Result, rusqlite::Error> { @@ -166,6 +164,71 @@ impl Cache { rows.collect() } + pub fn get_entry_details( + &self, + path_id: PathId, + ) -> Result { + let aux = |row: &Row| -> Result { + Ok(EntryDetails { + max_size: row.get("max_size")?, + max_size_snapshot_hash: row.get("max_size_snapshot_hash")?, + first_seen: timestamp_to_datetime(row.get("first_seen")?)?, + first_seen_snapshot_hash: row + .get("first_seen_snapshot_hash")?, + last_seen: timestamp_to_datetime(row.get("last_seen")?)?, + last_seen_snapshot_hash: row.get("last_seen_snapshot_hash")?, + }) + }; + let raw_path_id = path_id.0; + let rich_entries_cte = get_tables(&self.conn)? + .iter() + .filter_map(|name| name.strip_prefix("entries_")) + .map(|snapshot_hash| { + format!( + "SELECT \ + hash, \ + size, \ + time \ + FROM \"entries_{snapshot_hash}\" \ + JOIN paths ON path_id = paths.id \ + JOIN snapshots ON hash = '{snapshot_hash}' \ + WHERE path_id = {raw_path_id}\n" + ) + }) + .intersperse(String::from(" UNION ALL ")) + .collect::(); + let query = format!( + "WITH \ + rich_entries AS ({rich_entries_cte}), \ + first_seen AS ( + SELECT hash, time + FROM rich_entries + ORDER BY time ASC + LIMIT 1), \ + last_seen AS ( + SELECT hash, time + FROM rich_entries + ORDER BY time DESC + LIMIT 1), \ + max_size AS ( + SELECT hash, size + FROM rich_entries + ORDER BY size DESC, time DESC + LIMIT 1) \ + SELECT \ + max_size.size AS max_size, \ + max_size.hash AS max_size_snapshot_hash, \ + first_seen.time AS first_seen, \ + first_seen.hash as first_seen_snapshot_hash, \ + last_seen.time AS last_seen, \ + last_seen.hash as last_seen_snapshot_hash \ + FROM max_size + JOIN first_seen ON 1=1 + JOIN last_seen ON 1=1" + ); + self.conn.query_row_and_then(&query, [], aux) + } + pub fn save_snapshot( &mut self, snapshot: &Snapshot, @@ -190,7 +253,7 @@ impl Cache { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ snapshot.id, - snapshot.time.timestamp_micros(), + datetime_to_timestamp(snapshot.time), snapshot.parent, snapshot.tree, snapshot.hostname, @@ -341,6 +404,16 @@ pub struct Entry { pub is_dir: bool, } +#[derive(Clone, Debug)] +pub struct EntryDetails { + pub max_size: usize, + pub max_size_snapshot_hash: String, + pub first_seen: DateTime, + pub first_seen_snapshot_hash: String, + pub last_seen: DateTime, + pub last_seen_snapshot_hash: String, +} + ////////// Migrations ////////////////////////////////////////////////////////// type VersionId = u64; @@ -513,3 +586,14 @@ fn get_tables(conn: &Connection) -> Result, rusqlite::Error> { let names = stmt.query_map([], |row| row.get(0))?; names.collect() } + +////////// Misc //////////////////////////////////////////////////////////////// +fn timestamp_to_datetime(timestamp: i64) -> Result, Error> { + DateTime::from_timestamp_micros(timestamp) + .map(Ok) + .unwrap_or(Err(Error::ExhaustedTimestampPrecision)) +} + +fn datetime_to_timestamp(datetime: DateTime) -> i64 { + datetime.timestamp_micros() +} diff --git a/src/cache/tests.rs b/src/cache/tests.rs index ba721e4..3d53fe9 100644 --- a/src/cache/tests.rs +++ b/src/cache/tests.rs @@ -321,7 +321,7 @@ fn cache_snapshots_entries() { vec![] } else { cache - .get_max_file_sizes(path_id) + .get_entries(path_id) .unwrap() .into_iter() .map(|e| (e.component, e.size, e.is_dir)) @@ -505,4 +505,6 @@ fn test_migrate_v0_to_v1() { assert_marks(&cache, &marks); assert_eq!(determine_version(&cache.conn).unwrap(), Some(1)); + + cache_snapshots_entries(); } diff --git a/src/main.rs b/src/main.rs index 7135476..92f1e8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,10 +42,12 @@ use redu::{ }; use scopeguard::defer; use thiserror::Error; +use util::snapshot_short_id; use crate::ui::{Action, App, Event}; mod ui; +mod util; /// This is like ncdu for a restic respository. /// @@ -195,9 +197,11 @@ fn main() -> anyhow::Result<()> { rect.as_size(), None, Utf8PathBuf::new(), - cache.get_max_file_sizes(None)?, + cache.get_entries(None)?, cache.get_marks().unwrap(), vec![ + "Enter".bold(), + ":Details ".into(), "m".bold(), ":Mark ".into(), "u".bold(), @@ -232,13 +236,15 @@ fn main() -> anyhow::Result<()> { Action::GetParentEntries(path_id) => { let parent_id = cache.get_parent_id(path_id)? .expect("The UI requested a GetParentEntries with a path_id that does not exist"); - let entries = cache.get_max_file_sizes(parent_id)?; + let entries = cache.get_entries(parent_id)?; Some(Event::Entries { path_id: parent_id, entries }) } Action::GetEntries(path_id) => { - let entries = cache.get_max_file_sizes(path_id)?; + let entries = cache.get_entries(path_id)?; Some(Event::Entries { path_id, entries }) } + Action::GetEntryDetails(path_id) => + Some(Event::EntryDetails(cache.get_entry_details(path_id)?)), Action::UpsertMark(path) => { cache.upsert_mark(&path)?; Some(Event::Marks(cache.get_marks()?)) @@ -606,10 +612,6 @@ fn mpb_insert_end(mpb: &MultiProgress, template: &str) -> ProgressBar { pb } -fn snapshot_short_id(id: &str) -> String { - id.chars().take(7).collect::() -} - #[derive(Clone)] struct FixedSizeQueue(Arc>>); diff --git a/src/ui.rs b/src/ui.rs index b242a6c..5c588d3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,13 +13,17 @@ use ratatui::{ style::{Style, Stylize}, text::Span, widgets::{ - Block, BorderType, Clear, List, ListItem, Padding, Paragraph, Widget, + Block, BorderType, Clear, Padding, Paragraph, Row, Table, Widget, WidgetRef, Wrap, }, }; +use redu::cache::EntryDetails; use unicode_segmentation::UnicodeSegmentation; -use crate::cache::{Entry, PathId}; +use crate::{ + cache::{Entry, PathId}, + util::snapshot_short_id, +}; #[derive(Clone, Debug)] pub enum Event { @@ -42,6 +46,7 @@ pub enum Event { path_id: Option, entries: Vec, }, + EntryDetails(EntryDetails), Marks(Vec), } @@ -53,6 +58,7 @@ pub enum Action { Generate(Vec), GetParentEntries(PathId), GetEntries(Option), + GetEntryDetails(PathId), UpsertMark(Utf8PathBuf), DeleteMark(Utf8PathBuf), DeleteAllMarks, @@ -67,6 +73,7 @@ pub struct App { selected: usize, offset: usize, footer_extra: Vec>, + details_drawer: Option, confirm_dialog: Option, } @@ -90,6 +97,7 @@ impl App { selected: 0, offset: 0, footer_extra, + details_drawer: None, confirm_dialog: None, } } @@ -126,11 +134,15 @@ impl App { } else { Action::Render } + } else if self.confirm_dialog.is_none() { + Action::GetEntryDetails(self.entries[self.selected].path_id) } else { Action::Nothing }, Exit => - if self.confirm_dialog.take().is_some() { + if self.confirm_dialog.take().is_some() + || self.details_drawer.take().is_some() + { Action::Render } else { Action::Nothing @@ -154,6 +166,10 @@ impl App { Quit => Action::Quit, Generate => self.generate(), Entries { path_id, entries } => self.set_entries(path_id, entries), + EntryDetails(details) => { + self.details_drawer = Some(DetailsDrawer { details }); + Action::Render + } Marks(new_marks) => self.set_marks(new_marks), } } @@ -196,7 +212,11 @@ impl App { } as usize; self.fix_offset(); - Action::Render + if self.details_drawer.is_some() { + Action::GetEntryDetails(self.entries[self.selected].path_id) + } else { + Action::Render + } } fn mark_selection(&mut self) -> Action { @@ -241,7 +261,12 @@ impl App { } self.entries = entries; self.fix_offset(); - Action::Render + + if self.details_drawer.is_some() { + Action::GetEntryDetails(self.entries[self.selected].path_id) + } else { + Action::Render + } } fn set_marks(&mut self, new_marks: Vec) -> Action { @@ -280,174 +305,26 @@ impl App { } } -/// ConfirmDialog ////////////////////////////////////////////////////////////// -struct ConfirmDialog { - text: String, - yes: String, - no: String, - yes_selected: bool, - action: Action, -} - -impl WidgetRef for ConfirmDialog { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let main_text = Paragraph::new(self.text.clone()) - .centered() - .wrap(Wrap { trim: false }); - - let padding = Padding { left: 2, right: 2, top: 1, bottom: 0 }; - let horiz_padding = padding.left + padding.right; - let vert_padding = padding.top + padding.bottom; - let dialog_area = { - let max_text_width = min(80, area.width - 2 - horiz_padding); // take out the border and padding - let text_width = - min(self.text.graphemes(true).count() as u16, max_text_width); - let text_height = main_text.line_count(max_text_width) as u16; - let max_width = text_width + 2 + horiz_padding; // text + border + padding - let max_height = text_height + 2 + vert_padding + 1 + 2 + 1; // text + border + padding + empty line + buttons - centered(max_width, max_height, area) - }; - - let block = Block::bordered().title("Confirm").padding(padding); - - let (main_text_area, buttons_area) = { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Fill(100), Constraint::Length(3)]) - .split(block.inner(dialog_area)); - (layout[0], layout[1]) - }; - let (no_button_area, yes_button_area) = { - let layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Fill(1), - Constraint::Min(self.no.graphemes(true).count() as u16), - Constraint::Fill(1), - Constraint::Min(self.yes.graphemes(true).count() as u16), - Constraint::Fill(1), - ]) - .split(buttons_area); - (layout[1], layout[3]) - }; - - fn render_button( - label: &str, - selected: bool, - area: Rect, - buf: &mut Buffer, - ) { - let mut block = Block::bordered().border_type(BorderType::Plain); - let mut button = - Paragraph::new(label).centered().wrap(Wrap { trim: false }); - if selected { - block = block.border_type(BorderType::QuadrantInside); - button = button.black().on_white(); - } - button.render(block.inner(area), buf); - block.render(area, buf); - } - - Clear.render(dialog_area, buf); - block.render(dialog_area, buf); - main_text.render(main_text_area, buf); - render_button(&self.no, !self.yes_selected, no_button_area, buf); - render_button(&self.yes, self.yes_selected, yes_button_area, buf); - } -} - -/// Render ///////////////////////////////////////////////////////////////////// - -struct ListEntry<'a> { - name: &'a str, - size: usize, - relative_size: f64, - is_dir: bool, - is_marked: bool, +fn compute_list_size(area: Size) -> Size { + let (_, list, _) = compute_layout((Position::new(0, 0), area).into()); + list.as_size() } -impl<'a> ListEntry<'a> { - fn to_line(&self, width: u16, selected: bool) -> Line { - let mut spans = Vec::with_capacity(4); - - // Mark - spans.push(Span::raw(if self.is_marked { "*" } else { " " })); - - // Size - spans.push(Span::raw(format!( - " {:>10}", - humansize::format_size(self.size, humansize::BINARY) - ))); - - // Bar - spans.push( - Span::raw({ - const MAX_BAR_WIDTH: usize = 16; - let bar_frac_width = - (self.relative_size * (MAX_BAR_WIDTH * 8) as f64) as usize; - let full_blocks = bar_frac_width / 8; - let last_block = match (bar_frac_width % 8) as u32 { - 0 => String::new(), - x => String::from(unsafe { - char::from_u32_unchecked(0x2590 - x) - }), - }; - let empty_width = MAX_BAR_WIDTH - - full_blocks - - last_block.graphemes(true).count(); - let mut bar = String::with_capacity(1 + MAX_BAR_WIDTH + 1); - bar.push(' '); - for _ in 0..full_blocks { - bar.push('\u{2588}'); - } - bar.push_str(&last_block); - for _ in 0..empty_width { - bar.push(' '); - } - bar.push(' '); - bar - }) - .green(), - ); - - // Name - spans.push({ - let available_width = { - let used: usize = spans - .iter() - .map(|s| s.content.graphemes(true).count()) - .sum(); - max(0, width as isize - used as isize) as usize - }; - if self.is_dir { - let mut name = Cow::Borrowed(self.name); - if !name.ends_with('/') { - name.to_mut().push('/'); - } - let span = - Span::raw(shorten_to(&name, available_width).into_owned()) - .bold(); - if selected { - span.dark_gray() - } else { - span.blue() - } - } else { - Span::raw(shorten_to(self.name, available_width)) - } - }); - - Line::from(spans).style(if selected { - Style::new().black().on_white() - } else { - Style::new() - }) - } +fn compute_layout(area: Rect) -> (Rect, Rect, Rect) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Fill(100), + Constraint::Length(1), + ]) + .split(area); + (layout[0], layout[1], layout[2]) } impl WidgetRef for App { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let (header_rect, list_rect, footer_rect) = compute_layout(area); + let (header_area, table_area, footer_area) = compute_layout(area); { // Header let mut string = "--- ".to_string(); @@ -458,14 +335,14 @@ impl WidgetRef for App { } else { self.path.as_str() }, - max(0, header_rect.width as isize - string.len() as isize) + max(0, header_area.width as isize - string.len() as isize) as usize, ) .as_ref(), ); let mut remaining_width = max( 0, - header_rect.width as isize + header_area.width as isize - string.graphemes(true).count() as isize, ) as usize; if remaining_width > 0 { @@ -473,25 +350,60 @@ impl WidgetRef for App { remaining_width -= 1; } string.push_str(&"-".repeat(remaining_width)); - Paragraph::new(string).on_light_blue().render_ref(header_rect, buf); + Paragraph::new(string).on_light_blue().render_ref(header_area, buf); } { - // List - let list_entries = to_list_entries( - |e| self.marks.contains(&self.full_path(e)), - self.entries.iter(), - ); - let items = - list_entries.iter().enumerate().skip(self.offset).map( - |(index, entry)| { - ListItem::new(entry.to_line( - self.list_size.width, - index == self.selected, - )) - }, - ); - List::new(items).render_ref(list_rect, buf) + // Table + let mut rows: Vec = Vec::with_capacity(self.entries.len()); + let mut entries = self.entries.iter(); + if let Some(first) = entries.next() { + let largest_size = first.size as f64; + for (index, entry) in iter::once(first) + .chain(entries) + .enumerate() + .skip(self.offset) + { + let selected = index == self.selected; + let mark_span = render_mark( + self.marks.contains(&self.full_path(entry)), + ); + let size_span = render_size(entry.size); + let sizebar_span = + render_sizebar(entry.size as f64 / largest_size); + let used_width: usize = grapheme_len(&mark_span.content) + + grapheme_len(&size_span.content) + + grapheme_len(&sizebar_span.content) + + 3; // separators + let available_width = + max(0, table_area.width as isize - used_width as isize) + as usize; + let name_span = render_name( + &entry.component, + entry.is_dir, + selected, + available_width, + ); + let row = Row::new(vec![ + mark_span, + size_span, + sizebar_span, + name_span, + ]); + rows.push(row.style(if selected { + Style::new().black().on_white() + } else { + Style::new() + })); + } + } + Table::new(rows, [ + Constraint::Min(MARK_LEN), + Constraint::Min(SIZE_LEN), + Constraint::Min(SIZEBAR_LEN), + Constraint::Percentage(100), + ]) + .render_ref(table_area, buf) } { @@ -505,7 +417,11 @@ impl WidgetRef for App { .collect::>(); Paragraph::new(Line::from(spans)) .on_light_blue() - .render_ref(footer_rect, buf); + .render_ref(footer_area, buf); + } + + if let Some(details_dialog) = &self.details_drawer { + details_dialog.render_ref(table_area, buf); } if let Some(confirm_dialog) = &self.confirm_dialog { @@ -514,26 +430,67 @@ impl WidgetRef for App { } } -/// `entries` is expected to be sorted by size, largest first. -fn to_list_entries<'a>( - mut is_marked: impl FnMut(&'a Entry) -> bool, - entries: impl IntoIterator, -) -> Vec> { - let mut entries = entries.into_iter(); - if let Some(first) = entries.next() { - let largest = first.size as f64; - iter::once(first) - .chain(entries) - .map(|e @ Entry { component, size, is_dir, .. }| ListEntry { - name: component, - size: *size, - relative_size: *size as f64 / largest, - is_dir: *is_dir, - is_marked: is_marked(e), - }) - .collect() +const MARK_LEN: u16 = 1; + +fn render_mark(is_marked: bool) -> Span<'static> { + Span::raw(if is_marked { "*" } else { " " }) +} + +const SIZE_LEN: u16 = 11; + +fn render_size(size: usize) -> Span<'static> { + Span::raw(format!( + "{:>11}", + humansize::format_size(size, humansize::BINARY) + )) +} + +const SIZEBAR_LEN: u16 = 16; + +fn render_sizebar(relative_size: f64) -> Span<'static> { + Span::raw({ + let bar_frac_width = + (relative_size * (SIZEBAR_LEN * 8) as f64) as usize; + let full_blocks = bar_frac_width / 8; + let last_block = match (bar_frac_width % 8) as u32 { + 0 => String::new(), + x => String::from(unsafe { char::from_u32_unchecked(0x2590 - x) }), + }; + let empty_width = + SIZEBAR_LEN as usize - full_blocks - grapheme_len(&last_block); + let mut bar = String::with_capacity(1 + SIZEBAR_LEN as usize + 1); + for _ in 0..full_blocks { + bar.push('\u{2588}'); + } + bar.push_str(&last_block); + for _ in 0..empty_width { + bar.push(' '); + } + bar + }) + .green() +} + +fn render_name( + name: &str, + is_dir: bool, + selected: bool, + available_width: usize, +) -> Span { + if is_dir { + let mut name = Cow::Borrowed(name); + if !name.ends_with('/') { + name.to_mut().push('/'); + } + let span = + Span::raw(shorten_to(&name, available_width).into_owned()).bold(); + if selected { + span.dark_gray() + } else { + span.blue() + } } else { - Vec::new() + Span::raw(shorten_to(name, available_width)) } } @@ -557,23 +514,133 @@ fn shorten_to(s: &str, width: usize) -> Cow { res } -/// Misc ////////////////////////////////////////////////////////////////////// +/// DetailsDialog ////////////////////////////////////////////////////////////// +struct DetailsDrawer { + details: EntryDetails, +} -fn compute_list_size(area: Size) -> Size { - let (_, list, _) = compute_layout((Position::new(0, 0), area).into()); - list.as_size() +impl WidgetRef for DetailsDrawer { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let details = &self.details; + let text = format!( + "max size: {} ({})\n\ + first seen: {} ({})\n\ + last seen: {} ({})\n", + humansize::format_size(details.max_size, humansize::BINARY), + snapshot_short_id(&details.max_size_snapshot_hash), + details.first_seen.date_naive(), + snapshot_short_id(&details.first_seen_snapshot_hash), + details.last_seen.date_naive(), + snapshot_short_id(&details.last_seen_snapshot_hash), + ); + let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); + let padding = Padding { left: 2, right: 2, top: 0, bottom: 0 }; + let horiz_padding = padding.left + padding.right; + let inner_width = { + let desired_inner_width = paragraph.line_width() as u16; + let max_inner_width = area.width.saturating_sub(2 + horiz_padding); + min(max_inner_width, desired_inner_width) + }; + let outer_width = inner_width + 2 + horiz_padding; + let outer_height = { + let vert_padding = padding.top + padding.bottom; + let inner_height = paragraph.line_count(inner_width) as u16; + inner_height + 2 + vert_padding + }; + let block_area = Rect { + x: area.x + area.width - outer_width, + y: area.y + area.height - outer_height, + width: outer_width, + height: outer_height, + }; + let block = Block::bordered().title("Details").padding(padding); + let paragraph_area = block.inner(block_area); + Clear.render(block_area, buf); + block.render(block_area, buf); + paragraph.render(paragraph_area, buf); + } } -fn compute_layout(area: Rect) -> (Rect, Rect, Rect) { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Fill(100), - Constraint::Length(1), - ]) - .split(area); - (layout[0], layout[1], layout[2]) +/// ConfirmDialog ////////////////////////////////////////////////////////////// +struct ConfirmDialog { + text: String, + yes: String, + no: String, + yes_selected: bool, + action: Action, +} + +impl WidgetRef for ConfirmDialog { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let main_text = Paragraph::new(self.text.clone()) + .centered() + .wrap(Wrap { trim: false }); + + let padding = Padding { left: 2, right: 2, top: 1, bottom: 0 }; + let width = min(80, grapheme_len(&self.text) as u16); + let height = main_text.line_count(width) as u16 + 1 + 3; // text + empty line + buttons + let dialog_area = dialog(padding, width, height, area); + + let block = Block::bordered().title("Confirm").padding(padding); + + let (main_text_area, buttons_area) = { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(100), Constraint::Length(3)]) + .split(block.inner(dialog_area)); + (layout[0], layout[1]) + }; + let (no_button_area, yes_button_area) = { + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Min(self.no.graphemes(true).count() as u16), + Constraint::Fill(1), + Constraint::Min(self.yes.graphemes(true).count() as u16), + Constraint::Fill(1), + ]) + .split(buttons_area); + (layout[1], layout[3]) + }; + + fn render_button( + label: &str, + selected: bool, + area: Rect, + buf: &mut Buffer, + ) { + let mut block = Block::bordered().border_type(BorderType::Plain); + let mut button = + Paragraph::new(label).centered().wrap(Wrap { trim: false }); + if selected { + block = block.border_type(BorderType::QuadrantInside); + button = button.black().on_white(); + } + button.render(block.inner(area), buf); + block.render(area, buf); + } + + Clear.render(dialog_area, buf); + block.render(dialog_area, buf); + main_text.render(main_text_area, buf); + render_button(&self.no, !self.yes_selected, no_button_area, buf); + render_button(&self.yes, self.yes_selected, yes_button_area, buf); + } +} + +/// Misc ////////////////////////////////////////////////////////////////////// +fn dialog( + padding: Padding, + max_inner_width: u16, + max_inner_height: u16, + area: Rect, +) -> Rect { + let horiz_padding = padding.left + padding.right; + let vert_padding = padding.top + padding.bottom; + let max_width = max_inner_width + 2 + horiz_padding; // The extra 2 is the border + let max_height = max_inner_height + 2 + vert_padding; + centered(max_width, max_height, area) } /// Returns a `Rect` centered in `area` with a maximum width and height. @@ -588,8 +655,11 @@ fn centered(max_width: u16, max_height: u16, area: Rect) -> Rect { } } -/// Tests ////////////////////////////////////////////////////////////////////// +fn grapheme_len(s: &str) -> usize { + s.graphemes(true).count() +} +/// Tests ////////////////////////////////////////////////////////////////////// #[cfg(test)] mod tests { use std::borrow::Cow; @@ -597,173 +667,24 @@ mod tests { use super::{shorten_to, *}; #[test] - fn list_entry_to_line_narrow_width() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 999 * 1024 + 1010, - relative_size: 0.9, - is_dir: false, - is_marked: false, - }; - assert_eq!( - f.to_line(40, false), - Line::from(vec![ - Span::raw(" "), - Span::raw(" 999.99 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("123...7890") - ]) - ); - } - - #[test] - fn list_entry_to_line_large_size_file() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 999 * 1024 + 1010, - relative_size: 0.9, - is_dir: false, - is_marked: false, - }; - assert_eq!( - f.to_line(80, false), - Line::from(vec![ - Span::raw(" "), - Span::raw(" 999.99 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("1234567890123456789012345678901234567890") - ]) - ); - } - - #[test] - fn list_entry_to_line_small_size_file() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 9 * 1024, - relative_size: 0.9, - is_dir: false, - is_marked: false, - }; - assert_eq!( - f.to_line(80, false), - Line::from(vec![ - Span::raw(" "), - Span::raw(" 9 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("1234567890123456789012345678901234567890") - ]) - ); - } - - #[test] - fn list_entry_to_line_directory() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 9 * 1024 + 1010, - relative_size: 0.9, - is_dir: true, - is_marked: false, - }; - assert_eq!( - f.to_line(80, false), - Line::from(vec![ - Span::raw(" "), - Span::raw(" 9.99 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("1234567890123456789012345678901234567890/") - .bold() - .blue() - ]) - ); - } - - #[test] - fn list_entry_to_line_file_selected() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 999 * 1024 + 1010, - relative_size: 0.9, - is_dir: false, - is_marked: false, - }; - assert_eq!( - f.to_line(80, true), - Line::from(vec![ - Span::raw(" "), - Span::raw(" 999.99 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("1234567890123456789012345678901234567890") - ]) - .black() - .on_white() - ); - } - - #[test] - fn list_entry_to_line_directory_selected() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 9 * 1024 + 1010, - relative_size: 0.9, - is_dir: true, - is_marked: false, - }; - assert_eq!( - f.to_line(80, true), - Line::from(vec![ - Span::raw(" "), - Span::raw(" 9.99 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("1234567890123456789012345678901234567890/") - .bold() - .dark_gray() - ]) - .black() - .on_white() - ); - } - - #[test] - fn list_entry_to_line_file_marked() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 999 * 1024 + 1010, - relative_size: 0.9, - is_dir: false, - is_marked: true, - }; - assert_eq!( - f.to_line(80, false), - Line::from(vec![ - Span::raw("*"), - Span::raw(" 999.99 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("1234567890123456789012345678901234567890") - ]) - ); - } + fn render_sizebar_test() { + fn aux(size: f64, content: &str) { + assert_eq!(render_sizebar(size).content, content); + } - #[test] - fn list_entry_to_line_file_marked_selected() { - let f = ListEntry { - name: "1234567890123456789012345678901234567890", - size: 999 * 1024 + 1010, - relative_size: 0.9, - is_dir: false, - is_marked: true, - }; - assert_eq!( - f.to_line(80, true), - Line::from(vec![ - Span::raw("*"), - Span::raw(" 999.99 KiB"), - Span::raw(" ██████████████▍ ").green(), - Span::raw("1234567890123456789012345678901234567890") - ]) - .black() - .on_white() - ); + aux(0.00, " "); + aux(0.25, "████ "); + aux(0.50, "████████ "); + aux(0.75, "████████████ "); + aux(0.90, "██████████████▍ "); + aux(1.00, "████████████████"); + aux(0.5 + (1.0 / (8.0 * 16.0)), "████████▏ "); + aux(0.5 + (2.0 / (8.0 * 16.0)), "████████▎ "); + aux(0.5 + (3.0 / (8.0 * 16.0)), "████████▍ "); + aux(0.5 + (4.0 / (8.0 * 16.0)), "████████▌ "); + aux(0.5 + (5.0 / (8.0 * 16.0)), "████████▋ "); + aux(0.5 + (6.0 / (8.0 * 16.0)), "████████▊ "); + aux(0.5 + (7.0 / (8.0 * 16.0)), "████████▉ "); } #[test] diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..522530b --- /dev/null +++ b/src/util.rs @@ -0,0 +1,3 @@ +pub fn snapshot_short_id(id: &str) -> String { + id.chars().take(7).collect::() +}