Merge pull request #193 from federico-terzi/dev

Version 0.5.1
This commit is contained in:
Federico Terzi 2020-03-04 20:25:02 +01:00 committed by GitHub
commit c454227973
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 746 additions and 118 deletions

4
.gitignore vendored
View File

@ -32,3 +32,7 @@ profile
*.moved-aside *.moved-aside
DerivedData DerivedData
.idea/ .idea/
*.snap
venv/

2
Cargo.lock generated
View File

@ -370,7 +370,7 @@ dependencies = [
[[package]] [[package]]
name = "espanso" name = "espanso"
version = "0.5.0" 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.5.0" 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"

View File

@ -307,6 +307,10 @@ 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() { void trigger_copy() {
// Release the other keys, for an explanation, read the 'trigger_paste' method // Release the other keys, for an explanation, read the 'trigger_paste' method
@ -467,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,11 @@ 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 ) * Trigger copy shortcut ( Pressing CTRL+C )
*/ */

View File

@ -7,6 +7,7 @@ import click
import shutil import shutil
import toml import toml
import hashlib import hashlib
import glob
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
@ -77,9 +78,23 @@ def build_windows(package_info):
TARGET_DIR = os.path.join(PACKAGER_TARGET_DIR, "win") TARGET_DIR = os.path.join(PACKAGER_TARGET_DIR, "win")
os.makedirs(TARGET_DIR, exist_ok=True) os.makedirs(TARGET_DIR, exist_ok=True)
print("Downloading Visual C++ redistributable") print("Gathering CRT DLLs...")
vc_redist_file = os.path.join(TARGET_DIR, "vc_redist.x64.exe") msvc_dirs = glob.glob("C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\*\\VC\\Redist\\MSVC\\*")
urllib.request.urlretrieve("https://aka.ms/vs/16/release/vc_redist.x64.exe", vc_redist_file) print("Found Redists: ", msvc_dirs)
msvc_dir = msvc_dirs[0]
print("Using: ",msvc_dir)
if len(msvc_dir) == 0:
raise Exception("Cannot find redistributable dlls")
dll_files = glob.glob(msvc_dir + "\\x64\\*CRT\\*.dll")
print("Found DLLs:")
dll_include_list = []
for dll in dll_files:
print("Including: "+dll)
dll_include_list.append("Source: \""+dll+"\"; DestDir: \"{app}\"; Flags: ignoreversion")
dll_include = "\r\n".join(dll_include_list)
INSTALLER_NAME = f"espanso-win-installer" INSTALLER_NAME = f"espanso-win-installer"
@ -100,6 +115,7 @@ def build_windows(package_info):
content = content.replace("{{{executable_path}}}", os.path.abspath("target/release/espanso.exe")) content = content.replace("{{{executable_path}}}", os.path.abspath("target/release/espanso.exe"))
content = content.replace("{{{output_dir}}}", os.path.abspath(TARGET_DIR)) content = content.replace("{{{output_dir}}}", os.path.abspath(TARGET_DIR))
content = content.replace("{{{output_name}}}", INSTALLER_NAME) content = content.replace("{{{output_name}}}", INSTALLER_NAME)
content = content.replace("{{{dll_include}}}", dll_include)
with open(os.path.join(TARGET_DIR, "setupscript.iss"), "w") as output_script: with open(os.path.join(TARGET_DIR, "setupscript.iss"), "w") as output_script:
output_script.write(content) output_script.write(content)
@ -174,6 +190,7 @@ def build_mac(package_info):
print("Done!") print("Done!")
if __name__ == '__main__': if __name__ == '__main__':
print("[[ espanso packager ]]") print("[[ espanso packager ]]")

View File

@ -30,7 +30,6 @@ Compression=lzma
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
ChangesEnvironment=yes ChangesEnvironment=yes
AlwaysRestart = yes
[Languages] [Languages]
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"
@ -38,7 +37,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
[Files] [Files]
Source: "{{{executable_path}}}"; DestDir: "{app}"; Flags: ignoreversion Source: "{{{executable_path}}}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{{{app_icon}}}"; DestDir: "{app}"; Flags: ignoreversion Source: "{{{app_icon}}}"; DestDir: "{app}"; Flags: ignoreversion
Source: "vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall {{{dll_include}}}
; NOTE: Don't use "Flags: ignoreversion" on any shared system files ; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons] [Icons]
@ -53,18 +52,13 @@ Name: "StartMenuEntry" ; Description: "Start espanso at Windows startup" ;
const const
ModPathName = 'modifypath'; ModPathName = 'modifypath';
ModPathType = 'user'; ModPathType = 'user';
function ModPathDir(): TArrayOfString; function ModPathDir(): TArrayOfString;
begin begin
setArrayLength(Result, 1) setArrayLength(Result, 1)
Result[0] := ExpandConstant('{app}'); Result[0] := ExpandConstant('{app}');
end; end;
#include "modpath.iss" #include "modpath.iss"
[Run] [Run]
Filename: {tmp}\vc_redist.x64.exe; \
Parameters: "/install /quiet /norestart"; \
StatusMsg: "Installing Visual C++ 2019 Redistributable";
Filename: "{app}\{#MyAppExeName}"; Parameters: "start"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#MyAppExeName}"; Parameters: "start"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[UninstallRun] [UninstallRun]

70
snapcraft.yaml Normal file
View File

@ -0,0 +1,70 @@
name: espanso
version: 0.5.1
summary: A Cross-platform Text Expander written in Rust
description: |
espanso is a Cross-platform, Text Expander written in Rust.
## What is a Text Expander?
A text expander is a program that detects when you type
a specific keyword and replaces it with something else.
This is useful in many ways:
* Save a lot of typing, expanding common sentences.
* Create system-wide code snippets.
* Execute custom scripts
* Use emojis like a pro.
___
## Key Features
* Works on Windows, macOS and Linux
* Works with almost any program
* Works with Emojis 😄
* Works with Images
* Date expansion support
* Custom scripts support
* Shell commands support
* App-specific configurations
* Expandable with packages
* Built-in package manager for espanso hub: https://hub.espanso.org/
* File based configuration
## Get Started
Visit the official documentation: https://espanso.org/docs/
## Support
If you need some help to setup espanso, want to ask a question or simply get involved
in the community, Join the official Subreddit: https://www.reddit.com/r/espanso/
confinement: classic
base: core18
parts:
espanso:
plugin: rust
source: .
build-packages:
- libssl-dev
- pkg-config
- cmake
- libxtst-dev
- libx11-dev
- libxdo-dev
stage-packages:
- libx11-6
- libxau6
- libxcb1
- libxdmcp6
- libxdo3
- libxext6
- libxinerama1
- libxkbcommon0
- libxtst6
- libnotify-bin
- xclip
apps:
espanso:
command: bin/espanso

View File

@ -44,5 +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(); pub fn trigger_copy();
} }

