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
DerivedData
.idea/
*.snap
venv/

2
Cargo.lock generated
View File

@ -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)",

View File

@ -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"

View File

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

View File

@ -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 )
*/

View File

@ -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 ]]")

View File

@ -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
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_shift_ins_paste();
pub fn trigger_alt_shift_ins_paste();
pub fn trigger_ctrl_alt_paste();
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");
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]

View File

@ -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
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::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]);
},
}

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 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)),
]
}

View File

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

View File

@ -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
}

View File

@ -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();

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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");
}
}

View File

@ -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;