Repository: espanso/espanso (language: rust)

Issue:
clipboard contents getting overwritten during passive match (xclip)
Arch Linux, espanso 0.6.3, GNU `sed`

The goal is to indent the clipboard contents, line by line, with a given number of spaces. I have a similar active match that always does 4 spaces, but want to add one that prepends an arbitrary number of spaces, and the best I could think of so far was cramming a looped `printf` in there.

```yaml
backend: Clipboard
fast_inject: false
enable_passive: true
passive_key: CTRL

matches:

  - trigger: :in
    replace: "{{indented}}"
    passive_only: true
    vars:
      - name: indented
        type: shell
        params:
          cmd: sed "s/^/$(for i in {1..$0}; do printf "%s" " "; done)/g" <<<"$(xclip -sel clip -o)"
          trim: false
```

```console
$ printf '%s\n' line1 line2 | xclip -sel clip
$ xclip -sel clip -o
line1
line2
```

Before and after:
```
:in/2/

  :in/2/

```

```console
$ xclip -sel clip -o
  :in/2/
```

I don't have any syncing going on between `XA_PRIMARY` and `XA_CLIPBOARD`; without running espanso, I can select text after text, but the output of `xclip -sel clip -o` will not change. It seems during a passive replacement, espanso writes over the contents of `XA_CLIPBOARD`.
Red systray icon when disabled
When espanso is displayed in the systray it shows its green logo independend of being enabled. (this is also true for the notifications)

I think the logo should be red or grayed out when epanso is disabled to clearly distinguish active from passive on the first glance.

