diff --git a/native/liblinuxbridge/bridge.cpp b/native/liblinuxbridge/bridge.cpp
index c3e94f6..4fcdd58 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/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..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;
}
});
@@ -180,6 +188,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 +332,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/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/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/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/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/config/mod.rs b/src/config/mod.rs
index 582106a..073e0f1 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -54,7 +54,13 @@ 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_action_noop_interval() -> u128 { 500 }
fn default_backspace_limit() -> i32 { 3 }
fn default_exclude_default_matches() -> bool {false}
fn default_matches() -> Vec { Vec::new() }
@@ -97,7 +103,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")]
@@ -106,6 +112,21 @@ 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_delimiter")]
+ pub passive_arg_delimiter: char,
+
+ #[serde(default = "default_passive_arg_escape")]
+ pub passive_arg_escape: char,
+
+ #[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,
@@ -149,12 +170,17 @@ 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_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());
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_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 8f49a5c..4145d48 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -26,43 +26,44 @@ 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;
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> {
+ 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,
+ 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>
- 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);
+ 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,
- extension_map,
- enabled
+ renderer,
+ enabled,
+ last_action_time,
+ action_noop_interval,
}
}
@@ -108,14 +109,28 @@ 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! {
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();
@@ -124,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{
@@ -134,40 +154,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 +224,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);
},
}
@@ -258,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{
@@ -271,10 +262,48 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa
self.ui_manager.notify(message);
}
+
+ 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
+ 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,
- 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/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/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..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) -> Option {
+ 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.");
@@ -82,7 +85,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 +93,30 @@ mod tests {
assert!(choices.iter().any(|x| x == &output));
}
+
+ #[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 a5519aa..16bd0ce 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, 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..])
@@ -55,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());
@@ -71,4 +79,50 @@ 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]
+ #[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"]));
+
+ let extension = ScriptExtension::new();
+ let output = extension.calculate(¶ms, &vec![]);
+
+ assert!(output.is_some());
+ 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"]));
+
+ let extension = ScriptExtension::new();
+ let output = extension.calculate(¶ms, &vec!["jon".to_owned()]);
+
+ assert!(output.is_some());
+ 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"]));
+ 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());
+ 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 aa1012a..8ad9798 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) -> Option {
+ 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()
};
@@ -90,7 +110,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 +128,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 +146,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 +159,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 +177,37 @@ 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");
}
+
+ #[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
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/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)}
}
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/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
diff --git a/src/main.rs b/src/main.rs
index 5d59a2a..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::*;
@@ -52,6 +52,7 @@ mod utils;
mod bridge;
mod engine;
mod config;
+mod render;
mod system;
mod context;
mod matcher;
@@ -332,11 +333,14 @@ fn daemon_background(receive_channel: Receiver, config_set: ConfigSet) {
let extensions = extension::get_extensions();
+ let renderer = render::default::DefaultRenderer::new(extensions,
+ config_manager.default_config().clone());
+
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/matcher/mod.rs b/src/matcher/mod.rs
index c1ac263..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}
@@ -181,6 +187,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..ce1f951 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),
}
@@ -111,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 {
@@ -193,22 +201,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 +245,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
diff --git a/src/render/default.rs b/src/render/default.rs
new file mode 100644
index 0000000..2663c0b
--- /dev/null
+++ b/src/render/default.rs
@@ -0,0 +1,411 @@
+/*
+ * 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>,
+
+ // Regex used to identify matches (and arguments) in passive expansions
+ passive_match_regex: Regex,
+}
+
+impl 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");
+ });
+
+ DefaultRenderer{
+ extension_map,
+ passive_match_regex,
+ }
+ }
+
+ 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 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
+ // 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);
+ 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()
+ };
+
+ // Render any argument that may be present
+ let target_string = utils::render_args(&target_string, &args);
+
+ 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
+ }
+ },
+ }
+ }
+
+ 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 = utils::split_args(match_args,
+ config.passive_arg_delimiter,
+ config.passive_arg_escape);
+
+ 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())
+ }
+}
+
+// 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) => {
+ 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);
+ }
+
+ #[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
new file mode 100644
index 0000000..80bf645
--- /dev/null
+++ b/src/render/mod.rs
@@ -0,0 +1,39 @@
+/*
+ * 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(crate) mod utils;
+
+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 {
+ Text(String),
+ Image(PathBuf),
+ Error
+}
\ No newline at end of file
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