View File

@ -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() }
@ -54,7 +54,7 @@ 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_toggle_key() -> KeyModifier { KeyModifier::ALT } fn default_toggle_key() -> KeyModifier { KeyModifier::ALT }
fn default_preserve_clipboard() -> bool {false} fn default_preserve_clipboard() -> bool {true}
fn default_passive_match_regex() -> String{ "(?P<name>:\\p{L}+)(/(?P<args>.*)/)?".to_owned() } fn default_passive_match_regex() -> String{ "(?P<name>:\\p{L}+)(/(?P<args>.*)/)?".to_owned() }
fn default_passive_arg_delimiter() -> char { '/' } fn default_passive_arg_delimiter() -> char { '/' }
fn default_passive_arg_escape() -> char { '\\' } fn default_passive_arg_escape() -> char { '\\' }
@ -70,7 +70,7 @@ fn default_global_vars() -> Vec<MatchVariable> { Vec::new() }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Configs { pub struct Configs {
#[serde(default = "default_name")] #[serde(default = "default_name")]
pub name: String, pub name: String,
#[serde(default = "default_parent")] #[serde(default = "default_parent")]
@ -253,10 +253,10 @@ impl Configs {
let mut merged_matches = new_config.matches; let mut merged_matches = new_config.matches;
let mut match_trigger_set = HashSet::new(); let mut match_trigger_set = HashSet::new();
merged_matches.iter().for_each(|m| { merged_matches.iter().for_each(|m| {
match_trigger_set.insert(m.trigger.clone()); match_trigger_set.extend(m.triggers.clone());
}); });
let parent_matches : Vec<Match> = self.matches.iter().filter(|&m| { let parent_matches : Vec<Match> = self.matches.iter().filter(|&m| {
!match_trigger_set.contains(&m.trigger) !m.triggers.iter().any(|trigger| match_trigger_set.contains(trigger))
}).cloned().collect(); }).cloned().collect();
merged_matches.extend(parent_matches); merged_matches.extend(parent_matches);
@ -280,10 +280,10 @@ impl Configs {
// Merge matches // Merge matches
let mut match_trigger_set = HashSet::new(); let mut match_trigger_set = HashSet::new();
self.matches.iter().for_each(|m| { self.matches.iter().for_each(|m| {
match_trigger_set.insert(m.trigger.clone()); match_trigger_set.extend(m.triggers.clone());
}); });
let default_matches : Vec<Match> = default.matches.iter().filter(|&m| { let default_matches : Vec<Match> = default.matches.iter().filter(|&m| {
!match_trigger_set.contains(&m.trigger) !m.triggers.iter().any(|trigger| match_trigger_set.contains(trigger))
}).cloned().collect(); }).cloned().collect();
self.matches.extend(default_matches); self.matches.extend(default_matches);
@ -465,16 +465,16 @@ impl ConfigSet {
} }
fn has_conflicts(default: &Configs, specific: &Vec<Configs>) -> bool { fn has_conflicts(default: &Configs, specific: &Vec<Configs>) -> bool {
let mut sorted_triggers : Vec<String> = default.matches.iter().map(|t| { let mut sorted_triggers : Vec<String> = default.matches.iter().flat_map(|t| {
t.trigger.clone() t.triggers.clone()
}).collect(); }).collect();
sorted_triggers.sort(); sorted_triggers.sort();
let mut has_conflicts = Self::list_has_conflicts(&sorted_triggers); let mut has_conflicts = Self::list_has_conflicts(&sorted_triggers);
for s in specific.iter() { for s in specific.iter() {
let mut specific_triggers : Vec<String> = s.matches.iter().map(|t| { let mut specific_triggers : Vec<String> = s.matches.iter().flat_map(|t| {
t.trigger.clone() t.triggers.clone()
}).collect(); }).collect();
specific_triggers.sort(); specific_triggers.sort();
has_conflicts |= Self::list_has_conflicts(&specific_triggers); has_conflicts |= Self::list_has_conflicts(&specific_triggers);
@ -831,9 +831,9 @@ mod tests {
assert_eq!(config_set.default.matches.len(), 2); assert_eq!(config_set.default.matches.len(), 2);
assert_eq!(config_set.specific[0].matches.len(), 3); assert_eq!(config_set.specific[0].matches.len(), 3);
assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == "hello").is_some()); assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == "hello").is_some());
assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":lol").is_some()); assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == ":lol").is_some());
assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":yess").is_some()); assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == ":yess").is_some());
} }
#[test] #[test]
@ -860,12 +860,12 @@ mod tests {
assert!(config_set.specific[0].matches.iter().find(|x| { assert!(config_set.specific[0].matches.iter().find(|x| {
if let MatchContentType::Text(content) = &x.content { if let MatchContentType::Text(content) = &x.content {
x.trigger == ":lol" && content.replace == "newstring" x.triggers[0] == ":lol" && content.replace == "newstring"
}else{ }else{
false false
} }
}).is_some()); }).is_some());
assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":yess").is_some()); assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == ":yess").is_some());
} }
#[test] #[test]
@ -894,7 +894,7 @@ mod tests {
assert!(config_set.specific[0].matches.iter().find(|x| { assert!(config_set.specific[0].matches.iter().find(|x| {
if let MatchContentType::Text(content) = &x.content { if let MatchContentType::Text(content) = &x.content {
x.trigger == "hello" && content.replace == "newstring" x.triggers[0] == "hello" && content.replace == "newstring"
}else{ }else{
false false
} }
@ -962,8 +962,8 @@ 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!(config_set.specific.len(), 0); assert_eq!(config_set.specific.len(), 0);
assert_eq!(config_set.default.matches.len(), 2); assert_eq!(config_set.default.matches.len(), 2);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hello"));
} }
#[test] #[test]
@ -983,9 +983,9 @@ 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!(config_set.specific.len(), 1); assert_eq!(config_set.specific.len(), 1);
assert_eq!(config_set.default.matches.len(), 1); assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
assert!(!config_set.default.matches.iter().any(|m| m.trigger == "hello")); assert!(!config_set.default.matches.iter().any(|m| m.triggers[0] == "hello"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "hello")); assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "hello"));
} }
#[test] #[test]
@ -1016,9 +1016,9 @@ 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!(config_set.specific.len(), 0); assert_eq!(config_set.specific.len(), 0);
assert_eq!(config_set.default.matches.len(), 3); assert_eq!(config_set.default.matches.len(), 3);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hello"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "super")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "super"));
} }
#[test] #[test]
@ -1042,7 +1042,7 @@ mod tests {
assert_eq!(config_set.default.matches.len(), 1); assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| { assert!(config_set.default.matches.iter().any(|m| {
if let MatchContentType::Text(content) = &m.content { if let MatchContentType::Text(content) = &m.content {
m.trigger == "hasta" && content.replace == "world" m.triggers[0] == "hasta" && content.replace == "world"
}else{ }else{
false false
} }
@ -1068,8 +1068,8 @@ 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!(config_set.specific.len(), 0); assert_eq!(config_set.specific.len(), 0);
assert_eq!(config_set.default.matches.len(), 2); assert_eq!(config_set.default.matches.len(), 2);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "harry")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "harry"));
} }
#[test] #[test]
@ -1089,8 +1089,8 @@ 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!(config_set.specific.len(), 1); assert_eq!(config_set.specific.len(), 1);
assert_eq!(config_set.default.matches.len(), 1); assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry")); assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "harry"));
} }
#[test] #[test]
@ -1120,9 +1120,9 @@ 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!(config_set.specific.len(), 1); assert_eq!(config_set.specific.len(), 1);
assert_eq!(config_set.default.matches.len(), 1); assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry")); assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "harry"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "ron")); assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "ron"));
} }
#[test] #[test]

