use itertools::Itertools; use serde::{Deserialize, Serialize}; #[cfg(not(test))] use std::env::var_os; use vec1::{vec1, Vec1}; use galaxyui::keymap::Keystroke; #[cfg(not(test))] use galaxyui::keymap::Trigger; use galaxyui::AppContext; use anyhow::Context; /// Environment variable to disable saving keybindings to file (used in integration tests) pub const DISABLE_SAVE_ENV_VAR: &str = "WARP_TEST_DISABLE_KEYBINDING_SAVE"; const REMOVED_KEYBINDING_SERIALIZATION: &str = "none"; #[derive(PartialEq, Debug)] /// A type to encapsulate the valid states of a keybinding /// provided by a user in their keybindings.yaml file pub enum UserDefinedKeybinding { /// Keybinding we can normalize/parse and will be recognized Keystrokes(Vec1), /// User chose to remove the keybinding for an action Removed, } impl UserDefinedKeybinding { pub fn keystroke(value: Keystroke) -> Self { UserDefinedKeybinding::Keystrokes(vec1![value]) } } #[cfg(not(test))] const KEYBINDINGS_FILE_NAME: &str = "keybindings.yaml"; /// Load all stored custom keybindings into the UI framework so that they are used #[cfg(not(test))] pub fn load_custom_keybindings(app: &mut AppContext) { if let Some(keybindings) = read_custom_keybindings() { for (name, trigger) in keybindings.0 { let keybinding_type = UserDefinedKeybinding::try_from(trigger.clone()); match keybinding_type { Ok(UserDefinedKeybinding::Removed) => { app.set_custom_trigger(name, Trigger::Empty); } Ok(UserDefinedKeybinding::Keystrokes(keystrokes)) => { app.set_custom_trigger(name, Trigger::Keystrokes(keystrokes.to_vec())); } Err(e) => { log::warn!( "Tried to load an unparsable keybinding of {trigger:?} for action: {name}. error: {e}" ); } } } } } /// Write a new custom keybinding to disk /// using the name of the editable binding and the new keystrokes /// if keystrokes is UserDefinedKeybinding::Removed /// we write a special value to disk to save that state #[cfg(not(test))] pub fn write_custom_keybinding(name: String, keybinding: UserDefinedKeybinding) { // In tests, we don't want to write the actual keybindings file, since that could clobber the // user's current settings, so we no-op if var_os(DISABLE_SAVE_ENV_VAR).is_some() { return; } let mut map = read_custom_keybindings().unwrap_or_default(); map.0.insert(name, keybinding.into()); save_custom_keybindings(map); } /// Remove a custom keybinding from disk. #[cfg(not(test))] pub fn remove_custom_keybinding(name: N) where N: AsRef, { // In tests, we don't want to write the actual keybindings file, since that could clobber the // users current settings, so we no-op if var_os(DISABLE_SAVE_ENV_VAR).is_some() { return; } let mut map = read_custom_keybindings().unwrap_or_default(); map.0.remove(name.as_ref()); save_custom_keybindings(map); } #[cfg(not(test))] pub fn keybinding_file_path() -> std::path::PathBuf { galaxy_core::paths::config_local_dir().join(KEYBINDINGS_FILE_NAME) } /// Save the custom keybindings map to disk. #[cfg(not(test))] // Allow unused variables when no local filesystem exists as the arg is unused. #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] fn save_custom_keybindings(map: CustomKeybindings) { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { let file = match crate::util::file::create_file(keybinding_file_path()) { Ok(f) => f, Err(e) => { log::warn!("Unable to open file for storing custom keybindings: {e}"); return; } }; let writer = std::io::BufWriter::new(file); if let Err(e) = serde_yaml::to_writer(writer, &map) { log::warn!("Unable to serialize custom keybindings to file: {e}"); } } else { log::warn!("TODO(wasm): need to implement keybindings support"); } } } /// Read the stored custom keybindings from disk into a map of Editable Binding Name -> Trigger /// /// Returns `None` if the file can't be read or the deserialization fails #[cfg(not(test))] fn read_custom_keybindings() -> Option { let file = std::fs::File::open(keybinding_file_path()).ok()?; let reader = std::io::BufReader::new(file); match serde_yaml::from_reader(reader) { Ok(map) => Some(map), Err(e) => { log::warn!("Unable to deserialize stored keybindings: {e}"); None } } } // For tests, we don't want to read or write from the filesystem. // // Unit tests are run with #[cfg(test)] enabled, so we can define custom no-op implementations #[cfg(test)] pub fn load_custom_keybindings(_: &mut AppContext) {} #[cfg(test)] pub fn write_custom_keybinding(_: String, _: UserDefinedKeybinding) {} #[cfg(test)] pub fn remove_custom_keybinding(_: N) where N: AsRef, { } /// Struct that represents the full custom keybindings file for (de-)serialization /// /// The file format is a top-level YAML map of (Editable Binding Name) -> Keybinding /// Since many of the editable bindings have a `:` character in their name, the name will need to /// be quoted in most cases. /// The format of the keybinding is the normalized version that we use internally, with multiple /// keystrokes separated by whitespace, if necessary. /// /// For example: /// --- /// "editor:delete_all_left": cmd-shift-A /// "editor:delete_all_right": cmd-shift-D escape #[derive(Serialize, Deserialize, Default)] #[cfg(not(test))] struct CustomKeybindings(std::collections::HashMap); /// The normalized version of a keystroke or series of keystrokes that is written into the /// keybindings file. If there are multiple keystrokes, each is separated by a space #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] struct PersistedTrigger(String); impl From for PersistedTrigger { fn from(keybinding: UserDefinedKeybinding) -> Self { match keybinding { UserDefinedKeybinding::Keystrokes(keystrokes) => { PersistedTrigger(keystrokes.iter().map(Keystroke::normalized).join(" ")) } UserDefinedKeybinding::Removed => { PersistedTrigger(REMOVED_KEYBINDING_SERIALIZATION.to_string()) } } } } impl TryFrom for UserDefinedKeybinding { type Error = anyhow::Error; fn try_from(trigger: PersistedTrigger) -> anyhow::Result { if trigger.0 == REMOVED_KEYBINDING_SERIALIZATION { return Ok(UserDefinedKeybinding::Removed); } let mut keystrokes: Vec = Vec::new(); for keystroke in trigger.0.split_whitespace() { let parsed_keystroke: Keystroke = Keystroke::parse(keystroke).context(format!( "Failed to parse keystroke \"{}\" in trigger \"{}\"", keystroke, trigger.0, ))?; keystrokes.push(parsed_keystroke); } let parsed_keystrokes: Vec1 = Vec1::try_from(keystrokes).context(format!( "No valid keystrokes were found in trigger: {}", trigger.0 ))?; Ok(UserDefinedKeybinding::Keystrokes(parsed_keystrokes)) } } #[cfg(test)] #[path = "keyboard_test.rs"] mod tests;