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
.el .ds Aq '
.TH television 1 "television 0.10.9"
.TH television 1 "television 0.10.10"
.SH NAME
television \- A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.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
A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH OPTIONS
@ -44,6 +44,15 @@ compromise for most users.
This option is deprecated and will be removed in a future release.
.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
Passthrough keybindings (comma separated, e.g. "q,ctrl\-w,ctrl\-t")
@ -95,6 +104,6 @@ Initializes shell completion ("tv init zsh")
television\-help(1)
Print this message or the help of the given subcommand(s)
.SH VERSION
v0.10.9
v0.10.10
.SH AUTHORS
Alexandre Pasmantier <alex.pasmant@gmail.com>

View File

@ -110,7 +110,7 @@ impl App {
let (render_tx, render_rx) = mpsc::unbounded_channel();
let (_, event_rx) = 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(), {
&KeyBindings::from(passthrough_keybindings.iter().filter_map(
|s| match parse_key(s) {

View File

@ -56,6 +56,16 @@ pub struct Cli {
#[arg(short, long, value_name = "FLOAT", verbatim_doc_comment)]
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")
///
/// 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,
};
use crate::cli::args::{Cli, Command};
use crate::config::KeyBindings;
use crate::{
cable,
config::{get_config_dir, get_data_dir},
@ -28,6 +29,7 @@ pub struct PostProcessedCli {
pub command: Option<Command>,
pub working_directory: Option<String>,
pub autocomplete_prompt: Option<String>,
pub keybindings: Option<KeyBindings>,
}
impl Default for PostProcessedCli {
@ -43,12 +45,21 @@ impl Default for PostProcessedCli {
command: None,
working_directory: None,
autocomplete_prompt: None,
keybindings: None,
}
}
}
impl From<Cli> for PostProcessedCli {
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
.passthrough_keybindings
.unwrap_or_default()
@ -62,11 +73,13 @@ impl From<Cli> for PostProcessedCli {
command: preview,
delimiter: cli.delimiter.clone(),
})
.map(|preview_command| {
.map_or(PreviewKind::None, |preview_command| {
parse_preview_kind(&preview_command)
.expect("Error parsing preview command")
})
.unwrap_or(PreviewKind::None);
.map_err(|e| {
cli_parsing_error_exit(&e.to_string());
})
.unwrap()
});
let channel: ParsedCliChannel;
let working_directory: Option<String>;
@ -102,10 +115,16 @@ impl From<Cli> for PostProcessedCli {
command: cli.command,
working_directory,
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) {
eprintln!("Unknown channel: {channel}\n");
std::process::exit(1);
@ -117,6 +136,24 @@ pub enum ParsedCliChannel {
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> {
let cable_channels = cable::load_cable_channels().unwrap_or_default();
// try to parse the channel as a cable channel
@ -259,7 +296,10 @@ Data directory: {data_dir_path}"
#[cfg(test)]
mod tests {
use crate::channels::entry::PreviewType;
use crate::{
action::Action, channels::entry::PreviewType, config::Binding,
event::Key,
};
use super::*;
@ -273,6 +313,7 @@ mod tests {
delimiter: ":".to_string(),
tick_rate: Some(50.0),
frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: Some("q,ctrl-w,ctrl-t".to_string()),
input: None,
command: None,
@ -315,6 +356,7 @@ mod tests {
delimiter: ":".to_string(),
tick_rate: Some(50.0),
frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: None,
input: None,
command: None,
@ -344,6 +386,7 @@ mod tests {
delimiter: ":".to_string(),
tick_rate: Some(50.0),
frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: None,
input: None,
command: None,
@ -368,6 +411,7 @@ mod tests {
delimiter: ":".to_string(),
tick_rate: Some(50.0),
frame_rate: Some(60.0),
keybindings: None,
passthrough_keybindings: None,
input: None,
command: None,
@ -382,4 +426,36 @@ mod tests {
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
#[allow(clippy::struct_field_names)]
#[serde(default, flatten)]
pub config: AppConfig,
pub application: AppConfig,
/// Keybindings configuration
#[serde(default)]
pub keybindings: KeyBindings,
@ -176,7 +176,7 @@ impl Config {
user.keybindings = keybindings;
Config {
config: user.config,
application: user.application,
keybindings: user.keybindings,
ui: user.ui,
previewers: user.previewers,
@ -288,8 +288,8 @@ mod tests {
file.write_all(DEFAULT_CONFIG.as_bytes()).unwrap();
let config = Config::load_user_config(config_dir).unwrap();
assert_eq!(config.config.data_dir, get_data_dir());
assert_eq!(config.config.config_dir, get_config_dir());
assert_eq!(config.application.data_dir, get_data_dir());
assert_eq!(config.application.config_dir, get_config_dir());
assert_eq!(config, toml::from_str(DEFAULT_CONFIG).unwrap());
}
@ -310,7 +310,7 @@ mod tests {
toml::from_str(DEFAULT_CONFIG).unwrap();
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.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers);
@ -364,7 +364,7 @@ mod tests {
let mut default_config: Config =
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.theme = "television".to_string();
default_config.previewers.file.theme =
@ -392,7 +392,7 @@ mod tests {
.insert("command_history".to_string(), "ctrl-h".to_string());
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.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers);

View File

@ -19,7 +19,7 @@ use television::cli::{
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::{completion_script, Shell},
@ -45,11 +45,7 @@ async fn main() -> Result<()> {
args.working_directory.as_ref().map(set_current_dir);
// optionally override configuration values with CLI arguments
config.config.tick_rate =
args.tick_rate.unwrap_or(config.config.tick_rate);
if args.no_preview {
config.ui.show_preview_panel = false;
}
apply_cli_overrides(&args, &mut config);
// determine the channel to use based on the CLI arguments and configuration
let channel =
@ -76,6 +72,22 @@ async fn main() -> Result<()> {
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<()> {
let path = Path::new(path);
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);
}
}