Merge branch 'dev' into snap

This commit is contained in:
Federico Terzi 2020-02-28 21:46:46 +01:00
commit aaad61a044
33 changed files with 1492 additions and 132 deletions

2
Cargo.lock generated
View File

@ -370,7 +370,7 @@ dependencies = [
[[package]] [[package]]
name = "espanso" name = "espanso"
version = "0.4.1" version = "0.5.1"
dependencies = [ dependencies = [
"backtrace 0.3.37 (registry+https://github.com/rust-lang/crates.io-index)", "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)", "chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "espanso" name = "espanso"
version = "0.4.1" version = "0.5.1"
authors = ["Federico Terzi <federicoterzi96@gmail.com>"] authors = ["Federico Terzi <federicoterzi96@gmail.com>"]
license = "GPL-3.0" license = "GPL-3.0"
description = "Cross-platform Text Expander written in Rust" description = "Cross-platform Text Expander written in Rust"
@ -38,4 +38,10 @@ libc = "0.2.62"
zip = "0.5.3" zip = "0.5.3"
[build-dependencies] [build-dependencies]
cmake = "0.1.31" cmake = "0.1.31"
[package.metadata.deb]
maintainer = "Federico Terzi <federicoterzi96@gmail.com>"
depends = "$auto, systemd, libxtst6, libxdo3, xclip, libnotify-bin"
section = "utility"
license-file = ["LICENSE", "1"]

View File

@ -7,4 +7,12 @@ steps:
cp target/release/espanso-*.gz . cp target/release/espanso-*.gz .
sha256sum espanso-*.gz | awk '{ print $1 }' > espanso-linux-sha256.txt sha256sum espanso-*.gz | awk '{ print $1 }' > espanso-linux-sha256.txt
ls -la ls -la
displayName: "Cargo build and packaging for Linux" 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"

View File

@ -15,7 +15,7 @@ steps:
# Windows. # Windows.
- script: | - script: |
curl -sSf -o rustup-init.exe https://win.rustup.rs 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 set PATH=%PATH%;%USERPROFILE%\.cargo\bin
echo "##vso[task.setvariable variable=PATH;]%PATH%;%USERPROFILE%\.cargo\bin" echo "##vso[task.setvariable variable=PATH;]%PATH%;%USERPROFILE%\.cargo\bin"
env: env:

View File

@ -307,6 +307,18 @@ void trigger_alt_shift_ins_paste() {
xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Shift+Alt+Insert", 8000); xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Shift+Alt+Insert", 8000);
} }
void trigger_ctrl_alt_paste() {
xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Control_L+Alt+v", 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 // SYSTEM MODULE
// Function taken from the wmlib tool source code // Function taken from the wmlib tool source code
@ -459,8 +471,8 @@ int32_t is_current_window_special() {
if (res > 0) { if (res > 0) {
if (strstr(class_buffer, "terminal") != NULL) { if (strstr(class_buffer, "terminal") != NULL) {
return 1; return 1;
}else if (strstr(class_buffer, "URxvt") != NULL) { // Manjaro terminal }else if (strstr(class_buffer, "URxvt") != NULL) { // urxvt terminal
return 1; return 4;
}else if (strstr(class_buffer, "XTerm") != NULL) { // XTerm and UXTerm }else if (strstr(class_buffer, "XTerm") != NULL) { // XTerm and UXTerm
return 1; return 1;
}else if (strstr(class_buffer, "Termite") != NULL) { // Termite }else if (strstr(class_buffer, "Termite") != NULL) { // Termite

View File

@ -92,6 +92,15 @@ extern "C" void trigger_shift_ins_paste();
*/ */
extern "C" void trigger_alt_shift_ins_paste(); extern "C" void trigger_alt_shift_ins_paste();
/*
* Trigger CTRL+ALT+V pasting
*/
extern "C" void trigger_ctrl_alt_paste();
/*
* Trigger copy shortcut ( Pressing CTRL+C )
*/
extern "C" void trigger_copy();
// SYSTEM MODULE // SYSTEM MODULE

View File

@ -80,6 +80,11 @@ void delete_string(int32_t count);
*/ */
void trigger_paste(); void trigger_paste();
/*
* Trigger normal copy ( Pressing CMD+C )
*/
void trigger_copy();
// UI // UI
/* /*

View File

@ -101,6 +101,14 @@ void send_string(const char * string) {
usleep(2000); 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; 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) { int32_t get_active_app_bundle(char * buffer, int32_t size) {
NSRunningApplication *frontApp = [[NSWorkspace sharedWorkspace] frontmostApplication]; NSRunningApplication *frontApp = [[NSWorkspace sharedWorkspace] frontmostApplication];
NSString *bundlePath = [frontApp bundleURL].path; NSString *bundlePath = [frontApp bundleURL].path;
@ -291,4 +332,5 @@ int32_t prompt_accessibility() {
void open_settings_panel() { void open_settings_panel() {
NSString *urlString = @"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; NSString *urlString = @"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:urlString]]; [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:urlString]];
} }

View File

@ -524,6 +524,31 @@ void trigger_paste() {
SendInput(vec.size(), vec.data(), sizeof(INPUT)); SendInput(vec.size(), vec.data(), sizeof(INPUT));
} }
void trigger_copy() {
std::vector<INPUT> 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 // SYSTEM
@ -699,3 +724,4 @@ int32_t set_clipboard_image(wchar_t *path) {
return result; return result;
} }

View File

@ -79,6 +79,11 @@ extern "C" void delete_string(int32_t count);
*/ */
extern "C" void trigger_paste(); extern "C" void trigger_paste();
/*
* Send the copy keyboard shortcut (CTRL+C)
*/
extern "C" void trigger_copy();
// Detect current application commands // Detect current application commands
/* /*

View File

@ -44,4 +44,6 @@ extern {
pub fn trigger_terminal_paste(); pub fn trigger_terminal_paste();
pub fn trigger_shift_ins_paste(); pub fn trigger_shift_ins_paste();
pub fn trigger_alt_shift_ins_paste(); pub fn trigger_alt_shift_ins_paste();
pub fn trigger_ctrl_alt_paste();
pub fn trigger_copy();
} }

View File

@ -59,4 +59,5 @@ extern {
pub fn send_multi_vkey(vk: i32, count: i32); pub fn send_multi_vkey(vk: i32, count: i32);
pub fn delete_string(count: i32); pub fn delete_string(count: i32);
pub fn trigger_paste(); pub fn trigger_paste();
pub fn trigger_copy();
} }

View File

@ -59,4 +59,5 @@ extern {
pub fn send_multi_vkey(vk: i32, count: i32); pub fn send_multi_vkey(vk: i32, count: i32);
pub fn delete_string(count: i32); pub fn delete_string(count: i32);
pub fn trigger_paste(); pub fn trigger_paste();
pub fn trigger_copy();
} }

View File

@ -21,7 +21,7 @@ extern crate dirs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{fs}; use std::{fs};
use crate::matcher::Match; use crate::matcher::{Match, MatchVariable};
use std::fs::{File, create_dir_all}; use std::fs::{File, create_dir_all};
use std::io::Read; use std::io::Read;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
@ -38,7 +38,7 @@ pub(crate) mod runtime;
const DEFAULT_CONFIG_FILE_CONTENT : &str = include_str!("../res/config.yml"); const DEFAULT_CONFIG_FILE_CONTENT : &str = include_str!("../res/config.yml");
pub const DEFAULT_CONFIG_FILE_NAME : &str = "default.yml"; pub const DEFAULT_CONFIG_FILE_NAME : &str = "default.yml";
const USER_CONFIGS_FOLDER_NAME: &str = "user"; pub const USER_CONFIGS_FOLDER_NAME: &str = "user";
// Default values for primitives // Default values for primitives
fn default_name() -> String{ "default".to_owned() } fn default_name() -> String{ "default".to_owned() }
@ -46,7 +46,6 @@ fn default_parent() -> String{ "self".to_owned() }
fn default_filter_title() -> String{ "".to_owned() } fn default_filter_title() -> String{ "".to_owned() }
fn default_filter_class() -> String{ "".to_owned() } fn default_filter_class() -> String{ "".to_owned() }
fn default_filter_exec() -> String{ "".to_owned() } fn default_filter_exec() -> String{ "".to_owned() }
fn default_disabled() -> bool{ false }
fn default_log_level() -> i32 { 0 } fn default_log_level() -> i32 { 0 }
fn default_conflict_check() -> bool{ true } fn default_conflict_check() -> bool{ true }
fn default_ipc_server_port() -> i32 { 34982 } fn default_ipc_server_port() -> i32 { 34982 }
@ -54,10 +53,27 @@ fn default_use_system_agent() -> bool { true }
fn default_config_caching_interval() -> i32 { 800 } fn default_config_caching_interval() -> i32 { 800 }
fn default_word_separators() -> Vec<char> { vec![' ', ',', '.', '\r', '\n', 22u8 as char] } fn default_word_separators() -> Vec<char> { vec![' ', ',', '.', '\r', '\n', 22u8 as char] }
fn default_toggle_interval() -> u32 { 230 } fn default_toggle_interval() -> u32 { 230 }
fn default_preserve_clipboard() -> bool {false} fn default_toggle_key() -> KeyModifier { KeyModifier::ALT }
fn default_preserve_clipboard() -> bool {true}
fn default_passive_match_regex() -> String{ "(?P<name>:\\p{L}+)(/(?P<args>.*)/)?".to_owned() }
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_backspace_limit() -> i32 { 3 }
fn default_exclude_default_matches() -> bool {false} fn default_restore_clipboard_delay() -> i32 { 300 }
fn default_exclude_default_entries() -> bool {false}
fn default_matches() -> Vec<Match> { Vec::new() } fn default_matches() -> Vec<Match> { Vec::new() }
fn default_global_vars() -> Vec<MatchVariable> { Vec::new() }
#[cfg(target_os = "linux")]
fn default_editor() -> String{ "/bin/nano".to_owned() }
#[cfg(target_os = "macos")]
fn default_editor() -> String{ "/usr/bin/nano".to_owned() } // TODO: change
#[cfg(target_os = "windows")]
fn default_editor() -> String{ "C:\\Windows\\System32\\notepad.exe".to_owned() }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Configs { pub struct Configs {
@ -76,9 +92,6 @@ pub struct Configs {
#[serde(default = "default_filter_exec")] #[serde(default = "default_filter_exec")]
pub filter_exec: String, pub filter_exec: String,
#[serde(default = "default_disabled")]
pub disabled: bool,
#[serde(default = "default_log_level")] #[serde(default = "default_log_level")]
pub log_level: i32, pub log_level: i32,
@ -97,7 +110,7 @@ pub struct Configs {
#[serde(default = "default_word_separators")] #[serde(default = "default_word_separators")]
pub word_separators: Vec<char>, // TODO: add parsing test pub word_separators: Vec<char>, // TODO: add parsing test
#[serde(default)] #[serde(default = "default_toggle_key")]
pub toggle_key: KeyModifier, pub toggle_key: KeyModifier,
#[serde(default = "default_toggle_interval")] #[serde(default = "default_toggle_interval")]
@ -106,20 +119,51 @@ pub struct Configs {
#[serde(default = "default_preserve_clipboard")] #[serde(default = "default_preserve_clipboard")]
pub preserve_clipboard: bool, 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_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,
#[serde(default)] #[serde(default)]
pub paste_shortcut: PasteShortcut, pub paste_shortcut: PasteShortcut,
#[serde(default = "default_backspace_limit")] #[serde(default = "default_backspace_limit")]
pub backspace_limit: i32, pub backspace_limit: i32,
#[serde(default = "default_restore_clipboard_delay")]
pub restore_clipboard_delay: i32,
#[serde(default)] #[serde(default)]
pub backend: BackendType, pub backend: BackendType,
#[serde(default = "default_exclude_default_matches")] #[serde(default = "default_exclude_default_entries")]
pub exclude_default_matches: bool, pub exclude_default_entries: bool,
#[serde(default = "default_editor")]
pub editor: String,
#[serde(default = "default_matches")] #[serde(default = "default_matches")]
pub matches: Vec<Match> pub matches: Vec<Match>,
#[serde(default = "default_global_vars")]
pub global_vars: Vec<MatchVariable>
} }
// Macro used to validate config fields // Macro used to validate config fields
@ -149,12 +193,18 @@ impl Configs {
validate_field!(result, self.config_caching_interval, default_config_caching_interval()); validate_field!(result, self.config_caching_interval, default_config_caching_interval());
validate_field!(result, self.log_level, default_log_level()); validate_field!(result, self.log_level, default_log_level());
validate_field!(result, self.conflict_check, default_conflict_check()); 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.toggle_interval, default_toggle_interval());
validate_field!(result, self.backspace_limit, default_backspace_limit()); validate_field!(result, self.backspace_limit, default_backspace_limit());
validate_field!(result, self.ipc_server_port, default_ipc_server_port()); 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.use_system_agent, default_use_system_agent());
validate_field!(result, self.preserve_clipboard, default_preserve_clipboard()); 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());
validate_field!(result, self.restore_clipboard_delay, default_restore_clipboard_delay());
result result
} }
@ -209,29 +259,56 @@ impl Configs {
} }
fn merge_config(&mut self, new_config: Configs) { fn merge_config(&mut self, new_config: Configs) {
// Merge matches
let mut merged_matches = new_config.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| { merged_matches.iter().for_each(|m| {
trigger_set.insert(m.trigger.clone()); match_trigger_set.insert(m.trigger.clone());
}); });
let parent_matches : Vec<Match> = self.matches.iter().filter(|&m| { let parent_matches : Vec<Match> = self.matches.iter().filter(|&m| {
!trigger_set.contains(&m.trigger) !match_trigger_set.contains(&m.trigger)
}).cloned().collect(); }).cloned().collect();
merged_matches.extend(parent_matches); merged_matches.extend(parent_matches);
self.matches = merged_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<MatchVariable> = 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) { 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| { self.matches.iter().for_each(|m| {
trigger_set.insert(m.trigger.clone()); match_trigger_set.insert(m.trigger.clone());
}); });
let default_matches : Vec<Match> = default.matches.iter().filter(|&m| { let default_matches : Vec<Match> = default.matches.iter().filter(|&m| {
!trigger_set.contains(&m.trigger) !match_trigger_set.contains(&m.trigger)
}).cloned().collect(); }).cloned().collect();
self.matches.extend(default_matches); 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<MatchVariable> = default.global_vars.iter().filter(|&m| {
!vars_name_set.contains(&m.name)
}).cloned().collect();
self.global_vars.extend(default_vars);
} }
} }
@ -322,9 +399,9 @@ impl ConfigSet {
let default= configs.get(0).unwrap().clone(); let default= configs.get(0).unwrap().clone();
let mut specific = (&configs[1..]).to_vec().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() { for config in specific.iter_mut() {
if !config.exclude_default_matches { if !config.exclude_default_entries {
config.merge_default(&default); config.merge_default(&default);
} }
} }
@ -814,7 +891,7 @@ mod tests {
let user_defined_path2 = create_user_config_file(data_dir.path(), "specific2.yml", r###" let user_defined_path2 = create_user_config_file(data_dir.path(), "specific2.yml", r###"
name: specific1 name: specific1
exclude_default_matches: true exclude_default_entries: true
matches: matches:
- trigger: "hello" - trigger: "hello"
@ -849,7 +926,7 @@ mod tests {
let user_defined_path2 = create_user_config_file(data_dir.path(), "specific.zzz", r###" let user_defined_path2 = create_user_config_file(data_dir.path(), "specific.zzz", r###"
name: specific1 name: specific1
exclude_default_matches: true exclude_default_entries: true
matches: matches:
- trigger: "hello" - trigger: "hello"
@ -1166,4 +1243,83 @@ mod tests {
let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
assert_eq!(ConfigSet::has_conflicts(&config_set.default, &config_set.specific), false); 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"));
}
} }

57
src/edit.rs Normal file
View File

@ -0,0 +1,57 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
use crate::config::ConfigSet;
use std::path::Path;
pub fn open_editor(config: &ConfigSet, file_path: &Path) -> bool {
use std::process::Command;
// Check if another editor is defined in the environment variables
let editor_var = std::env::var_os("EDITOR");
let visual_var = std::env::var_os("VISUAL");
// Prioritize the editors specified by the environment variable, otherwise use the config
let editor : String = if let Some(editor_var) = editor_var {
editor_var.to_string_lossy().to_string()
}else if let Some(visual_var) = visual_var {
visual_var.to_string_lossy().to_string()
}else{
config.default.editor.clone()
};
// Start the editor and wait for its termination
let status = Command::new(editor)
.arg(file_path)
.spawn();
if let Ok(mut child) = status {
// Wait for the user to edit the configuration
let result = child.wait();
if let Ok(exit_status) = result {
exit_status.success()
}else{
false
}
}else{
println!("Error: could not start editor at: {}", config.default.editor);
false
}
}

View File

@ -26,43 +26,44 @@ use log::{info, warn, error};
use crate::ui::{UIManager, MenuItem, MenuItemType}; use crate::ui::{UIManager, MenuItem, MenuItemType};
use crate::event::{ActionEventReceiver, ActionType}; use crate::event::{ActionEventReceiver, ActionType};
use crate::extension::Extension; use crate::extension::Extension;
use crate::render::{Renderer, RenderResult};
use std::cell::RefCell; use std::cell::RefCell;
use std::process::exit; use std::process::exit;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use regex::{Regex, Captures}; use regex::{Regex, Captures};
use std::time::SystemTime;
pub struct Engine<'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, pub struct Engine<'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>,
U: UIManager> { U: UIManager, R: Renderer> {
keyboard_manager: &'a S, keyboard_manager: &'a S,
clipboard_manager: &'a C, clipboard_manager: &'a C,
config_manager: &'a M, config_manager: &'a M,
ui_manager: &'a U, ui_manager: &'a U,
renderer: &'a R,
extension_map: HashMap<String, Box<dyn Extension>>,
enabled: RefCell<bool>, enabled: RefCell<bool>,
last_action_time: RefCell<SystemTime>, // 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> impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer>
Engine<'a, S, C, M, U> { Engine<'a, S, C, M, U, R> {
pub fn new(keyboard_manager: &'a S, clipboard_manager: &'a C, pub fn new(keyboard_manager: &'a S, clipboard_manager: &'a C,
config_manager: &'a M, ui_manager: &'a U, config_manager: &'a M, ui_manager: &'a U,
extensions: Vec<Box<dyn Extension>>) -> Engine<'a, S, C, M, U> { renderer: &'a R) -> Engine<'a, S, C, M, U, R> {
// Register all the extensions
let mut extension_map = HashMap::new();
for extension in extensions.into_iter() {
extension_map.insert(extension.name(), extension);
}
let enabled = RefCell::new(true); 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, Engine{keyboard_manager,
clipboard_manager, clipboard_manager,
config_manager, config_manager,
ui_manager, ui_manager,
extension_map, renderer,
enabled enabled,
last_action_time,
action_noop_interval,
} }
} }
@ -108,19 +109,38 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa
None 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! { lazy_static! {
static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(?P<name>\\w+)\\s*\\}\\}").unwrap(); static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(?P<name>\\w+)\\s*\\}\\}").unwrap();
} }
impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager> impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer>
MatchReceiver for Engine<'a, S, C, M, U>{ MatchReceiver for Engine<'a, S, C, M, U, R>{
fn on_match(&self, m: &Match, trailing_separator: Option<char>) { fn on_match(&self, m: &Match, trailing_separator: Option<char>) {
let config = self.config_manager.active_config(); let config = self.config_manager.active_config();
if config.disabled { if !config.enable_active {
return;
}
// avoid espanso reinterpreting its own actions
if self.check_last_action_and_set(self.action_noop_interval) {
return; return;
} }
@ -134,40 +154,10 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa
let mut previous_clipboard_content : Option<String> = None; let mut previous_clipboard_content : Option<String> = None;
// Manage the different types of matches let rendered = self.renderer.render_match(m, config, vec![]);
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()
};
match rendered {
RenderResult::Text(mut target_string) => {
// If a trailing separator was counted in the match, add it back to the 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 let Some(trailing_separator) = trailing_separator {
if trailing_separator == '\r' { // If the trailing separator is a carriage return, if trailing_separator == '\r' { // If the trailing separator is a carriage return,
@ -234,30 +224,35 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa
self.keyboard_manager.move_cursor_left(moves); 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 self.clipboard_manager.set_clipboard_image(&image_path);
MatchContentType::Image(content) => { self.keyboard_manager.trigger_paste(&config.paste_shortcut);
// Make sure the image exist beforehand },
if content.path.exists() { RenderResult::Error => {
// If the preserve_clipboard option is enabled, save the current error!("Could not render match: {}", m.trigger);
// 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);
}
}, },
} }
// Restore previous clipboard content // Restore previous clipboard content
if let Some(previous_clipboard_content) = 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); self.clipboard_manager.set_clipboard(&previous_clipboard_content);
} }
} }
fn on_enable_update(&self, status: bool) { 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 { let message = if status {
"espanso enabled" "espanso enabled"
}else{ }else{
@ -271,10 +266,52 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa
self.ui_manager.notify(message); 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;
}
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
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 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, 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) { fn on_action_event(&self, e: ActionType) {
match e { match e {

View File

@ -66,12 +66,6 @@ pub enum KeyModifier {
OFF, OFF,
} }
impl Default for KeyModifier {
fn default() -> Self {
KeyModifier::ALT
}
}
// Receivers // Receivers
pub trait KeyEventReceiver { pub trait KeyEventReceiver {

View File

@ -33,7 +33,7 @@ impl super::Extension for DateExtension {
String::from("date") String::from("date")
} }
fn calculate(&self, params: &Mapping) -> Option<String> { fn calculate(&self, params: &Mapping, _: &Vec<String>) -> Option<String> {
let now: DateTime<Local> = Local::now(); let now: DateTime<Local> = Local::now();
let format = params.get(&Value::from("format")); let format = params.get(&Value::from("format"));

44
src/extension/dummy.rs Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>) -> Option<String> {
let echo = params.get(&Value::from("echo"));
if let Some(echo) = echo {
Some(echo.as_str().unwrap_or_default().to_owned())
}else{
None
}
}
}

View File

@ -23,10 +23,11 @@ mod date;
mod shell; mod shell;
mod script; mod script;
mod random; mod random;
mod dummy;
pub trait Extension { pub trait Extension {
fn name(&self) -> String; fn name(&self) -> String;
fn calculate(&self, params: &Mapping) -> Option<String>; fn calculate(&self, params: &Mapping, args: &Vec<String>) -> Option<String>;
} }
pub fn get_extensions() -> Vec<Box<dyn Extension>> { pub fn get_extensions() -> Vec<Box<dyn Extension>> {
@ -35,5 +36,6 @@ pub fn get_extensions() -> Vec<Box<dyn Extension>> {
Box::new(shell::ShellExtension::new()), Box::new(shell::ShellExtension::new()),
Box::new(script::ScriptExtension::new()), Box::new(script::ScriptExtension::new()),
Box::new(random::RandomExtension::new()), Box::new(random::RandomExtension::new()),
Box::new(dummy::DummyExtension::new()),
] ]
} }

View File

@ -34,7 +34,7 @@ impl super::Extension for RandomExtension {
String::from("random") String::from("random")
} }
fn calculate(&self, params: &Mapping) -> Option<String> { fn calculate(&self, params: &Mapping, args: &Vec<String>) -> Option<String> {
let choices = params.get(&Value::from("choices")); let choices = params.get(&Value::from("choices"));
if choices.is_none() { if choices.is_none() {
warn!("No 'choices' parameter specified for random variable"); warn!("No 'choices' parameter specified for random variable");
@ -51,7 +51,10 @@ impl super::Extension for RandomExtension {
match choice { match choice {
Some(output) => { Some(output) => {
return Some(output.clone()) // Render arguments
let output = crate::render::utils::render_args(output, args);
return Some(output)
}, },
None => { None => {
error!("Could not select a random choice."); error!("Could not select a random choice.");
@ -82,7 +85,7 @@ mod tests {
params.insert(Value::from("choices"), Value::from(choices.clone())); params.insert(Value::from("choices"), Value::from(choices.clone()));
let extension = RandomExtension::new(); let extension = RandomExtension::new();
let output = extension.calculate(&params); let output = extension.calculate(&params, &vec![]);
assert!(output.is_some()); assert!(output.is_some());
@ -90,4 +93,30 @@ mod tests {
assert!(choices.iter().any(|x| x == &output)); 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(&params, &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));
}
} }

View File

@ -34,7 +34,7 @@ impl super::Extension for ScriptExtension {
String::from("script") String::from("script")
} }
fn calculate(&self, params: &Mapping) -> Option<String> { fn calculate(&self, params: &Mapping, user_args: &Vec<String>) -> Option<String> {
let args = params.get(&Value::from("args")); let args = params.get(&Value::from("args"));
if args.is_none() { if args.is_none() {
warn!("No 'args' parameter specified for script variable"); warn!("No 'args' parameter specified for script variable");
@ -42,10 +42,17 @@ impl super::Extension for ScriptExtension {
} }
let args = args.unwrap().as_sequence(); let args = args.unwrap().as_sequence();
if let Some(args) = args { 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() arg.as_str().unwrap_or_default().to_string()
}).collect::<Vec<String>>(); }).collect::<Vec<String>>();
// 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 { let output = if str_args.len() > 1 {
Command::new(&str_args[0]) Command::new(&str_args[0])
.args(&str_args[1..]) .args(&str_args[1..])
@ -55,6 +62,7 @@ impl super::Extension for ScriptExtension {
.output() .output()
}; };
println!("{:?}", output);
match output { match output {
Ok(output) => { Ok(output) => {
let output_str = String::from_utf8_lossy(output.stdout.as_slice()); 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); error!("Could not execute script with args '{:?}'", args);
None 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(&params, &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(&params, &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(&params, &vec!["jon".to_owned()]);
assert!(output.is_some());
assert_eq!(output.unwrap(), "hello world jon\n");
}
} }

View File

@ -20,6 +20,15 @@
use serde_yaml::{Mapping, Value}; use serde_yaml::{Mapping, Value};
use std::process::Command; use std::process::Command;
use log::{warn, error}; use log::{warn, error};
use regex::{Regex, Captures};
lazy_static! {
static ref POS_ARG_REGEX: Regex = if cfg!(target_os = "windows") {
Regex::new("%(?P<pos>\\d+)").unwrap()
}else{
Regex::new("\\$(?P<pos>\\d+)").unwrap()
};
}
pub struct ShellExtension {} pub struct ShellExtension {}
@ -34,7 +43,7 @@ impl super::Extension for ShellExtension {
String::from("shell") String::from("shell")
} }
fn calculate(&self, params: &Mapping) -> Option<String> { fn calculate(&self, params: &Mapping, args: &Vec<String>) -> Option<String> {
let cmd = params.get(&Value::from("cmd")); let cmd = params.get(&Value::from("cmd"));
if cmd.is_none() { if cmd.is_none() {
warn!("No 'cmd' parameter specified for shell variable"); warn!("No 'cmd' parameter specified for shell variable");
@ -42,14 +51,25 @@ impl super::Extension for ShellExtension {
} }
let cmd = cmd.unwrap().as_str().unwrap(); 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::<i32>().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") { let output = if cfg!(target_os = "windows") {
Command::new("cmd") Command::new("cmd")
.args(&["/C", cmd]) .args(&["/C", &cmd])
.output() .output()
} else { } else {
Command::new("sh") Command::new("sh")
.arg("-c") .arg("-c")
.arg(cmd) .arg(&cmd)
.output() .output()
}; };
@ -90,7 +110,7 @@ mod tests {
params.insert(Value::from("cmd"), Value::from("echo hello world")); params.insert(Value::from("cmd"), Value::from("echo hello world"));
let extension = ShellExtension::new(); let extension = ShellExtension::new();
let output = extension.calculate(&params); let output = extension.calculate(&params, &vec![]);
assert!(output.is_some()); assert!(output.is_some());
@ -108,7 +128,7 @@ mod tests {
params.insert(Value::from("trim"), Value::from(true)); params.insert(Value::from("trim"), Value::from(true));
let extension = ShellExtension::new(); let extension = ShellExtension::new();
let output = extension.calculate(&params); let output = extension.calculate(&params, &vec![]);
assert!(output.is_some()); assert!(output.is_some());
assert_eq!(output.unwrap(), "hello world"); assert_eq!(output.unwrap(), "hello world");
@ -126,7 +146,7 @@ mod tests {
params.insert(Value::from("trim"), Value::from(true)); params.insert(Value::from("trim"), Value::from(true));
let extension = ShellExtension::new(); let extension = ShellExtension::new();
let output = extension.calculate(&params); let output = extension.calculate(&params, &vec![]);
assert!(output.is_some()); assert!(output.is_some());
assert_eq!(output.unwrap(), "hello world"); assert_eq!(output.unwrap(), "hello world");
@ -139,7 +159,7 @@ mod tests {
params.insert(Value::from("trim"), Value::from("error")); params.insert(Value::from("trim"), Value::from("error"));
let extension = ShellExtension::new(); let extension = ShellExtension::new();
let output = extension.calculate(&params); let output = extension.calculate(&params, &vec![]);
assert!(output.is_some()); assert!(output.is_some());
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
@ -157,9 +177,37 @@ mod tests {
params.insert(Value::from("trim"), Value::from(true)); params.insert(Value::from("trim"), Value::from(true));
let extension = ShellExtension::new(); let extension = ShellExtension::new();
let output = extension.calculate(&params); let output = extension.calculate(&params, &vec![]);
assert!(output.is_some()); assert!(output.is_some());
assert_eq!(output.unwrap(), "hello world"); 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(&params, &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(&params, &vec!["hello".to_owned()]);
assert!(output.is_some());
assert_eq!(output.unwrap(), "hello\r\n");
}
} }

View File

@ -52,6 +52,8 @@ impl super::KeyboardManager for LinuxKeyboardManager {
trigger_alt_shift_ins_paste(); trigger_alt_shift_ins_paste();
}else if is_special == 3 { // Special case for Emacs }else if is_special == 3 { // Special case for Emacs
trigger_shift_ins_paste(); trigger_shift_ins_paste();
}else if is_special == 4 { // CTRL+ALT+V used in some terminals (urxvt)
trigger_ctrl_alt_paste();
}else{ }else{
trigger_terminal_paste(); trigger_terminal_paste();
} }
@ -65,6 +67,9 @@ impl super::KeyboardManager for LinuxKeyboardManager {
PasteShortcut::ShiftInsert=> { PasteShortcut::ShiftInsert=> {
trigger_shift_ins_paste(); trigger_shift_ins_paste();
}, },
PasteShortcut::CtrlAltV => {
trigger_ctrl_alt_paste();
},
_ => { _ => {
error!("Linux backend does not support this Paste Shortcut, please open an issue on GitHub if you need it.") error!("Linux backend does not support this Paste Shortcut, please open an issue on GitHub if you need it.")
} }
@ -81,4 +86,10 @@ impl super::KeyboardManager for LinuxKeyboardManager {
left_arrow(count); left_arrow(count);
} }
} }
fn trigger_copy(&self) {
unsafe {
trigger_copy();
}
}
} }

View File

@ -56,6 +56,12 @@ impl super::KeyboardManager for MacKeyboardManager {
} }
} }
fn trigger_copy(&self) {
unsafe {
trigger_copy();
}
}
fn delete_string(&self, count: i32) { fn delete_string(&self, count: i32) {
unsafe {delete_string(count)} unsafe {delete_string(count)}
} }

View File

@ -34,6 +34,7 @@ pub trait KeyboardManager {
fn trigger_paste(&self, shortcut: &PasteShortcut); fn trigger_paste(&self, shortcut: &PasteShortcut);
fn delete_string(&self, count: i32); fn delete_string(&self, count: i32);
fn move_cursor_left(&self, count: i32); fn move_cursor_left(&self, count: i32);
fn trigger_copy(&self);
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@ -42,6 +43,7 @@ pub enum PasteShortcut {
CtrlV, // Classic Ctrl+V shortcut CtrlV, // Classic Ctrl+V shortcut
CtrlShiftV, // Could be used to paste without formatting in many applications CtrlShiftV, // Could be used to paste without formatting in many applications
ShiftInsert, // Often used in Linux systems ShiftInsert, // Often used in Linux systems
CtrlAltV, // Used in some Linux terminals (urxvt)
MetaV, // Corresponding to Win+V on Windows and Linux, CMD+V on macOS MetaV, // Corresponding to Win+V on Windows and Linux, CMD+V on macOS
} }

View File

@ -73,4 +73,10 @@ impl super::KeyboardManager for WindowsKeyboardManager {
send_multi_vkey(0x25, count) send_multi_vkey(0x25, count)
} }
} }
fn trigger_copy(&self) {
unsafe {
trigger_copy();
}
}
} }

View File

@ -32,7 +32,7 @@ use fs2::FileExt;
use log::{info, warn, LevelFilter}; use log::{info, warn, LevelFilter};
use simplelog::{CombinedLogger, SharedLogger, TerminalMode, TermLogger, WriteLogger}; use simplelog::{CombinedLogger, SharedLogger, TerminalMode, TermLogger, WriteLogger};
use crate::config::ConfigSet; use crate::config::{ConfigSet, ConfigManager};
use crate::config::runtime::RuntimeConfigManager; use crate::config::runtime::RuntimeConfigManager;
use crate::engine::Engine; use crate::engine::Engine;
use crate::event::*; use crate::event::*;
@ -46,12 +46,14 @@ use crate::package::default::DefaultPackageManager;
use crate::package::{PackageManager, InstallResult, UpdateResult, RemoveResult}; use crate::package::{PackageManager, InstallResult, UpdateResult, RemoveResult};
mod ui; mod ui;
mod edit;
mod event; mod event;
mod check; mod check;
mod utils; mod utils;
mod bridge; mod bridge;
mod engine; mod engine;
mod config; mod config;
mod render;
mod system; mod system;
mod context; mod context;
mod matcher; mod matcher;
@ -95,6 +97,10 @@ fn main() {
.subcommand(SubCommand::with_name("toggle") .subcommand(SubCommand::with_name("toggle")
.about("Toggle the status of the espanso replacement engine.")) .about("Toggle the status of the espanso replacement engine."))
) )
.subcommand(SubCommand::with_name("edit")
.about("Open the default text editor to edit config files and reload them automatically when exiting")
.arg(Arg::with_name("config")
.help("Defaults to \"default\". The configuration file name to edit (without the .yml extension).")))
.subcommand(SubCommand::with_name("dump") .subcommand(SubCommand::with_name("dump")
.about("Prints all current configuration options.")) .about("Prints all current configuration options."))
.subcommand(SubCommand::with_name("detect") .subcommand(SubCommand::with_name("detect")
@ -162,6 +168,11 @@ fn main() {
return; return;
} }
if let Some(matches) = matches.subcommand_matches("edit") {
edit_main(config_set, matches);
return;
}
if matches.subcommand_matches("dump").is_some() { if matches.subcommand_matches("dump").is_some() {
println!("{:#?}", config_set); println!("{:#?}", config_set);
return; return;
@ -332,11 +343,14 @@ fn daemon_background(receive_channel: Receiver<Event>, config_set: ConfigSet) {
let extensions = extension::get_extensions(); let extensions = extension::get_extensions();
let renderer = render::default::DefaultRenderer::new(extensions,
config_manager.default_config().clone());
let engine = Engine::new(&keyboard_manager, let engine = Engine::new(&keyboard_manager,
&clipboard_manager, &clipboard_manager,
&config_manager, &config_manager,
&ui_manager, &ui_manager,
extensions, &renderer,
); );
let matcher = ScrollingMatcher::new(&config_manager, &engine); let matcher = ScrollingMatcher::new(&config_manager, &engine);
@ -869,6 +883,67 @@ fn path_main(_config_set: ConfigSet, matches: &ArgMatches) {
} }
} }
fn edit_main(config_set: ConfigSet, matches: &ArgMatches) {
// Determine which is the file to edit
let config = matches.value_of("config").unwrap_or("default");
let config_dir = crate::context::get_config_dir();
let config_path = match config {
"default" => {
config_dir.join(crate::config::DEFAULT_CONFIG_FILE_NAME)
},
name => { // Otherwise, search in the user/ config folder
config_dir.join(crate::config::USER_CONFIGS_FOLDER_NAME)
.join(name.to_owned() + ".yml")
}
};
println!("Editing file: {:?}", &config_path);
// Based on the fact that the file already exists or not, we should detect in different
// ways if a reload is needed
let should_reload =if config_path.exists() {
// Get the last modified date, so that we can detect if the user actually edits the file
// before reloading
let metadata = std::fs::metadata(&config_path).expect("cannot gather file metadata");
let last_modified = metadata.modified().expect("cannot read file last modified date");
let result = crate::edit::open_editor(&config_set, &config_path);
if result {
let new_metadata = std::fs::metadata(&config_path).expect("cannot gather file metadata");
let new_last_modified = new_metadata.modified().expect("cannot read file last modified date");
if last_modified != new_last_modified {
println!("File has been modified, reloading configuration");
true
}else{
println!("File has not been modified, avoiding reload");
false
}
}else{
false
}
}else{
let result = crate::edit::open_editor(&config_set, &config_path);
if result {
// If the file has been created, we should reload the espanso config
if config_path.exists() {
println!("A new file has been created, reloading configuration");
true
}else{
println!("No file has been created, avoiding reload");
false
}
}else{
false
}
};
if should_reload {
restart_main(config_set)
}
}
fn acquire_lock() -> Option<File> { fn acquire_lock() -> Option<File> {
let espanso_dir = context::get_data_dir(); let espanso_dir = context::get_data_dir();

View File

@ -32,6 +32,7 @@ pub struct Match {
pub trigger: String, pub trigger: String,
pub content: MatchContentType, pub content: MatchContentType,
pub word: bool, pub word: bool,
pub passive_only: bool,
// Automatically calculated from the trigger, used by the matcher to check for correspondences. // Automatically calculated from the trigger, used by the matcher to check for correspondences.
#[serde(skip_serializing)] #[serde(skip_serializing)]
@ -133,7 +134,8 @@ impl<'a> From<&'a AutoMatch> for Match{
Self { Self {
trigger: other.trigger.clone(), trigger: other.trigger.clone(),
content, content,
word: other.word.clone(), word: other.word,
passive_only: other.passive_only,
_trigger_sequence: trigger_sequence, _trigger_sequence: trigger_sequence,
} }
} }
@ -155,10 +157,14 @@ struct AutoMatch {
#[serde(default = "default_word")] #[serde(default = "default_word")]
pub word: bool, pub word: bool,
#[serde(default = "default_passive_only")]
pub passive_only: bool,
} }
fn default_vars() -> Vec<MatchVariable> {Vec::new()} fn default_vars() -> Vec<MatchVariable> {Vec::new()}
fn default_word() -> bool {false} fn default_word() -> bool {false}
fn default_passive_only() -> bool {false}
fn default_replace() -> Option<String> {None} fn default_replace() -> Option<String> {None}
fn default_image_path() -> Option<String> {None} fn default_image_path() -> Option<String> {None}
@ -181,6 +187,7 @@ pub enum TriggerEntry {
pub trait MatchReceiver { pub trait MatchReceiver {
fn on_match(&self, m: &Match, trailing_separator: Option<char>); fn on_match(&self, m: &Match, trailing_separator: Option<char>);
fn on_enable_update(&self, status: bool); fn on_enable_update(&self, status: bool);
fn on_passive(&self);
} }
pub trait Matcher : KeyEventReceiver { pub trait Matcher : KeyEventReceiver {

View File

@ -18,7 +18,7 @@
*/ */
use crate::matcher::{Match, MatchReceiver, TriggerEntry}; use crate::matcher::{Match, MatchReceiver, TriggerEntry};
use std::cell::RefCell; use std::cell::{RefCell, Ref};
use crate::event::{KeyModifier, ActionEventReceiver, ActionType}; use crate::event::{KeyModifier, ActionEventReceiver, ActionType};
use crate::config::ConfigManager; use crate::config::ConfigManager;
use crate::event::KeyModifier::BACKSPACE; use crate::event::KeyModifier::BACKSPACE;
@ -30,6 +30,7 @@ pub struct ScrollingMatcher<'a, R: MatchReceiver, M: ConfigManager<'a>> {
receiver: &'a R, receiver: &'a R,
current_set_queue: RefCell<VecDeque<Vec<MatchEntry<'a>>>>, current_set_queue: RefCell<VecDeque<Vec<MatchEntry<'a>>>>,
toggle_press_time: RefCell<SystemTime>, toggle_press_time: RefCell<SystemTime>,
passive_press_time: RefCell<SystemTime>,
is_enabled: RefCell<bool>, is_enabled: RefCell<bool>,
was_previous_char_word_separator: RefCell<bool>, was_previous_char_word_separator: RefCell<bool>,
} }
@ -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> { pub fn new(config_manager: &'a M, receiver: &'a R) -> ScrollingMatcher<'a, R, M> {
let current_set_queue = RefCell::new(VecDeque::new()); let current_set_queue = RefCell::new(VecDeque::new());
let toggle_press_time = RefCell::new(SystemTime::now()); let toggle_press_time = RefCell::new(SystemTime::now());
let passive_press_time = RefCell::new(SystemTime::now());
ScrollingMatcher{ ScrollingMatcher{
config_manager, config_manager,
receiver, receiver,
current_set_queue, current_set_queue,
toggle_press_time, toggle_press_time,
passive_press_time,
is_enabled: RefCell::new(true), is_enabled: RefCell::new(true),
was_previous_char_word_separator: 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<MatchEntry> = active_config.matches.iter() let new_matches: Vec<MatchEntry> = active_config.matches.iter()
.filter(|&x| { .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); let mut result = Self::is_matching(x, c, 0, is_current_word_separator);
if x.word { if x.word {
@ -193,22 +201,25 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa
fn handle_modifier(&self, m: KeyModifier) { fn handle_modifier(&self, m: KeyModifier) {
let config = self.config_manager.default_config(); 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 == config.toggle_key {
if m == KeyModifier::OFF { return } check_interval(&self.toggle_press_time,
let mut toggle_press_time = self.toggle_press_time.borrow_mut(); u128::from(config.toggle_interval), || {
if let Ok(elapsed) = toggle_press_time.elapsed() { self.toggle();
if elapsed.as_millis() < u128::from(config.toggle_interval) {
self.toggle();
let is_enabled = self.is_enabled.borrow(); let is_enabled = self.is_enabled.borrow();
if !*is_enabled { if !*is_enabled {
self.current_set_queue.borrow_mut().clear(); self.current_set_queue.borrow_mut().clear();
}
} }
} });
}else if m == config.passive_key {
(*toggle_press_time) = SystemTime::now(); check_interval(&self.passive_press_time,
u128::from(config.toggle_interval), || {
self.receiver.on_passive();
});
} }
// Backspace handling, basically "rewinding history" // Backspace handling, basically "rewinding history"
@ -234,4 +245,15 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> ActionEventReceiver for Scroll
_ => {} _ => {}
} }
} }
}
fn check_interval<F>(state_var: &RefCell<SystemTime>, 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();
} }

524
src/render/default.rs Normal file
View File

@ -0,0 +1,524 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<name>\\w+)\\s*\\}\\}").unwrap();
static ref UNKNOWN_VARIABLE : String = "".to_string();
}
pub struct DefaultRenderer {
extension_map: HashMap<String, Box<dyn Extension>>,
// Regex used to identify matches (and arguments) in passive expansions
passive_match_regex: Regex,
}
impl DefaultRenderer {
pub fn new(extensions: Vec<Box<dyn Extension>>, 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<Match> {
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<String>) -> RenderResult {
// Manage the different types of matches
match &m.content {
// Text Match
MatchContentType::Text(content) => {
let target_string = if content._has_vars || !config.global_vars.is_empty(){
let mut output_map = HashMap::new();
// 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" {
// 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, &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_or(&UNKNOWN_VARIABLE)
});
result.to_string()
}else{ // No variables, simple text substitution
content.replace.clone()
};
// Unescape any brackets (needed to be able to insert double brackets in replacement
// text, without triggering the variable system). See issue #187
let target_string = target_string.replace("\\{", "{")
.replace("\\}", "}");
// 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<String> = 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");
}
#[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");
}
#[test]
fn test_render_match_with_unknown_variable_does_not_crash() {
let text = "this is :test";
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: "my {{unknown}}"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is my ");
}
#[test]
fn test_render_escaped_double_brackets_should_not_consider_them_variable() {
let text = "this is :test";
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: "my \\{\\{unknown\\}\\}"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is my {{unknown}}");
}
}

39
src/render/mod.rs Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>) -> RenderResult;
// Render a passive expansion text
fn render_passive(&self, text: &str, config: &Configs) -> RenderResult;
}
pub enum RenderResult {
Text(String),
Image(PathBuf),
Error
}

130
src/render/utils.rs Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
use regex::{Regex, Captures};
lazy_static! {
static ref ARG_REGEX: Regex = Regex::new("\\$(?P<pos>\\d+)\\$").unwrap();
}
pub fn render_args(text: &str, args: &Vec<String>) -> String {
let result = ARG_REGEX.replace_all(text, |caps: &Captures| {
let position_str = caps.name("pos").unwrap().as_str();
let position = position_str.parse::<i32>().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<String> {
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<String> = vec![];
assert_eq!(split_args("", '/', '\\'), empty_vec)
}
}