diff --git a/native/libmacbridge/bridge.h b/native/libmacbridge/bridge.h index dd86bf4..33848c9 100644 --- a/native/libmacbridge/bridge.h +++ b/native/libmacbridge/bridge.h @@ -148,5 +148,11 @@ int32_t get_clipboard(char * buffer, int32_t size); */ int32_t set_clipboard(char * text); +/* + * Set the clipboard image to the given file + */ +int32_t set_clipboard_image(char * path); + + }; #endif //ESPANSO_BRIDGE_H diff --git a/native/libmacbridge/bridge.mm b/native/libmacbridge/bridge.mm index 02ed1a5..8b31e07 100644 --- a/native/libmacbridge/bridge.mm +++ b/native/libmacbridge/bridge.mm @@ -230,6 +230,24 @@ int32_t set_clipboard(char * text) { [pasteboard setString:nsText forType:NSPasteboardTypeString]; } +int32_t set_clipboard_image(char *path) { + NSString *pathString = [NSString stringWithUTF8String:path]; + NSImage *image = [[NSImage alloc] initWithContentsOfFile:pathString]; + int result = 0; + + if (image != nil) { + NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; + [pasteboard clearContents]; + NSArray *copiedObjects = [NSArray arrayWithObject:image]; + [pasteboard writeObjects:copiedObjects]; + result = 1; + } + [image release]; + + return result; +} + + // CONTEXT MENU int32_t show_context_menu(MenuItem * items, int32_t count) { @@ -273,4 +291,4 @@ int32_t prompt_accessibility() { void open_settings_panel() { NSString *urlString = @"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:urlString]]; -} +} \ No newline at end of file diff --git a/src/bridge/macos.rs b/src/bridge/macos.rs index 0b09101..f99a6f8 100644 --- a/src/bridge/macos.rs +++ b/src/bridge/macos.rs @@ -43,6 +43,7 @@ extern { // Clipboard pub fn get_clipboard(buffer: *mut c_char, size: i32) -> i32; pub fn set_clipboard(text: *const c_char) -> i32; + pub fn set_clipboard_image(path: *const c_char) -> i32; // UI pub fn register_icon_click_callback(cb: extern fn(_self: *mut c_void)); diff --git a/src/clipboard/macos.rs b/src/clipboard/macos.rs index 65c10bf..6c653db 100644 --- a/src/clipboard/macos.rs +++ b/src/clipboard/macos.rs @@ -18,8 +18,10 @@ */ use std::os::raw::c_char; -use crate::bridge::macos::{get_clipboard, set_clipboard}; +use crate::bridge::macos::*; use std::ffi::{CStr, CString}; +use std::path::Path; +use log::{error, warn}; pub struct MacClipboardManager { @@ -52,6 +54,24 @@ impl super::ClipboardManager for MacClipboardManager { } } } + + fn set_clipboard_image(&self, image_path: &Path) { + // Make sure the image exist beforehand + if !image_path.exists() { + error!("Image not found in path: {:?}", image_path); + }else{ + let path_string = image_path.to_string_lossy().into_owned(); + let res = CString::new(path_string); + if let Ok(path) = res { + unsafe { + let result = set_clipboard_image(path.as_ptr()); + if result != 1 { + warn!("Couldn't set clipboard for image: {:?}", image_path) + } + } + } + } + } } impl MacClipboardManager { diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs index c99a4cc..b04d5c2 100644 --- a/src/clipboard/mod.rs +++ b/src/clipboard/mod.rs @@ -17,6 +17,8 @@ * along with espanso. If not, see . */ +use std::path::Path; + #[cfg(target_os = "windows")] mod windows; @@ -29,6 +31,7 @@ mod macos; pub trait ClipboardManager { fn get_clipboard(&self) -> Option; fn set_clipboard(&self, payload: &str); + fn set_clipboard_image(&self, image_path: &Path); } // LINUX IMPLEMENTATION diff --git a/src/config/mod.rs b/src/config/mod.rs index 50361ae..78326e3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -426,6 +426,7 @@ mod tests { use std::io::Write; use tempfile::{NamedTempFile, TempDir}; use std::any::Any; + use crate::matcher::{TextContent, MatchContentType}; const TEST_WORKING_CONFIG_FILE : &str = include_str!("../res/test/working_config.yml"); const TEST_CONFIG_FILE_WITH_BAD_YAML : &str = include_str!("../res/test/config_with_bad_yaml.yml"); @@ -727,7 +728,13 @@ mod tests { assert_eq!(config_set.default.matches.len(), 2); assert_eq!(config_set.specific[0].matches.len(), 2); - assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":lol" && x.replace == "newstring").is_some()); + assert!(config_set.specific[0].matches.iter().find(|x| { + if let MatchContentType::Text(content) = &x.content { + x.trigger == ":lol" && content.replace == "newstring" + }else{ + false + } + }).is_some()); assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":yess").is_some()); } @@ -755,7 +762,13 @@ mod tests { assert_eq!(config_set.default.matches.len(), 2); assert_eq!(config_set.specific[0].matches.len(), 1); - assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == "hello" && x.replace == "newstring").is_some()); + assert!(config_set.specific[0].matches.iter().find(|x| { + if let MatchContentType::Text(content) = &x.content { + x.trigger == "hello" && content.replace == "newstring" + }else{ + false + } + }).is_some()); } #[test] @@ -897,7 +910,13 @@ mod tests { let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); assert_eq!(config_set.specific.len(), 0); assert_eq!(config_set.default.matches.len(), 1); - assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta" && m.replace == "world")); + assert!(config_set.default.matches.iter().any(|m| { + if let MatchContentType::Text(content) = &m.content { + m.trigger == "hasta" && content.replace == "world" + }else{ + false + } + })); } #[test] diff --git a/src/engine.rs b/src/engine.rs index bc604c1..5abdde0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -17,7 +17,7 @@ * along with espanso. If not, see . */ -use crate::matcher::{Match, MatchReceiver}; +use crate::matcher::{Match, MatchReceiver, MatchContentType}; use crate::keyboard::KeyboardManager; use crate::config::ConfigManager; use crate::config::BackendType; @@ -29,6 +29,7 @@ use crate::extension::Extension; use std::cell::RefCell; use std::process::exit; use std::collections::HashMap; +use std::path::PathBuf; use regex::{Regex, Captures}; pub struct Engine<'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, @@ -118,97 +119,110 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa self.keyboard_manager.delete_string(char_count); - let mut target_string = if m._has_vars { - let mut output_map = HashMap::new(); + // Manage the different types of matches + match &m.content { + // Text Match + MatchContentType::Text(content) => { + let mut target_string = if content._has_vars { + let mut output_map = HashMap::new(); - for variable in m.vars.iter() { - let extension = self.extension_map.get(&variable.var_type); - if let Some(extension) = extension { - let ext_out = extension.calculate(&variable.params); - if let Some(output) = ext_out { - output_map.insert(variable.name.clone(), output); - }else{ - output_map.insert(variable.name.clone(), "".to_owned()); - warn!("Could not generate output for variable: {}", variable.name); - } - }else{ - error!("No extension found for variable type: {}", variable.var_type); - } - } - - // Replace the variables - let result = VAR_REGEX.replace_all(&m.replace, |caps: &Captures| { - let var_name = caps.name("name").unwrap().as_str(); - let output = output_map.get(var_name); - output.unwrap() - }); - - result.to_string() - }else{ // No variables, simple text substitution - m.replace.clone() - }; - - // If a trailing separator was counted in the match, add it back to the target string - if let Some(trailing_separator) = trailing_separator { - if trailing_separator == '\r' { // If the trailing separator is a carriage return, - target_string.push('\n'); // convert it to new line - }else{ - target_string.push(trailing_separator); - } - } - - // Convert Windows style newlines into unix styles - target_string = target_string.replace("\r\n", "\n"); - - // Calculate cursor rewind moves if a Cursor Hint is present - let index = target_string.find("$|$"); - let cursor_rewind = if let Some(index) = index { - // Convert the byte index to a char index - let char_str = &target_string[0..index]; - let char_index = char_str.chars().count(); - let total_size = target_string.chars().count(); - - // Remove the $|$ placeholder - target_string = target_string.replace("$|$", ""); - - // Calculate the amount of rewind moves needed (LEFT ARROW). - // Subtract also 3, equal to the number of chars of the placeholder "$|$" - let moves = (total_size - char_index - 3) as i32; - Some(moves) - }else{ - None - }; - - match config.backend { - BackendType::Inject => { - // Send the expected string. On linux, newlines are managed automatically - // while on windows and macos, we need to emulate a Enter key press. - - if cfg!(target_os = "linux") { - self.keyboard_manager.send_string(&target_string); - }else{ - // To handle newlines, substitute each "\n" char with an Enter key press. - let splits = target_string.split('\n'); - - for (i, split) in splits.enumerate() { - if i > 0 { - self.keyboard_manager.send_enter(); + for variable in content.vars.iter() { + let extension = self.extension_map.get(&variable.var_type); + if let Some(extension) = extension { + let ext_out = extension.calculate(&variable.params); + if let Some(output) = ext_out { + output_map.insert(variable.name.clone(), output); + }else{ + output_map.insert(variable.name.clone(), "".to_owned()); + warn!("Could not generate output for variable: {}", variable.name); + } + }else{ + error!("No extension found for variable type: {}", variable.var_type); } - - self.keyboard_manager.send_string(split); } + + // Replace the variables + let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| { + let var_name = caps.name("name").unwrap().as_str(); + let output = output_map.get(var_name); + output.unwrap() + }); + + result.to_string() + }else{ // No variables, simple text substitution + content.replace.clone() + }; + + // If a trailing separator was counted in the match, add it back to the target string + if let Some(trailing_separator) = trailing_separator { + if trailing_separator == '\r' { // If the trailing separator is a carriage return, + target_string.push('\n'); // convert it to new line + }else{ + target_string.push(trailing_separator); + } + } + + // Convert Windows style newlines into unix styles + target_string = target_string.replace("\r\n", "\n"); + + // Calculate cursor rewind moves if a Cursor Hint is present + let index = target_string.find("$|$"); + let cursor_rewind = if let Some(index) = index { + // Convert the byte index to a char index + let char_str = &target_string[0..index]; + let char_index = char_str.chars().count(); + let total_size = target_string.chars().count(); + + // Remove the $|$ placeholder + target_string = target_string.replace("$|$", ""); + + // Calculate the amount of rewind moves needed (LEFT ARROW). + // Subtract also 3, equal to the number of chars of the placeholder "$|$" + let moves = (total_size - char_index - 3) as i32; + Some(moves) + }else{ + None + }; + + match config.backend { + BackendType::Inject => { + // Send the expected string. On linux, newlines are managed automatically + // while on windows and macos, we need to emulate a Enter key press. + + if cfg!(target_os = "linux") { + self.keyboard_manager.send_string(&target_string); + }else{ + // To handle newlines, substitute each "\n" char with an Enter key press. + let splits = target_string.split('\n'); + + for (i, split) in splits.enumerate() { + if i > 0 { + self.keyboard_manager.send_enter(); + } + + self.keyboard_manager.send_string(split); + } + } + }, + BackendType::Clipboard => { + self.clipboard_manager.set_clipboard(&target_string); + self.keyboard_manager.trigger_paste(); + }, + } + + if let Some(moves) = cursor_rewind { + // Simulate left arrow key presses to bring the cursor into the desired position + self.keyboard_manager.move_cursor_left(moves); } }, - BackendType::Clipboard => { - self.clipboard_manager.set_clipboard(&target_string); + + // Image Match + MatchContentType::Image(content) => { + let image_path = PathBuf::from(&content.path); + self.clipboard_manager.set_clipboard_image(&image_path); self.keyboard_manager.trigger_paste(); }, } - - if let Some(moves) = cursor_rewind { - // Simulate left arrow key presses to bring the cursor into the desired position - self.keyboard_manager.move_cursor_left(moves); - } } fn on_enable_update(&self, status: bool) { diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 4667318..2811a07 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -28,18 +28,34 @@ pub(crate) mod scrolling; #[derive(Debug, Serialize, Clone)] pub struct Match { pub trigger: String, - pub replace: String, - pub vars: Vec, + pub content: MatchContentType, pub word: bool, - #[serde(skip_serializing)] - pub _has_vars: bool, - // Automatically calculated from the trigger, used by the matcher to check for correspondences. #[serde(skip_serializing)] pub _trigger_sequence: Vec, } +#[derive(Debug, Serialize, Clone)] +pub enum MatchContentType { + Text(TextContent), + Image(ImageContent), +} + +#[derive(Debug, Serialize, Clone)] +pub struct TextContent { + pub replace: String, + pub vars: Vec, + + #[serde(skip_serializing)] + pub _has_vars: bool, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ImageContent { + pub path: String, +} + impl <'de> serde::Deserialize<'de> for Match { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de> { @@ -53,15 +69,10 @@ impl<'a> From<&'a AutoMatch> for Match{ fn from(other: &'a AutoMatch) -> Self { lazy_static! { static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)\\s*\\}\\}").unwrap(); - } + }; // TODO: may need to replace windows newline (\r\n) with newline only (\n) - let new_replace = other.replace.clone(); - - // Check if the match contains variables - let has_vars = VAR_REGEX.is_match(&other.replace); - // Calculate the trigger sequence let mut trigger_sequence = Vec::new(); let trigger_chars : Vec = other.trigger.chars().collect(); @@ -72,12 +83,34 @@ impl<'a> From<&'a AutoMatch> for Match{ trigger_sequence.push(TriggerEntry::WordSeparator); } + let content = if let Some(replace) = &other.replace { // Text match + let new_replace = replace.clone(); + + // Check if the match contains variables + let has_vars = VAR_REGEX.is_match(replace); + + let content = TextContent { + replace: new_replace, + vars: other.vars.clone(), + _has_vars: has_vars, + }; + + MatchContentType::Text(content) + }else if let Some(image_path) = &other.image_path { // Image match + let content = ImageContent { + path: image_path.clone() + }; + + MatchContentType::Image(content) + }else { + eprintln!("ERROR: no action specified for match {}, please specify either 'replace' or 'image_path'", other.trigger); + std::process::exit(2); + }; + Self { trigger: other.trigger.clone(), - replace: new_replace, - vars: other.vars.clone(), + content, word: other.word.clone(), - _has_vars: has_vars, _trigger_sequence: trigger_sequence, } } @@ -87,7 +120,12 @@ impl<'a> From<&'a AutoMatch> for Match{ #[derive(Debug, Serialize, Deserialize, Clone)] struct AutoMatch { pub trigger: String, - pub replace: String, + + #[serde(default = "default_replace")] + pub replace: Option, + + #[serde(default = "default_image_path")] + pub image_path: Option, #[serde(default = "default_vars")] pub vars: Vec, @@ -98,6 +136,8 @@ struct AutoMatch { fn default_vars() -> Vec {Vec::new()} fn default_word() -> bool {false} +fn default_replace() -> Option {None} +fn default_image_path() -> Option {None} #[derive(Debug, Serialize, Deserialize, Clone)] pub struct MatchVariable { @@ -154,7 +194,14 @@ mod tests { let _match : Match = serde_yaml::from_str(match_str).unwrap(); - assert_eq!(_match._has_vars, false); + match _match.content { + MatchContentType::Text(content) => { + assert_eq!(content._has_vars, false); + }, + _ => { + assert!(false); + }, + } } #[test] @@ -166,7 +213,14 @@ mod tests { let _match : Match = serde_yaml::from_str(match_str).unwrap(); - assert_eq!(_match._has_vars, true); + match _match.content { + MatchContentType::Text(content) => { + assert_eq!(content._has_vars, true); + }, + _ => { + assert!(false); + }, + } } #[test] @@ -178,7 +232,14 @@ mod tests { let _match : Match = serde_yaml::from_str(match_str).unwrap(); - assert_eq!(_match._has_vars, true); + match _match.content { + MatchContentType::Text(content) => { + assert_eq!(content._has_vars, true); + }, + _ => { + assert!(false); + }, + } } #[test] @@ -212,4 +273,23 @@ mod tests { assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t')); assert_eq!(_match._trigger_sequence[4], TriggerEntry::WordSeparator); } + + #[test] + fn test_match_with_image_content() { + let match_str = r###" + trigger: "test" + image_path: "/path/to/file" + "###; + + let _match : Match = serde_yaml::from_str(match_str).unwrap(); + + match _match.content { + MatchContentType::Image(content) => { + assert_eq!(content.path, "/path/to/file"); + }, + _ => { + assert!(false); + }, + } + } } \ No newline at end of file