![image](https://user-images.githubusercontent.com/2736207/82790162-ea6e2e00-9e6b-11ea-838c-9864f202716b.png)
![image](https://user-images.githubusercontent.com/2736207/82790469-649eb280-9e6c-11ea-8afe-9e0ef74269d0.png)



Discussion / hints:

Hey @stheid 

This is indeed a great idea, thank you for the suggestion! I'll add it to the roadmap :)


Fix patch:
diff --git a/.gitignore b/.gitignore
index f4ff355d..3619f48f 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 9a891de8..08ff7f96 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 6e499752..10ea57fb 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 <federicoterzi96@gmail.com>"]
 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 4a0f1d81..169c3cf2 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 1ddd5498..bafe1ae3 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 01024c1e..55d9567d 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 f0ef9eb4..7db802a5 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 1f2c3719..6a944a1a 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")
+
+    print("Including modulo")
+    include_list.append("Source: \""+os.path.abspath(modulo_target_file)+"\"; DestDir: \"{app}\"; Flags: ignoreversion")
 
-    dll_include = "\r\n".join(dll_include_list)
+    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 6e7f477e..399f151b 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 4d783d18..11a25935 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 cb87675f..c9f8c9fa 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 35cec740..dd7ed324 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 3e68ea16..13dea998 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<Match> {
 fn default_global_vars() -> Vec<MatchVariable> {
     Vec::new()
 }
+fn default_modulo_path() -> Option<String> {
+    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<MatchVariable>,
+
+    #[serde(default = "default_modulo_path")]
+    pub modulo_path: Option<String>,
 }
 
 // Macro used to validate config fields
diff --git a/src/context/mod.rs b/src/context/mod.rs
index 50805fb4..ea656feb 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<PathBuf> {
+    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<PathBuf> {
+    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<PathBuf> {
+    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 07eeffa5..69288509 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<Event>,
@@ -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 f48c8100..3e2a5341 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<AtomicBool>,
 
     enabled: RefCell<bool>,
+    // Trigger string and injected text len pair
+    last_expansion_data: RefCell<Option<(String, i32)>>,
 }
 
 impl<
@@ -70,6 +72,7 @@ impl<
         is_injecting: Arc<AtomicBool>,
     ) -> 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<char>,
         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
-                };
-
-                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);
-                            }
+                // 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.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.inject_text(&config, &target_string, m.force_clipboard);
 
-                        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<char>, 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(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(100)); // TODO: avoid hardcoding
+        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");
+
+                // Restore original clipboard in case it's used during render
+                self.clipboard_manager.set_clipboard(&previous_clipboard);
 
-                        let rendered = self.renderer.render_passive(&clipboard, &config);
+                let rendered = self.renderer.render_passive(&clipboard, &config);
 
-                        match rendered {
-                            RenderResult::Text(payload) => {
-                                // Paste back the result in the field
-                                self.clipboard_manager.set_clipboard(&payload);
+                match rendered {
+                    RenderResult::Text(payload) => {
+                        // Paste back the result in the field
+                        self.clipboard_manager.set_clipboard(&payload);
 
-                                std::thread::sleep(std::time::Duration::from_millis(100)); // TODO: avoid hardcoding
-                                self.keyboard_manager.trigger_paste(&config);
-                            }
-                            _ => warn!("Cannot expand passive match"),
-                        }
+                        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 60846ca8..9b5477be 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<dyn ClipboardManager>,
@@ -35,7 +37,16 @@ impl super::Extension for ClipboardExtension {
         String::from("clipboard")
     }
 
-    fn calculate(&self, _: &Mapping, _: &Vec<String>) -> Option<String> {
-        self.clipboard_manager.get_clipboard()
+    fn calculate(
+        &self,
+        _: &Mapping,
+        _: &Vec<String>,
+        _: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
+        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 ac15295e..2e4324b8 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 <https://www.gnu.org/licenses/>.
  */
 
-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<String>) -> Option<String> {
-        let now: DateTime<Local> = Local::now();
+    fn calculate(
+        &self,
+        params: &Mapping,
+        _: &Vec<String>,
+        _: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
+        let mut now: DateTime<Local> = 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 6bbb1dd3..e8108231 100644
--- a/src/extension/dummy.rs
+++ b/src/extension/dummy.rs
@@ -17,26 +17,39 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+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<String>) -> Option<String> {
+    fn calculate(
+        &self,
+        params: &Mapping,
+        _: &Vec<String>,
+        _: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
         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 00000000..79133f67
--- /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 <https://www.gnu.org/licenses/>.
+ */
+
+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<String>,
+        _: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
+        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<HashMap<String, String>, _> = 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 9a363c55..7ea9546e 100644
--- a/src/extension/mod.rs
+++ b/src/extension/mod.rs
@@ -17,28 +17,50 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-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<String, String>),
+}
 
 pub trait Extension {
     fn name(&self) -> String;
-    fn calculate(&self, params: &Mapping, args: &Vec<String>) -> Option<String>;
+    fn calculate(
+        &self,
+        params: &Mapping,
+        args: &Vec<String>,
+        current_vars: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult>;
 }
 
-pub fn get_extensions(clipboard_manager: Box<dyn ClipboardManager>) -> Vec<Box<dyn Extension>> {
+pub fn get_extensions(
+    config: &Configs,
+    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(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 00000000..e48eaf83
--- /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 <https://www.gnu.org/licenses/>.
+ */
+
+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<String>,
+        _: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
+        let mut output: HashMap<String, String> = 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 5d168c99..3a13d3ff 100644
--- a/src/extension/random.rs
+++ b/src/extension/random.rs
@@ -17,9 +17,11 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+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<String>) -> Option<String> {
+    fn calculate(
+        &self,
+        params: &Mapping,
+        args: &Vec<String>,
+        _: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
         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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec!["test".to_owned()]);
+        let output = extension.calculate(&params, &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 61448548..3a68a102 100644
--- a/src/extension/script.rs
+++ b/src/extension/script.rs
@@ -17,8 +17,10 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+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<String>) -> Option<String> {
+    fn calculate(
+        &self,
+        params: &Mapping,
+        user_args: &Vec<String>,
+        vars: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
         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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec!["jon".to_owned()]);
+        let output = extension.calculate(&params, &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(&params, &vec!["jon".to_owned()]);
+        let output = extension.calculate(&params, &vec!["jon".to_owned()], &HashMap::new());
+
+        assert!(output.is_some());
+        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<String, ExtensionResult> = 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(&params, &vec![], &vars);
 
         assert!(output.is_some());
-        assert_eq!(output.unwrap(), "hello world jon");
+        assert_eq!(
+            output.unwrap(),
+            ExtensionResult::Single("hello John".to_owned())
+        );
     }
 }
diff --git a/src/extension/shell.rs b/src/extension/shell.rs
index f1b9da48..fd0f43a5 100644
--- a/src/extension/shell.rs
+++ b/src/extension/shell.rs
@@ -17,9 +17,11 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-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<Output> {
+    fn execute_cmd(&self, cmd: &str, vars: &HashMap<String, String>) -> std::io::Result<Output> {
+        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<String>) -> Option<String> {
+    fn calculate(
+        &self,
+        params: &Mapping,
+        args: &Vec<String>,
+        vars: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
         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::<i32>().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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec![]);
+        let output = extension.calculate(&params, &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(&params, &vec!["hello".to_owned()]);
+        let output = extension.calculate(&params, &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(&params, &vec!["hello".to_owned()]);
+        let output = extension.calculate(&params, &vec!["hello".to_owned()], &HashMap::new());
+
+        assert!(output.is_some());
+
+        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<String, ExtensionResult> = HashMap::new();
+        vars.insert(
+            "var1".to_owned(),
+            ExtensionResult::Single("hello".to_owned()),
+        );
+        let output = extension.calculate(&params, &vec![], &vars);
 
         assert!(output.is_some());
+        assert_eq!(output.unwrap(), ExtensionResult::Single("hello".to_owned()));
+    }
 
-        assert_eq!(output.unwrap(), "hello");
+    #[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<String, ExtensionResult> = 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(&params, &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 00000000..fcf5bacc
--- /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<String, ExtensionResult>,
+) -> HashMap<String, String> {
+    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<String, ExtensionResult> = 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 00000000..4108ae1d
--- /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 <https://www.gnu.org/licenses/>.
+ */
+
+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<String>,
+        vars: &HashMap<String, ExtensionResult>,
+    ) -> Option<ExtensionResult> {
+        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 a5ea03ea..b7d9699d 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 add9499a..dece4803 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 12f602de..bb9c0edf 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17,6 +17,8 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+#![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<Event>) {
                 };
 
                 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 6914f97b..4b64c4b3 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<MatchVariable>,
@@ -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<String>,
 
+    #[serde(default = "default_form")]
+    pub form: Option<String>,
+
+    #[serde(default = "default_form_fields")]
+    pub form_fields: Option<HashMap<String, Value>>,
+
     #[serde(default = "default_vars")]
     pub vars: Vec<MatchVariable>,
 
@@ -238,6 +279,12 @@ fn default_passive_only() -> bool {
 fn default_replace() -> Option<String> {
     None
 }
+fn default_form() -> Option<String> {
+    None
+}
+fn default_form_fields() -> Option<HashMap<String, Value>> {
+    None
+}
 fn default_image_path() -> Option<String> {
     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<char>, 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 0dd38a42..d78cb48f 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<SystemTime>,
     is_enabled: RefCell<bool>,
     was_previous_char_word_separator: RefCell<bool>,
+    was_previous_char_a_match: RefCell<bool>,
 }
 
 #[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 7a03222d..660abb0b 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<String>) -> io::Result<Child> {
     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 6c9fcbc7..40d18575 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<name>\\w+)\\s*\\}\\}").unwrap();
+    static ref VAR_REGEX: Regex =
+        Regex::new(r"\{\{\s*((?P<name>\w+)(\.(?P<subname>(\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<String> = 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<String, &MatchVariable> = 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<String, ExtensionResult> = 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 00000000..334bab0c
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 1400b197..f25491c0 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 00000000..008956d0
--- /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<String>,
+}
+
+impl ModuloManager {
+    pub fn new(config: &Configs) -> Self {
+        let mut modulo_path: Option<String> = 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<String> {
+        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<String> {
+        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 e619c8c8..bea9fe2e 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<dyn Error>> {
     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<dyn Error>
     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::*;


Test patch: