feat(cli): allow passing custom keybindings through the cli (#409)

Fixes #134
This commit is contained in:
Alexandre Pasmantier 2025-03-19 21:59:53 +01:00 committed by GitHub
parent 3a5b5ec0cc
commit 3222037a02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 143 additions and 22 deletions

View File

@ -1,10 +1,10 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH television 1 "television 0.10.9" .TH television 1 "television 0.10.10"
.SH NAME .SH NAME
television \- A cross\-platform, fast and extensible general purpose fuzzy finder TUI. television \- A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH SYNOPSIS .SH SYNOPSIS
\fBtelevision\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-\-no\-preview\fR] [\fB\-\-delimiter\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-f\fR|\fB\-\-frame\-rate\fR] [\fB\-\-passthrough\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR] \fBtelevision\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-\-no\-preview\fR] [\fB\-\-delimiter\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-f\fR|\fB\-\-frame\-rate\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-\-passthrough\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
.SH DESCRIPTION .SH DESCRIPTION
A cross\-platform, fast and extensible general purpose fuzzy finder TUI. A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH OPTIONS .SH OPTIONS
@ -44,6 +44,15 @@ compromise for most users.
This option is deprecated and will be removed in a future release. This option is deprecated and will be removed in a future release.
.TP .TP
\fB\-k\fR, \fB\-\-keybindings\fR=\fISTRING\fR
Keybindings to override the default keybindings.
This can be used to override the default keybindings with a custom subset
The keybindings are specified as a semicolon separated list of keybinding
expressions using the configuration file formalism.
Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq`
.TP
\fB\-\-passthrough\-keybindings\fR=\fISTRING\fR \fB\-\-passthrough\-keybindings\fR=\fISTRING\fR
Passthrough keybindings (comma separated, e.g. "q,ctrl\-w,ctrl\-t") Passthrough keybindings (comma separated, e.g. "q,ctrl\-w,ctrl\-t")
@ -95,6 +104,6 @@ Initializes shell completion ("tv init zsh")
television\-help(1) television\-help(1)
Print this message or the help of the given subcommand(s) Print this message or the help of the given subcommand(s)
.SH VERSION .SH VERSION
v0.10.9 v0.10.10
.SH AUTHORS .SH AUTHORS
Alexandre Pasmantier <alex.pasmant@gmail.com> Alexandre Pasmantier <alex.pasmant@gmail.com>

View File

@ -110,7 +110,7 @@ impl App {
let (render_tx, render_rx) = mpsc::unbounded_channel(); let (render_tx, render_rx) = mpsc::unbounded_channel();
let (_, event_rx) = mpsc::unbounded_channel(); let (_, event_rx) = mpsc::unbounded_channel();
let (event_abort_tx, _) = mpsc::unbounded_channel(); let (event_abort_tx, _) = mpsc::unbounded_channel();
let tick_rate = config.config.tick_rate; let tick_rate = config.application.tick_rate;
let keybindings = merge_keybindings(config.keybindings.clone(), { let keybindings = merge_keybindings(config.keybindings.clone(), {
&KeyBindings::from(passthrough_keybindings.iter().filter_map( &KeyBindings::from(passthrough_keybindings.iter().filter_map(
|s| match parse_key(s) { |s| match parse_key(s) {

View File

@ -56,6 +56,16 @@ pub struct Cli {
#[arg(short, long, value_name = "FLOAT", verbatim_doc_comment)] #[arg(short, long, value_name = "FLOAT", verbatim_doc_comment)]
pub frame_rate: Option<f64>, pub frame_rate: Option<f64>,
/// Keybindings to override the default keybindings.
///
/// This can be used to override the default keybindings with a custom subset
/// The keybindings are specified as a semicolon separated list of keybinding
/// expressions using the configuration file formalism.
///
/// Example: `tv --keybindings='quit="esc";select_next_entry=["down","ctrl-j"]'`
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
pub keybindings: Option<String>,
/// Passthrough keybindings (comma separated, e.g. "q,ctrl-w,ctrl-t") /// Passthrough keybindings (comma separated, e.g. "q,ctrl-w,ctrl-t")
/// ///
/// These keybindings will trigger selection of the current entry and be /// These keybindings will trigger selection of the current entry and be

View File

@ -9,6 +9,7 @@ use crate::channels::{
cable::CableChannelPrototype, entry::PreviewCommand, CliTvChannel, cable::CableChannelPrototype, entry::PreviewCommand, CliTvChannel,
}; };
use crate::cli::args::{Cli, Command}; use crate::cli::args::{Cli, Command};
use crate::config::KeyBindings;
use crate::{ use crate::{
cable, cable,
config::{get_config_dir, get_data_dir}, config::{get_config_dir, get_data_dir},
@ -28,6 +29,7 @@ pub struct PostProcessedCli {
pub command: Option<Command>, pub command: Option<Command>,
pub working_directory: Option<String>, pub working_directory: Option<String>,
pub autocomplete_prompt: Option<String>, pub autocomplete_prompt: Option<String>,
pub keybindings: Option<KeyBindings>,
} }
impl Default for PostProcessedCli { impl Default for PostProcessedCli {
@ -43,12 +45,21 @@ impl Default for PostProcessedCli {
command: None, command: None,
working_directory: None, working_directory: None,
autocomplete_prompt: None, autocomplete_prompt: None,
keybindings: None,
} }
} }
} }
impl From<Cli> for PostProcessedCli { impl From<Cli> for PostProcessedCli {
fn from(cli: Cli) -> Self { fn from(cli: Cli) -> Self {
let keybindings: Option<KeyBindings> = cli.keybindings.map(|kb| {
parse_keybindings(&kb)
.map_err(|e| {
cli_parsing_error_exit(&e.to_string());
})
.unwrap()
});
let passthrough_keybindings = cli let passthrough_keybindings = cli
.passthrough_keybindings .passthrough_keybindings
.unwrap_or_default() .unwrap_or_default()
@ -62,11 +73,13 @@ impl From<Cli> for PostProcessedCli {
command: preview, command: preview,
delimiter: cli.delimiter.clone(), delimiter: cli.delimiter.clone(),
}) })
.map(|preview_command| { .map_or(PreviewKind::None, |preview_command| {
parse_preview_kind(&preview_command) parse_preview_kind(&preview_command)
.expect("Error parsing preview command") .map_err(|e| {
}) cli_parsing_error_exit(&e.to_string());
.unwrap_or(PreviewKind::None); })
.unwrap()
});
let channel: ParsedCliChannel; let channel: ParsedCliChannel;
let working_directory: Option<String>; let working_directory: Option<String>;
@ -102,10 +115,16 @@ impl From<Cli> for PostProcessedCli {
command: cli.command, command: cli.command,
working_directory, working_directory,
autocomplete_prompt: cli.autocomplete_prompt, autocomplete_prompt: cli.autocomplete_prompt,
keybindings,
} }
} }
} }
fn cli_parsing_error_exit(message: &str) {
eprintln!("Error parsing CLI arguments: {message}\n");
std::process::exit(1);
}
fn unknown_channel_exit(channel: &str) { fn unknown_channel_exit(channel: &str) {
eprintln!("Unknown channel: {channel}\n"); eprintln!("Unknown channel: {channel}\n");
std::process::exit(1); std::process::exit(1);
@ -117,6 +136,24 @@ pub enum ParsedCliChannel {
Cable(CableChannelPrototype), Cable(CableChannelPrototype),
} }
const CLI_KEYBINDINGS_DELIMITER: char = ';';
/// Parse the keybindings string into a hashmap of key -> action.
///
/// The formalism used is the same as the one used in the configuration file:
/// ```ignore
/// quit="esc";select_next_entry=["down","ctrl-j"]
/// ```
/// Parsing it globally consists of splitting by the delimiter, reconstructing toml key-value pairs
/// and parsing that using logic already implemented in the configuration module.
fn parse_keybindings(cli_keybindings: &str) -> Result<KeyBindings> {
let toml_definition = cli_keybindings
.split(CLI_KEYBINDINGS_DELIMITER)
.fold(String::new(), |acc, kb| acc + kb + "\n");
toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
}
fn parse_channel(channel: &str) -> Result<ParsedCliChannel> { fn parse_channel(channel: &str) -> Result<ParsedCliChannel> {
let cable_channels = cable::load_cable_channels().unwrap_or_default(); let cable_channels = cable::load_cable_channels().unwrap_or_default();
// try to parse the channel as a cable channel // try to parse the channel as a cable channel
@ -259,7 +296,10 @@ Data directory: {data_dir_path}"
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::channels::entry::PreviewType; use crate::{
action::Action, channels::entry::PreviewType, config::Binding,
event::Key,
};
use super::*; use super::*;
@ -273,6 +313,7 @@ mod tests {
delimiter: ":".to_string(), delimiter: ":".to_string(),
tick_rate: Some(50.0), tick_rate: Some(50.0),
frame_rate: Some(60.0), frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: Some("q,ctrl-w,ctrl-t".to_string()), passthrough_keybindings: Some("q,ctrl-w,ctrl-t".to_string()),
input: None, input: None,
command: None, command: None,
@ -315,6 +356,7 @@ mod tests {
delimiter: ":".to_string(), delimiter: ":".to_string(),
tick_rate: Some(50.0), tick_rate: Some(50.0),
frame_rate: Some(60.0), frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: None, passthrough_keybindings: None,
input: None, input: None,
command: None, command: None,
@ -344,6 +386,7 @@ mod tests {
delimiter: ":".to_string(), delimiter: ":".to_string(),
tick_rate: Some(50.0), tick_rate: Some(50.0),
frame_rate: Some(60.0), frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: None, passthrough_keybindings: None,
input: None, input: None,
command: None, command: None,
@ -368,6 +411,7 @@ mod tests {
delimiter: ":".to_string(), delimiter: ":".to_string(),
tick_rate: Some(50.0), tick_rate: Some(50.0),
frame_rate: Some(60.0), frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: None, passthrough_keybindings: None,
input: None, input: None,
command: None, command: None,
@ -382,4 +426,36 @@ mod tests {
PreviewKind::Builtin(PreviewType::EnvVar) PreviewKind::Builtin(PreviewType::EnvVar)
); );
} }
#[test]
fn test_custom_keybindings() {
let cli = Cli {
channel: "files".to_string(),
preview: Some(":env_var:".to_string()),
no_preview: false,
delimiter: ":".to_string(),
tick_rate: Some(50.0),
frame_rate: Some(60.0),
keybindings: Some(
"quit=\"esc\";select_next_entry=[\"down\",\"ctrl-j\"]"
.to_string(),
),
passthrough_keybindings: None,
input: None,
command: None,
working_directory: None,
autocomplete_prompt: None,
};
let post_processed_cli: PostProcessedCli = cli.into();
let mut expected = KeyBindings::default();
expected.insert(Action::Quit, Binding::SingleKey(Key::Esc));
expected.insert(
Action::SelectNextEntry,
Binding::MultipleKeys(vec![Key::Down, Key::Ctrl('j')]),
);
assert_eq!(post_processed_cli.keybindings, Some(expected));
}
} }

View File

@ -54,7 +54,7 @@ pub struct Config {
/// General application configuration /// General application configuration
#[allow(clippy::struct_field_names)] #[allow(clippy::struct_field_names)]
#[serde(default, flatten)] #[serde(default, flatten)]
pub config: AppConfig, pub application: AppConfig,
/// Keybindings configuration /// Keybindings configuration
#[serde(default)] #[serde(default)]
pub keybindings: KeyBindings, pub keybindings: KeyBindings,
@ -176,7 +176,7 @@ impl Config {
user.keybindings = keybindings; user.keybindings = keybindings;
Config { Config {
config: user.config, application: user.application,
keybindings: user.keybindings, keybindings: user.keybindings,
ui: user.ui, ui: user.ui,
previewers: user.previewers, previewers: user.previewers,
@ -288,8 +288,8 @@ mod tests {
file.write_all(DEFAULT_CONFIG.as_bytes()).unwrap(); file.write_all(DEFAULT_CONFIG.as_bytes()).unwrap();
let config = Config::load_user_config(config_dir).unwrap(); let config = Config::load_user_config(config_dir).unwrap();
assert_eq!(config.config.data_dir, get_data_dir()); assert_eq!(config.application.data_dir, get_data_dir());
assert_eq!(config.config.config_dir, get_config_dir()); assert_eq!(config.application.config_dir, get_config_dir());
assert_eq!(config, toml::from_str(DEFAULT_CONFIG).unwrap()); assert_eq!(config, toml::from_str(DEFAULT_CONFIG).unwrap());
} }
@ -310,7 +310,7 @@ mod tests {
toml::from_str(DEFAULT_CONFIG).unwrap(); toml::from_str(DEFAULT_CONFIG).unwrap();
default_config.shell_integration.merge_triggers(); default_config.shell_integration.merge_triggers();
assert_eq!(config.config, default_config.config); assert_eq!(config.application, default_config.application);
assert_eq!(config.keybindings, default_config.keybindings); assert_eq!(config.keybindings, default_config.keybindings);
assert_eq!(config.ui, default_config.ui); assert_eq!(config.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers); assert_eq!(config.previewers, default_config.previewers);
@ -364,7 +364,7 @@ mod tests {
let mut default_config: Config = let mut default_config: Config =
toml::from_str(DEFAULT_CONFIG).unwrap(); toml::from_str(DEFAULT_CONFIG).unwrap();
default_config.config.frame_rate = 30.0; default_config.application.frame_rate = 30.0;
default_config.ui.ui_scale = 40; default_config.ui.ui_scale = 40;
default_config.ui.theme = "television".to_string(); default_config.ui.theme = "television".to_string();
default_config.previewers.file.theme = default_config.previewers.file.theme =
@ -392,7 +392,7 @@ mod tests {
.insert("command_history".to_string(), "ctrl-h".to_string()); .insert("command_history".to_string(), "ctrl-h".to_string());
default_config.shell_integration.merge_triggers(); default_config.shell_integration.merge_triggers();
assert_eq!(config.config, default_config.config); assert_eq!(config.application, default_config.application);
assert_eq!(config.keybindings, default_config.keybindings); assert_eq!(config.keybindings, default_config.keybindings);
assert_eq!(config.ui, default_config.ui); assert_eq!(config.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers); assert_eq!(config.previewers, default_config.previewers);

View File

@ -19,7 +19,7 @@ use television::cli::{
PostProcessedCli, PostProcessedCli,
}; };
use television::config::{Config, ConfigEnv}; use television::config::{merge_keybindings, Config, ConfigEnv};
use television::utils::shell::render_autocomplete_script_template; use television::utils::shell::render_autocomplete_script_template;
use television::utils::{ use television::utils::{
shell::{completion_script, Shell}, shell::{completion_script, Shell},
@ -45,11 +45,7 @@ async fn main() -> Result<()> {
args.working_directory.as_ref().map(set_current_dir); args.working_directory.as_ref().map(set_current_dir);
// optionally override configuration values with CLI arguments // optionally override configuration values with CLI arguments
config.config.tick_rate = apply_cli_overrides(&args, &mut config);
args.tick_rate.unwrap_or(config.config.tick_rate);
if args.no_preview {
config.ui.show_preview_panel = false;
}
// determine the channel to use based on the CLI arguments and configuration // determine the channel to use based on the CLI arguments and configuration
let channel = let channel =
@ -76,6 +72,22 @@ async fn main() -> Result<()> {
exit(0); exit(0);
} }
/// Apply overrides from the CLI arguments to the configuration.
///
/// This function mutates the configuration in place.
fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
if let Some(tick_rate) = args.tick_rate {
config.application.tick_rate = tick_rate;
}
if args.no_preview {
config.ui.show_preview_panel = false;
}
if let Some(keybindings) = &args.keybindings {
config.keybindings =
merge_keybindings(config.keybindings.clone(), keybindings);
}
}
pub fn set_current_dir(path: &String) -> Result<()> { pub fn set_current_dir(path: &String) -> Result<()> {
let path = Path::new(path); let path = Path::new(path);
if !path.exists() { if !path.exists() {
@ -239,4 +251,18 @@ mod tests {
), ),
); );
} }
#[test]
fn test_apply_cli_overrides() {
let mut config = Config::default();
let args = PostProcessedCli {
tick_rate: Some(100_f64),
no_preview: true,
..Default::default()
};
apply_cli_overrides(&args, &mut config);
assert_eq!(config.application.tick_rate, 100_f64);
assert!(!config.ui.show_preview_panel);
}
} }