diff --git a/Cargo.lock b/Cargo.lock index b6cfa3ad6..97e727580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,6 +443,21 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e9e01327e6c86e92ec72b1c798d4a94810f147209bbe3ffab6a86954937a6f" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -586,6 +601,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if 1.0.0", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent_arena" version = "0.1.8" @@ -750,7 +778,10 @@ dependencies = [ "bitflags 2.5.0", "crossterm_winapi", "libc", + "mio", "parking_lot", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -1766,6 +1797,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inout" version = "0.1.3" @@ -1943,6 +1980,15 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown 0.14.3", +] + [[package]] name = "matchers" version = "0.1.0" @@ -2031,6 +2077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.48.0", ] @@ -2406,6 +2453,12 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + [[package]] name = "path-dedot" version = "3.1.1" @@ -2726,6 +2779,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8" +dependencies = [ + "bitflags 2.5.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "itertools", + "lru", + "paste", + "stability", + "strum 0.26.2", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.9.0" @@ -3034,6 +3107,7 @@ dependencies = [ "clap_complete", "comfy-table", "convert_case 0.6.0", + "crossterm", "dav-server", "dialoguer", "dircmp", @@ -3054,6 +3128,7 @@ dependencies = [ "pretty_assertions", "quickcheck", "quickcheck_macros", + "ratatui", "rhai", "rstest", "rustic_backend", @@ -3069,6 +3144,7 @@ dependencies = [ "thiserror", "tokio", "toml 0.8.12", + "tui-textarea", "warp", ] @@ -3526,6 +3602,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -3650,6 +3747,16 @@ dependencies = [ "serde", ] +[[package]] +name = "stability" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4161,6 +4268,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width", +] + [[package]] name = "tungstenite" version = "0.20.1" diff --git a/Cargo.toml b/Cargo.toml index 0f462d4cf..194c4ef81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,11 @@ rustic - fast, encrypted, deduplicated backups powered by Rust """ [features] -default = ["self-update", "webdav"] +default = ["self-update", "tui", "webdav"] mimalloc = ["dep:mimalloc"] jemallocator = ["dep:jemallocator-global"] self-update = ["dep:self_update", "dep:semver"] +tui = ["dep:ratatui", "dep:crossterm", "dep:tui-textarea"] webdav = ["dep:dav-server", "dep:warp", "dep:tokio", "rustic_core/webdav"] [[bin]] @@ -52,6 +53,11 @@ dav-server = { version = "0.5.8", default-features = false, features = ["warp-co tokio = { version = "1", optional = true } warp = { version = "0.3.6", optional = true } +# tui +crossterm = { version = "0.27", optional = true } +ratatui = { version = "0.26.1", optional = true } +tui-textarea = { version = "0.4.0", optional = true } + # logging log = "0.4" diff --git a/src/commands.rs b/src/commands.rs index 7943d7f28..00e19bd14 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -22,6 +22,8 @@ pub(crate) mod self_update; pub(crate) mod show_config; pub(crate) mod snapshots; pub(crate) mod tag; +#[cfg(feature = "tui")] +pub(crate) mod tui; #[cfg(feature = "webdav")] pub(crate) mod webdav; @@ -37,7 +39,7 @@ use crate::{ config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, dump::DumpCmd, forget::ForgetCmd, init::InitCmd, key::KeyCmd, list::ListCmd, ls::LsCmd, merge::MergeCmd, prune::PruneCmd, repair::RepairCmd, repoinfo::RepoInfoCmd, restore::RestoreCmd, self_update::SelfUpdateCmd, - show_config::ShowConfigCmd, snapshots::SnapshotCmd, tag::TagCmd, + show_config::ShowConfigCmd, snapshots::SnapshotCmd, tag::TagCmd, tui::TuiCmd, }, config::{progress_options::ProgressOptions, AllRepositoryOptions, RusticConfig}, {Application, RUSTIC_APP}, @@ -164,7 +166,14 @@ impl Runnable for EntryPoint { // Set up panic hook for better error messages and logs setup_panic!(); - self.commands.run(); + if self.config.global.interactive { + if matches!(self.commands, RusticCmd::Snapshots(_) | RusticCmd::Tag(_)) { + let tui = TuiCmd {}; + tui.run(); + } + } else { + self.commands.run(); + } RUSTIC_APP.shutdown(Shutdown::Graceful) } } diff --git a/src/commands/snapshots.rs b/src/commands/snapshots.rs index 2b4e0821f..b5c329a2d 100644 --- a/src/commands/snapshots.rs +++ b/src/commands/snapshots.rs @@ -80,40 +80,17 @@ impl SnapshotCmd { if self.long { for snap in snapshots { - snap.print_table(); - } - } else { - let snap_to_table = |(sn, count): (SnapshotFile, usize)| { - let tags = sn.tags.formatln(); - let paths = sn.paths.formatln(); - let time = sn.time.format("%Y-%m-%d %H:%M:%S"); - let (files, dirs, size) = sn.summary.as_ref().map_or_else( - || ("?".to_string(), "?".to_string(), "?".to_string()), - |s| { - ( - s.total_files_processed.to_string(), - s.total_dirs_processed.to_string(), - bytes_size_to_string(s.total_bytes_processed), - ) - }, - ); - let id = match count { - 0 => format!("{}", sn.id), - count => format!("{} (+{})", sn.id, count), + let mut table = table(); + + let add_entry = |title: &str, value: String| { + _ = table.add_row([bold_cell(title), Cell::new(value)]); }; - [ - id, - time.to_string(), - sn.hostname, - sn.label, - tags, - paths, - files, - dirs, - size, - ] - }; + fill_table(&snap, add_entry); + println!("{table}"); + println!(); + } + } else { let mut table = table_right_from( 6, [ @@ -125,8 +102,7 @@ impl SnapshotCmd { .into_iter() .group_by(|sn| if self.all { sn.id } else { sn.tree }) .into_iter() - .map(|(_, mut g)| (g.next().unwrap(), g.count())) - .map(snap_to_table) + .map(|(_, mut g)| snap_to_table(&g.next().unwrap(), g.count())) .collect(); _ = table.add_rows(snapshots); println!("{table}"); @@ -141,99 +117,113 @@ impl SnapshotCmd { } } -/// Trait to print a table -trait PrintTable { - /// Print a table - fn print_table(&self); +pub fn snap_to_table(sn: &SnapshotFile, count: usize) -> [String; 9] { + let tags = sn.tags.formatln(); + let paths = sn.paths.formatln(); + let time = sn.time.format("%Y-%m-%d %H:%M:%S"); + let (files, dirs, size) = sn.summary.as_ref().map_or_else( + || ("?".to_string(), "?".to_string(), "?".to_string()), + |s| { + ( + s.total_files_processed.to_string(), + s.total_dirs_processed.to_string(), + bytes_size_to_string(s.total_bytes_processed), + ) + }, + ); + let id = match count { + 0 => format!("{}", sn.id), + count => format!("{} (+{})", sn.id, count), + }; + [ + id, + time.to_string(), + sn.hostname.clone(), + sn.label.clone(), + tags, + paths, + files, + dirs, + size, + ] } -impl PrintTable for SnapshotFile { - fn print_table(&self) { - let mut table = table(); +pub fn fill_table(snap: &SnapshotFile, mut add_entry: impl FnMut(&str, String)) { + add_entry("Snapshot", snap.id.to_hex().to_string()); + // note that if original was not set, it is set to snap.id by the load process + if snap.original != Some(snap.id) { + add_entry("Original ID", snap.original.unwrap().to_hex().to_string()); + } + add_entry("Time", snap.time.format("%Y-%m-%d %H:%M:%S").to_string()); + add_entry("Generated by", snap.program_version.clone()); + add_entry("Host", snap.hostname.clone()); + add_entry("Label", snap.label.clone()); + add_entry("Tags", snap.tags.formatln()); + let delete = match snap.delete { + DeleteOption::NotSet => "not set".to_string(), + DeleteOption::Never => "never".to_string(), + DeleteOption::After(t) => format!("after {}", t.format("%Y-%m-%d %H:%M:%S")), + }; + add_entry("Delete", delete); + add_entry("Paths", snap.paths.formatln()); + let parent = snap.parent.map_or_else( + || "no parent snapshot".to_string(), + |p| p.to_hex().to_string(), + ); + add_entry("Parent", parent); + if let Some(ref summary) = snap.summary { + add_entry("", String::new()); + add_entry("Command", summary.command.clone()); + + let source = format!( + "files: {} / dirs: {} / size: {}", + summary.total_files_processed, + summary.total_dirs_processed, + bytes_size_to_string(summary.total_bytes_processed) + ); + add_entry("Source", source); + add_entry("", String::new()); - let mut add_entry = |title: &str, value: String| { - _ = table.add_row([bold_cell(title), Cell::new(value)]); - }; + let files = format!( + "new: {:>10} / changed: {:>10} / unchanged: {:>10}", + summary.files_new, summary.files_changed, summary.files_unmodified, + ); + add_entry("Files", files); - add_entry("Snapshot", self.id.to_hex().to_string()); - // note that if original was not set, it is set to self.id by the load process - if self.original != Some(self.id) { - add_entry("Original ID", self.original.unwrap().to_hex().to_string()); - } - add_entry("Time", self.time.format("%Y-%m-%d %H:%M:%S").to_string()); - add_entry("Generated by", self.program_version.clone()); - add_entry("Host", self.hostname.clone()); - add_entry("Label", self.label.clone()); - add_entry("Tags", self.tags.formatln()); - let delete = match self.delete { - DeleteOption::NotSet => "not set".to_string(), - DeleteOption::Never => "never".to_string(), - DeleteOption::After(t) => format!("after {}", t.format("%Y-%m-%d %H:%M:%S")), - }; - add_entry("Delete", delete); - add_entry("Paths", self.paths.formatln()); - let parent = self.parent.map_or_else( - || "no parent snapshot".to_string(), - |p| p.to_hex().to_string(), + let trees = format!( + "new: {:>10} / changed: {:>10} / unchanged: {:>10}", + summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified, ); - add_entry("Parent", parent); - if let Some(ref summary) = self.summary { - add_entry("", String::new()); - add_entry("Command", summary.command.clone()); - - let source = format!( - "files: {} / dirs: {} / size: {}", - summary.total_files_processed, - summary.total_dirs_processed, - bytes_size_to_string(summary.total_bytes_processed) - ); - add_entry("Source", source); - add_entry("", String::new()); - - let files = format!( - "new: {:>10} / changed: {:>10} / unchanged: {:>10}", - summary.files_new, summary.files_changed, summary.files_unmodified, - ); - add_entry("Files", files); - - let trees = format!( - "new: {:>10} / changed: {:>10} / unchanged: {:>10}", - summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified, - ); - add_entry("Dirs", trees); - add_entry("", String::new()); - - let written = format!( - "data: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\ + add_entry("Dirs", trees); + add_entry("", String::new()); + + let written = format!( + "data: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\ tree: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\ total: {:>10} blobs / raw: {:>10} / packed: {:>10}", - summary.data_blobs, - bytes_size_to_string(summary.data_added_files), - bytes_size_to_string(summary.data_added_files_packed), - summary.tree_blobs, - bytes_size_to_string(summary.data_added_trees), - bytes_size_to_string(summary.data_added_trees_packed), - summary.tree_blobs + summary.data_blobs, - bytes_size_to_string(summary.data_added), - bytes_size_to_string(summary.data_added_packed), - ); - add_entry("Added to repo", written); - - let duration = format!( - "backup start: {} / backup end: {} / backup duration: {}\n\ - total duration: {}", - summary.backup_start.format("%Y-%m-%d %H:%M:%S"), - summary.backup_end.format("%Y-%m-%d %H:%M:%S"), - format_duration(std::time::Duration::from_secs_f64(summary.backup_duration)), - format_duration(std::time::Duration::from_secs_f64(summary.total_duration)) - ); - add_entry("Duration", duration); - } - if let Some(ref description) = self.description { - add_entry("Description", description.clone()); - } + summary.data_blobs, + bytes_size_to_string(summary.data_added_files), + bytes_size_to_string(summary.data_added_files_packed), + summary.tree_blobs, + bytes_size_to_string(summary.data_added_trees), + bytes_size_to_string(summary.data_added_trees_packed), + summary.tree_blobs + summary.data_blobs, + bytes_size_to_string(summary.data_added), + bytes_size_to_string(summary.data_added_packed), + ); + add_entry("Added to repo", written); - println!("{table}"); - println!(); + let duration = format!( + "backup start: {} / backup end: {} / backup duration: {}\n\ + total duration: {}", + summary.backup_start.format("%Y-%m-%d %H:%M:%S"), + summary.backup_end.format("%Y-%m-%d %H:%M:%S"), + format_duration(std::time::Duration::from_secs_f64(summary.backup_duration)), + format_duration(std::time::Duration::from_secs_f64(summary.total_duration)) + ); + add_entry("Duration", duration); + } + if let Some(ref description) = snap.description { + add_entry("Description", description.clone()); } } diff --git a/src/commands/tui.rs b/src/commands/tui.rs new file mode 100644 index 000000000..529b449ee --- /dev/null +++ b/src/commands/tui.rs @@ -0,0 +1,86 @@ +//! `tui` subcommand +mod snapshots; +mod widgets; + +use crossterm::event; +use snapshots::Snapshots; + +use crate::{Application, RUSTIC_APP}; + +use abscissa_core::{status_err, Command, Runnable, Shutdown}; + +use anyhow::Result; +use std::io; + +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::prelude::*; + +use crate::commands::open_repository; + +/// `tui` subcommand +#[derive(clap::Parser, Command, Debug)] +pub(crate) struct TuiCmd {} + +impl Runnable for TuiCmd { + fn run(&self) { + if let Err(err) = self.inner_run() { + status_err!("{}", err); + RUSTIC_APP.shutdown(Shutdown::Crash); + }; + } +} + +struct App { + snapshots: Snapshots, +} + +impl TuiCmd { + fn inner_run(&self) -> Result<()> { + let config = RUSTIC_APP.config(); + let repo = open_repository(&config.repository)?; + + // setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // create app and run it + let snapshots = Snapshots::new(repo, config.snapshot_filter.clone())?; + let app = App { snapshots }; + let res = run_app(&mut terminal, app); + + // restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{err:?}"); + } + + Ok(()) + } +} + +fn run_app(terminal: &mut Terminal, mut app: App) -> Result<()> { + loop { + _ = terminal.draw(|f| ui(f, &mut app))?; + let event = event::read()?; + app.snapshots.input(event)?; + } +} + +fn ui(f: &mut Frame<'_>, app: &mut App) { + let area = f.size(); + app.snapshots.draw(area, f); +} diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs new file mode 100644 index 000000000..97ea695e3 --- /dev/null +++ b/src/commands/tui/snapshots.rs @@ -0,0 +1,819 @@ +use std::{iter::once, str::FromStr}; + +use anyhow::Result; +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::{prelude::*, widgets::*}; +use rustic_core::{ + repofile::{DeleteOption, SnapshotFile}, + OpenStatus, Repository, StringList, +}; +use style::palette::tailwind; + +use crate::{ + commands::{ + snapshots::{fill_table, snap_to_table}, + tui::widgets::{ + Draw, PopUp, ProcessEvent, Prompt, PromptResult, SelectTable, SizedParagraph, + SizedTable, TextInput, TextInputResult, WithBlock, + }, + }, + config::progress_options::ProgressOptions, + filtering::SnapshotFilter, +}; + +// the widgets we are using and convenience builders +type PopUpInput = PopUp>; +fn popup_input(title: &'static str, text: &str, initial: &str) -> PopUpInput { + PopUp(WithBlock::new( + TextInput::new(text, initial), + Block::bordered().title(title), + )) +} + +type PopUpText = PopUp>; +fn popup_text(title: &'static str, text: Text<'static>) -> PopUpText { + PopUp(WithBlock::new( + SizedParagraph::new(text), + Block::bordered().title(title), + )) +} + +type PopUpTable = PopUp>; +fn popup_table(title: &'static str, content: Vec>>) -> PopUpTable { + PopUp(WithBlock::new( + SizedTable::new(content), + Block::bordered().title(title), + )) +} + +type PopUpPrompt = Prompt; +fn popup_prompt(title: &'static str, text: Text<'static>) -> PopUpPrompt { + Prompt(popup_text(title, text)) +} + +// the states this screen can be in +enum CurrentScreen { + Snapshots, + ShowHelp(PopUpText), + SnapshotDetails(PopUpTable), + EnterLabel(PopUpInput), + EnterAddTags(PopUpInput), + EnterSetTags(PopUpInput), + EnterRemoveTags(PopUpInput), + PromptWrite(PopUpPrompt), +} + +// status of each snapshot +#[derive(Clone, Copy, Default)] +struct SnapStatus { + marked: bool, + modified: bool, +} + +impl SnapStatus { + fn toggle_mark(&mut self) { + self.marked = !self.marked; + } +} + +#[derive(Debug)] +enum View { + Filter, + All, + Marked, + Modified, +} + +// struct to support collapsing of items +struct Collapser(Vec); + +impl Collapser { + fn iter(&self) -> CollapserIter<'_> { + CollapserIter { + index: 0, + showed_extended: false, + c: &self.0, + } + } + + fn collapse(&mut self, i: usize) { + self.0[i].collapsed = true; + } + + fn extend(&mut self, i: usize) { + if self.0[i].child_count > 0 { + self.0[i].collapsed = false; + } + } +} + +#[derive(Clone, Copy)] +struct CollapserItem { + child_count: usize, + collapsed: bool, +} + +struct CollapseInfo { + index: usize, + item: CollapserItem, +} + +impl CollapseInfo { + fn indices(&self) -> impl Iterator { + self.index..=(self.index + self.item.child_count) + } + fn collapsable(&self) -> bool { + !self.item.collapsed + } + fn extendable(&self) -> bool { + self.item.collapsed && self.item.child_count > 0 + } +} + +// an iterator to iterator over a Collapser +struct CollapserIter<'a> { + index: usize, + showed_extended: bool, + c: &'a [CollapserItem], +} + +impl CollapserIter<'_> { + fn increase_index(&mut self, inc: usize) { + self.index += inc; + } +} + +impl Iterator for CollapserIter<'_> { + type Item = CollapseInfo; + + fn next(&mut self) -> Option { + let index = self.index; + + let mut item = *match self.c.get(index) { + None => return None, + Some(item) => item, + }; + + if item.collapsed { + self.increase_index(item.child_count + 1); + } else if !self.showed_extended { + self.showed_extended = true; + } else { + self.increase_index(1); + self.showed_extended = false; + item.collapsed = false; + item.child_count = 0; + } + + Some(CollapseInfo { index, item }) + } +} + +const INFO_TEXT: &str = + "(Esc) quit | (F5) reload snaphots | (v) toggle view | (i) show snapshot | (?) show all commands"; + +const HELP_TEXT: &str = r#" +General Commands: + + q,Esc : exit + F5 : re-read all snapshots from repository + v : toggle snapshot view [Filtered -> All -> Marked -> Modified] + i : show detailed snapshot information for selected snapshot + w : write modified snapshots + ? : show this help page + +Commands for marking snapshot(s): + + x : toggle marking for selected snapshot + X : toggle markings for all snapshots + Ctrl-x : clear all markings + +Commands applied to marked snapshot(s) (selected if none marked): + + l : set label for snapshot(s) + Ctrl-l : remove label for snapshot(s) + t : add tag(s) for snapshot(s) + Ctrl-t : remove all tags for snapshot(s) + s : set tag(s) for snapshot(s) + r : remove tag(s) for snapshot(s) + p : set delete protection for snapshot(s) + Ctrl-p : remove delete protection for snapshot(s) + "#; + +pub(crate) struct Snapshots { + current_screen: CurrentScreen, + current_view: View, + table: WithBlock, + repo: Repository, + snaps_status: Vec, + snapshots: Vec, + original_snapshots: Vec, + snaps_selection: Vec, + snaps_collapse: Collapser, //position in snaps_selection and count + filter: SnapshotFilter, + default_filter: SnapshotFilter, +} + +impl Snapshots { + pub fn new( + repo: Repository, + filter: SnapshotFilter, + ) -> Result { + let mut snapshots = repo.get_all_snapshots()?; + snapshots.sort_unstable(); + + let header = [ + "", " ID", "Time", "Host", "Label", "Tags", "Paths", "Files", "Dirs", "Size", + ] + .into_iter() + .map(Text::from) + .collect(); + + let mut app = Self { + current_screen: CurrentScreen::Snapshots, + current_view: View::Filter, + table: WithBlock::new(SelectTable::new(header), Block::new()), + repo, + snaps_status: vec![SnapStatus::default(); snapshots.len()], + original_snapshots: snapshots.clone(), + snapshots, + snaps_selection: Vec::new(), + snaps_collapse: Collapser(Vec::new()), + default_filter: filter.clone(), + filter, + }; + app.apply_filter(); + Ok(app) + } + + fn selected_collapse_info(&self) -> Option { + self.table + .widget + .selected() + .and_then(|selected| self.snaps_collapse.iter().nth(selected)) + } + + fn snap_idx(&self) -> Vec { + self.selected_collapse_info() + .iter() + .flat_map(CollapseInfo::indices) + .collect() + } + + pub fn has_mark(&self) -> bool { + self.snaps_status.iter().any(|s| s.marked) + } + + pub fn has_modified(&self) -> bool { + self.snaps_status.iter().any(|s| s.modified) + } + + pub fn toggle_view_mark(&mut self) { + match self.current_view { + View::Filter => self.current_view = View::All, + View::All => { + self.current_view = View::Marked; + if !self.has_mark() { + self.toggle_view_mark(); + } + } + View::Marked => { + self.current_view = View::Modified; + if !self.has_modified() { + self.toggle_view_mark(); + } + } + View::Modified => self.current_view = View::Filter, + } + } + + pub fn toggle_view(&mut self) { + self.toggle_view_mark(); + self.apply_filter(); + } + + pub fn apply_filter(&mut self) { + // remember current snapshot index + let snap_id = self + .snap_idx() + .first() + .map(|i| self.snapshots[self.snaps_selection[*i]].id); + // select snapshots to show + self.snaps_selection = self + .snapshots + .iter() + .enumerate() + .zip(self.snaps_status.iter()) + .filter_map(|((i, sn), status)| { + match self.current_view { + View::All => true, + View::Filter => self.filter.matches(sn), + View::Marked => status.marked, + View::Modified => status.modified, + } + .then_some(i) + }) + .collect(); + let len = self.snaps_selection.len(); + + // collapse snapshots with identical treeid + // we reverse iter snaps_selection as we need to count identical ids + let mut id = None; + let mut collapse = Vec::new(); + let mut same_tree: Vec = Vec::new(); + for i in &self.snaps_selection { + let tree_id = self.snapshots[*i].tree; + if id.is_some_and(|id| tree_id != id) { + same_tree[0].child_count = same_tree.len() - 1; + collapse.append(&mut same_tree); + } + id = Some(tree_id); + same_tree.push(CollapserItem { + child_count: 0, + collapsed: true, + }); + } + same_tree[0].child_count = same_tree.len() - 1; + collapse.append(&mut same_tree); + self.snaps_collapse = Collapser(collapse); + + self.update_table(); + + if len != 0 { + let selected = self + .snaps_collapse + .iter() + .position(|info| { + Some(self.snapshots[self.snaps_selection[info.index]].id) == snap_id + }) + .unwrap_or(len - 1); + + self.table.widget.set_to(selected); + } + } + + fn snap_row(&self, info: CollapseInfo) -> Vec> { + let idx = info.index; + let snap_id = self.snaps_selection[idx]; + let snap = &self.snapshots[snap_id]; + let symbols = match ( + snap.delete == DeleteOption::NotSet, + snap.description.is_none(), + ) { + (true, true) => "", + (true, false) => "🗎", + (false, true) => "🛡", + (false, false) => "🛡 🗎", + }; + let mark = if info + .indices() + .all(|i| self.snaps_status[self.snaps_selection[i]].marked) + { + "X" + } else if info + .indices() + .all(|i| !self.snaps_status[self.snaps_selection[i]].marked) + { + " " + } else { + "*" + }; + let modified = if info + .indices() + .any(|i| self.snaps_status[self.snaps_selection[i]].modified) + { + "*" + } else { + " " + }; + let count = info.item.child_count; + let collapse = match (info.item.collapsed, info.item.child_count) { + (_, 0) => "", + (true, _) => ">", + (false, _) => "v", + }; + once(&mark.to_string()) + .chain(snap_to_table(snap, count).iter()) + .cloned() + .enumerate() + .map(|(i, mut content)| { + if i == 1 { + // ID gets modified and protected marks + content = format!("{collapse}{modified}{content}{symbols}"); + } + Text::from(content) + }) + .collect() + } + + pub fn update_table(&mut self) { + let max_tags = self + .snaps_selection + .iter() + .map(|&i| self.snapshots[i].tags.iter().count()) + .max() + .unwrap_or(1); + let max_paths = self + .snaps_selection + .iter() + .map(|&i| self.snapshots[i].paths.iter().count()) + .max() + .unwrap_or(1); + let height = max_tags.max(max_paths).max(1) + 1; + + let mut rows = Vec::new(); + for collapse_info in self.snaps_collapse.iter() { + let row = self.snap_row(collapse_info); + rows.push(row); + } + + self.table.widget.set_content(rows, height); + self.table.block = Block::new() + .borders(Borders::BOTTOM) + .title_bottom(format!( + "{:?} view: {}, total: {}, marked: {}, modified: {}, ", + self.current_view, + self.snaps_selection.len(), + self.snapshots.len(), + self.count_marked_snaps(), + self.count_modified_snaps(), + )) + .title_alignment(Alignment::Center); + } + + pub fn toggle_mark(&mut self) { + for snap_idx in self.snap_idx() { + self.snaps_status[self.snaps_selection[snap_idx]].toggle_mark(); + } + self.update_table(); + } + + pub fn toggle_mark_all(&mut self) { + for snap_idx in &self.snaps_selection { + self.snaps_status[*snap_idx].toggle_mark(); + } + self.update_table(); + } + + pub fn clear_marks(&mut self) { + for status in self.snaps_status.iter_mut() { + status.marked = false; + } + self.update_table(); + } + + pub fn clear_filter(&mut self) { + self.filter = SnapshotFilter::default(); + self.apply_filter(); + } + + pub fn reset_filter(&mut self) { + self.filter = self.default_filter.clone(); + self.apply_filter(); + } + + pub fn collapse(&mut self) { + if let Some(info) = self.selected_collapse_info() { + if info.collapsable() { + self.snaps_collapse.collapse(info.index); + self.update_table(); + } + } + } + + pub fn extend(&mut self) { + if let Some(info) = self.selected_collapse_info() { + if info.extendable() { + self.snaps_collapse.extend(info.index); + self.update_table(); + } + } + } + + pub fn snapshot_details(&self) -> PopUpTable { + let mut rows = Vec::new(); + if let Some(info) = self.selected_collapse_info() { + let snap = &self.snapshots[info.index]; + fill_table(snap, |title, value| { + rows.push(vec![Text::from(title.to_string()), Text::from(value)]); + }); + } + popup_table("snapshot details", rows) + } + + pub fn count_marked_snaps(&self) -> usize { + self.snaps_status.iter().filter(|s| s.marked).count() + } + + pub fn count_modified_snaps(&self) -> usize { + self.snaps_status.iter().filter(|s| s.modified).count() + } + + // process marked snapshots (or the current one if none is marked) + // the process function must return true if it modified the snapshot, else false + pub fn process_marked_snaps(&mut self, mut process: impl FnMut(&mut SnapshotFile) -> bool) { + let has_mark = self.has_mark(); + + if !has_mark { + self.toggle_mark(); + } + + for ((snap, status), original_snap) in self + .snapshots + .iter_mut() + .zip(self.snaps_status.iter_mut()) + .zip(self.original_snapshots.iter()) + { + if status.marked && process(snap) { + // Note that snap impls Eq, but only by comparing the time! + status.modified = serde_json::to_string(snap).unwrap() + != serde_json::to_string(original_snap).unwrap(); + } + } + + if !has_mark { + self.toggle_mark(); + } + self.apply_filter(); + } + + pub fn get_label(&mut self) -> String { + let has_mark = self.has_mark(); + + if !has_mark { + self.toggle_mark(); + } + + let label = self + .snapshots + .iter() + .zip(self.snaps_status.iter()) + .filter_map(|(snap, status)| status.marked.then_some(snap.label.clone())) + .reduce(|label, l| if label == l { l } else { String::new() }) + .unwrap_or_default(); + + if !has_mark { + self.toggle_mark(); + } + label + } + + pub fn get_tags(&mut self) -> String { + let has_mark = self.has_mark(); + + if !has_mark { + self.toggle_mark(); + } + + let label = self + .snapshots + .iter() + .zip(self.snaps_status.iter()) + .filter_map(|(snap, status)| status.marked.then_some(snap.tags.formatln())) + .reduce(|tags, t| if tags == t { t } else { String::new() }) + .unwrap_or_default(); + + if !has_mark { + self.toggle_mark(); + } + label + } + + pub fn set_label(&mut self, label: String) { + self.process_marked_snaps(|snap| { + if snap.label == label { + return false; + } + snap.label = label.clone(); + true + }); + } + + pub fn clear_label(&mut self) { + self.set_label(String::new()); + } + + pub fn add_tags(&mut self, tags: String) { + let tags = vec![StringList::from_str(&tags).unwrap()]; + self.process_marked_snaps(|snap| snap.add_tags(tags.clone())); + } + + pub fn set_tags(&mut self, tags: String) { + let tags = vec![StringList::from_str(&tags).unwrap()]; + self.process_marked_snaps(|snap| snap.set_tags(tags.clone())); + } + + pub fn remove_tags(&mut self, tags: String) { + let tags = vec![StringList::from_str(&tags).unwrap()]; + self.process_marked_snaps(|snap| snap.remove_tags(&tags)); + } + + pub fn clear_tags(&mut self) { + let no_tags = vec![StringList::default()]; + self.process_marked_snaps(|snap| snap.set_tags(no_tags.clone())); + } + + pub fn set_delete_to(&mut self, delete: DeleteOption) { + self.process_marked_snaps(|snap| { + if snap.delete == delete { + return false; + } + snap.delete = delete; + true + }); + } + + pub fn apply_input(&mut self, input: String) { + match self.current_screen { + CurrentScreen::EnterLabel(_) => self.set_label(input), + CurrentScreen::EnterAddTags(_) => self.add_tags(input), + CurrentScreen::EnterSetTags(_) => self.set_tags(input), + CurrentScreen::EnterRemoveTags(_) => self.remove_tags(input), + _ => {} + } + } + + pub fn set_delete_protection(&mut self) { + self.set_delete_to(DeleteOption::Never); + } + + pub fn clear_delete_protection(&mut self) { + self.set_delete_to(DeleteOption::NotSet); + } + + pub fn write(&mut self) -> Result<()> { + if !self.has_modified() { + return Ok(()); + }; + + let save_snaps: Vec<_> = self + .snapshots + .iter() + .zip(self.snaps_status.iter()) + .filter_map(|(snap, status)| status.modified.then_some(snap)) + .cloned() + .collect(); + let old_snap_ids: Vec<_> = save_snaps.iter().map(|sn| sn.id).collect(); + self.repo.save_snapshots(save_snaps)?; + self.repo.delete_snapshots(&old_snap_ids)?; + // re-read snapshots + self.reread() + } + + // re-read all snapshots + pub fn reread(&mut self) -> Result<()> { + self.snapshots = self.repo.get_all_snapshots()?; + self.snapshots.sort_unstable(); + for status in self.snaps_status.iter_mut() { + status.modified = false; + } + self.original_snapshots = self.snapshots.clone(); + self.table.widget.select(None); + self.apply_filter(); + Ok(()) + } + + pub fn input(&mut self, event: Event) -> Result<()> { + use KeyCode::*; + match &mut self.current_screen { + CurrentScreen::Snapshots => { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if key.modifiers == KeyModifiers::CONTROL { + match key.code { + Char('x') => self.clear_marks(), + Char('f') => self.clear_filter(), + Char('l') => self.clear_label(), + Char('t') => self.clear_tags(), + Char('p') => self.clear_delete_protection(), + _ => {} + } + } else { + match key.code { + Char('q') | Esc => return Ok(()), + F(5) => self.reread()?, + Right => self.extend(), + Left => self.collapse(), + Char('?') => { + self.current_screen = CurrentScreen::ShowHelp(popup_text( + "help", + HELP_TEXT.into(), + )); + } + Char('x') => { + self.toggle_mark(); + self.table.widget.next(); + } + Char('X') => self.toggle_mark_all(), + Char('F') => self.reset_filter(), + Char('v') => self.toggle_view(), + Char('i') => { + self.current_screen = + CurrentScreen::SnapshotDetails(self.snapshot_details()); + } + Char('l') => { + self.current_screen = CurrentScreen::EnterLabel(popup_input( + "set label", + "enter label", + &self.get_label(), + )); + } + Char('t') => { + self.current_screen = CurrentScreen::EnterAddTags(popup_input( + "add tags", + "enter tags", + "", + )); + } + Char('s') => { + self.current_screen = CurrentScreen::EnterSetTags(popup_input( + "set tags", + "enter tags", + &self.get_tags(), + )); + } + Char('r') => { + self.current_screen = CurrentScreen::EnterRemoveTags( + popup_input("remove tags", "enter tags", ""), + ); + } + // TODO: Allow to enter delete protection option + Char('p') => self.set_delete_protection(), + Char('w') => { + let msg = format!( + "Do you want to write {} modified snapshots?", + self.count_modified_snaps() + ); + self.current_screen = CurrentScreen::PromptWrite(popup_prompt( + "write snapshots", + msg.into(), + )); + } + _ => self.table.input(event), + } + } + } + _ => {} + } + } + CurrentScreen::SnapshotDetails(_) | CurrentScreen::ShowHelp(_) => match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if matches!( + key.code, + Char('q') | Esc | Enter | Char(' ') | Char('i') | Char('?') + ) { + self.current_screen = CurrentScreen::Snapshots; + } + } + _ => {} + }, + CurrentScreen::EnterLabel(prompt) + | CurrentScreen::EnterAddTags(prompt) + | CurrentScreen::EnterSetTags(prompt) + | CurrentScreen::EnterRemoveTags(prompt) => match prompt.input(event) { + TextInputResult::Cancel => self.current_screen = CurrentScreen::Snapshots, + TextInputResult::Input(input) => { + self.apply_input(input); + self.current_screen = CurrentScreen::Snapshots; + } + TextInputResult::None => {} + }, + CurrentScreen::PromptWrite(prompt) => match prompt.input(event) { + PromptResult::Ok => { + self.write()?; + self.current_screen = CurrentScreen::Snapshots; + } + PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshots, + PromptResult::None => {} + }, + } + Ok(()) + } + + pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area); + + // draw the table + self.table.draw(rects[0], f); + + // draw the footer + let buffer_bg = tailwind::SLATE.c950; + let row_fg = tailwind::SLATE.c200; + let info_footer = Paragraph::new(Line::from(INFO_TEXT)) + .style(Style::new().fg(row_fg).bg(buffer_bg)) + .centered(); + f.render_widget(info_footer, rects[1]); + + // draw popups + match &mut self.current_screen { + CurrentScreen::Snapshots => {} + CurrentScreen::SnapshotDetails(popup) => popup.draw(area, f), + CurrentScreen::ShowHelp(popup) => popup.draw(area, f), + CurrentScreen::EnterLabel(popup) + | CurrentScreen::EnterAddTags(popup) + | CurrentScreen::EnterSetTags(popup) + | CurrentScreen::EnterRemoveTags(popup) => popup.draw(area, f), + CurrentScreen::PromptWrite(popup) => popup.draw(area, f), + } + } +} diff --git a/src/commands/tui/widgets.rs b/src/commands/tui/widgets.rs new file mode 100644 index 000000000..c0d6abcfe --- /dev/null +++ b/src/commands/tui/widgets.rs @@ -0,0 +1,38 @@ +mod popup; +mod prompt; +mod select_table; +mod sized_paragraph; +mod sized_table; +mod text_input; +mod with_block; + +pub use popup::*; +pub use prompt::*; +pub use select_table::*; +pub use sized_paragraph::*; +pub use sized_table::*; +pub use text_input::*; +pub use with_block::*; + +use crossterm::event::Event; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::prelude::*; +use ratatui::widgets::*; + +pub trait ProcessEvent { + type Result; + fn input(&mut self, event: Event) -> Self::Result; +} + +pub trait SizedWidget { + fn height(&self) -> Option { + None + } + fn width(&self) -> Option { + None + } +} + +pub trait Draw { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>); +} diff --git a/src/commands/tui/widgets/popup.rs b/src/commands/tui/widgets/popup.rs new file mode 100644 index 000000000..d861bd5b4 --- /dev/null +++ b/src/commands/tui/widgets/popup.rs @@ -0,0 +1,38 @@ +use super::*; + +// Make a popup from a SizedWidget +pub struct PopUp(pub T); + +impl ProcessEvent for PopUp { + type Result = T::Result; + fn input(&mut self, event: Event) -> Self::Result { + self.0.input(event) + } +} + +impl Draw for PopUp { + fn draw(&mut self, mut area: Rect, f: &mut Frame<'_>) { + // center vertically + if let Some(h) = self.0.height() { + let layout = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(h), + Constraint::Min(1), + ]); + area = layout.split(area)[1]; + } + + // center horizontally + if let Some(w) = self.0.width() { + let layout = Layout::horizontal([ + Constraint::Min(1), + Constraint::Length(w), + Constraint::Min(1), + ]); + area = layout.split(area)[1]; + } + + f.render_widget(Clear, area); + self.0.draw(area, f); + } +} diff --git a/src/commands/tui/widgets/prompt.rs b/src/commands/tui/widgets/prompt.rs new file mode 100644 index 000000000..7b177846b --- /dev/null +++ b/src/commands/tui/widgets/prompt.rs @@ -0,0 +1,39 @@ +use super::*; + +pub struct Prompt(pub T); + +pub enum PromptResult { + Ok, + Cancel, + None, +} + +impl SizedWidget for Prompt { + fn height(&self) -> Option { + self.0.height() + } + fn width(&self) -> Option { + self.0.width() + } +} + +impl Draw for Prompt { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + self.0.draw(area, f); + } +} + +impl ProcessEvent for Prompt { + type Result = PromptResult; + fn input(&mut self, event: Event) -> PromptResult { + use KeyCode::*; + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + Char('q') | Char('n') | Char('c') | Esc => PromptResult::Cancel, + Enter | Char('y') | Char('j') | Char(' ') => PromptResult::Ok, + _ => PromptResult::None, + }, + _ => PromptResult::None, + } + } +} diff --git a/src/commands/tui/widgets/select_table.rs b/src/commands/tui/widgets/select_table.rs new file mode 100644 index 000000000..8d3066149 --- /dev/null +++ b/src/commands/tui/widgets/select_table.rs @@ -0,0 +1,196 @@ +use super::*; +use std::iter::once; +use style::palette::tailwind; + +struct TableColors { + buffer_bg: Color, + header_bg: Color, + header_fg: Color, + row_fg: Color, + selected_style_fg: Color, + normal_row_color: Color, + alt_row_color: Color, +} + +impl TableColors { + fn new(color: &tailwind::Palette) -> Self { + Self { + buffer_bg: tailwind::SLATE.c950, + header_bg: color.c900, + header_fg: tailwind::SLATE.c200, + row_fg: tailwind::SLATE.c200, + selected_style_fg: color.c400, + normal_row_color: tailwind::SLATE.c950, + alt_row_color: tailwind::SLATE.c900, + } + } +} + +pub struct SelectTable { + header: Vec>, + table: Table<'static>, + state: TableState, + scroll_state: ScrollbarState, + rows: usize, + rows_display: usize, + row_height: usize, +} + +impl SelectTable { + pub fn new(header: Vec>) -> Self { + let table = Table::default(); + + Self { + header, + table, + state: TableState::default(), + scroll_state: ScrollbarState::new(0), + rows: 0, + rows_display: 0, + row_height: 0, + } + } + + pub fn set_content(&mut self, content: Vec>>, row_height: usize) { + let colors = TableColors::new(&tailwind::BLUE); + let selected_style = Style::default() + .add_modifier(Modifier::REVERSED) + .fg(colors.selected_style_fg); + + let header_style = Style::default().fg(colors.header_fg).bg(colors.header_bg); + + self.row_height = row_height; + let widths = once(&self.header) + .chain(content.iter()) + .map(|row| row.iter().map(Text::width).collect()) + .reduce(|widths: Vec, row| { + row.iter() + .zip(widths.iter()) + .map(|(r, w)| r.max(w)) + .cloned() + .collect() + }) + .unwrap_or_default(); + + self.rows = content.len(); + self.scroll_state = ScrollbarState::new(self.rows * self.row_height); + + let content = content.into_iter().enumerate().map(|(i, row)| { + let color = match i % 2 { + 0 => colors.normal_row_color, + _ => colors.alt_row_color, + }; + Row::new(row) + .style(Style::new().fg(colors.row_fg).bg(color)) + .height(self.row_height.try_into().unwrap()) + }); + + self.table = Table::default() + .header(Row::new(self.header.clone()).style(header_style)) + .highlight_style(selected_style) + .bg(colors.buffer_bg) + .widths(widths.iter().map(|w| { + (*w).try_into() + .ok() + .map_or(Constraint::Min(0), Constraint::Length) + })) + .flex(layout::Flex::SpaceBetween) + .rows(content); + } + + pub fn selected(&self) -> Option { + self.state.selected() + } + + pub fn select(&mut self, index: Option) { + self.state.select(index); + } + + pub fn set_to(&mut self, i: usize) { + self.state.select(Some(i)); + self.scroll_state = self.scroll_state.position(i * self.row_height); + } + + pub fn go_forward(&mut self, step: usize) { + if let Some(selected_old) = self.state.selected() { + let selected = (selected_old + step).min(self.rows - 1); + self.set_to(selected); + } + } + + pub fn go_back(&mut self, step: usize) { + if let Some(selected_old) = self.state.selected() { + let selected = selected_old.saturating_sub(step); + self.set_to(selected); + } + } + + pub fn next(&mut self) { + self.go_forward(1); + } + + pub fn page_down(&mut self) { + self.go_forward(self.rows_display); + } + + pub fn previous(&mut self) { + self.go_back(1); + } + + pub fn page_up(&mut self) { + self.go_back(self.rows_display); + } + + pub fn home(&mut self) { + if self.state.selected().is_some() { + self.set_to(0); + } + } + + pub fn end(&mut self) { + if self.state.selected().is_some() { + self.set_to(self.rows - 1); + } + } + + pub fn set_rows(&mut self, rows: usize) { + self.rows_display = rows / self.row_height; + } +} + +impl ProcessEvent for SelectTable { + type Result = (); + fn input(&mut self, event: Event) { + use KeyCode::*; + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + Down => self.next(), + Up => self.previous(), + PageDown => self.page_down(), + PageUp => self.page_up(), + Home => self.home(), + End => self.end(), + _ => {} + }, + _ => {} + } + } +} + +impl SizedWidget for SelectTable {} + +impl Draw for SelectTable { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + self.set_rows(area.height.into()); + let chunks = Layout::horizontal([Constraint::Min(0), Constraint::Length(1)]).split(area); + f.render_stateful_widget(&self.table, chunks[0], &mut self.state); + f.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None), + chunks[1], + &mut self.scroll_state, + ); + } +} diff --git a/src/commands/tui/widgets/sized_paragraph.rs b/src/commands/tui/widgets/sized_paragraph.rs new file mode 100644 index 000000000..391abd26d --- /dev/null +++ b/src/commands/tui/widgets/sized_paragraph.rs @@ -0,0 +1,31 @@ +use super::*; + +pub struct SizedParagraph { + p: Paragraph<'static>, + height: Option, + width: Option, +} + +impl SizedParagraph { + pub fn new(text: Text<'static>) -> Self { + let height = text.height().try_into().ok(); + let width = text.width().try_into().ok(); + let p = Paragraph::new(text); + Self { p, height, width } + } +} + +impl SizedWidget for SizedParagraph { + fn width(&self) -> Option { + self.width + } + fn height(&self) -> Option { + self.height + } +} + +impl Draw for SizedParagraph { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + f.render_widget(&self.p, area); + } +} diff --git a/src/commands/tui/widgets/sized_table.rs b/src/commands/tui/widgets/sized_table.rs new file mode 100644 index 000000000..506c0f48e --- /dev/null +++ b/src/commands/tui/widgets/sized_table.rs @@ -0,0 +1,63 @@ +use super::*; + +pub struct SizedTable { + table: Table<'static>, + height: usize, + width: usize, +} + +impl SizedTable { + pub fn new(content: Vec>>) -> Self { + let height = content + .iter() + .map(|row| row.iter().map(Text::height).max().unwrap_or_default()) + .sum::(); + + let widths = content + .iter() + .map(|row| row.iter().map(Text::width).collect()) + .reduce(|widths: Vec, row| { + row.iter() + .zip(widths.iter()) + .map(|(r, w)| r.max(w)) + .cloned() + .collect() + }) + .unwrap_or_default(); + + let width = widths + .iter() + .cloned() + .reduce(|width, w| width + w + 1) // +1 because of space between entries + .unwrap_or_default(); + + let rows = content.into_iter().map(Row::new); + let table = Table::default() + .widths(widths.iter().map(|w| { + (*w).try_into() + .ok() + .map_or(Constraint::Min(0), Constraint::Length) + })) + .rows(rows); + Self { + table, + height, + width, + } + } +} + +impl SizedWidget for SizedTable { + fn height(&self) -> Option { + self.height.try_into().ok() + } + fn width(&self) -> Option { + self.width.try_into().ok() + } +} + +impl Draw for SizedTable { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + f.render_widget(&self.table, area); + } +} diff --git a/src/commands/tui/widgets/text_input.rs b/src/commands/tui/widgets/text_input.rs new file mode 100644 index 000000000..be7950127 --- /dev/null +++ b/src/commands/tui/widgets/text_input.rs @@ -0,0 +1,57 @@ +use super::*; + +use tui_textarea::TextArea; + +pub struct TextInput { + textarea: TextArea<'static>, +} + +pub enum TextInputResult { + Cancel, + Input(String), + None, +} + +impl TextInput { + pub fn new(text: &str, initial: &str) -> Self { + let mut textarea = TextArea::default(); + textarea.set_style(Style::default()); + textarea.set_placeholder_text(text); + _ = textarea.insert_str(initial); + Self { textarea } + } +} + +impl SizedWidget for TextInput { + fn height(&self) -> Option { + Some(1) + } +} + +impl Draw for TextInput { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + f.render_widget(self.textarea.widget(), area); + } +} + +impl ProcessEvent for TextInput { + type Result = TextInputResult; + fn input(&mut self, event: Event) -> TextInputResult { + if let Event::Key(key) = event { + if key.kind != KeyEventKind::Press { + return TextInputResult::None; + } + use KeyCode::*; + match key { + KeyEvent { code: Esc, .. } => return TextInputResult::Cancel, + KeyEvent { code: Enter, .. } => { + return TextInputResult::Input(self.textarea.lines()[0].clone()); + } + key => { + _ = self.textarea.input(key); + } + } + } + TextInputResult::None + } +} diff --git a/src/commands/tui/widgets/with_block.rs b/src/commands/tui/widgets/with_block.rs new file mode 100644 index 000000000..7a8b4b126 --- /dev/null +++ b/src/commands/tui/widgets/with_block.rs @@ -0,0 +1,57 @@ +use super::*; +use layout::Size; + +pub struct WithBlock { + pub block: Block<'static>, + pub widget: T, +} + +impl WithBlock { + pub fn new(widget: T, block: Block<'static>) -> Self { + Self { block, widget } + } + + // Note: this could be a method of self.block, but is unfortunately not present + // So we compute ourselves using self.block.inner() on an artificial Rect. + fn size_diff(&self) -> Size { + let rect = Rect { + x: 0, + y: 0, + width: u16::MAX, + height: u16::MAX, + }; + let inner = self.block.inner(rect); + Size { + width: rect.as_size().width - inner.as_size().width, + height: rect.as_size().height - inner.as_size().height, + } + } +} + +impl ProcessEvent for WithBlock { + type Result = T::Result; + fn input(&mut self, event: Event) -> Self::Result { + self.widget.input(event) + } +} + +impl SizedWidget for WithBlock { + fn height(&self) -> Option { + self.widget + .height() + .map(|h| h.saturating_add(self.size_diff().height)) + } + + fn width(&self) -> Option { + self.widget + .width() + .map(|w| w.saturating_add(self.size_diff().width)) + } +} + +impl Draw for WithBlock { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + f.render_widget(self.block.clone(), area); + self.widget.draw(self.block.inner(area), f); + } +} diff --git a/src/config.rs b/src/config.rs index 4d0875851..634ba54b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -141,6 +141,11 @@ pub struct GlobalOptions { #[merge(strategy = merge::vec::append)] pub use_profile: Vec, + /// Run rustic in interactive UI mode + #[clap(long, short, global = true, env = "RUSTIC_INTERACTIVE")] + #[merge(strategy = merge::bool::overwrite_false)] + pub interactive: bool, + /// Only show what would be done without modifying anything. Does not affect read-only commands. #[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")] #[merge(strategy = merge::bool::overwrite_false)]