From 6eec895b2145b3069f1788defd9f984be6617d11 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 22 Dec 2019 00:06:55 +0100 Subject: [PATCH 01/23] Move match rendering to standalone component. Add support for nested triggers ( Fix #110 ) --- src/engine.rs | 84 ++++++----------------- src/main.rs | 5 +- src/render/default.rs | 155 ++++++++++++++++++++++++++++++++++++++++++ src/render/mod.rs | 34 +++++++++ 4 files changed, 215 insertions(+), 63 deletions(-) create mode 100644 src/render/default.rs create mode 100644 src/render/mod.rs diff --git a/src/engine.rs b/src/engine.rs index 8f49a5c..7cfd507 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -26,6 +26,7 @@ use log::{info, warn, error}; use crate::ui::{UIManager, MenuItem, MenuItemType}; use crate::event::{ActionEventReceiver, ActionType}; use crate::extension::Extension; +use crate::render::{Renderer, RenderResult}; use std::cell::RefCell; use std::process::exit; use std::collections::HashMap; @@ -33,35 +34,28 @@ use std::path::PathBuf; use regex::{Regex, Captures}; pub struct Engine<'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, - U: UIManager> { + U: UIManager, R: Renderer> { keyboard_manager: &'a S, clipboard_manager: &'a C, config_manager: &'a M, ui_manager: &'a U, - - extension_map: HashMap>, + renderer: &'a R, enabled: RefCell, } -impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager> - Engine<'a, S, C, M, U> { +impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer> + Engine<'a, S, C, M, U, R> { pub fn new(keyboard_manager: &'a S, clipboard_manager: &'a C, config_manager: &'a M, ui_manager: &'a U, - extensions: Vec>) -> Engine<'a, S, C, M, U> { - // Register all the extensions - let mut extension_map = HashMap::new(); - for extension in extensions.into_iter() { - extension_map.insert(extension.name(), extension); - } - + renderer: &'a R) -> Engine<'a, S, C, M, U, R> { let enabled = RefCell::new(true); Engine{keyboard_manager, clipboard_manager, config_manager, ui_manager, - extension_map, + renderer, enabled } } @@ -114,8 +108,8 @@ lazy_static! { static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(?P\\w+)\\s*\\}\\}").unwrap(); } -impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager> - MatchReceiver for Engine<'a, S, C, M, U>{ +impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer> + MatchReceiver for Engine<'a, S, C, M, U, R>{ fn on_match(&self, m: &Match, trailing_separator: Option) { let config = self.config_manager.active_config(); @@ -134,40 +128,10 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa let mut previous_clipboard_content : Option = None; - // 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 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); - } - } - - // 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() - }; + let rendered = self.renderer.render_match(m, config, vec![]); + match rendered { + RenderResult::Text(mut target_string) => { // 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, @@ -234,20 +198,16 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa self.keyboard_manager.move_cursor_left(moves); } }, + RenderResult::Image(image_path) => { + // If the preserve_clipboard option is enabled, save the current + // clipboard content to restore it later. + previous_clipboard_content = self.return_content_if_preserve_clipboard_is_enabled(); - // Image Match - MatchContentType::Image(content) => { - // Make sure the image exist beforehand - if content.path.exists() { - // If the preserve_clipboard option is enabled, save the current - // clipboard content to restore it later. - previous_clipboard_content = self.return_content_if_preserve_clipboard_is_enabled(); - - 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); - } + self.clipboard_manager.set_clipboard_image(&image_path); + self.keyboard_manager.trigger_paste(&config.paste_shortcut); + }, + RenderResult::Error => { + error!("Could not render match: {}", m.trigger); }, } @@ -274,7 +234,7 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa } impl <'a, S: KeyboardManager, C: ClipboardManager, - M: ConfigManager<'a>, U: UIManager> ActionEventReceiver for Engine<'a, S, C, M, U>{ + M: ConfigManager<'a>, U: UIManager, R: Renderer> ActionEventReceiver for Engine<'a, S, C, M, U, R>{ fn on_action_event(&self, e: ActionType) { match e { diff --git a/src/main.rs b/src/main.rs index 5d59a2a..c0e8434 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,7 @@ mod utils; mod bridge; mod engine; mod config; +mod render; mod system; mod context; mod matcher; @@ -332,11 +333,13 @@ fn daemon_background(receive_channel: Receiver, config_set: ConfigSet) { let extensions = extension::get_extensions(); + let renderer = render::default::DefaultRenderer::new(extensions); + let engine = Engine::new(&keyboard_manager, &clipboard_manager, &config_manager, &ui_manager, - extensions, + &renderer, ); let matcher = ScrollingMatcher::new(&config_manager, &engine); diff --git a/src/render/default.rs b/src/render/default.rs new file mode 100644 index 0000000..d0d3f93 --- /dev/null +++ b/src/render/default.rs @@ -0,0 +1,155 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use serde_yaml::{Mapping, Value}; +use std::path::PathBuf; +use std::collections::HashMap; +use regex::{Regex, Captures}; +use log::{warn, error}; +use super::*; +use crate::matcher::{Match, MatchContentType}; +use crate::config::Configs; +use crate::extension::Extension; + +lazy_static! { + static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(?P\\w+)\\s*\\}\\}").unwrap(); +} + +pub struct DefaultRenderer { + extension_map: HashMap>, +} + +impl DefaultRenderer { + pub fn new(extensions: Vec>) -> DefaultRenderer { + // Register all the extensions + let mut extension_map = HashMap::new(); + for extension in extensions.into_iter() { + extension_map.insert(extension.name(), extension); + } + + DefaultRenderer{ + extension_map + } + } + + fn find_match(config: &Configs, trigger: &str) -> Option { + let mut result = None; + + // TODO: if performances become a problem, implement a more efficient lookup + for m in config.matches.iter() { + if m.trigger == trigger { + result = Some(m.clone()); + break; + } + } + + result + } +} + +impl super::Renderer for DefaultRenderer { + fn render_match(&self, m: &Match, config: &Configs, args: Vec) -> RenderResult { + // 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 content.vars.iter() { + // In case of variables of type match, we need to recursively call + // the render function + if variable.var_type == "match" { + // Extract the match trigger from the variable params + let trigger = variable.params.get(&Value::from("trigger")); + if trigger.is_none() { + warn!("Missing param 'trigger' in match variable: {}", variable.name); + continue; + } + let trigger = trigger.unwrap(); + + // Find the given match from the active configs + let inner_match = DefaultRenderer::find_match(config, trigger.as_str().unwrap_or("")); + + if inner_match.is_none() { + warn!("Could not find inner match with trigger: '{}'", trigger.as_str().unwrap_or("undefined")); + continue + } + + let inner_match = inner_match.unwrap(); + + // Render the inner match + // TODO: inner arguments + let result = self.render_match(&inner_match, config, vec![]); + + // Inner matches are only supported for text-expansions, warn the user otherwise + match result { + RenderResult::Text(inner_content) => { + output_map.insert(variable.name.clone(), inner_content); + }, + _ => { + warn!("Inner matches must be of TEXT type. Mixing images is not supported yet.") + }, + } + }else{ // Normal extension variables + 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(&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() + }; + + RenderResult::Text(target_string) + }, + + // Image Match + MatchContentType::Image(content) => { + // Make sure the image exist beforehand + if content.path.exists() { + RenderResult::Image(content.path.clone()) + }else{ + error!("Image not found in path: {:?}", content.path); + RenderResult::Error + } + }, + } + } +} + +// TODO: tests \ No newline at end of file diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..a676f7a --- /dev/null +++ b/src/render/mod.rs @@ -0,0 +1,34 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::path::PathBuf; +use crate::matcher::{Match}; +use crate::config::Configs; + +pub(crate) mod default; + +pub trait Renderer { + fn render_match(&self, m: &Match, config: &Configs, args: Vec) -> RenderResult; +} + +pub enum RenderResult { + Text(String), + Image(PathBuf), + Error +} \ No newline at end of file From 0eb58704a92740be31b2bf36a4c7792507b9d675 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Fri, 10 Jan 2020 23:29:21 +0100 Subject: [PATCH 02/23] First steps in passive match rendering --- src/config/mod.rs | 10 +++ src/main.rs | 5 +- src/render/default.rs | 182 +++++++++++++++++++++++++++++++++++++++++- src/render/mod.rs | 4 + 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index c849527..361751b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -54,6 +54,8 @@ fn default_config_caching_interval() -> i32 { 800 } fn default_word_separators() -> Vec { vec![' ', ',', '.', '\r', '\n', 22u8 as char] } fn default_toggle_interval() -> u32 { 230 } fn default_preserve_clipboard() -> bool {false} +fn default_passive_match_regex() -> String{ "(?P:\\p{L}+)(/(?P.*)/)?".to_owned() } +fn default_passive_arg_regex() -> String{ "((\\\\/)|[^/])+".to_owned() } fn default_backspace_limit() -> i32 { 3 } fn default_exclude_default_matches() -> bool {false} fn default_matches() -> Vec { Vec::new() } @@ -102,6 +104,12 @@ pub struct Configs { #[serde(default = "default_preserve_clipboard")] pub preserve_clipboard: bool, + #[serde(default = "default_passive_match_regex")] + pub passive_match_regex: String, + + #[serde(default = "default_passive_arg_regex")] + pub passive_arg_regex: String, + #[serde(default)] pub paste_shortcut: PasteShortcut, @@ -150,6 +158,8 @@ impl Configs { validate_field!(result, self.ipc_server_port, default_ipc_server_port()); validate_field!(result, self.use_system_agent, default_use_system_agent()); validate_field!(result, self.preserve_clipboard, default_preserve_clipboard()); + validate_field!(result, self.passive_match_regex, default_passive_match_regex()); + validate_field!(result, self.passive_arg_regex, default_passive_arg_regex()); result } diff --git a/src/main.rs b/src/main.rs index c0e8434..12c7561 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,7 @@ use fs2::FileExt; use log::{info, warn, LevelFilter}; use simplelog::{CombinedLogger, SharedLogger, TerminalMode, TermLogger, WriteLogger}; -use crate::config::ConfigSet; +use crate::config::{ConfigSet, ConfigManager}; use crate::config::runtime::RuntimeConfigManager; use crate::engine::Engine; use crate::event::*; @@ -333,7 +333,8 @@ fn daemon_background(receive_channel: Receiver, config_set: ConfigSet) { let extensions = extension::get_extensions(); - let renderer = render::default::DefaultRenderer::new(extensions); + let renderer = render::default::DefaultRenderer::new(extensions, + config_manager.default_config().clone()); let engine = Engine::new(&keyboard_manager, &clipboard_manager, diff --git a/src/render/default.rs b/src/render/default.rs index d0d3f93..f1ef5f2 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -33,18 +33,36 @@ lazy_static! { pub struct DefaultRenderer { extension_map: HashMap>, + + // Regex used to identify matches (and arguments) in passive expansions + passive_match_regex: Regex, + + // Regex used to separate arguments in passive expansions + passive_arg_regex: Regex, } impl DefaultRenderer { - pub fn new(extensions: Vec>) -> DefaultRenderer { + pub fn new(extensions: Vec>, config: Configs) -> DefaultRenderer { // Register all the extensions let mut extension_map = HashMap::new(); for extension in extensions.into_iter() { extension_map.insert(extension.name(), extension); } + // Compile the regexes + let passive_match_regex = Regex::new(&config.passive_match_regex) + .unwrap_or_else(|e| { + panic!("Invalid passive match regex"); + }); + let passive_arg_regex = Regex::new(&config.passive_arg_regex) + .unwrap_or_else(|e| { + panic!("Invalid passive arg regex"); + }); + DefaultRenderer{ - extension_map + extension_map, + passive_match_regex, + passive_arg_regex } } @@ -69,7 +87,7 @@ impl super::Renderer for DefaultRenderer { match &m.content { // Text Match MatchContentType::Text(content) => { - let mut target_string = if content._has_vars { + let target_string = if content._has_vars { let mut output_map = HashMap::new(); for variable in content.vars.iter() { @@ -108,6 +126,7 @@ impl super::Renderer for DefaultRenderer { }, } }else{ // Normal extension variables + // TODO: pass the arguments to the extension let extension = self.extension_map.get(&variable.var_type); if let Some(extension) = extension { let ext_out = extension.calculate(&variable.params); @@ -123,6 +142,11 @@ impl super::Renderer for DefaultRenderer { } } + // TODO: replace the arguments + // the idea is that every param placeholder, such as $1$, is replaced with + // an ArgExtension when loading a match, which renders the argument as output + // this is only used as syntactic sugar + // Replace the variables let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| { let var_name = caps.name("name").unwrap().as_str(); @@ -150,6 +174,156 @@ impl super::Renderer for DefaultRenderer { }, } } + + fn render_passive(&self, text: &str, config: &Configs) -> RenderResult { + // Render the matches + let result = self.passive_match_regex.replace_all(&text, |caps: &Captures| { + let match_name = if let Some(name) = caps.name("name") { + name.as_str() + }else{ + "" + }; + + + // Get the original matching string, useful to return the match untouched + let original_match = caps.get(0).unwrap().as_str(); + + // Find the corresponding match + let m = DefaultRenderer::find_match(config, match_name); + + // If no match is found, leave the match without modifications + if m.is_none() { + return original_match.to_owned(); + } + + // Compute the args by separating them + let match_args = if let Some(args) = caps.name("args") { + args.as_str() + }else{ + "" + }; + let args : Vec = self.passive_arg_regex.split(match_args).into_iter().map( + |arg| arg.to_owned() + ).collect(); + + let m = m.unwrap(); + // Render the actual match + let result = self.render_match(&m, &config, args); + + match result { + RenderResult::Text(out) => { + out + }, + _ => { + original_match.to_owned() + } + } + }); + + RenderResult::Text(result.into_owned()) + } } -// TODO: tests \ No newline at end of file +// TESTS + +#[cfg(test)] +mod tests { + use super::*; + + fn get_renderer(config: Configs) -> DefaultRenderer { + DefaultRenderer::new(crate::extension::get_extensions(), config) + } + + fn get_config_for(s: &str) -> Configs { + let config : Configs = serde_yaml::from_str(s).unwrap(); + config + } + + fn verify_render(rendered: RenderResult, target: &str) { + match rendered { + RenderResult::Text(rendered) => { + println!("{}", rendered); + assert_eq!(rendered, target); + }, + _ => { + assert!(false) + } + } + } + + #[test] + fn test_render_passive_no_matches() { + let text = r###" + this text contains no matches + "###; + + let config = get_config_for(r###" + matches: + - trigger: test + replace: result + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, text); + } + + #[test] + fn test_render_passive_simple_match_no_args() { + let text = "this is a :test"; + + let config = get_config_for(r###" + matches: + - trigger: ':test' + replace: result + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "this is a result"); + } + + #[test] + fn test_render_passive_multiple_match_no_args() { + let text = "this is a :test and then another :test"; + + let config = get_config_for(r###" + matches: + - trigger: ':test' + replace: result + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "this is a result and then another result"); + } + + #[test] + fn test_render_passive_simple_match_multiline_no_args() { + let text = r###"this is a + :test + "###; + + let result= r###"this is a + result + "###; + + let config = get_config_for(r###" + matches: + - trigger: ':test' + replace: result + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, result); + } +} \ No newline at end of file diff --git a/src/render/mod.rs b/src/render/mod.rs index a676f7a..d83e982 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -24,7 +24,11 @@ use crate::config::Configs; pub(crate) mod default; pub trait Renderer { + // Render a match output fn render_match(&self, m: &Match, config: &Configs, args: Vec) -> RenderResult; + + // Render a passive expansion text + fn render_passive(&self, text: &str, config: &Configs) -> RenderResult; } pub enum RenderResult { From 6378aa3bccc77818bd0cec7107a2672bee652d83 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sat, 18 Jan 2020 22:55:50 +0100 Subject: [PATCH 03/23] Add argument rendering in passive matches --- src/config/mod.rs | 13 ++-- src/extension/date.rs | 2 +- src/extension/mod.rs | 2 +- src/extension/random.rs | 6 +- src/extension/script.rs | 2 +- src/extension/shell.rs | 14 +++-- src/render/default.rs | 118 ++++++++++++++++++++++++++++++------ src/render/mod.rs | 1 + src/render/utils.rs | 130 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 src/render/utils.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index 361751b..32bfb93 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -55,7 +55,8 @@ fn default_word_separators() -> Vec { vec![' ', ',', '.', '\r', '\n', 22u8 fn default_toggle_interval() -> u32 { 230 } fn default_preserve_clipboard() -> bool {false} fn default_passive_match_regex() -> String{ "(?P:\\p{L}+)(/(?P.*)/)?".to_owned() } -fn default_passive_arg_regex() -> String{ "((\\\\/)|[^/])+".to_owned() } +fn default_passive_arg_delimiter() -> char { '/' } +fn default_passive_arg_escape() -> char { '\\' } fn default_backspace_limit() -> i32 { 3 } fn default_exclude_default_matches() -> bool {false} fn default_matches() -> Vec { Vec::new() } @@ -107,8 +108,11 @@ pub struct Configs { #[serde(default = "default_passive_match_regex")] pub passive_match_regex: String, - #[serde(default = "default_passive_arg_regex")] - pub passive_arg_regex: String, + #[serde(default = "default_passive_arg_delimiter")] + pub passive_arg_delimiter: char, + + #[serde(default = "default_passive_arg_escape")] + pub passive_arg_escape: char, #[serde(default)] pub paste_shortcut: PasteShortcut, @@ -159,7 +163,8 @@ impl Configs { validate_field!(result, self.use_system_agent, default_use_system_agent()); validate_field!(result, self.preserve_clipboard, default_preserve_clipboard()); validate_field!(result, self.passive_match_regex, default_passive_match_regex()); - validate_field!(result, self.passive_arg_regex, default_passive_arg_regex()); + validate_field!(result, self.passive_arg_delimiter, default_passive_arg_delimiter()); + validate_field!(result, self.passive_arg_escape, default_passive_arg_escape()); result } diff --git a/src/extension/date.rs b/src/extension/date.rs index 6c81924..431b4e5 100644 --- a/src/extension/date.rs +++ b/src/extension/date.rs @@ -33,7 +33,7 @@ impl super::Extension for DateExtension { String::from("date") } - fn calculate(&self, params: &Mapping) -> Option { + fn calculate(&self, params: &Mapping, _: &Vec) -> Option { let now: DateTime = Local::now(); let format = params.get(&Value::from("format")); diff --git a/src/extension/mod.rs b/src/extension/mod.rs index 4c0f11e..ccb6934 100644 --- a/src/extension/mod.rs +++ b/src/extension/mod.rs @@ -26,7 +26,7 @@ mod random; pub trait Extension { fn name(&self) -> String; - fn calculate(&self, params: &Mapping) -> Option; + fn calculate(&self, params: &Mapping, args: &Vec) -> Option; } pub fn get_extensions() -> Vec> { diff --git a/src/extension/random.rs b/src/extension/random.rs index e44a2e6..909a696 100644 --- a/src/extension/random.rs +++ b/src/extension/random.rs @@ -34,7 +34,7 @@ impl super::Extension for RandomExtension { String::from("random") } - fn calculate(&self, params: &Mapping) -> Option { + fn calculate(&self, params: &Mapping, _: &Vec) -> Option { // TODO: add argument handling let choices = params.get(&Value::from("choices")); if choices.is_none() { warn!("No 'choices' parameter specified for random variable"); @@ -82,7 +82,7 @@ mod tests { params.insert(Value::from("choices"), Value::from(choices.clone())); let extension = RandomExtension::new(); - let output = extension.calculate(¶ms); + let output = extension.calculate(¶ms, &vec![]); assert!(output.is_some()); @@ -90,4 +90,6 @@ mod tests { assert!(choices.iter().any(|x| x == &output)); } + + // TODO: add test with arguments } \ No newline at end of file diff --git a/src/extension/script.rs b/src/extension/script.rs index a5519aa..ecbc1bd 100644 --- a/src/extension/script.rs +++ b/src/extension/script.rs @@ -34,7 +34,7 @@ impl super::Extension for ScriptExtension { String::from("script") } - fn calculate(&self, params: &Mapping) -> Option { + fn calculate(&self, params: &Mapping, _: &Vec) -> Option { // TODO: add argument handling let args = params.get(&Value::from("args")); if args.is_none() { warn!("No 'args' parameter specified for script variable"); diff --git a/src/extension/shell.rs b/src/extension/shell.rs index aa1012a..9736628 100644 --- a/src/extension/shell.rs +++ b/src/extension/shell.rs @@ -34,7 +34,7 @@ impl super::Extension for ShellExtension { String::from("shell") } - fn calculate(&self, params: &Mapping) -> Option { + fn calculate(&self, params: &Mapping, _: &Vec) -> Option { // TODO: add argument handling let cmd = params.get(&Value::from("cmd")); if cmd.is_none() { warn!("No 'cmd' parameter specified for shell variable"); @@ -90,7 +90,7 @@ mod tests { params.insert(Value::from("cmd"), Value::from("echo hello world")); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms); + let output = extension.calculate(¶ms, &vec![]); assert!(output.is_some()); @@ -108,7 +108,7 @@ mod tests { params.insert(Value::from("trim"), Value::from(true)); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms); + let output = extension.calculate(¶ms, &vec![]); assert!(output.is_some()); assert_eq!(output.unwrap(), "hello world"); @@ -126,7 +126,7 @@ mod tests { params.insert(Value::from("trim"), Value::from(true)); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms); + let output = extension.calculate(¶ms, &vec![]); assert!(output.is_some()); assert_eq!(output.unwrap(), "hello world"); @@ -139,7 +139,7 @@ mod tests { params.insert(Value::from("trim"), Value::from("error")); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms); + let output = extension.calculate(¶ms, &vec![]); assert!(output.is_some()); if cfg!(target_os = "windows") { @@ -157,9 +157,11 @@ mod tests { params.insert(Value::from("trim"), Value::from(true)); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms); + let output = extension.calculate(¶ms, &vec![]); assert!(output.is_some()); assert_eq!(output.unwrap(), "hello world"); } + + // TODO: add tests with arguments } \ No newline at end of file diff --git a/src/render/default.rs b/src/render/default.rs index f1ef5f2..2663c0b 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -36,9 +36,6 @@ pub struct DefaultRenderer { // Regex used to identify matches (and arguments) in passive expansions passive_match_regex: Regex, - - // Regex used to separate arguments in passive expansions - passive_arg_regex: Regex, } impl DefaultRenderer { @@ -54,15 +51,10 @@ impl DefaultRenderer { .unwrap_or_else(|e| { panic!("Invalid passive match regex"); }); - let passive_arg_regex = Regex::new(&config.passive_arg_regex) - .unwrap_or_else(|e| { - panic!("Invalid passive arg regex"); - }); DefaultRenderer{ extension_map, passive_match_regex, - passive_arg_regex } } @@ -129,7 +121,7 @@ impl super::Renderer for DefaultRenderer { // TODO: pass the arguments to the extension let extension = self.extension_map.get(&variable.var_type); if let Some(extension) = extension { - let ext_out = extension.calculate(&variable.params); + let ext_out = extension.calculate(&variable.params, &args); if let Some(output) = ext_out { output_map.insert(variable.name.clone(), output); }else{ @@ -142,11 +134,6 @@ impl super::Renderer for DefaultRenderer { } } - // TODO: replace the arguments - // the idea is that every param placeholder, such as $1$, is replaced with - // an ArgExtension when loading a match, which renders the argument as output - // this is only used as syntactic sugar - // Replace the variables let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| { let var_name = caps.name("name").unwrap().as_str(); @@ -159,6 +146,9 @@ impl super::Renderer for DefaultRenderer { content.replace.clone() }; + // Render any argument that may be present + let target_string = utils::render_args(&target_string, &args); + RenderResult::Text(target_string) }, @@ -202,9 +192,9 @@ impl super::Renderer for DefaultRenderer { }else{ "" }; - let args : Vec = self.passive_arg_regex.split(match_args).into_iter().map( - |arg| arg.to_owned() - ).collect(); + let args : Vec = utils::split_args(match_args, + config.passive_arg_delimiter, + config.passive_arg_escape); let m = m.unwrap(); // Render the actual match @@ -242,7 +232,6 @@ mod tests { fn verify_render(rendered: RenderResult, target: &str) { match rendered { RenderResult::Text(rendered) => { - println!("{}", rendered); assert_eq!(rendered, target); }, _ => { @@ -326,4 +315,97 @@ mod tests { verify_render(rendered, result); } + + #[test] + fn test_render_passive_nested_matches_no_args() { + let text = ":greet"; + + let config = get_config_for(r###" + matches: + - trigger: ':greet' + replace: "hi {{name}}" + vars: + - name: name + type: match + params: + trigger: ":name" + + - trigger: ':name' + replace: john + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "hi john"); + } + + #[test] + fn test_render_passive_simple_match_with_args() { + let text = ":greet/Jon/"; + + let config = get_config_for(r###" + matches: + - trigger: ':greet' + replace: "Hi $0$" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "Hi Jon"); + } + + #[test] + fn test_render_passive_simple_match_with_multiple_args() { + let text = ":greet/Jon/Snow/"; + + let config = get_config_for(r###" + matches: + - trigger: ':greet' + replace: "Hi $0$, there is $1$ outside" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "Hi Jon, there is Snow outside"); + } + + #[test] + fn test_render_passive_simple_match_with_escaped_args() { + let text = ":greet/Jon/10\\/12/"; + + let config = get_config_for(r###" + matches: + - trigger: ':greet' + replace: "Hi $0$, today is $1$" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "Hi Jon, today is 10/12"); + } + + #[test] + fn test_render_passive_simple_match_with_args_not_closed() { + let text = ":greet/Jon/Snow"; + + let config = get_config_for(r###" + matches: + - trigger: ':greet' + replace: "Hi $0$" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "Hi JonSnow"); + } } \ No newline at end of file diff --git a/src/render/mod.rs b/src/render/mod.rs index d83e982..80bf645 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -22,6 +22,7 @@ use crate::matcher::{Match}; use crate::config::Configs; pub(crate) mod default; +pub(crate) mod utils; pub trait Renderer { // Render a match output diff --git a/src/render/utils.rs b/src/render/utils.rs new file mode 100644 index 0000000..fe486b6 --- /dev/null +++ b/src/render/utils.rs @@ -0,0 +1,130 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2020 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use regex::{Regex, Captures}; + +lazy_static! { + static ref ARG_REGEX: Regex = Regex::new("\\$(?P\\d+)\\$").unwrap(); +} + +pub fn render_args(text: &str, args: &Vec) -> String { + let result = ARG_REGEX.replace_all(text, |caps: &Captures| { + let position_str = caps.name("pos").unwrap().as_str(); + let position = position_str.parse::().unwrap_or(-1); + + if position >= 0 && position < args.len() as i32 { + args[position as usize].to_owned() + }else{ + "".to_owned() + } + }); + + result.to_string() +} + +pub fn split_args(text: &str, delimiter: char, escape: char) -> Vec { + let mut output = vec![]; + + // Make sure the text is not empty + if text.is_empty() { + return output + } + + let mut last = String::from(""); + let mut previous : char = char::from(0); + text.chars().into_iter().for_each(|c| { + if c == delimiter { + if previous != escape { + output.push(last.clone()); + last = String::from(""); + }else{ + last.push(c); + } + }else if c == escape { + if previous == escape { + last.push(c); + } + }else{ + last.push(c); + } + previous = c; + }); + + // Add the last one + output.push(last); + + output +} + +// TESTS + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_args_no_args() { + let args = vec!("hello".to_owned()); + assert_eq!(render_args("no args", &args), "no args") + } + + #[test] + fn test_render_args_one_arg() { + let args = vec!("jon".to_owned()); + assert_eq!(render_args("hello $0$", &args), "hello jon") + } + + #[test] + fn test_render_args_one_multiple_args() { + let args = vec!("jon".to_owned(), "snow".to_owned()); + assert_eq!(render_args("hello $0$, the $1$ is white", &args), "hello jon, the snow is white") + } + + #[test] + fn test_render_args_out_of_range() { + let args = vec!("jon".to_owned()); + assert_eq!(render_args("hello $10$", &args), "hello ") + } + + #[test] + fn test_split_args_one_arg() { + assert_eq!(split_args("jon", '/', '\\'), vec!["jon"]) + } + + #[test] + fn test_split_args_two_args() { + assert_eq!(split_args("jon/snow", '/', '\\'), vec!["jon", "snow"]) + } + + #[test] + fn test_split_args_escaping() { + assert_eq!(split_args("jon\\/snow", '/', '\\'), vec!["jon/snow"]) + } + + #[test] + fn test_split_args_escaping_escape() { + assert_eq!(split_args("jon\\\\snow", '/', '\\'), vec!["jon\\snow"]) + } + + #[test] + fn test_split_args_empty() { + let empty_vec : Vec = vec![]; + assert_eq!(split_args("", '/', '\\'), empty_vec) + } +} \ No newline at end of file From 9e5a2a7c95d3e5106d9a576a3136f8127a46625f Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sat, 18 Jan 2020 23:33:02 +0100 Subject: [PATCH 04/23] Add argument handling in extensions --- src/extension/random.rs | 33 ++++++++++++++++++-- src/extension/script.rs | 69 +++++++++++++++++++++++++++++++++++++++-- src/extension/shell.rs | 54 +++++++++++++++++++++++++++++--- 3 files changed, 147 insertions(+), 9 deletions(-) diff --git a/src/extension/random.rs b/src/extension/random.rs index 909a696..6923a6e 100644 --- a/src/extension/random.rs +++ b/src/extension/random.rs @@ -34,7 +34,7 @@ impl super::Extension for RandomExtension { String::from("random") } - fn calculate(&self, params: &Mapping, _: &Vec) -> Option { // TODO: add argument handling + fn calculate(&self, params: &Mapping, args: &Vec) -> Option { let choices = params.get(&Value::from("choices")); if choices.is_none() { warn!("No 'choices' parameter specified for random variable"); @@ -51,7 +51,10 @@ impl super::Extension for RandomExtension { match choice { Some(output) => { - return Some(output.clone()) + // Render arguments + let output = crate::render::utils::render_args(output, args); + + return Some(output) }, None => { error!("Could not select a random choice."); @@ -91,5 +94,29 @@ mod tests { assert!(choices.iter().any(|x| x == &output)); } - // TODO: add test with arguments + #[test] + fn test_random_with_args() { + let mut params = Mapping::new(); + let choices = vec!( + "first $0$", + "second $0$", + "$0$ third", + ); + params.insert(Value::from("choices"), Value::from(choices.clone())); + + let extension = RandomExtension::new(); + let output = extension.calculate(¶ms, &vec!["test".to_owned()]); + + assert!(output.is_some()); + + let output = output.unwrap(); + + let rendered_choices = vec!( + "first test", + "second test", + "test third", + ); + + assert!(rendered_choices.iter().any(|x| x == &output)); + } } \ No newline at end of file diff --git a/src/extension/script.rs b/src/extension/script.rs index ecbc1bd..554fdf5 100644 --- a/src/extension/script.rs +++ b/src/extension/script.rs @@ -34,7 +34,7 @@ impl super::Extension for ScriptExtension { String::from("script") } - fn calculate(&self, params: &Mapping, _: &Vec) -> Option { // TODO: add argument handling + fn calculate(&self, params: &Mapping, user_args: &Vec) -> Option { let args = params.get(&Value::from("args")); if args.is_none() { warn!("No 'args' parameter specified for script variable"); @@ -42,10 +42,17 @@ impl super::Extension for ScriptExtension { } let args = args.unwrap().as_sequence(); if let Some(args) = args { - let str_args = args.iter().map(|arg| { + let mut str_args = args.iter().map(|arg| { arg.as_str().unwrap_or_default().to_string() }).collect::>(); + // The user has to enable argument concatenation explicitly + let inject_args = params.get(&Value::from("inject_args")) + .unwrap_or(&Value::from(false)).as_bool().unwrap_or(false); + if inject_args { + str_args.extend(user_args.clone()); + } + let output = if str_args.len() > 1 { Command::new(&str_args[0]) .args(&str_args[1..]) @@ -71,4 +78,62 @@ impl super::Extension for ScriptExtension { error!("Could not execute script with args '{:?}'", args); None } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extension::Extension; + + #[test] + fn test_script_basic() { + let mut params = Mapping::new(); + params.insert(Value::from("args"), Value::from(vec!["echo", "hello world"])); + + let extension = ScriptExtension::new(); + let output = extension.calculate(¶ms, &vec![]); + + assert!(output.is_some()); + + if cfg!(target_os = "windows") { + assert_eq!(output.unwrap(), "hello world\r\n"); + }else{ + assert_eq!(output.unwrap(), "hello world\n"); + } + } + + #[test] + fn test_script_inject_args_off() { + let mut params = Mapping::new(); + params.insert(Value::from("args"), Value::from(vec!["echo", "hello world"])); + + let extension = ScriptExtension::new(); + let output = extension.calculate(¶ms, &vec!["jon".to_owned()]); + + assert!(output.is_some()); + + if cfg!(target_os = "windows") { + assert_eq!(output.unwrap(), "hello world\r\n"); + }else{ + assert_eq!(output.unwrap(), "hello world\n"); + } + } + + #[test] + fn test_script_inject_args_on() { + let mut params = Mapping::new(); + params.insert(Value::from("args"), Value::from(vec!["echo", "hello world"])); + params.insert(Value::from("inject_args"), Value::from(true)); + + let extension = ScriptExtension::new(); + let output = extension.calculate(¶ms, &vec!["jon".to_owned()]); + + assert!(output.is_some()); + + if cfg!(target_os = "windows") { + assert_eq!(output.unwrap(), "hello world jon\r\n"); + }else{ + assert_eq!(output.unwrap(), "hello world jon\n"); + } + } } \ No newline at end of file diff --git a/src/extension/shell.rs b/src/extension/shell.rs index 9736628..f8e014a 100644 --- a/src/extension/shell.rs +++ b/src/extension/shell.rs @@ -20,6 +20,15 @@ use serde_yaml::{Mapping, Value}; use std::process::Command; use log::{warn, error}; +use regex::{Regex, Captures}; + +lazy_static! { + static ref POS_ARG_REGEX: Regex = if cfg!(target_os = "windows") { + Regex::new("\\%(?P\\d+)").unwrap() + }else{ + Regex::new("\\$(?P\\d+)").unwrap() + }; +} pub struct ShellExtension {} @@ -34,7 +43,7 @@ impl super::Extension for ShellExtension { String::from("shell") } - fn calculate(&self, params: &Mapping, _: &Vec) -> Option { // TODO: add argument handling + fn calculate(&self, params: &Mapping, args: &Vec) -> Option { let cmd = params.get(&Value::from("cmd")); if cmd.is_none() { warn!("No 'cmd' parameter specified for shell variable"); @@ -42,14 +51,25 @@ impl super::Extension for ShellExtension { } let cmd = cmd.unwrap().as_str().unwrap(); + // Render positional parameters in args + let cmd = POS_ARG_REGEX.replace_all(&cmd, |caps: &Captures| { + let position_str = caps.name("pos").unwrap().as_str(); + let position = position_str.parse::().unwrap_or(-1); + if position >= 0 && position < args.len() as i32 { + args[position as usize].to_owned() + }else{ + "".to_owned() + } + }).to_string(); + let output = if cfg!(target_os = "windows") { Command::new("cmd") - .args(&["/C", cmd]) + .args(&["/C", &cmd]) .output() } else { Command::new("sh") .arg("-c") - .arg(cmd) + .arg(&cmd) .output() }; @@ -163,5 +183,31 @@ mod tests { assert_eq!(output.unwrap(), "hello world"); } - // TODO: add tests with arguments + #[test] + #[cfg(not(target_os = "windows"))] + fn test_shell_args_unix() { + let mut params = Mapping::new(); + params.insert(Value::from("cmd"), Value::from("echo $0")); + + let extension = ShellExtension::new(); + let output = extension.calculate(¶ms, &vec!["hello".to_owned()]); + + assert!(output.is_some()); + + assert_eq!(output.unwrap(), "hello\n"); + } + + #[test] + #[cfg(target_os = "windows")] + fn test_shell_args_windows() { + let mut params = Mapping::new(); + params.insert(Value::from("cmd"), Value::from("echo %0")); + + let extension = ShellExtension::new(); + let output = extension.calculate(¶ms, &vec!["hello".to_owned()]); + + assert!(output.is_some()); + + assert_eq!(output.unwrap(), "hello\r\n"); + } } \ No newline at end of file From 9332899969ce28d2649ff79b892c153a6dd7f30f Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 19 Jan 2020 00:30:30 +0100 Subject: [PATCH 05/23] First draft of working passive mode on linux --- native/liblinuxbridge/bridge.cpp | 8 ++++++ native/liblinuxbridge/bridge.h | 4 +++ src/bridge/linux.rs | 1 + src/config/mod.rs | 10 ++++++-- src/engine.rs | 33 ++++++++++++++++++++++++ src/event/mod.rs | 6 ----- src/keyboard/linux.rs | 6 +++++ src/keyboard/mod.rs | 1 + src/matcher/mod.rs | 1 + src/matcher/scrolling.rs | 43 ++++++++++++++++++++++---------- 10 files changed, 92 insertions(+), 21 deletions(-) diff --git a/native/liblinuxbridge/bridge.cpp b/native/liblinuxbridge/bridge.cpp index a34661a..beb9646 100644 --- a/native/liblinuxbridge/bridge.cpp +++ b/native/liblinuxbridge/bridge.cpp @@ -307,6 +307,14 @@ void trigger_alt_shift_ins_paste() { xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Shift+Alt+Insert", 8000); } +void trigger_copy() { + // Release the other keys, for an explanation, read the 'trigger_paste' method + + xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Alt", 8000); + xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Shift", 8000); + xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Control_L+c", 8000); +} + // SYSTEM MODULE // Function taken from the wmlib tool source code diff --git a/native/liblinuxbridge/bridge.h b/native/liblinuxbridge/bridge.h index 6e921bf..2389cf8 100644 --- a/native/liblinuxbridge/bridge.h +++ b/native/liblinuxbridge/bridge.h @@ -92,6 +92,10 @@ extern "C" void trigger_shift_ins_paste(); */ extern "C" void trigger_alt_shift_ins_paste(); +/* + * Trigger copy shortcut ( Pressing CTRL+C ) + */ +extern "C" void trigger_copy(); // SYSTEM MODULE diff --git a/src/bridge/linux.rs b/src/bridge/linux.rs index 90edfc6..967aa9d 100644 --- a/src/bridge/linux.rs +++ b/src/bridge/linux.rs @@ -44,4 +44,5 @@ extern { pub fn trigger_terminal_paste(); pub fn trigger_shift_ins_paste(); pub fn trigger_alt_shift_ins_paste(); + pub fn trigger_copy(); } \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs index 32bfb93..166b6bc 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -53,10 +53,12 @@ fn default_use_system_agent() -> bool { true } fn default_config_caching_interval() -> i32 { 800 } fn default_word_separators() -> Vec { vec![' ', ',', '.', '\r', '\n', 22u8 as char] } fn default_toggle_interval() -> u32 { 230 } +fn default_toggle_key() -> KeyModifier { KeyModifier::ALT } fn default_preserve_clipboard() -> bool {false} fn default_passive_match_regex() -> String{ "(?P:\\p{L}+)(/(?P.*)/)?".to_owned() } fn default_passive_arg_delimiter() -> char { '/' } fn default_passive_arg_escape() -> char { '\\' } +fn default_passive_key() -> KeyModifier { KeyModifier::OFF } fn default_backspace_limit() -> i32 { 3 } fn default_exclude_default_matches() -> bool {false} fn default_matches() -> Vec { Vec::new() } @@ -96,7 +98,7 @@ pub struct Configs { #[serde(default = "default_word_separators")] pub word_separators: Vec, // TODO: add parsing test - #[serde(default)] + #[serde(default = "default_toggle_key")] pub toggle_key: KeyModifier, #[serde(default = "default_toggle_interval")] @@ -114,6 +116,9 @@ pub struct Configs { #[serde(default = "default_passive_arg_escape")] pub passive_arg_escape: char, + #[serde(default = "default_passive_key")] + pub passive_key: KeyModifier, + #[serde(default)] pub paste_shortcut: PasteShortcut, @@ -156,7 +161,7 @@ impl Configs { validate_field!(result, self.config_caching_interval, default_config_caching_interval()); validate_field!(result, self.log_level, default_log_level()); - validate_field!(result, self.toggle_key, KeyModifier::default()); + validate_field!(result, self.toggle_key, default_toggle_key()); validate_field!(result, self.toggle_interval, default_toggle_interval()); validate_field!(result, self.backspace_limit, default_backspace_limit()); validate_field!(result, self.ipc_server_port, default_ipc_server_port()); @@ -165,6 +170,7 @@ impl Configs { validate_field!(result, self.passive_match_regex, default_passive_match_regex()); validate_field!(result, self.passive_arg_delimiter, default_passive_arg_delimiter()); validate_field!(result, self.passive_arg_escape, default_passive_arg_escape()); + validate_field!(result, self.passive_key, default_passive_key()); result } diff --git a/src/engine.rs b/src/engine.rs index 7cfd507..7ef9ee8 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -231,6 +231,39 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa self.ui_manager.notify(message); } + + fn on_passive(&self) { + info!("Passive mode activated"); + + // Trigger a copy shortcut to transfer the content of the selection to the clipboard + self.keyboard_manager.trigger_copy(); + + // Sleep for a while, giving time to effectively copy the text + std::thread::sleep(std::time::Duration::from_millis(100)); // TODO: avoid hardcoding + + // Then get the text from the clipboard and render the match output + let clipboard = self.clipboard_manager.get_clipboard(); + + if let Some(clipboard) = clipboard { + let config = self.config_manager.active_config(); + + let rendered = self.renderer.render_passive(&clipboard, + &config); + + match rendered { + RenderResult::Text(payload) => { + // Paste back the result in the field + self.clipboard_manager.set_clipboard(&payload); + + std::thread::sleep(std::time::Duration::from_millis(100)); // TODO: avoid hardcoding + self.keyboard_manager.trigger_paste(&config.paste_shortcut); + }, + _ => { + warn!("Cannot expand passive match") + }, + } + } + } } impl <'a, S: KeyboardManager, C: ClipboardManager, diff --git a/src/event/mod.rs b/src/event/mod.rs index bc9828a..dc47691 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -66,12 +66,6 @@ pub enum KeyModifier { OFF, } -impl Default for KeyModifier { - fn default() -> Self { - KeyModifier::ALT - } -} - // Receivers pub trait KeyEventReceiver { diff --git a/src/keyboard/linux.rs b/src/keyboard/linux.rs index 538c375..78e0961 100644 --- a/src/keyboard/linux.rs +++ b/src/keyboard/linux.rs @@ -81,4 +81,10 @@ impl super::KeyboardManager for LinuxKeyboardManager { left_arrow(count); } } + + fn trigger_copy(&self) { + unsafe { + trigger_copy(); + } + } } \ No newline at end of file diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs index d8f2611..66ebe77 100644 --- a/src/keyboard/mod.rs +++ b/src/keyboard/mod.rs @@ -34,6 +34,7 @@ pub trait KeyboardManager { fn trigger_paste(&self, shortcut: &PasteShortcut); fn delete_string(&self, count: i32); fn move_cursor_left(&self, count: i32); + fn trigger_copy(&self); } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index c1ac263..a8f0ffa 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -181,6 +181,7 @@ pub enum TriggerEntry { pub trait MatchReceiver { fn on_match(&self, m: &Match, trailing_separator: Option); fn on_enable_update(&self, status: bool); + fn on_passive(&self); } pub trait Matcher : KeyEventReceiver { diff --git a/src/matcher/scrolling.rs b/src/matcher/scrolling.rs index 2d59b19..9c70fcd 100644 --- a/src/matcher/scrolling.rs +++ b/src/matcher/scrolling.rs @@ -18,7 +18,7 @@ */ use crate::matcher::{Match, MatchReceiver, TriggerEntry}; -use std::cell::RefCell; +use std::cell::{RefCell, Ref}; use crate::event::{KeyModifier, ActionEventReceiver, ActionType}; use crate::config::ConfigManager; use crate::event::KeyModifier::BACKSPACE; @@ -30,6 +30,7 @@ pub struct ScrollingMatcher<'a, R: MatchReceiver, M: ConfigManager<'a>> { receiver: &'a R, current_set_queue: RefCell>>>, toggle_press_time: RefCell, + passive_press_time: RefCell, is_enabled: RefCell, was_previous_char_word_separator: RefCell, } @@ -45,12 +46,14 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> ScrollingMatcher<'a, R, M> { pub fn new(config_manager: &'a M, receiver: &'a R) -> ScrollingMatcher<'a, R, M> { let current_set_queue = RefCell::new(VecDeque::new()); let toggle_press_time = RefCell::new(SystemTime::now()); + let passive_press_time = RefCell::new(SystemTime::now()); ScrollingMatcher{ config_manager, receiver, current_set_queue, toggle_press_time, + passive_press_time, is_enabled: RefCell::new(true), was_previous_char_word_separator: RefCell::new(true), } @@ -193,22 +196,25 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa fn handle_modifier(&self, m: KeyModifier) { let config = self.config_manager.default_config(); + // TODO: at the moment, activating the passive key triggers the toggle key + // study a mechanism to avoid this problem + 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) { - self.toggle(); + check_interval(&self.toggle_press_time, + u128::from(config.toggle_interval), || { + self.toggle(); - let is_enabled = self.is_enabled.borrow(); + let is_enabled = self.is_enabled.borrow(); - if !*is_enabled { - self.current_set_queue.borrow_mut().clear(); - } + if !*is_enabled { + self.current_set_queue.borrow_mut().clear(); } - } - - (*toggle_press_time) = SystemTime::now(); + }); + }else if m == config.passive_key { + check_interval(&self.passive_press_time, + u128::from(config.toggle_interval), || { + self.receiver.on_passive(); + }); } // Backspace handling, basically "rewinding history" @@ -234,4 +240,15 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> ActionEventReceiver for Scroll _ => {} } } +} + +fn check_interval(state_var: &RefCell, interval: u128, elapsed_callback: F) where F:Fn() { + let mut press_time = state_var.borrow_mut(); + if let Ok(elapsed) = press_time.elapsed() { + if elapsed.as_millis() < interval { + elapsed_callback(); + } + } + + (*press_time) = SystemTime::now(); } \ No newline at end of file From e2c98284b6faf6d19c0d922ed68bdadc6cf5e468 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 19 Jan 2020 23:41:11 +0100 Subject: [PATCH 06/23] Add check to avoid espanso reinterpreting its own actions --- src/config/mod.rs | 5 +++++ src/engine.rs | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 166b6bc..96e6a8f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -59,6 +59,7 @@ fn default_passive_match_regex() -> String{ "(?P:\\p{L}+)(/(?P.*)/)? fn default_passive_arg_delimiter() -> char { '/' } fn default_passive_arg_escape() -> char { '\\' } fn default_passive_key() -> KeyModifier { KeyModifier::OFF } +fn default_action_noop_interval() -> u128 { 500 } fn default_backspace_limit() -> i32 { 3 } fn default_exclude_default_matches() -> bool {false} fn default_matches() -> Vec { Vec::new() } @@ -119,6 +120,9 @@ pub struct Configs { #[serde(default = "default_passive_key")] pub passive_key: KeyModifier, + #[serde(default = "default_action_noop_interval")] + pub action_noop_interval: u128, + #[serde(default)] pub paste_shortcut: PasteShortcut, @@ -171,6 +175,7 @@ impl Configs { validate_field!(result, self.passive_arg_delimiter, default_passive_arg_delimiter()); validate_field!(result, self.passive_arg_escape, default_passive_arg_escape()); validate_field!(result, self.passive_key, default_passive_key()); + validate_field!(result, self.action_noop_interval, default_action_noop_interval()); result } diff --git a/src/engine.rs b/src/engine.rs index 7ef9ee8..4145d48 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -32,6 +32,7 @@ use std::process::exit; use std::collections::HashMap; use std::path::PathBuf; use regex::{Regex, Captures}; +use std::time::SystemTime; pub struct Engine<'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer> { @@ -42,6 +43,8 @@ pub struct Engine<'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager< renderer: &'a R, enabled: RefCell, + last_action_time: RefCell, // Used to block espanso from re-interpreting it's own inputs + action_noop_interval: u128, } impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer> @@ -50,13 +53,17 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa config_manager: &'a M, ui_manager: &'a U, renderer: &'a R) -> Engine<'a, S, C, M, U, R> { let enabled = RefCell::new(true); + let last_action_time = RefCell::new(SystemTime::now()); + let action_noop_interval = config_manager.default_config().action_noop_interval; Engine{keyboard_manager, clipboard_manager, config_manager, ui_manager, renderer, - enabled + enabled, + last_action_time, + action_noop_interval, } } @@ -102,6 +109,20 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa None } } + + /// Used to check if the last action has been executed within a specified interval. + /// If so, return true (blocking the action), otherwise false. + fn check_last_action_and_set(&self, interval: u128) -> bool { + let mut last_action_time = self.last_action_time.borrow_mut(); + if let Ok(elapsed) = last_action_time.elapsed() { + if elapsed.as_millis() < interval { + return true; + } + } + + (*last_action_time) = SystemTime::now(); + return false; + } } lazy_static! { @@ -118,6 +139,11 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa return; } + // avoid espanso reinterpreting its own actions + if self.check_last_action_and_set(self.action_noop_interval) { + return; + } + let char_count = if trailing_separator.is_none() { m.trigger.chars().count() as i32 }else{ @@ -218,6 +244,11 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa } fn on_enable_update(&self, status: bool) { + // avoid espanso reinterpreting its own actions + if self.check_last_action_and_set(self.action_noop_interval) { + return; + } + let message = if status { "espanso enabled" }else{ @@ -233,6 +264,11 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa } fn on_passive(&self) { + // avoid espanso reinterpreting its own actions + if self.check_last_action_and_set(self.action_noop_interval) { + return; + } + info!("Passive mode activated"); // Trigger a copy shortcut to transfer the content of the selection to the clipboard From 6e03e7e8e41e88857e777282c0fa3f3f230e7e74 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Mon, 20 Jan 2020 00:03:23 +0100 Subject: [PATCH 07/23] Add passive only matches --- src/matcher/mod.rs | 8 +++++++- src/matcher/scrolling.rs | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index a8f0ffa..1c8916b 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -32,6 +32,7 @@ pub struct Match { pub trigger: String, pub content: MatchContentType, pub word: bool, + pub passive_only: bool, // Automatically calculated from the trigger, used by the matcher to check for correspondences. #[serde(skip_serializing)] @@ -133,7 +134,8 @@ impl<'a> From<&'a AutoMatch> for Match{ Self { trigger: other.trigger.clone(), content, - word: other.word.clone(), + word: other.word, + passive_only: other.passive_only, _trigger_sequence: trigger_sequence, } } @@ -155,10 +157,14 @@ struct AutoMatch { #[serde(default = "default_word")] pub word: bool, + + #[serde(default = "default_passive_only")] + pub passive_only: bool, } fn default_vars() -> Vec {Vec::new()} fn default_word() -> bool {false} +fn default_passive_only() -> bool {false} fn default_replace() -> Option {None} fn default_image_path() -> Option {None} diff --git a/src/matcher/scrolling.rs b/src/matcher/scrolling.rs index 9c70fcd..ce1f951 100644 --- a/src/matcher/scrolling.rs +++ b/src/matcher/scrolling.rs @@ -114,6 +114,11 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa let new_matches: Vec = active_config.matches.iter() .filter(|&x| { + // only active-enabled matches are considered + if x.passive_only { + return false; + } + let mut result = Self::is_matching(x, c, 0, is_current_word_separator); if x.word { From 6135787eb0f85858174248f0de450ab3a7f21e20 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Tue, 21 Jan 2020 00:22:22 +0100 Subject: [PATCH 08/23] Add passive mode Windows implementation --- native/libwinbridge/bridge.cpp | 26 ++++++++++++++++++++++++++ native/libwinbridge/bridge.h | 5 +++++ src/bridge/windows.rs | 1 + src/extension/script.rs | 25 +++++++------------------ src/extension/shell.rs | 2 +- src/keyboard/windows.rs | 6 ++++++ 6 files changed, 46 insertions(+), 19 deletions(-) diff --git a/native/libwinbridge/bridge.cpp b/native/libwinbridge/bridge.cpp index f83e26b..0621a5a 100644 --- a/native/libwinbridge/bridge.cpp +++ b/native/libwinbridge/bridge.cpp @@ -524,6 +524,31 @@ void trigger_paste() { SendInput(vec.size(), vec.data(), sizeof(INPUT)); } +void trigger_copy() { + std::vector vec; + + INPUT input = { 0 }; + + input.type = INPUT_KEYBOARD; + input.ki.wScan = 0; + input.ki.time = 0; + input.ki.dwExtraInfo = 0; + input.ki.wVk = VK_CONTROL; + input.ki.dwFlags = 0; // 0 for key press + vec.push_back(input); + + input.ki.wVk = 0x43; // C KEY + vec.push_back(input); + + input.ki.dwFlags = KEYEVENTF_KEYUP; // KEYEVENTF_KEYUP for key release + vec.push_back(input); + + input.ki.wVk = VK_CONTROL; + vec.push_back(input); + + SendInput(vec.size(), vec.data(), sizeof(INPUT)); +} + // SYSTEM @@ -699,3 +724,4 @@ int32_t set_clipboard_image(wchar_t *path) { return result; } + diff --git a/native/libwinbridge/bridge.h b/native/libwinbridge/bridge.h index 77fa4d4..a5f1d89 100644 --- a/native/libwinbridge/bridge.h +++ b/native/libwinbridge/bridge.h @@ -79,6 +79,11 @@ extern "C" void delete_string(int32_t count); */ extern "C" void trigger_paste(); +/* + * Send the copy keyboard shortcut (CTRL+C) + */ +extern "C" void trigger_copy(); + // Detect current application commands /* diff --git a/src/bridge/windows.rs b/src/bridge/windows.rs index ff916fa..f7a4459 100644 --- a/src/bridge/windows.rs +++ b/src/bridge/windows.rs @@ -59,4 +59,5 @@ extern { pub fn send_multi_vkey(vk: i32, count: i32); pub fn delete_string(count: i32); pub fn trigger_paste(); + pub fn trigger_copy(); } \ No newline at end of file diff --git a/src/extension/script.rs b/src/extension/script.rs index 554fdf5..16bd0ce 100644 --- a/src/extension/script.rs +++ b/src/extension/script.rs @@ -62,6 +62,7 @@ impl super::Extension for ScriptExtension { .output() }; + println!("{:?}", output); match output { Ok(output) => { let output_str = String::from_utf8_lossy(output.stdout.as_slice()); @@ -86,6 +87,7 @@ mod tests { use crate::extension::Extension; #[test] + #[cfg(not(target_os = "windows"))] fn test_script_basic() { let mut params = Mapping::new(); params.insert(Value::from("args"), Value::from(vec!["echo", "hello world"])); @@ -94,15 +96,11 @@ mod tests { let output = extension.calculate(¶ms, &vec![]); assert!(output.is_some()); - - if cfg!(target_os = "windows") { - assert_eq!(output.unwrap(), "hello world\r\n"); - }else{ - assert_eq!(output.unwrap(), "hello world\n"); - } + assert_eq!(output.unwrap(), "hello world\n"); } #[test] + #[cfg(not(target_os = "windows"))] fn test_script_inject_args_off() { let mut params = Mapping::new(); params.insert(Value::from("args"), Value::from(vec!["echo", "hello world"])); @@ -111,15 +109,11 @@ mod tests { let output = extension.calculate(¶ms, &vec!["jon".to_owned()]); assert!(output.is_some()); - - if cfg!(target_os = "windows") { - assert_eq!(output.unwrap(), "hello world\r\n"); - }else{ - assert_eq!(output.unwrap(), "hello world\n"); - } + assert_eq!(output.unwrap(), "hello world\n"); } #[test] + #[cfg(not(target_os = "windows"))] fn test_script_inject_args_on() { let mut params = Mapping::new(); params.insert(Value::from("args"), Value::from(vec!["echo", "hello world"])); @@ -129,11 +123,6 @@ mod tests { let output = extension.calculate(¶ms, &vec!["jon".to_owned()]); assert!(output.is_some()); - - if cfg!(target_os = "windows") { - assert_eq!(output.unwrap(), "hello world jon\r\n"); - }else{ - assert_eq!(output.unwrap(), "hello world jon\n"); - } + assert_eq!(output.unwrap(), "hello world jon\n"); } } \ No newline at end of file diff --git a/src/extension/shell.rs b/src/extension/shell.rs index f8e014a..8ad9798 100644 --- a/src/extension/shell.rs +++ b/src/extension/shell.rs @@ -24,7 +24,7 @@ use regex::{Regex, Captures}; lazy_static! { static ref POS_ARG_REGEX: Regex = if cfg!(target_os = "windows") { - Regex::new("\\%(?P\\d+)").unwrap() + Regex::new("%(?P\\d+)").unwrap() }else{ Regex::new("\\$(?P\\d+)").unwrap() }; diff --git a/src/keyboard/windows.rs b/src/keyboard/windows.rs index c4e110b..c7ee10a 100644 --- a/src/keyboard/windows.rs +++ b/src/keyboard/windows.rs @@ -73,4 +73,10 @@ impl super::KeyboardManager for WindowsKeyboardManager { send_multi_vkey(0x25, count) } } + + fn trigger_copy(&self) { + unsafe { + trigger_copy(); + } + } } \ No newline at end of file From dadfb8628e20ad326b77e81dbe518c9e905487d1 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Tue, 21 Jan 2020 21:25:42 +0100 Subject: [PATCH 09/23] Add support for Yakuake terminal. Fix #153 --- native/liblinuxbridge/bridge.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/native/liblinuxbridge/bridge.cpp b/native/liblinuxbridge/bridge.cpp index a34661a..eeb5922 100644 --- a/native/liblinuxbridge/bridge.cpp +++ b/native/liblinuxbridge/bridge.cpp @@ -479,6 +479,8 @@ int32_t is_current_window_special() { return 1; }else if (strstr(class_buffer, "Emacs") != NULL) { // Emacs return 3; + }else if (strstr(class_buffer, "yakuake") != NULL) { // Yakuake terminal + return 1; } } From 73c8f5be8681ced0614104bbf8b1cbfdefd7398b Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Tue, 21 Jan 2020 21:58:10 +0100 Subject: [PATCH 10/23] Add check to warn the user about conflicting triggers. Fix #135 --- src/config/mod.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/config/mod.rs b/src/config/mod.rs index c849527..8bbce18 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -48,6 +48,7 @@ fn default_filter_class() -> String{ "".to_owned() } fn default_filter_exec() -> String{ "".to_owned() } fn default_disabled() -> bool{ false } fn default_log_level() -> i32 { 0 } +fn default_conflict_check() -> bool{ true } fn default_ipc_server_port() -> i32 { 34982 } fn default_use_system_agent() -> bool { true } fn default_config_caching_interval() -> i32 { 800 } @@ -81,6 +82,9 @@ pub struct Configs { #[serde(default = "default_log_level")] pub log_level: i32, + #[serde(default = "default_conflict_check")] + pub conflict_check: bool, + #[serde(default = "default_ipc_server_port")] pub ipc_server_port: i32, @@ -144,6 +148,7 @@ impl Configs { validate_field!(result, self.config_caching_interval, default_config_caching_interval()); validate_field!(result, self.log_level, default_log_level()); + validate_field!(result, self.conflict_check, default_conflict_check()); validate_field!(result, self.toggle_key, KeyModifier::default()); validate_field!(result, self.toggle_interval, default_toggle_interval()); validate_field!(result, self.backspace_limit, default_backspace_limit()); @@ -324,6 +329,18 @@ impl ConfigSet { } } + // Check if some triggers are conflicting with each other + // For more information, see: https://github.com/federico-terzi/espanso/issues/135 + if default.conflict_check { + for s in specific.iter() { + let has_conflicts = Self::has_conflicts(&default, &specific); + if has_conflicts { + eprintln!("Warning: some triggers had conflicts and may not behave as intended"); + eprintln!("To turn off this check, add \"conflict_check: false\" in the configuration"); + } + } + } + Ok(ConfigSet { default, specific @@ -379,6 +396,42 @@ impl ConfigSet { return ConfigSet::load(config_dir.as_path(), package_dir.as_path()); } + + fn has_conflicts(default: &Configs, specific: &Vec) -> bool { + let mut sorted_triggers : Vec = default.matches.iter().map(|t| { + t.trigger.clone() + }).collect(); + sorted_triggers.sort(); + + let mut has_conflicts = Self::list_has_conflicts(&sorted_triggers); + + for s in specific.iter() { + let mut specific_triggers : Vec = s.matches.iter().map(|t| { + t.trigger.clone() + }).collect(); + has_conflicts |= Self::list_has_conflicts(&specific_triggers); + } + + has_conflicts + } + + fn list_has_conflicts(sorted_list: &Vec) -> bool { + if sorted_list.len() <= 1 { + return false + } + + let mut has_conflicts = false; + + for (i, item) in sorted_list.iter().skip(1).enumerate() { + let previous = &sorted_list[i]; + if item.starts_with(previous) { + has_conflicts = true; + eprintln!("Warning: trigger '{}' is conflicting with '{}' and may not behave as intended", item, previous); + } + } + + has_conflicts + } } pub trait ConfigManager<'a> { From 67e8ece408599d880ba4299d9ee7c91bf5caf514 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Tue, 21 Jan 2020 23:11:14 +0100 Subject: [PATCH 11/23] Add tests for #153 --- src/config/mod.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/config/mod.rs b/src/config/mod.rs index 8bbce18..582106a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -409,6 +409,7 @@ impl ConfigSet { let mut specific_triggers : Vec = s.matches.iter().map(|t| { t.trigger.clone() }).collect(); + specific_triggers.sort(); has_conflicts |= Self::list_has_conflicts(&specific_triggers); } @@ -1056,4 +1057,113 @@ mod tests { assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry")); assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "ron")); } + + #[test] + fn test_list_has_conflict_no_conflict() { + assert_eq!(ConfigSet::list_has_conflicts(&vec!(":ab".to_owned(), ":bc".to_owned())), false); + } + + #[test] + fn test_list_has_conflict_conflict() { + let mut list = vec!("ac".to_owned(), "ab".to_owned(), "abc".to_owned()); + list.sort(); + assert_eq!(ConfigSet::list_has_conflicts(&list), true); + } + + #[test] + fn test_has_conflict_no_conflict() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + "###); + + let user_defined_path = create_user_config_file(data_dir.path(), "specific.yml", r###" + name: specific1 + + matches: + - trigger: "hello" + replace: "world" + "###); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(ConfigSet::has_conflicts(&config_set.default, &config_set.specific), false); + } + + #[test] + fn test_has_conflict_conflict_in_default() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + - trigger: acb + replace: Error + "###); + + let user_defined_path = create_user_config_file(data_dir.path(), "specific.yml", r###" + name: specific1 + + matches: + - trigger: "hello" + replace: "world" + "###); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(ConfigSet::has_conflicts(&config_set.default, &config_set.specific), true); + } + + #[test] + fn test_has_conflict_conflict_in_specific_and_default() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + "###); + + let user_defined_path = create_user_config_file(data_dir.path(), "specific.yml", r###" + name: specific1 + + matches: + - trigger: "bcd" + replace: "Conflict" + "###); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(ConfigSet::has_conflicts(&config_set.default, &config_set.specific), true); + } + + #[test] + fn test_has_conflict_no_conflict_in_specific_and_specific() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + "###); + + let user_defined_path = create_user_config_file(data_dir.path(), "specific.yml", r###" + name: specific1 + + matches: + - trigger: "bad" + replace: "Conflict" + "###); + let user_defined_path2 = create_user_config_file(data_dir.path(), "specific2.yml", r###" + name: specific2 + + matches: + - trigger: "badass" + replace: "Conflict" + "###); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(ConfigSet::has_conflicts(&config_set.default, &config_set.specific), false); + } } \ No newline at end of file From 3b254b4179ea9a90b0a804a23ecb63d946ab3589 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Tue, 21 Jan 2020 23:16:37 +0100 Subject: [PATCH 12/23] Add support for Tilix terminal. #143 --- native/liblinuxbridge/bridge.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/native/liblinuxbridge/bridge.cpp b/native/liblinuxbridge/bridge.cpp index eeb5922..c3e94f6 100644 --- a/native/liblinuxbridge/bridge.cpp +++ b/native/liblinuxbridge/bridge.cpp @@ -481,6 +481,8 @@ int32_t is_current_window_special() { return 3; }else if (strstr(class_buffer, "yakuake") != NULL) { // Yakuake terminal return 1; + }else if (strstr(class_buffer, "Tilix") != NULL) { // Tilix terminal + return 1; } } From 28d5f1d2e5319245aadd060ac89c6ce61957b473 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Tue, 21 Jan 2020 23:20:54 +0100 Subject: [PATCH 13/23] Change donation image --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 976fbde..25a2661 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,7 @@ espanso is a free, open source software developed in my (little) spare time. If you liked the project and would like to support further development, please consider making a small donation, it really helps :) -[![Donate with PayPal](images/githubsponsor.png)](https://github.com/sponsors/federico-terzi) -[![Donate with PayPal](images/paypal.png)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=FHNLR5DRS267E&source=url) +[![Donate with PayPal](images/donate.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=FHNLR5DRS267E&source=url) ## Contributors From 86586623cca163129e6ec9fbd9bca797e299e90f Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Wed, 22 Jan 2020 21:51:36 +0100 Subject: [PATCH 14/23] Add trigger_copy implementation on MacOS --- native/libmacbridge/bridge.h | 5 +++++ native/libmacbridge/bridge.mm | 36 ++++++++++++++++++++++++++++++++++- src/bridge/macos.rs | 1 + src/keyboard/macos.rs | 6 ++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/native/libmacbridge/bridge.h b/native/libmacbridge/bridge.h index 33848c9..3fc9f9d 100644 --- a/native/libmacbridge/bridge.h +++ b/native/libmacbridge/bridge.h @@ -80,6 +80,11 @@ void delete_string(int32_t count); */ void trigger_paste(); +/* + * Trigger normal copy ( Pressing CMD+C ) + */ +void trigger_copy(); + // UI /* diff --git a/native/libmacbridge/bridge.mm b/native/libmacbridge/bridge.mm index 8b31e07..0280789 100644 --- a/native/libmacbridge/bridge.mm +++ b/native/libmacbridge/bridge.mm @@ -180,6 +180,39 @@ void trigger_paste() { }); } + +void trigger_copy() { + dispatch_async(dispatch_get_main_queue(), ^(void) { + CGEventRef keydown; + keydown = CGEventCreateKeyboardEvent(NULL, 0x37, true); // CMD + CGEventPost(kCGHIDEventTap, keydown); + CFRelease(keydown); + + usleep(2000); + + CGEventRef keydown2; + keydown2 = CGEventCreateKeyboardEvent(NULL, 0x08, true); // C key + CGEventPost(kCGHIDEventTap, keydown2); + CFRelease(keydown2); + + usleep(2000); + + CGEventRef keyup; + keyup = CGEventCreateKeyboardEvent(NULL, 0x08, false); + CGEventPost(kCGHIDEventTap, keyup); + CFRelease(keyup); + + usleep(2000); + + CGEventRef keyup2; + keyup2 = CGEventCreateKeyboardEvent(NULL, 0x37, false); // CMD + CGEventPost(kCGHIDEventTap, keyup2); + CFRelease(keyup2); + + usleep(2000); + }); +} + int32_t get_active_app_bundle(char * buffer, int32_t size) { NSRunningApplication *frontApp = [[NSWorkspace sharedWorkspace] frontmostApplication]; NSString *bundlePath = [frontApp bundleURL].path; @@ -291,4 +324,5 @@ 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 f99a6f8..c698d35 100644 --- a/src/bridge/macos.rs +++ b/src/bridge/macos.rs @@ -59,4 +59,5 @@ extern { pub fn send_multi_vkey(vk: i32, count: i32); pub fn delete_string(count: i32); pub fn trigger_paste(); + pub fn trigger_copy(); } \ No newline at end of file diff --git a/src/keyboard/macos.rs b/src/keyboard/macos.rs index 6cba0da..ccc4faf 100644 --- a/src/keyboard/macos.rs +++ b/src/keyboard/macos.rs @@ -56,6 +56,12 @@ impl super::KeyboardManager for MacKeyboardManager { } } + fn trigger_copy(&self) { + unsafe { + trigger_copy(); + } + } + fn delete_string(&self, count: i32) { unsafe {delete_string(count)} } From d8392b4e48e106711d92ca94098ff527e4879702 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sat, 25 Jan 2020 14:58:07 +0100 Subject: [PATCH 15/23] Fix bug that prevented certain applications, such as Photoshop, from working correctly with espanso on macOS. Fix #159 --- native/libmacbridge/bridge.mm | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/native/libmacbridge/bridge.mm b/native/libmacbridge/bridge.mm index 0280789..564405b 100644 --- a/native/libmacbridge/bridge.mm +++ b/native/libmacbridge/bridge.mm @@ -101,6 +101,14 @@ void send_string(const char * string) { usleep(2000); + // Some applications require an explicit release of the space key + // For more information: https://github.com/federico-terzi/espanso/issues/159 + CGEventRef e2 = CGEventCreateKeyboardEvent(NULL, 0x31, false); + CGEventPost(kCGHIDEventTap, e2); + CFRelease(e2); + + usleep(2000); + i += chunk_size; } }); From 47340e4a405af1fdcb16d7f37e31e8fc7da912f0 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 26 Jan 2020 00:43:25 +0100 Subject: [PATCH 16/23] Add basic deb packaging metadata --- Cargo.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index fccfc68..4998e9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,4 +38,9 @@ libc = "0.2.62" zip = "0.5.3" [build-dependencies] -cmake = "0.1.31" \ No newline at end of file +cmake = "0.1.31" + +[package.metadata.deb] +maintainer = "Federico Terzi " +depends = "$auto, systemd, libxtst6, libxdo3, xclip, libnotify-bin" +section = "utility" From fa74737d5d03e2ae719003df3282f603478e44e6 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 26 Jan 2020 22:00:44 +0100 Subject: [PATCH 17/23] Add license file to deb package --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 4998e9a..5d0fccc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,3 +44,4 @@ cmake = "0.1.31" maintainer = "Federico Terzi " depends = "$auto, systemd, libxtst6, libxdo3, xclip, libnotify-bin" section = "utility" +license-file = ["LICENSE", "1"] \ No newline at end of file From 15ad35046aaec5610bfb82c2ec04ce0b38aee94b Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 26 Jan 2020 22:13:07 +0100 Subject: [PATCH 18/23] Add DEB packaging to CI --- ci/build-linux.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ci/build-linux.yml b/ci/build-linux.yml index a8e2dd8..c0fc49f 100644 --- a/ci/build-linux.yml +++ b/ci/build-linux.yml @@ -7,4 +7,12 @@ steps: cp target/release/espanso-*.gz . sha256sum espanso-*.gz | awk '{ print $1 }' > espanso-linux-sha256.txt ls -la - displayName: "Cargo build and packaging for Linux" \ No newline at end of file + displayName: "Cargo build and packaging for Linux" + + - script: | + cargo install cargo-deb + cargo deb + cp target/release/debian/espanso*amd64.deb espanso-debian-amd64.deb + sha256sum espanso-*amd64.deb | awk '{ print $1 }' > espanso-debian-amd64-sha256.txt + ls -la + displayName: "Packaging deb package" \ No newline at end of file From 1ad5bae62f29264052da807048c0ef077098fd8b Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 26 Jan 2020 22:16:39 +0100 Subject: [PATCH 19/23] Version bump 0.5.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84de321..ecf6760 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "espanso" -version = "0.4.1" +version = "0.5.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 fccfc68..b037d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "0.4.1" +version = "0.5.0" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" From 025d00ad2608d45609fb39a24d9410e28189a9da Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 26 Jan 2020 22:30:54 +0100 Subject: [PATCH 20/23] Add options to selectively disable active and passive mode --- src/config/mod.rs | 12 ++++++++---- src/engine.rs | 10 +++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 073e0f1..154dccf 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -46,7 +46,6 @@ fn default_parent() -> String{ "self".to_owned() } fn default_filter_title() -> String{ "".to_owned() } fn default_filter_class() -> String{ "".to_owned() } fn default_filter_exec() -> String{ "".to_owned() } -fn default_disabled() -> bool{ false } fn default_log_level() -> i32 { 0 } fn default_conflict_check() -> bool{ true } fn default_ipc_server_port() -> i32 { 34982 } @@ -60,6 +59,8 @@ fn default_passive_match_regex() -> String{ "(?P:\\p{L}+)(/(?P.*)/)? fn default_passive_arg_delimiter() -> char { '/' } fn default_passive_arg_escape() -> char { '\\' } fn default_passive_key() -> KeyModifier { KeyModifier::OFF } +fn default_enable_passive() -> bool { false } +fn default_enable_active() -> bool { true } fn default_action_noop_interval() -> u128 { 500 } fn default_backspace_limit() -> i32 { 3 } fn default_exclude_default_matches() -> bool {false} @@ -82,9 +83,6 @@ pub struct Configs { #[serde(default = "default_filter_exec")] pub filter_exec: String, - #[serde(default = "default_disabled")] - pub disabled: bool, - #[serde(default = "default_log_level")] pub log_level: i32, @@ -124,6 +122,12 @@ pub struct Configs { #[serde(default = "default_passive_key")] pub passive_key: KeyModifier, + #[serde(default = "default_enable_passive")] + pub enable_passive: bool, + + #[serde(default = "default_enable_active")] + pub enable_active: bool, + #[serde(default = "default_action_noop_interval")] pub action_noop_interval: u128, diff --git a/src/engine.rs b/src/engine.rs index 4145d48..a971a4f 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -135,7 +135,7 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa fn on_match(&self, m: &Match, trailing_separator: Option) { let config = self.config_manager.active_config(); - if config.disabled { + if !config.enable_active { return; } @@ -269,6 +269,12 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa return; } + let config = self.config_manager.active_config(); + + if !config.enable_passive { + return; + } + info!("Passive mode activated"); // Trigger a copy shortcut to transfer the content of the selection to the clipboard @@ -281,8 +287,6 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa let clipboard = self.clipboard_manager.get_clipboard(); if let Some(clipboard) = clipboard { - let config = self.config_manager.active_config(); - let rendered = self.renderer.render_passive(&clipboard, &config); From a6282b1a9d961f55506abb9dacd2bfeb09307ec8 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 26 Jan 2020 22:40:32 +0100 Subject: [PATCH 21/23] Add delay to mitigate clipboard restoration race condition. Fix #148 --- src/config/mod.rs | 5 +++++ src/engine.rs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/config/mod.rs b/src/config/mod.rs index 154dccf..8eb2c2e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -63,6 +63,7 @@ fn default_enable_passive() -> bool { false } fn default_enable_active() -> bool { true } fn default_action_noop_interval() -> u128 { 500 } fn default_backspace_limit() -> i32 { 3 } +fn default_restore_clipboard_delay() -> i32 { 300 } fn default_exclude_default_matches() -> bool {false} fn default_matches() -> Vec { Vec::new() } @@ -137,6 +138,9 @@ pub struct Configs { #[serde(default = "default_backspace_limit")] pub backspace_limit: i32, + #[serde(default = "default_restore_clipboard_delay")] + pub restore_clipboard_delay: i32, + #[serde(default)] pub backend: BackendType, @@ -185,6 +189,7 @@ impl Configs { validate_field!(result, self.passive_arg_escape, default_passive_arg_escape()); validate_field!(result, self.passive_key, default_passive_key()); validate_field!(result, self.action_noop_interval, default_action_noop_interval()); + validate_field!(result, self.restore_clipboard_delay, default_restore_clipboard_delay()); result } diff --git a/src/engine.rs b/src/engine.rs index a971a4f..ce3faca 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -239,6 +239,10 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa // Restore previous clipboard content if let Some(previous_clipboard_content) = previous_clipboard_content { + // Sometimes an expansion gets overwritten before pasting by the previous content + // A delay is needed to mitigate the problem + std::thread::sleep(std::time::Duration::from_millis(config.restore_clipboard_delay as u64)); + self.clipboard_manager.set_clipboard(&previous_clipboard_content); } } From 0dfcb9cef725613ac4ffec6df05f9981df02c57b Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 26 Jan 2020 23:56:50 +0100 Subject: [PATCH 22/23] First draft of global variables. Fix #162 --- src/config/mod.rs | 143 ++++++++++++++++++++++++++++++++++++----- src/extension/dummy.rs | 44 +++++++++++++ src/extension/mod.rs | 2 + src/render/default.rs | 79 ++++++++++++++++++++++- 4 files changed, 249 insertions(+), 19 deletions(-) create mode 100644 src/extension/dummy.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index 8eb2c2e..9dec411 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -21,7 +21,7 @@ extern crate dirs; use std::path::{Path, PathBuf}; use std::{fs}; -use crate::matcher::Match; +use crate::matcher::{Match, MatchVariable}; use std::fs::{File, create_dir_all}; use std::io::Read; use serde::{Serialize, Deserialize}; @@ -64,12 +64,13 @@ fn default_enable_active() -> bool { true } fn default_action_noop_interval() -> u128 { 500 } fn default_backspace_limit() -> i32 { 3 } fn default_restore_clipboard_delay() -> i32 { 300 } -fn default_exclude_default_matches() -> bool {false} +fn default_exclude_default_entries() -> bool {false} fn default_matches() -> Vec { Vec::new() } +fn default_global_vars() -> Vec { Vec::new() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Configs { - #[serde(default = "default_name")] +#[serde(default = "default_name")] pub name: String, #[serde(default = "default_parent")] @@ -144,11 +145,15 @@ pub struct Configs { #[serde(default)] pub backend: BackendType, - #[serde(default = "default_exclude_default_matches")] - pub exclude_default_matches: bool, + #[serde(default = "default_exclude_default_entries")] + pub exclude_default_entries: bool, #[serde(default = "default_matches")] - pub matches: Vec + pub matches: Vec, + + #[serde(default = "default_global_vars")] + pub global_vars: Vec + } // Macro used to validate config fields @@ -244,29 +249,56 @@ impl Configs { } fn merge_config(&mut self, new_config: Configs) { + // Merge matches let mut merged_matches = new_config.matches; - let mut trigger_set = HashSet::new(); + let mut match_trigger_set = HashSet::new(); merged_matches.iter().for_each(|m| { - trigger_set.insert(m.trigger.clone()); + match_trigger_set.insert(m.trigger.clone()); }); let parent_matches : Vec = self.matches.iter().filter(|&m| { - !trigger_set.contains(&m.trigger) + !match_trigger_set.contains(&m.trigger) }).cloned().collect(); merged_matches.extend(parent_matches); self.matches = merged_matches; + + // Merge global variables + let mut merged_global_vars = new_config.global_vars; + let mut vars_name_set = HashSet::new(); + merged_global_vars.iter().for_each(|m| { + vars_name_set.insert(m.name.clone()); + }); + let parent_vars : Vec = self.global_vars.iter().filter(|&m| { + !vars_name_set.contains(&m.name) + }).cloned().collect(); + + merged_global_vars.extend(parent_vars); + self.global_vars = merged_global_vars; } fn merge_default(&mut self, default: &Configs) { - let mut trigger_set = HashSet::new(); + // Merge matches + let mut match_trigger_set = HashSet::new(); self.matches.iter().for_each(|m| { - trigger_set.insert(m.trigger.clone()); + match_trigger_set.insert(m.trigger.clone()); }); let default_matches : Vec = default.matches.iter().filter(|&m| { - !trigger_set.contains(&m.trigger) + !match_trigger_set.contains(&m.trigger) }).cloned().collect(); self.matches.extend(default_matches); + + // Merge global variables + let mut vars_name_set = HashSet::new(); + self.global_vars.iter().for_each(|m| { + vars_name_set.insert(m.name.clone()); + }); + let default_vars : Vec = default.global_vars.iter().filter(|&m| { + !vars_name_set.contains(&m.name) + }).cloned().collect(); + + self.global_vars.extend(default_vars); + } } @@ -357,9 +389,9 @@ impl ConfigSet { let default= configs.get(0).unwrap().clone(); let mut specific = (&configs[1..]).to_vec().clone(); - // Add default matches to specific configs when needed + // Add default entries to specific configs when needed for config in specific.iter_mut() { - if !config.exclude_default_matches { + if !config.exclude_default_entries { config.merge_default(&default); } } @@ -849,7 +881,7 @@ mod tests { let user_defined_path2 = create_user_config_file(data_dir.path(), "specific2.yml", r###" name: specific1 - exclude_default_matches: true + exclude_default_entries: true matches: - trigger: "hello" @@ -884,7 +916,7 @@ mod tests { let user_defined_path2 = create_user_config_file(data_dir.path(), "specific.zzz", r###" name: specific1 - exclude_default_matches: true + exclude_default_entries: true matches: - trigger: "hello" @@ -1201,4 +1233,83 @@ mod tests { let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); assert_eq!(ConfigSet::has_conflicts(&config_set.default, &config_set.specific), false); } + + #[test] + fn test_config_set_specific_inherits_default_global_vars() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(r###" + global_vars: + - name: testvar + type: date + params: + format: "%m" + "###); + + let user_defined_path = create_user_config_file(data_dir.path(), "specific.yml", r###" + global_vars: + - name: specificvar + type: date + params: + format: "%m" + "###); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 1); + assert_eq!(config_set.default.global_vars.len(), 1); + assert_eq!(config_set.specific[0].global_vars.len(), 2); + assert!(config_set.specific[0].global_vars.iter().any(|m| m.name == "testvar")); + assert!(config_set.specific[0].global_vars.iter().any(|m| m.name == "specificvar")); + } + + #[test] + fn test_config_set_default_get_variables_from_specific() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(r###" + global_vars: + - name: testvar + type: date + params: + format: "%m" + "###); + + let user_defined_path = create_user_config_file(data_dir.path(), "specific.yml", r###" + parent: default + global_vars: + - name: specificvar + type: date + params: + format: "%m" + "###); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + assert_eq!(config_set.default.global_vars.len(), 2); + assert!(config_set.default.global_vars.iter().any(|m| m.name == "testvar")); + assert!(config_set.default.global_vars.iter().any(|m| m.name == "specificvar")); + } + + #[test] + fn test_config_set_specific_dont_inherits_default_global_vars_when_exclude_is_on() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(r###" + global_vars: + - name: testvar + type: date + params: + format: "%m" + "###); + + let user_defined_path = create_user_config_file(data_dir.path(), "specific.yml", r###" + exclude_default_entries: true + + global_vars: + - name: specificvar + type: date + params: + format: "%m" + "###); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 1); + assert_eq!(config_set.default.global_vars.len(), 1); + assert_eq!(config_set.specific[0].global_vars.len(), 1); + assert!(config_set.specific[0].global_vars.iter().any(|m| m.name == "specificvar")); + } } \ No newline at end of file diff --git a/src/extension/dummy.rs b/src/extension/dummy.rs new file mode 100644 index 0000000..c9932e8 --- /dev/null +++ b/src/extension/dummy.rs @@ -0,0 +1,44 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2020 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use serde_yaml::{Mapping, Value}; + +pub struct DummyExtension {} + +impl DummyExtension { + pub fn new() -> DummyExtension { + DummyExtension{} + } +} + +impl super::Extension for DummyExtension { + fn name(&self) -> String { + String::from("dummy") + } + + fn calculate(&self, params: &Mapping, _: &Vec) -> Option { + let echo = params.get(&Value::from("echo")); + + if let Some(echo) = echo { + Some(echo.as_str().unwrap_or_default().to_owned()) + }else{ + None + } + } +} \ No newline at end of file diff --git a/src/extension/mod.rs b/src/extension/mod.rs index ccb6934..047060d 100644 --- a/src/extension/mod.rs +++ b/src/extension/mod.rs @@ -23,6 +23,7 @@ mod date; mod shell; mod script; mod random; +mod dummy; pub trait Extension { fn name(&self) -> String; @@ -35,5 +36,6 @@ pub fn get_extensions() -> Vec> { Box::new(shell::ShellExtension::new()), Box::new(script::ScriptExtension::new()), Box::new(random::RandomExtension::new()), + Box::new(dummy::DummyExtension::new()), ] } \ No newline at end of file diff --git a/src/render/default.rs b/src/render/default.rs index 2663c0b..feafac6 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -79,10 +79,11 @@ impl super::Renderer for DefaultRenderer { match &m.content { // Text Match MatchContentType::Text(content) => { - let target_string = if content._has_vars { + let target_string = if content._has_vars || !config.global_vars.is_empty(){ let mut output_map = HashMap::new(); - for variable in content.vars.iter() { + // Cycle through both the local and global variables + for variable in config.global_vars.iter().chain(&content.vars) { // In case of variables of type match, we need to recursively call // the render function if variable.var_type == "match" { @@ -118,7 +119,6 @@ impl super::Renderer for DefaultRenderer { }, } }else{ // Normal extension variables - // TODO: pass the arguments to the extension let extension = self.extension_map.get(&variable.var_type); if let Some(extension) = extension { let ext_out = extension.calculate(&variable.params, &args); @@ -408,4 +408,77 @@ mod tests { verify_render(rendered, "Hi JonSnow"); } + + #[test] + fn test_render_passive_local_var() { + let text = "this is :test"; + + let config = get_config_for(r###" + matches: + - trigger: ':test' + replace: "my {{output}}" + vars: + - name: output + type: dummy + params: + echo: "result" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "this is my result"); + } + + #[test] + fn test_render_passive_global_var() { + let text = "this is :test"; + + let config = get_config_for(r###" + global_vars: + - name: output + type: dummy + params: + echo: "result" + matches: + - trigger: ':test' + replace: "my {{output}}" + + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "this is my result"); + } + + #[test] + fn test_render_passive_global_var_is_overridden_by_local() { + let text = "this is :test"; + + let config = get_config_for(r###" + global_vars: + - name: output + type: dummy + params: + echo: "result" + matches: + - trigger: ':test' + replace: "my {{output}}" + vars: + - name: "output" + type: dummy + params: + echo: "local" + + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "this is my local"); + } } \ No newline at end of file From 084209155f63b04e5fec56aa324508fec45d4e13 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 2 Feb 2020 17:10:55 +0100 Subject: [PATCH 23/23] Fix broken windows CI pipeline --- ci/install-rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/install-rust.yml b/ci/install-rust.yml index ca9e2c4..163eda8 100644 --- a/ci/install-rust.yml +++ b/ci/install-rust.yml @@ -15,7 +15,7 @@ steps: # Windows. - script: | curl -sSf -o rustup-init.exe https://win.rustup.rs - rustup-init.exe -y --default-toolchain %RUSTUP_TOOLCHAIN% + rustup-init.exe -y --default-toolchain %RUSTUP_TOOLCHAIN% --default-host x86_64-pc-windows-msvc set PATH=%PATH%;%USERPROFILE%\.cargo\bin echo "##vso[task.setvariable variable=PATH;]%PATH%;%USERPROFILE%\.cargo\bin" env: