commit
c454227973
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -32,3 +32,7 @@ profile
|
|||
*.moved-aside
|
||||
DerivedData
|
||||
.idea/
|
||||
|
||||
*.snap
|
||||
|
||||
venv/
|
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -370,7 +370,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "espanso"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"backtrace 0.3.37 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "espanso"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
authors = ["Federico Terzi <federicoterzi96@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
description = "Cross-platform Text Expander written in Rust"
|
||||
|
|
|
@ -307,6 +307,10 @@ void trigger_alt_shift_ins_paste() {
|
|||
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
|
||||
|
||||
|
@ -467,8 +471,8 @@ int32_t is_current_window_special() {
|
|||
if (res > 0) {
|
||||
if (strstr(class_buffer, "terminal") != NULL) {
|
||||
return 1;
|
||||
}else if (strstr(class_buffer, "URxvt") != NULL) { // Manjaro terminal
|
||||
return 1;
|
||||
}else if (strstr(class_buffer, "URxvt") != NULL) { // urxvt terminal
|
||||
return 4;
|
||||
}else if (strstr(class_buffer, "XTerm") != NULL) { // XTerm and UXTerm
|
||||
return 1;
|
||||
}else if (strstr(class_buffer, "Termite") != NULL) { // Termite
|
||||
|
|
|
@ -92,6 +92,11 @@ extern "C" void trigger_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 )
|
||||
*/
|
||||
|
|
23
packager.py
23
packager.py
|
@ -7,6 +7,7 @@ import click
|
|||
import shutil
|
||||
import toml
|
||||
import hashlib
|
||||
import glob
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
@ -77,9 +78,23 @@ def build_windows(package_info):
|
|||
TARGET_DIR = os.path.join(PACKAGER_TARGET_DIR, "win")
|
||||
os.makedirs(TARGET_DIR, exist_ok=True)
|
||||
|
||||
print("Downloading Visual C++ redistributable")
|
||||
vc_redist_file = os.path.join(TARGET_DIR, "vc_redist.x64.exe")
|
||||
urllib.request.urlretrieve("https://aka.ms/vs/16/release/vc_redist.x64.exe", vc_redist_file)
|
||||
print("Gathering CRT DLLs...")
|
||||
msvc_dirs = glob.glob("C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\*\\VC\\Redist\\MSVC\\*")
|
||||
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"
|
||||
|
||||
|
@ -100,6 +115,7 @@ def build_windows(package_info):
|
|||
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_name}}}", INSTALLER_NAME)
|
||||
content = content.replace("{{{dll_include}}}", dll_include)
|
||||
|
||||
with open(os.path.join(TARGET_DIR, "setupscript.iss"), "w") as output_script:
|
||||
output_script.write(content)
|
||||
|
@ -174,6 +190,7 @@ def build_mac(package_info):
|
|||
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("[[ espanso packager ]]")
|
||||
|
||||
|
|
|
@ -30,7 +30,6 @@ Compression=lzma
|
|||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
ChangesEnvironment=yes
|
||||
AlwaysRestart = yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
@ -38,7 +37,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
|
|||
[Files]
|
||||
Source: "{{{executable_path}}}"; 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
|
||||
|
||||
[Icons]
|
||||
|
@ -53,18 +52,13 @@ Name: "StartMenuEntry" ; Description: "Start espanso at Windows startup" ;
|
|||
const
|
||||
ModPathName = 'modifypath';
|
||||
ModPathType = 'user';
|
||||
|
||||
function ModPathDir(): TArrayOfString;
|
||||
begin
|
||||
setArrayLength(Result, 1)
|
||||
Result[0] := ExpandConstant('{app}');
|
||||
end;
|
||||
#include "modpath.iss"
|
||||
|
||||
[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
|
||||
|
||||
[UninstallRun]
|
||||
|
|
70
snapcraft.yaml
Normal file
70
snapcraft.yaml
Normal 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
|
|
@ -44,5 +44,6 @@ extern {
|
|||
pub fn trigger_terminal_paste();
|
||||
pub fn trigger_shift_ins_paste();
|
||||
pub fn trigger_alt_shift_ins_paste();
|
||||
pub fn trigger_ctrl_alt_paste();
|
||||
pub fn trigger_copy();
|
||||
}
|
|
@ -38,7 +38,7 @@ pub(crate) mod runtime;
|
|||
const DEFAULT_CONFIG_FILE_CONTENT : &str = include_str!("../res/config.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
|
||||
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_toggle_interval() -> u32 { 230 }
|
||||
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_arg_delimiter() -> char { '/' }
|
||||
fn default_passive_arg_escape() -> char { '\\' }
|
||||
|
@ -70,7 +70,7 @@ fn default_global_vars() -> Vec<MatchVariable> { Vec::new() }
|
|||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Configs {
|
||||
#[serde(default = "default_name")]
|
||||
#[serde(default = "default_name")]
|
||||
pub name: String,
|
||||
|
||||
#[serde(default = "default_parent")]
|
||||
|
@ -253,10 +253,10 @@ impl Configs {
|
|||
let mut merged_matches = new_config.matches;
|
||||
let mut match_trigger_set = HashSet::new();
|
||||
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| {
|
||||
!match_trigger_set.contains(&m.trigger)
|
||||
!m.triggers.iter().any(|trigger| match_trigger_set.contains(trigger))
|
||||
}).cloned().collect();
|
||||
|
||||
merged_matches.extend(parent_matches);
|
||||
|
@ -280,10 +280,10 @@ impl Configs {
|
|||
// Merge matches
|
||||
let mut match_trigger_set = HashSet::new();
|
||||
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| {
|
||||
!match_trigger_set.contains(&m.trigger)
|
||||
!m.triggers.iter().any(|trigger| match_trigger_set.contains(trigger))
|
||||
}).cloned().collect();
|
||||
|
||||
self.matches.extend(default_matches);
|
||||
|
@ -465,16 +465,16 @@ impl ConfigSet {
|
|||
}
|
||||
|
||||
fn has_conflicts(default: &Configs, specific: &Vec<Configs>) -> bool {
|
||||
let mut sorted_triggers : Vec<String> = default.matches.iter().map(|t| {
|
||||
t.trigger.clone()
|
||||
let mut sorted_triggers : Vec<String> = default.matches.iter().flat_map(|t| {
|
||||
t.triggers.clone()
|
||||
}).collect();
|
||||
sorted_triggers.sort();
|
||||
|
||||
let mut has_conflicts = Self::list_has_conflicts(&sorted_triggers);
|
||||
|
||||
for s in specific.iter() {
|
||||
let mut specific_triggers : Vec<String> = s.matches.iter().map(|t| {
|
||||
t.trigger.clone()
|
||||
let mut specific_triggers : Vec<String> = s.matches.iter().flat_map(|t| {
|
||||
t.triggers.clone()
|
||||
}).collect();
|
||||
specific_triggers.sort();
|
||||
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.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.trigger == ":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] == "hello").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.triggers[0] == ":yess").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -860,12 +860,12 @@ mod tests {
|
|||
|
||||
assert!(config_set.specific[0].matches.iter().find(|x| {
|
||||
if let MatchContentType::Text(content) = &x.content {
|
||||
x.trigger == ":lol" && content.replace == "newstring"
|
||||
x.triggers[0] == ":lol" && content.replace == "newstring"
|
||||
}else{
|
||||
false
|
||||
}
|
||||
}).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]
|
||||
|
@ -894,7 +894,7 @@ mod tests {
|
|||
|
||||
assert!(config_set.specific[0].matches.iter().find(|x| {
|
||||
if let MatchContentType::Text(content) = &x.content {
|
||||
x.trigger == "hello" && content.replace == "newstring"
|
||||
x.triggers[0] == "hello" && content.replace == "newstring"
|
||||
}else{
|
||||
false
|
||||
}
|
||||
|
@ -962,8 +962,8 @@ mod tests {
|
|||
let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
|
||||
assert_eq!(config_set.specific.len(), 0);
|
||||
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.trigger == "hello"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -983,9 +983,9 @@ mod tests {
|
|||
let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
|
||||
assert_eq!(config_set.specific.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.trigger == "hello"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "hello"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
|
||||
assert!(!config_set.default.matches.iter().any(|m| m.triggers[0] == "hello"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1016,9 +1016,9 @@ mod tests {
|
|||
let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
|
||||
assert_eq!(config_set.specific.len(), 0);
|
||||
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.trigger == "hello"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.trigger == "super"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hello"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "super"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1042,7 +1042,7 @@ mod tests {
|
|||
assert_eq!(config_set.default.matches.len(), 1);
|
||||
assert!(config_set.default.matches.iter().any(|m| {
|
||||
if let MatchContentType::Text(content) = &m.content {
|
||||
m.trigger == "hasta" && content.replace == "world"
|
||||
m.triggers[0] == "hasta" && content.replace == "world"
|
||||
}else{
|
||||
false
|
||||
}
|
||||
|
@ -1068,8 +1068,8 @@ mod tests {
|
|||
let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
|
||||
assert_eq!(config_set.specific.len(), 0);
|
||||
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.trigger == "harry"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "harry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1089,8 +1089,8 @@ mod tests {
|
|||
let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
|
||||
assert_eq!(config_set.specific.len(), 1);
|
||||
assert_eq!(config_set.default.matches.len(), 1);
|
||||
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "harry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1120,9 +1120,9 @@ mod tests {
|
|||
let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
|
||||
assert_eq!(config_set.specific.len(), 1);
|
||||
assert_eq!(config_set.default.matches.len(), 1);
|
||||
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "ron"));
|
||||
assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "harry"));
|
||||
assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
@ -23,7 +23,7 @@ use crate::event::*;
|
|||
use crate::event::KeyModifier::*;
|
||||
use crate::bridge::linux::*;
|
||||
use std::process::exit;
|
||||
use log::{error, info};
|
||||
use log::{debug, error, info};
|
||||
use std::ffi::CStr;
|
||||
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();
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Unable to receive char: {}",e);
|
||||
debug!("Unable to receive char: {}",e);
|
||||
},
|
||||
}
|
||||
}else{ // Modifier event
|
||||
|
|
63
src/edit.rs
Normal file
63
src/edit.rs
Normal 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
|
||||
}
|
||||
}
|
|
@ -22,7 +22,7 @@ use crate::keyboard::KeyboardManager;
|
|||
use crate::config::ConfigManager;
|
||||
use crate::config::BackendType;
|
||||
use crate::clipboard::ClipboardManager;
|
||||
use log::{info, warn, error};
|
||||
use log::{info, warn, debug, error};
|
||||
use crate::ui::{UIManager, MenuItem, MenuItemType};
|
||||
use crate::event::{ActionEventReceiver, ActionType};
|
||||
use crate::extension::Extension;
|
||||
|
@ -132,7 +132,7 @@ lazy_static! {
|
|||
impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer>
|
||||
MatchReceiver for Engine<'a, S, C, M, U, R>{
|
||||
|
||||
fn on_match(&self, m: &Match, trailing_separator: Option<char>) {
|
||||
fn on_match(&self, m: &Match, trailing_separator: Option<char>, trigger_offset: usize) {
|
||||
let config = self.config_manager.active_config();
|
||||
|
||||
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
|
||||
if self.check_last_action_and_set(self.action_noop_interval) {
|
||||
debug!("Last action was too near, nooping the action.");
|
||||
return;
|
||||
}
|
||||
|
||||
let char_count = if trailing_separator.is_none() {
|
||||
m.trigger.chars().count() as i32
|
||||
m.triggers[trigger_offset].chars().count() as i32
|
||||
}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);
|
||||
|
||||
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 {
|
||||
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);
|
||||
},
|
||||
RenderResult::Error => {
|
||||
error!("Could not render match: {}", m.trigger);
|
||||
error!("Could not render match: {}", m.triggers[trigger_offset]);
|
||||
},
|
||||
}
|
||||
|
||||
|
|
43
src/extension/clipboard.rs
Normal file
43
src/extension/clipboard.rs
Normal 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()
|
||||
}
|
||||
}
|
|
@ -18,24 +18,27 @@
|
|||
*/
|
||||
|
||||
use serde_yaml::Mapping;
|
||||
use crate::clipboard::ClipboardManager;
|
||||
|
||||
mod date;
|
||||
mod shell;
|
||||
mod script;
|
||||
mod random;
|
||||
mod dummy;
|
||||
mod clipboard;
|
||||
pub mod dummy;
|
||||
|
||||
pub trait Extension {
|
||||
fn name(&self) -> 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![
|
||||
Box::new(date::DateExtension::new()),
|
||||
Box::new(shell::ShellExtension::new()),
|
||||
Box::new(script::ScriptExtension::new()),
|
||||
Box::new(random::RandomExtension::new()),
|
||||
Box::new(dummy::DummyExtension::new()),
|
||||
Box::new(clipboard::ClipboardExtension::new(clipboard_manager)),
|
||||
]
|
||||
}
|
|
@ -52,6 +52,8 @@ impl super::KeyboardManager for LinuxKeyboardManager {
|
|||
trigger_alt_shift_ins_paste();
|
||||
}else if is_special == 3 { // Special case for Emacs
|
||||
trigger_shift_ins_paste();
|
||||
}else if is_special == 4 { // CTRL+ALT+V used in some terminals (urxvt)
|
||||
trigger_ctrl_alt_paste();
|
||||
}else{
|
||||
trigger_terminal_paste();
|
||||
}
|
||||
|
@ -65,6 +67,9 @@ impl super::KeyboardManager for LinuxKeyboardManager {
|
|||
PasteShortcut::ShiftInsert=> {
|
||||
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.")
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ pub enum PasteShortcut {
|
|||
CtrlV, // Classic Ctrl+V shortcut
|
||||
CtrlShiftV, // Could be used to paste without formatting in many applications
|
||||
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
|
||||
}
|
||||
|
||||
|
|
85
src/main.rs
85
src/main.rs
|
@ -46,6 +46,7 @@ use crate::package::default::DefaultPackageManager;
|
|||
use crate::package::{PackageManager, InstallResult, UpdateResult, RemoveResult};
|
||||
|
||||
mod ui;
|
||||
mod edit;
|
||||
mod event;
|
||||
mod check;
|
||||
mod utils;
|
||||
|
@ -96,6 +97,10 @@ fn main() {
|
|||
.subcommand(SubCommand::with_name("toggle")
|
||||
.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")
|
||||
.about("Prints all current configuration options."))
|
||||
.subcommand(SubCommand::with_name("detect")
|
||||
|
@ -146,6 +151,14 @@ fn main() {
|
|||
|
||||
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;
|
||||
|
||||
// Load the configuration
|
||||
|
@ -156,7 +169,7 @@ fn main() {
|
|||
|
||||
config_set.default.log_level = log_level;
|
||||
|
||||
// Match the correct subcommand
|
||||
// Commands that require the configuration
|
||||
|
||||
if let Some(matches) = matches.subcommand_matches("cmd") {
|
||||
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 extensions = extension::get_extensions();
|
||||
let extensions = extension::get_extensions(Box::new(clipboard::get_manager()));
|
||||
|
||||
let renderer = render::default::DefaultRenderer::new(extensions,
|
||||
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> {
|
||||
let espanso_dir = context::get_data_dir();
|
||||
|
|
|
@ -29,14 +29,15 @@ pub(crate) mod scrolling;
|
|||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Match {
|
||||
pub trigger: String,
|
||||
pub triggers: Vec<String>,
|
||||
pub content: MatchContentType,
|
||||
pub word: 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)]
|
||||
pub _trigger_sequence: Vec<TriggerEntry>,
|
||||
pub _trigger_sequences: Vec<Vec<TriggerEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
|
@ -74,11 +75,38 @@ impl<'a> From<&'a AutoMatch> for Match{
|
|||
static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)\\s*\\}\\}").unwrap();
|
||||
};
|
||||
|
||||
// TODO: may need to replace windows newline (\r\n) with newline only (\n)
|
||||
let 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)
|
||||
};
|
||||
|
||||
// If propagate_case is true, we need to generate all the possible triggers
|
||||
// For example, specifying "hello" as a trigger, we need to have:
|
||||
// "hello", "Hello", "HELLO"
|
||||
if other.propagate_case {
|
||||
// List with first letter capitalized
|
||||
let first_capitalized : Vec<String> = triggers.iter().map(|trigger| {
|
||||
let mut capitalized = trigger.clone();
|
||||
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> = other.trigger.chars().collect();
|
||||
let trigger_chars : Vec<char> = trigger.chars().collect();
|
||||
trigger_sequence.extend(trigger_chars.into_iter().map(|c| {
|
||||
TriggerEntry::Char(c)
|
||||
}));
|
||||
|
@ -86,6 +114,10 @@ impl<'a> From<&'a AutoMatch> for Match{
|
|||
trigger_sequence.push(TriggerEntry::WordSeparator);
|
||||
}
|
||||
|
||||
trigger_sequence
|
||||
}).collect();
|
||||
|
||||
|
||||
let content = if let Some(replace) = &other.replace { // Text match
|
||||
let new_replace = replace.clone();
|
||||
|
||||
|
@ -132,11 +164,12 @@ impl<'a> From<&'a AutoMatch> for Match{
|
|||
};
|
||||
|
||||
Self {
|
||||
trigger: other.trigger.clone(),
|
||||
triggers,
|
||||
content,
|
||||
word: other.word,
|
||||
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.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct AutoMatch {
|
||||
#[serde(default = "default_trigger")]
|
||||
pub trigger: String,
|
||||
|
||||
#[serde(default = "default_triggers")]
|
||||
pub triggers: Vec<String>,
|
||||
|
||||
#[serde(default = "default_replace")]
|
||||
pub replace: Option<String>,
|
||||
|
||||
|
@ -160,13 +197,19 @@ struct AutoMatch {
|
|||
|
||||
#[serde(default = "default_passive_only")]
|
||||
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_word() -> bool {false}
|
||||
fn default_passive_only() -> bool {false}
|
||||
fn default_replace() -> Option<String> {None}
|
||||
fn default_image_path() -> Option<String> {None}
|
||||
fn default_propagate_case() -> bool {false}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MatchVariable {
|
||||
|
@ -175,9 +218,12 @@ pub struct MatchVariable {
|
|||
#[serde(rename = "type")]
|
||||
pub var_type: String,
|
||||
|
||||
#[serde(default = "default_params")]
|
||||
pub params: Mapping,
|
||||
}
|
||||
|
||||
fn default_params() -> Mapping {Mapping::new()}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum TriggerEntry {
|
||||
Char(char),
|
||||
|
@ -185,7 +231,7 @@ pub enum TriggerEntry {
|
|||
}
|
||||
|
||||
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_passive(&self);
|
||||
}
|
||||
|
@ -281,10 +327,10 @@ mod tests {
|
|||
|
||||
let _match : Match = serde_yaml::from_str(match_str).unwrap();
|
||||
|
||||
assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t'));
|
||||
assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e'));
|
||||
assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s'));
|
||||
assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t'));
|
||||
assert_eq!(_match._trigger_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'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -297,11 +343,11 @@ mod tests {
|
|||
|
||||
let _match : Match = serde_yaml::from_str(match_str).unwrap();
|
||||
|
||||
assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t'));
|
||||
assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e'));
|
||||
assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s'));
|
||||
assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t'));
|
||||
assert_eq!(_match._trigger_sequence[4], TriggerEntry::WordSeparator);
|
||||
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);
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
|
@ -39,6 +39,7 @@ pub struct ScrollingMatcher<'a, R: MatchReceiver, M: ConfigManager<'a>> {
|
|||
struct MatchEntry<'a> {
|
||||
start: usize,
|
||||
count: usize,
|
||||
trigger_offset: usize, // The index of the trigger in the Match that matched
|
||||
_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);
|
||||
}
|
||||
|
||||
fn is_matching(mtc: &Match, current_char: &str, start: usize, is_current_word_separator: bool) -> bool {
|
||||
match mtc._trigger_sequence[start] {
|
||||
fn is_matching(mtc: &Match, current_char: &str, start: usize, trigger_offset: usize, is_current_word_separator: bool) -> bool {
|
||||
match mtc._trigger_sequences[trigger_offset][start] {
|
||||
TriggerEntry::Char(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 new_matches: Vec<MatchEntry> = active_config.matches.iter()
|
||||
.filter(|&x| {
|
||||
let mut new_matches: Vec<MatchEntry> = Vec::new();
|
||||
|
||||
for m in active_config.matches.iter() {
|
||||
// only active-enabled matches are considered
|
||||
if x.passive_only {
|
||||
return false;
|
||||
if m.passive_only {
|
||||
continue
|
||||
}
|
||||
|
||||
let mut result = Self::is_matching(x, c, 0, is_current_word_separator);
|
||||
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 x.word {
|
||||
if m.word {
|
||||
result = result && *was_previous_word_separator
|
||||
}
|
||||
|
||||
result
|
||||
})
|
||||
.map(|x | MatchEntry{
|
||||
if result {
|
||||
new_matches.push(MatchEntry{
|
||||
start: 1,
|
||||
count: x._trigger_sequence.len(),
|
||||
_match: &x
|
||||
})
|
||||
.collect();
|
||||
count: m._trigger_sequences[trigger_offset].len(),
|
||||
trigger_offset,
|
||||
_match: &m
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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() {
|
||||
Some(last_matches) => {
|
||||
let mut updated: Vec<MatchEntry> = last_matches.iter()
|
||||
.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{
|
||||
start: x.start+1,
|
||||
count: x.count,
|
||||
trigger_offset: x.trigger_offset,
|
||||
_match: &x._match
|
||||
})
|
||||
.collect();
|
||||
|
@ -154,11 +160,11 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa
|
|||
None => {new_matches},
|
||||
};
|
||||
|
||||
let mut found_match = None;
|
||||
let mut found_entry = None;
|
||||
|
||||
for entry in combined_matches.iter() {
|
||||
if entry.start == entry.count {
|
||||
found_match = Some(entry._match);
|
||||
found_entry = Some(entry.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -171,7 +177,9 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa
|
|||
|
||||
*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() {
|
||||
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
|
||||
*was_previous_word_separator = true;
|
||||
|
||||
self.receiver.on_match(mtc, trailing_separator);
|
||||
self.receiver.on_match(mtc, trailing_separator, entry.trigger_offset);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ 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 {
|
||||
|
@ -58,23 +59,26 @@ 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;
|
||||
|
||||
// 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());
|
||||
for (trigger_offset, m_trigger) in m.triggers.iter().enumerate() {
|
||||
if m_trigger == trigger {
|
||||
result = Some((m.clone(), trigger_offset));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
match &m.content {
|
||||
// Text Match
|
||||
|
@ -103,11 +107,11 @@ impl super::Renderer for DefaultRenderer {
|
|||
continue
|
||||
}
|
||||
|
||||
let inner_match = inner_match.unwrap();
|
||||
let (inner_match, trigger_offset) = inner_match.unwrap();
|
||||
|
||||
// Render the inner match
|
||||
// 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
|
||||
match result {
|
||||
|
@ -138,7 +142,7 @@ impl super::Renderer for DefaultRenderer {
|
|||
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()
|
||||
output.unwrap_or(&UNKNOWN_VARIABLE)
|
||||
});
|
||||
|
||||
result.to_string()
|
||||
|
@ -146,9 +150,55 @@ impl super::Renderer for DefaultRenderer {
|
|||
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);
|
||||
|
||||
// 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)
|
||||
},
|
||||
|
||||
|
@ -196,9 +246,9 @@ impl super::Renderer for DefaultRenderer {
|
|||
config.passive_arg_delimiter,
|
||||
config.passive_arg_escape);
|
||||
|
||||
let m = m.unwrap();
|
||||
let (m, trigger_offset) = m.unwrap();
|
||||
// Render the actual match
|
||||
let result = self.render_match(&m, &config, args);
|
||||
let result = self.render_match(&m, trigger_offset, &config, args);
|
||||
|
||||
match result {
|
||||
RenderResult::Text(out) => {
|
||||
|
@ -221,7 +271,7 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
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 {
|
||||
|
@ -481,4 +531,132 @@ mod tests {
|
|||
|
||||
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");
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ pub(crate) mod utils;
|
|||
|
||||
pub trait Renderer {
|
||||
// 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
|
||||
fn render_passive(&self, text: &str, config: &Configs) -> RenderResult;
|
||||
|
|
Loading…
Reference in New Issue
Block a user