diff --git a/.gitignore b/.gitignore index f4ff355..3619f48 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ DerivedData *.snap -venv/ \ No newline at end of file +venv/ + +.vscode/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 9a891de..08ff7f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,7 +18,7 @@ name = "ansi_term" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -45,7 +45,7 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -181,7 +181,7 @@ dependencies = [ "atty 0.2.13 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -212,7 +212,7 @@ dependencies = [ "regex 1.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "termios 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "unicode-width 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -334,7 +334,7 @@ dependencies = [ "cfg-if 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", "redox_users 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -371,7 +371,7 @@ dependencies = [ [[package]] name = "espanso" -version = "0.6.3" +version = "0.7.0" 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)", @@ -397,6 +397,7 @@ dependencies = [ "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "walkdir 2.2.9 (registry+https://github.com/rust-lang/crates.io-index)", "widestring 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", "zip 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -428,7 +429,7 @@ dependencies = [ "cfg-if 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", "redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -465,7 +466,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -808,7 +809,7 @@ name = "named_pipe" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -835,7 +836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "cfg-if 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -857,7 +858,7 @@ dependencies = [ "mio 0.6.19 (registry+https://github.com/rust-lang/crates.io-index)", "mio-extras 2.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "walkdir 2.2.9 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -941,7 +942,7 @@ dependencies = [ "rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)", "rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "smallvec 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1028,7 +1029,7 @@ dependencies = [ "rand_os 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", "rand_pcg 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "rand_xorshift 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1113,7 +1114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", "rand_core 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1126,7 +1127,7 @@ dependencies = [ "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", "rand_core 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", "rdrand 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1191,7 +1192,7 @@ name = "remove_dir_all" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1269,7 +1270,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1461,7 +1462,7 @@ dependencies = [ "rand 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)", "redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)", "remove_dir_all 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1470,7 +1471,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "dirs 2.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1504,7 +1505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", "redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1731,7 +1732,7 @@ version = "2.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "same-file 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", "winapi-util 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -1762,7 +1763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "winapi" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1784,7 +1785,7 @@ name = "winapi-util" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1797,7 +1798,7 @@ name = "winreg" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -2028,7 +2029,7 @@ dependencies = [ "checksum wasi 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b89c3ce4ce14bdc6fb6beaf9ec7928ca331de5df7e5ea278375642a2f478570d" "checksum widestring 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "effc0e4ff8085673ea7b9b2e3c73f6bd4d118810c9009ed8f1e16bd96c331db6" "checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" -"checksum winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" +"checksum winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" "checksum winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" "checksum winapi-util 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7168bab6e1daee33b4557efd0e95d5ca70a03706d39fa5f3fe7a236f584b03c9" diff --git a/Cargo.toml b/Cargo.toml index 6e49975..10ea57f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "0.6.3" +version = "0.7.0" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" @@ -9,6 +9,9 @@ homepage = "https://github.com/federico-terzi/espanso" edition = "2018" build="build.rs" +[modulo] +version = "0.1.0" + [dependencies] widestring = "0.4.0" serde = { version = "1.0", features = ["derive"] } @@ -38,6 +41,7 @@ signal-hook = "0.1.15" [target.'cfg(windows)'.dependencies] named_pipe = "0.4.1" +winapi = { version = "0.3.9", features = ["wincon"] } [build-dependencies] cmake = "0.1.31" diff --git a/native/libmacbridge/bridge.h b/native/libmacbridge/bridge.h index 4a0f1d8..169c3cf 100644 --- a/native/libmacbridge/bridge.h +++ b/native/libmacbridge/bridge.h @@ -76,6 +76,11 @@ void send_multi_vkey(int32_t vk, int32_t count); */ void delete_string(int32_t count); +/* + * Check whether keyboard modifiers (CTRL, CMD, SHIFT, ecc) are pressed + */ +int32_t are_modifiers_pressed(); + /* * Trigger normal paste ( Pressing CMD+V ) */ diff --git a/native/libmacbridge/bridge.mm b/native/libmacbridge/bridge.mm index 1ddd549..bafe1ae 100644 --- a/native/libmacbridge/bridge.mm +++ b/native/libmacbridge/bridge.mm @@ -235,6 +235,14 @@ void trigger_copy() { }); } +int32_t are_modifiers_pressed() { + if ((NSEventModifierFlagControl | NSEventModifierFlagOption | + NSEventModifierFlagCommand | NSEventModifierFlagShift) & [NSEvent modifierFlags]) { + return 1; + } + return 0; +} + int32_t get_active_app_bundle(char * buffer, int32_t size) { NSRunningApplication *frontApp = [[NSWorkspace sharedWorkspace] frontmostApplication]; NSString *bundlePath = [frontApp bundleURL].path; diff --git a/native/libwinbridge/bridge.cpp b/native/libwinbridge/bridge.cpp index 01024c1..55d9567 100644 --- a/native/libwinbridge/bridge.cpp +++ b/native/libwinbridge/bridge.cpp @@ -62,6 +62,7 @@ HWND nw = NULL; HWND hwnd_st_u = NULL; HBITMAP g_espanso_bmp = NULL; HICON g_espanso_ico = NULL; +HICON g_espanso_red_ico = NULL; NOTIFYICONDATA nid = {}; UINT WM_TASKBARCREATED = RegisterWindowMessage(L"TaskbarCreated"); @@ -309,13 +310,14 @@ LRESULT CALLBACK window_procedure(HWND window, unsigned int msg, WPARAM wp, LPAR } } -int32_t initialize(void * self, wchar_t * ico_path, wchar_t * bmp_path, int32_t _show_icon) { +int32_t initialize(void * self, wchar_t * ico_path, wchar_t * red_ico_path, wchar_t * bmp_path, int32_t _show_icon) { manager_instance = self; show_icon = _show_icon; // Load the images g_espanso_bmp = (HBITMAP)LoadImage(NULL, bmp_path, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE); g_espanso_ico = (HICON)LoadImage(NULL, ico_path, IMAGE_ICON, 0, 0, LR_DEFAULTCOLOR | LR_SHARED | LR_DEFAULTSIZE | LR_LOADFROMFILE); + g_espanso_red_ico = (HICON)LoadImage(NULL, red_ico_path, IMAGE_ICON, 0, 0, LR_DEFAULTCOLOR | LR_SHARED | LR_DEFAULTSIZE | LR_LOADFROMFILE); // Make the notification capable of handling different screen definitions SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE); @@ -472,6 +474,19 @@ void eventloop() { // Something went wrong, this should have been an infinite loop. } +void update_tray_icon(int32_t enabled) { + if (enabled) { + nid.hIcon = g_espanso_ico; + }else{ + nid.hIcon = g_espanso_red_ico; + } + + // Update the icon + if (show_icon) { + Shell_NotifyIcon(NIM_MODIFY, &nid); + } +} + /* * Type the given string simulating keyboard presses. */ @@ -730,7 +745,7 @@ int32_t start_daemon_process() { NULL, NULL, FALSE, - DETACHED_PROCESS, + DETACHED_PROCESS | CREATE_NO_WINDOW, NULL, NULL, &si, diff --git a/native/libwinbridge/bridge.h b/native/libwinbridge/bridge.h index f0ef9eb..7db802a 100644 --- a/native/libwinbridge/bridge.h +++ b/native/libwinbridge/bridge.h @@ -33,7 +33,7 @@ extern void * manager_instance; * Initialize the Windows parameters * return: 1 if OK, -1 otherwise. */ -extern "C" int32_t initialize(void * self, wchar_t * ico_path, wchar_t * bmp_path, int32_t show_icon); +extern "C" int32_t initialize(void * self, wchar_t * ico_path, wchar_t * red_ico_path, wchar_t * bmp_path, int32_t show_icon); #define LEFT_VARIANT 1 #define RIGHT_VARIANT 2 @@ -152,6 +152,11 @@ extern "C" int32_t show_notification(wchar_t * message); */ extern "C" void close_notification(); +/* + * Update the tray icon status + */ +extern "C" void update_tray_icon(int32_t enabled); + // CLIPBOARD /* diff --git a/packager.py b/packager.py index 1f2c371..6a944a1 100644 --- a/packager.py +++ b/packager.py @@ -20,6 +20,7 @@ class PackageInfo: description: str publisher: str url: str + modulo_version: str @click.group() def cli(): @@ -44,7 +45,8 @@ def build(skipcargo): cargo_info["package"]["version"], cargo_info["package"]["description"], cargo_info["package"]["authors"][0], - cargo_info["package"]["homepage"]) + cargo_info["package"]["homepage"], + cargo_info["modulo"]["version"]) print(package_info) if not skipcargo: @@ -58,6 +60,11 @@ def build(skipcargo): elif TARGET_OS == "macos": build_mac(package_info) +def calculate_sha256(file): + with open(file, "rb") as f: + b = f.read() # read entire file as bytes + readable_hash = hashlib.sha256(b).hexdigest() + return readable_hash def build_windows(package_info): print("Starting packaging process for Windows...") @@ -78,6 +85,22 @@ def build_windows(package_info): TARGET_DIR = os.path.join(PACKAGER_TARGET_DIR, "win") os.makedirs(TARGET_DIR, exist_ok=True) + modulo_url = "https://github.com/federico-terzi/modulo/releases/download/v{0}/modulo-win.exe".format(package_info.modulo_version) + modulo_sha_url = "https://github.com/federico-terzi/modulo/releases/download/v{0}/modulo-win.exe.sha256.txt".format(package_info.modulo_version) + print("Pulling modulo depencency from:", modulo_url) + modulo_target_file = os.path.join(TARGET_DIR, "modulo.exe") + urllib.request.urlretrieve(modulo_url, modulo_target_file) + print("Pulling SHA signature from:", modulo_sha_url) + modulo_sha_file = os.path.join(TARGET_DIR, "modulo.sha256") + urllib.request.urlretrieve(modulo_sha_url, modulo_sha_file) + print("Checking signatures...") + expected_sha = None + with open(modulo_sha_file, "r") as sha_f: + expected_sha = sha_f.read() + actual_sha = calculate_sha256(modulo_target_file) + if actual_sha != expected_sha: + raise Exception("Modulo SHA256 is not matching") + print("Gathering CRT DLLs...") msvc_dirs = glob.glob("C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\*\\VC\\Redist\\MSVC\\*") print("Found Redists: ", msvc_dirs) @@ -104,12 +127,15 @@ def build_windows(package_info): dll_files = glob.glob(msvc_dir + "\\x64\\*CRT\\*.dll") print("Found DLLs:") - dll_include_list = [] + include_list = [] for dll in dll_files: print("Including: "+dll) - dll_include_list.append("Source: \""+dll+"\"; DestDir: \"{app}\"; Flags: ignoreversion") + include_list.append("Source: \""+dll+"\"; DestDir: \"{app}\"; Flags: ignoreversion") - dll_include = "\r\n".join(dll_include_list) + print("Including modulo") + include_list.append("Source: \""+os.path.abspath(modulo_target_file)+"\"; DestDir: \"{app}\"; Flags: ignoreversion") + + include = "\r\n".join(include_list) INSTALLER_NAME = f"espanso-win-installer" @@ -130,7 +156,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) + content = content.replace("{{{dll_include}}}", include) with open(os.path.join(TARGET_DIR, "setupscript.iss"), "w") as output_script: output_script.write(content) @@ -185,6 +211,16 @@ def build_mac(package_info): with open(hash_file, "w") as hf: hf.write(sha256_hash.hexdigest()) + modulo_sha_url = "https://github.com/federico-terzi/modulo/releases/download/v{0}/modulo-mac.sha256.txt".format(package_info.modulo_version) + print("Pulling SHA signature from:", modulo_sha_url) + modulo_sha_file = os.path.join(TARGET_DIR, "modulo.sha256") + urllib.request.urlretrieve(modulo_sha_url, modulo_sha_file) + modulo_sha = None + with open(modulo_sha_file, "r") as sha_f: + modulo_sha = sha_f.read() + if modulo_sha is None: + raise Exception("Cannot determine modulo SHA") + print("Processing Homebrew formula template") with open("packager/mac/espanso.rb", "r") as formula_template: content = formula_template.read() @@ -193,6 +229,8 @@ def build_mac(package_info): content = content.replace("{{{app_desc}}}", package_info.description) content = content.replace("{{{app_url}}}", package_info.url) content = content.replace("{{{app_version}}}", package_info.version) + content = content.replace("{{{modulo_version}}}", package_info.modulo_version) + content = content.replace("{{{modulo_sha}}}", modulo_sha) # Calculate hash with open(archive_target, "rb") as f: diff --git a/packager/mac/espanso.rb b/packager/mac/espanso.rb index 6e7f477..399f151 100644 --- a/packager/mac/espanso.rb +++ b/packager/mac/espanso.rb @@ -4,11 +4,18 @@ class Espanso < Formula desc "{{{app_desc}}}" homepage "{{{app_url}}}" - url "https://github.com/federico-terzi/espanso/releases/latest/download/espanso-mac.tar.gz" + url "https://github.com/federico-terzi/espanso/releases/v{{{app_version}}}/download/espanso-mac.tar.gz" sha256 "{{{release_hash}}}" version "{{{app_version}}}" + resource "modulo" do + url "https://github.com/federico-terzi/modulo/releases/download/v{{{modulo_version}}}/modulo-mac" + sha256 "{{{modulo_sha}}}" + end + def install bin.install "espanso" - end + + resource("modulo").stage { bin.install "modulo-mac" => "modulo" } + end end \ No newline at end of file diff --git a/snapcraft.yaml b/snapcraft.yaml index 4d783d1..11a2593 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -1,5 +1,5 @@ name: espanso -version: 0.6.3 +version: 0.7.0 summary: A Cross-platform Text Expander written in Rust description: | espanso is a Cross-platform, Text Expander written in Rust. diff --git a/src/bridge/macos.rs b/src/bridge/macos.rs index cb87675..c9f8c9f 100644 --- a/src/bridge/macos.rs +++ b/src/bridge/macos.rs @@ -63,4 +63,5 @@ extern "C" { pub fn delete_string(count: i32); pub fn trigger_paste(); pub fn trigger_copy(); + pub fn are_modifiers_pressed() -> i32; } diff --git a/src/bridge/windows.rs b/src/bridge/windows.rs index 35cec74..dd7ed32 100644 --- a/src/bridge/windows.rs +++ b/src/bridge/windows.rs @@ -33,6 +33,7 @@ extern "C" { pub fn initialize( s: *const c_void, ico_path: *const u16, + red_ico_path: *const u16, bmp_path: *const u16, show_icon: i32, ) -> i32; @@ -48,6 +49,7 @@ extern "C" { pub fn register_icon_click_callback(cb: extern "C" fn(_self: *mut c_void)); pub fn register_context_menu_click_callback(cb: extern "C" fn(_self: *mut c_void, id: i32)); pub fn cleanup_ui(); + pub fn update_tray_icon(enabled: i32); // CLIPBOARD pub fn get_clipboard(buffer: *mut u16, size: i32) -> i32; diff --git a/src/config/mod.rs b/src/config/mod.rs index 3e68ea1..13dea99 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -95,6 +95,9 @@ fn default_passive_arg_delimiter() -> char { fn default_passive_arg_escape() -> char { '\\' } +fn default_passive_delay() -> u64 { + 100 +} fn default_passive_key() -> KeyModifier { KeyModifier::OFF } @@ -131,6 +134,9 @@ fn default_show_notifications() -> bool { fn default_auto_restart() -> bool { true } +fn default_undo_backspace() -> bool { + true +} fn default_show_icon() -> bool { true } @@ -146,6 +152,12 @@ fn default_matches() -> Vec { fn default_global_vars() -> Vec { Vec::new() } +fn default_modulo_path() -> Option { + None +} +fn default_mac_post_inject_delay() -> u64 { + 100 +} #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Configs { @@ -206,12 +218,18 @@ pub struct Configs { #[serde(default = "default_passive_key")] pub passive_key: KeyModifier, + #[serde(default = "default_passive_delay")] + pub passive_delay: u64, + #[serde(default = "default_enable_passive")] pub enable_passive: bool, #[serde(default = "default_enable_active")] pub enable_active: bool, + #[serde(default = "default_undo_backspace")] + pub undo_backspace: bool, + #[serde(default)] pub paste_shortcut: PasteShortcut, @@ -227,6 +245,9 @@ pub struct Configs { #[serde(default = "default_secure_input_watcher_interval")] pub secure_input_watcher_interval: i32, + #[serde(default = "default_mac_post_inject_delay")] + pub mac_post_inject_delay: u64, + #[serde(default = "default_secure_input_notification")] pub secure_input_notification: bool, @@ -259,6 +280,9 @@ pub struct Configs { #[serde(default = "default_global_vars")] pub global_vars: Vec, + + #[serde(default = "default_modulo_path")] + pub modulo_path: Option, } // Macro used to validate config fields diff --git a/src/context/mod.rs b/src/context/mod.rs index 50805fb..ea656fe 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -48,6 +48,16 @@ pub fn new( macos::MacContext::new(config, send_channel, is_injecting) } +#[cfg(target_os = "macos")] +pub fn update_icon(enabled: bool) { + // TODO: add update icon on macOS +} + +#[cfg(target_os = "macos")] +pub fn get_icon_path() -> Option { + None +} + // LINUX IMPLEMENTATION #[cfg(target_os = "linux")] pub fn new( @@ -58,6 +68,16 @@ pub fn new( linux::LinuxContext::new(config, send_channel, is_injecting) } +#[cfg(target_os = "linux")] +pub fn update_icon(enabled: bool) { + // No icon on Linux +} + +#[cfg(target_os = "linux")] +pub fn get_icon_path() -> Option { + None +} + // WINDOWS IMPLEMENTATION #[cfg(target_os = "windows")] pub fn new( @@ -68,6 +88,16 @@ pub fn new( windows::WindowsContext::new(config, send_channel, is_injecting) } +#[cfg(target_os = "windows")] +pub fn update_icon(enabled: bool) { + windows::update_icon(enabled); +} + +#[cfg(target_os = "windows")] +pub fn get_icon_path() -> Option { + Some(windows::get_icon_path(&get_data_dir())) +} + // espanso directories static WARING_INIT: Once = Once::new(); diff --git a/src/context/windows.rs b/src/context/windows.rs index 07eeffa..6928850 100644 --- a/src/context/windows.rs +++ b/src/context/windows.rs @@ -28,10 +28,12 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering::Acquire; use std::sync::mpsc::Sender; use std::sync::Arc; +use std::path::{Path, PathBuf}; use widestring::{U16CStr, U16CString}; const BMP_BINARY: &[u8] = include_bytes!("../res/win/espanso.bmp"); const ICO_BINARY: &[u8] = include_bytes!("../res/win/espanso.ico"); +const RED_ICO_BINARY: &[u8] = include_bytes!("../res/win/espansored.ico"); pub struct WindowsContext { send_channel: Sender, @@ -65,7 +67,7 @@ impl WindowsContext { ); } - let espanso_ico_image = espanso_dir.join("espanso.ico"); + let espanso_ico_image = get_icon_path(&espanso_dir); if espanso_ico_image.exists() { info!("ICO already initialized, skipping."); } else { @@ -77,8 +79,22 @@ impl WindowsContext { ); } + let espanso_red_ico_image = espanso_dir.join("espansored.ico"); + if espanso_red_ico_image.exists() { + info!("red ICO already initialized, skipping."); + } else { + fs::write(&espanso_red_ico_image, RED_ICO_BINARY) + .expect("Unable to write windows ico file"); + + info!( + "Extracted 'red ico' icon to: {}", + espanso_red_ico_image.to_str().unwrap_or("error") + ); + } + let bmp_icon = espanso_bmp_image.to_str().unwrap_or_default(); let ico_icon = espanso_ico_image.to_str().unwrap_or_default(); + let red_ico_icon = espanso_red_ico_image.to_str().unwrap_or_default(); let send_channel = send_channel; @@ -96,6 +112,7 @@ impl WindowsContext { register_context_menu_click_callback(context_menu_click_callback); let ico_file_c = U16CString::from_str(ico_icon).unwrap(); + let red_ico_file_c = U16CString::from_str(red_ico_icon).unwrap(); let bmp_file_c = U16CString::from_str(bmp_icon).unwrap(); let show_icon = if config.show_icon { 1 } else { 0 }; @@ -104,6 +121,7 @@ impl WindowsContext { let res = initialize( context_ptr, ico_file_c.as_ptr(), + red_ico_file_c.as_ptr(), bmp_file_c.as_ptr(), show_icon, ); @@ -124,8 +142,18 @@ impl super::Context for WindowsContext { } } +pub fn get_icon_path(espanso_dir: &Path) -> PathBuf { + espanso_dir.join("espanso.ico") +} + // Native bridge code +pub fn update_icon(enabled: bool) { + unsafe { + crate::bridge::windows::update_tray_icon(if enabled { 1 } else { 0 }); + } +} + extern "C" fn keypress_callback( _self: *mut c_void, raw_buffer: *const u16, diff --git a/src/engine.rs b/src/engine.rs index f48c810..3e2a534 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -19,7 +19,7 @@ use crate::clipboard::ClipboardManager; use crate::config::BackendType; -use crate::config::ConfigManager; +use crate::config::{ConfigManager, Configs}; use crate::event::{ActionEventReceiver, ActionType, SystemEvent, SystemEventReceiver}; use crate::keyboard::KeyboardManager; use crate::matcher::{Match, MatchReceiver}; @@ -50,6 +50,8 @@ pub struct Engine< is_injecting: Arc, enabled: RefCell, + // Trigger string and injected text len pair + last_expansion_data: RefCell>, } impl< @@ -70,6 +72,7 @@ impl< is_injecting: Arc, ) -> Engine<'a, S, C, M, U, R> { let enabled = RefCell::new(true); + let last_expansion_data = RefCell::new(None); Engine { keyboard_manager, @@ -79,6 +82,7 @@ impl< renderer, is_injecting, enabled, + last_expansion_data, } } @@ -147,17 +151,61 @@ impl< } } + fn inject_text(&self, config: &Configs, target_string: &str, force_clipboard: bool) { + let backend = if force_clipboard { + &BackendType::Clipboard + } else if config.backend == BackendType::Auto { + if cfg!(target_os = "linux") { + let all_ascii = target_string.chars().all(|c| c.is_ascii()); + if all_ascii { + debug!("All elements of the replacement are ascii, using Inject backend"); + &BackendType::Inject + } else { + debug!("There are non-ascii characters, using Clipboard backend"); + &BackendType::Clipboard + } + } else { + &BackendType::Inject + } + } else { + &config.backend + }; + + match backend { + BackendType::Inject => { + // To handle newlines, substitute each "\n" char with an Enter key press. + let splits = target_string.split('\n'); + + for (i, split) in splits.enumerate() { + if i > 0 { + self.keyboard_manager.send_enter(&config); + } + + self.keyboard_manager.send_string(&config, split); + } + } + BackendType::Clipboard => { + self.clipboard_manager.set_clipboard(&target_string); + self.keyboard_manager.trigger_paste(&config); + } + _ => { + error!("Unsupported backend type evaluation."); + return; + } + } + } + fn inject_match( &self, m: &Match, trailing_separator: Option, trigger_offset: usize, skip_delete: bool, - ) { + ) -> Option<(String, i32)> { let config = self.config_manager.active_config(); if !config.enable_active { - return; + return None; } // Block espanso from reinterpreting its own actions @@ -179,6 +227,8 @@ impl< .renderer .render_match(m, trigger_offset, config, vec![]); + let mut expansion_data: Option<(String, i32)> = None; + match rendered { RenderResult::Text(mut target_string) => { // If a trailing separator was counted in the match, add it back to the target string @@ -213,53 +263,18 @@ impl< None }; - let backend = if m.force_clipboard { - &BackendType::Clipboard - } else if config.backend == BackendType::Auto { - if cfg!(target_os = "linux") { - let all_ascii = target_string.chars().all(|c| c.is_ascii()); - if all_ascii { - debug!( - "All elements of the replacement are ascii, using Inject backend" - ); - &BackendType::Inject - } else { - debug!("There are non-ascii characters, using Clipboard backend"); - &BackendType::Clipboard - } - } else { - &BackendType::Inject - } - } else { - &config.backend - }; + // If the preserve_clipboard option is enabled, save the current + // clipboard content to restore it later. + previous_clipboard_content = self.return_content_if_preserve_clipboard_is_enabled(); - match backend { - BackendType::Inject => { - // To handle newlines, substitute each "\n" char with an Enter key press. - let splits = target_string.split('\n'); + self.inject_text(&config, &target_string, m.force_clipboard); - for (i, split) in splits.enumerate() { - if i > 0 { - self.keyboard_manager.send_enter(&config); - } - - self.keyboard_manager.send_string(&config, split); - } - } - BackendType::Clipboard => { - // If the preserve_clipboard option is enabled, save the current - // clipboard content to restore it later. - previous_clipboard_content = - self.return_content_if_preserve_clipboard_is_enabled(); - - self.clipboard_manager.set_clipboard(&target_string); - self.keyboard_manager.trigger_paste(&config); - } - _ => { - error!("Unsupported backend type evaluation."); - return; - } + // Disallow undo backspace if cursor positioning is used + if cursor_rewind.is_none() { + expansion_data = Some(( + m.triggers[trigger_offset].clone(), + target_string.chars().count() as i32, + )); } if let Some(moves) = cursor_rewind { @@ -292,8 +307,19 @@ impl< .set_clipboard(&previous_clipboard_content); } + // On macOS, because the keyinjection is async, we need to wait a bit before + // giving back the control. Otherwise, the injected actions will be handled back + // by espanso itself. + if cfg!(target_os = "macos") { + std::thread::sleep(std::time::Duration::from_millis( + config.mac_post_inject_delay, + )); + } + // Re-allow espanso to interpret actions self.is_injecting.store(false, Release); + + expansion_data } } @@ -311,7 +337,27 @@ impl< > MatchReceiver for Engine<'a, S, C, M, U, R> { fn on_match(&self, m: &Match, trailing_separator: Option, trigger_offset: usize) { - self.inject_match(m, trailing_separator, trigger_offset, false); + let expansion_data = self.inject_match(m, trailing_separator, trigger_offset, false); + let mut last_expansion_data = self.last_expansion_data.borrow_mut(); + (*last_expansion_data) = expansion_data; + } + + fn on_undo(&self) { + let config = self.config_manager.active_config(); + + if !config.undo_backspace { + return; + } + + let last_expansion_data = self.last_expansion_data.borrow(); + if let Some(ref last_expansion_data) = *last_expansion_data { + let (trigger_string, injected_text_len) = last_expansion_data; + // Delete the previously injected text, minus one character as it has been consumed by the backspace + self.keyboard_manager + .delete_string(&config, *injected_text_len - 1); + // Restore previous text + self.inject_text(&config, trigger_string, false); + } } fn on_enable_update(&self, status: bool) { @@ -331,6 +377,9 @@ impl< if config.show_notifications { self.ui_manager.notify(message); } + + // Update the icon on supported OSes. + crate::context::update_icon(status); } fn on_passive(&self) { @@ -346,16 +395,22 @@ impl< // In order to avoid pasting previous clipboard contents, we need to check if // a new clipboard was effectively copied. // See issue: https://github.com/federico-terzi/espanso/issues/213 - let previous_clipboard = self.clipboard_manager.get_clipboard(); + let previous_clipboard = self.clipboard_manager.get_clipboard().unwrap_or_default(); // Sleep for a while, giving time to effectively copy the text - std::thread::sleep(std::time::Duration::from_millis(100)); // TODO: avoid hardcoding + std::thread::sleep(std::time::Duration::from_millis(config.passive_delay)); + + // Clear the clipboard, for new-content detection later + self.clipboard_manager.set_clipboard(""); + + // Sleep for a while, giving time to effectively copy the text + std::thread::sleep(std::time::Duration::from_millis(config.passive_delay)); // Trigger a copy shortcut to transfer the content of the selection to the clipboard self.keyboard_manager.trigger_copy(&config); // Sleep for a while, giving time to effectively copy the text - std::thread::sleep(std::time::Duration::from_millis(100)); // TODO: avoid hardcoding + std::thread::sleep(std::time::Duration::from_millis(config.passive_delay)); // Then get the text from the clipboard and render the match output let clipboard = self.clipboard_manager.get_clipboard(); @@ -365,30 +420,31 @@ impl< if clipboard.trim().is_empty() { info!("Avoiding passive expansion, as the user didn't select anything"); } else { - if let Some(previous_content) = previous_clipboard { - // Because of issue #213, we need to make sure the user selected something. - if clipboard == previous_content { - info!("Avoiding passive expansion, as the user didn't select anything"); - } else { - info!("Passive mode activated"); + info!("Passive mode activated"); - let rendered = self.renderer.render_passive(&clipboard, &config); + // Restore original clipboard in case it's used during render + self.clipboard_manager.set_clipboard(&previous_clipboard); - match rendered { - RenderResult::Text(payload) => { - // Paste back the result in the field - self.clipboard_manager.set_clipboard(&payload); + let rendered = self.renderer.render_passive(&clipboard, &config); - std::thread::sleep(std::time::Duration::from_millis(100)); // TODO: avoid hardcoding - self.keyboard_manager.trigger_paste(&config); - } - _ => warn!("Cannot expand passive match"), - } + match rendered { + RenderResult::Text(payload) => { + // Paste back the result in the field + self.clipboard_manager.set_clipboard(&payload); + + std::thread::sleep(std::time::Duration::from_millis(config.passive_delay)); + self.keyboard_manager.trigger_paste(&config); } + _ => warn!("Cannot expand passive match"), } } } + std::thread::sleep(std::time::Duration::from_millis(config.passive_delay)); + + // Restore original clipboard + self.clipboard_manager.set_clipboard(&previous_clipboard); + // Re-allow espanso to interpret actions self.is_injecting.store(false, Release); } diff --git a/src/extension/clipboard.rs b/src/extension/clipboard.rs index 60846ca..9b5477b 100644 --- a/src/extension/clipboard.rs +++ b/src/extension/clipboard.rs @@ -18,7 +18,9 @@ */ use crate::clipboard::ClipboardManager; +use crate::extension::ExtensionResult; use serde_yaml::Mapping; +use std::collections::HashMap; pub struct ClipboardExtension { clipboard_manager: Box, @@ -35,7 +37,16 @@ impl super::Extension for ClipboardExtension { String::from("clipboard") } - fn calculate(&self, _: &Mapping, _: &Vec) -> Option { - self.clipboard_manager.get_clipboard() + fn calculate( + &self, + _: &Mapping, + _: &Vec, + _: &HashMap, + ) -> Option { + if let Some(clipboard) = self.clipboard_manager.get_clipboard() { + Some(ExtensionResult::Single(clipboard)) + } else { + None + } } } diff --git a/src/extension/date.rs b/src/extension/date.rs index ac15295..2e4324b 100644 --- a/src/extension/date.rs +++ b/src/extension/date.rs @@ -1,7 +1,7 @@ /* * This file is part of espanso. * - * Copyright (C) 2019 Federico Terzi + * Copyright (C) 2019-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 @@ -17,8 +17,10 @@ * along with espanso. If not, see . */ -use chrono::{DateTime, Local}; +use crate::extension::ExtensionResult; +use chrono::{DateTime, Duration, Local}; use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; pub struct DateExtension {} @@ -33,8 +35,21 @@ impl super::Extension for DateExtension { String::from("date") } - fn calculate(&self, params: &Mapping, _: &Vec) -> Option { - let now: DateTime = Local::now(); + fn calculate( + &self, + params: &Mapping, + _: &Vec, + _: &HashMap, + ) -> Option { + let mut now: DateTime = Local::now(); + + // Compute the given offset + let offset = params.get(&Value::from("offset")); + if let Some(offset) = offset { + let seconds = offset.as_i64().unwrap_or_else(|| 0); + let offset = Duration::seconds(seconds); + now = now + offset; + } let format = params.get(&Value::from("format")); @@ -44,6 +59,6 @@ impl super::Extension for DateExtension { now.to_rfc2822() }; - Some(date) + Some(ExtensionResult::Single(date)) } } diff --git a/src/extension/dummy.rs b/src/extension/dummy.rs index 6bbb1dd..e810823 100644 --- a/src/extension/dummy.rs +++ b/src/extension/dummy.rs @@ -17,26 +17,39 @@ * along with espanso. If not, see . */ +use crate::extension::ExtensionResult; use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; -pub struct DummyExtension {} +pub struct DummyExtension { + name: String, +} impl DummyExtension { - pub fn new() -> DummyExtension { - DummyExtension {} + pub fn new(name: &str) -> DummyExtension { + DummyExtension { + name: name.to_owned(), + } } } impl super::Extension for DummyExtension { fn name(&self) -> String { - String::from("dummy") + self.name.clone() } - fn calculate(&self, params: &Mapping, _: &Vec) -> Option { + fn calculate( + &self, + params: &Mapping, + _: &Vec, + _: &HashMap, + ) -> Option { let echo = params.get(&Value::from("echo")); if let Some(echo) = echo { - Some(echo.as_str().unwrap_or_default().to_owned()) + Some(ExtensionResult::Single( + echo.as_str().unwrap_or_default().to_owned(), + )) } else { None } diff --git a/src/extension/form.rs b/src/extension/form.rs new file mode 100644 index 0000000..79133f6 --- /dev/null +++ b/src/extension/form.rs @@ -0,0 +1,106 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2020 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use crate::{config::Configs, extension::ExtensionResult, ui::modulo::ModuloManager}; +use log::{error, warn}; +use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; + +pub struct FormExtension { + manager: ModuloManager, +} + +impl FormExtension { + pub fn new(config: &Configs) -> FormExtension { + let manager = ModuloManager::new(config); + FormExtension { manager } + } +} + +impl super::Extension for FormExtension { + fn name(&self) -> String { + "form".to_owned() + } + + fn calculate( + &self, + params: &Mapping, + _: &Vec, + _: &HashMap, + ) -> Option { + let layout = params.get(&Value::from("layout")); + let layout = if let Some(value) = layout { + value.as_str().unwrap_or_default().to_string() + } else { + error!("invoking form extension without specifying a layout"); + return None; + }; + + let mut form_config = Mapping::new(); + form_config.insert(Value::from("title"), Value::from("espanso")); + form_config.insert(Value::from("layout"), Value::from(layout)); + + if let Some(fields) = params.get(&Value::from("fields")) { + form_config.insert(Value::from("fields"), fields.clone()); + } + + if let Some(icon_path) = crate::context::get_icon_path() { + form_config.insert(Value::from("icon"), Value::from(icon_path.to_string_lossy().to_string())); + } + + let serialized_config: String = + serde_yaml::to_string(&form_config).expect("unable to serialize form config"); + + let output = self + .manager + .invoke(&["form", "-i", "-"], &serialized_config); + + // On macOS, after the form closes we have to wait until the user releases the modifier keys + on_form_close(); + + if let Some(output) = output { + let json: Result, _> = serde_json::from_str(&output); + match json { + Ok(json) => { + return Some(ExtensionResult::Multiple(json)); + } + Err(error) => { + error!("modulo json parsing error: {}", error); + return None; + } + } + } else { + error!("modulo form didn't return any output"); + return None; + } + } +} + +#[cfg(not(target_os = "macos"))] +fn on_form_close() { + // NOOP on Windows and Linux +} + +#[cfg(target_os = "macos")] +fn on_form_close() { + let released = crate::keyboard::macos::wait_for_modifiers_release(); + if !released { + warn!("Wait for modifiers release timed out! Please after closing the form, release your modifiers keys (CTRL, CMD, ALT, SHIFT)"); + } +} diff --git a/src/extension/mod.rs b/src/extension/mod.rs index 9a363c5..7ea9546 100644 --- a/src/extension/mod.rs +++ b/src/extension/mod.rs @@ -17,28 +17,50 @@ * along with espanso. If not, see . */ -use crate::clipboard::ClipboardManager; +use crate::{clipboard::ClipboardManager, config::Configs}; use serde_yaml::Mapping; +use std::collections::HashMap; mod clipboard; mod date; pub mod dummy; +mod form; +pub mod multiecho; mod random; mod script; mod shell; +mod utils; +pub mod vardummy; + +#[derive(Clone, Debug, PartialEq)] +pub enum ExtensionResult { + Single(String), + Multiple(HashMap), +} pub trait Extension { fn name(&self) -> String; - fn calculate(&self, params: &Mapping, args: &Vec) -> Option; + fn calculate( + &self, + params: &Mapping, + args: &Vec, + current_vars: &HashMap, + ) -> Option; } -pub fn get_extensions(clipboard_manager: Box) -> Vec> { +pub fn get_extensions( + config: &Configs, + clipboard_manager: Box, +) -> Vec> { 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(multiecho::MultiEchoExtension::new()), + Box::new(dummy::DummyExtension::new("dummy")), + Box::new(dummy::DummyExtension::new("echo")), Box::new(clipboard::ClipboardExtension::new(clipboard_manager)), + Box::new(form::FormExtension::new(config)), ] } diff --git a/src/extension/multiecho.rs b/src/extension/multiecho.rs new file mode 100644 index 0000000..e48eaf8 --- /dev/null +++ b/src/extension/multiecho.rs @@ -0,0 +1,53 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2020 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use crate::extension::ExtensionResult; +use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; + +pub struct MultiEchoExtension {} + +impl MultiEchoExtension { + pub fn new() -> MultiEchoExtension { + MultiEchoExtension {} + } +} + +impl super::Extension for MultiEchoExtension { + fn name(&self) -> String { + "multiecho".to_owned() + } + + fn calculate( + &self, + params: &Mapping, + _: &Vec, + _: &HashMap, + ) -> Option { + let mut output: HashMap = HashMap::new(); + for (key, value) in params.iter() { + if let Some(key) = key.as_str() { + if let Some(value) = value.as_str() { + output.insert(key.to_owned(), value.to_owned()); + } + } + } + Some(ExtensionResult::Multiple(output)) + } +} diff --git a/src/extension/random.rs b/src/extension/random.rs index 5d168c9..3a13d3f 100644 --- a/src/extension/random.rs +++ b/src/extension/random.rs @@ -17,9 +17,11 @@ * along with espanso. If not, see . */ +use crate::extension::ExtensionResult; use log::{error, warn}; use rand::seq::SliceRandom; use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; pub struct RandomExtension {} @@ -34,7 +36,12 @@ impl super::Extension for RandomExtension { String::from("random") } - fn calculate(&self, params: &Mapping, args: &Vec) -> Option { + fn calculate( + &self, + params: &Mapping, + args: &Vec, + _: &HashMap, + ) -> Option { let choices = params.get(&Value::from("choices")); if choices.is_none() { warn!("No 'choices' parameter specified for random variable"); @@ -55,7 +62,7 @@ impl super::Extension for RandomExtension { // Render arguments let output = crate::render::utils::render_args(output, args); - return Some(output); + return Some(ExtensionResult::Single(output)); } None => { error!("Could not select a random choice."); @@ -81,13 +88,15 @@ mod tests { params.insert(Value::from("choices"), Value::from(choices.clone())); let extension = RandomExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); let output = output.unwrap(); - assert!(choices.iter().any(|x| x == &output)); + assert!(choices + .into_iter() + .any(|x| ExtensionResult::Single(x.to_owned()) == output)); } #[test] @@ -97,7 +106,7 @@ mod tests { params.insert(Value::from("choices"), Value::from(choices.clone())); let extension = RandomExtension::new(); - let output = extension.calculate(¶ms, &vec!["test".to_owned()]); + let output = extension.calculate(¶ms, &vec!["test".to_owned()], &HashMap::new()); assert!(output.is_some()); @@ -105,6 +114,8 @@ mod tests { let rendered_choices = vec!["first test", "second test", "test third"]; - assert!(rendered_choices.iter().any(|x| x == &output)); + assert!(rendered_choices + .into_iter() + .any(|x| ExtensionResult::Single(x.to_owned()) == output)); } } diff --git a/src/extension/script.rs b/src/extension/script.rs index 6144854..3a68a10 100644 --- a/src/extension/script.rs +++ b/src/extension/script.rs @@ -17,8 +17,10 @@ * along with espanso. If not, see . */ +use crate::extension::ExtensionResult; use log::{error, warn}; use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; @@ -35,7 +37,12 @@ impl super::Extension for ScriptExtension { String::from("script") } - fn calculate(&self, params: &Mapping, user_args: &Vec) -> Option { + fn calculate( + &self, + params: &Mapping, + user_args: &Vec, + vars: &HashMap, + ) -> Option { let args = params.get(&Value::from("args")); if args.is_none() { warn!("No 'args' parameter specified for script variable"); @@ -60,11 +67,28 @@ impl super::Extension for ScriptExtension { // Replace %HOME% with current user home directory to // create cross-platform paths. See issue #265 + // Also replace %CONFIG% and %PACKAGES% path. See issue #380 let home_dir = dirs::home_dir().unwrap_or_default(); str_args.iter_mut().for_each(|arg| { if arg.contains("%HOME%") { *arg = arg.replace("%HOME%", &home_dir.to_string_lossy().to_string()); } + if arg.contains("%CONFIG%") { + *arg = arg.replace( + "%CONFIG%", + &crate::context::get_config_dir() + .to_string_lossy() + .to_string(), + ); + } + if arg.contains("%PACKAGES%") { + *arg = arg.replace( + "%PACKAGES%", + &crate::context::get_package_dir() + .to_string_lossy() + .to_string(), + ); + } // On Windows, correct paths separators if cfg!(target_os = "windows") { @@ -77,9 +101,18 @@ impl super::Extension for ScriptExtension { let mut command = Command::new(&str_args[0]); + // Set the OS-specific flags + crate::utils::set_command_flags(&mut command); + // Inject the $CONFIG variable command.env("CONFIG", crate::context::get_config_dir()); + // Inject all the env variables + let env_variables = super::utils::convert_to_env_variables(&vars); + for (key, value) in env_variables.iter() { + command.env(key, value); + } + let output = if str_args.len() > 1 { command.args(&str_args[1..]).output() } else { @@ -112,7 +145,7 @@ impl super::Extension for ScriptExtension { output_str = output_str.trim().to_owned() } - return Some(output_str); + return Some(ExtensionResult::Single(output_str)); } Err(e) => { error!("Could not execute script '{:?}', error: {}", args, e); @@ -141,10 +174,13 @@ mod tests { ); let extension = ScriptExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world".to_owned()) + ); } #[test] @@ -158,10 +194,13 @@ mod tests { params.insert(Value::from("trim"), Value::from(false)); let extension = ScriptExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world\n"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world\n".to_owned()) + ); } #[test] @@ -174,10 +213,13 @@ mod tests { ); let extension = ScriptExtension::new(); - let output = extension.calculate(¶ms, &vec!["jon".to_owned()]); + let output = extension.calculate(¶ms, &vec!["jon".to_owned()], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world".to_owned()) + ); } #[test] @@ -191,9 +233,40 @@ mod tests { params.insert(Value::from("inject_args"), Value::from(true)); let extension = ScriptExtension::new(); - let output = extension.calculate(¶ms, &vec!["jon".to_owned()]); + let output = extension.calculate(¶ms, &vec!["jon".to_owned()], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world jon"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world jon".to_owned()) + ); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_script_var_injection() { + let mut params = Mapping::new(); + params.insert( + Value::from("args"), + Value::from(vec!["bash", "-c", "echo $ESPANSO_VAR1 $ESPANSO_FORM1_NAME"]), + ); + + let mut vars: HashMap = HashMap::new(); + let mut subvars = HashMap::new(); + subvars.insert("name".to_owned(), "John".to_owned()); + vars.insert("form1".to_owned(), ExtensionResult::Multiple(subvars)); + vars.insert( + "var1".to_owned(), + ExtensionResult::Single("hello".to_owned()), + ); + + let extension = ScriptExtension::new(); + let output = extension.calculate(¶ms, &vec![], &vars); + + assert!(output.is_some()); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello John".to_owned()) + ); } } diff --git a/src/extension/shell.rs b/src/extension/shell.rs index f1b9da4..fd0f43a 100644 --- a/src/extension/shell.rs +++ b/src/extension/shell.rs @@ -17,9 +17,11 @@ * along with espanso. If not, see . */ -use log::{error, warn}; +use crate::extension::ExtensionResult; +use log::{error, info, warn}; use regex::{Captures, Regex}; use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; use std::process::{Command, Output}; lazy_static! { @@ -40,7 +42,9 @@ pub enum Shell { } impl Shell { - fn execute_cmd(&self, cmd: &str) -> std::io::Result { + fn execute_cmd(&self, cmd: &str, vars: &HashMap) -> std::io::Result { + let mut is_wsl = false; + let mut command = match self { Shell::Cmd => { let mut command = Command::new("cmd"); @@ -53,11 +57,13 @@ impl Shell { command } Shell::WSL => { + is_wsl = true; let mut command = Command::new("bash"); command.args(&["-c", &cmd]); command } Shell::WSL2 => { + is_wsl = true; let mut command = Command::new("wsl"); command.args(&["bash", "-c", &cmd]); command @@ -74,9 +80,33 @@ impl Shell { } }; + // Set the OS-specific flags + crate::utils::set_command_flags(&mut command); + // Inject the $CONFIG variable command.env("CONFIG", crate::context::get_config_dir()); + // Inject all the previous variables + for (key, value) in vars.iter() { + command.env(key, value); + } + + // In WSL environment, we have to specify which ENV variables + // should be passed to linux. + // For more information: https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/ + if is_wsl { + let mut tokens: Vec<&str> = Vec::new(); + tokens.push("CONFIG/p"); + + // Add all the previous variables + for (key, _) in vars.iter() { + tokens.push(key); + } + + let wsl_env = tokens.join(":"); + command.env("WSLENV", wsl_env); + } + command.output() } @@ -120,17 +150,23 @@ impl super::Extension for ShellExtension { String::from("shell") } - fn calculate(&self, params: &Mapping, args: &Vec) -> Option { + fn calculate( + &self, + params: &Mapping, + args: &Vec, + vars: &HashMap, + ) -> Option { let cmd = params.get(&Value::from("cmd")); if cmd.is_none() { warn!("No 'cmd' parameter specified for shell variable"); return None; } - let cmd = cmd.unwrap().as_str().unwrap(); + + let original_cmd = cmd.unwrap().as_str().unwrap(); // Render positional parameters in args let cmd = POS_ARG_REGEX - .replace_all(&cmd, |caps: &Captures| { + .replace_all(&original_cmd, |caps: &Captures| { let position_str = caps.name("pos").unwrap().as_str(); let position = position_str.parse::().unwrap_or(-1); if position >= 0 && position < args.len() as i32 { @@ -156,7 +192,9 @@ impl super::Extension for ShellExtension { Shell::default() }; - let output = shell.execute_cmd(&cmd); + let env_variables = super::utils::convert_to_env_variables(&vars); + + let output = shell.execute_cmd(&cmd, &env_variables); match output { Ok(output) => { @@ -171,6 +209,22 @@ impl super::Extension for ShellExtension { warn!("Shell command reported error: \n{}", error_str); } + // Check if debug flag set, provide additional context when an error occurs. + let debug_opt = params.get(&Value::from("debug")); + let with_debug = if let Some(value) = debug_opt { + let val = value.as_bool(); + val.unwrap_or(false) + } else { + false + }; + + if with_debug { + info!( + "debug for shell cmd '{}', exit_status '{}', stdout '{}', stderr '{}'", + original_cmd, output.status, output_str, error_str + ); + } + // If specified, trim the output let trim_opt = params.get(&Value::from("trim")); let should_trim = if let Some(value) = trim_opt { @@ -184,7 +238,7 @@ impl super::Extension for ShellExtension { output_str = output_str.trim().to_owned() } - Some(output_str) + Some(ExtensionResult::Single(output_str)) } Err(e) => { error!("Could not execute cmd '{}', error: {}", cmd, e); @@ -206,14 +260,20 @@ mod tests { params.insert(Value::from("trim"), Value::from(false)); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); if cfg!(target_os = "windows") { - assert_eq!(output.unwrap(), "hello world\r\n"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world\r\n".to_owned()) + ); } else { - assert_eq!(output.unwrap(), "hello world\n"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world\n".to_owned()) + ); } } @@ -223,10 +283,13 @@ mod tests { params.insert(Value::from("cmd"), Value::from("echo \"hello world\"")); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world".to_owned()) + ); } #[test] @@ -238,10 +301,13 @@ mod tests { ); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world".to_owned()) + ); } #[test] @@ -251,10 +317,13 @@ mod tests { params.insert(Value::from("trim"), Value::from("error")); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world".to_owned()) + ); } #[test] @@ -265,10 +334,13 @@ mod tests { params.insert(Value::from("trim"), Value::from(true)); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms, &vec![]); + let output = extension.calculate(¶ms, &vec![], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello world"); + assert_eq!( + output.unwrap(), + ExtensionResult::Single("hello world".to_owned()) + ); } #[test] @@ -278,11 +350,11 @@ mod tests { params.insert(Value::from("cmd"), Value::from("echo $0")); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms, &vec!["hello".to_owned()]); + let output = extension.calculate(¶ms, &vec!["hello".to_owned()], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello"); + assert_eq!(output.unwrap(), ExtensionResult::Single("hello".to_owned())); } #[test] @@ -292,10 +364,53 @@ mod tests { params.insert(Value::from("cmd"), Value::from("echo %0")); let extension = ShellExtension::new(); - let output = extension.calculate(¶ms, &vec!["hello".to_owned()]); + let output = extension.calculate(¶ms, &vec!["hello".to_owned()], &HashMap::new()); assert!(output.is_some()); - assert_eq!(output.unwrap(), "hello"); + assert_eq!(output.unwrap(), ExtensionResult::Single("hello".to_owned())); + } + + #[test] + fn test_shell_vars_single_injection() { + let mut params = Mapping::new(); + if cfg!(target_os = "windows") { + params.insert(Value::from("cmd"), Value::from("echo %ESPANSO_VAR1%")); + params.insert(Value::from("shell"), Value::from("cmd")); + } else { + params.insert(Value::from("cmd"), Value::from("echo $ESPANSO_VAR1")); + } + + let extension = ShellExtension::new(); + let mut vars: HashMap = HashMap::new(); + vars.insert( + "var1".to_owned(), + ExtensionResult::Single("hello".to_owned()), + ); + let output = extension.calculate(¶ms, &vec![], &vars); + + assert!(output.is_some()); + assert_eq!(output.unwrap(), ExtensionResult::Single("hello".to_owned())); + } + + #[test] + fn test_shell_vars_multiple_injection() { + let mut params = Mapping::new(); + if cfg!(target_os = "windows") { + params.insert(Value::from("cmd"), Value::from("echo %ESPANSO_FORM1_NAME%")); + params.insert(Value::from("shell"), Value::from("cmd")); + } else { + params.insert(Value::from("cmd"), Value::from("echo $ESPANSO_FORM1_NAME")); + } + + let extension = ShellExtension::new(); + let mut vars: HashMap = HashMap::new(); + let mut subvars = HashMap::new(); + subvars.insert("name".to_owned(), "John".to_owned()); + vars.insert("form1".to_owned(), ExtensionResult::Multiple(subvars)); + let output = extension.calculate(¶ms, &vec![], &vars); + + assert!(output.is_some()); + assert_eq!(output.unwrap(), ExtensionResult::Single("John".to_owned())); } } diff --git a/src/extension/utils.rs b/src/extension/utils.rs new file mode 100644 index 0000000..fcf5bac --- /dev/null +++ b/src/extension/utils.rs @@ -0,0 +1,49 @@ +use crate::extension::ExtensionResult; +use std::collections::HashMap; + +pub fn convert_to_env_variables( + original_vars: &HashMap, +) -> HashMap { + let mut output = HashMap::new(); + + for (key, result) in original_vars.iter() { + match result { + ExtensionResult::Single(value) => { + let name = format!("ESPANSO_{}", key.to_uppercase()); + output.insert(name, value.clone()); + } + ExtensionResult::Multiple(values) => { + for (sub_key, sub_value) in values.iter() { + let name = format!("ESPANSO_{}_{}", key.to_uppercase(), sub_key.to_uppercase()); + output.insert(name, sub_value.clone()); + } + } + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extension::Extension; + + #[test] + fn test_convert_to_env_variables() { + let mut vars: HashMap = HashMap::new(); + let mut subvars = HashMap::new(); + subvars.insert("name".to_owned(), "John".to_owned()); + subvars.insert("lastname".to_owned(), "Snow".to_owned()); + vars.insert("form1".to_owned(), ExtensionResult::Multiple(subvars)); + vars.insert( + "var1".to_owned(), + ExtensionResult::Single("test".to_owned()), + ); + + let output = convert_to_env_variables(&vars); + assert_eq!(output.get("ESPANSO_FORM1_NAME").unwrap(), "John"); + assert_eq!(output.get("ESPANSO_FORM1_LASTNAME").unwrap(), "Snow"); + assert_eq!(output.get("ESPANSO_VAR1").unwrap(), "test"); + } +} diff --git a/src/extension/vardummy.rs b/src/extension/vardummy.rs new file mode 100644 index 0000000..4108ae1 --- /dev/null +++ b/src/extension/vardummy.rs @@ -0,0 +1,52 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2020 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use crate::extension::ExtensionResult; +use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; + +pub struct VarDummyExtension {} + +impl VarDummyExtension { + pub fn new() -> Self { + Self {} + } +} + +impl super::Extension for VarDummyExtension { + fn name(&self) -> String { + "vardummy".to_owned() + } + + fn calculate( + &self, + params: &Mapping, + _: &Vec, + vars: &HashMap, + ) -> Option { + let target = params.get(&Value::from("target")); + + if let Some(target) = target { + let value = vars.get(target.as_str().unwrap_or_default()); + Some(value.unwrap().clone()) + } else { + None + } + } +} diff --git a/src/keyboard/macos.rs b/src/keyboard/macos.rs index a5ea03e..b7d9699 100644 --- a/src/keyboard/macos.rs +++ b/src/keyboard/macos.rs @@ -75,3 +75,15 @@ impl super::KeyboardManager for MacKeyboardManager { } } } + +pub fn wait_for_modifiers_release() -> bool { + let start = std::time::SystemTime::now(); + while start.elapsed().unwrap_or_default().as_millis() < 3000 { + let pressed = unsafe { crate::bridge::macos::are_modifiers_pressed() }; + if pressed == 0 { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + false +} diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs index add9499..dece480 100644 --- a/src/keyboard/mod.rs +++ b/src/keyboard/mod.rs @@ -27,7 +27,7 @@ mod windows; mod linux; #[cfg(target_os = "macos")] -mod macos; +pub mod macos; pub trait KeyboardManager { fn send_string(&self, active_config: &Configs, s: &str); diff --git a/src/main.rs b/src/main.rs index 12f602d..bb9c0ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,8 @@ * along with espanso. If not, see . */ +#![cfg_attr(not(test), windows_subsystem = "windows")] + #[macro_use] extern crate lazy_static; @@ -76,6 +78,8 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); const LOG_FILE: &str = "espanso.log"; fn main() { + attach_console(); + let install_subcommand = SubCommand::with_name("install") .about("Install a package. Equivalent to 'espanso package install'") .arg( @@ -344,6 +348,18 @@ fn main() { println!(); } +#[cfg(target_os = "windows")] +fn attach_console() { + // When using the windows subsystem we loose the terminal output. + // Therefore we try to attach to the current console if available. + unsafe { winapi::um::wincon::AttachConsole(0xFFFFFFFF) }; +} + +#[cfg(not(target_os = "windows"))] +fn attach_console() { + // Not necessary on Linux and macOS +} + fn init_logger(config_set: &ConfigSet, reset: bool) { // Initialize log let log_level = match config_set.default.log_level { @@ -566,8 +582,14 @@ fn watcher_background(sender: Sender) { }; if let Some(path) = path { - if path.extension().unwrap_or_default() == "yml" { - // Only load yml files + if path.extension().unwrap_or_default() == "yml" + && !path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .starts_with(".") + { + // Only load non-hidden yml files true } else { false @@ -671,7 +693,10 @@ fn worker_background( let keyboard_manager = keyboard::get_manager(); - let extensions = extension::get_extensions(Box::new(clipboard::get_manager())); + let extensions = extension::get_extensions( + config_manager.default_config(), + Box::new(clipboard::get_manager()), + ); let renderer = render::default::DefaultRenderer::new(extensions, config_manager.default_config().clone()); diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 6914f97..4b64c4b 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -1,5 +1,5 @@ /* - * This file is part of espanso. + * This file is part of espans{ name: (), var_type: (), params: ()} * * Copyright (C) 2019 Federico Terzi * @@ -19,9 +19,10 @@ use crate::event::KeyEventReceiver; use crate::event::{KeyEvent, KeyModifier}; -use regex::Regex; +use regex::{Captures, Regex}; use serde::{Deserialize, Deserializer, Serialize}; -use serde_yaml::Mapping; +use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -47,7 +48,7 @@ pub enum MatchContentType { Image(ImageContent), } -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Clone, PartialEq)] pub struct TextContent { pub replace: String, pub vars: Vec, @@ -74,7 +75,8 @@ impl<'de> serde::Deserialize<'de> for Match { impl<'a> From<&'a AutoMatch> for Match { fn from(other: &'a AutoMatch) -> Self { lazy_static! { - static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)\\s*\\}\\}").unwrap(); + static ref VAR_REGEX: Regex = + Regex::new("\\{\\{\\s*(\\w+)(\\.\\w+)?\\s*\\}\\}").unwrap(); }; let mut triggers = if !other.triggers.is_empty() { @@ -143,6 +145,39 @@ impl<'a> From<&'a AutoMatch> for Match { _has_vars: has_vars, }; + MatchContentType::Text(content) + } else if let Some(form) = &other.form { + // Form shorthand + // Replace all the form fields with actual variables + let new_replace = VAR_REGEX.replace_all(&form, |caps: &Captures| { + let var_name = caps.get(1).unwrap().as_str(); + format!("{{{{form1.{}}}}}", var_name) + }); + let new_replace = new_replace.to_string(); + + // Convert the form data to valid variables + let mut params = Mapping::new(); + if let Some(fields) = &other.form_fields { + let mut mapping_fields = Mapping::new(); + fields.iter().for_each(|(key, value)| { + mapping_fields.insert(Value::from(key.to_owned()), Value::from(value.clone())); + }); + params.insert(Value::from("fields"), Value::from(mapping_fields)); + } + params.insert(Value::from("layout"), Value::from(form.to_owned())); + + let vars = vec![MatchVariable { + name: "form1".to_owned(), + var_type: "form".to_owned(), + params, + }]; + + let content = TextContent { + replace: new_replace, + vars, + _has_vars: true, + }; + MatchContentType::Text(content) } else if let Some(image_path) = &other.image_path { // Image match @@ -173,7 +208,7 @@ impl<'a> From<&'a AutoMatch> for Match { MatchContentType::Image(content) } else { - eprintln!("ERROR: no action specified for match {}, please specify either 'replace' or 'image_path'", other.trigger); + eprintln!("ERROR: no action specified for match {}, please specify either 'replace', 'image_path' or 'form'", other.trigger); std::process::exit(2); }; @@ -204,6 +239,12 @@ struct AutoMatch { #[serde(default = "default_image_path")] pub image_path: Option, + #[serde(default = "default_form")] + pub form: Option, + + #[serde(default = "default_form_fields")] + pub form_fields: Option>, + #[serde(default = "default_vars")] pub vars: Vec, @@ -238,6 +279,12 @@ fn default_passive_only() -> bool { fn default_replace() -> Option { None } +fn default_form() -> Option { + None +} +fn default_form_fields() -> Option> { + None +} fn default_image_path() -> Option { None } @@ -248,7 +295,7 @@ fn default_force_clipboard() -> bool { false } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct MatchVariable { pub name: String, @@ -273,6 +320,7 @@ pub trait MatchReceiver { fn on_match(&self, m: &Match, trailing_separator: Option, trigger_offset: usize); fn on_enable_update(&self, status: bool); fn on_passive(&self); + fn on_undo(&self); } pub trait Matcher: KeyEventReceiver { @@ -543,4 +591,76 @@ mod tests { assert_eq!(_match.triggers, vec![":..", ":..", ":.."]) } + + #[test] + fn test_match_form_translated_correctly() { + let match_str = r###" + trigger: ":test" + form: "Hey {{name}}, how are you? {{greet}}" + "###; + + let _match: Match = serde_yaml::from_str(match_str).unwrap(); + match _match.content { + MatchContentType::Text(content) => { + let mut mapping = Mapping::new(); + mapping.insert( + Value::from("layout"), + Value::from("Hey {{name}}, how are you? {{greet}}"), + ); + assert_eq!( + content, + TextContent { + replace: "Hey {{form1.name}}, how are you? {{form1.greet}}".to_owned(), + _has_vars: true, + vars: vec![MatchVariable { + name: "form1".to_owned(), + var_type: "form".to_owned(), + params: mapping, + }] + } + ); + } + _ => panic!("wrong content"), + } + } + + #[test] + fn test_match_form_with_fields_translated_correctly() { + let match_str = r###" + trigger: ":test" + form: "Hey {{name}}, how are you? {{greet}}" + form_fields: + name: + multiline: true + "###; + + let _match: Match = serde_yaml::from_str(match_str).unwrap(); + match _match.content { + MatchContentType::Text(content) => { + let mut name_mapping = Mapping::new(); + name_mapping.insert(Value::from("multiline"), Value::Bool(true)); + let mut submapping = Mapping::new(); + submapping.insert(Value::from("name"), Value::from(name_mapping)); + let mut mapping = Mapping::new(); + mapping.insert(Value::from("fields"), Value::from(submapping)); + mapping.insert( + Value::from("layout"), + Value::from("Hey {{name}}, how are you? {{greet}}"), + ); + assert_eq!( + content, + TextContent { + replace: "Hey {{form1.name}}, how are you? {{form1.greet}}".to_owned(), + _has_vars: true, + vars: vec![MatchVariable { + name: "form1".to_owned(), + var_type: "form".to_owned(), + params: mapping, + }] + } + ); + } + _ => panic!("wrong content"), + } + } } diff --git a/src/matcher/scrolling.rs b/src/matcher/scrolling.rs index 0dd38a4..d78cb48 100644 --- a/src/matcher/scrolling.rs +++ b/src/matcher/scrolling.rs @@ -33,6 +33,7 @@ pub struct ScrollingMatcher<'a, R: MatchReceiver, M: ConfigManager<'a>> { passive_press_time: RefCell, is_enabled: RefCell, was_previous_char_word_separator: RefCell, + was_previous_char_a_match: RefCell, } #[derive(Clone)] @@ -57,6 +58,7 @@ impl<'a, R: MatchReceiver, M: ConfigManager<'a>> ScrollingMatcher<'a, R, M> { passive_press_time, is_enabled: RefCell::new(true), was_previous_char_word_separator: RefCell::new(true), + was_previous_char_a_match: RefCell::new(true), } } @@ -104,12 +106,8 @@ impl<'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMat .word_separators .contains(&c.chars().nth(0).unwrap_or_default()); - // Workaround needed on macos to consider espanso replacement key presses as separators. - if cfg!(target_os = "macos") { - if c.len() > 1 { - is_current_word_separator = true; - } - } + let mut was_previous_char_a_match = self.was_previous_char_a_match.borrow_mut(); + (*was_previous_char_a_match) = false; let mut was_previous_word_separator = self.was_previous_char_word_separator.borrow_mut(); @@ -192,9 +190,7 @@ impl<'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMat if let Some(entry) = found_entry { let mtc = entry._match; - if let Some(last) = current_set_queue.back_mut() { - last.clear(); - } + current_set_queue.clear(); let trailing_separator = if !mtc.word { // If it's not a word match, it cannot have a trailing separator @@ -216,12 +212,16 @@ impl<'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMat self.receiver .on_match(mtc, trailing_separator, entry.trigger_offset); + + (*was_previous_char_a_match) = true; } } fn handle_modifier(&self, m: KeyModifier) { let config = self.config_manager.default_config(); + let mut was_previous_char_a_match = self.was_previous_char_a_match.borrow_mut(); + // TODO: at the moment, activating the passive key triggers the toggle key // study a mechanism to avoid this problem @@ -253,8 +253,16 @@ impl<'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMat if m == BACKSPACE { let mut current_set_queue = self.current_set_queue.borrow_mut(); current_set_queue.pop_back(); + + if (*was_previous_char_a_match) { + current_set_queue.clear(); + self.receiver.on_undo(); + } } + // Disable the "backspace undo" feature + (*was_previous_char_a_match) = false; + // Consider modifiers as separators to improve word matches reliability if m != LEFT_SHIFT && m != RIGHT_SHIFT && m != CAPS_LOCK { let mut was_previous_char_word_separator = @@ -269,6 +277,10 @@ impl<'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMat let mut was_previous_char_word_separator = self.was_previous_char_word_separator.borrow_mut(); *was_previous_char_word_separator = true; + + // Disable the "backspace undo" feature + let mut was_previous_char_a_match = self.was_previous_char_a_match.borrow_mut(); + (*was_previous_char_a_match) = false; } } diff --git a/src/process.rs b/src/process.rs index 7a03222..660abb0 100644 --- a/src/process.rs +++ b/src/process.rs @@ -25,7 +25,7 @@ use std::process::{Child, Command, Stdio}; pub fn spawn_process(cmd: &str, args: &Vec) -> io::Result { use std::os::windows::process::CommandExt; Command::new(cmd) - .creation_flags(0x00000008) // Detached Process + .creation_flags(0x08000008) // Detached Process without window .args(args) .spawn() } diff --git a/src/render/default.rs b/src/render/default.rs index 6c9fcbc..40d1857 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -19,15 +19,16 @@ use super::*; use crate::config::Configs; -use crate::extension::Extension; -use crate::matcher::{Match, MatchContentType}; +use crate::extension::{Extension, ExtensionResult}; +use crate::matcher::{Match, MatchContentType, MatchVariable}; use log::{error, warn}; use regex::{Captures, Regex}; use serde_yaml::Value; use std::collections::{HashMap, HashSet}; lazy_static! { - static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(?P\\w+)\\s*\\}\\}").unwrap(); + static ref VAR_REGEX: Regex = + Regex::new(r"\{\{\s*((?P\w+)(\.(?P(\w+)))?)\s*\}\}").unwrap(); static ref UNKNOWN_VARIABLE: String = "".to_string(); } @@ -86,24 +87,55 @@ impl super::Renderer for DefaultRenderer { match &m.content { // Text Match MatchContentType::Text(content) => { - // Find all the variables that are required by the current match - let mut target_vars = HashSet::new(); + let target_string = if content._has_vars { + // Find all the variables that are required by the current match + let mut target_vars: HashSet = HashSet::new(); - for caps in VAR_REGEX.captures_iter(&content.replace) { - let var_name = caps.name("name").unwrap().as_str(); - target_vars.insert(var_name.to_owned()); - } + for caps in VAR_REGEX.captures_iter(&content.replace) { + let var_name = caps.name("name").unwrap().as_str(); + target_vars.insert(var_name.to_owned()); + } - let target_string = if target_vars.len() > 0 { - let mut output_map = HashMap::new(); + let match_variables: HashSet<&String> = + content.vars.iter().map(|var| &var.name).collect(); - // Cycle through both the local and global variables - for variable in config.global_vars.iter().chain(&content.vars) { - // Skip all non-required variables - if !target_vars.contains(&variable.name) { - continue; + // Find the global variables that are not specified in the var list + let mut missing_globals = Vec::new(); + let mut specified_globals: HashMap = HashMap::new(); + for global_var in config.global_vars.iter() { + if target_vars.contains(&global_var.name) { + if match_variables.contains(&global_var.name) { + specified_globals.insert(global_var.name.clone(), &global_var); + } else { + missing_globals.push(global_var); + } } + } + // Determine the variable evaluation order + let mut variables: Vec<&MatchVariable> = Vec::new(); + // First place the global that are not explicitly specified + variables.extend(missing_globals); + // Then the ones explicitly specified, in the given order + variables.extend(&content.vars); + + // Replace variable type "global" with the actual reference + let variables: Vec<&MatchVariable> = variables + .into_iter() + .map(|variable| { + if variable.var_type == "global" { + if let Some(actual_variable) = specified_globals.get(&variable.name) + { + return actual_variable.clone(); + } + } + variable + }) + .collect(); + + let mut output_map: HashMap = HashMap::new(); + + for variable in variables.into_iter() { // In case of variables of type match, we need to recursively call // the render function if variable.var_type == "match" { @@ -140,7 +172,7 @@ impl super::Renderer for DefaultRenderer { // Inner matches are only supported for text-expansions, warn the user otherwise match result { RenderResult::Text(inner_content) => { - output_map.insert(variable.name.clone(), inner_content); + output_map.insert(variable.name.clone(), ExtensionResult::Single(inner_content)); }, _ => { warn!("Inner matches must be of TEXT type. Mixing images is not supported yet.") @@ -150,11 +182,15 @@ impl super::Renderer for DefaultRenderer { // Normal extension variables let extension = self.extension_map.get(&variable.var_type); if let Some(extension) = extension { - let ext_out = extension.calculate(&variable.params, &args); + let ext_out = + extension.calculate(&variable.params, &args, &output_map); if let Some(output) = ext_out { output_map.insert(variable.name.clone(), output); } else { - output_map.insert(variable.name.clone(), "".to_owned()); + output_map.insert( + variable.name.clone(), + ExtensionResult::Single("".to_owned()), + ); warn!( "Could not generate output for variable: {}", variable.name @@ -172,8 +208,26 @@ impl super::Renderer for DefaultRenderer { // Replace the variables let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| { let var_name = caps.name("name").unwrap().as_str(); - let output = output_map.get(var_name); - output.unwrap_or(&UNKNOWN_VARIABLE) + let var_subname = caps.name("subname"); + match output_map.get(var_name) { + Some(result) => match result { + ExtensionResult::Single(output) => output, + ExtensionResult::Multiple(results) => match var_subname { + Some(var_subname) => { + let var_subname = var_subname.as_str(); + results.get(var_subname).unwrap_or(&UNKNOWN_VARIABLE) + } + None => { + error!( + "nested name missing from multi-value variable: {}", + var_name + ); + &UNKNOWN_VARIABLE + } + }, + }, + None => &UNKNOWN_VARIABLE, + } }); result.to_string() @@ -311,7 +365,11 @@ mod tests { fn get_renderer(config: Configs) -> DefaultRenderer { DefaultRenderer::new( - vec![Box::new(crate::extension::dummy::DummyExtension::new())], + vec![ + Box::new(crate::extension::dummy::DummyExtension::new("dummy")), + Box::new(crate::extension::vardummy::VarDummyExtension::new()), + Box::new(crate::extension::multiecho::MultiEchoExtension::new()), + ], config, ) } @@ -737,4 +795,82 @@ mod tests { verify_render(rendered, "RESULT"); } + + #[test] + fn test_render_variable_order() { + let config = get_config_for( + r###" + matches: + - trigger: 'test' + replace: "{{output}}" + vars: + - name: first + type: dummy + params: + echo: "hello" + - name: output + type: vardummy + params: + target: "first" + "###, + ); + + let renderer = get_renderer(config.clone()); + let m = config.matches[0].clone(); + let rendered = renderer.render_match(&m, 0, &config, vec![]); + verify_render(rendered, "hello"); + } + + #[test] + fn test_render_global_variable_order() { + let config = get_config_for( + r###" + global_vars: + - name: hello + type: dummy + params: + echo: "hello" + matches: + - trigger: 'test' + replace: "{{hello}} {{output}}" + vars: + - name: first + type: dummy + params: + echo: "world" + - name: output + type: vardummy + params: + target: "first" + - name: hello + type: global + "###, + ); + + let renderer = get_renderer(config.clone()); + let m = config.matches[0].clone(); + let rendered = renderer.render_match(&m, 0, &config, vec![]); + verify_render(rendered, "hello world"); + } + + #[test] + fn test_render_multiple_results() { + let config = get_config_for( + r###" + matches: + - trigger: 'test' + replace: "hello {{var1.name}}" + vars: + - name: var1 + type: multiecho + params: + name: "world" + "###, + ); + + let renderer = get_renderer(config.clone()); + let m = config.matches[0].clone(); + let rendered = renderer.render_match(&m, 0, &config, vec![]); + verify_render(rendered, "hello world"); + } } diff --git a/src/res/win/espansored.ico b/src/res/win/espansored.ico new file mode 100644 index 0000000..334bab0 Binary files /dev/null and b/src/res/win/espansored.ico differ diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1400b19..f25491c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -26,6 +26,8 @@ mod linux; #[cfg(target_os = "macos")] mod macos; +pub mod modulo; + pub trait UIManager { fn notify(&self, message: &str); fn notify_delay(&self, message: &str, duration: i32); diff --git a/src/ui/modulo/mod.rs b/src/ui/modulo/mod.rs new file mode 100644 index 0000000..008956d --- /dev/null +++ b/src/ui/modulo/mod.rs @@ -0,0 +1,122 @@ +use crate::config::Configs; +use log::{error, info}; +use std::io::{Error, Write}; +use std::process::{Child, Command, Output}; + +pub struct ModuloManager { + modulo_path: Option, +} + +impl ModuloManager { + pub fn new(config: &Configs) -> Self { + let mut modulo_path: Option = None; + // Check if the `MODULO_PATH` env variable is configured + if let Some(_modulo_path) = std::env::var_os("MODULO_PATH") { + modulo_path = Some(_modulo_path.to_string_lossy().to_string()) + } else if let Some(ref _modulo_path) = config.modulo_path { + // Check the configs + modulo_path = Some(_modulo_path.to_owned()); + } else { + // Check in the same directory of espanso + if let Ok(exe_path) = std::env::current_exe() { + if let Some(parent) = exe_path.parent() { + let possible_path = parent.join("modulo"); + let possible_path = possible_path.to_string_lossy().to_string(); + + if let Ok(output) = Command::new(&possible_path).arg("--version").output() { + if output.status.success() { + modulo_path = Some(possible_path); + } + } + } + } + + // Otherwise check if present in the PATH + if modulo_path.is_none() { + if let Ok(output) = Command::new("modulo").arg("--version").output() { + if output.status.success() { + modulo_path = Some("modulo".to_owned()); + } + } + } + } + + if let Some(ref modulo_path) = modulo_path { + info!("Using modulo at {:?}", modulo_path); + } + + Self { modulo_path } + } + + pub fn is_valid(&self) -> bool { + self.modulo_path.is_some() + } + + pub fn get_version(&self) -> Option { + if let Some(ref modulo_path) = self.modulo_path { + if let Ok(output) = Command::new(modulo_path).arg("--version").output() { + let version = String::from_utf8_lossy(&output.stdout); + return Some(version.to_string()); + } + } + + None + } + + pub fn invoke(&self, args: &[&str], body: &str) -> Option { + if self.modulo_path.is_none() { + error!("Attempt to invoke modulo even though it's not configured"); + return None; + } + + if let Some(ref modulo_path) = self.modulo_path { + let mut command = Command::new(modulo_path); + command.args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + crate::utils::set_command_flags(&mut command); + + let child = command.spawn(); + + match child { + Ok(mut child) => { + if let Some(stdin) = child.stdin.as_mut() { + match stdin.write_all(body.as_bytes()) { + Ok(_) => { + // Get the output + match child.wait_with_output() { + Ok(child_output) => { + let output = String::from_utf8_lossy(&child_output.stdout); + + // Check also if the program reports an error + let error = String::from_utf8_lossy(&child_output.stderr); + if !error.is_empty() { + error!("modulo reported an error: {}", error); + } + + return Some(output.to_string()); + } + Err(error) => { + error!("error while getting output from modulo: {}", error); + } + } + } + Err(error) => { + error!("error while sending body to modulo: {}", error); + } + } + } else { + error!("unable to open stdin to modulo"); + } + } + Err(error) => { + error!("error reported when invoking modulo: {}", error); + } + } + } + + None + } +} diff --git a/src/utils.rs b/src/utils.rs index e619c8c..bea9fe2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -20,6 +20,7 @@ use std::error::Error; use std::fs::create_dir; use std::path::Path; +use std::process::Command; pub fn copy_dir(source_dir: &Path, dest_dir: &Path) -> Result<(), Box> { for entry in std::fs::read_dir(source_dir)? { @@ -40,6 +41,19 @@ pub fn copy_dir(source_dir: &Path, dest_dir: &Path) -> Result<(), Box Ok(()) } +#[cfg(target_os = "windows")] +pub fn set_command_flags(command: &mut Command) { + use std::os::windows::process::CommandExt; + // Avoid showing the shell window + // See: https://github.com/federico-terzi/espanso/issues/249 + command.creation_flags(0x08000000); +} + +#[cfg(not(target_os = "windows"))] +pub fn set_command_flags(command: &mut Command) { + // NOOP on Linux and macOS +} + #[cfg(test)] mod tests { use super::*;