View File

@ -23,7 +23,7 @@ use crate::event::*;
use crate::event::KeyModifier::*; use crate::event::KeyModifier::*;
use crate::bridge::linux::*; use crate::bridge::linux::*;
use std::process::exit; use std::process::exit;
use log::{error, info}; use log::{debug, error, info};
use std::ffi::CStr; use std::ffi::CStr;
use std::{thread, time}; use std::{thread, time};
@ -97,7 +97,7 @@ extern fn keypress_callback(_self: *mut c_void, raw_buffer: *const u8, len: i32,
(*_self).send_channel.send(event).unwrap(); (*_self).send_channel.send(event).unwrap();
}, },
Err(e) => { Err(e) => {
error!("Unable to receive char: {}",e); debug!("Unable to receive char: {}",e);
}, },
} }
}else{ // Modifier event }else{ // Modifier event

63
src/edit.rs Normal file
View File

@ -0,0 +1,63 @@
/*
* 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 std::path::Path;
#[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() }
#[cfg(target_os = "windows")]
fn default_editor() -> String{ "C:\\Windows\\System32\\notepad.exe".to_owned() }
pub fn open_editor(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, use the default one
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{
default_editor()
};
// 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: {}", &editor);
false
}
}

View File

@ -22,7 +22,7 @@ use crate::keyboard::KeyboardManager;
use crate::config::ConfigManager; use crate::config::ConfigManager;
use crate::config::BackendType; use crate::config::BackendType;
use crate::clipboard::ClipboardManager; use crate::clipboard::ClipboardManager;
use log::{info, warn, error}; use log::{info, warn, debug, 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;
@ -132,7 +132,7 @@ lazy_static! {
impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer> impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer>
MatchReceiver for Engine<'a, S, C, M, U, R>{ 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>, trigger_offset: usize) {
let config = self.config_manager.active_config(); let config = self.config_manager.active_config();
if !config.enable_active { if !config.enable_active {
@ -141,20 +141,21 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa
// avoid espanso reinterpreting its own actions // avoid espanso reinterpreting its own actions
if self.check_last_action_and_set(self.action_noop_interval) { if self.check_last_action_and_set(self.action_noop_interval) {
debug!("Last action was too near, nooping the action.");
return; return;
} }
let char_count = if trailing_separator.is_none() { let char_count = if trailing_separator.is_none() {
m.trigger.chars().count() as i32 m.triggers[trigger_offset].chars().count() as i32
}else{ }else{
m.trigger.chars().count() as i32 + 1 // Count also the separator m.triggers[trigger_offset].chars().count() as i32 + 1 // Count also the separator
}; };
self.keyboard_manager.delete_string(char_count); self.keyboard_manager.delete_string(char_count);
let mut previous_clipboard_content : Option<String> = None; let mut previous_clipboard_content : Option<String> = None;
let rendered = self.renderer.render_match(m, config, vec![]); let rendered = self.renderer.render_match(m, trigger_offset, config, vec![]);
match rendered { match rendered {
RenderResult::Text(mut target_string) => { RenderResult::Text(mut target_string) => {
@ -233,7 +234,7 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa
self.keyboard_manager.trigger_paste(&config.paste_shortcut); self.keyboard_manager.trigger_paste(&config.paste_shortcut);
}, },
RenderResult::Error => { RenderResult::Error => {
error!("Could not render match: {}", m.trigger); error!("Could not render match: {}", m.triggers[trigger_offset]);
}, },
} }

View File

@ -0,0 +1,43 @@
/*
* 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};
use crate::clipboard::ClipboardManager;
pub struct ClipboardExtension {
clipboard_manager: Box<dyn ClipboardManager>,
}
impl ClipboardExtension {
pub fn new(clipboard_manager: Box<dyn ClipboardManager>) -> ClipboardExtension {
ClipboardExtension{
clipboard_manager
}
}
}
impl super::Extension for ClipboardExtension {
fn name(&self) -> String {
String::from("clipboard")
}
fn calculate(&self, params: &Mapping, _: &Vec<String>) -> Option<String> {
self.clipboard_manager.get_clipboard()
}
}

View File

@ -18,24 +18,27 @@
*/ */
use serde_yaml::Mapping; use serde_yaml::Mapping;
use crate::clipboard::ClipboardManager;
mod date; mod date;
mod shell; mod shell;
mod script; mod script;
mod random; mod random;
mod dummy; mod clipboard;
pub mod dummy;
pub trait Extension { pub trait Extension {
fn name(&self) -> String; fn name(&self) -> String;
fn calculate(&self, params: &Mapping, args: &Vec<String>) -> Option<String>; fn calculate(&self, params: &Mapping, args: &Vec<String>) -> Option<String>;
} }
pub fn get_extensions() -> Vec<Box<dyn Extension>> { pub fn get_extensions(clipboard_manager: Box<dyn ClipboardManager>) -> Vec<Box<dyn Extension>>{
vec![ vec![
Box::new(date::DateExtension::new()), Box::new(date::DateExtension::new()),
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()), Box::new(dummy::DummyExtension::new()),
Box::new(clipboard::ClipboardExtension::new(clipboard_manager)),
] ]
} }

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.")
} }

