diff --git a/Cargo.lock b/Cargo.lock index 9f2dae4..8302dbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "espanso" -version = "0.3.5" +version = "0.4.0" dependencies = [ "backtrace 0.3.37 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 1812ac1..985ca3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "0.3.5" +version = "0.4.0" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" diff --git a/README.md b/README.md index 1df359f..df78593 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ ___ Visit the [official documentation](https://espanso.org/docs/). +## Support + +If you need some help to setup espanso, want to ask a question or simply get involved +in the community, [Join the official Subreddit](https://www.reddit.com/r/espanso/)! :) + + + ## Donations espanso is a free, open source software developed in my (little) spare time. @@ -57,6 +64,7 @@ Many people helped the project along the way, thanks to all of you. In particula * [Luca Antognetti](https://github.com/luca-ant) - Linux and Windows Tester * [Matteo Pellegrino](https://www.matteopellegrino.me/) - MacOS Tester * [Timo Runge](http://timorunge.com/) - MacOS contributor +* [NickSeagull](http://nickseagull.github.io/) - Contributor ## Remarks diff --git a/native/liblinuxbridge/bridge.cpp b/native/liblinuxbridge/bridge.cpp index b757ff4..5695fa3 100644 --- a/native/liblinuxbridge/bridge.cpp +++ b/native/liblinuxbridge/bridge.cpp @@ -299,6 +299,10 @@ void trigger_terminal_paste() { xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Control_L+Shift+v", 8000); } +void trigger_shift_ins_paste() { + xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Shift+Insert", 8000); +} + // SYSTEM MODULE // Function taken from the wmlib tool source code @@ -470,3 +474,5 @@ int32_t is_current_window_terminal() { return 0; } + + diff --git a/native/liblinuxbridge/bridge.h b/native/liblinuxbridge/bridge.h index 16972eb..9d07122 100644 --- a/native/liblinuxbridge/bridge.h +++ b/native/liblinuxbridge/bridge.h @@ -82,6 +82,11 @@ extern "C" void trigger_paste(); */ extern "C" void trigger_terminal_paste(); +/* + * Trigger shift ins pasting( Pressing SHIFT+INS ) + */ +extern "C" void trigger_shift_ins_paste(); + // SYSTEM MODULE 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/native/libwinbridge/bridge.cpp b/native/libwinbridge/bridge.cpp index 309395c..f83e26b 100644 --- a/native/libwinbridge/bridge.cpp +++ b/native/libwinbridge/bridge.cpp @@ -31,6 +31,9 @@ #include #include +#pragma comment( lib, "gdiplus.lib" ) +#include + // How many milliseconds must pass between keystrokes to refresh the keyboard layout const long refreshKeyboardLayoutInterval = 2000; @@ -656,3 +659,43 @@ int32_t get_clipboard(wchar_t *buffer, int32_t size) { CloseClipboard(); } + +int32_t set_clipboard_image(wchar_t *path) { + bool result = false; + + Gdiplus::GdiplusStartupInput gdiplusStartupInput; + ULONG_PTR gdiplusToken; + Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL); + + Gdiplus::Bitmap *gdibmp = Gdiplus::Bitmap::FromFile(path); + if (gdibmp) + { + HBITMAP hbitmap; + gdibmp->GetHBITMAP(0, &hbitmap); + if (OpenClipboard(NULL)) + { + EmptyClipboard(); + DIBSECTION ds; + if (GetObject(hbitmap, sizeof(DIBSECTION), &ds)) + { + HDC hdc = GetDC(HWND_DESKTOP); + //create compatible bitmap (get DDB from DIB) + HBITMAP hbitmap_ddb = CreateDIBitmap(hdc, &ds.dsBmih, CBM_INIT, + ds.dsBm.bmBits, (BITMAPINFO*)&ds.dsBmih, DIB_RGB_COLORS); + ReleaseDC(HWND_DESKTOP, hdc); + SetClipboardData(CF_BITMAP, hbitmap_ddb); + DeleteObject(hbitmap_ddb); + result = true; + } + CloseClipboard(); + } + + //cleanup: + DeleteObject(hbitmap); + delete gdibmp; + } + + Gdiplus::GdiplusShutdown(gdiplusToken); + + return result; +} diff --git a/native/libwinbridge/bridge.h b/native/libwinbridge/bridge.h index 85d86ad..77fa4d4 100644 --- a/native/libwinbridge/bridge.h +++ b/native/libwinbridge/bridge.h @@ -146,4 +146,9 @@ extern "C" int32_t get_clipboard(wchar_t * buffer, int32_t size); */ extern "C" int32_t set_clipboard(wchar_t * text); +/* + * Set the clipboard image to the given path + */ +extern "C" int32_t set_clipboard_image(wchar_t * path); + #endif //ESPANSO_BRIDGE_H \ No newline at end of file diff --git a/src/bridge/linux.rs b/src/bridge/linux.rs index f93efbb..4947629 100644 --- a/src/bridge/linux.rs +++ b/src/bridge/linux.rs @@ -42,4 +42,5 @@ extern { pub fn left_arrow(count: i32); pub fn trigger_paste(); pub fn trigger_terminal_paste(); + pub fn trigger_shift_ins_paste(); } \ 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/bridge/windows.rs b/src/bridge/windows.rs index db9833e..ff916fa 100644 --- a/src/bridge/windows.rs +++ b/src/bridge/windows.rs @@ -47,6 +47,7 @@ extern { // CLIPBOARD pub fn get_clipboard(buffer: *mut u16, size: i32) -> i32; pub fn set_clipboard(payload: *const u16) -> i32; + pub fn set_clipboard_image(path: *const u16) -> i32; // KEYBOARD pub fn register_keypress_callback(cb: extern fn(_self: *mut c_void, *const u16, diff --git a/src/clipboard/linux.rs b/src/clipboard/linux.rs index 7e14a07..8fb76e1 100644 --- a/src/clipboard/linux.rs +++ b/src/clipboard/linux.rs @@ -19,7 +19,8 @@ use std::process::{Command, Stdio}; use std::io::{Write}; -use log::error; +use log::{error, warn}; +use std::path::Path; pub struct LinuxClipboardManager {} @@ -63,6 +64,29 @@ impl super::ClipboardManager for LinuxClipboardManager { } } } + + fn set_clipboard_image(&self, image_path: &Path) { + let extension = image_path.extension(); + let mime = match extension { + Some(ext) => { + let ext = ext.to_string_lossy().to_lowercase(); + match ext.as_ref() { + "png" => {"image/png"}, + "jpg" | "jpeg" => {"image/jpeg"}, + "gif" => {"image/gif"}, + "svg" => {"image/svg"}, + _ => {"image/png"}, + } + }, + None => {"image/png"}, + }; + + let image_path = image_path.to_string_lossy().into_owned(); + + let res = Command::new("xclip") + .args(&["-selection", "clipboard", "-t", mime, "-i", &image_path]) + .spawn(); + } } impl LinuxClipboardManager { diff --git a/src/clipboard/macos.rs b/src/clipboard/macos.rs index 65c10bf..3777d21 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,19 @@ impl super::ClipboardManager for MacClipboardManager { } } } + + fn set_clipboard_image(&self, image_path: &Path) { + 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/clipboard/windows.rs b/src/clipboard/windows.rs index caa8e0e..63fa070 100644 --- a/src/clipboard/windows.rs +++ b/src/clipboard/windows.rs @@ -18,7 +18,8 @@ */ use widestring::U16CString; -use crate::bridge::windows::{set_clipboard, get_clipboard}; +use crate::bridge::windows::{set_clipboard, get_clipboard, set_clipboard_image}; +use std::path::Path; pub struct WindowsClipboardManager { @@ -53,4 +54,12 @@ impl super::ClipboardManager for WindowsClipboardManager { set_clipboard(payload_c.as_ptr()); } } + + fn set_clipboard_image(&self, image_path: &Path) { + let path_string = image_path.to_string_lossy().into_owned(); + unsafe { + let payload_c = U16CString::from_str(path_string).unwrap(); + set_clipboard_image(payload_c.as_ptr()); + } + } } \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs index 50361ae..64463d4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -26,6 +26,7 @@ use std::fs::{File, create_dir_all}; use std::io::Read; use serde::{Serialize, Deserialize}; use crate::event::KeyModifier; +use crate::keyboard::PasteShortcut; use std::collections::{HashSet, HashMap}; use log::{error}; use std::fmt; @@ -97,6 +98,9 @@ pub struct Configs { #[serde(default = "default_toggle_interval")] pub toggle_interval: u32, + #[serde(default)] + pub paste_shortcut: PasteShortcut, + #[serde(default = "default_backspace_limit")] pub backspace_limit: i32, @@ -426,6 +430,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 +732,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 +766,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 +914,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..79f5e3d 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,96 +119,113 @@ 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); } } - }, - 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); + // 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(&config.paste_shortcut); + }, + } + + 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); + } + }, + + // Image Match + MatchContentType::Image(content) => { + // Make sure the image exist beforehand + if content.path.exists() { + self.clipboard_manager.set_clipboard_image(&content.path); + self.keyboard_manager.trigger_paste(&config.paste_shortcut); + }else{ + error!("Image not found in path: {:?}", content.path); + } + }, } } diff --git a/src/event/mod.rs b/src/event/mod.rs index aeb50da..bc9828a 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -63,6 +63,7 @@ pub enum KeyModifier { ALT, META, BACKSPACE, + OFF, } impl Default for KeyModifier { diff --git a/src/keyboard/linux.rs b/src/keyboard/linux.rs index 7b30028..956d89d 100644 --- a/src/keyboard/linux.rs +++ b/src/keyboard/linux.rs @@ -19,6 +19,8 @@ use std::ffi::CString; use crate::bridge::linux::*; +use super::PasteShortcut; +use log::error; pub struct LinuxKeyboardManager { } @@ -36,16 +38,32 @@ impl super::KeyboardManager for LinuxKeyboardManager { // On linux this is not needed, so NOOP } - fn trigger_paste(&self) { + fn trigger_paste(&self, shortcut: &PasteShortcut) { unsafe { - let is_terminal = is_current_window_terminal(); + match shortcut { + PasteShortcut::Default => { + let is_terminal = is_current_window_terminal(); - // Terminals use a different keyboard combination to paste from clipboard, - // so we need to check the correct situation. - if is_terminal == 0 { - trigger_paste(); - }else{ - trigger_terminal_paste(); + // Terminals use a different keyboard combination to paste from clipboard, + // so we need to check the correct situation. + if is_terminal == 0 { + trigger_paste(); + }else{ + trigger_terminal_paste(); + } + }, + PasteShortcut::CtrlV => { + trigger_paste(); + }, + PasteShortcut::CtrlShiftV => { + trigger_terminal_paste(); + }, + PasteShortcut::ShiftInsert=> { + trigger_shift_ins_paste(); + }, + _ => { + error!("Linux backend does not support this Paste Shortcut, please open an issue on GitHub if you need it.") + } } } } diff --git a/src/keyboard/macos.rs b/src/keyboard/macos.rs index 724845a..6cba0da 100644 --- a/src/keyboard/macos.rs +++ b/src/keyboard/macos.rs @@ -19,6 +19,8 @@ use std::ffi::CString; use crate::bridge::macos::*; +use super::PasteShortcut; +use log::error; pub struct MacKeyboardManager { } @@ -39,9 +41,18 @@ impl super::KeyboardManager for MacKeyboardManager { } } - fn trigger_paste(&self) { + fn trigger_paste(&self, shortcut: &PasteShortcut) { unsafe { - trigger_paste(); + match shortcut { + PasteShortcut::Default => { + unsafe { + trigger_paste(); + } + }, + _ => { + error!("MacOS backend does not support this Paste Shortcut, please open an issue on GitHub if you need it.") + } + } } } diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs index 5a9cc5b..d8f2611 100644 --- a/src/keyboard/mod.rs +++ b/src/keyboard/mod.rs @@ -17,6 +17,8 @@ * along with espanso. If not, see . */ +use serde::{Serialize, Deserialize, Deserializer}; + #[cfg(target_os = "windows")] mod windows; @@ -29,11 +31,26 @@ mod macos; pub trait KeyboardManager { fn send_string(&self, s: &str); fn send_enter(&self); - fn trigger_paste(&self); + fn trigger_paste(&self, shortcut: &PasteShortcut); fn delete_string(&self, count: i32); fn move_cursor_left(&self, count: i32); } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum PasteShortcut { + Default, // Default one for the current system + CtrlV, // Classic Ctrl+V shortcut + CtrlShiftV, // Could be used to paste without formatting in many applications + ShiftInsert, // Often used in Linux systems + MetaV, // Corresponding to Win+V on Windows and Linux, CMD+V on macOS +} + +impl Default for PasteShortcut{ + fn default() -> Self { + PasteShortcut::Default + } +} + // WINDOWS IMPLEMENTATION #[cfg(target_os = "windows")] pub fn get_manager() -> impl KeyboardManager { diff --git a/src/keyboard/windows.rs b/src/keyboard/windows.rs index 8193006..c4e110b 100644 --- a/src/keyboard/windows.rs +++ b/src/keyboard/windows.rs @@ -19,6 +19,8 @@ use widestring::{U16CString}; use crate::bridge::windows::*; +use super::PasteShortcut; +use log::error; pub struct WindowsKeyboardManager { } @@ -44,9 +46,18 @@ impl super::KeyboardManager for WindowsKeyboardManager { } } - fn trigger_paste(&self) { + fn trigger_paste(&self, shortcut: &PasteShortcut) { unsafe { - trigger_paste(); + match shortcut { + PasteShortcut::Default => { + unsafe { + trigger_paste(); + } + }, + _ => { + error!("Windows backend does not support this Paste Shortcut, please open an issue on GitHub if you need it.") + } + } } } diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 4667318..c1ac263 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -22,24 +22,42 @@ use crate::event::{KeyEvent, KeyModifier}; use crate::event::KeyEventReceiver; use serde_yaml::Mapping; use regex::Regex; +use std::path::PathBuf; +use std::fs; 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: PathBuf, +} + impl <'de> serde::Deserialize<'de> for Match { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de> { @@ -53,15 +71,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 +85,55 @@ 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 + // On Windows, we have to replace the forward / with the backslash \ in the path + let new_path = if cfg!(target_os = "windows") { + image_path.replace("/", "\\") + }else{ + image_path.to_owned() + }; + + // Calculate variables in path + let new_path = if new_path.contains("$CONFIG") { + let config_dir = crate::context::get_config_dir(); + let config_path = fs::canonicalize(&config_dir); + let config_path = if let Ok(config_path) = config_path { + config_path.to_string_lossy().into_owned() + }else{ + "".to_owned() + }; + new_path.replace("$CONFIG", &config_path) + }else{ + new_path.to_owned() + }; + + let content = ImageContent { + path: PathBuf::from(new_path) + }; + + 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 +143,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 +159,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 +217,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 +236,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 +255,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 +296,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, PathBuf::from("/path/to/file")); + }, + _ => { + assert!(false); + }, + } + } } \ No newline at end of file diff --git a/src/matcher/scrolling.rs b/src/matcher/scrolling.rs index 83db187..2d59b19 100644 --- a/src/matcher/scrolling.rs +++ b/src/matcher/scrolling.rs @@ -194,6 +194,7 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa let config = self.config_manager.default_config(); if m == config.toggle_key { + if m == KeyModifier::OFF { return } let mut toggle_press_time = self.toggle_press_time.borrow_mut(); if let Ok(elapsed) = toggle_press_time.elapsed() { if elapsed.as_millis() < u128::from(config.toggle_interval) {