diff --git a/.config/config.toml b/.config/config.toml index 1b4e76a..96ef5ea 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -149,52 +149,30 @@ toggle_preview = "ctrl-o" # E.g. typing `git checkout ` will open television with a list of # branches to choose from. -[shell_integration.commands] -# Add your commands here. Each key is a command that will trigger tv with the -# corresponding channel as value. -# Example: say you want the following prompts to trigger the following channels +[shell_integration.channel_triggers] +# Add your channel triggers here. Each key is a channel that will be triggered +# by the corresponding commands. +# Example: say you want the following commands to trigger the following channels # when pressing : -# `git checkout` should trigger the `git-branches` channel -# `ls` should trigger the `dirs` channel -# `cat` should trigger the `files` channel +# `git checkout` should trigger the `git-branches` channel +# `ls` should trigger the `dirs` channel +# `cat` and `cp` should trigger the `files` channel # # You would add the following to your configuration file: # ``` -# [shell_integration.commands] -# "git checkout" = "git-branch" -# "ls" = "dirs" -# "cat" = "files" +# [shell_integration.channel_triggers] +# "git-branches" = ["git checkout"] +# "dirs" = ["ls"] +# "files" = ["cat", "cp"] # ``` +"env" = ["export", "unset"] +"dirs" = ["cd", "ls", "rmdir"] +"files" = ["cat", "less", "head", "tail", "vim", "bat"] +"git-diff" = ["git add"] +"git-branch" = ["git checkout", "git branch -d"] +"docker-images" = ["docker run"] +"git-repos" = ["nvim"] -# environment variables -"export" = "env" -"unset" = "env" - -# dirs channel -"cd" = "dirs" -"ls" = "dirs" -"rmdir" = "dirs" - -# files channel -"cat" = "files" -"less" = "files" -"head" = "files" -"tail" = "files" -"vim" = "files" -"bat" = "files" - -# git-diff channel -"git add" = "git-diff" - -# git-branch channel -"git checkout" = "git-branch" -"git branch -d" = "git-branch" - -# docker-images channel -"docker run" = "docker-images" - -# gitrepos channel -"nvim" = "git-repos" [shell_integration.keybindings] # controls which key binding should trigger tv @@ -203,4 +181,3 @@ toggle_preview = "ctrl-o" # controls which keybinding should trigger tv # for command history "command_history" = "ctrl-r" - diff --git a/Cargo.lock b/Cargo.lock index 9ee80cf..3ca92df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1928,6 +1928,7 @@ dependencies = [ "strum", "syntect", "television-derive", + "tempfile", "thiserror 2.0.11", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 796389c..250df5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ winapi-util = "0.1.9" [dev-dependencies] criterion = { version = "0.5", features = ["async_tokio"] } +tempfile = "3.16.0" [features] simd = ["dep:simdutf8"] diff --git a/benches/main/draw.rs b/benches/main/draw.rs index ed7e7ee..8b6ec47 100644 --- a/benches/main/draw.rs +++ b/benches/main/draw.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use television::action::Action; use television::channels::OnAir; use television::channels::{files::Channel, TelevisionChannel}; -use television::config::Config; +use television::config::{Config, ConfigEnv}; use television::television::Television; use tokio::runtime::Runtime; @@ -20,7 +20,7 @@ fn draw(c: &mut Criterion) { b.to_async(&rt).iter_batched( // FIXME: this is kind of hacky || { - let config = Config::new().unwrap(); + let config = Config::new(&ConfigEnv::init().unwrap()).unwrap(); let backend = TestBackend::new(width, height); let terminal = Terminal::new(backend).unwrap(); let (tx, _) = tokio::sync::mpsc::unbounded_channel(); diff --git a/television/config/mod.rs b/television/config/mod.rs index 2b71823..f3bdeac 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -1,7 +1,11 @@ #![allow(clippy::module_name_repetitions, clippy::ref_option)] -use std::{env, hash::Hash, path::PathBuf}; +use std::{ + env, + hash::Hash, + path::{Path, PathBuf}, +}; -use anyhow::Result; +use anyhow::{Context, Result}; use directories::ProjectDirs; use keybindings::merge_keybindings; pub use keybindings::{parse_key, Binding, KeyBindings}; @@ -85,51 +89,98 @@ lazy_static! { const CONFIG_FILE_NAME: &str = "config.toml"; -impl Config { - #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] - pub fn new() -> Result { - // Load the default_config values as base defaults - let default_config: Config = toml::from_str(DEFAULT_CONFIG) - .expect("Error parsing default config"); +pub struct ConfigEnv { + _data_dir: PathBuf, + config_dir: PathBuf, +} - // initialize the config builder +impl ConfigEnv { + pub fn init() -> Result { let data_dir = get_data_dir(); let config_dir = get_config_dir(); std::fs::create_dir_all(&config_dir) - .expect("Failed creating configuration directory"); + .context("Failed creating configuration directory")?; std::fs::create_dir_all(&data_dir) - .expect("Failed creating data directory"); + .context("Failed creating data directory")?; - if config_dir.join(CONFIG_FILE_NAME).is_file() { - debug!("Found config file at {:?}", config_dir); + Ok(Self { + _data_dir: data_dir, + config_dir, + }) + } +} - let path = config_dir.join(CONFIG_FILE_NAME); - let contents = std::fs::read_to_string(&path)?; +impl Config { + #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] + pub fn new(config_env: &ConfigEnv) -> Result { + // Load the default_config values as base defaults + let default_config: Config = toml::from_str(DEFAULT_CONFIG) + .expect("Error parsing default config"); - let cfg: Config = toml::from_str(&contents).unwrap_or_else(|e| { - warn!( - "Error parsing config file, using default configuration: {}" , e - ); - default_config.clone() - }); + // if a config file exists, load it and merge it with the default configuration + if config_env.config_dir.join(CONFIG_FILE_NAME).is_file() { + debug!("Found config file at {:?}", config_env.config_dir); - // merge keybindings with default keybindings - let keybindings = merge_keybindings( - default_config.keybindings, - &cfg.keybindings, - ); - let cfg = Config { keybindings, ..cfg }; + let user_cfg: Config = + Self::load_user_config(&config_env.config_dir)?; - debug!("Config: {:?}", cfg); - Ok(cfg) + // merge the user configuration with the default configuration + let final_cfg = + Self::merge_user_with_default(default_config, user_cfg); + + debug!("Config: {:?}", final_cfg); + Ok(final_cfg) } else { - warn!("No config file found at {:?}, creating default configuration file at that location.", config_dir); + // otherwise, create the default configuration file + warn!("No config file found at {:?}, creating default configuration file at that location.", config_env.config_dir); // create the default configuration file in the user's config directory - std::fs::write(config_dir.join(CONFIG_FILE_NAME), DEFAULT_CONFIG)?; + std::fs::write( + config_env.config_dir.join(CONFIG_FILE_NAME), + DEFAULT_CONFIG, + )?; Ok(default_config) } } + + fn load_user_config(config_dir: &Path) -> Result { + let path = config_dir.join(CONFIG_FILE_NAME); + let contents = std::fs::read_to_string(&path)?; + let user_cfg: Config = toml::from_str(&contents) + .context("Error parsing configuration file.")?; + Ok(user_cfg) + } + + fn merge_user_with_default( + mut default: Config, + mut user: Config, + ) -> Config { + // merge shell integration triggers with commands + default.shell_integration.merge_triggers(); + user.shell_integration.merge_triggers(); + // merge shell integration commands with default commands + let mut merged_commands = default.shell_integration.commands.clone(); + merged_commands.extend(user.shell_integration.commands.clone()); + user.shell_integration.commands = merged_commands; + // merge shell integration keybindings with default keybindings + let mut merged_keybindings = + default.shell_integration.keybindings.clone(); + merged_keybindings.extend(user.shell_integration.keybindings.clone()); + user.shell_integration.keybindings = merged_keybindings; + + // merge keybindings with default keybindings + let keybindings = + merge_keybindings(default.keybindings.clone(), &user.keybindings); + user.keybindings = keybindings; + + Config { + config: user.config, + keybindings: user.keybindings, + ui: user.ui, + previewers: user.previewers, + shell_integration: user.shell_integration, + } + } } pub fn get_data_dir() -> PathBuf { @@ -179,3 +230,175 @@ fn default_frame_rate() -> f64 { fn default_tick_rate() -> f64 { 50.0 } + +#[cfg(test)] +mod tests { + use crate::action::Action; + use crate::event::Key; + use crate::television::Mode; + + use super::*; + use rustc_hash::FxHashMap; + use std::fs::File; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn test_get_data_dir() { + let data_dir = get_data_dir(); + assert!(data_dir.is_absolute()); + } + + #[test] + fn test_get_config_dir() { + let config_dir = get_config_dir(); + assert!(config_dir.is_absolute()); + } + + #[test] + fn test_load_user_config() { + let dir = tempdir().unwrap(); + let config_dir = dir.path(); + let config_file = config_dir.join(CONFIG_FILE_NAME); + let mut file = File::create(&config_file).unwrap(); + 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, toml::from_str(DEFAULT_CONFIG).unwrap()); + } + + #[test] + fn test_config_new_empty_user_cfg() { + // write user config to a file + let dir = tempdir().unwrap(); + let config_dir = dir.path(); + let config_file = config_dir.join(CONFIG_FILE_NAME); + let _ = File::create(&config_file).unwrap(); + + let config_env = ConfigEnv { + _data_dir: get_data_dir(), + config_dir: config_dir.to_path_buf(), + }; + let config = Config::new(&config_env).unwrap(); + let mut default_config: Config = + toml::from_str(DEFAULT_CONFIG).unwrap(); + default_config.shell_integration.merge_triggers(); + + assert_eq!(config.config, default_config.config); + assert_eq!(config.keybindings, default_config.keybindings); + assert_eq!(config.ui, default_config.ui); + assert_eq!(config.previewers, default_config.previewers); + // backwards compatibility + assert_eq!( + config.shell_integration.commands, + default_config.shell_integration.commands + ); + assert_eq!( + config.shell_integration.keybindings, + default_config.shell_integration.keybindings + ); + } + + const USER_CONFIG_1: &str = r#" + frame_rate = 30.0 + + [ui] + ui_scale = 40 + theme = "television" + + [previewers.file] + theme = "Visual Studio Dark" + + [keybindings.Channel] + toggle_help = ["ctrl-a", "ctrl-b"] + + [keybindings.RemoteControl] + toggle_help = ["ctrl-c", "ctrl-d"] + + [shell_integration.commands] + "git add" = "git-diff" + + [shell_integration.keybindings] + "command_history" = "ctrl-h" + + "#; + + #[test] + fn test_config_new_with_user_cfg() { + // write user config to a file + let dir = tempdir().unwrap(); + let config_dir = dir.path(); + let config_file = config_dir.join(CONFIG_FILE_NAME); + let mut file = File::create(&config_file).unwrap(); + file.write_all(USER_CONFIG_1.as_bytes()).unwrap(); + + let config_env = ConfigEnv { + _data_dir: get_data_dir(), + config_dir: config_dir.to_path_buf(), + }; + let config = Config::new(&config_env).unwrap(); + + let mut default_config: Config = + toml::from_str(DEFAULT_CONFIG).unwrap(); + default_config.config.frame_rate = 30.0; + default_config.ui.ui_scale = 40; + default_config.ui.theme = "television".to_string(); + default_config.previewers.file.theme = + "Visual Studio Dark".to_string(); + default_config + .keybindings + .get_mut(&Mode::Channel) + .unwrap() + .extend({ + let mut map = FxHashMap::default(); + map.insert( + Action::ToggleHelp, + Binding::MultipleKeys(vec![ + Key::Ctrl('a'), + Key::Ctrl('b'), + ]), + ); + map + }); + default_config + .keybindings + .get_mut(&Mode::RemoteControl) + .unwrap() + .extend({ + let mut map = FxHashMap::default(); + map.insert( + Action::ToggleHelp, + Binding::MultipleKeys(vec![ + Key::Ctrl('c'), + Key::Ctrl('d'), + ]), + ); + map + }); + + default_config + .shell_integration + .commands + .extend(vec![("git add".to_string(), "git-diff".to_string())]); + default_config + .shell_integration + .keybindings + .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.keybindings, default_config.keybindings); + assert_eq!(config.ui, default_config.ui); + assert_eq!(config.previewers, default_config.previewers); + assert_eq!( + config.shell_integration.commands, + default_config.shell_integration.commands + ); + assert_eq!( + config.shell_integration.keybindings, + default_config.shell_integration.keybindings + ); + } +} diff --git a/television/config/previewers.rs b/television/config/previewers.rs index 6bab0e6..f89b9e9 100644 --- a/television/config/previewers.rs +++ b/television/config/previewers.rs @@ -31,7 +31,7 @@ impl Default for FilePreviewerConfig { fn default() -> Self { Self { //max_file_size: 1024 * 1024, - theme: String::from("gruvbox-dark"), + theme: String::from("TwoDark"), } } } diff --git a/television/config/shell_integration.rs b/television/config/shell_integration.rs index 0820fca..c12f4db 100644 --- a/television/config/shell_integration.rs +++ b/television/config/shell_integration.rs @@ -2,13 +2,19 @@ use std::hash::Hash; use crate::config::parse_key; use crate::event::Key; +use crate::utils::hashmaps; use rustc_hash::FxHashMap; use serde::Deserialize; #[derive(Clone, Debug, Deserialize, Default, PartialEq)] #[serde(default)] pub struct ShellIntegrationConfig { + /// DEPRECATED: This is a legacy configuration option that is no longer used. + /// It is kept here for backwards compatibility. + /// {command: channel} pub commands: FxHashMap, + /// {channel: [commands]} + pub channel_triggers: FxHashMap>, pub keybindings: FxHashMap, } @@ -19,6 +25,21 @@ impl Hash for ShellIntegrationConfig { } } +impl ShellIntegrationConfig { + /// Merge the channel triggers into the commands hashmap + /// This is done to maintain backwards compatibility with the old configuration + /// format. + /// + /// {command: channel} + {channel: [commands]} => {command: channel} + pub fn merge_triggers(&mut self) { + // invert the hashmap to get {command: channel} + let inverted_triggers = + hashmaps::invert_hashmap(&self.channel_triggers); + // merge the inverted hashmap with the existing commands hashmap + self.commands.extend(inverted_triggers); + } +} + const SMART_AUTOCOMPLETE_CONFIGURATION_KEY: &str = "smart_autocomplete"; const COMMAND_HISTORY_CONFIGURATION_KEY: &str = "command_history"; const DEFAULT_SHELL_AUTOCOMPLETE_KEY: char = 'T'; diff --git a/television/main.rs b/television/main.rs index 01ac4ac..9eed959 100644 --- a/television/main.rs +++ b/television/main.rs @@ -16,7 +16,7 @@ use television::cli::{ PostProcessedCli, }; -use television::config::Config; +use television::config::{Config, ConfigEnv}; use television::utils::shell::render_autocomplete_script_template; use television::utils::{ shell::{completion_script, Shell}, @@ -31,7 +31,7 @@ async fn main() -> Result<()> { let args: PostProcessedCli = Cli::parse().into(); debug!("{:?}", args); - let mut config = Config::new()?; + let mut config = Config::new(&ConfigEnv::init()?)?; if let Some(command) = args.command { match command { diff --git a/television/screen/layout.rs b/television/screen/layout.rs index 3cabc6d..7049317 100644 --- a/television/screen/layout.rs +++ b/television/screen/layout.rs @@ -49,9 +49,9 @@ impl HelpBarLayout { #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Hash)] pub enum InputPosition { #[serde(rename = "top")] + #[default] Top, #[serde(rename = "bottom")] - #[default] Bottom, } diff --git a/television/utils/hashmaps.rs b/television/utils/hashmaps.rs new file mode 100644 index 0000000..dce7932 --- /dev/null +++ b/television/utils/hashmaps.rs @@ -0,0 +1,19 @@ +use std::collections::HashMap; +use std::hash::Hash; + +pub fn invert_hashmap( + hashmap: &HashMap, +) -> HashMap +where + K: Eq + Hash + Clone, + V: Eq + Hash + Clone + IntoIterator, + I: Eq + Hash + Clone, +{ + let mut inverted = HashMap::new(); + for (key, values) in hashmap { + for value in values.clone() { + inverted.insert(value, key.clone()); + } + } + inverted +} diff --git a/television/utils/mod.rs b/television/utils/mod.rs index 8328621..80fa27a 100644 --- a/television/utils/mod.rs +++ b/television/utils/mod.rs @@ -1,6 +1,7 @@ pub mod cache; pub mod command; pub mod files; +pub mod hashmaps; pub mod indices; pub mod input; pub mod metadata;