diff --git a/Cargo.lock b/Cargo.lock index 4d46f31..ad44ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "espanso" -version = "0.3.1" +version = "0.3.2" 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 24c361d..0419878 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "0.3.1" +version = "0.3.2" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" diff --git a/README.md b/README.md index 22abe01..1df359f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Many people helped the project along the way, thanks to all of you. In particula * [Scrumplex](https://scrumplex.net/) - Official AUR repo mantainer and Linux Guru * [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 ## Remarks diff --git a/native/liblinuxbridge/bridge.cpp b/native/liblinuxbridge/bridge.cpp index 55d1c99..88183e8 100644 --- a/native/liblinuxbridge/bridge.cpp +++ b/native/liblinuxbridge/bridge.cpp @@ -278,6 +278,12 @@ void delete_string(int32_t count) { } } +void left_arrow(int32_t count) { + for (int i = 0; i buffer; - int result = ToUnicodeEx(raw->data.keyboard.VKey, raw->data.keyboard.MakeCode, lpKeyState.data(), buffer.data(), buffer.size(), 0, currentKeyboardLayout); + + // This flag is needed to avoid chaning the keyboard state for some layouts. + // Refer to issue: https://github.com/federico-terzi/espanso/issues/86 + UINT flags = 1 << 2; + + int result = ToUnicodeEx(raw->data.keyboard.VKey, raw->data.keyboard.MakeCode, lpKeyState.data(), buffer.data(), buffer.size(), flags, currentKeyboardLayout); //std::cout << result << " " << buffer[0] << " " << raw->data.keyboard.VKey << std::endl; @@ -448,24 +453,7 @@ void send_string(const wchar_t * string) { * Send the backspace keypress, *count* times. */ void delete_string(int32_t count) { - std::vector vec; - - for (int i = 0; i < count; i++) { - INPUT input = { 0 }; - - input.type = INPUT_KEYBOARD; - input.ki.wScan = 0; - input.ki.time = 0; - input.ki.dwExtraInfo = 0; - input.ki.wVk = VK_BACK; - input.ki.dwFlags = 0; // 0 for key press - vec.push_back(input); - - input.ki.dwFlags = KEYEVENTF_KEYUP; // KEYEVENTF_KEYUP for key release - vec.push_back(input); - } - - SendInput(vec.size(), vec.data(), sizeof(INPUT)); + send_multi_vkey(VK_BACK, count); } void send_vkey(int32_t vk) { @@ -487,6 +475,27 @@ void send_vkey(int32_t vk) { SendInput(vec.size(), vec.data(), sizeof(INPUT)); } +void send_multi_vkey(int32_t vk, int32_t count) { + std::vector vec; + + for (int i = 0; i < count; i++) { + INPUT input = { 0 }; + + input.type = INPUT_KEYBOARD; + input.ki.wScan = 0; + input.ki.time = 0; + input.ki.dwExtraInfo = 0; + input.ki.wVk = vk; + input.ki.dwFlags = 0; // 0 for key press + vec.push_back(input); + + input.ki.dwFlags = KEYEVENTF_KEYUP; // KEYEVENTF_KEYUP for key release + vec.push_back(input); + } + + SendInput(vec.size(), vec.data(), sizeof(INPUT)); +} + void trigger_paste() { std::vector vec; diff --git a/native/libwinbridge/bridge.h b/native/libwinbridge/bridge.h index fc0a80c..85d86ad 100644 --- a/native/libwinbridge/bridge.h +++ b/native/libwinbridge/bridge.h @@ -64,6 +64,11 @@ extern "C" void send_string(const wchar_t * string); */ extern "C" void send_vkey(int32_t vk); +/* + * Send the given Virtual Key press multiple times + */ +extern "C" void send_multi_vkey(int32_t vk, int32_t count); + /* * Send the backspace keypress, *count* times. */ diff --git a/src/bridge/linux.rs b/src/bridge/linux.rs index d85156b..f93efbb 100644 --- a/src/bridge/linux.rs +++ b/src/bridge/linux.rs @@ -39,6 +39,7 @@ extern { pub fn send_string(string: *const c_char); pub fn delete_string(count: i32); + pub fn left_arrow(count: i32); pub fn trigger_paste(); pub fn trigger_terminal_paste(); } \ No newline at end of file diff --git a/src/bridge/macos.rs b/src/bridge/macos.rs index 830e994..0b09101 100644 --- a/src/bridge/macos.rs +++ b/src/bridge/macos.rs @@ -31,6 +31,7 @@ pub struct MacMenuItem { extern { pub fn initialize(s: *const c_void, icon_path: *const c_char); pub fn eventloop(); + pub fn headless_eventloop(); // System pub fn check_accessibility() -> i32; @@ -54,6 +55,7 @@ extern { pub fn send_string(string: *const c_char); pub fn send_vkey(vk: i32); + pub fn send_multi_vkey(vk: i32, count: i32); pub fn delete_string(count: i32); pub fn trigger_paste(); } \ No newline at end of file diff --git a/src/bridge/windows.rs b/src/bridge/windows.rs index 349230c..db9833e 100644 --- a/src/bridge/windows.rs +++ b/src/bridge/windows.rs @@ -55,6 +55,7 @@ extern { pub fn eventloop(); pub fn send_string(string: *const u16); pub fn send_vkey(vk: i32); + pub fn send_multi_vkey(vk: i32, count: i32); pub fn delete_string(count: i32); pub fn trigger_paste(); } \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs index a8bf6cc..009526b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -50,6 +50,7 @@ fn default_log_level() -> i32 { 0 } fn default_ipc_server_port() -> i32 { 34982 } fn default_use_system_agent() -> bool { true } fn default_config_caching_interval() -> i32 { 800 } +fn default_word_separators() -> Vec { vec![' ', ',', '.', '\r', '\n'] } fn default_toggle_interval() -> u32 { 230 } fn default_backspace_limit() -> i32 { 3 } fn default_exclude_default_matches() -> bool {false} @@ -87,6 +88,9 @@ pub struct Configs { #[serde(default = "default_config_caching_interval")] pub config_caching_interval: i32, + #[serde(default = "default_word_separators")] + pub word_separators: Vec, // TODO: add parsing test + #[serde(default)] pub toggle_key: KeyModifier, diff --git a/src/context/macos.rs b/src/context/macos.rs index 1f46a50..6c66ed3 100644 --- a/src/context/macos.rs +++ b/src/context/macos.rs @@ -142,4 +142,4 @@ extern fn context_menu_click_callback(_self: *mut c_void, id: i32) { let event = Event::Action(ActionType::from(id)); (*_self).send_channel.send(event).unwrap(); } -} \ No newline at end of file +} diff --git a/src/engine.rs b/src/engine.rs index d848b27..bc604c1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -103,16 +103,22 @@ lazy_static! { impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager> MatchReceiver for Engine<'a, S, C, M, U>{ - fn on_match(&self, m: &Match) { + fn on_match(&self, m: &Match, trailing_separator: Option) { let config = self.config_manager.active_config(); if config.disabled { return; } - self.keyboard_manager.delete_string(m.trigger.chars().count() as i32); + let char_count = if trailing_separator.is_none() { + m.trigger.chars().count() as i32 + }else{ + m.trigger.chars().count() as i32 + 1 // Count also the separator + }; - let target_string = if m._has_vars { + self.keyboard_manager.delete_string(char_count); + + let mut target_string = if m._has_vars { let mut output_map = HashMap::new(); for variable in m.vars.iter() { @@ -142,6 +148,37 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa 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 @@ -151,7 +188,7 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa self.keyboard_manager.send_string(&target_string); }else{ // To handle newlines, substitute each "\n" char with an Enter key press. - let splits = target_string.lines(); + let splits = target_string.split('\n'); for (i, split) in splits.enumerate() { if i > 0 { @@ -167,6 +204,11 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa 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/keyboard/linux.rs b/src/keyboard/linux.rs index 4b9f15b..7b30028 100644 --- a/src/keyboard/linux.rs +++ b/src/keyboard/linux.rs @@ -53,4 +53,10 @@ impl super::KeyboardManager for LinuxKeyboardManager { fn delete_string(&self, count: i32) { unsafe {delete_string(count)} } + + fn move_cursor_left(&self, count: i32) { + unsafe { + left_arrow(count); + } + } } \ No newline at end of file diff --git a/src/keyboard/macos.rs b/src/keyboard/macos.rs index 7381dc1..724845a 100644 --- a/src/keyboard/macos.rs +++ b/src/keyboard/macos.rs @@ -48,4 +48,11 @@ impl super::KeyboardManager for MacKeyboardManager { fn delete_string(&self, count: i32) { unsafe {delete_string(count)} } + + fn move_cursor_left(&self, count: i32) { + unsafe { + // Simulate the Left arrow count times + send_multi_vkey(0x7B, count); + } + } } \ No newline at end of file diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs index c48134c..5a9cc5b 100644 --- a/src/keyboard/mod.rs +++ b/src/keyboard/mod.rs @@ -31,6 +31,7 @@ pub trait KeyboardManager { fn send_enter(&self); fn trigger_paste(&self); fn delete_string(&self, count: i32); + fn move_cursor_left(&self, count: i32); } // WINDOWS IMPLEMENTATION diff --git a/src/keyboard/windows.rs b/src/keyboard/windows.rs index df3564d..8193006 100644 --- a/src/keyboard/windows.rs +++ b/src/keyboard/windows.rs @@ -55,4 +55,11 @@ impl super::KeyboardManager for WindowsKeyboardManager { delete_string(count) } } + + fn move_cursor_left(&self, count: i32) { + unsafe { + // Send the left arrow key multiple times + send_multi_vkey(0x25, count) + } + } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 6718ee7..566b8a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -530,6 +530,7 @@ fn restart_main(config_set: ConfigSet) { /// Cli tool used to analyze active windows to extract useful information /// to create configuration filters. +#[cfg(not(target_os = "macos"))] fn detect_main() { let system_manager = system::get_manager(); @@ -562,6 +563,52 @@ fn detect_main() { } } +/// Cli tool used to analyze active windows to extract useful information +/// to create configuration filters. +/// On macOS version we need to start an event loop for the app to register changes. +#[cfg(target_os = "macos")] +fn detect_main() { + thread::spawn(|| { + use std::io::Write; + use std::io::stdout; + + let system_manager = system::get_manager(); + + println!("Listening for changes, now focus the window you want to analyze."); + println!("Warning: stay on the window for a few seconds, as it may take a while to register."); + println!("You can terminate with CTRL+C\n"); + + let mut last_title : String = "".to_owned(); + let mut last_class : String = "".to_owned(); + let mut last_exec : String = "".to_owned(); + + loop { + let curr_title = system_manager.get_current_window_title().unwrap_or_default(); + let curr_class = system_manager.get_current_window_class().unwrap_or_default(); + let curr_exec = system_manager.get_current_window_executable().unwrap_or_default(); + + // Check if a change occurred + if curr_title != last_title || curr_class != last_class || curr_exec != last_exec { + println!("Detected change, current window has properties:"); + println!("==> Title: '{}'", curr_title); + println!("==> Class: '{}'", curr_class); + println!("==> Executable: '{}'", curr_exec); + println!(); + } + + last_title = curr_title; + last_class = curr_class; + last_exec = curr_exec; + + thread::sleep(Duration::from_millis(500)); + } + }); + + unsafe { + crate::bridge::macos::headless_eventloop(); + } +} + /// Send the given command to the espanso daemon fn cmd_main(config_set: ConfigSet, matches: &ArgMatches) { let command = if matches.subcommand_matches("exit").is_some() { diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 59b9ca7..4667318 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -30,9 +30,14 @@ pub struct Match { pub trigger: String, pub replace: String, pub vars: Vec, + 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, } impl <'de> serde::Deserialize<'de> for Match { @@ -50,14 +55,30 @@ impl<'a> From<&'a AutoMatch> for Match{ 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(); + trigger_sequence.extend(trigger_chars.into_iter().map(|c| { + TriggerEntry::Char(c) + })); + if other.word { // If it's a word match, end with a word separator + trigger_sequence.push(TriggerEntry::WordSeparator); + } + Self { trigger: other.trigger.clone(), - replace: other.replace.clone(), + replace: new_replace, vars: other.vars.clone(), + word: other.word.clone(), _has_vars: has_vars, + _trigger_sequence: trigger_sequence, } } } @@ -70,9 +91,13 @@ struct AutoMatch { #[serde(default = "default_vars")] pub vars: Vec, + + #[serde(default = "default_word")] + pub word: bool, } fn default_vars() -> Vec {Vec::new()} +fn default_word() -> bool {false} #[derive(Debug, Serialize, Deserialize, Clone)] pub struct MatchVariable { @@ -84,8 +109,14 @@ pub struct MatchVariable { pub params: Mapping, } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum TriggerEntry { + Char(char), + WordSeparator +} + pub trait MatchReceiver { - fn on_match(&self, m: &Match); + fn on_match(&self, m: &Match, trailing_separator: Option); fn on_enable_update(&self, status: bool); } @@ -149,4 +180,36 @@ mod tests { assert_eq!(_match._has_vars, true); } + + #[test] + fn test_match_trigger_sequence_without_word() { + let match_str = r###" + trigger: "test" + replace: "This is a test" + "###; + + let _match : Match = serde_yaml::from_str(match_str).unwrap(); + + assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t')); + assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e')); + assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s')); + assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t')); + } + + #[test] + fn test_match_trigger_sequence_with_word() { + let match_str = r###" + trigger: "test" + replace: "This is a test" + word: true + "###; + + let _match : Match = serde_yaml::from_str(match_str).unwrap(); + + assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t')); + assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e')); + assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s')); + assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t')); + assert_eq!(_match._trigger_sequence[4], TriggerEntry::WordSeparator); + } } \ No newline at end of file diff --git a/src/matcher/scrolling.rs b/src/matcher/scrolling.rs index 3d919db..330a01a 100644 --- a/src/matcher/scrolling.rs +++ b/src/matcher/scrolling.rs @@ -17,7 +17,7 @@ * along with espanso. If not, see . */ -use crate::matcher::{Match, MatchReceiver}; +use crate::matcher::{Match, MatchReceiver, TriggerEntry}; use std::cell::RefCell; use crate::event::{KeyModifier, ActionEventReceiver, ActionType}; use crate::config::ConfigManager; @@ -31,8 +31,10 @@ pub struct ScrollingMatcher<'a, R: MatchReceiver, M: ConfigManager<'a>> { current_set_queue: RefCell>>>, toggle_press_time: RefCell, is_enabled: RefCell, + was_previous_char_word_separator: RefCell, } +#[derive(Clone)] struct MatchEntry<'a> { start: usize, count: usize, @@ -49,7 +51,8 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> ScrollingMatcher<'a, R, M> { receiver, current_set_queue, toggle_press_time, - is_enabled: RefCell::new(true) + is_enabled: RefCell::new(true), + was_previous_char_word_separator: RefCell::new(true), } } @@ -66,6 +69,17 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> ScrollingMatcher<'a, R, M> { self.receiver.on_enable_update(*is_enabled); } + + fn is_matching(mtc: &Match, current_char: &str, start: usize, is_current_word_separator: bool) -> bool { + match mtc._trigger_sequence[start] { + TriggerEntry::Char(c) => { + current_char.starts_with(c) + }, + TriggerEntry::WordSeparator => { + is_current_word_separator + }, + } + } } impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMatcher<'a, R, M> { @@ -75,28 +89,49 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa return; } + // Obtain the configuration for the active application if present, + // otherwise get the default one + let active_config = self.config_manager.active_config(); + + // Check if the current char is a word separator + let mut is_current_word_separator = active_config.word_separators.contains( + &c.chars().nth(0).unwrap_or_default() + ); + + // Workaround needed on macos to consider espanso replacement key presses as separators. + if cfg!(target_os = "macos") { + if c.len() > 1 { + is_current_word_separator = true; + } + } + + let mut was_previous_word_separator = self.was_previous_char_word_separator.borrow_mut(); + let mut current_set_queue = self.current_set_queue.borrow_mut(); - let new_matches: Vec = self.config_manager.matches().iter() - .filter(|&x| x.trigger.starts_with(c)) + let new_matches: Vec = active_config.matches.iter() + .filter(|&x| { + let mut result = Self::is_matching(x, c, 0, is_current_word_separator); + + if x.word { + result = result && *was_previous_word_separator + } + + result + }) .map(|x | MatchEntry{ start: 1, - count: x.trigger.chars().count(), + count: x._trigger_sequence.len(), _match: &x }) .collect(); // TODO: use an associative structure to improve the efficiency of this first "new_matches" lookup. - let combined_matches: Vec = match current_set_queue.back() { + let combined_matches: Vec = match current_set_queue.back_mut() { Some(last_matches) => { let mut updated: Vec = last_matches.iter() .filter(|&x| { - let nchar = x._match.trigger.chars().nth(x.start); - if let Some(nchar) = nchar { - c.starts_with(nchar) - }else{ - false - } + Self::is_matching(x._match, c, x.start, is_current_word_separator) }) .map(|x | MatchEntry{ start: x.start+1, @@ -126,11 +161,29 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa current_set_queue.pop_front(); } - if let Some(_match) = found_match { + *was_previous_word_separator = is_current_word_separator; + + if let Some(mtc) = found_match { if let Some(last) = current_set_queue.back_mut() { last.clear(); } - self.receiver.on_match(_match); + + let trailing_separator = if !is_current_word_separator { + None + }else{ + let as_char = c.chars().nth(0); + match as_char { + Some(c) => { + Some(c) // Current char is the trailing separator + }, + None => {None}, + } + }; + + // Force espanso to consider the last char as a separator + *was_previous_word_separator = true; + + self.receiver.on_match(mtc, trailing_separator); } } diff --git a/src/res/mac/icon.png b/src/res/mac/icon.png index 04d9593..cde3e91 100644 Binary files a/src/res/mac/icon.png and b/src/res/mac/icon.png differ