View File

@ -43,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

@ -46,6 +46,7 @@ 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;
@ -96,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")
@ -146,6 +151,14 @@ fn main() {
let matches = clap_instance.clone().get_matches(); let matches = clap_instance.clone().get_matches();
// The edit subcommand must be run before the configuration parsing. Otherwise, if the
// configuration is corrupted, the edit command won't work, which makes it pretty useless.
if let Some(matches) = matches.subcommand_matches("edit") {
edit_main(matches);
return;
}
let log_level = matches.occurrences_of("v") as i32; let log_level = matches.occurrences_of("v") as i32;
// Load the configuration // Load the configuration
@ -156,7 +169,7 @@ fn main() {
config_set.default.log_level = log_level; config_set.default.log_level = log_level;
// Match the correct subcommand // Commands that require the configuration
if let Some(matches) = matches.subcommand_matches("cmd") { if let Some(matches) = matches.subcommand_matches("cmd") {
cmd_main(config_set, matches); cmd_main(config_set, matches);
@ -331,7 +344,7 @@ fn daemon_background(receive_channel: Receiver<Event>, config_set: ConfigSet) {
let keyboard_manager = keyboard::get_manager(); let keyboard_manager = keyboard::get_manager();
let extensions = extension::get_extensions(); let extensions = extension::get_extensions(Box::new(clipboard::get_manager()));
let renderer = render::default::DefaultRenderer::new(extensions, let renderer = render::default::DefaultRenderer::new(extensions,
config_manager.default_config().clone()); config_manager.default_config().clone());
@ -873,6 +886,74 @@ fn path_main(_config_set: ConfigSet, matches: &ArgMatches) {
} }
} }
fn edit_main(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_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_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 {
// Load the configuration
let mut config_set = ConfigSet::load_default().unwrap_or_else(|e| {
eprintln!("{}", e);
eprintln!("Unable to reload espanso due to previous configuration error.");
exit(1);
});
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

@ -29,14 +29,15 @@ pub(crate) mod scrolling;
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct Match { pub struct Match {
pub trigger: String, pub triggers: Vec<String>,
pub content: MatchContentType, pub content: MatchContentType,
pub word: bool, pub word: bool,
pub passive_only: bool, pub passive_only: bool,
pub propagate_case: bool,
// Automatically calculated from the trigger, used by the matcher to check for correspondences. // Automatically calculated from the triggers, used by the matcher to check for correspondences.
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub _trigger_sequence: Vec<TriggerEntry>, pub _trigger_sequences: Vec<Vec<TriggerEntry>>,
} }
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
@ -74,18 +75,49 @@ impl<'a> From<&'a AutoMatch> for Match{
static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)\\s*\\}\\}").unwrap(); static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)\\s*\\}\\}").unwrap();
}; };
// TODO: may need to replace windows newline (\r\n) with newline only (\n) let mut triggers = if !other.triggers.is_empty() {
other.triggers.clone()
}else if !other.trigger.is_empty() {
vec!(other.trigger.clone())
}else{
panic!("Match does not have any trigger defined: {:?}", other)
};
// Calculate the trigger sequence // If propagate_case is true, we need to generate all the possible triggers
let mut trigger_sequence = Vec::new(); // For example, specifying "hello" as a trigger, we need to have:
let trigger_chars : Vec<char> = other.trigger.chars().collect(); // "hello", "Hello", "HELLO"
trigger_sequence.extend(trigger_chars.into_iter().map(|c| { if other.propagate_case {
TriggerEntry::Char(c) // List with first letter capitalized
})); let first_capitalized : Vec<String> = triggers.iter().map(|trigger| {
if other.word { // If it's a word match, end with a word separator let mut capitalized = trigger.clone();
trigger_sequence.push(TriggerEntry::WordSeparator); let mut v: Vec<char> = capitalized.chars().collect();
v[0] = v[0].to_uppercase().nth(0).unwrap();
v.into_iter().collect()
}).collect();
let all_capitalized : Vec<String> = triggers.iter().map(|trigger| {
trigger.to_uppercase()
}).collect();
triggers.extend(first_capitalized);
triggers.extend(all_capitalized);
} }
let trigger_sequences = triggers.iter().map(|trigger| {
// Calculate the trigger sequence
let mut trigger_sequence = Vec::new();
let trigger_chars : Vec<char> = trigger.chars().collect();
trigger_sequence.extend(trigger_chars.into_iter().map(|c| {
TriggerEntry::Char(c)
}));
if other.word { // If it's a word match, end with a word separator
trigger_sequence.push(TriggerEntry::WordSeparator);
}
trigger_sequence
}).collect();
let content = if let Some(replace) = &other.replace { // Text match let content = if let Some(replace) = &other.replace { // Text match
let new_replace = replace.clone(); let new_replace = replace.clone();
@ -132,11 +164,12 @@ impl<'a> From<&'a AutoMatch> for Match{
}; };
Self { Self {
trigger: other.trigger.clone(), triggers,
content, content,
word: other.word, word: other.word,
passive_only: other.passive_only, passive_only: other.passive_only,
_trigger_sequence: trigger_sequence, _trigger_sequences: trigger_sequences,
propagate_case: other.propagate_case,
} }
} }
} }
@ -144,8 +177,12 @@ impl<'a> From<&'a AutoMatch> for Match{
/// Used to deserialize the Match struct before applying some custom elaboration. /// Used to deserialize the Match struct before applying some custom elaboration.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
struct AutoMatch { struct AutoMatch {
#[serde(default = "default_trigger")]
pub trigger: String, pub trigger: String,
#[serde(default = "default_triggers")]
pub triggers: Vec<String>,
#[serde(default = "default_replace")] #[serde(default = "default_replace")]
pub replace: Option<String>, pub replace: Option<String>,
@ -160,13 +197,19 @@ struct AutoMatch {
#[serde(default = "default_passive_only")] #[serde(default = "default_passive_only")]
pub passive_only: bool, pub passive_only: bool,
#[serde(default = "default_propagate_case")]
pub propagate_case: bool,
} }
fn default_trigger() -> String {"".to_owned()}
fn default_triggers() -> Vec<String> {Vec::new()}
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_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}
fn default_propagate_case() -> bool {false}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MatchVariable { pub struct MatchVariable {
@ -175,9 +218,12 @@ pub struct MatchVariable {
#[serde(rename = "type")] #[serde(rename = "type")]
pub var_type: String, pub var_type: String,
#[serde(default = "default_params")]
pub params: Mapping, pub params: Mapping,
} }
fn default_params() -> Mapping {Mapping::new()}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum TriggerEntry { pub enum TriggerEntry {
Char(char), Char(char),
@ -185,7 +231,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>, trigger_offset: usize);
fn on_enable_update(&self, status: bool); fn on_enable_update(&self, status: bool);
fn on_passive(&self); fn on_passive(&self);
} }
@ -281,10 +327,10 @@ mod tests {
let _match : Match = serde_yaml::from_str(match_str).unwrap(); let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t')); assert_eq!(_match._trigger_sequences[0][0], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e')); assert_eq!(_match._trigger_sequences[0][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s')); assert_eq!(_match._trigger_sequences[0][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t')); assert_eq!(_match._trigger_sequences[0][3], TriggerEntry::Char('t'));
} }
#[test] #[test]
@ -297,11 +343,11 @@ mod tests {
let _match : Match = serde_yaml::from_str(match_str).unwrap(); let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t')); assert_eq!(_match._trigger_sequences[0][0], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e')); assert_eq!(_match._trigger_sequences[0][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s')); assert_eq!(_match._trigger_sequences[0][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t')); assert_eq!(_match._trigger_sequences[0][3], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequence[4], TriggerEntry::WordSeparator); assert_eq!(_match._trigger_sequences[0][4], TriggerEntry::WordSeparator);
} }
#[test] #[test]
@ -322,4 +368,108 @@ mod tests {
}, },
} }
} }
#[test]
fn test_match_trigger_populates_triggers_vector() {
let match_str = r###"
trigger: ":test"
replace: "This is a test"
"###;
let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":test"])
}
#[test]
fn test_match_triggers_are_correctly_parsed() {
let match_str = r###"
triggers:
- ":test1"
- :test2
replace: "This is a test"
"###;
let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":test1", ":test2"])
}
#[test]
fn test_match_triggers_are_correctly_parsed_square_brackets() {
let match_str = r###"
triggers: [":test1", ":test2"]
replace: "This is a test"
"###;
let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":test1", ":test2"])
}
#[test]
fn test_match_propagate_case() {
let match_str = r###"
trigger: "hello"
replace: "This is a test"
propagate_case: true
"###;
let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec!["hello", "Hello", "HELLO"])
}
#[test]
fn test_match_propagate_case_multi_trigger() {
let match_str = r###"
triggers: ["hello", "hi"]
replace: "This is a test"
propagate_case: true
"###;
let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec!["hello", "hi", "Hello", "Hi", "HELLO", "HI"])
}
#[test]
fn test_match_trigger_sequence_with_word_propagate_case() {
let match_str = r###"
trigger: "test"
replace: "This is a test"
word: true
propagate_case: true
"###;
let _match : Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match._trigger_sequences[0][0], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[0][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequences[0][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequences[0][3], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[0][4], TriggerEntry::WordSeparator);
assert_eq!(_match._trigger_sequences[1][0], TriggerEntry::Char('T'));
assert_eq!(_match._trigger_sequences[1][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequences[1][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequences[1][3], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[1][4], TriggerEntry::WordSeparator);
assert_eq!(_match._trigger_sequences[2][0], TriggerEntry::Char('T'));
assert_eq!(_match._trigger_sequences[2][1], TriggerEntry::Char('E'));
assert_eq!(_match._trigger_sequences[2][2], TriggerEntry::Char('S'));
assert_eq!(_match._trigger_sequences[2][3], TriggerEntry::Char('T'));
assert_eq!(_match._trigger_sequences[2][4], TriggerEntry::WordSeparator);
}
#[test]
fn test_match_empty_replace_doesnt_crash() {
let match_str = r###"
trigger: "hello"
replace: ""
"###;
let _match : Match = serde_yaml::from_str(match_str).unwrap();
}
} }

View File

@ -39,6 +39,7 @@ pub struct ScrollingMatcher<'a, R: MatchReceiver, M: ConfigManager<'a>> {
struct MatchEntry<'a> { struct MatchEntry<'a> {
start: usize, start: usize,
count: usize, count: usize,
trigger_offset: usize, // The index of the trigger in the Match that matched
_match: &'a Match _match: &'a Match
} }
@ -73,8 +74,8 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> ScrollingMatcher<'a, R, M> {
self.receiver.on_enable_update(*is_enabled); self.receiver.on_enable_update(*is_enabled);
} }
fn is_matching(mtc: &Match, current_char: &str, start: usize, is_current_word_separator: bool) -> bool { fn is_matching(mtc: &Match, current_char: &str, start: usize, trigger_offset: usize, is_current_word_separator: bool) -> bool {
match mtc._trigger_sequence[start] { match mtc._trigger_sequences[trigger_offset][start] {
TriggerEntry::Char(c) => { TriggerEntry::Char(c) => {
current_char.starts_with(c) current_char.starts_with(c)
}, },
@ -112,38 +113,43 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa
let mut current_set_queue = self.current_set_queue.borrow_mut(); let mut current_set_queue = self.current_set_queue.borrow_mut();
let new_matches: Vec<MatchEntry> = active_config.matches.iter() let mut new_matches: Vec<MatchEntry> = Vec::new();
.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); for m in active_config.matches.iter() {
// only active-enabled matches are considered
if m.passive_only {
continue
}
if x.word { for trigger_offset in 0..m._trigger_sequences.len() {
let mut result = Self::is_matching(m, c, 0, trigger_offset, is_current_word_separator);
if m.word {
result = result && *was_previous_word_separator result = result && *was_previous_word_separator
} }
result if result {
}) new_matches.push(MatchEntry{
.map(|x | MatchEntry{ start: 1,
start: 1, count: m._trigger_sequences[trigger_offset].len(),
count: x._trigger_sequence.len(), trigger_offset,
_match: &x _match: &m
}) });
.collect(); }
}
}
// TODO: use an associative structure to improve the efficiency of this first "new_matches" lookup. // TODO: use an associative structure to improve the efficiency of this first "new_matches" lookup.
let combined_matches: Vec<MatchEntry> = match current_set_queue.back_mut() { let combined_matches: Vec<MatchEntry> = match current_set_queue.back_mut() {
Some(last_matches) => { Some(last_matches) => {
let mut updated: Vec<MatchEntry> = last_matches.iter() let mut updated: Vec<MatchEntry> = last_matches.iter()
.filter(|&x| { .filter(|&x| {
Self::is_matching(x._match, c, x.start, is_current_word_separator) Self::is_matching(x._match, c, x.start, x.trigger_offset, is_current_word_separator)
}) })
.map(|x | MatchEntry{ .map(|x | MatchEntry{
start: x.start+1, start: x.start+1,
count: x.count, count: x.count,
trigger_offset: x.trigger_offset,
_match: &x._match _match: &x._match
}) })
.collect(); .collect();
@ -154,11 +160,11 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa
None => {new_matches}, None => {new_matches},
}; };
let mut found_match = None; let mut found_entry = None;
for entry in combined_matches.iter() { for entry in combined_matches.iter() {
if entry.start == entry.count { if entry.start == entry.count {
found_match = Some(entry._match); found_entry = Some(entry.clone());
break; break;
} }
} }
@ -171,7 +177,9 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa
*was_previous_word_separator = is_current_word_separator; *was_previous_word_separator = is_current_word_separator;
if let Some(mtc) = found_match { if let Some(entry) = found_entry {
let mtc = entry._match;
if let Some(last) = current_set_queue.back_mut() { if let Some(last) = current_set_queue.back_mut() {
last.clear(); last.clear();
} }
@ -194,7 +202,7 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa
// Force espanso to consider the last char as a separator // Force espanso to consider the last char as a separator
*was_previous_word_separator = true; *was_previous_word_separator = true;
self.receiver.on_match(mtc, trailing_separator); self.receiver.on_match(mtc, trailing_separator, entry.trigger_offset);
} }
} }

View File

@ -29,6 +29,7 @@ use crate::extension::Extension;
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();
static ref UNKNOWN_VARIABLE : String = "".to_string();
} }
pub struct DefaultRenderer { pub struct DefaultRenderer {
@ -58,15 +59,18 @@ impl DefaultRenderer {
} }
} }
fn find_match(config: &Configs, trigger: &str) -> Option<Match> { fn find_match(config: &Configs, trigger: &str) -> Option<(Match, usize)> {
let mut result = None; let mut result = None;
// TODO: if performances become a problem, implement a more efficient lookup // TODO: if performances become a problem, implement a more efficient lookup
for m in config.matches.iter() { for m in config.matches.iter() {
if m.trigger == trigger { for (trigger_offset, m_trigger) in m.triggers.iter().enumerate() {
result = Some(m.clone()); if m_trigger == trigger {
break; result = Some((m.clone(), trigger_offset));
break;
}
} }
} }
result result
@ -74,7 +78,7 @@ impl DefaultRenderer {
} }
impl super::Renderer for DefaultRenderer { impl super::Renderer for DefaultRenderer {
fn render_match(&self, m: &Match, config: &Configs, args: Vec<String>) -> RenderResult { fn render_match(&self, m: &Match, trigger_offset: usize, config: &Configs, args: Vec<String>) -> RenderResult {
// Manage the different types of matches // Manage the different types of matches
match &m.content { match &m.content {
// Text Match // Text Match
@ -103,11 +107,11 @@ impl super::Renderer for DefaultRenderer {
continue continue
} }
let inner_match = inner_match.unwrap(); let (inner_match, trigger_offset) = inner_match.unwrap();
// Render the inner match // Render the inner match
// TODO: inner arguments // TODO: inner arguments
let result = self.render_match(&inner_match, config, vec![]); let result = self.render_match(&inner_match, trigger_offset, config, vec![]);
// Inner matches are only supported for text-expansions, warn the user otherwise // Inner matches are only supported for text-expansions, warn the user otherwise
match result { match result {
@ -138,7 +142,7 @@ impl super::Renderer for DefaultRenderer {
let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| { let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| {
let var_name = caps.name("name").unwrap().as_str(); let var_name = caps.name("name").unwrap().as_str();
let output = output_map.get(var_name); let output = output_map.get(var_name);
output.unwrap() output.unwrap_or(&UNKNOWN_VARIABLE)
}); });
result.to_string() result.to_string()
@ -146,9 +150,55 @@ impl super::Renderer for DefaultRenderer {
content.replace.clone() 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 // Render any argument that may be present
let target_string = utils::render_args(&target_string, &args); let target_string = utils::render_args(&target_string, &args);
// Handle case propagation
let target_string = if m.propagate_case {
let trigger = &m.triggers[trigger_offset];
let first_char = trigger.chars().nth(0);
let second_char = trigger.chars().nth(1);
let mode: i32 = if let Some(first_char) = first_char {
if first_char.is_uppercase() {
if let Some(second_char) = second_char {
if second_char.is_uppercase() {
2 // Full CAPITALIZATION
}else{
1 // Only first letter capitalized: Capitalization
}
}else{
2 // Single char, defaults to full CAPITALIZATION
}
}else{
0 // Lowercase, no action
}
}else{
0
};
match mode {
1 => {
// Capitalize the first letter
let mut v: Vec<char> = target_string.chars().collect();
v[0] = v[0].to_uppercase().nth(0).unwrap();
v.into_iter().collect()
},
2 => { // Full capitalization
target_string.to_uppercase()
},
_ => { // Noop
target_string
}
}
}else{
target_string
};
RenderResult::Text(target_string) RenderResult::Text(target_string)
}, },
@ -196,9 +246,9 @@ impl super::Renderer for DefaultRenderer {
config.passive_arg_delimiter, config.passive_arg_delimiter,
config.passive_arg_escape); config.passive_arg_escape);
let m = m.unwrap(); let (m, trigger_offset) = m.unwrap();
// Render the actual match // Render the actual match
let result = self.render_match(&m, &config, args); let result = self.render_match(&m, trigger_offset, &config, args);
match result { match result {
RenderResult::Text(out) => { RenderResult::Text(out) => {
@ -221,7 +271,7 @@ mod tests {
use super::*; use super::*;
fn get_renderer(config: Configs) -> DefaultRenderer { fn get_renderer(config: Configs) -> DefaultRenderer {
DefaultRenderer::new(crate::extension::get_extensions(), config) DefaultRenderer::new(vec![Box::new(crate::extension::dummy::DummyExtension::new())], config)
} }
fn get_config_for(s: &str) -> Configs { fn get_config_for(s: &str) -> Configs {
@ -481,4 +531,132 @@ mod tests {
verify_render(rendered, "this is my local"); 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}}");
}
#[test]
fn test_render_passive_simple_match_multi_trigger_no_args() {
let text = "this is a :yolo and :test";
let config = get_config_for(r###"
matches:
- triggers: [':test', ':yolo']
replace: result
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is a result and result");
}
#[test]
fn test_render_passive_simple_match_multi_trigger_with_args() {
let text = ":yolo/Jon/";
let config = get_config_for(r###"
matches:
- triggers: [':greet', ':yolo']
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_match_case_propagation_no_case() {
let config = get_config_for(r###"
matches:
- trigger: 'test'
replace: result
propagate_case: true
"###);
let renderer = get_renderer(config.clone());
let m = config.matches[0].clone();
let trigger_offset = m.triggers.iter().position(|x| x== "test").unwrap();
let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]);
verify_render(rendered, "result");
}
#[test]
fn test_render_match_case_propagation_first_capital() {
let config = get_config_for(r###"
matches:
- trigger: 'test'
replace: result
propagate_case: true
"###);
let renderer = get_renderer(config.clone());
let m = config.matches[0].clone();
let trigger_offset = m.triggers.iter().position(|x| x== "Test").unwrap();
let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]);
verify_render(rendered, "Result");
}
#[test]
fn test_render_match_case_propagation_all_capital() {
let config = get_config_for(r###"
matches:
- trigger: 'test'
replace: result
propagate_case: true
"###);
let renderer = get_renderer(config.clone());
let m = config.matches[0].clone();
let trigger_offset = m.triggers.iter().position(|x| x== "TEST").unwrap();
let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]);
verify_render(rendered, "RESULT");
}
} }

View File

@ -26,7 +26,7 @@ pub(crate) mod utils;
pub trait Renderer { pub trait Renderer {
// Render a match output // Render a match output
fn render_match(&self, m: &Match, config: &Configs, args: Vec<String>) -> RenderResult; fn render_match(&self, m: &Match, trigger_offset: usize, config: &Configs, args: Vec<String>) -> RenderResult;
// Render a passive expansion text // Render a passive expansion text
fn render_passive(&self, text: &str, config: &Configs) -> RenderResult; fn render_passive(&self, text: &str, config: &Configs) -> RenderResult;