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