From 31856c02a116e375790a76fc1470cebd8c245f35 Mon Sep 17 00:00:00 2001 From: Fritz Rehde Date: Sun, 4 Feb 2024 12:26:57 +1100 Subject: [PATCH] feat: added support for user-defined keybinding descriptions (#97) - New TOML keybinding syntax was introduced, which allows specifying optional descriptions for each keybinding, as well as shorter syntax for specifying the operations. - The UI of the help menu has been revamped. It now displays the keybindings hashmap, as well as the env variables, in an ascii table. - A new config setting `keybindings-help-menu-format` has been introduced, which allows users to set exactly which columns should be shown (key, description, operations), and in which order. --- Cargo.lock | 4 +- README.md | 31 +- examples/file-manager.toml | 35 ++- examples/ls.toml | 10 +- src/config/fields/field_selection.rs | 1 + src/config/keybindings/help_menu_format.rs | 33 ++ src/config/keybindings/mod.rs | 335 +++++++++++++++++---- src/config/mod.rs | 154 +++++----- src/config/table.rs | 113 +++++++ src/ui/mod.rs | 9 +- src/ui/state/env_variables/mod.rs | 46 ++- src/ui/state/help_menu.rs | 43 +-- src/ui/state/mod.rs | 11 +- 13 files changed, 614 insertions(+), 211 deletions(-) create mode 100644 src/config/keybindings/help_menu_format.rs create mode 100644 src/config/table.rs diff --git a/Cargo.lock b/Cargo.lock index c96c886..5138330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,9 +574,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.1.0" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520" dependencies = [ "equivalent", "hashbrown", diff --git a/README.md b/README.md index 7e66018..47ca879 100644 --- a/README.md +++ b/README.md @@ -105,20 +105,25 @@ The operations are separated by `+` and executed in succession (one after the ot #### Via TOML Config File -In a TOML config file, specify keybindings like so: +In a TOML config file, there are several different ways you can specify keybindings: ```toml [keybindings] -"KEY" = [ "OP" ] -"KEY" = [ "OP", "OP", "OP" ] -"KEY" = [ - "OP", - "OP" -] +# Single operation, without description +"KEY" = "OP" + +# Single operation, with description +"KEY" = { description = "DESC", operations = "OP" } + +# Multiple operations, without description +"KEY" = [ "OP_1", "OP_2", "OP_3" ] + +# Multiple operations, with description +"KEY" = { description = "DESC", operations = [ "OP_1", "OP_2", "OP_3" ] } ``` This syntax differs from the command-line syntax because using the TOML array feature is more expressive and more native to the TOML file format. -Furthermore, this allows you to use the `+` character in your commands. -It also doesn't require escaping shell specific characters like `$` in (read more [in this section](#subshell)). +Furthermore, this allows you to use the `+` character in your commands, which is not possible with the CLI arguments (since that character is interpreted as a separator). +It also doesn't require escaping shell specific characters like `$` (read more about subshell behaviour [here](#subshell)). You can find some keybinding examples in the [`examples/`](examples/) directory. @@ -193,7 +198,7 @@ Operation | Description `exec tui -- ` | Execute a `TUI-CMD` that spawns a TUI (e.g. text editor). Watchbind's own TUI is replaced with `TUI-CMD`'s TUI until `TUI-CMD` terminates. Note that `TUI-CMD` must spawn a full-screen TUI that covers the entire terminal, otherwise undefined behaviour will ensue. `set-env -- ` | Blockingly execute `CMD`, and save its output to the environment variable `ENV`. `unset-env -- ` | Unsets environment variable `ENV`. -`help-[show\|hide\|toggle]` | \[Show\|Hide\|Toggle\] the help menu that shows all activated keybindings. +`help-[show\|hide\|toggle]` | \[Show\|Hide\|Toggle the visibility of\] the help menu. All `CMD` and `TUI-CMD` shell commands will be executed in a subshell (i.e. `sh -c "CMD"`) that has some environment variables set. The environment variable `$line` is set to the line the cursor is on. @@ -264,6 +269,12 @@ The `set-env` and `unset-env` operations allow you to manage state through envir Additionally, you can use the `initial-env` option to specify a list of `set-env` commands that will be executed **before** the first execution of the watched command. This powerful combination allows you to set some initial state with `initial-env`, reference that state directly in the watched command, and update the state with keybindings at runtime with `set-env`. +### Help menu + +Watchbind supports a help menu that displays: +- All environment variables set by `set-env` commands along with their values. +- All keybindings along with the operations and the description they are mapped to, though you can customize what exactly gets displayed with `--keybindings-help-menu-format`. + ## Tips diff --git a/examples/file-manager.toml b/examples/file-manager.toml index 1f8a401..b4aed2c 100644 --- a/examples/file-manager.toml +++ b/examples/file-manager.toml @@ -3,10 +3,10 @@ # Note: the `printf`s used in some commands are necessary to remove the newlines produced by their nested commands # Executed before the watched command is executed -initial-env = [ '''set-env pwd -- printf "$(pwd)"''' ] +initial-env = ['set-env pwd -- printf "$(pwd)"'] # All env variables are set as env variables in subshell where watched command is executed -watched-command = '''echo "$pwd"; ls "$pwd"''' +watched-command = 'echo "$pwd"; ls "$pwd"' header-lines = 1 # Since we reload after each operation that changes the output, a small interval is not necessary. @@ -14,41 +14,40 @@ header-lines = 1 interval = 3 [keybindings] -# Move cursor up and down -"j" = [ "cursor down 1" ] -"k" = [ "cursor up 1" ] - # Delete (multiple) files -"d" = [ +"d" = { description = "Delete (multiple) files", operations = [ 'exec -- echo "$lines" | xargs -I {} rm "$pwd/{}"', - "reload" -] + "reload", +] } # Open file (blocking) -"o" = [ 'exec -- echo "$lines" | xargs -I {} xdg-open "$pwd/{}"' ] +"o" = { description = "Open file (blocking)", operations = 'exec -- echo "$lines" | xargs -I {} xdg-open "$pwd/{}"' } # Open file (non-blocking in background) -"O" = [ 'exec & -- echo "$lines" | xargs -I {} xdg-open "$pwd/{}"' ] +"O" = { description = "Open file (non-blocking in background)", operations = 'exec & -- echo "$lines" | xargs -I {} xdg-open "$pwd/{}"' } # Edit text file in TUI editor -"e" = [ 'exec tui -- echo "$line" | xargs -I {} $EDITOR "$pwd/{}"' ] +"e" = { description = "Edit text file in TUI editor", operations = 'exec tui -- echo "$line" | xargs -I {} $EDITOR "$pwd/{}"' } # Traverse out of directories -"h" = [ +"h" = { description = "Traverse out of directories", operations = [ # Set $pwd to the parent dir of current dir 'set-env pwd -- printf "$(dirname "$pwd")"', - "reload" -] + "reload", +] } + # Traverse into directories -"l" = [ +"l" = { description = "Traverse into directories", operations = [ # Only update $pwd if it is a directory 'set-env pwd -- new_pwd="$pwd/$line"; [ -d "$new_pwd" ] && pwd="$new_pwd"; printf "$pwd"', - "reload" -] + "reload", +] } # Create a new file (with random name) # "n" = [ "exec -- touch $(mktemp new_file_XXXXXX.txt)", "reload" ] +# TODO: read-into-env not yet supported + # Create a new file # "n" = [ # "read-into-env NAME", diff --git a/examples/ls.toml b/examples/ls.toml index cb31ddc..1af07ec 100644 --- a/examples/ls.toml +++ b/examples/ls.toml @@ -15,11 +15,11 @@ selected-bg = "red" [keybindings] "esc" = [ "unselect-all", "help-hide" ] -"q" = [ "exit" ] -"j" = [ "cursor down 1" ] -"A" = [ "select-all" ] -"down" = [ "cursor down 1", "cursor down 1", "cursor down 1" ] -"J" = [ "cursor down 3" ] +"q" = { operations = "exit", description = "Exit watchbind." } +"j" = "cursor down 1" +"A" = { operations = "select-all", description = "Select all lines." } +"down" = { operations = [ "cursor down 1", "cursor down 1", "cursor down 1" ], description = "Move the cursor down 3 lines in 3 separate steps." } +"J" = { operations = "cursor down 3", description = "Move the cursor down 3 lines in one step." } "K" = [ "cursor up 1", "cursor up 1", diff --git a/src/config/fields/field_selection.rs b/src/config/fields/field_selection.rs index b149ea8..afa8115 100644 --- a/src/config/fields/field_selection.rs +++ b/src/config/fields/field_selection.rs @@ -16,6 +16,7 @@ struct FieldSelection { end: Option, } +// TODO: should be (at least partially) generated by e.g. parse_display crate impl FromStr for FieldSelections { type Err = Error; fn from_str(s: &str) -> Result { diff --git a/src/config/keybindings/help_menu_format.rs b/src/config/keybindings/help_menu_format.rs new file mode 100644 index 0000000..296e279 --- /dev/null +++ b/src/config/keybindings/help_menu_format.rs @@ -0,0 +1,33 @@ +use anyhow::{Error, Result}; +use derive_more::IntoIterator; +use parse_display::{Display, FromStr}; +use serde::Deserialize; +use std::str; + +/// Specifies which columns should be included in the keybindings help menu, +/// and in what order. +#[derive(Debug, Deserialize, Clone, IntoIterator)] +#[cfg_attr(test, derive(PartialEq))] +pub struct KeybindingsHelpMenuFormat(#[into_iterator(ref)] Vec); + +#[derive(Debug, Deserialize, FromStr, Display, Clone)] +#[cfg_attr(test, derive(PartialEq))] +#[serde(rename_all = "kebab-case")] +#[display(style = "kebab-case")] +pub enum KeybindingsHelpMenuColumn { + Key, + Operations, + Description, +} + +// TODO: should get generated by e.g. parse_display directly +impl str::FromStr for KeybindingsHelpMenuFormat { + type Err = Error; + fn from_str(s: &str) -> Result { + let help_menu_columns = s + .split(',') + .map(KeybindingsHelpMenuColumn::from_str) + .collect::>()?; + Ok(Self(help_menu_columns)) + } +} diff --git a/src/config/keybindings/mod.rs b/src/config/keybindings/mod.rs index 1e315f0..e4d6712 100644 --- a/src/config/keybindings/mod.rs +++ b/src/config/keybindings/mod.rs @@ -1,18 +1,18 @@ +mod help_menu_format; mod key; mod operations; -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Context, Error, Result}; use derive_more::From; -use itertools::Itertools; use serde::Deserialize; -use std::io::Write; use std::sync::Arc; -use std::{collections::HashMap, fmt}; -use tabwriter::TabWriter; +use std::{collections::HashMap, fmt, str}; use tokio::sync::Mutex; +use super::table::Table; use crate::ui::EnvVariables; +pub use self::help_menu_format::{KeybindingsHelpMenuColumn, KeybindingsHelpMenuFormat}; pub use self::key::{KeyCode, KeyEvent, KeyModifier}; pub use self::operations::{OperationExecutable, OperationParsed, Operations, OperationsParsed}; @@ -31,14 +31,28 @@ impl Keybindings { keybindings_parsed .0 .into_iter() - .map(|(key, ops)| (key, Operations::from_parsed(ops, env_variables))) + .map(|(key, (ops, _desc))| (key, Operations::from_parsed(ops, env_variables))) .collect(), ) } } #[derive(Debug, Clone, PartialEq, Eq, From)] -pub struct KeybindingsParsed(HashMap); +pub struct KeybindingsParsed(HashMap); + +// TODO: should be generated by some crate + +/// Allow initialization without `Description`. +impl From> for KeybindingsParsed { + fn from(value: HashMap) -> Self { + Self( + value + .into_iter() + .map(|(key, ops)| (key, (ops, Description::default()))) + .collect(), + ) + } +} impl KeybindingsParsed { /// Merge two keybinding hashmaps, where a value is taken from `opt_a` over @@ -57,43 +71,97 @@ impl KeybindingsParsed { None => opt_b, } } +} + +type KeyPrintable = String; +type OperationsPrintable = String; +type DescriptionPrintable = String; + +#[derive(Debug, Clone, From)] +pub struct KeybindingsPrintable { + keybindings: HashMap, + format: KeybindingsHelpMenuFormat, + header_column_names: Vec, +} + +impl KeybindingsPrintable { + pub fn new(keybindings_parsed: KeybindingsParsed, format: KeybindingsHelpMenuFormat) -> Self { + let keybindings_printable = keybindings_parsed + .0 + .into_iter() + .map(|(key, (operations, description))| { + ( + key.to_string(), + (operations.to_string(), description.to_string()), + ) + }) + .collect(); - /// Write formatted version (insert elastic tabstops) to a buffer. - fn write(&self, writer: W) -> Result<()> { - let mut tw = TabWriter::new(writer); - for (key, operations) in self.0.iter().sorted() { - writeln!(tw, "{}\t= {}", key, operations)?; + let header_column_names = (&format) + .into_iter() + .map(|column_name| column_name.to_string()) + .collect(); + + Self { + keybindings: keybindings_printable, + format, + header_column_names, } - tw.flush()?; - Ok(()) } - fn fmt(&self) -> Result { - let mut buffer = vec![]; - self.write(&mut buffer)?; - let written = String::from_utf8(buffer)?; - Ok(written) - } -} + pub fn display(&self, display_width: U) -> String + where + usize: From, + { + let column_names = self.header_column_names.as_slice(); -impl fmt::Display for KeybindingsParsed { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let formatted = self.fmt().map_err(|_| fmt::Error)?; - f.write_str(&formatted) + let rows_iter = self + .keybindings + .iter() + .map(|(key, (operations, description))| { + // TODO: optimize, since currently O(n^2) (iterating format for every keybinding) + (&self.format).into_iter().map(move |column| match column { + KeybindingsHelpMenuColumn::Key => key, + KeybindingsHelpMenuColumn::Operations => operations, + KeybindingsHelpMenuColumn::Description => description, + }) + }); + + Table::new(rows_iter) + .width(Some(display_width)) + .left_margin(2) + .header(column_names) + .border() + .make_string() } } -impl TryFrom for KeybindingsParsed { +/// Keybindings parsed from TOML. +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub struct KeybindingsToml(HashMap); + +impl TryFrom for KeybindingsParsed { type Error = anyhow::Error; - fn try_from(value: StringKeybindings) -> Result { + fn try_from(value: KeybindingsToml) -> Result { let keybindings = value .0 .into_iter() - .map(|(key, ops)| { + .map(|(key, operations)| { + let (operations, description) = match operations { + TomlOperationsWithDescription::OpsOnly(operations) => { + (operations, Description::default()) + } + TomlOperationsWithDescription::OpsWithDesc { + operations, + description, + } => (operations, Description(description)), + }; Ok(( - key.parse() - .with_context(|| format!("Invalid KeyEvent: {}", key))?, - ops.try_into()?, + key.0 + .parse() + .with_context(|| format!("Invalid key event: {}", key.0))?, + (Vec::::from(operations).try_into()?, description), )) }) .collect::>()?; @@ -101,39 +169,109 @@ impl TryFrom for KeybindingsParsed { } } -// TODO: remove once clap supports parsing directly into HashMap -pub type ClapKeybindings = Vec<(String, Vec)>; +#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Hash)] +struct KeyToml(String); #[derive(Debug, Deserialize, Clone)] #[cfg_attr(test, derive(PartialEq))] -pub struct StringKeybindings(HashMap>); +#[serde(untagged)] +pub enum TomlOperationsWithDescription { + OpsOnly(TomlOperations), + OpsWithDesc { + operations: TomlOperations, + description: Option, + }, +} -impl From for StringKeybindings { - fn from(clap: ClapKeybindings) -> Self { - Self(clap.into_iter().collect()) +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(test, derive(PartialEq))] +#[serde(untagged)] +pub enum TomlOperations { + Single(String), + Multiple(Vec), +} + +impl From for Vec { + fn from(toml_ops: TomlOperations) -> Self { + match toml_ops { + TomlOperations::Single(op) => vec![op], + TomlOperations::Multiple(ops) => ops, + } } } -// TODO: implement FromStr trait -// TODO: replace with nom -// TODO: parse to Vec and provide from_str for keybinding -pub fn parse_str(s: &str) -> Result<(String, Vec)> { - let Some((key, operations)) = s.split_once(':') else { - bail!("invalid format: expected \"KEY:OP[+OP]*\", found \"{}\"", s); - }; - - Ok(( - key.to_string(), - operations - .split('+') - .map(|op| op.trim().to_owned()) - .collect(), - )) +#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Default)] +struct Description(Option); + +impl fmt::Display for Description { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(description) = &self.0 { + write!(f, "{}", description)?; + } + Ok(()) + } +} + +// TODO: setup tests for KeybindingsCli: test that both `-b a:c,b:d` and `-b a:c -b b:d` work (clap features) +// TODO: remove once clap supports parsing directly into HashMap> + +/// Keybindings parsed from a CLI argument. +#[derive(Debug, Clone, From)] +#[cfg_attr(test, derive(PartialEq))] +pub struct KeybindingsCli(Vec); + +impl TryFrom for KeybindingsParsed { + type Error = anyhow::Error; + fn try_from(value: KeybindingsCli) -> Result { + let keybindings = value + .0 + .into_iter() + .map(|KeybindingCli { key, operations }| { + Ok(( + key.parse() + .with_context(|| format!("Invalid key event: {}", key))?, + ( + operations.try_into()?, + // Descriptions are not available in `KeybindingsCli`. + Description::default(), + ), + )) + }) + .collect::>()?; + Ok(Self(keybindings)) + } +} + +/// A keybinding parsed from a string in the format: `KEY:OP[+OP]*` +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub struct KeybindingCli { + key: String, + operations: Vec, +} + +// TODO: replace string parsing with nom +impl str::FromStr for KeybindingCli { + type Err = Error; + fn from_str(s: &str) -> Result { + let Some((key, operations)) = s.split_once(':') else { + bail!("Invalid format: expected \"KEY:OP[+OP]*\", found \"{}\"", s); + }; + + Ok(Self { + key: key.to_string(), + operations: operations + .split('+') + .map(|op| op.trim().to_owned()) + .collect(), + }) + } } #[cfg(test)] mod tests { use super::*; + use indoc::indoc; #[test] fn test_merge_keybindings() { @@ -182,4 +320,97 @@ mod tests { ); assert_eq!(a.0.get(&k3), merged.0.get(&k3), "a's value should be used"); } + + /// `OperationsToml` will always be parsed as a value in a table. To + /// independently test the parsing of `OperationsToml`, we must parse + /// into an artificial map with a key `foo`. + #[derive(Debug, Deserialize, Clone)] + struct OperationsTomlWrapper { + foo: TomlOperationsWithDescription, + } + + #[test] + fn test_parse_toml_operations() { + let ops_only_single = r#"foo = "op""#; + assert_eq!( + TomlOperationsWithDescription::OpsOnly(TomlOperations::Single("op".to_string())), + toml::from_str::(ops_only_single) + .unwrap() + .foo + ); + + let ops_only_multiple = r#"foo = [ "op 1", "op 2" ]"#; + assert_eq!( + TomlOperationsWithDescription::OpsOnly(TomlOperations::Multiple(vec![ + "op 1".to_string(), + "op 2".to_string() + ])), + toml::from_str::(ops_only_multiple) + .unwrap() + .foo + ); + + let ops_without_desc = r#"foo = { operations = [ "op 1", "op 2" ] }"#; + assert_eq!( + TomlOperationsWithDescription::OpsWithDesc { + operations: TomlOperations::Multiple(vec!["op 1".to_string(), "op 2".to_string()]), + description: None, + }, + toml::from_str::(ops_without_desc) + .unwrap() + .foo + ); + + let long_with_desc = + r#"foo = { operations = [ "op 1", "op 2" ], description = "A custom description" }"#; + assert_eq!( + TomlOperationsWithDescription::OpsWithDesc { + operations: TomlOperations::Multiple(vec!["op 1".to_string(), "op 2".to_string()]), + description: Some("A custom description".to_string()) + }, + toml::from_str::(long_with_desc) + .unwrap() + .foo + ); + } + + #[test] + fn test_parse_keybindings_toml() { + let keybindings = indoc! {r#" + "a" = [ "op1", "op2" ] + "b" = "op" + "#}; + + assert_eq!( + KeybindingsToml(HashMap::from([ + ( + KeyToml("a".to_string()), + TomlOperationsWithDescription::OpsOnly(TomlOperations::Multiple(vec![ + "op1".to_string(), + "op2".to_string() + ])) + ), + ( + KeyToml("b".to_string()), + TomlOperationsWithDescription::OpsOnly(TomlOperations::Single( + "op".to_string() + )) + ) + ])), + toml::from_str(keybindings).unwrap() + ); + } + + #[test] + fn test_parse_keybinding_cli() { + let keybinding = "a:op multiple words+op2"; + + assert_eq!( + KeybindingCli { + key: "a".to_string(), + operations: vec!["op multiple words".to_string(), "op2".to_string()] + }, + keybinding.parse().unwrap() + ); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 8c01fec..0e72b4f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,7 @@ mod fields; mod keybindings; mod style; +mod table; mod xdg; use anyhow::{bail, Context, Error, Result}; @@ -9,14 +10,13 @@ use indoc::indoc; use serde::Deserialize; use simplelog::{LevelFilter, WriteLogger}; use std::{ + borrow::Cow, fs::{read_to_string, File}, path::{Path, PathBuf}, str::FromStr, time::Duration, }; -use tabled::settings::{peaker::PriorityMax, Margin, Padding, Style as TableStyle, Width}; -use tabled::{builder::Builder, Table}; -use terminal_size::{terminal_size, Width as TerminalWidth}; +use terminal_size::Width; #[cfg(test)] use derive_builder::Builder; @@ -25,15 +25,20 @@ use crate::config::keybindings::{KeyCode, KeyModifier}; use crate::config::style::PrettyColor; use crate::utils::possible_enum_values::PossibleEnumValues; -use self::fields::{FieldSelections, FieldSeparator}; -use self::keybindings::{KeybindingsParsed, StringKeybindings}; +use self::keybindings::{KeybindingCli, KeybindingsHelpMenuFormat, KeybindingsToml}; use self::style::{Boldness, Color, Style}; +use self::{ + fields::{FieldSelections, FieldSeparator}, + keybindings::KeybindingsCli, +}; pub use self::fields::{Fields, TableFormatter}; pub use self::keybindings::{ - KeyEvent, Keybindings, OperationExecutable, OperationParsed, Operations, OperationsParsed, + KeyEvent, Keybindings, KeybindingsParsed, KeybindingsPrintable, OperationExecutable, + OperationParsed, Operations, OperationsParsed, }; pub use self::style::Styles; +pub use self::table::Table; // TODO: don't have public members @@ -43,6 +48,7 @@ pub struct Config { pub watch_rate: Duration, pub styles: Styles, pub keybindings_parsed: KeybindingsParsed, + pub keybindings_help_menu_format: KeybindingsHelpMenuFormat, pub header_lines: usize, pub fields: Fields, pub initial_env_ops: OperationsParsed, @@ -148,6 +154,7 @@ impl TryFrom for Config { watch_rate: Duration::from_secs_f64(expect!(config, interval)), styles, keybindings_parsed: expect!(config, keybindings), + keybindings_help_menu_format: expect!(config, keybindings_help_menu_format), header_lines: expect!(config, header_lines), fields: Fields::try_new(config.field_separator, config.field_selections)?, update_ui_while_blocking: expect!(config, update_ui_while_blocking), @@ -179,6 +186,7 @@ pub struct PartialConfig { field_separator: Option, update_ui_while_blocking: Option, keybindings: Option, + keybindings_help_menu_format: Option, } impl PartialConfig { @@ -231,6 +239,9 @@ impl PartialConfig { .update_ui_while_blocking .or(other.update_ui_while_blocking), keybindings: KeybindingsParsed::merge(self.keybindings, other.keybindings), + keybindings_help_menu_format: self + .keybindings_help_menu_format + .or(other.keybindings_help_menu_format), } } @@ -301,7 +312,9 @@ pub struct TomlFileConfig { update_ui_while_blocking: Option, - keybindings: Option, + keybindings: Option, + + keybindings_help_menu_format: Option, } impl TomlFileConfig { @@ -355,6 +368,7 @@ impl TryFrom for PartialConfig { .keybindings .map(KeybindingsParsed::try_from) .transpose()?, + keybindings_help_menu_format: toml.keybindings_help_menu_format, }) } } @@ -383,9 +397,10 @@ impl TryFrom for PartialConfig { update_ui_while_blocking: cli.update_ui_while_blocking, keybindings: cli .keybindings - .map(StringKeybindings::from) + .map(KeybindingsCli::from) .map(KeybindingsParsed::try_from) .transpose()?, + keybindings_help_menu_format: cli.keybindings_help_menu_format, }) } } @@ -394,39 +409,47 @@ impl TryFrom for PartialConfig { impl Default for PartialConfig { fn default() -> Self { let default_toml = indoc! {r#" - "interval" = 3.0 + "interval" = 3.0 - "cursor-fg" = "unspecified" - "cursor-bg" = "blue" - "cursor-boldness" = "bold" + "cursor-fg" = "unspecified" + "cursor-bg" = "blue" + "cursor-boldness" = "bold" - "header-fg" = "blue" - "header-bg" = "unspecified" - "header-boldness" = "non-bold" + "header-fg" = "blue" + "header-bg" = "unspecified" + "header-boldness" = "non-bold" "header-lines" = 0 - "non-cursor-non-header-fg" = "unspecified" - "non-cursor-non-header-bg" = "unspecified" - "non-cursor-non-header-boldness" = "unspecified" + "non-cursor-non-header-fg" = "unspecified" + "non-cursor-non-header-bg" = "unspecified" + "non-cursor-non-header-boldness" = "unspecified" - "selected-bg" = "magenta" + "selected-bg" = "magenta" "update-ui-while-blocking" = false - [keybindings] - "ctrl+c" = [ "exit" ] - "q" = [ "exit" ] - "r" = [ "reload" ] - "?" = [ "help-toggle" ] - "space" = [ "toggle-selection", "cursor down 1" ] - "v" = [ "toggle-selection" ] - "esc" = [ "unselect-all" ] - "down" = [ "cursor down 1" ] - "up" = [ "cursor up 1" ] - "j" = [ "cursor down 1" ] - "k" = [ "cursor up 1" ] - "g" = [ "cursor first" ] - "G" = [ "cursor last" ] + "keybindings-help-menu-format" = [ "key", "description", "operations" ] + + [keybindings] + "ctrl+c" = { description = "Exit watchbind", operations = "exit" } + "q" = { description = "Exit watchbind", operations = "exit" } + "r" = { description = "Reload the watched command manually, resets interval timer", operations = "reload" } + + # Moving around + "down" = { description = "Move cursor down 1 line", operations = "cursor down 1" } + "up" = { description = "Move cursor up 1 line", operations = "cursor up 1" } + "j" = { description = "Move cursor down 1 line", operations = "cursor down 1" } + "k" = { description = "Move cursor up 1 line", operations = "cursor up 1" } + "g" = { description = "Move cursor to the first line", operations = "cursor first" } + "G" = { description = "Move cursor to the last line" , operations = "cursor last" } + + # Selecting lines + "space" = { description = "Toggle selection of line that cursor is currently on, and move cursor down 1 line", operations = [ "toggle-selection", "cursor down 1" ] } + "v" = { description = "Select line that cursor is currently on", operations = "select" } + "esc" = { description = "Unselect all currently selected lines", operations = "unselect-all" } + + # Help menu + "?" = { description = "Toggle the visibility of the help menu", operations = "help-toggle" } "#}; default_toml @@ -577,22 +600,28 @@ pub struct CliArgs { #[arg(long, value_name = "BOOL")] update_ui_while_blocking: Option, - // TODO: replace with ClapKeybindings (currently panics, known clap bug) - // TODO: replace with StringKeybindings once clap supports parsing into HashMap /// Keybindings as comma-separated `KEY:OP[+OP]*` pairs, e.g. `q:select+exit,r:reload`. - #[arg(short = 'b', long = "bind", value_name = "LIST", value_delimiter = ',', value_parser = keybindings::parse_str)] - keybindings: Option)>>, + #[arg(short = 'b', long = "bind", value_name = "LIST", value_delimiter = ',')] + keybindings: Option>, + + /// Format of keybindings help menu as comma-separated list, e.g. `key,operations,description`. + #[arg(long, value_name = "FORMAT")] + keybindings_help_menu_format: Option, } -/// Convert [[&str, String]] to [[String, String]] by calling str::to_owned(). -macro_rules! to_owned_first { +/// Convert [[&str, String]] to [[Cow::Borrowed(&str), Cow::Owned(&str)]]. +macro_rules! cowify { ($([$str_slice:expr, $string:expr]),* $(,)?) => { [$( - [str::to_owned($str_slice), $string], + [Cow::Borrowed($str_slice), Cow::Owned($string)], )*] }; } +fn terminal_width() -> Option { + terminal_size::terminal_size().map(|(Width(width), _)| width) +} + impl CliArgs { /// Get extra help menu as string. fn extra_help_menu() -> String { @@ -615,7 +644,7 @@ impl CliArgs { .custom_names() .get(); - let possible_values_table_data = to_owned_first![ + let possible_values_table_data = cowify![ ["COLOR", format!("[{color}]")], ["BOLDNESS", format!("[{boldness}]")], ["KEY", format!("[+, ]")], @@ -623,7 +652,10 @@ impl CliArgs { ["KEY-CODE", format!("[{key_code}]")], ["OP", format!("[{operation}]")], ]; - let possible_values_table = create_table_from(possible_values_table_data); + let possible_values_table = Table::new(possible_values_table_data) + .width(terminal_width()) + .left_margin(2) + .displayable(); // Mimic clap's bold underlined style for headers. format!( @@ -637,11 +669,16 @@ impl CliArgs { fn global_config_file_help() -> String { use owo_colors::OwoColorize; - let global_config_file = global_config_file_path() - .map_or("Unknown".to_string(), |file| file.display().to_string()); + let global_config_file: Cow = global_config_file_path() + .map_or(Cow::Borrowed("Unknown"), |file| { + Cow::Owned(file.display().to_string()) + }); - let global_config_file_table_data = [[global_config_file]]; - let global_config_file_table = create_table_from(global_config_file_table_data); + let global_config_file_table_data = [[global_config_file.as_ref()]]; + let global_config_file_table = Table::new(global_config_file_table_data) + .width(terminal_width()) + .left_margin(2) + .displayable(); // Mimic clap's bold underlined style for headers. format!( @@ -652,31 +689,6 @@ impl CliArgs { } } -/// Create a formatted `tabled::Table` with two columns. -fn create_table_from(table_data: R) -> Table -where - R: IntoIterator, - C: IntoIterator, -{ - let mut table = Builder::from_iter(table_data.into_iter().map(|row| row.into_iter())).build(); - table - .with(TableStyle::blank()) - // Add left margin for indent. - .with(Margin::new(2, 0, 0, 0)) - // Remove left padding. - .with(Padding::new(0, 1, 0, 0)); - - // Set table width to terminal width. - if let Some((TerminalWidth(width), _)) = terminal_size() { - let width: usize = width.into(); - table - .with(Width::wrap(width).priority::().keep_words()) - .with(Width::increase(width)); - } - - table -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/config/table.rs b/src/config/table.rs new file mode 100644 index 0000000..09ad236 --- /dev/null +++ b/src/config/table.rs @@ -0,0 +1,113 @@ +use std::fmt; +use tabled::{ + builder::Builder, + settings::{ + peaker::PriorityMax, themes::ColumnNames, Margin, Padding, Style, Width as TabledWidth, + }, + Table as TabledTable, +}; + +/// A formatted table. Columns will never overflow if an appropriate maximum +/// screen/widget width was specified. +pub struct Table<'a, RowsIter, RowIter, TableItem> +where + TableItem: Into, + RowIter: IntoIterator, + RowsIter: IntoIterator, +{ + data: RowsIter, + width: Option, + left_margin: Option, + border: bool, + header: Option<&'a [String]>, +} + +impl<'a, RowsIter, RowIter, TableItem> Table<'a, RowsIter, RowIter, TableItem> +where + TableItem: Into, + RowIter: IntoIterator, + RowsIter: IntoIterator, +{ + pub fn new(data: RowsIter) -> Self { + Table { + data, + width: None, + left_margin: None, + border: false, + header: None, + } + } + + /// Add a left margin. + pub fn left_margin(mut self, left_margin: usize) -> Self { + self.left_margin = Some(left_margin); + self + } + + /// Set the maximum width for string/display output. + pub fn width(mut self, width: Option) -> Self + where + usize: From, + { + self.width = width.map_or(self.width, |w| Some(usize::from(w))); + self + } + + /// Add a border. + pub fn border(mut self) -> Self { + self.border = true; + self + } + + /// Use the first input row as a header. + pub fn header(mut self, column_names: &'a [String]) -> Self { + self.header = Some(column_names); + self + } + + /// Return the table as something that can be displayed (e.g. be printed + /// to stdout). + pub fn displayable(self) -> impl fmt::Display { + self.create_table() + } + + /// Return the table as a string. + pub fn make_string(self) -> String { + self.create_table().to_string() + } + + fn create_table(self) -> TabledTable { + let left_margin = self.left_margin.unwrap_or(0); + let mut table = Builder::from_iter(self.data.into_iter().map(RowIter::into_iter)).build(); + + if self.border { + table.with(Style::modern()); + } else { + table + .with(Style::blank()) + // Remove left padding. + .with(Padding::new(0, 1, 0, 0)); + } + + // Add left margin for indent. + table.with(Margin::new(left_margin, 0, 0, 0)); + + if let Some(column_names) = self.header { + // Set the header to the column names. + table.with(ColumnNames::new(column_names)); + } + + if let Some(width) = self.width { + // Set table width. + table + .with( + TabledWidth::wrap(width) + .priority::() + .keep_words(), + ) + .with(TabledWidth::increase(width)); + } + + table + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b0297f8..ad6a4bf 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use terminal_manager::Tui; use tokio::sync::mpsc::{self, Receiver, Sender}; -use crate::config::{Config, KeyEvent, Keybindings}; +use crate::config::{Config, KeyEvent, Keybindings, KeybindingsPrintable}; use crate::utils::command::{ Blocking, CommandBuilder, ExecutionResult, Interruptible, WasWoken, WithEnv, WithOutput, }; @@ -160,12 +160,15 @@ impl UI { let terminal_manager = Tui::new()?; // Create `State`. - let keybindings_str = config.keybindings_parsed.to_string(); + // let keybindings_str = config.keybindings_parsed.to_string(); let mut state = State::new( config.header_lines, config.fields, config.styles, - keybindings_str, + KeybindingsPrintable::new( + config.keybindings_parsed.clone(), + config.keybindings_help_menu_format, + ), EnvVariables::new(), ); state diff --git a/src/ui/state/env_variables/mod.rs b/src/ui/state/env_variables/mod.rs index 663785e..db5abf1 100644 --- a/src/ui/state/env_variables/mod.rs +++ b/src/ui/state/env_variables/mod.rs @@ -1,13 +1,12 @@ mod env_variable; -use anyhow::Result; -use itertools::Itertools; -use std::{collections::HashMap, fmt, io::Write}; -use tabwriter::TabWriter; +use std::collections::HashMap; + +use crate::config::Table; pub use self::env_variable::EnvVariable; -#[derive(Default, Debug)] +#[derive(Default, Debug, Clone)] pub struct EnvVariables(HashMap); impl EnvVariables { @@ -32,30 +31,23 @@ impl EnvVariables { self.0.remove(env_var); } - /// Write formatted version (insert elastic tabstops) to a buffer. - fn write(&self, writer: W) -> Result<()> { - let mut tw = TabWriter::new(writer); - for (env_variable, value) in self.0.iter().sorted() { - writeln!(tw, "{}\t= \"{}\"", env_variable, value)?; - } - tw.flush()?; - Ok(()) - } + pub fn display(&self, display_width: U) -> String + where + usize: From, + { + let column_names = &["environment variable".to_string(), "value".to_string()]; - // TODO: code duplication from KeybindingsParsed - fn fmt(&self) -> Result { - let mut buffer = vec![]; - self.write(&mut buffer)?; - let written = String::from_utf8(buffer)?; - Ok(written) - } -} + let rows_iter = self + .0 + .iter() + .map(|(env_variable, value)| [env_variable.to_string(), value.to_owned()]); -// TODO: code duplication from KeybindingsParsed -impl fmt::Display for EnvVariables { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let formatted = self.fmt().map_err(|_| fmt::Error)?; - f.write_str(&formatted) + Table::new(rows_iter) + .width(Some(display_width)) + .left_margin(2) + .header(column_names) + .border() + .make_string() } } diff --git a/src/ui/state/help_menu.rs b/src/ui/state/help_menu.rs index 1f60279..4b3c356 100644 --- a/src/ui/state/help_menu.rs +++ b/src/ui/state/help_menu.rs @@ -1,70 +1,71 @@ use ratatui::{ prelude::{Alignment, Constraint, Direction, Layout, Margin, Rect}, text::Text, - widgets::{ - Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, - }, + widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, Frame, }; use std::sync::Arc; use tokio::sync::Mutex; +use crate::config::KeybindingsPrintable; + use super::EnvVariables; pub struct HelpMenu { env_variables: Arc>, - keybindings_str: String, - env_variables_str: String, + /// A local/non-shared copy of the shared `env_variables`. + env_variables_copy: EnvVariables, + keybindings: KeybindingsPrintable, vertical_scroll_index: usize, vertical_scroll_state: ScrollbarState, } // TODO: scrollbar should be hidden if not necessary; currently it's always shown -// TODO: we should display a mapping of all set EnvVariables (with their actual values) impl HelpMenu { - pub fn new(keybindings_str: String, env_variables: Arc>) -> Self { + pub fn new(keybindings: KeybindingsPrintable, env_variables: Arc>) -> Self { HelpMenu { - // vertical_scroll_state: ScrollbarState::default() - // .content_length(keybindings_str.lines().count() as u16), env_variables, - keybindings_str, - env_variables_str: String::default(), + env_variables_copy: EnvVariables::default(), + keybindings, vertical_scroll_state: ScrollbarState::default(), vertical_scroll_index: 0, + // vertical_scroll_state: ScrollbarState::default() + // .content_length(keybindings_str.lines().count() as u16), } } pub fn render(&mut self, frame: &mut Frame) { // TODO: maybe in the future, when we add more features for manipulating ENV variable state, we have to fetch the new - let area = centered_rect(50, 50, frame.size()); + let popup_area = centered_rect(90, 90, frame.size()); + // Get the inner popup width, so take borders into account. + let popup_width = popup_area.width - 2; let rendered_text = format!( "ENV VARIABLES:\n{}\nKEYBINDINGS:\n{}\n", - self.env_variables_str, self.keybindings_str + self.env_variables_copy.display(popup_width), + self.keybindings.display(popup_width) ); - // let text: Text = self.keybindings_str.as_str().into(); let text: Text = rendered_text.into(); // Render the paragraph with the updated scroll state let paragraph = Paragraph::new(text) .block(Block::default().title("help").borders(Borders::ALL)) .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) // scroll offset for each axis: (y, x) .scroll((self.vertical_scroll_index as u16, 0)); // Render the scrollbar next to the paragraph - frame.render_widget(Clear, area); - frame.render_widget(paragraph, area); + frame.render_widget(Clear, popup_area); + frame.render_widget(paragraph, popup_area); frame.render_stateful_widget( Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None), - area.inner(&Margin { + popup_area.inner(&Margin { vertical: 1, horizontal: 0, }), @@ -109,7 +110,7 @@ impl HelpMenu { pub async fn show(&mut self) { // TODO: here, we would also have to set the vertical scroll length let env_variables = self.env_variables.lock().await; - self.env_variables_str = env_variables.to_string(); + self.env_variables_copy = env_variables.clone(); } pub fn hide(&mut self) { @@ -118,8 +119,8 @@ impl HelpMenu { } /// Helper function to create a centered rect using up certain percentage -/// of the available rect `r` -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { +/// of the available rect `r`. +pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints( diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 2eefd21..2f6fb29 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -8,7 +8,9 @@ use ratatui::Frame; use std::sync::Arc; use tokio::sync::Mutex; -use crate::config::{Fields, OperationExecutable, Operations, OperationsParsed, Styles}; +use crate::config::{ + Fields, KeybindingsPrintable, OperationExecutable, Operations, OperationsParsed, Styles, +}; use self::{ help_menu::HelpMenu, @@ -36,7 +38,7 @@ impl State { header_lines: usize, fields: Fields, styles: Styles, - keybindings_str: String, + keybindings_str: KeybindingsPrintable, env_variables: EnvVariables, ) -> Self { let env_variables = Arc::new(Mutex::new(env_variables)); @@ -52,6 +54,11 @@ impl State { self.lines.render(frame); if let Mode::HelpMenu = self.mode { + // TODO: ratatui: how to constrain widget to certain custom frame + // let popup_frame = centered_rect(90, 90, frame.size()); + // self.help_menu.render(popup_frame); + // dbg!(&frame); + log::info!("full-screen frame width: {:?}", &frame.size().width); self.help_menu.render(frame); } }