feat(misc): release alpha v2.0.1

v2.0.1 alpha release
This commit is contained in:
Federico Terzi 2021-10-09 19:16:55 +02:00 committed by GitHub
commit aa9465490b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
624 changed files with 84780 additions and 18662 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
target/*

View File

@ -3,12 +3,13 @@ FROM ubuntu:18.04
RUN apt-get update \
&& apt-get install -y libssl-dev \
libxdo-dev libxtst-dev libx11-dev \
wget git cmake build-essential pkg-config
libxkbcommon-dev libwxgtk3.0-gtk3-dev libdbus-1-dev \
wget git file build-essential pkg-config
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH \
RUST_VERSION=1.41.0
RUST_VERSION=1.55.0
RUN set -eux; \
dpkgArch="$(dpkg --print-architecture)"; \
@ -30,9 +31,8 @@ RUN set -eux; \
cargo --version; \
rustc --version;
RUN mkdir espanso
RUN mkdir espanso && cargo install --force cargo-make
COPY . espanso
RUN cd espanso \
&& cargo install cargo-deb
RUN cd espanso

18
.github/scripts/ubuntu/build_appimage.sh vendored Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
set -e
echo "Testing espanso..."
cd espanso
cargo make test-binary --profile release
echo "Building espanso and creating AppImage"
cargo make create-app-image --profile release
cd ..
cp espanso/target/linux/AppImage/out/Espanso-*.AppImage Espanso-X11.AppImage
sha256sum Espanso-X11.AppImage > Espanso-X11.AppImage.sha256.txt
ls -la
echo "Copying to mounted volume"
cp Espanso-X11* /shared

82
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,82 @@
# Huge thanks to Alacritty, as their configuration served as a starting point for this one!
# See: https://github.com/alacritty/alacritty
name: CI
on: [push, pull_request]
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Check formatting
run: |
rustup component add rustfmt
cargo fmt --all -- --check
- name: Install Linux dependencies
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt install libx11-dev libxtst-dev libxkbcommon-dev libdbus-1-dev libwxgtk3.0-gtk3-dev
- name: Check clippy
run: |
rustup component add clippy
cargo clippy -- -D warnings
- name: Install cargo-make
run: |
cargo install --force cargo-make
- name: Run test suite
run: cargo make test-binary
- name: Build
run: |
cargo make build-binary
build-wayland:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Check formatting
run: |
rustup component add rustfmt
cargo fmt --all -- --check
- name: Install Linux dependencies
run: |
sudo apt install libxkbcommon-dev libwxgtk3.0-gtk3-dev libdbus-1-dev
- name: Check clippy
run: |
rustup component add clippy
cargo clippy -p espanso --features wayland -- -D warnings
- name: Install cargo-make
run: |
cargo install --force cargo-make
- name: Run test suite
run: cargo make test-binary --env NO_X11=true
- name: Build
run: cargo make build-binary --env NO_X11=true
build-macos-arm:
runs-on: macos-11
steps:
- uses: actions/checkout@v2
- name: Install target
run: rustup update && rustup target add aarch64-apple-darwin
- name: Install cargo-make
run: |
cargo install --force cargo-make
- name: Build
run: |
cargo make build-macos-arm-binary
# - name: Setup tmate session
# uses: mxschmitt/action-tmate@v3
# with:
# limit-access-to-actor: true
# TODO: add clippy check

199
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,199 @@
# Huge thanks to Alacritty, as their configuration served as a starting point for this one!
# See: https://github.com/alacritty/alacritty
name: Release
on:
push:
branches:
- master
- dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_TERM_COLOR: always
jobs:
extract-version:
name: extract-version
runs-on: ubuntu-latest
outputs:
espanso_version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v2
- name: "Extract version"
id: "version"
run: |
ESPANSO_VERSION=$(cat espanso/Cargo.toml | grep version | head -1 | awk -F '"' '{ print $2 }')
echo version: $ESPANSO_VERSION
echo "::set-output name=version::v$ESPANSO_VERSION"
create-release:
name: create-release
needs: ["extract-version"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Create new release (only on master)
if: ${{ github.ref == 'refs/heads/master' }}
run: |
COMMIT_HASH=$(git rev-list --max-count=1 HEAD)
echo "Creating release: ${{ needs.extract-version.outputs.espanso_version }}"
echo "for hash: $COMMIT_HASH"
gh release create ${{ needs.extract-version.outputs.espanso_version }} \
-t ${{ needs.extract-version.outputs.espanso_version }} \
--notes "Automatically released by CI" \
--prerelease \
--target $COMMIT_HASH
windows:
needs: ["extract-version", "create-release"]
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v2
- name: Print target version
run: |
echo Using version ${{ needs.extract-version.outputs.espanso_version }}
- name: Install cargo-make
run: |
cargo install --force cargo-make
- name: Test
run: cargo make test-binary --profile release
- name: Build
run: cargo make build-windows-all --profile release
- name: Create portable mode archive
shell: powershell
run: |
Rename-Item target/windows/portable espanso-portable
Compress-Archive target/windows/espanso-portable target/windows/Espanso-Win-Portable-x86_64.zip
- name: Calculate hashes
shell: powershell
run: |
Get-FileHash target/windows/Espanso-Win-Portable-x86_64.zip -Algorithm SHA256 | select-object -ExpandProperty Hash > target/windows/Espanso-Win-Portable-x86_64.zip.sha256.txt
Get-FileHash target/windows/installer/Espanso-Win-Installer-x86_64.exe -Algorithm SHA256 | select-object -ExpandProperty Hash > target/windows/installer/Espanso-Win-Installer-x86_64.exe.sha256.txt
- uses: actions/upload-artifact@v2
name: "Upload artifacts"
with:
name: Windows Artifacts
path: |
target/windows/installer/Espanso-Win-Installer-x86_64.exe
target/windows/Espanso-Win-Portable-x86_64.zip
target/windows/installer/Espanso-Win-Installer-x86_64.exe.sha256.txt
target/windows/Espanso-Win-Portable-x86_64.zip.sha256.txt
- name: Upload artifacts to Github Releases (if master)
if: ${{ github.ref == 'refs/heads/master' }}
run: |
gh release upload ${{ needs.extract-version.outputs.espanso_version }} target/windows/installer/Espanso-Win-Installer-x86_64.exe target/windows/Espanso-Win-Portable-x86_64.zip target/windows/installer/Espanso-Win-Installer-x86_64.exe.sha256.txt target/windows/Espanso-Win-Portable-x86_64.zip.sha256.txt
linux-x11:
needs: ["extract-version", "create-release"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Print target version
run: |
echo Using version ${{ needs.extract-version.outputs.espanso_version }}
- name: Build docker image
run: |
sudo docker build -t espanso-ubuntu . -f .github/scripts/ubuntu/Dockerfile
- name: Build AppImage
run: |
sudo docker run --rm -v "$(pwd):/shared" espanso-ubuntu espanso/.github/scripts/ubuntu/build_appimage.sh
- uses: actions/upload-artifact@v2
name: "Upload artifacts"
with:
name: Linux X11 Artifacts
path: |
Espanso-X11.AppImage
Espanso-X11.AppImage.sha256.txt
- name: Upload artifacts to Github Releases (if master)
if: ${{ github.ref == 'refs/heads/master' }}
run: |
gh release upload ${{ needs.extract-version.outputs.espanso_version }} Espanso-X11.AppImage Espanso-X11.AppImage.sha256.txt
macos-intel:
needs: ["extract-version", "create-release"]
runs-on: macos-11
steps:
- uses: actions/checkout@v2
- name: Print target version
run: |
echo Using version ${{ needs.extract-version.outputs.espanso_version }}
- name: Install cargo-make
run: |
cargo install --force cargo-make
- name: Test
run: cargo make test-binary --profile release
- name: Build
run: cargo make create-bundle --profile release
- name: Create ZIP archive
run: |
ditto -c -k --sequesterRsrc --keepParent target/mac/Espanso.app Espanso-Mac-Intel.zip
- name: Calculate hashes
run: |
shasum -a 256 Espanso-Mac-Intel.zip > Espanso-Mac-Intel.zip.sha256.txt
- uses: actions/upload-artifact@v2
name: "Upload artifacts"
with:
name: Mac Intel Artifacts
path: |
Espanso-Mac-Intel.zip
Espanso-Mac-Intel.zip.sha256.txt
- name: Upload artifacts to Github Releases (if master)
if: ${{ github.ref == 'refs/heads/master' }}
run: |
gh release upload ${{ needs.extract-version.outputs.espanso_version }} Espanso-Mac-Intel.zip Espanso-Mac-Intel.zip.sha256.txt
macos-m1:
needs: ["extract-version", "create-release"]
runs-on: macos-11
steps:
- uses: actions/checkout@v2
- name: Print target version
run: |
echo Using version ${{ needs.extract-version.outputs.espanso_version }}
- name: Install rust target
run: rustup update && rustup target add aarch64-apple-darwin
- name: Install cargo-make
run: |
cargo install --force cargo-make
- name: Build
run: cargo make create-bundle --profile release --env BUILD_ARCH=aarch64-apple-darwin
- name: Codesign executable
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
MACOS_CI_KEYCHAIN_PWD: ${{ secrets.MACOS_CI_KEYCHAIN_PWD }}
run: |
echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p $MACOS_CI_KEYCHAIN_PWD buildespanso.keychain
security default-keychain -s buildespanso.keychain
security unlock-keychain -p $MACOS_CI_KEYCHAIN_PWD buildespanso.keychain
security import certificate.p12 -k buildespanso.keychain -P $MACOS_CERTIFICATE_PWD -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $MACOS_CI_KEYCHAIN_PWD buildespanso.keychain
/usr/bin/codesign --force -s "Espanso CI Self-Signed" target/mac/Espanso.app -v
- name: Create ZIP archive
run: |
ditto -c -k --sequesterRsrc --keepParent target/mac/Espanso.app Espanso-Mac-M1.zip
- name: Calculate hashes
run: |
shasum -a 256 Espanso-Mac-M1.zip > Espanso-Mac-M1.zip.sha256.txt
- uses: actions/upload-artifact@v2
name: "Upload artifacts"
with:
name: Mac M1 Artifacts
path: |
Espanso-Mac-M1.zip
Espanso-Mac-M1.zip.sha256.txt
- name: Upload artifacts to Github Releases (if master)
if: ${{ github.ref == 'refs/heads/master' }}
run: |
gh release upload ${{ needs.extract-version.outputs.espanso_version }} Espanso-Mac-M1.zip Espanso-Mac-M1.zip.sha256.txt

2856
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +1,21 @@
[package]
name = "espanso"
version = "0.7.3"
authors = ["Federico Terzi <federicoterzi96@gmail.com>"]
license = "GPL-3.0"
description = "Cross-platform Text Expander written in Rust"
readme = "README.md"
homepage = "https://github.com/federico-terzi/espanso"
edition = "2018"
build="build.rs"
[workspace]
[modulo]
version = "0.1.1"
[dependencies]
widestring = "0.4.0"
serde = { version = "1.0.117", features = ["derive"] }
serde_yaml = "0.8"
dirs = "2.0.2"
clap = "2.33.0"
regex = "1.3.1"
log = "0.4.8"
simplelog = "0.7.1"
fs2 = "0.4.3"
serde_json = "1.0.60"
log-panics = {version = "2.0.0", features = ["with-backtrace"]}
backtrace = "0.3.37"
chrono = "0.4.9"
lazy_static = "1.4.0"
walkdir = "2.2.9"
reqwest = "0.9.20"
tempfile = "3.1.0"
dialoguer = "0.4.0"
rand = "0.7.2"
zip = "0.5.3"
notify = "4.0.13"
markdown = "0.3.0"
html2text = "0.2.1"
[target.'cfg(unix)'.dependencies]
libc = "0.2.62"
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"
[package.metadata.deb]
maintainer = "Federico Terzi <federicoterzi96@gmail.com>"
depends = "$auto, systemd, libxtst6, libxdo3, xclip, libnotify-bin"
section = "utility"
license-file = ["LICENSE", "1"]
members = [
"espanso",
"espanso-detect",
"espanso-ui",
"espanso-inject",
"espanso-ipc",
"espanso-config",
"espanso-match",
"espanso-clipboard",
"espanso-render",
"espanso-info",
"espanso-path",
"espanso-modulo",
"espanso-migrate",
"espanso-mac-utils",
"espanso-kvs",
"espanso-engine",
"espanso-package",
]

67
Compilation.md Normal file
View File

@ -0,0 +1,67 @@
# Compilation
This document tries to explain the various steps needed to build espanso. (Work in progress).
# Prerequisites
These are the basic tools required to build espanso:
* A recent Rust compiler. You can install it following these instructions: https://www.rust-lang.org/tools/install
* A C/C++ compiler. There are multiple of them depending on the platform, but espanso officially supports the following:
* On Windows, you should use the MSVC compiler. The easiest way to install it is by downloading Visual Studio and checking "Desktop development with C++" in the installer: https://visualstudio.microsoft.com/
* On macOS, you should use the official build tools that come with Xcode. If you don't want to install Xcode, you should be able to download only the build tools by executing `xcode-select —install` and following the instructions.
* On Linux, you should use the default C/C++ compiler (it's usually GCC). On Ubuntu/Debian systems, you can install them with `sudo apt install build-essential`
* Espanso heavily relies on [cargo make](https://github.com/sagiegurari/cargo-make) for the various packaging
steps. You can install it by running:
```
cargo install --force cargo-make
```
# Linux
Espanso on Linux comes in two different flavors: one for X11 and one for Wayland.
If you don't know which one to choose, follow these steps to determine which one you are running: https://unix.stackexchange.com/a/325972
## Compiling for X11
### Necessary packages
If compiling on Ubuntu X11:
* `sudo apt install libx11-dev libxtst-dev libxkbcommon-dev libdbus-1-dev libwxgtk3.0-gtk3-dev`
### AppImage
The AppImage is a convenient format to distribute Linux applications, as besides the binary,
it also bundles all the required libraries.
You can create the AppImage by running (this will work on X11 systems):
```
cargo make create-app-image --profile release
```
You will find the resulting AppImage in the `target/linux/AppImage/out` folder.
### Binary
TODO
## Compiling on Wayland
TODO
## Advanced
Espanso offers a few flags that might be necessary if you want to further tune the resulting binary.
### Disabling modulo (GUI features)
Espanso includes a component known as _modulo_, which handles most of the graphical-related parts of the tool.
For example, the Search bar or Forms are handled by it.
If you don't want them, you can pass the `--env NO_MODULO=true` flag to any of the previous `cargo make` commands
to remove support for it.
Keep in mind that espanso was designed with modulo as a first class citizen, so the experience might be far from perfect without it.

105
Makefile.toml Normal file
View File

@ -0,0 +1,105 @@
[config]
default_to_workspace = false
[env]
DEBUG = true
RELEASE = false
NO_X11 = false
NO_MODULO = false
EXEC_PATH = "target/debug/espanso"
BUILD_ARCH = "current"
[env.release]
DEBUG = false
RELEASE = true
EXEC_PATH = "target/release/espanso"
# Build variants
# This one was written in Rust instead of bash because it has to run on Windows as well
[tasks.build-binary]
script_runner = "@rust"
script = { file = "scripts/build_binary.rs" }
[tasks.run-binary]
command = "${EXEC_PATH}"
args = ["${@}"]
dependencies = ["build-binary"]
[tasks.test-binary]
script_runner = "@rust"
script = { file = "scripts/test_binary.rs" }
# Windows
[tasks.build-windows-resources]
script_runner = "@rust"
script = { file = "scripts/build_windows_resources.rs" }
dependencies = ["build-binary"]
[tasks.build-windows-portable]
script_runner = "@rust"
script = { file = "scripts/build_windows_portable.rs" }
dependencies = ["build-windows-resources"]
[tasks.build-windows-installer]
script_runner = "@rust"
script = { file = "scripts/build_windows_installer.rs" }
dependencies = ["build-windows-resources"]
[tasks.build-windows-all]
dependencies = ["build-windows-portable", "build-windows-installer"]
# macOS
[tasks.build-macos-arm-binary]
env = { "BUILD_ARCH" = "aarch64-apple-darwin" }
run_task = [
{ name = "build-binary" }
]
[tasks.build-macos-x86-binary]
env = { "BUILD_ARCH" = "x86_64-apple-darwin" }
run_task = [
{ name = "build-binary" }
]
[tasks.build-universal-binary]
script = { file = "scripts/join_universal_binary.sh"}
dependencies=["build-macos-arm-binary", "build-macos-x86-binary"]
[tasks.create-bundle]
script = { file = "scripts/create_bundle.sh" }
dependencies=["build-binary"]
[tasks.create-universal-bundle]
env = { "EXEC_PATH" = "target/universal/espanso" }
script = { file = "scripts/create_bundle.sh" }
dependencies=["build-universal-binary"]
[tasks.run-bundle]
command="target/mac/Espanso.app/Contents/MacOS/espanso"
args=["${@}"]
dependencies=["create-bundle"]
# Linux
[tasks.create-app-image]
script = { file = "scripts/create_app_image.sh" }
dependencies=["build-binary"]
[tasks.run-app-image]
args=["${@}"]
script='''
#!/usr/bin/env bash
set -e
echo Launching AppImage with args: "$@"
./target/linux/AppImage/out/Espanso-*.AppImage "$@"
'''
dependencies=["create-app-image"]
# Test runs
[tasks.test-output]
command = "cargo"
args = ["test", "--workspace", "--exclude", "espanso-modulo", "--exclude", "espanso-ipc", "--no-default-features", "--", "--nocapture"]

View File

@ -1,4 +1,4 @@
![espanso](images/titlebar.png)
![espanso](images/logo_extended.png)
> A cross-platform Text Expander written in Rust
@ -6,7 +6,6 @@
![Language](https://img.shields.io/badge/language-rust-orange)
![Platforms](https://img.shields.io/badge/platforms-Windows%2C%20macOS%20and%20Linux-blue)
![License](https://img.shields.io/github/license/federico-terzi/espanso)
[![Build Status](https://dev.azure.com/freddy6896/espanso/_apis/build/status/federico-terzi.espanso?branchName=master)](https://dev.azure.com/freddy6896/espanso/_build/latest?definitionId=1&branchName=master)
![example](images/example.gif)
@ -30,6 +29,7 @@ ___
* Works with almost **any program**
* Works with **Emojis** 😄
* Works with **Images**
* Includes a powerful **Search Bar** 🔎
* **Date** expansion support
* **Custom scripts** support
* **Shell commands** support
@ -38,6 +38,8 @@ ___
* Expandable with **packages**
* Built-in **package manager** for [espanso hub](https://hub.espanso.org/)
* File based configuration
* Support Regex triggers
* Experimental Wayland support
## Get Started
@ -60,20 +62,17 @@ please consider making a small donation, it really helps :)
## Contributors
Many people helped the project along the way, thanks to all of you. In particular, I want to thank:
Many people helped the project along the way, thank you to all of you!
* [Scrumplex](https://scrumplex.net/) - Official AUR repo mantainer and Linux Guru
* [Luca Antognetti](https://github.com/luca-ant) - Linux and Windows Tester
* [Matteo Pellegrino](https://www.matteopellegrino.me/) - MacOS Tester
* [Timo Runge](http://timorunge.com/) - MacOS contributor
* [NickSeagull](http://nickseagull.github.io/) - Contributor
* [matt-h](https://github.com/matt-h) - Contributor
<a href="https://github.com/federico-terzi/espanso/graphs/contributors">
<img src="https://contrib.rocks/image?repo=federico-terzi/espanso" />
</a>
## Remarks
* Thanks to [libxdo](https://github.com/jordansissel/xdotool) and [xclip](https://github.com/astrand/xclip), used to implement the Linux port.
* Thanks to the ModifyPath
script, used by espanso to improve the Windows installer.
* Thanks to [libxkbcommon](https://xkbcommon.org/) and [wl-clipboard](https://github.com/bugaevc/wl-clipboard), used to implement the Wayland port.
* Thanks to [wxWidgets](https://www.wxwidgets.org/) for providing a powerful cross-platform GUI library.
## License

View File

@ -1,3 +1,8 @@
> TODO: this document is relative to version 1 and will be updated soon for changes introduced in version 2
>
> Despite significant architectural differences, the following points are still a good approximation
> of the internals.
# Security
Espanso has always been designed with a strong focus on security.

View File

@ -1,47 +0,0 @@
# Starter pipeline
# Start with a minimal pipeline that you can customize to build and deploy your code.
# Add steps that build, run tests, deploy, and more:
# https://aka.ms/yaml
trigger:
- master
jobs:
- job: Linux
pool:
vmImage: 'ubuntu-latest'
steps:
- script: |
sudo apt -y update
sudo apt install -y libxtst-dev libx11-dev libxdo3 libxdo-dev
displayName: Install library dependencies
- template: ci/test.yml
- template: ci/build-linux.yml
- template: ci/deploy.yml
- job: UbuntuDEB
pool:
vmImage: 'ubuntu-latest'
steps:
- script: |
sudo docker build -t espanso-ubuntu . -f ci/ubuntu/Dockerfile
sudo docker run --rm -v "$(pwd):/shared" espanso-ubuntu espanso/ci/ubuntu/build_deb.sh
displayName: Setting up docker
- template: ci/deploy.yml
- job: macOS
pool:
vmImage: 'macOS-10.14'
steps:
- template: ci/test.yml
- template: ci/build-macos.yml
- template: ci/deploy.yml
- template: ci/publish-homebrew.yml
- job: Windows
pool:
vmImage: 'windows-2019'
steps:
- template: ci/test.yml
- template: ci/build-win.yml
- template: ci/deploy.yml

View File

@ -1,59 +0,0 @@
extern crate cmake;
use cmake::Config;
use std::path::PathBuf;
/* OS SPECIFIC CONFIGS */
#[cfg(target_os = "windows")]
fn get_config() -> PathBuf {
Config::new("native/libwinbridge").build()
}
#[cfg(target_os = "linux")]
fn get_config() -> PathBuf {
Config::new("native/liblinuxbridge").build()
}
#[cfg(target_os = "macos")]
fn get_config() -> PathBuf {
Config::new("native/libmacbridge").build()
}
/*
OS CUSTOM CARGO CONFIG LINES
Note: this is where linked libraries should be specified.
*/
#[cfg(target_os = "windows")]
fn print_config() {
println!("cargo:rustc-link-lib=static=winbridge");
println!("cargo:rustc-link-lib=dylib=user32");
#[cfg(target_env = "gnu")]
println!("cargo:rustc-link-lib=dylib=gdiplus");
#[cfg(target_env = "gnu")]
println!("cargo:rustc-link-lib=dylib=stdc++");
}
#[cfg(target_os = "linux")]
fn print_config() {
println!("cargo:rustc-link-search=native=/usr/lib/x86_64-linux-gnu/");
println!("cargo:rustc-link-lib=static=linuxbridge");
println!("cargo:rustc-link-lib=dylib=X11");
println!("cargo:rustc-link-lib=dylib=Xtst");
println!("cargo:rustc-link-lib=dylib=xdo");
}
#[cfg(target_os = "macos")]
fn print_config() {
println!("cargo:rustc-link-lib=dylib=c++");
println!("cargo:rustc-link-lib=static=macbridge");
println!("cargo:rustc-link-lib=framework=Cocoa");
println!("cargo:rustc-link-lib=framework=IOKit");
}
fn main() {
let dst = get_config();
println!("cargo:rustc-link-search=native={}", dst.display());
print_config();
}

View File

@ -1,11 +0,0 @@
steps:
- script: |
cargo build --release
cd target/release/
tar czf "espanso-linux.tar.gz" espanso
cd ../..
cp target/release/espanso-*.gz .
sha256sum espanso-*.gz | awk '{ print $1 }' > espanso-linux-sha256.txt
ls -la
displayName: "Cargo build and packaging for Linux"

View File

@ -1,19 +0,0 @@
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.7'
addToPath: true
- script: |
python --version
python -m pip install toml click
displayName: Installing python dependencies
- script: |
set -e
python packager.py build
cp target/packager/mac/espanso-*.gz .
cp target/packager/mac/espanso-*.txt .
cp target/packager/mac/espanso.rb .
ls -la
displayName: "Cargo build and packaging for MacOS"

View File

@ -1,18 +0,0 @@
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.7'
addToPath: true
- script: |
python --version
python -m pip install toml click
displayName: Installing python dependencies
- script: |
python packager.py build
copy "target\\packager\\win\\espanso-win-installer.exe" "espanso-win-installer.exe"
copy "target\\packager\\win\\espanso-win-installer-sha256.txt" "espanso-win-installer-sha256.txt"
dir
displayName: "Build and packaging for Windows"

View File

@ -1,36 +0,0 @@
parameters:
github:
isPreRelease: false
repositoryName: '$(Build.Repository.Name)'
gitHubConnection: "MyGithubConnection"
dependsOn: []
displayName: "Release to github"
steps:
- script: |
VER=$(cat Cargo.toml| grep version -m 1 | awk -F '"' '{ print $2 }')
echo '##vso[task.setvariable variable=vers]'v$VER
condition: not(eq(variables['Agent.OS'], 'Windows_NT'))
displayName: Obtain version from Cargo.toml on Unix
- powershell: |
Select-String -Path "Cargo.toml" -Pattern "version" | Select-Object -First 1 -outvariable v
$vv = [regex]::match($v, '"([^"]+)"').Groups[1].Value
echo "##vso[task.setvariable variable=vers]v$vv"
condition: eq(variables['Agent.OS'], 'Windows_NT')
displayName: Obtain version from Cargo.toml on Windows
- task: GitHubRelease@0
displayName: Create GitHub release
inputs:
gitHubConnection: ${{ parameters.github.gitHubConnection }}
tagSource: manual
title: '$(vers)'
tag: '$(vers)'
assetUploadMode: replace
action: edit
assets: 'espanso-*'
addChangeLog: false
repositoryName: ${{ parameters.github.repositoryName }}
isPreRelease: ${{ parameters.github.isPreRelease }}
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'))

View File

@ -1,35 +0,0 @@
# defaults for any parameters that aren't specified
parameters:
rust_version: stable
steps:
# Linux and macOS.
- script: |
set -e
curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain $RUSTUP_TOOLCHAIN
echo "##vso[task.setvariable variable=PATH;]$PATH:$HOME/.cargo/bin"
env:
RUSTUP_TOOLCHAIN: ${{parameters.rust_version}}
displayName: "Install rust (*nix)"
condition: not(eq(variables['Agent.OS'], 'Windows_NT'))
# Windows.
- script: |
curl -sSf -o rustup-init.exe https://win.rustup.rs
rustup-init.exe -y --default-toolchain %RUSTUP_TOOLCHAIN% --default-host x86_64-pc-windows-msvc
set PATH=%PATH%;%USERPROFILE%\.cargo\bin
echo "##vso[task.setvariable variable=PATH;]%PATH%;%USERPROFILE%\.cargo\bin"
env:
RUSTUP_TOOLCHAIN: ${{parameters.rust_version}}
displayName: "Install rust (windows)"
condition: eq(variables['Agent.OS'], 'Windows_NT')
# Install additional components:
- ${{ each component in parameters.components }}:
- script: rustup component add ${{ component }}
# All platforms.
- script: |
rustup -V
rustup component list --installed
rustc -Vv
cargo -V
displayName: Query rust and cargo versions

View File

@ -1,22 +0,0 @@
steps:
- task: InstallSSHKey@0
inputs:
knownHostsEntry: "github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ=="
sshPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsB9zcHN84/T5URAsfIpb52HnJl2kUK7WWXyV9pFXaO6yz722JxzVq56J3TTrcUCDhM3DKSGKivB6n/tmLw4mefcY3t7kh8puAtaNrNnB4TWqVPFHZtnpYuYslp1rM92r7Bz1FHfVfsDZxqSWlGU/lp0gNEEgXbr2PCExbCh3TGTsKePARhMAtPEvyEZk1+8uA/HvUTjhuDp7P+BbejAsqtgVF0QoEvqDE5af8DZY6+i1cHRgwBYgSnOus8FHsZUGMyAJQtb+dD7imGw/nzokPJzbmQJwQetyhp52CfThpAm12EFtIU43imb8nndlVAmsIHF6czbmI5LP3U0UcTLct freddy@freddy-Z97M-DS3H"
sshKeySecureFile: "azuressh"
- script: |
set -ex
cat ~/.ssh/known_hosts
git config --global user.email "federicoterzi96@gmail.com"
git config --global user.email "Federico Terzi"
VER=$(cat Cargo.toml| grep version -m 1 | awk -F '"' '{ print $2 }')
git clone git@github.com:federico-terzi/homebrew-espanso.git
rm homebrew-espanso/Formula/espanso.rb
cp espanso.rb homebrew-espanso/Formula/espanso.rb
cd homebrew-espanso
git add -A
git commit -m "Update to version: $VER"
git push
displayName: "Publishing to Homebrew"
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'))

View File

@ -1,21 +0,0 @@
parameters:
rust_version: stable
steps:
- script: |
echo Master check
displayName: Master branch check
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
- template: install-rust.yml
- script: |
set -e
cargo test --release
displayName: Cargo tests on Unix
condition: not(eq(variables['Agent.OS'], 'Windows_NT'))
- script: |
cargo test --release
displayName: Cargo tests on Windows
condition: eq(variables['Agent.OS'], 'Windows_NT')

View File

@ -1,16 +0,0 @@
#!/bin/bash
echo "Testing espanso..."
cd espanso
cargo test --release
echo "Building espanso and packaging deb"
cargo deb
cd ..
cp espanso/target/debian/espanso*.deb espanso-debian-amd64.deb
sha256sum espanso-debian-amd64.deb > espanso-debian-amd64-sha256.txt
ls -la
echo "Copying to mounted volume"
cp espanso-debian-* /shared

View File

@ -0,0 +1,31 @@
[package]
name = "espanso-clipboard"
version = "0.1.0"
authors = ["Federico Terzi <federico-terzi@users.noreply.github.com>"]
edition = "2018"
build="build.rs"
[features]
# If the wayland feature is enabled, all X11 dependencies will be dropped
# and wayland support will be enabled
wayland = ["wait-timeout"]
# If enabled, avoid linking with the gdiplus library on Windows, which
# might conflict with wxWidgets
avoid-gdi = []
[dependencies]
log = "0.4.14"
lazycell = "1.3.0"
anyhow = "1.0.38"
thiserror = "1.0.23"
lazy_static = "1.4.0"
[target.'cfg(windows)'.dependencies]
widestring = "0.4.3"
[target.'cfg(target_os = "linux")'.dependencies]
wait-timeout = { version = "0.2.0", optional = true }
[build-dependencies]
cc = "1.0.66"

View File

@ -0,0 +1,82 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#[cfg(target_os = "windows")]
fn cc_config() {
println!("cargo:rerun-if-changed=src/win32/native.cpp");
println!("cargo:rerun-if-changed=src/win32/native.h");
cc::Build::new()
.cpp(true)
.include("src/win32/native.h")
.file("src/win32/native.cpp")
.compile("espansoclipboard");
println!("cargo:rustc-link-lib=static=espansoclipboard");
println!("cargo:rustc-link-lib=dylib=user32");
println!("cargo:rustc-link-lib=dylib=gdi32");
if cfg!(not(feature = "avoid-gdi")) {
println!("cargo:rustc-link-lib=dylib=gdiplus");
}
#[cfg(target_env = "gnu")]
println!("cargo:rustc-link-lib=dylib=stdc++");
}
#[cfg(target_os = "linux")]
fn cc_config() {
if cfg!(not(feature = "wayland")) {
println!("cargo:rerun-if-changed=src/x11/native/native.h");
println!("cargo:rerun-if-changed=src/x11/native/native.c");
cc::Build::new()
.cpp(true)
.include("src/x11/native/clip")
.include("src/x11/native")
.file("src/x11/native/clip/clip.cpp")
.file("src/x11/native/clip/clip_x11.cpp")
.file("src/x11/native/clip/image.cpp")
.file("src/x11/native/native.cpp")
.compile("espansoclipboardx11");
println!("cargo:rustc-link-search=native=/usr/lib/x86_64-linux-gnu/");
println!("cargo:rustc-link-lib=static=espansoclipboardx11");
println!("cargo:rustc-link-lib=dylib=xcb");
println!("cargo:rustc-link-lib=dylib=stdc++");
} else {
// Nothing to compile on wayland
}
}
#[cfg(target_os = "macos")]
fn cc_config() {
println!("cargo:rerun-if-changed=src/cocoa/native.mm");
println!("cargo:rerun-if-changed=src/cocoa/native.h");
cc::Build::new()
.cpp(true)
.include("src/cocoa/native.h")
.file("src/cocoa/native.mm")
.compile("espansoclipboard");
println!("cargo:rustc-link-lib=dylib=c++");
println!("cargo:rustc-link-lib=static=espansoclipboard");
println!("cargo:rustc-link-lib=framework=Cocoa");
}
fn main() {
cc_config();
}

View File

@ -0,0 +1,28 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::os::raw::c_char;
#[link(name = "espansoclipboard", kind = "static")]
extern "C" {
pub fn clipboard_get_text(buffer: *mut c_char, buffer_size: i32) -> i32;
pub fn clipboard_set_text(text: *const c_char) -> i32;
pub fn clipboard_set_image(image_path: *const c_char) -> i32;
pub fn clipboard_set_html(html_descriptor: *const c_char, fallback_text: *const c_char) -> i32;
}

View File

@ -0,0 +1,103 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
mod ffi;
use std::{
ffi::{CStr, CString},
path::PathBuf,
};
use crate::Clipboard;
use anyhow::Result;
use log::error;
use thiserror::Error;
pub struct CocoaClipboard {}
impl CocoaClipboard {
pub fn new() -> Result<Self> {
Ok(Self {})
}
}
impl Clipboard for CocoaClipboard {
fn get_text(&self) -> Option<String> {
let mut buffer: [i8; 2048] = [0; 2048];
let native_result =
unsafe { ffi::clipboard_get_text(buffer.as_mut_ptr(), (buffer.len() - 1) as i32) };
if native_result > 0 {
let string = unsafe { CStr::from_ptr(buffer.as_ptr()) };
Some(string.to_string_lossy().to_string())
} else {
None
}
}
fn set_text(&self, text: &str) -> anyhow::Result<()> {
let string = CString::new(text)?;
let native_result = unsafe { ffi::clipboard_set_text(string.as_ptr()) };
if native_result > 0 {
Ok(())
} else {
Err(CocoaClipboardError::SetOperationFailed().into())
}
}
fn set_image(&self, image_path: &std::path::Path) -> anyhow::Result<()> {
if !image_path.exists() || !image_path.is_file() {
return Err(CocoaClipboardError::ImageNotFound(image_path.to_path_buf()).into());
}
let path = CString::new(image_path.to_string_lossy().to_string())?;
let native_result = unsafe { ffi::clipboard_set_image(path.as_ptr()) };
if native_result > 0 {
Ok(())
} else {
Err(CocoaClipboardError::SetOperationFailed().into())
}
}
fn set_html(&self, html: &str, fallback_text: Option<&str>) -> anyhow::Result<()> {
let html_string = CString::new(html)?;
let fallback_string = CString::new(fallback_text.unwrap_or_default())?;
let fallback_ptr = if fallback_text.is_some() {
fallback_string.as_ptr()
} else {
std::ptr::null()
};
let native_result = unsafe { ffi::clipboard_set_html(html_string.as_ptr(), fallback_ptr) };
if native_result > 0 {
Ok(())
} else {
Err(CocoaClipboardError::SetOperationFailed().into())
}
}
}
#[derive(Error, Debug)]
pub enum CocoaClipboardError {
#[error("clipboard set operation failed")]
SetOperationFailed(),
#[error("image not found: `{0}`")]
ImageNotFound(PathBuf),
}

View File

@ -0,0 +1,30 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#ifndef ESPANSO_CLIPBOARD_H
#define ESPANSO_CLIPBOARD_H
#include <stdint.h>
extern "C" int32_t clipboard_get_text(char * buffer, int32_t buffer_size);
extern "C" int32_t clipboard_set_text(char * text);
extern "C" int32_t clipboard_set_image(char * image_path);
extern "C" int32_t clipboard_set_html(char * html, char * fallback_text);
#endif //ESPANSO_CLIPBOARD_H

View File

@ -0,0 +1,87 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#include "native.h"
#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
#include <string.h>
int32_t clipboard_get_text(char * buffer, int32_t buffer_size) {
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
for (id element in pasteboard.pasteboardItems) {
NSString *string = [element stringForType: NSPasteboardTypeString];
if (string != NULL) {
const char * text = [string UTF8String];
strncpy(buffer, text, buffer_size);
[string release];
return 1;
}
}
return 0;
}
int32_t clipboard_set_text(char * text) {
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
NSArray *array = @[NSPasteboardTypeString];
[pasteboard declareTypes:array owner:nil];
NSString *nsText = [NSString stringWithUTF8String:text];
[pasteboard setString:nsText forType:NSPasteboardTypeString];
}
int32_t clipboard_set_image(char * image_path) {
NSString *pathString = [NSString stringWithUTF8String:image_path];
NSImage *image = [[NSImage alloc] initWithContentsOfFile:pathString];
int result = 0;
if (image != nil) {
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
[pasteboard clearContents];
NSArray *copiedObjects = [NSArray arrayWithObject:image];
[pasteboard writeObjects:copiedObjects];
result = 1;
}
[image release];
return result;
}
int32_t clipboard_set_html(char * html, char * fallback_text) {
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
NSArray *array = @[NSRTFPboardType, NSPasteboardTypeString];
[pasteboard declareTypes:array owner:nil];
NSString *nsHtml = [NSString stringWithUTF8String:html];
NSDictionary *documentAttributes = [NSDictionary dictionaryWithObjectsAndKeys:NSHTMLTextDocumentType, NSDocumentTypeDocumentAttribute, NSCharacterEncodingDocumentAttribute,[NSNumber numberWithInt:NSUTF8StringEncoding], nil];
NSAttributedString* atr = [[NSAttributedString alloc] initWithData:[nsHtml dataUsingEncoding:NSUTF8StringEncoding] options:documentAttributes documentAttributes:nil error:nil];
NSData *rtf = [atr RTFFromRange:NSMakeRange(0, [atr length])
documentAttributes:nil];
[pasteboard setData:rtf forType:NSRTFPboardType];
if (fallback_text) {
NSString *nsText = [NSString stringWithUTF8String:fallback_text];
[pasteboard setString:nsText forType:NSPasteboardTypeString];
}
return 1;
}

View File

@ -0,0 +1,97 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::path::Path;
use anyhow::Result;
use log::info;
#[cfg(target_os = "windows")]
mod win32;
#[cfg(target_os = "linux")]
#[cfg(not(feature = "wayland"))]
mod x11;
#[cfg(target_os = "linux")]
#[cfg(feature = "wayland")]
mod wayland;
#[cfg(target_os = "macos")]
mod cocoa;
pub trait Clipboard {
fn get_text(&self) -> Option<String>;
fn set_text(&self, text: &str) -> Result<()>;
fn set_image(&self, image_path: &Path) -> Result<()>;
fn set_html(&self, html: &str, fallback_text: Option<&str>) -> Result<()>;
}
#[allow(dead_code)]
pub struct ClipboardOptions {
// Wayland-only
// The number of milliseconds the wl-clipboard commands are allowed
// to run before triggering a time-out event.
wayland_command_timeout_ms: u64,
}
impl Default for ClipboardOptions {
fn default() -> Self {
Self {
wayland_command_timeout_ms: 2000,
}
}
}
#[cfg(target_os = "windows")]
pub fn get_clipboard(_: ClipboardOptions) -> Result<Box<dyn Clipboard>> {
info!("using Win32Clipboard");
Ok(Box::new(win32::Win32Clipboard::new()?))
}
#[cfg(target_os = "macos")]
pub fn get_clipboard(_: ClipboardOptions) -> Result<Box<dyn Clipboard>> {
info!("using CocoaClipboard");
Ok(Box::new(cocoa::CocoaClipboard::new()?))
}
#[cfg(target_os = "linux")]
#[cfg(not(feature = "wayland"))]
pub fn get_clipboard(_: ClipboardOptions) -> Result<Box<dyn Clipboard>> {
info!("using X11NativeClipboard");
Ok(Box::new(x11::native::X11NativeClipboard::new()?))
}
#[cfg(target_os = "linux")]
#[cfg(feature = "wayland")]
pub fn get_clipboard(options: ClipboardOptions) -> Result<Box<dyn Clipboard>> {
// TODO: On some Wayland compositors (currently sway), the "wlr-data-control" protocol
// could enable the use of a much more efficient implementation relying on the "wl-clipboard-rs" crate.
// Useful links: https://github.com/YaLTeR/wl-clipboard-rs/issues/8
//
// We could even decide the correct implementation at runtime by checking if the
// required protocol is available, if so use the efficient implementation
// instead of the fallback one, which calls the wl-copy and wl-paste binaries, and is thus
// less efficient
info!("using WaylandFallbackClipboard");
Ok(Box::new(wayland::fallback::WaylandFallbackClipboard::new(
options,
)?))
}

View File

@ -0,0 +1,33 @@
# Notes on Wayland and clipboard support
### Running espanso as another user
When running espanso as another user, we need to set up a couple of permissions
in order to enable the clipboard tools to correctly connect to the Wayland desktop.
In particular, we need to add the `espanso` user to the same group as the current user
so that it can access the `/run/user/X` directory (with X depending on the user).
```
# Find the current user wayland dir with
echo $XDG_RUNTIME_DIR # in my case output: /run/user/1000
ls -la /run/user/1000
# Now add the `espanso` user to the current user group
sudo usermod -a -G freddy espanso
# Give permissions to the group
chmod g+rwx /run/user/1000
# Give write permission to the wayland socket
chmod g+w /run/user/1000/wayland-0
```
Now the clipboard should work as expected
## Better implementation
On some Wayland compositors (currently sway), the "wlr-data-control" protocol could enable the use of a much more efficient implementation relying on the "wl-clipboard-rs" crate.
Useful links: https://github.com/YaLTeR/wl-clipboard-rs/issues/8

View File

@ -0,0 +1,201 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{
io::{Read, Write},
os::unix::net::UnixStream,
path::PathBuf,
process::Stdio,
};
use crate::{Clipboard, ClipboardOptions};
use anyhow::Result;
use log::error;
use std::process::Command;
use thiserror::Error;
use wait_timeout::ChildExt;
pub(crate) struct WaylandFallbackClipboard {
command_timeout: u64,
}
impl WaylandFallbackClipboard {
pub fn new(options: ClipboardOptions) -> Result<Self> {
// Make sure wl-paste and wl-copy are available
if Command::new("wl-paste").arg("--version").output().is_err() {
error!("unable to call 'wl-paste' binary, please install the wl-clipboard package.");
return Err(WaylandFallbackClipboardError::MissingWLClipboard().into());
}
if Command::new("wl-copy").arg("--version").output().is_err() {
error!("unable to call 'wl-copy' binary, please install the wl-clipboard package.");
return Err(WaylandFallbackClipboardError::MissingWLClipboard().into());
}
// Try to connect to the wayland display
let wayland_socket = if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(runtime_dir).join("wayland-0")
} else {
error!("environment variable XDG_RUNTIME_DIR is missing, can't initialize the clipboard");
return Err(WaylandFallbackClipboardError::MissingEnvVariable().into());
};
if UnixStream::connect(wayland_socket).is_err() {
error!("failed to connect to Wayland display");
return Err(WaylandFallbackClipboardError::ConnectionFailed().into());
}
Ok(Self {
command_timeout: options.wayland_command_timeout_ms,
})
}
}
impl Clipboard for WaylandFallbackClipboard {
fn get_text(&self) -> Option<String> {
let timeout = std::time::Duration::from_millis(self.command_timeout);
match Command::new("wl-paste")
.arg("--no-newline")
.stdout(Stdio::piped())
.spawn()
{
Ok(mut child) => match child.wait_timeout(timeout) {
Ok(status_code) => {
if let Some(status) = status_code {
if status.success() {
if let Some(mut io) = child.stdout {
let mut output = Vec::new();
io.read_to_end(&mut output).ok()?;
Some(String::from_utf8_lossy(&output).to_string())
} else {
None
}
} else {
error!("error, wl-paste exited with non-zero exit code");
None
}
} else {
error!("error, wl-paste has timed-out, killing the process");
if child.kill().is_err() {
error!("unable to kill wl-paste");
}
None
}
}
Err(err) => {
error!("error while executing 'wl-paste': {}", err);
None
}
},
Err(err) => {
error!("could not invoke 'wl-paste': {}", err);
None
}
}
}
fn set_text(&self, text: &str) -> anyhow::Result<()> {
self.invoke_command_with_timeout(&mut Command::new("wl-copy"), text.as_bytes(), "wl-copy")
}
fn set_image(&self, image_path: &std::path::Path) -> anyhow::Result<()> {
if !image_path.exists() || !image_path.is_file() {
return Err(WaylandFallbackClipboardError::ImageNotFound(image_path.to_path_buf()).into());
}
// Load the image data
let mut file = std::fs::File::open(image_path)?;
let mut data = Vec::new();
file.read_to_end(&mut data)?;
self.invoke_command_with_timeout(
&mut Command::new("wl-copy").arg("--type").arg("image/png"),
&data,
"wl-copy",
)
}
fn set_html(&self, html: &str, _fallback_text: Option<&str>) -> anyhow::Result<()> {
self.invoke_command_with_timeout(
&mut Command::new("wl-copy").arg("--type").arg("text/html"),
html.as_bytes(),
"wl-copy",
)
}
}
impl WaylandFallbackClipboard {
fn invoke_command_with_timeout(
&self,
command: &mut Command,
data: &[u8],
name: &str,
) -> Result<()> {
let timeout = std::time::Duration::from_millis(self.command_timeout);
match command.stdin(Stdio::piped()).spawn() {
Ok(mut child) => {
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(data)?;
}
match child.wait_timeout(timeout) {
Ok(status_code) => {
if let Some(status) = status_code {
if status.success() {
Ok(())
} else {
error!("error, {} exited with non-zero exit code", name);
Err(WaylandFallbackClipboardError::SetOperationFailed().into())
}
} else {
error!("error, {} has timed-out, killing the process", name);
if child.kill().is_err() {
error!("unable to kill {}", name);
}
Err(WaylandFallbackClipboardError::SetOperationFailed().into())
}
}
Err(err) => {
error!("error while executing '{}': {}", name, err);
Err(WaylandFallbackClipboardError::SetOperationFailed().into())
}
}
}
Err(err) => {
error!("could not invoke '{}': {}", name, err);
Err(WaylandFallbackClipboardError::SetOperationFailed().into())
}
}
}
}
#[derive(Error, Debug)]
pub(crate) enum WaylandFallbackClipboardError {
#[error("wl-clipboard binaries are missing")]
MissingWLClipboard(),
#[error("missing XDG_RUNTIME_DIR env variable")]
MissingEnvVariable(),
#[error("can't connect to Wayland display")]
ConnectionFailed(),
#[error("clipboard set operation failed")]
SetOperationFailed(),
#[error("image not found: `{0}`")]
ImageNotFound(PathBuf),
}

View File

@ -1,7 +1,7 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019 Federico Terzi
* Copyright (C) 2019-2021 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,10 +17,4 @@
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
#import <Cocoa/Cocoa.h>
@interface AppDelegate : NSObject <NSApplicationDelegate, NSUserNotificationCenterDelegate>
@end
pub(crate) mod fallback;

View File

@ -0,0 +1,28 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::os::raw::c_char;
#[link(name = "espansoclipboard", kind = "static")]
extern "C" {
pub fn clipboard_get_text(buffer: *mut u16, buffer_size: i32) -> i32;
pub fn clipboard_set_text(text: *const u16) -> i32;
pub fn clipboard_set_image(image_path: *const u16) -> i32;
pub fn clipboard_set_html(html_descriptor: *const c_char, fallback_text: *const u16) -> i32;
}

View File

@ -0,0 +1,147 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
mod ffi;
use std::{ffi::CString, path::PathBuf};
use crate::Clipboard;
use anyhow::Result;
use log::error;
use thiserror::Error;
use widestring::{U16CStr, U16CString};
pub struct Win32Clipboard {}
impl Win32Clipboard {
pub fn new() -> Result<Self> {
Ok(Self {})
}
}
impl Clipboard for Win32Clipboard {
fn get_text(&self) -> Option<String> {
let mut buffer: [u16; 2048] = [0; 2048];
let native_result =
unsafe { ffi::clipboard_get_text(buffer.as_mut_ptr(), (buffer.len() - 1) as i32) };
if native_result > 0 {
let string = unsafe { U16CStr::from_ptr_str(buffer.as_ptr()) };
Some(string.to_string_lossy())
} else {
None
}
}
fn set_text(&self, text: &str) -> anyhow::Result<()> {
let string = U16CString::from_str(text)?;
let native_result = unsafe { ffi::clipboard_set_text(string.as_ptr()) };
if native_result > 0 {
Ok(())
} else {
Err(Win32ClipboardError::SetOperationFailed().into())
}
}
fn set_image(&self, image_path: &std::path::Path) -> anyhow::Result<()> {
if !image_path.exists() || !image_path.is_file() {
return Err(Win32ClipboardError::ImageNotFound(image_path.to_path_buf()).into());
}
let path = U16CString::from_os_str(image_path.as_os_str())?;
let native_result = unsafe { ffi::clipboard_set_image(path.as_ptr()) };
if native_result > 0 {
Ok(())
} else {
Err(Win32ClipboardError::SetOperationFailed().into())
}
}
fn set_html(&self, html: &str, fallback_text: Option<&str>) -> anyhow::Result<()> {
let html_descriptor = generate_html_descriptor(html);
let html_string = CString::new(html_descriptor)?;
let fallback_string = U16CString::from_str(fallback_text.unwrap_or_default())?;
let fallback_ptr = if fallback_text.is_some() {
fallback_string.as_ptr()
} else {
std::ptr::null()
};
let native_result = unsafe { ffi::clipboard_set_html(html_string.as_ptr(), fallback_ptr) };
if native_result > 0 {
Ok(())
} else {
Err(Win32ClipboardError::SetOperationFailed().into())
}
}
}
fn generate_html_descriptor(html: &str) -> String {
// In order to set the HTML clipboard, we have to create a prefix with a specific format
// For more information, look here:
// https://docs.microsoft.com/en-us/windows/win32/dataxchg/html-clipboard-format
// https://docs.microsoft.com/en-za/troubleshoot/cpp/add-html-code-clipboard
let content = format!("<!--StartFragment-->{}<!--EndFragment-->", html);
let tokens = vec![
"Version:0.9",
"StartHTML:<<STR*#>",
"EndHTML:<<END*#>",
"StartFragment:<<SFG#*>",
"EndFragment:<<EFG#*>",
"<html>",
"<body>",
&content,
"</body>",
"</html>",
];
let mut render = tokens.join("\r\n");
// Now replace the placeholders with the actual positions
render = render.replace(
"<<STR*#>",
&format!("{:0>8}", render.find("<html>").unwrap_or_default()),
);
render = render.replace("<<END*#>", &format!("{:0>8}", render.len()));
render = render.replace(
"<<SFG#*>",
&format!(
"{:0>8}",
render.find("<!--StartFragment-->").unwrap_or_default() + "<!--StartFragment-->".len()
),
);
render = render.replace(
"<<EFG#*>",
&format!(
"{:0>8}",
render.find("<!--EndFragment-->").unwrap_or_default()
),
);
render
}
#[derive(Error, Debug)]
pub enum Win32ClipboardError {
#[error("clipboard set operation failed")]
SetOperationFailed(),
#[error("image not found: `{0}`")]
ImageNotFound(PathBuf),
}

View File

@ -0,0 +1,184 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#include "native.h"
#include <iostream>
#include <stdio.h>
#include <string>
#include <vector>
#include <memory>
#include <array>
#define UNICODE
#ifdef __MINGW32__
#ifndef WINVER
#define WINVER 0x0606
#endif
#define STRSAFE_NO_DEPRECATE
#endif
#include <windows.h>
#include <winuser.h>
#include <strsafe.h>
#include <gdiplus.h>
#include <Windows.h>
int32_t clipboard_get_text(wchar_t *buffer, int32_t buffer_size)
{
int32_t result = 0;
if (OpenClipboard(NULL))
{
HANDLE hData;
if (hData = GetClipboardData(CF_UNICODETEXT))
{
HGLOBAL hMem;
if (hMem = GlobalLock(hData))
{
GlobalUnlock(hMem);
wcsncpy(buffer, (wchar_t *)hMem, buffer_size);
if (wcsnlen_s(buffer, buffer_size) > 0)
{
result = 1;
}
}
}
CloseClipboard();
}
return result;
}
int32_t clipboard_set_text(wchar_t *text)
{
int32_t result = 0;
const size_t len = wcslen(text) + 1;
if (OpenClipboard(NULL))
{
EmptyClipboard();
HGLOBAL hMem;
if (hMem = GlobalAlloc(GMEM_MOVEABLE, len * sizeof(wchar_t)))
{
memcpy(GlobalLock(hMem), text, len * sizeof(wchar_t));
GlobalUnlock(hMem);
if (SetClipboardData(CF_UNICODETEXT, hMem))
{
result = 1;
}
}
CloseClipboard();
}
return result;
}
int32_t clipboard_set_image(wchar_t *path)
{
int32_t result = 0;
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
ULONG_PTR gdiplusToken;
Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL);
Gdiplus::Bitmap *gdibmp = Gdiplus::Bitmap::FromFile(path);
if (gdibmp)
{
HBITMAP hbitmap;
gdibmp->GetHBITMAP(0, &hbitmap);
if (OpenClipboard(NULL))
{
EmptyClipboard();
DIBSECTION ds;
if (GetObject(hbitmap, sizeof(DIBSECTION), &ds))
{
HDC hdc = GetDC(HWND_DESKTOP);
//create compatible bitmap (get DDB from DIB)
HBITMAP hbitmap_ddb = CreateDIBitmap(hdc, &ds.dsBmih, CBM_INIT,
ds.dsBm.bmBits, (BITMAPINFO *)&ds.dsBmih, DIB_RGB_COLORS);
ReleaseDC(HWND_DESKTOP, hdc);
SetClipboardData(CF_BITMAP, hbitmap_ddb);
DeleteObject(hbitmap_ddb);
result = 1;
}
CloseClipboard();
}
DeleteObject(hbitmap);
delete gdibmp;
}
Gdiplus::GdiplusShutdown(gdiplusToken);
return result;
}
// Inspired by https://docs.microsoft.com/en-za/troubleshoot/cpp/add-html-code-clipboard
int32_t clipboard_set_html(char * html_descriptor, wchar_t * fallback_text) {
// Get clipboard id for HTML format
static int cfid = 0;
if(!cfid) {
cfid = RegisterClipboardFormat(L"HTML Format");
}
int32_t result = 0;
const size_t html_len = strlen(html_descriptor) + 1;
const size_t fallback_len = (fallback_text != nullptr) ? wcslen(fallback_text) + 1 : 0;
if (OpenClipboard(NULL))
{
EmptyClipboard();
// First copy the HTML
HGLOBAL hMem;
if (hMem = GlobalAlloc(GMEM_MOVEABLE, html_len * sizeof(char)))
{
memcpy(GlobalLock(hMem), html_descriptor, html_len * sizeof(char));
GlobalUnlock(hMem);
if (SetClipboardData(cfid, hMem))
{
result = 1;
}
}
// Then try to set the fallback text, if present.
if (fallback_len > 0) {
if (hMem = GlobalAlloc(GMEM_MOVEABLE, fallback_len * sizeof(wchar_t)))
{
memcpy(GlobalLock(hMem), fallback_text, fallback_len * sizeof(wchar_t));
GlobalUnlock(hMem);
SetClipboardData(CF_UNICODETEXT, hMem);
}
}
CloseClipboard();
}
return result;
}

View File

@ -0,0 +1,30 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#ifndef ESPANSO_CLIPBOARD_H
#define ESPANSO_CLIPBOARD_H
#include <stdint.h>
extern "C" int32_t clipboard_get_text(wchar_t * buffer, int32_t buffer_size);
extern "C" int32_t clipboard_set_text(wchar_t * text);
extern "C" int32_t clipboard_set_image(wchar_t * image);
extern "C" int32_t clipboard_set_html(char * html_descriptor, wchar_t * fallback_text);
#endif //ESPANSO_CLIPBOARD_H

View File

@ -0,0 +1,20 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
pub(crate) mod native;

View File

@ -0,0 +1,4 @@
The X11NativeClipboard modules uses the wonderful [clip](https://github.com/dacap/clip) library
by David Capello to manipulate the clipboard.
At the time of writing, the library is MIT licensed.

View File

@ -0,0 +1,20 @@
Copyright (c) 2015-2020 David Capello
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,174 @@
// Clip Library
// Copyright (c) 2015-2018 David Capello
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#include "clip.h"
#include "clip_lock_impl.h"
#include <vector>
#include <stdexcept>
namespace clip {
namespace {
void default_error_handler(ErrorCode code) {
static const char* err[] = {
"Cannot lock clipboard",
"Image format is not supported"
};
throw std::runtime_error(err[static_cast<int>(code)]);
}
} // anonymous namespace
error_handler g_error_handler = default_error_handler;
lock::lock(void* native_window_handle)
: p(new impl(native_window_handle)) {
}
lock::~lock() = default;
bool lock::locked() const {
return p->locked();
}
bool lock::clear() {
return p->clear();
}
bool lock::is_convertible(format f) const {
return p->is_convertible(f);
}
bool lock::set_data(format f, const char* buf, size_t length) {
return p->set_data(f, buf, length);
}
bool lock::get_data(format f, char* buf, size_t len) const {
return p->get_data(f, buf, len);
}
size_t lock::get_data_length(format f) const {
return p->get_data_length(f);
}
bool lock::set_image(const image& img) {
return p->set_image(img);
}
bool lock::get_image(image& img) const {
return p->get_image(img);
}
bool lock::get_image_spec(image_spec& spec) const {
return p->get_image_spec(spec);
}
format empty_format() { return 0; }
format text_format() { return 1; }
format image_format() { return 2; }
bool has(format f) {
lock l;
if (l.locked())
return l.is_convertible(f);
else
return false;
}
bool clear() {
lock l;
if (l.locked())
return l.clear();
else
return false;
}
bool set_text(const std::string& value) {
lock l;
if (l.locked()) {
l.clear();
return l.set_data(text_format(), value.c_str(), value.size());
}
else
return false;
}
bool get_text(std::string& value) {
lock l;
if (!l.locked())
return false;
format f = text_format();
if (!l.is_convertible(f))
return false;
size_t len = l.get_data_length(f);
if (len > 0) {
std::vector<char> buf(len);
l.get_data(f, &buf[0], len);
value = &buf[0];
return true;
}
else {
value.clear();
return true;
}
}
bool set_image(const image& img) {
lock l;
if (l.locked()) {
l.clear();
return l.set_image(img);
}
else
return false;
}
bool get_image(image& img) {
lock l;
if (!l.locked())
return false;
format f = image_format();
if (!l.is_convertible(f))
return false;
return l.get_image(img);
}
bool get_image_spec(image_spec& spec) {
lock l;
if (!l.locked())
return false;
format f = image_format();
if (!l.is_convertible(f))
return false;
return l.get_image_spec(spec);
}
void set_error_handler(error_handler handler) {
g_error_handler = handler;
}
error_handler get_error_handler() {
return g_error_handler;
}
#ifdef HAVE_XCB_XLIB_H
static int g_x11_timeout = 1000;
void set_x11_wait_timeout(int msecs) { g_x11_timeout = msecs; }
int get_x11_wait_timeout() { return g_x11_timeout; }
#else
void set_x11_wait_timeout(int) { }
int get_x11_wait_timeout() { return 1000; }
#endif
} // namespace clip

View File

@ -0,0 +1,178 @@
// Clip Library
// Copyright (c) 2015-2018 David Capello
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#ifndef CLIP_H_INCLUDED
#define CLIP_H_INCLUDED
#pragma once
#include <cassert>
#include <memory>
#include <string>
namespace clip {
// ======================================================================
// Low-level API to lock the clipboard/pasteboard and modify it
// ======================================================================
// Clipboard format identifier.
typedef size_t format;
class image;
struct image_spec;
class lock {
public:
// You can give your current HWND as the "native_window_handle."
// Windows clipboard functions use this handle to open/close
// (lock/unlock) the clipboard. From the MSDN documentation we
// need this handler so SetClipboardData() doesn't fail after a
// EmptyClipboard() call. Anyway it looks to work just fine if we
// call OpenClipboard() with a null HWND.
lock(void* native_window_handle = nullptr);
~lock();
// Returns true if we've locked the clipboard successfully in
// lock() constructor.
bool locked() const;
// Clears the clipboard content. If you don't clear the content,
// previous clipboard content (in unknown formats) could persist
// after the unlock.
bool clear();
// Returns true if the clipboard can be converted to the given
// format.
bool is_convertible(format f) const;
bool set_data(format f, const char* buf, size_t len);
bool get_data(format f, char* buf, size_t len) const;
size_t get_data_length(format f) const;
// For images
bool set_image(const image& image);
bool get_image(image& image) const;
bool get_image_spec(image_spec& spec) const;
private:
class impl;
std::unique_ptr<impl> p;
};
format register_format(const std::string& name);
// This format is when the clipboard has no content.
format empty_format();
// When the clipboard has UTF8 text.
format text_format();
// When the clipboard has an image.
format image_format();
// Returns true if the clipboard has content of the given type.
bool has(format f);
// Clears the clipboard content.
bool clear();
// ======================================================================
// Error handling
// ======================================================================
enum class ErrorCode {
CannotLock,
ImageNotSupported,
};
typedef void (*error_handler)(ErrorCode code);
void set_error_handler(error_handler f);
error_handler get_error_handler();
// ======================================================================
// Text
// ======================================================================
// High-level API to put/get UTF8 text in/from the clipboard. These
// functions returns false in case of error.
bool set_text(const std::string& value);
bool get_text(std::string& value);
// ======================================================================
// Image
// ======================================================================
struct image_spec {
unsigned long width = 0;
unsigned long height = 0;
unsigned long bits_per_pixel = 0;
unsigned long bytes_per_row = 0;
unsigned long red_mask = 0;
unsigned long green_mask = 0;
unsigned long blue_mask = 0;
unsigned long alpha_mask = 0;
unsigned long red_shift = 0;
unsigned long green_shift = 0;
unsigned long blue_shift = 0;
unsigned long alpha_shift = 0;
};
// The image data must contain straight RGB values
// (non-premultiplied by alpha). The image retrieved from the
// clipboard will be non-premultiplied too. Basically you will be
// always dealing with straight alpha images.
//
// Details: Windows expects premultiplied images on its clipboard
// content, so the library code make the proper conversion
// automatically. macOS handles straight alpha directly, so there is
// no conversion at all. Linux/X11 images are transferred in
// image/png format which are specified in straight alpha.
class image {
public:
image();
image(const image_spec& spec);
image(const void* data, const image_spec& spec);
image(const image& image);
image(image&& image);
~image();
image& operator=(const image& image);
image& operator=(image&& image);
char* data() const { return m_data; }
const image_spec& spec() const { return m_spec; }
bool is_valid() const { return m_data != nullptr; }
void reset();
private:
void copy_image(const image& image);
void move_image(image&& image);
bool m_own_data;
char* m_data;
image_spec m_spec;
};
// High-level API to set/get an image in/from the clipboard. These
// functions returns false in case of error.
bool set_image(const image& img);
bool get_image(image& img);
bool get_image_spec(image_spec& spec);
// ======================================================================
// Platform-specific
// ======================================================================
// Only for X11: Sets the time (in milliseconds) that we must wait
// for the selection/clipboard owner to receive the content. This
// value is 1000 (one second) by default.
void set_x11_wait_timeout(int msecs);
int get_x11_wait_timeout();
} // namespace clip
#endif // CLIP_H_INCLUDED

View File

@ -0,0 +1,76 @@
// Clip Library
// Copyright (C) 2020 David Capello
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#ifndef CLIP_COMMON_H_INCLUDED
#define CLIP_COMMON_H_INCLUDED
#pragma once
namespace clip {
namespace details {
inline void divide_rgb_by_alpha(image& img,
bool hasAlphaGreaterThanZero = false) {
const image_spec& spec = img.spec();
bool hasValidPremultipliedAlpha = true;
for (unsigned long y=0; y<spec.height; ++y) {
const uint32_t* dst = (uint32_t*)(img.data()+y*spec.bytes_per_row);
for (unsigned long x=0; x<spec.width; ++x, ++dst) {
const uint32_t c = *dst;
const int r = ((c & spec.red_mask ) >> spec.red_shift );
const int g = ((c & spec.green_mask) >> spec.green_shift);
const int b = ((c & spec.blue_mask ) >> spec.blue_shift );
const int a = ((c & spec.alpha_mask) >> spec.alpha_shift);
if (a > 0)
hasAlphaGreaterThanZero = true;
if (r > a || g > a || b > a)
hasValidPremultipliedAlpha = false;
}
}
for (unsigned long y=0; y<spec.height; ++y) {
uint32_t* dst = (uint32_t*)(img.data()+y*spec.bytes_per_row);
for (unsigned long x=0; x<spec.width; ++x, ++dst) {
const uint32_t c = *dst;
int r = ((c & spec.red_mask ) >> spec.red_shift );
int g = ((c & spec.green_mask) >> spec.green_shift);
int b = ((c & spec.blue_mask ) >> spec.blue_shift );
int a = ((c & spec.alpha_mask) >> spec.alpha_shift);
// If all alpha values = 0, we make the image opaque.
if (!hasAlphaGreaterThanZero) {
a = 255;
// We cannot change the image spec (e.g. spec.alpha_mask=0) to
// make the image opaque, because the "spec" of the image is
// read-only. The image spec used by the client is the one
// returned by get_image_spec().
}
// If there is alpha information and it's pre-multiplied alpha
else if (hasValidPremultipliedAlpha) {
if (a > 0) {
// Convert it to straight alpha
r = r * 255 / a;
g = g * 255 / a;
b = b * 255 / a;
}
}
*dst =
(r << spec.red_shift ) |
(g << spec.green_shift) |
(b << spec.blue_shift ) |
(a << spec.alpha_shift);
}
}
}
} // namespace details
} // namespace clip
#endif // CLIP_H_INCLUDED

View File

@ -0,0 +1,33 @@
// Clip Library
// Copyright (c) 2015-2018 David Capello
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#ifndef CLIP_LOCK_IMPL_H_INCLUDED
#define CLIP_LOCK_IMPL_H_INCLUDED
namespace clip {
class lock::impl {
public:
impl(void* native_window_handle);
~impl();
bool locked() const { return m_locked; }
bool clear();
bool is_convertible(format f) const;
bool set_data(format f, const char* buf, size_t len);
bool get_data(format f, char* buf, size_t len) const;
size_t get_data_length(format f) const;
bool set_image(const image& image);
bool get_image(image& image) const;
bool get_image_spec(image_spec& spec) const;
private:
bool m_locked;
};
} // namespace clip
#endif

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,225 @@
// Clip Library
// Copyright (c) 2018 David Capello
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#include "clip.h"
#include <algorithm>
#include <vector>
#include "png.h"
namespace clip {
namespace x11 {
//////////////////////////////////////////////////////////////////////
// Functions to convert clip::image into png data to store it in the
// clipboard.
void write_data_fn(png_structp png, png_bytep buf, png_size_t len) {
std::vector<uint8_t>& output = *(std::vector<uint8_t>*)png_get_io_ptr(png);
const size_t i = output.size();
output.resize(i+len);
std::copy(buf, buf+len, output.begin()+i);
}
bool write_png(const image& image,
std::vector<uint8_t>& output) {
png_structp png = png_create_write_struct(PNG_LIBPNG_VER_STRING,
nullptr, nullptr, nullptr);
if (!png)
return false;
png_infop info = png_create_info_struct(png);
if (!info) {
png_destroy_write_struct(&png, nullptr);
return false;
}
if (setjmp(png_jmpbuf(png))) {
png_destroy_write_struct(&png, &info);
return false;
}
png_set_write_fn(png,
(png_voidp)&output,
write_data_fn,
nullptr); // No need for a flush function
const image_spec& spec = image.spec();
int color_type = (spec.alpha_mask ?
PNG_COLOR_TYPE_RGB_ALPHA:
PNG_COLOR_TYPE_RGB);
png_set_IHDR(png, info,
spec.width, spec.height, 8, color_type,
PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_BASE, PNG_FILTER_TYPE_BASE);
png_write_info(png, info);
png_set_packing(png);
png_bytep row =
(png_bytep)png_malloc(png, png_get_rowbytes(png, info));
for (png_uint_32 y=0; y<spec.height; ++y) {
const uint32_t* src =
(const uint32_t*)(((const uint8_t*)image.data())
+ y*spec.bytes_per_row);
uint8_t* dst = row;
unsigned int x, c;
for (x=0; x<spec.width; x++) {
c = *(src++);
*(dst++) = (c & spec.red_mask ) >> spec.red_shift;
*(dst++) = (c & spec.green_mask) >> spec.green_shift;
*(dst++) = (c & spec.blue_mask ) >> spec.blue_shift;
if (color_type == PNG_COLOR_TYPE_RGB_ALPHA)
*(dst++) = (c & spec.alpha_mask) >> spec.alpha_shift;
}
png_write_rows(png, &row, 1);
}
png_free(png, row);
png_write_end(png, info);
png_destroy_write_struct(&png, &info);
return true;
}
//////////////////////////////////////////////////////////////////////
// Functions to convert png data stored in the clipboard to a
// clip::image.
struct read_png_io {
const uint8_t* buf;
size_t len;
size_t pos;
};
void read_data_fn(png_structp png, png_bytep buf, png_size_t len) {
read_png_io& io = *(read_png_io*)png_get_io_ptr(png);
if (io.pos < io.len) {
size_t n = std::min(len, io.len-io.pos);
if (n > 0) {
std::copy(io.buf+io.pos,
io.buf+io.pos+n,
buf);
io.pos += n;
}
}
}
bool read_png(const uint8_t* buf,
const size_t len,
image* output_image,
image_spec* output_spec) {
png_structp png = png_create_read_struct(PNG_LIBPNG_VER_STRING,
nullptr, nullptr, nullptr);
if (!png)
return false;
png_infop info = png_create_info_struct(png);
if (!info) {
png_destroy_read_struct(&png, nullptr, nullptr);
return false;
}
if (setjmp(png_jmpbuf(png))) {
png_destroy_read_struct(&png, &info, nullptr);
return false;
}
read_png_io io = { buf, len, 0 };
png_set_read_fn(png, (png_voidp)&io, read_data_fn);
png_read_info(png, info);
png_uint_32 width, height;
int bit_depth, color_type, interlace_type;
png_get_IHDR(png, info, &width, &height,
&bit_depth, &color_type,
&interlace_type,
nullptr, nullptr);
image_spec spec;
spec.width = width;
spec.height = height;
spec.bits_per_pixel = 32;
spec.bytes_per_row = png_get_rowbytes(png, info);
spec.red_mask = 0x000000ff;
spec.green_mask = 0x0000ff00;
spec.blue_mask = 0x00ff0000;
spec.red_shift = 0;
spec.green_shift = 8;
spec.blue_shift = 16;
if (color_type == PNG_COLOR_TYPE_RGB_ALPHA ||
color_type == PNG_COLOR_TYPE_GRAY_ALPHA) {
spec.alpha_mask = 0xff000000;
spec.alpha_shift = 24;
}
else {
spec.alpha_mask = 0;
spec.alpha_shift = 0;
}
if (output_spec)
*output_spec = spec;
if (output_image &&
width > 0 &&
height > 0) {
image img(spec);
// We want RGB 24-bit or RGBA 32-bit as a result
png_set_strip_16(png); // Down to 8-bit
png_set_packing(png); // Use one byte if color depth < 8-bit
png_set_expand_gray_1_2_4_to_8(png);
png_set_palette_to_rgb(png);
png_set_gray_to_rgb(png);
png_set_tRNS_to_alpha(png);
int number_passes = png_set_interlace_handling(png);
png_read_update_info(png, info);
png_bytepp rows = (png_bytepp)png_malloc(png, sizeof(png_bytep)*height);
png_uint_32 y;
for (y=0; y<height; ++y)
rows[y] = (png_bytep)png_malloc(png, spec.bytes_per_row);
for (int pass=0; pass<number_passes; ++pass)
for (y=0; y<height; ++y)
png_read_rows(png, rows+y, nullptr, 1);
for (y=0; y<height; ++y) {
const uint8_t* src = rows[y];
uint32_t* dst = (uint32_t*)(img.data() + y*spec.bytes_per_row);
unsigned int x, r, g, b, a = 255;
for (x=0; x<width; x++) {
r = *(src++);
g = *(src++);
b = *(src++);
if (spec.alpha_mask)
a = *(src++);
*(dst++) =
(r << spec.red_shift) |
(g << spec.green_shift) |
(b << spec.blue_shift) |
(a << spec.alpha_shift);
}
png_free(png, rows[y]);
}
png_free(png, rows);
std::swap(*output_image, img);
}
png_destroy_read_struct(&png, &info, nullptr);
return true;
}
} // namespace x11
} // namespace clip

View File

@ -0,0 +1,83 @@
// Clip Library
// Copyright (c) 2015-2018 David Capello
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#include "clip.h"
namespace clip {
image::image()
: m_own_data(false),
m_data(nullptr)
{
}
image::image(const image_spec& spec)
: m_own_data(true),
m_data(new char[spec.bytes_per_row*spec.height]),
m_spec(spec) {
}
image::image(const void* data, const image_spec& spec)
: m_own_data(false),
m_data((char*)data),
m_spec(spec) {
}
image::image(const image& image)
: m_own_data(false),
m_data(nullptr),
m_spec(image.m_spec) {
copy_image(image);
}
image::image(image&& image)
: m_own_data(false),
m_data(nullptr) {
move_image(std::move(image));
}
image::~image() {
reset();
}
image& image::operator=(const image& image) {
copy_image(image);
return *this;
}
image& image::operator=(image&& image) {
move_image(std::move(image));
return *this;
}
void image::reset() {
if (m_own_data) {
delete[] m_data;
m_own_data = false;
m_data = nullptr;
}
}
void image::copy_image(const image& image) {
reset();
m_spec = image.spec();
std::size_t n = m_spec.bytes_per_row*m_spec.height;
m_own_data = true;
m_data = new char[n];
std::copy(image.data(),
image.data()+n,
m_data);
}
void image::move_image(image&& image) {
std::swap(m_own_data, image.m_own_data);
std::swap(m_data, image.m_data);
std::swap(m_spec, image.m_spec);
}
} // namespace clip

View File

@ -0,0 +1,28 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::os::raw::c_char;
#[link(name = "espansoclipboardx11", kind = "static")]
extern "C" {
pub fn clipboard_x11_get_text(buffer: *mut c_char, buffer_size: i32) -> i32;
pub fn clipboard_x11_set_text(text: *const c_char) -> i32;
pub fn clipboard_x11_set_html(html: *const c_char, fallback_text: *const c_char) -> i32;
pub fn clipboard_x11_set_image(buffer: *const u8, buffer_size: i32) -> i32;
}

View File

@ -0,0 +1,108 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{
ffi::{CStr, CString},
io::Read,
path::PathBuf,
};
use crate::Clipboard;
use anyhow::Result;
use std::os::raw::c_char;
use thiserror::Error;
mod ffi;
pub struct X11NativeClipboard {}
impl X11NativeClipboard {
pub fn new() -> Result<Self> {
Ok(Self {})
}
}
impl Clipboard for X11NativeClipboard {
fn get_text(&self) -> Option<String> {
let mut buffer: [c_char; 2048] = [0; 2048];
let native_result =
unsafe { ffi::clipboard_x11_get_text(buffer.as_mut_ptr(), (buffer.len() - 1) as i32) };
if native_result > 0 {
let string = unsafe { CStr::from_ptr(buffer.as_ptr()) };
Some(string.to_string_lossy().to_string())
} else {
None
}
}
fn set_text(&self, text: &str) -> anyhow::Result<()> {
let string = CString::new(text)?;
let native_result = unsafe { ffi::clipboard_x11_set_text(string.as_ptr()) };
if native_result > 0 {
Ok(())
} else {
Err(X11NativeClipboardError::SetOperationFailed().into())
}
}
fn set_image(&self, image_path: &std::path::Path) -> anyhow::Result<()> {
if !image_path.exists() || !image_path.is_file() {
return Err(X11NativeClipboardError::ImageNotFound(image_path.to_path_buf()).into());
}
// Load the image data
let mut file = std::fs::File::open(image_path)?;
let mut data = Vec::new();
file.read_to_end(&mut data)?;
let native_result = unsafe { ffi::clipboard_x11_set_image(data.as_ptr(), data.len() as i32) };
if native_result > 0 {
Ok(())
} else {
Err(X11NativeClipboardError::SetOperationFailed().into())
}
}
fn set_html(&self, html: &str, fallback_text: Option<&str>) -> anyhow::Result<()> {
let html_string = CString::new(html)?;
let fallback_string = CString::new(fallback_text.unwrap_or_default())?;
let fallback_ptr = if fallback_text.is_some() {
fallback_string.as_ptr()
} else {
std::ptr::null()
};
let native_result = unsafe { ffi::clipboard_x11_set_html(html_string.as_ptr(), fallback_ptr) };
if native_result > 0 {
Ok(())
} else {
Err(X11NativeClipboardError::SetOperationFailed().into())
}
}
}
#[derive(Error, Debug)]
pub enum X11NativeClipboardError {
#[error("clipboard set operation failed")]
SetOperationFailed(),
#[error("image not found: `{0}`")]
ImageNotFound(PathBuf),
}

View File

@ -0,0 +1,76 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#include "native.h"
#include "clip/clip.h"
#include "string.h"
#include <iostream>
clip::format html_format = clip::register_format("text/html");
clip::format png_format = clip::register_format("image/png");
int32_t clipboard_x11_get_text(char * buffer, int32_t buffer_size) {
std::string value;
if (!clip::get_text(value)) {
return 0;
}
if (value.length() == 0) {
return 0;
}
strncpy(buffer, value.c_str(), buffer_size - 1);
return 1;
}
int32_t clipboard_x11_set_text(char * text) {
if (!clip::set_text(text)) {
return 0;
} else {
return 1;
}
}
int32_t clipboard_x11_set_html(char * html, char * fallback_text) {
clip::lock l;
if (!l.clear()) {
return 0;
}
if (!l.set_data(html_format, html, strlen(html))) {
return 0;
}
if (fallback_text) {
// Best effort to set the fallback
l.set_data(clip::text_format(), fallback_text, strlen(fallback_text));
}
return 1;
}
int32_t clipboard_x11_set_image(char * buffer, int32_t size) {
clip::lock l;
if (!l.clear()) {
return 0;
}
if (!l.set_data(png_format, buffer, size)) {
return 0;
}
return 1;
}

View File

@ -0,0 +1,31 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#ifndef ESPANSO_X11_CLIPBOARD_H
#define ESPANSO_X11_CLIPBOARD_H
#include <stdint.h>
extern "C" int32_t clipboard_x11_get_text(char * buffer, int32_t buffer_size);
extern "C" int32_t clipboard_x11_set_text(char * text);
extern "C" int32_t clipboard_x11_set_html(char * html, char * fallback_text);
extern "C" int32_t clipboard_x11_set_image(char * buffer, int32_t buffer_size);
#endif //ESPANSO_X11_CLIPBOARD_H

25
espanso-config/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "espanso-config"
version = "0.1.0"
authors = ["Federico Terzi <federico-terzi@users.noreply.github.com>"]
edition = "2018"
[dependencies]
log = "0.4.14"
anyhow = "1.0.38"
thiserror = "1.0.23"
serde = { version = "1.0.123", features = ["derive"] }
serde_yaml = "0.8.17"
glob = "0.3.0"
regex = "1.4.3"
lazy_static = "1.4.0"
dunce = "1.0.1"
walkdir = "2.3.1"
enum-as-inner = "0.3.3"
ordered-float = "2.0"
indoc = "1.0.3"
[dev-dependencies]
tempdir = "0.3.7"
tempfile = "3.2.0"
mockall = "0.9.1"

View File

@ -0,0 +1,23 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
pub(crate) const DEFAULT_CLIPBOARD_THRESHOLD: usize = 100;
pub(crate) const DEFAULT_PRE_PASTE_DELAY: usize = 100;
pub(crate) const DEFAULT_SHORTCUT_EVENT_DELAY: usize = 10;
pub(crate) const DEFAULT_RESTORE_CLIPBOARD_DELAY: usize = 300;

View File

@ -0,0 +1,297 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Result;
use indoc::formatdoc;
use std::sync::Arc;
use std::{collections::HashSet, path::Path};
use thiserror::Error;
pub(crate) mod default;
mod parse;
mod path;
mod resolve;
pub(crate) mod store;
mod util;
#[cfg(test)]
use mockall::{automock, predicate::*};
use crate::error::NonFatalErrorSet;
#[cfg_attr(test, automock)]
pub trait Config: Send + Sync {
fn id(&self) -> i32;
fn label(&self) -> &str;
fn match_paths(&self) -> &[String];
// The mechanism used to perform the injection. Espanso can either
// inject text by simulating keypresses (Inject backend) or
// by using the clipboard (Clipboard backend). Both of them have pros
// and cons, so the "Auto" backend is used by default to automatically
// choose the most appropriate one based on the situation.
// If for whatever reason the Auto backend is not appropriate, you
// can change this option to override it.
fn backend(&self) -> Backend;
// If false, espanso will be disabled for the current configuration.
// This option can be used to selectively disable espanso when
// using a specific application (by creating an app-specific config).
fn enable(&self) -> bool;
// Number of chars after which a match is injected with the clipboard
// backend instead of the default one. This is done for efficiency
// reasons, as injecting a long match through separate events becomes
// slow for long strings.
fn clipboard_threshold(&self) -> usize;
// Delay (in ms) that espanso should wait to trigger the paste shortcut
// after copying the content in the clipboard. This is needed because
// if we trigger a "paste" shortcut before the content is actually
// copied in the clipboard, the operation will fail.
fn pre_paste_delay(&self) -> usize;
// Number of milliseconds between keystrokes when simulating the Paste shortcut
// For example: CTRL + (wait 5ms) + V + (wait 5ms) + release V + (wait 5ms) + release CTRL
// This is needed as sometimes (for example on macOS), without a delay some keystrokes
// were not registered correctly
fn paste_shortcut_event_delay(&self) -> usize;
// Customize the keyboard shortcut used to paste an expansion.
// This should follow this format: CTRL+SHIFT+V
fn paste_shortcut(&self) -> Option<String>;
// NOTE: This is only relevant on Linux under X11 environments
// Switch to a slower (but sometimes more supported) way of injecting
// key events based on XTestFakeKeyEvent instead of XSendEvent.
// From my experiements, disabling fast inject becomes particularly slow when
// using the Gnome desktop environment.
fn disable_x11_fast_inject(&self) -> bool;
// Defines the key that disables/enables espanso when double pressed
fn toggle_key(&self) -> Option<ToggleKey>;
// If true, instructs the daemon process to restart the worker (and refresh
// the configuration) after a configuration file change is detected on disk.
fn auto_restart(&self) -> bool;
// If true, espanso will attempt to preserve the previous clipboard content
// after an expansion has taken place (when using the Clipboard backend).
fn preserve_clipboard(&self) -> bool;
// The number of milliseconds to wait before restoring the previous clipboard
// content after an expansion. This is needed as without this delay, sometimes
// the target application detects the previous clipboard content instead of
// the expansion content.
fn restore_clipboard_delay(&self) -> usize;
// Number of milliseconds between text injection events. Increase if the target
// application is missing some characters.
fn inject_delay(&self) -> Option<usize>;
// Number of milliseconds between key injection events. Increase if the target
// application is missing some key events.
fn key_delay(&self) -> Option<usize>;
// Extra delay to apply when injecting modifiers under the EVDEV backend.
// This is useful on Wayland if espanso is injecting seemingly random
// cased letters, for example "Hi theRE1" instead of "Hi there!".
// Increase if necessary, decrease to speed up the injection.
fn evdev_modifier_delay(&self) -> Option<usize>;
// Chars that when pressed mark the start and end of a word.
// Examples of this are . or ,
fn word_separators(&self) -> Vec<String>;
// Maximum number of backspace presses espanso keeps track of.
// For example, this is needed to correctly expand even if typos
// are typed.
fn backspace_limit(&self) -> usize;
// If false, avoid applying the built-in patches to the current config.
fn apply_patch(&self) -> bool;
// On Wayland, overrides the auto-detected keyboard configuration (RMLVO)
// which is used both for the detection and injection process.
fn keyboard_layout(&self) -> Option<RMLVOConfig>;
// Trigger used to show the Search UI
fn search_trigger(&self) -> Option<String>;
// Hotkey used to trigger the Search UI
fn search_shortcut(&self) -> Option<String>;
// When enabled, espanso automatically "reverts" an expansion if the user
// presses the Backspace key afterwards.
fn undo_backspace(&self) -> bool;
// If false, disable all notifications
fn show_notifications(&self) -> bool;
// If false, avoid showing the espanso icon on the system's tray bar
// Note: currently not working on Linux
fn show_icon(&self) -> bool;
// If false, avoid showing the SecureInput notification on macOS
fn secure_input_notification(&self) -> bool;
// If true, filter out keyboard events without an explicit HID device source on Windows.
// This is needed to filter out the software-generated events, including
// those from espanso, but might need to be disabled when using some software-level keyboards.
// Disabling this option might conflict with the undo feature.
fn win32_exclude_orphan_events(&self) -> bool;
fn is_match<'a>(&self, app: &AppProperties<'a>) -> bool;
fn pretty_dump(&self) -> String {
formatdoc! {"
[espanso config: {:?}]
backend: {:?}
enable: {:?}
paste_shortcut: {:?}
inject_delay: {:?}
key_delay: {:?}
apply_patch: {:?}
word_separators: {:?}
preserve_clipboard: {:?}
clipboard_threshold: {:?}
disable_x11_fast_inject: {}
pre_paste_delay: {}
paste_shortcut_event_delay: {}
toggle_key: {:?}
auto_restart: {:?}
restore_clipboard_delay: {:?}
backspace_limit: {}
search_trigger: {:?}
search_shortcut: {:?}
keyboard_layout: {:?}
show_icon: {:?}
show_notifications: {:?}
secure_input_notification: {:?}
match_paths: {:#?}
",
self.label(),
self.backend(),
self.enable(),
self.paste_shortcut(),
self.inject_delay(),
self.key_delay(),
self.apply_patch(),
self.word_separators(),
self.preserve_clipboard(),
self.clipboard_threshold(),
self.disable_x11_fast_inject(),
self.pre_paste_delay(),
self.paste_shortcut_event_delay(),
self.toggle_key(),
self.auto_restart(),
self.restore_clipboard_delay(),
self.backspace_limit(),
self.search_trigger(),
self.search_shortcut(),
self.keyboard_layout(),
self.show_icon(),
self.show_notifications(),
self.secure_input_notification(),
self.match_paths(),
}
}
}
pub trait ConfigStore: Send {
fn default(&self) -> Arc<dyn Config>;
fn active<'a>(&'a self, app: &AppProperties) -> Arc<dyn Config>;
fn configs(&self) -> Vec<Arc<dyn Config>>;
fn get_all_match_paths(&self) -> HashSet<String>;
}
pub struct AppProperties<'a> {
pub title: Option<&'a str>,
pub class: Option<&'a str>,
pub exec: Option<&'a str>,
}
#[derive(Debug, Copy, Clone)]
pub enum Backend {
Inject,
Clipboard,
Auto,
}
#[derive(Debug, Copy, Clone)]
pub enum ToggleKey {
Ctrl,
Meta,
Alt,
Shift,
RightCtrl,
RightAlt,
RightShift,
RightMeta,
LeftCtrl,
LeftAlt,
LeftShift,
LeftMeta,
}
#[derive(Debug, Clone, Default)]
pub struct RMLVOConfig {
pub rules: Option<String>,
pub model: Option<String>,
pub layout: Option<String>,
pub variant: Option<String>,
pub options: Option<String>,
}
impl std::fmt::Display for RMLVOConfig {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"[R={}, M={}, L={}, V={}, O={}]",
self.rules.as_deref().unwrap_or_default(),
self.model.as_deref().unwrap_or_default(),
self.layout.as_deref().unwrap_or_default(),
self.variant.as_deref().unwrap_or_default(),
self.options.as_deref().unwrap_or_default(),
)
}
}
pub fn load_store(config_dir: &Path) -> Result<(impl ConfigStore, Vec<NonFatalErrorSet>)> {
store::DefaultConfigStore::load(config_dir)
}
#[derive(Error, Debug)]
pub enum ConfigStoreError {
#[error("invalid config directory")]
InvalidConfigDir(),
#[error("missing default.yml config")]
MissingDefault(),
#[error("io error")]
IOError(#[from] std::io::Error),
}

View File

@ -0,0 +1,85 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Result;
use std::{collections::BTreeMap, convert::TryInto, path::Path};
use thiserror::Error;
mod yaml;
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct ParsedConfig {
pub label: Option<String>,
pub backend: Option<String>,
pub enable: Option<bool>,
pub clipboard_threshold: Option<usize>,
pub auto_restart: Option<bool>,
pub preserve_clipboard: Option<bool>,
pub toggle_key: Option<String>,
pub paste_shortcut: Option<String>,
pub disable_x11_fast_inject: Option<bool>,
pub word_separators: Option<Vec<String>>,
pub backspace_limit: Option<usize>,
pub apply_patch: Option<bool>,
pub search_trigger: Option<String>,
pub search_shortcut: Option<String>,
pub undo_backspace: Option<bool>,
pub show_notifications: Option<bool>,
pub show_icon: Option<bool>,
pub secure_input_notification: Option<bool>,
pub win32_exclude_orphan_events: Option<bool>,
pub pre_paste_delay: Option<usize>,
pub restore_clipboard_delay: Option<usize>,
pub paste_shortcut_event_delay: Option<usize>,
pub inject_delay: Option<usize>,
pub key_delay: Option<usize>,
pub keyboard_layout: Option<BTreeMap<String, String>>,
pub evdev_modifier_delay: Option<usize>,
// Includes
pub includes: Option<Vec<String>>,
pub excludes: Option<Vec<String>>,
pub extra_includes: Option<Vec<String>>,
pub extra_excludes: Option<Vec<String>>,
pub use_standard_includes: Option<bool>,
// Filters
pub filter_title: Option<String>,
pub filter_class: Option<String>,
pub filter_exec: Option<String>,
pub filter_os: Option<String>,
}
impl ParsedConfig {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
match yaml::YAMLConfig::parse_from_str(&content) {
Ok(config) => Ok(config.try_into()?),
Err(err) => Err(ParsedConfigError::LoadFailed(err).into()),
}
}
}
#[derive(Error, Debug)]
pub enum ParsedConfigError {
#[error("can't load config `{0}`")]
LoadFailed(#[from] anyhow::Error),
}

View File

@ -0,0 +1,328 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::convert::TryFrom;
use crate::util::is_yaml_empty;
use super::ParsedConfig;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub(crate) struct YAMLConfig {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub enable: Option<bool>,
#[serde(default)]
pub clipboard_threshold: Option<usize>,
#[serde(default)]
pub pre_paste_delay: Option<usize>,
#[serde(default)]
pub toggle_key: Option<String>,
#[serde(default)]
pub auto_restart: Option<bool>,
#[serde(default)]
pub preserve_clipboard: Option<bool>,
#[serde(default)]
pub restore_clipboard_delay: Option<usize>,
#[serde(default)]
pub paste_shortcut_event_delay: Option<usize>,
#[serde(default)]
pub paste_shortcut: Option<String>,
#[serde(default)]
pub disable_x11_fast_inject: Option<bool>,
#[serde(default)]
pub inject_delay: Option<usize>,
#[serde(default)]
pub key_delay: Option<usize>,
#[serde(default)]
pub backspace_delay: Option<usize>,
#[serde(default)]
pub evdev_modifier_delay: Option<usize>,
#[serde(default)]
pub word_separators: Option<Vec<String>>,
#[serde(default)]
pub backspace_limit: Option<usize>,
#[serde(default)]
pub apply_patch: Option<bool>,
#[serde(default)]
pub keyboard_layout: Option<Mapping>,
#[serde(default)]
pub search_trigger: Option<String>,
#[serde(default)]
pub search_shortcut: Option<String>,
#[serde(default)]
pub undo_backspace: Option<bool>,
#[serde(default)]
pub show_notifications: Option<bool>,
#[serde(default)]
pub show_icon: Option<bool>,
#[serde(default)]
pub secure_input_notification: Option<bool>,
#[serde(default)]
pub win32_exclude_orphan_events: Option<bool>,
// Include/Exclude
#[serde(default)]
pub includes: Option<Vec<String>>,
#[serde(default)]
pub excludes: Option<Vec<String>>,
#[serde(default)]
pub extra_includes: Option<Vec<String>>,
#[serde(default)]
pub extra_excludes: Option<Vec<String>>,
#[serde(default)]
pub use_standard_includes: Option<bool>,
// Filters
#[serde(default)]
pub filter_title: Option<String>,
#[serde(default)]
pub filter_class: Option<String>,
#[serde(default)]
pub filter_exec: Option<String>,
#[serde(default)]
pub filter_os: Option<String>,
}
impl YAMLConfig {
pub fn parse_from_str(yaml: &str) -> Result<Self> {
// Because an empty string is not valid YAML but we want to support it anyway
if is_yaml_empty(yaml) {
return Ok(serde_yaml::from_str(
"arbitrary_field_that_will_not_block_the_parser: true",
)?);
}
Ok(serde_yaml::from_str(yaml)?)
}
}
impl TryFrom<YAMLConfig> for ParsedConfig {
type Error = anyhow::Error;
fn try_from(yaml_config: YAMLConfig) -> Result<Self, Self::Error> {
Ok(Self {
label: yaml_config.label,
backend: yaml_config.backend,
enable: yaml_config.enable,
clipboard_threshold: yaml_config.clipboard_threshold,
auto_restart: yaml_config.auto_restart,
toggle_key: yaml_config.toggle_key,
preserve_clipboard: yaml_config.preserve_clipboard,
paste_shortcut: yaml_config.paste_shortcut,
disable_x11_fast_inject: yaml_config.disable_x11_fast_inject,
inject_delay: yaml_config.inject_delay,
key_delay: yaml_config.key_delay.or(yaml_config.backspace_delay),
evdev_modifier_delay: yaml_config.evdev_modifier_delay,
word_separators: yaml_config.word_separators,
backspace_limit: yaml_config.backspace_limit,
apply_patch: yaml_config.apply_patch,
keyboard_layout: yaml_config.keyboard_layout.map(|mapping| {
mapping
.into_iter()
.filter_map(|(key, value)| {
if let (Some(key), Some(value)) = (key.as_str(), value.as_str()) {
Some((key.to_string(), value.to_string()))
} else {
None
}
})
.collect()
}),
search_trigger: yaml_config.search_trigger,
search_shortcut: yaml_config.search_shortcut,
undo_backspace: yaml_config.undo_backspace,
show_icon: yaml_config.show_icon,
show_notifications: yaml_config.show_notifications,
secure_input_notification: yaml_config.secure_input_notification,
pre_paste_delay: yaml_config.pre_paste_delay,
restore_clipboard_delay: yaml_config.restore_clipboard_delay,
paste_shortcut_event_delay: yaml_config.paste_shortcut_event_delay,
win32_exclude_orphan_events: yaml_config.win32_exclude_orphan_events,
use_standard_includes: yaml_config.use_standard_includes,
includes: yaml_config.includes,
extra_includes: yaml_config.extra_includes,
excludes: yaml_config.excludes,
extra_excludes: yaml_config.extra_excludes,
filter_class: yaml_config.filter_class,
filter_exec: yaml_config.filter_exec,
filter_os: yaml_config.filter_os,
filter_title: yaml_config.filter_title,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{collections::BTreeMap, convert::TryInto};
#[test]
fn conversion_to_parsed_config_works_correctly() {
let config = YAMLConfig::parse_from_str(
r#"
label: "test"
backend: clipboard
enable: false
clipboard_threshold: 200
pre_paste_delay: 300
toggle_key: CTRL
auto_restart: false
preserve_clipboard: false
restore_clipboard_delay: 400
paste_shortcut: CTRL+ALT+V
paste_shortcut_event_delay: 10
disable_x11_fast_inject: true
inject_delay: 10
key_delay: 20
backspace_delay: 30
evdev_modifier_delay: 40
word_separators: ["'", "."]
backspace_limit: 10
apply_patch: false
keyboard_layout:
rules: test_rule
model: test_model
layout: test_layout
variant: test_variant
options: test_options
search_trigger: "search"
search_shortcut: "CTRL+SPACE"
undo_backspace: false
show_icon: false
show_notifications: false
secure_input_notification: false
win32_exclude_orphan_events: false
use_standard_includes: true
includes: ["test1"]
extra_includes: ["test2"]
excludes: ["test3"]
extra_excludes: ["test4"]
filter_class: "test5"
filter_exec: "test6"
filter_os: "test7"
filter_title: "test8"
"#,
)
.unwrap();
let parsed_config: ParsedConfig = config.try_into().unwrap();
let keyboard_layout: BTreeMap<String, String> = vec![
("rules".to_string(), "test_rule".to_string()),
("model".to_string(), "test_model".to_string()),
("layout".to_string(), "test_layout".to_string()),
("variant".to_string(), "test_variant".to_string()),
("options".to_string(), "test_options".to_string()),
]
.into_iter()
.collect();
assert_eq!(
parsed_config,
ParsedConfig {
label: Some("test".to_string()),
backend: Some("clipboard".to_string()),
enable: Some(false),
clipboard_threshold: Some(200),
auto_restart: Some(false),
preserve_clipboard: Some(false),
restore_clipboard_delay: Some(400),
paste_shortcut: Some("CTRL+ALT+V".to_string()),
paste_shortcut_event_delay: Some(10),
disable_x11_fast_inject: Some(true),
inject_delay: Some(10),
key_delay: Some(20),
backspace_limit: Some(10),
apply_patch: Some(false),
keyboard_layout: Some(keyboard_layout),
search_trigger: Some("search".to_owned()),
search_shortcut: Some("CTRL+SPACE".to_owned()),
undo_backspace: Some(false),
show_icon: Some(false),
show_notifications: Some(false),
secure_input_notification: Some(false),
win32_exclude_orphan_events: Some(false),
pre_paste_delay: Some(300),
evdev_modifier_delay: Some(40),
toggle_key: Some("CTRL".to_string()),
word_separators: Some(vec!["'".to_owned(), ".".to_owned()]),
use_standard_includes: Some(true),
includes: Some(vec!["test1".to_string()]),
extra_includes: Some(vec!["test2".to_string()]),
excludes: Some(vec!["test3".to_string()]),
extra_excludes: Some(vec!["test4".to_string()]),
filter_class: Some("test5".to_string()),
filter_exec: Some("test6".to_string()),
filter_os: Some("test7".to_string()),
filter_title: Some("test8".to_string()),
}
)
}
}

View File

@ -0,0 +1,182 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{collections::HashSet, path::Path};
use glob::glob;
use log::error;
use regex::Regex;
lazy_static! {
static ref ABSOLUTE_PATH: Regex = Regex::new(r"(?m)^([a-zA-Z]:\\|/).*$").unwrap();
}
pub fn calculate_paths<'a>(
base_dir: &Path,
glob_patterns: impl Iterator<Item = &'a String>,
) -> HashSet<String> {
let mut path_set = HashSet::new();
for glob_pattern in glob_patterns {
// Handle relative and absolute paths appropriately
let pattern = if ABSOLUTE_PATH.is_match(glob_pattern) {
glob_pattern.clone()
} else {
format!("{}/{}", base_dir.to_string_lossy(), glob_pattern)
};
let entries = glob(&pattern);
match entries {
Ok(paths) => {
for path in paths {
match path {
Ok(path) => {
// Canonicalize the path
match dunce::canonicalize(&path) {
Ok(canonical_path) => {
path_set.insert(canonical_path.to_string_lossy().to_string());
}
Err(err) => {
error!(
"unable to canonicalize path from glob: {:?}, with error: {}",
path, err
);
}
}
}
Err(err) => error!(
"glob error when processing pattern: {}, with error: {}",
glob_pattern, err
),
}
}
}
Err(err) => {
error!(
"unable to calculate glob from pattern: {}, with error: {}",
glob_pattern, err
);
}
}
}
path_set
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::util::tests::use_test_directory;
use std::fs::create_dir_all;
#[test]
fn calculate_paths_relative_paths() {
use_test_directory(|base, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(&another_file, "test").unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(&under_file, "test").unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "test").unwrap();
let result = calculate_paths(
base,
vec![
"**/*.yml".to_string(),
"match/sub/*.yml".to_string(),
// Invalid path
"invalid".to_string(),
]
.iter(),
);
let mut expected = HashSet::new();
expected.insert(base_file.to_string_lossy().to_string());
expected.insert(another_file.to_string_lossy().to_string());
expected.insert(under_file.to_string_lossy().to_string());
expected.insert(sub_file.to_string_lossy().to_string());
assert_eq!(result, expected);
});
}
#[test]
fn calculate_paths_relative_with_parent_modifier() {
use_test_directory(|base, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(&another_file, "test").unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(&under_file, "test").unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "test").unwrap();
let result = calculate_paths(base, vec!["match/sub/../sub/*.yml".to_string()].iter());
let mut expected = HashSet::new();
expected.insert(sub_file.to_string_lossy().to_string());
assert_eq!(result, expected);
});
}
#[test]
fn calculate_paths_absolute_paths() {
use_test_directory(|base, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(&another_file, "test").unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(&under_file, "test").unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "test").unwrap();
let result = calculate_paths(
base,
vec![
format!("{}/**/*.yml", base.to_string_lossy()),
format!("{}/match/sub/*.yml", base.to_string_lossy()),
// Invalid path
"invalid".to_string(),
]
.iter(),
);
let mut expected = HashSet::new();
expected.insert(base_file.to_string_lossy().to_string());
expected.insert(another_file.to_string_lossy().to_string());
expected.insert(under_file.to_string_lossy().to_string());
expected.insert(sub_file.to_string_lossy().to_string());
assert_eq!(result, expected);
});
}
}

View File

@ -0,0 +1,962 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 super::{
default::{
DEFAULT_CLIPBOARD_THRESHOLD, DEFAULT_PRE_PASTE_DELAY, DEFAULT_RESTORE_CLIPBOARD_DELAY,
DEFAULT_SHORTCUT_EVENT_DELAY,
},
parse::ParsedConfig,
path::calculate_paths,
util::os_matches,
AppProperties, Backend, Config, RMLVOConfig, ToggleKey,
};
use crate::{counter::next_id, merge};
use anyhow::Result;
use log::error;
use regex::Regex;
use std::path::PathBuf;
use std::{collections::HashSet, path::Path};
use thiserror::Error;
const STANDARD_INCLUDES: &[&str] = &["../match/**/[!_]*.yml"];
#[derive(Debug, Clone)]
pub(crate) struct ResolvedConfig {
parsed: ParsedConfig,
source_path: Option<PathBuf>,
// Generated properties
id: i32,
match_paths: Vec<String>,
filter_title: Option<Regex>,
filter_class: Option<Regex>,
filter_exec: Option<Regex>,
}
impl Default for ResolvedConfig {
fn default() -> Self {
Self {
parsed: Default::default(),
source_path: None,
id: 0,
match_paths: Vec::new(),
filter_title: None,
filter_class: None,
filter_exec: None,
}
}
}
impl Config for ResolvedConfig {
fn id(&self) -> i32 {
self.id
}
fn label(&self) -> &str {
if let Some(label) = self.parsed.label.as_deref() {
return label;
}
if let Some(source_path) = self.source_path.as_ref() {
if let Some(source_path) = source_path.to_str() {
return source_path;
}
}
"none"
}
fn match_paths(&self) -> &[String] {
&self.match_paths
}
fn is_match(&self, app: &AppProperties) -> bool {
if self.parsed.filter_os.is_none()
&& self.parsed.filter_title.is_none()
&& self.parsed.filter_exec.is_none()
&& self.parsed.filter_class.is_none()
{
return false;
}
let is_os_match = if let Some(filter_os) = self.parsed.filter_os.as_deref() {
os_matches(filter_os)
} else {
true
};
let is_title_match = if let Some(title_regex) = self.filter_title.as_ref() {
if let Some(title) = app.title {
title_regex.is_match(title)
} else {
false
}
} else {
true
};
let is_exec_match = if let Some(exec_regex) = self.filter_exec.as_ref() {
if let Some(exec) = app.exec {
exec_regex.is_match(exec)
} else {
false
}
} else {
true
};
let is_class_match = if let Some(class_regex) = self.filter_class.as_ref() {
if let Some(class) = app.class {
class_regex.is_match(class)
} else {
false
}
} else {
true
};
// All the filters that have been specified must be true to define a match
is_os_match && is_exec_match && is_title_match && is_class_match
}
fn backend(&self) -> Backend {
// TODO: test
match self
.parsed
.backend
.as_deref()
.map(|b| b.to_lowercase())
.as_deref()
{
Some("clipboard") => Backend::Clipboard,
Some("inject") => Backend::Inject,
Some("auto") => Backend::Auto,
None => Backend::Auto,
err => {
error!("invalid backend specified {:?}, falling back to Auto", err);
Backend::Auto
}
}
}
fn enable(&self) -> bool {
self.parsed.enable.unwrap_or(true)
}
fn clipboard_threshold(&self) -> usize {
self
.parsed
.clipboard_threshold
.unwrap_or(DEFAULT_CLIPBOARD_THRESHOLD)
}
fn auto_restart(&self) -> bool {
self.parsed.auto_restart.unwrap_or(true)
}
fn pre_paste_delay(&self) -> usize {
self
.parsed
.pre_paste_delay
.unwrap_or(DEFAULT_PRE_PASTE_DELAY)
}
fn toggle_key(&self) -> Option<ToggleKey> {
// TODO: test
match self
.parsed
.toggle_key
.as_deref()
.map(|key| key.to_lowercase())
.as_deref()
{
Some("ctrl") => Some(ToggleKey::Ctrl),
Some("alt") => Some(ToggleKey::Alt),
Some("shift") => Some(ToggleKey::Shift),
Some("meta") | Some("cmd") => Some(ToggleKey::Meta),
Some("right_ctrl") => Some(ToggleKey::RightCtrl),
Some("right_alt") => Some(ToggleKey::RightAlt),
Some("right_shift") => Some(ToggleKey::RightShift),
Some("right_meta") | Some("right_cmd") => Some(ToggleKey::RightMeta),
Some("left_ctrl") => Some(ToggleKey::LeftCtrl),
Some("left_alt") => Some(ToggleKey::LeftAlt),
Some("left_shift") => Some(ToggleKey::LeftShift),
Some("left_meta") | Some("left_cmd") => Some(ToggleKey::LeftMeta),
Some("off") => None,
None => Some(ToggleKey::Alt),
err => {
error!(
"invalid toggle_key specified {:?}, falling back to ALT",
err
);
Some(ToggleKey::Alt)
}
}
}
fn preserve_clipboard(&self) -> bool {
self.parsed.preserve_clipboard.unwrap_or(true)
}
fn restore_clipboard_delay(&self) -> usize {
self
.parsed
.restore_clipboard_delay
.unwrap_or(DEFAULT_RESTORE_CLIPBOARD_DELAY)
}
fn paste_shortcut_event_delay(&self) -> usize {
self
.parsed
.paste_shortcut_event_delay
.unwrap_or(DEFAULT_SHORTCUT_EVENT_DELAY)
}
fn paste_shortcut(&self) -> Option<String> {
self.parsed.paste_shortcut.clone()
}
fn disable_x11_fast_inject(&self) -> bool {
self.parsed.disable_x11_fast_inject.unwrap_or(false)
}
fn inject_delay(&self) -> Option<usize> {
self.parsed.inject_delay
}
fn key_delay(&self) -> Option<usize> {
self.parsed.key_delay
}
fn word_separators(&self) -> Vec<String> {
self.parsed.word_separators.clone().unwrap_or_else(|| {
vec![
" ".to_string(),
",".to_string(),
".".to_string(),
"?".to_string(),
"!".to_string(),
"\r".to_string(),
"\n".to_string(),
(22u8 as char).to_string(),
]
})
}
fn backspace_limit(&self) -> usize {
self.parsed.backspace_limit.unwrap_or(5)
}
fn apply_patch(&self) -> bool {
self.parsed.apply_patch.unwrap_or(true)
}
fn keyboard_layout(&self) -> Option<RMLVOConfig> {
self
.parsed
.keyboard_layout
.as_ref()
.map(|layout| RMLVOConfig {
rules: layout.get("rules").map(String::from),
model: layout.get("model").map(String::from),
layout: layout.get("layout").map(String::from),
variant: layout.get("variant").map(String::from),
options: layout.get("options").map(String::from),
})
}
fn search_trigger(&self) -> Option<String> {
match self.parsed.search_trigger.as_deref() {
Some("OFF") | Some("off") => None,
Some(x) => Some(x.to_string()),
None => Some("jkj".to_string()),
}
}
fn search_shortcut(&self) -> Option<String> {
match self.parsed.search_shortcut.as_deref() {
Some("OFF") | Some("off") => None,
Some(x) => Some(x.to_string()),
None => Some("ALT+SPACE".to_string()),
}
}
fn undo_backspace(&self) -> bool {
self.parsed.undo_backspace.unwrap_or(true)
}
fn show_icon(&self) -> bool {
self.parsed.show_icon.unwrap_or(true)
}
fn show_notifications(&self) -> bool {
self.parsed.show_notifications.unwrap_or(true)
}
fn secure_input_notification(&self) -> bool {
self.parsed.secure_input_notification.unwrap_or(true)
}
fn win32_exclude_orphan_events(&self) -> bool {
self.parsed.win32_exclude_orphan_events.unwrap_or(true)
}
fn evdev_modifier_delay(&self) -> Option<usize> {
self.parsed.evdev_modifier_delay
}
}
impl ResolvedConfig {
pub fn load(path: &Path, parent: Option<&Self>) -> Result<Self> {
let mut config = ParsedConfig::load(path)?;
// Merge with parent config if present
if let Some(parent) = parent {
Self::merge_parsed(&mut config, &parent.parsed);
}
// Extract the base directory
let base_dir = path
.parent()
.ok_or_else(ResolveError::ParentResolveFailed)?;
let match_paths = Self::generate_match_paths(&config, base_dir)
.into_iter()
.collect();
let filter_title = if let Some(filter_title) = config.filter_title.as_deref() {
Some(Regex::new(filter_title)?)
} else {
None
};
let filter_class = if let Some(filter_class) = config.filter_class.as_deref() {
Some(Regex::new(filter_class)?)
} else {
None
};
let filter_exec = if let Some(filter_exec) = config.filter_exec.as_deref() {
Some(Regex::new(filter_exec)?)
} else {
None
};
Ok(Self {
parsed: config,
source_path: Some(path.to_owned()),
id: next_id(),
match_paths,
filter_title,
filter_class,
filter_exec,
})
}
fn merge_parsed(child: &mut ParsedConfig, parent: &ParsedConfig) {
// Override the None fields with the parent's value
merge!(
ParsedConfig,
child,
parent,
// Fields
label,
backend,
enable,
clipboard_threshold,
auto_restart,
pre_paste_delay,
preserve_clipboard,
restore_clipboard_delay,
paste_shortcut,
apply_patch,
paste_shortcut_event_delay,
disable_x11_fast_inject,
toggle_key,
inject_delay,
key_delay,
evdev_modifier_delay,
word_separators,
backspace_limit,
keyboard_layout,
search_trigger,
search_shortcut,
undo_backspace,
show_icon,
show_notifications,
secure_input_notification,
win32_exclude_orphan_events,
includes,
excludes,
extra_includes,
extra_excludes,
use_standard_includes,
filter_title,
filter_class,
filter_exec,
filter_os
);
}
fn aggregate_includes(config: &ParsedConfig) -> HashSet<String> {
let mut includes = HashSet::new();
if config.use_standard_includes.is_none() || config.use_standard_includes.unwrap() {
STANDARD_INCLUDES.iter().for_each(|include| {
includes.insert(include.to_string());
})
}
if let Some(yaml_includes) = config.includes.as_ref() {
yaml_includes.iter().for_each(|include| {
includes.insert(include.to_string());
})
}
if let Some(extra_includes) = config.extra_includes.as_ref() {
extra_includes.iter().for_each(|include| {
includes.insert(include.to_string());
})
}
includes
}
fn aggregate_excludes(config: &ParsedConfig) -> HashSet<String> {
let mut excludes = HashSet::new();
if let Some(yaml_excludes) = config.excludes.as_ref() {
yaml_excludes.iter().for_each(|exclude| {
excludes.insert(exclude.to_string());
})
}
if let Some(extra_excludes) = config.extra_excludes.as_ref() {
extra_excludes.iter().for_each(|exclude| {
excludes.insert(exclude.to_string());
})
}
excludes
}
fn generate_match_paths(config: &ParsedConfig, base_dir: &Path) -> HashSet<String> {
let includes = Self::aggregate_includes(config);
let excludes = Self::aggregate_excludes(config);
// Extract the paths
let exclude_paths = calculate_paths(base_dir, excludes.iter());
let include_paths = calculate_paths(base_dir, includes.iter());
include_paths
.difference(&exclude_paths)
.cloned()
.collect::<HashSet<_>>()
}
}
#[derive(Error, Debug)]
pub enum ResolveError {
#[error("unable to resolve parent path")]
ParentResolveFailed(),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::tests::use_test_directory;
use std::fs::create_dir_all;
#[test]
fn aggregate_includes_empty_config() {
assert_eq!(
ResolvedConfig::aggregate_includes(&ParsedConfig {
..Default::default()
}),
vec!["../match/**/[!_]*.yml".to_string(),]
.iter()
.cloned()
.collect::<HashSet<_>>()
);
}
#[test]
fn aggregate_includes_no_standard() {
assert_eq!(
ResolvedConfig::aggregate_includes(&ParsedConfig {
use_standard_includes: Some(false),
..Default::default()
}),
HashSet::new()
);
}
#[test]
fn aggregate_includes_custom_includes() {
assert_eq!(
ResolvedConfig::aggregate_includes(&ParsedConfig {
includes: Some(vec!["custom/*.yml".to_string()]),
..Default::default()
}),
vec![
"../match/**/[!_]*.yml".to_string(),
"custom/*.yml".to_string()
]
.iter()
.cloned()
.collect::<HashSet<_>>()
);
}
#[test]
fn aggregate_includes_extra_includes() {
assert_eq!(
ResolvedConfig::aggregate_includes(&ParsedConfig {
extra_includes: Some(vec!["custom/*.yml".to_string()]),
..Default::default()
}),
vec![
"../match/**/[!_]*.yml".to_string(),
"custom/*.yml".to_string()
]
.iter()
.cloned()
.collect::<HashSet<_>>()
);
}
#[test]
fn aggregate_includes_includes_and_extra_includes() {
assert_eq!(
ResolvedConfig::aggregate_includes(&ParsedConfig {
includes: Some(vec!["sub/*.yml".to_string()]),
extra_includes: Some(vec!["custom/*.yml".to_string()]),
..Default::default()
}),
vec![
"../match/**/[!_]*.yml".to_string(),
"custom/*.yml".to_string(),
"sub/*.yml".to_string()
]
.iter()
.cloned()
.collect::<HashSet<_>>()
);
}
#[test]
fn aggregate_excludes_empty_config() {
assert_eq!(
ResolvedConfig::aggregate_excludes(&ParsedConfig {
..Default::default()
})
.len(),
0
);
}
#[test]
fn aggregate_excludes_no_standard() {
assert_eq!(
ResolvedConfig::aggregate_excludes(&ParsedConfig {
use_standard_includes: Some(false),
..Default::default()
}),
HashSet::new()
);
}
#[test]
fn aggregate_excludes_custom_excludes() {
assert_eq!(
ResolvedConfig::aggregate_excludes(&ParsedConfig {
excludes: Some(vec!["custom/*.yml".to_string()]),
..Default::default()
}),
vec!["custom/*.yml".to_string()]
.iter()
.cloned()
.collect::<HashSet<_>>()
);
}
#[test]
fn aggregate_excludes_extra_excludes() {
assert_eq!(
ResolvedConfig::aggregate_excludes(&ParsedConfig {
extra_excludes: Some(vec!["custom/*.yml".to_string()]),
..Default::default()
}),
vec!["custom/*.yml".to_string()]
.iter()
.cloned()
.collect::<HashSet<_>>()
);
}
#[test]
fn aggregate_excludes_excludes_and_extra_excludes() {
assert_eq!(
ResolvedConfig::aggregate_excludes(&ParsedConfig {
excludes: Some(vec!["sub/*.yml".to_string()]),
extra_excludes: Some(vec!["custom/*.yml".to_string()]),
..Default::default()
}),
vec!["custom/*.yml".to_string(), "sub/*.yml".to_string()]
.iter()
.cloned()
.collect::<HashSet<_>>()
);
}
#[test]
fn merge_parent_field_parent_fallback() {
let parent = ParsedConfig {
use_standard_includes: Some(false),
..Default::default()
};
let mut child = ParsedConfig {
..Default::default()
};
assert_eq!(child.use_standard_includes, None);
ResolvedConfig::merge_parsed(&mut child, &parent);
assert_eq!(child.use_standard_includes, Some(false));
}
#[test]
fn merge_parent_field_child_overwrite_parent() {
let parent = ParsedConfig {
use_standard_includes: Some(true),
..Default::default()
};
let mut child = ParsedConfig {
use_standard_includes: Some(false),
..Default::default()
};
assert_eq!(child.use_standard_includes, Some(false));
ResolvedConfig::merge_parsed(&mut child, &parent);
assert_eq!(child.use_standard_includes, Some(false));
}
#[test]
fn match_paths_generated_correctly() {
use_test_directory(|_, match_dir, config_dir| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(&another_file, "test").unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(&under_file, "test").unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "test").unwrap();
let config_file = config_dir.join("default.yml");
std::fs::write(&config_file, "").unwrap();
let config = ResolvedConfig::load(&config_file, None).unwrap();
let mut expected = vec![
base_file.to_string_lossy().to_string(),
another_file.to_string_lossy().to_string(),
sub_file.to_string_lossy().to_string(),
];
expected.sort();
let mut result = config.match_paths().to_vec();
result.sort();
assert_eq!(result, expected.as_slice());
});
}
#[test]
fn match_paths_generated_correctly_with_child_config() {
use_test_directory(|_, match_dir, config_dir| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(&another_file, "test").unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(&under_file, "test").unwrap();
let sub_file = sub_dir.join("another.yml");
std::fs::write(&sub_file, "test").unwrap();
let sub_under_file = sub_dir.join("_sub.yml");
std::fs::write(&sub_under_file, "test").unwrap();
// Configs
let parent_file = config_dir.join("parent.yml");
std::fs::write(
&parent_file,
r#"
excludes: ['../**/another.yml']
"#,
)
.unwrap();
let config_file = config_dir.join("default.yml");
std::fs::write(
&config_file,
r#"
use_standard_includes: false
excludes: []
includes: ["../match/sub/*.yml"]
"#,
)
.unwrap();
let parent = ResolvedConfig::load(&parent_file, None).unwrap();
let child = ResolvedConfig::load(&config_file, Some(&parent)).unwrap();
let mut expected = vec![
sub_file.to_string_lossy().to_string(),
sub_under_file.to_string_lossy().to_string(),
];
expected.sort();
let mut result = child.match_paths().to_vec();
result.sort();
assert_eq!(result, expected.as_slice());
let expected = vec![base_file.to_string_lossy().to_string()];
assert_eq!(parent.match_paths(), expected.as_slice());
});
}
#[test]
fn match_paths_generated_correctly_with_underscore_files() {
use_test_directory(|_, match_dir, config_dir| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(&another_file, "test").unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(&under_file, "test").unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "test").unwrap();
let config_file = config_dir.join("default.yml");
std::fs::write(&config_file, "extra_includes: ['../match/_sub.yml']").unwrap();
let config = ResolvedConfig::load(&config_file, None).unwrap();
let mut expected = vec![
base_file.to_string_lossy().to_string(),
another_file.to_string_lossy().to_string(),
sub_file.to_string_lossy().to_string(),
under_file.to_string_lossy().to_string(),
];
expected.sort();
let mut result = config.match_paths().to_vec();
result.sort();
assert_eq!(result, expected.as_slice());
});
}
fn test_filter_is_match(config: &str, app: &AppProperties) -> bool {
let mut result = false;
let result_ref = &mut result;
use_test_directory(move |_, _, config_dir| {
let config_file = config_dir.join("default.yml");
std::fs::write(&config_file, config).unwrap();
let config = ResolvedConfig::load(&config_file, None).unwrap();
*result_ref = config.is_match(app)
});
result
}
#[test]
fn is_match_no_filters() {
assert!(!test_filter_is_match(
"",
&AppProperties {
title: Some("Google"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
}
#[test]
fn is_match_filter_title() {
assert!(test_filter_is_match(
"filter_title: Google",
&AppProperties {
title: Some("Google Mail"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
assert!(!test_filter_is_match(
"filter_title: Google",
&AppProperties {
title: Some("Yahoo"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
assert!(!test_filter_is_match(
"filter_title: Google",
&AppProperties {
title: None,
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
}
#[test]
fn is_match_filter_class() {
assert!(test_filter_is_match(
"filter_class: Chrome",
&AppProperties {
title: Some("Google Mail"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
assert!(!test_filter_is_match(
"filter_class: Chrome",
&AppProperties {
title: Some("Yahoo"),
class: Some("Another"),
exec: Some("chrome.exe"),
},
));
assert!(!test_filter_is_match(
"filter_class: Chrome",
&AppProperties {
title: Some("google"),
class: None,
exec: Some("chrome.exe"),
},
));
}
#[test]
fn is_match_filter_exec() {
assert!(test_filter_is_match(
"filter_exec: chrome.exe",
&AppProperties {
title: Some("Google Mail"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
assert!(!test_filter_is_match(
"filter_exec: chrome.exe",
&AppProperties {
title: Some("Yahoo"),
class: Some("Another"),
exec: Some("zoom.exe"),
},
));
assert!(!test_filter_is_match(
"filter_exec: chrome.exe",
&AppProperties {
title: Some("google"),
class: Some("Chrome"),
exec: None,
},
));
}
#[test]
fn is_match_filter_os() {
let (current, another) = if cfg!(target_os = "windows") {
("windows", "macos")
} else if cfg!(target_os = "macos") {
("macos", "windows")
} else if cfg!(target_os = "linux") {
("linux", "macos")
} else {
("invalid", "invalid")
};
assert!(test_filter_is_match(
&format!("filter_os: {}", current),
&AppProperties {
title: Some("Google Mail"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
assert!(!test_filter_is_match(
&format!("filter_os: {}", another),
&AppProperties {
title: Some("Google Mail"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
}
#[test]
fn is_match_multiple_filters() {
assert!(test_filter_is_match(
r#"
filter_exec: chrome.exe
filter_title: "Youtube"
"#,
&AppProperties {
title: Some("Youtube - Broadcast Yourself"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
assert!(!test_filter_is_match(
r#"
filter_exec: chrome.exe
filter_title: "Youtube"
"#,
&AppProperties {
title: Some("Gmail"),
class: Some("Chrome"),
exec: Some("chrome.exe"),
},
));
}
}

View File

@ -0,0 +1,196 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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::error::NonFatalErrorSet;
use super::{resolve::ResolvedConfig, Config, ConfigStore, ConfigStoreError};
use anyhow::{Context, Result};
use log::{debug, error};
use std::sync::Arc;
use std::{collections::HashSet, path::Path};
pub(crate) struct DefaultConfigStore {
default: Arc<dyn Config>,
customs: Vec<Arc<dyn Config>>,
}
impl ConfigStore for DefaultConfigStore {
fn default(&self) -> Arc<dyn super::Config> {
Arc::clone(&self.default)
}
fn active<'a>(&'a self, app: &super::AppProperties) -> Arc<dyn super::Config> {
// Find a custom config that matches or fallback to the default one
for custom in self.customs.iter() {
if custom.is_match(app) {
return Arc::clone(custom);
}
}
Arc::clone(&self.default)
}
fn configs(&self) -> Vec<Arc<dyn Config>> {
let mut configs = vec![Arc::clone(&self.default)];
for custom in self.customs.iter() {
configs.push(Arc::clone(custom));
}
configs
}
// TODO: test
fn get_all_match_paths(&self) -> HashSet<String> {
let mut paths = HashSet::new();
paths.extend(self.default().match_paths().iter().cloned());
for custom in self.customs.iter() {
paths.extend(custom.match_paths().iter().cloned());
}
paths
}
}
impl DefaultConfigStore {
pub fn load(config_dir: &Path) -> Result<(Self, Vec<NonFatalErrorSet>)> {
if !config_dir.is_dir() {
return Err(ConfigStoreError::InvalidConfigDir().into());
}
// First get the default.yml file
let default_file = config_dir.join("default.yml");
if !default_file.exists() || !default_file.is_file() {
return Err(ConfigStoreError::MissingDefault().into());
}
let mut non_fatal_errors = Vec::new();
let default = ResolvedConfig::load(&default_file, None)
.context("failed to load default.yml configuration")?;
debug!("loaded default config at path: {:?}", default_file);
// Then the others
let mut customs: Vec<Arc<dyn Config>> = Vec::new();
for entry in std::fs::read_dir(config_dir).map_err(ConfigStoreError::IOError)? {
let entry = entry?;
let config_file = entry.path();
let extension = config_file
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
// Additional config files are loaded best-effort
if config_file.is_file()
&& config_file != default_file
&& (extension == "yml" || extension == "yaml")
{
match ResolvedConfig::load(&config_file, Some(&default)) {
Ok(config) => {
customs.push(Arc::new(config));
debug!("loaded config at path: {:?}", config_file);
}
Err(err) => {
error!(
"unable to load config at path: {:?}, with error: {}",
config_file, err
);
non_fatal_errors.push(NonFatalErrorSet::single_error(&config_file, err));
}
}
}
}
Ok((
Self {
default: Arc::new(default),
customs,
},
non_fatal_errors,
))
}
pub fn from_configs(default: Arc<dyn Config>, customs: Vec<Arc<dyn Config>>) -> Result<Self> {
Ok(Self { default, customs })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MockConfig;
pub fn new_mock(label: &'static str, is_match: bool) -> MockConfig {
let label = label.to_owned();
let mut mock = MockConfig::new();
mock.expect_id().return_const(0);
mock.expect_label().return_const(label);
mock.expect_is_match().return_const(is_match);
mock
}
#[test]
fn config_store_selects_correctly() {
let default = new_mock("default", false);
let custom1 = new_mock("custom1", false);
let custom2 = new_mock("custom2", true);
let store = DefaultConfigStore {
default: Arc::new(default),
customs: vec![Arc::new(custom1), Arc::new(custom2)],
};
assert_eq!(store.default().label(), "default");
assert_eq!(
store
.active(&crate::config::AppProperties {
title: None,
class: None,
exec: None,
})
.label(),
"custom2"
);
}
#[test]
fn config_store_active_fallback_to_default_if_no_match() {
let default = new_mock("default", false);
let custom1 = new_mock("custom1", false);
let custom2 = new_mock("custom2", false);
let store = DefaultConfigStore {
default: Arc::new(default),
customs: vec![Arc::new(custom1), Arc::new(custom2)],
};
assert_eq!(store.default().label(), "default");
assert_eq!(
store
.active(&crate::config::AppProperties {
title: None,
class: None,
exec: None,
})
.label(),
"default"
);
}
}

View File

@ -0,0 +1,80 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#[macro_export]
macro_rules! merge {
( $t:ident, $child:expr, $parent:expr, $( $x:ident ),* ) => {
{
$(
if $child.$x.is_none() {
$child.$x = $parent.$x.clone();
}
)*
// Build a temporary object to verify that all fields
// are being used at compile time
$t {
$(
$x: None,
)*
};
}
};
}
pub fn os_matches(os: &str) -> bool {
match os {
"macos" => cfg!(target_os = "macos"),
"windows" => cfg!(target_os = "windows"),
"linux" => cfg!(target_os = "linux"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(target_os = "linux")]
fn os_matches_linux() {
assert!(os_matches("linux"));
assert!(!os_matches("windows"));
assert!(!os_matches("macos"));
assert!(!os_matches("invalid"));
}
#[test]
#[cfg(target_os = "macos")]
fn os_matches_macos() {
assert!(os_matches("macos"));
assert!(!os_matches("windows"));
assert!(!os_matches("linux"));
assert!(!os_matches("invalid"));
}
#[test]
#[cfg(target_os = "windows")]
fn os_matches_windows() {
assert!(os_matches("windows"));
assert!(!os_matches("macos"));
assert!(!os_matches("linux"));
assert!(!os_matches("invalid"));
}
}

View File

@ -0,0 +1,34 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::atomic::{AtomicI32, Ordering};
thread_local! {
// TODO: if thread local, we probably don't need an atomic
static STRUCT_COUNTER: AtomicI32 = AtomicI32::new(0);
}
pub type StructId = i32;
/// Some structs need a unique id.
/// In order to generate it, we use an atomic static variable
/// that is incremented for each struct.
pub fn next_id() -> StructId {
STRUCT_COUNTER.with(|count| count.fetch_add(1, Ordering::SeqCst))
}

View File

@ -0,0 +1,71 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Error;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct NonFatalErrorSet {
pub file: PathBuf,
pub errors: Vec<ErrorRecord>,
}
impl NonFatalErrorSet {
pub fn new(file: &Path, non_fatal_errors: Vec<ErrorRecord>) -> Self {
Self {
file: file.to_owned(),
errors: non_fatal_errors,
}
}
pub fn single_error(file: &Path, error: Error) -> Self {
Self {
file: file.to_owned(),
errors: vec![ErrorRecord::error(error)],
}
}
}
#[derive(Debug)]
pub struct ErrorRecord {
pub level: ErrorLevel,
pub error: Error,
}
impl ErrorRecord {
pub fn error(error: Error) -> Self {
Self {
level: ErrorLevel::Error,
error,
}
}
pub fn warn(error: Error) -> Self {
Self {
level: ErrorLevel::Warning,
error,
}
}
}
#[derive(Debug, PartialEq)]
pub enum ErrorLevel {
Error,
Warning,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,690 @@
/*
* This file is part of espanso.
*
* C title: (), class: (), exec: ()opyright (C) 2019-2021 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 anyhow::Result;
use log::warn;
use regex::Regex;
use std::{collections::HashMap, path::Path, sync::Arc};
use self::config::LegacyConfig;
use crate::matches::{
group::loader::yaml::{
parse::{YAMLMatch, YAMLVariable},
try_convert_into_match, try_convert_into_variable,
},
MatchEffect,
};
use crate::{config::store::DefaultConfigStore, counter::StructId};
use crate::{
config::Config,
config::{AppProperties, ConfigStore},
counter::next_id,
matches::{
store::{MatchSet, MatchStore},
Match, Variable,
},
};
use std::convert::TryInto;
mod config;
mod model;
pub fn load(
base_dir: &Path,
package_dir: &Path,
) -> Result<(Box<dyn ConfigStore>, Box<dyn MatchStore>)> {
let config_set = config::LegacyConfigSet::load(base_dir, package_dir)?;
let mut match_deduplicate_map = HashMap::new();
let mut var_deduplicate_map = HashMap::new();
let (default_config, mut default_match_group) = split_config(config_set.default);
deduplicate_ids(
&mut default_match_group,
&mut match_deduplicate_map,
&mut var_deduplicate_map,
);
let mut match_groups = HashMap::new();
match_groups.insert("default".to_string(), default_match_group);
let mut custom_configs: Vec<Arc<dyn Config>> = Vec::new();
for custom in config_set.specific {
let (custom_config, mut custom_match_group) = split_config(custom);
deduplicate_ids(
&mut custom_match_group,
&mut match_deduplicate_map,
&mut var_deduplicate_map,
);
match_groups.insert(custom_config.name.clone(), custom_match_group);
custom_configs.push(Arc::new(custom_config));
}
let config_store = DefaultConfigStore::from_configs(Arc::new(default_config), custom_configs)?;
let match_store = LegacyMatchStore::new(match_groups);
Ok((Box::new(config_store), Box::new(match_store)))
}
fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup) {
let global_vars = config
.global_vars
.iter()
.filter_map(|var| {
let var: YAMLVariable = serde_yaml::from_value(var.clone()).ok()?;
let (var, warnings) = try_convert_into_variable(var).ok()?;
warnings.into_iter().for_each(|warning| {
warn!("{}", warning);
});
Some(var)
})
.collect();
let matches = config
.matches
.iter()
.filter_map(|var| {
let m: YAMLMatch = serde_yaml::from_value(var.clone()).ok()?;
let (m, warnings) = try_convert_into_match(m).ok()?;
warnings.into_iter().for_each(|warning| {
warn!("{}", warning);
});
Some(m)
})
.collect();
let match_group = LegacyMatchGroup {
matches,
global_vars,
};
let config: LegacyInteropConfig = config.into();
(config, match_group)
}
/// Due to the way the legacy configs are loaded (matches are copied multiple times in the various configs)
/// we need to deduplicate the ids of those matches (and global vars).
fn deduplicate_ids(
match_group: &mut LegacyMatchGroup,
match_map: &mut HashMap<Match, StructId>,
var_map: &mut HashMap<Variable, StructId>,
) {
deduplicate_vars(&mut match_group.global_vars, var_map);
deduplicate_matches(&mut match_group.matches, match_map, var_map);
}
fn deduplicate_matches(
matches: &mut [Match],
match_map: &mut HashMap<Match, StructId>,
var_map: &mut HashMap<Variable, StructId>,
) {
for m in matches.iter_mut() {
// Deduplicate variables first
if let MatchEffect::Text(effect) = &mut m.effect {
deduplicate_vars(&mut effect.vars, var_map);
}
let mut m_without_id = m.clone();
m_without_id.id = 0;
if let Some(id) = match_map.get(&m_without_id) {
m.id = *id;
} else {
match_map.insert(m_without_id, m.id);
}
}
}
// TODO: test case of matches with inner variables
fn deduplicate_vars(vars: &mut [Variable], var_map: &mut HashMap<Variable, StructId>) {
for v in vars.iter_mut() {
let mut v_without_id = v.clone();
v_without_id.id = 0;
if let Some(id) = var_map.get(&v_without_id) {
v.id = *id;
} else {
var_map.insert(v_without_id, v.id);
}
}
}
struct LegacyInteropConfig {
pub name: String,
match_paths: Vec<String>,
id: i32,
config: LegacyConfig,
filter_title: Option<Regex>,
filter_class: Option<Regex>,
filter_exec: Option<Regex>,
}
impl From<config::LegacyConfig> for LegacyInteropConfig {
fn from(config: config::LegacyConfig) -> Self {
Self {
id: next_id(),
config: config.clone(),
name: config.name.clone(),
match_paths: vec![config.name],
filter_title: if !config.filter_title.is_empty() {
Regex::new(&config.filter_title).ok()
} else {
None
},
filter_class: if !config.filter_class.is_empty() {
Regex::new(&config.filter_class).ok()
} else {
None
},
filter_exec: if !config.filter_exec.is_empty() {
Regex::new(&config.filter_exec).ok()
} else {
None
},
}
}
}
impl Config for LegacyInteropConfig {
fn id(&self) -> i32 {
self.id
}
fn label(&self) -> &str {
&self.config.name
}
fn backend(&self) -> crate::config::Backend {
match self.config.backend {
config::BackendType::Inject => crate::config::Backend::Inject,
config::BackendType::Clipboard => crate::config::Backend::Clipboard,
config::BackendType::Auto => crate::config::Backend::Auto,
}
}
fn auto_restart(&self) -> bool {
self.config.auto_restart
}
fn match_paths(&self) -> &[String] {
&self.match_paths
}
fn is_match(&self, app: &AppProperties) -> bool {
if self.filter_title.is_none() && self.filter_exec.is_none() && self.filter_class.is_none() {
return false;
}
let is_title_match = if let Some(title_regex) = self.filter_title.as_ref() {
if let Some(title) = app.title {
title_regex.is_match(title)
} else {
false
}
} else {
true
};
let is_exec_match = if let Some(exec_regex) = self.filter_exec.as_ref() {
if let Some(exec) = app.exec {
exec_regex.is_match(exec)
} else {
false
}
} else {
true
};
let is_class_match = if let Some(class_regex) = self.filter_class.as_ref() {
if let Some(class) = app.class {
class_regex.is_match(class)
} else {
false
}
} else {
true
};
// All the filters that have been specified must be true to define a match
is_exec_match && is_title_match && is_class_match
}
fn clipboard_threshold(&self) -> usize {
crate::config::default::DEFAULT_CLIPBOARD_THRESHOLD
}
fn pre_paste_delay(&self) -> usize {
crate::config::default::DEFAULT_PRE_PASTE_DELAY
}
fn toggle_key(&self) -> Option<crate::config::ToggleKey> {
match self.config.toggle_key {
model::KeyModifier::CTRL => Some(crate::config::ToggleKey::Ctrl),
model::KeyModifier::SHIFT => Some(crate::config::ToggleKey::Shift),
model::KeyModifier::ALT => Some(crate::config::ToggleKey::Alt),
model::KeyModifier::META => Some(crate::config::ToggleKey::Meta),
model::KeyModifier::BACKSPACE => None,
model::KeyModifier::OFF => None,
model::KeyModifier::LEFT_CTRL => Some(crate::config::ToggleKey::LeftCtrl),
model::KeyModifier::RIGHT_CTRL => Some(crate::config::ToggleKey::RightCtrl),
model::KeyModifier::LEFT_ALT => Some(crate::config::ToggleKey::LeftAlt),
model::KeyModifier::RIGHT_ALT => Some(crate::config::ToggleKey::RightAlt),
model::KeyModifier::LEFT_META => Some(crate::config::ToggleKey::LeftMeta),
model::KeyModifier::RIGHT_META => Some(crate::config::ToggleKey::RightMeta),
model::KeyModifier::LEFT_SHIFT => Some(crate::config::ToggleKey::LeftShift),
model::KeyModifier::RIGHT_SHIFT => Some(crate::config::ToggleKey::RightShift),
model::KeyModifier::CAPS_LOCK => None,
}
}
fn preserve_clipboard(&self) -> bool {
self.config.preserve_clipboard
}
fn restore_clipboard_delay(&self) -> usize {
self.config.restore_clipboard_delay.try_into().unwrap()
}
fn paste_shortcut_event_delay(&self) -> usize {
crate::config::default::DEFAULT_SHORTCUT_EVENT_DELAY
}
fn paste_shortcut(&self) -> Option<String> {
match self.config.paste_shortcut {
model::PasteShortcut::Default => None,
model::PasteShortcut::CtrlV => Some("CTRL+V".to_string()),
model::PasteShortcut::CtrlShiftV => Some("CTRL+SHIFT+V".to_string()),
model::PasteShortcut::ShiftInsert => Some("SHIFT+INSERT".to_string()),
model::PasteShortcut::CtrlAltV => Some("CTRL+ALT+V".to_string()),
model::PasteShortcut::MetaV => Some("META+V".to_string()),
}
}
fn disable_x11_fast_inject(&self) -> bool {
!self.config.fast_inject
}
fn inject_delay(&self) -> Option<usize> {
if self.config.inject_delay == 0 {
None
} else {
Some(self.config.inject_delay.try_into().unwrap())
}
}
fn key_delay(&self) -> Option<usize> {
if self.config.backspace_delay == 0 {
None
} else {
Some(self.config.backspace_delay.try_into().unwrap())
}
}
fn word_separators(&self) -> Vec<String> {
self
.config
.word_separators
.iter()
.map(|c| String::from(*c))
.collect()
}
fn backspace_limit(&self) -> usize {
self.config.backspace_limit.try_into().unwrap()
}
fn apply_patch(&self) -> bool {
true
}
fn keyboard_layout(&self) -> Option<crate::config::RMLVOConfig> {
None
}
fn search_trigger(&self) -> Option<String> {
self.config.search_trigger.clone()
}
fn search_shortcut(&self) -> Option<String> {
self.config.search_shortcut.clone()
}
fn undo_backspace(&self) -> bool {
self.config.undo_backspace
}
fn show_icon(&self) -> bool {
self.config.show_icon
}
fn show_notifications(&self) -> bool {
self.config.show_notifications
}
fn secure_input_notification(&self) -> bool {
self.config.secure_input_notification
}
fn enable(&self) -> bool {
self.config.enable_active
}
fn win32_exclude_orphan_events(&self) -> bool {
true
}
fn evdev_modifier_delay(&self) -> Option<usize> {
Some(10)
}
}
struct LegacyMatchGroup {
matches: Vec<Match>,
global_vars: Vec<Variable>,
}
struct LegacyMatchStore {
groups: HashMap<String, LegacyMatchGroup>,
}
impl LegacyMatchStore {
pub fn new(groups: HashMap<String, LegacyMatchGroup>) -> Self {
Self { groups }
}
}
impl MatchStore for LegacyMatchStore {
fn query(&self, paths: &[String]) -> MatchSet {
let group = if !paths.is_empty() {
self.groups.get(&paths[0])
} else {
None
};
if let Some(group) = group {
MatchSet {
matches: group.matches.iter().collect(),
global_vars: group.global_vars.iter().collect(),
}
} else {
MatchSet {
matches: Vec::new(),
global_vars: Vec::new(),
}
}
}
fn loaded_paths(&self) -> Vec<String> {
self.groups.keys().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs::create_dir_all, path::Path};
use tempdir::TempDir;
pub fn use_test_directory(callback: impl FnOnce(&Path, &Path, &Path)) {
let dir = TempDir::new("tempconfig").unwrap();
let user_dir = dir.path().join("user");
create_dir_all(&user_dir).unwrap();
let package_dir = TempDir::new("tempconfig").unwrap();
callback(
&dunce::canonicalize(&dir.path()).unwrap(),
&dunce::canonicalize(&user_dir).unwrap(),
&dunce::canonicalize(&package_dir.path()).unwrap(),
);
}
#[test]
fn load_legacy_works_correctly() {
use_test_directory(|base, user, packages| {
std::fs::write(
base.join("default.yml"),
r#"
backend: Clipboard
global_vars:
- name: var1
type: test
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
std::fs::write(
user.join("specific.yml"),
r#"
name: specific
parent: default
matches:
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
std::fs::write(
user.join("separate.yml"),
r#"
name: separate
filter_title: "Google"
matches:
- trigger: "eren"
replace: "mikasa"
"#,
)
.unwrap();
let (config_store, match_store) = load(base, packages).unwrap();
let default_config = config_store.default();
assert_eq!(default_config.match_paths().len(), 1);
let active_config = config_store.active(&AppProperties {
title: Some("Google"),
class: None,
exec: None,
});
assert_eq!(active_config.match_paths().len(), 1);
let default_fallback = config_store.active(&AppProperties {
title: Some("Yahoo"),
class: None,
exec: None,
});
assert_eq!(default_fallback.match_paths().len(), 1);
assert_eq!(
match_store
.query(default_config.match_paths())
.matches
.len(),
2
);
assert_eq!(
match_store
.query(default_config.match_paths())
.global_vars
.len(),
1
);
assert_eq!(
match_store.query(active_config.match_paths()).matches.len(),
3
);
assert_eq!(
match_store
.query(active_config.match_paths())
.global_vars
.len(),
1
);
assert_eq!(
match_store
.query(default_fallback.match_paths())
.matches
.len(),
2
);
assert_eq!(
match_store
.query(default_fallback.match_paths())
.global_vars
.len(),
1
);
});
}
#[test]
fn load_legacy_deduplicates_ids_correctly() {
use_test_directory(|base, user, packages| {
std::fs::write(
base.join("default.yml"),
r#"
backend: Clipboard
global_vars:
- name: var1
type: test
matches:
- trigger: "hello"
replace: "world"
- trigger: "withvars"
replace: "{{output}}"
vars:
- name: "output"
type: "echo"
params:
echo: "test"
"#,
)
.unwrap();
std::fs::write(
user.join("specific.yml"),
r#"
name: specific
filter_title: "Google"
"#,
)
.unwrap();
let (config_store, match_store) = load(base, packages).unwrap();
let default_config = config_store.default();
let active_config = config_store.active(&AppProperties {
title: Some("Google"),
class: None,
exec: None,
});
for (i, m) in match_store
.query(default_config.match_paths())
.matches
.into_iter()
.enumerate()
{
assert_eq!(
m.id,
match_store
.query(active_config.match_paths())
.matches
.get(i)
.unwrap()
.id
);
}
assert_eq!(
match_store
.query(default_config.match_paths())
.global_vars
.first()
.unwrap()
.id,
match_store
.query(active_config.match_paths())
.global_vars
.first()
.unwrap()
.id,
);
});
}
#[test]
fn load_legacy_with_packages() {
use_test_directory(|base, _, packages| {
std::fs::write(
base.join("default.yml"),
r#"
backend: Clipboard
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
create_dir_all(packages.join("test-package")).unwrap();
std::fs::write(
packages.join("test-package").join("package.yml"),
r#"
name: test-package
parent: default
matches:
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
let (config_store, match_store) = load(base, packages).unwrap();
let default_config = config_store.default();
assert_eq!(default_config.match_paths().len(), 1);
assert_eq!(
match_store
.query(default_config.match_paths())
.matches
.len(),
2
);
});
}
}

View File

@ -0,0 +1,62 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use serde::{Deserialize, Serialize};
#[allow(non_camel_case_types)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum KeyModifier {
CTRL,
SHIFT,
ALT,
META,
BACKSPACE,
OFF,
// These are specific variants of the ones above. See issue: #117
// https://github.com/federico-terzi/espanso/issues/117
LEFT_CTRL,
RIGHT_CTRL,
LEFT_ALT,
RIGHT_ALT,
LEFT_META,
RIGHT_META,
LEFT_SHIFT,
RIGHT_SHIFT,
// Special cases, should not be used in config
CAPS_LOCK,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum PasteShortcut {
Default, // Default one for the current system
CtrlV, // Classic Ctrl+V shortcut
CtrlShiftV, // Could be used to paste without formatting in many applications
ShiftInsert, // Often used in Linux systems
CtrlAltV, // Used in some Linux terminals (urxvt)
MetaV, // Corresponding to Win+V on Windows and Linux, CMD+V on macOS
}
impl Default for PasteShortcut {
fn default() -> Self {
PasteShortcut::Default
}
}

319
espanso-config/src/lib.rs Normal file
View File

@ -0,0 +1,319 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Result;
use config::ConfigStore;
use matches::store::MatchStore;
use std::path::Path;
use thiserror::Error;
#[macro_use]
extern crate lazy_static;
pub mod config;
mod counter;
pub mod error;
mod legacy;
pub mod matches;
mod util;
#[allow(clippy::type_complexity)]
pub fn load(
base_path: &Path,
) -> Result<(
Box<dyn ConfigStore>,
Box<dyn MatchStore>,
Vec<error::NonFatalErrorSet>,
)> {
let config_dir = base_path.join("config");
if !config_dir.exists() || !config_dir.is_dir() {
return Err(ConfigError::MissingConfigDir().into());
}
let (config_store, non_fatal_config_errors) = config::load_store(&config_dir)?;
let root_paths = config_store.get_all_match_paths();
let (match_store, non_fatal_match_errors) =
matches::store::load(&root_paths.into_iter().collect::<Vec<String>>());
let mut non_fatal_errors = Vec::new();
non_fatal_errors.extend(non_fatal_config_errors.into_iter());
non_fatal_errors.extend(non_fatal_match_errors.into_iter());
Ok((
Box::new(config_store),
Box::new(match_store),
non_fatal_errors,
))
}
pub fn load_legacy(
config_dir: &Path,
package_dir: &Path,
) -> Result<(Box<dyn ConfigStore>, Box<dyn MatchStore>)> {
legacy::load(config_dir, package_dir)
}
pub fn is_legacy_config(base_dir: &Path) -> bool {
base_dir.join("user").is_dir() && base_dir.join("default.yml").is_file()
}
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("missing config directory")]
MissingConfigDir(),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::tests::use_test_directory;
use config::AppProperties;
#[test]
fn load_works_correctly() {
use_test_directory(|base, match_dir, config_dir| {
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(
&another_file,
r#"
imports:
- "_sub.yml"
matches:
- trigger: "hello2"
replace: "world2"
"#,
)
.unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(
&under_file,
r#"
matches:
- trigger: "hello3"
replace: "world3"
"#,
)
.unwrap();
let config_file = config_dir.join("default.yml");
std::fs::write(&config_file, "").unwrap();
let custom_config_file = config_dir.join("custom.yml");
std::fs::write(
&custom_config_file,
r#"
filter_title: "Chrome"
use_standard_includes: false
includes: ["../match/another.yml"]
"#,
)
.unwrap();
let (config_store, match_store, errors) = load(base).unwrap();
assert_eq!(errors.len(), 0);
assert_eq!(config_store.default().match_paths().len(), 2);
assert_eq!(
config_store
.active(&AppProperties {
title: Some("Google Chrome"),
class: None,
exec: None,
})
.match_paths()
.len(),
1
);
assert_eq!(
match_store
.query(config_store.default().match_paths())
.matches
.len(),
3
);
assert_eq!(
match_store
.query(
config_store
.active(&AppProperties {
title: Some("Chrome"),
class: None,
exec: None,
})
.match_paths()
)
.matches
.len(),
2
);
});
}
#[test]
fn load_non_fatal_errors() {
use_test_directory(|base, match_dir, config_dir| {
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
matches:
- "invalid"invalid
"#,
)
.unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(
&another_file,
r#"
imports:
- "_sub.yml"
matches:
- trigger: "hello2"
replace: "world2"
"#,
)
.unwrap();
let under_file = match_dir.join("_sub.yml");
std::fs::write(
&under_file,
r#"
matches:
- trigger: "hello3"
replace: "world3"invalid
"#,
)
.unwrap();
let config_file = config_dir.join("default.yml");
std::fs::write(&config_file, r#""#).unwrap();
let custom_config_file = config_dir.join("custom.yml");
std::fs::write(
&custom_config_file,
r#"
filter_title: "Chrome"
"
use_standard_includes: false
includes: ["../match/another.yml"]
"#,
)
.unwrap();
let (config_store, match_store, errors) = load(base).unwrap();
assert_eq!(errors.len(), 3);
// It shouldn't have loaded the "config.yml" one because of the YAML error
assert_eq!(config_store.configs().len(), 1);
// It shouldn't load "base.yml" and "_sub.yml" due to YAML errors
assert_eq!(match_store.loaded_paths().len(), 1);
});
}
#[test]
fn load_non_fatal_match_errors() {
use_test_directory(|base, match_dir, config_dir| {
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
matches:
- trigger: "hello"
replace: "world"
- trigger: "invalid because there is no action field"
"#,
)
.unwrap();
let config_file = config_dir.join("default.yml");
std::fs::write(&config_file, r#""#).unwrap();
let (config_store, match_store, errors) = load(base).unwrap();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].file, base_file);
assert_eq!(errors[0].errors.len(), 1);
assert_eq!(
match_store
.query(config_store.default().match_paths())
.matches
.len(),
1
);
});
}
#[test]
fn load_fatal_errors() {
use_test_directory(|base, match_dir, config_dir| {
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
matches:
- trigger: hello
replace: world
"#,
)
.unwrap();
let config_file = config_dir.join("default.yml");
std::fs::write(
&config_file,
r#"
invalid
"
"#,
)
.unwrap();
// A syntax error in the default.yml file cannot be handled gracefully
assert!(load(base).is_err());
});
}
#[test]
fn load_without_valid_config_dir() {
use_test_directory(|_, match_dir, _| {
// To correcly load the configs, the "load" method looks for the "config" directory
assert!(load(match_dir).is_err());
});
}
}

View File

@ -0,0 +1,179 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Result;
use std::path::Path;
use thiserror::Error;
use crate::error::NonFatalErrorSet;
use self::yaml::YAMLImporter;
use super::MatchGroup;
pub(crate) mod yaml;
trait Importer {
fn is_supported(&self, extension: &str) -> bool;
fn load_group(&self, path: &Path) -> Result<(MatchGroup, Option<NonFatalErrorSet>)>;
}
lazy_static! {
static ref IMPORTERS: Vec<Box<dyn Importer + Sync + Send>> = vec![Box::new(YAMLImporter::new()),];
}
pub(crate) fn load_match_group(path: &Path) -> Result<(MatchGroup, Option<NonFatalErrorSet>)> {
if let Some(extension) = path.extension() {
let extension = extension.to_string_lossy().to_lowercase();
let importer = IMPORTERS
.iter()
.find(|importer| importer.is_supported(&extension));
match importer {
Some(importer) => match importer.load_group(path) {
Ok((group, non_fatal_error_set)) => Ok((group, non_fatal_error_set)),
Err(err) => Err(LoadError::ParsingError(err).into()),
},
None => Err(LoadError::InvalidFormat.into()),
}
} else {
Err(LoadError::MissingExtension.into())
}
}
#[derive(Error, Debug)]
pub enum LoadError {
#[error("missing extension in match group file")]
MissingExtension,
#[error("invalid match group format")]
InvalidFormat,
#[error(transparent)]
ParsingError(anyhow::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::tests::use_test_directory;
#[test]
fn load_group_invalid_format() {
use_test_directory(|_, match_dir, _| {
let file = match_dir.join("base.invalid");
std::fs::write(&file, "test").unwrap();
assert!(matches!(
load_match_group(&file)
.unwrap_err()
.downcast::<LoadError>()
.unwrap(),
LoadError::InvalidFormat
));
});
}
#[test]
fn load_group_missing_extension() {
use_test_directory(|_, match_dir, _| {
let file = match_dir.join("base");
std::fs::write(&file, "test").unwrap();
assert!(matches!(
load_match_group(&file)
.unwrap_err()
.downcast::<LoadError>()
.unwrap(),
LoadError::MissingExtension
));
});
}
#[test]
fn load_group_parsing_error() {
use_test_directory(|_, match_dir, _| {
let file = match_dir.join("base.yml");
std::fs::write(&file, "test").unwrap();
assert!(matches!(
load_match_group(&file)
.unwrap_err()
.downcast::<LoadError>()
.unwrap(),
LoadError::ParsingError(_)
));
});
}
#[test]
fn load_group_yaml_format() {
use_test_directory(|_, match_dir, _| {
let file = match_dir.join("base.yml");
std::fs::write(
&file,
r#"
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
assert_eq!(load_match_group(&file).unwrap().0.matches.len(), 1);
});
}
#[test]
fn load_group_yaml_format_2() {
use_test_directory(|_, match_dir, _| {
let file = match_dir.join("base.yaml");
std::fs::write(
&file,
r#"
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
assert_eq!(load_match_group(&file).unwrap().0.matches.len(), 1);
});
}
#[test]
fn load_group_yaml_format_casing() {
use_test_directory(|_, match_dir, _| {
let file = match_dir.join("base.YML");
std::fs::write(
&file,
r#"
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
assert_eq!(load_match_group(&file).unwrap().0.matches.len(), 1);
});
}
}

View File

@ -0,0 +1,720 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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::{
counter::next_id,
error::{ErrorRecord, NonFatalErrorSet},
matches::{
group::{path::resolve_imports, MatchGroup},
ImageEffect, Match, Params, RegexCause, TextFormat, TextInjectMode, UpperCasingStyle, Value,
Variable,
},
};
use anyhow::{anyhow, bail, Context, Result};
use parse::YAMLMatchGroup;
use regex::{Captures, Regex};
use self::{
parse::{YAMLMatch, YAMLVariable},
util::convert_params,
};
use crate::matches::{MatchCause, MatchEffect, TextEffect, TriggerCause};
use super::Importer;
pub(crate) mod parse;
mod util;
lazy_static! {
static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)(\\.\\w+)?\\s*\\}\\}").unwrap();
}
// Create an alias to make the meaning more explicit
type Warning = anyhow::Error;
pub(crate) struct YAMLImporter {}
impl YAMLImporter {
pub fn new() -> Self {
Self {}
}
}
impl Importer for YAMLImporter {
fn is_supported(&self, extension: &str) -> bool {
extension == "yaml" || extension == "yml"
}
fn load_group(
&self,
path: &std::path::Path,
) -> anyhow::Result<(crate::matches::group::MatchGroup, Option<NonFatalErrorSet>)> {
let yaml_group =
YAMLMatchGroup::parse_from_file(path).context("failed to parse YAML match group")?;
let mut non_fatal_errors = Vec::new();
let mut global_vars = Vec::new();
for yaml_global_var in yaml_group.global_vars.as_ref().cloned().unwrap_or_default() {
match try_convert_into_variable(yaml_global_var) {
Ok((var, warnings)) => {
global_vars.push(var);
non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn));
}
Err(err) => {
non_fatal_errors.push(ErrorRecord::error(err));
}
}
}
let mut matches = Vec::new();
for yaml_match in yaml_group.matches.as_ref().cloned().unwrap_or_default() {
match try_convert_into_match(yaml_match) {
Ok((m, warnings)) => {
matches.push(m);
non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn));
}
Err(err) => {
non_fatal_errors.push(ErrorRecord::error(err));
}
}
}
// Resolve imports
let (resolved_imports, import_errors) =
resolve_imports(path, &yaml_group.imports.unwrap_or_default())
.context("failed to resolve YAML match group imports")?;
non_fatal_errors.extend(import_errors);
let non_fatal_error_set = if !non_fatal_errors.is_empty() {
Some(NonFatalErrorSet::new(path, non_fatal_errors))
} else {
None
};
Ok((
MatchGroup {
imports: resolved_imports,
global_vars,
matches,
},
non_fatal_error_set,
))
}
}
pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warning>)> {
let mut warnings = Vec::new();
if yaml_match.uppercase_style.is_some() && yaml_match.propagate_case.is_none() {
warnings.push(anyhow!(
"specifying the 'uppercase_style' option without 'propagate_case' has no effect"
));
}
let triggers = if let Some(trigger) = yaml_match.trigger {
Some(vec![trigger])
} else {
yaml_match.triggers
};
let uppercase_style = match yaml_match
.uppercase_style
.map(|s| s.to_lowercase())
.as_deref()
{
Some("uppercase") => UpperCasingStyle::Uppercase,
Some("capitalize") => UpperCasingStyle::Capitalize,
Some("capitalize_words") => UpperCasingStyle::CapitalizeWords,
Some(style) => {
warnings.push(anyhow!(
"unrecognized uppercase_style: {:?}, falling back to the default",
style
));
TriggerCause::default().uppercase_style
}
_ => TriggerCause::default().uppercase_style,
};
let cause = if let Some(triggers) = triggers {
MatchCause::Trigger(TriggerCause {
triggers,
left_word: yaml_match
.left_word
.or(yaml_match.word)
.unwrap_or(TriggerCause::default().left_word),
right_word: yaml_match
.right_word
.or(yaml_match.word)
.unwrap_or(TriggerCause::default().right_word),
propagate_case: yaml_match
.propagate_case
.unwrap_or(TriggerCause::default().propagate_case),
uppercase_style,
})
} else if let Some(regex) = yaml_match.regex {
// TODO: add test case
MatchCause::Regex(RegexCause { regex })
} else {
MatchCause::None
};
// TODO: test force_mode/force_clipboard
let force_mode = if let Some(true) = yaml_match.force_clipboard {
Some(TextInjectMode::Clipboard)
} else if let Some(mode) = yaml_match.force_mode {
match mode.to_lowercase().as_str() {
"clipboard" => Some(TextInjectMode::Clipboard),
"keys" => Some(TextInjectMode::Keys),
_ => None,
}
} else {
None
};
let effect =
if yaml_match.replace.is_some() || yaml_match.markdown.is_some() || yaml_match.html.is_some() {
// TODO: test markdown and html cases
let (replace, format) = if let Some(plain) = yaml_match.replace {
(plain, TextFormat::Plain)
} else if let Some(markdown) = yaml_match.markdown {
(markdown, TextFormat::Markdown)
} else if let Some(html) = yaml_match.html {
(html, TextFormat::Html)
} else {
unreachable!();
};
let mut vars: Vec<Variable> = Vec::new();
for yaml_var in yaml_match.vars.unwrap_or_default() {
let (var, var_warnings) = try_convert_into_variable(yaml_var.clone())
.with_context(|| format!("failed to load variable: {:?}", yaml_var))?;
warnings.extend(var_warnings);
vars.push(var);
}
MatchEffect::Text(TextEffect {
replace,
vars,
format,
force_mode,
})
} else if let Some(form_layout) = yaml_match.form {
// TODO: test form case
// Replace all the form fields with actual variables
let resolved_layout = VAR_REGEX
.replace_all(&form_layout, |caps: &Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!("{{{{form1.{}}}}}", var_name)
})
.to_string();
// Convert escaped brakets in forms
let resolved_layout = resolved_layout.replace("\\{", "{ ").replace("\\}", " }");
// Convert the form data to valid variables
let mut params = Params::new();
params.insert("layout".to_string(), Value::String(form_layout));
if let Some(fields) = yaml_match.form_fields {
params.insert("fields".to_string(), Value::Object(convert_params(fields)?));
}
let vars = vec![Variable {
id: next_id(),
name: "form1".to_owned(),
var_type: "form".to_owned(),
params,
}];
MatchEffect::Text(TextEffect {
replace: resolved_layout,
vars,
format: TextFormat::Plain,
force_mode,
})
} else if let Some(image_path) = yaml_match.image_path {
// TODO: test image case
MatchEffect::Image(ImageEffect { path: image_path })
} else {
MatchEffect::None
};
if let MatchEffect::None = effect {
bail!(
"match triggered by {:?} does not produce any effect. Did you forget the 'replace' field?",
cause.long_description()
);
}
Ok((
Match {
cause,
effect,
label: yaml_match.label,
id: next_id(),
},
warnings,
))
}
pub fn try_convert_into_variable(yaml_var: YAMLVariable) -> Result<(Variable, Vec<Warning>)> {
Ok((
Variable {
name: yaml_var.name,
var_type: yaml_var.var_type,
params: convert_params(yaml_var.params)?,
id: next_id(),
},
Vec::new(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
matches::{Match, Params, Value},
util::tests::use_test_directory,
};
use std::fs::create_dir_all;
fn create_match_with_warnings(yaml: &str) -> Result<(Match, Vec<Warning>)> {
let yaml_match: YAMLMatch = serde_yaml::from_str(yaml)?;
let (mut m, warnings) = try_convert_into_match(yaml_match)?;
// Reset the IDs to correctly compare them
m.id = 0;
if let MatchEffect::Text(e) = &mut m.effect {
e.vars.iter_mut().for_each(|v| v.id = 0);
}
Ok((m, warnings))
}
fn create_match(yaml: &str) -> Result<Match> {
let (m, warnings) = create_match_with_warnings(yaml)?;
if !warnings.is_empty() {
panic!("warnings were detected but not handled: {:?}", warnings);
}
Ok(m)
}
#[test]
fn basic_match_maps_correctly() {
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn multiple_triggers_maps_correctly() {
assert_eq!(
create_match(
r#"
triggers: ["Hello", "john"]
replace: "world"
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string(), "john".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn word_maps_correctly() {
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
word: true
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
left_word: true,
right_word: true,
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn left_word_maps_correctly() {
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
left_word: true
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
left_word: true,
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn right_word_maps_correctly() {
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
right_word: true
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
right_word: true,
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn propagate_case_maps_correctly() {
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
propagate_case: true
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
propagate_case: true,
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn uppercase_style_maps_correctly() {
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
uppercase_style: "capitalize"
propagate_case: true
"#
)
.unwrap()
.cause
.into_trigger()
.unwrap()
.uppercase_style,
UpperCasingStyle::Capitalize,
);
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
uppercase_style: "capitalize_words"
propagate_case: true
"#
)
.unwrap()
.cause
.into_trigger()
.unwrap()
.uppercase_style,
UpperCasingStyle::CapitalizeWords,
);
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
uppercase_style: "uppercase"
propagate_case: true
"#
)
.unwrap()
.cause
.into_trigger()
.unwrap()
.uppercase_style,
UpperCasingStyle::Uppercase,
);
// Invalid without propagate_case
let (m, warnings) = create_match_with_warnings(
r#"
trigger: "Hello"
replace: "world"
uppercase_style: "capitalize"
"#,
)
.unwrap();
assert_eq!(
m.cause.into_trigger().unwrap().uppercase_style,
UpperCasingStyle::Capitalize,
);
assert_eq!(warnings.len(), 1);
// Invalid style
let (m, warnings) = create_match_with_warnings(
r#"
trigger: "Hello"
replace: "world"
uppercase_style: "invalid"
propagate_case: true
"#,
)
.unwrap();
assert_eq!(
m.cause.into_trigger().unwrap().uppercase_style,
UpperCasingStyle::Uppercase,
);
assert_eq!(warnings.len(), 1);
}
#[test]
fn vars_maps_correctly() {
let mut params = Params::new();
params.insert("param1".to_string(), Value::Bool(true));
let vars = vec![Variable {
name: "var1".to_string(),
var_type: "test".to_string(),
params,
..Default::default()
}];
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
vars:
- name: var1
type: test
params:
param1: true
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
vars,
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn vars_no_params_maps_correctly() {
let vars = vec![Variable {
name: "var1".to_string(),
var_type: "test".to_string(),
params: Params::new(),
..Default::default()
}];
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
vars:
- name: var1
type: test
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
vars,
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn importer_is_supported() {
let importer = YAMLImporter::new();
assert!(importer.is_supported("yaml"));
assert!(importer.is_supported("yml"));
assert!(!importer.is_supported("invalid"));
}
#[test]
fn importer_works_correctly() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- "sub/sub.yml"
- "invalid/import.yml" # This should be discarded
global_vars:
- name: "var1"
type: "test"
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "").unwrap();
let importer = YAMLImporter::new();
let (mut group, non_fatal_error_set) = importer.load_group(&base_file).unwrap();
// The invalid import path should be reported as error
assert_eq!(non_fatal_error_set.unwrap().errors.len(), 1);
// Reset the ids to compare them correctly
group.matches.iter_mut().for_each(|mut m| m.id = 0);
group.global_vars.iter_mut().for_each(|mut v| v.id = 0);
let vars = vec![Variable {
name: "var1".to_string(),
var_type: "test".to_string(),
params: Params::new(),
..Default::default()
}];
assert_eq!(
group,
MatchGroup {
imports: vec![sub_file.to_string_lossy().to_string(),],
global_vars: vars,
matches: vec![Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
..Default::default()
}),
..Default::default()
}],
}
)
});
}
#[test]
fn importer_invalid_syntax() {
use_test_directory(|_, match_dir, _| {
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- invalid
- indentation
"#,
)
.unwrap();
let importer = YAMLImporter::new();
assert!(importer.load_group(&base_file).is_err());
})
}
}

View File

@ -0,0 +1,132 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use crate::util::is_yaml_empty;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct YAMLMatchGroup {
#[serde(default)]
pub imports: Option<Vec<String>>,
#[serde(default)]
pub global_vars: Option<Vec<YAMLVariable>>,
#[serde(default)]
pub matches: Option<Vec<YAMLMatch>>,
}
impl YAMLMatchGroup {
pub fn parse_from_str(yaml: &str) -> Result<Self> {
// Because an empty string is not valid YAML but we want to support it anyway
if is_yaml_empty(yaml) {
return Ok(serde_yaml::from_str(
"arbitrary_field_that_will_not_block_the_parser: true",
)?);
}
Ok(serde_yaml::from_str(yaml)?)
}
// TODO: test
pub fn parse_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::parse_from_str(&content)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct YAMLMatch {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub triggers: Option<Vec<String>>,
#[serde(default)]
pub regex: Option<String>,
#[serde(default)]
pub replace: Option<String>,
#[serde(default)]
pub image_path: Option<String>,
#[serde(default)]
pub form: Option<String>,
#[serde(default)]
pub form_fields: Option<Mapping>,
#[serde(default)]
pub vars: Option<Vec<YAMLVariable>>,
#[serde(default)]
pub word: Option<bool>,
#[serde(default)]
pub left_word: Option<bool>,
#[serde(default)]
pub right_word: Option<bool>,
#[serde(default)]
pub propagate_case: Option<bool>,
#[serde(default)]
pub uppercase_style: Option<String>,
#[serde(default)]
pub force_clipboard: Option<bool>,
#[serde(default)]
pub force_mode: Option<String>,
#[serde(default)]
pub markdown: Option<String>,
#[serde(default)]
pub paragraph: Option<bool>,
#[serde(default)]
pub html: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct YAMLVariable {
pub name: String,
#[serde(rename = "type")]
pub var_type: String,
#[serde(default = "default_params")]
pub params: Mapping,
}
fn default_params() -> Mapping {
Mapping::new()
}

View File

@ -0,0 +1,176 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::convert::TryInto;
use anyhow::Result;
use serde_yaml::Mapping;
use thiserror::Error;
use crate::matches::{Number, Params, Value};
pub(crate) fn convert_params(m: Mapping) -> Result<Params> {
let mut params = Params::new();
for (key, value) in m {
let key = key.as_str().ok_or(ConversionError::InvalidKeyFormat)?;
let value = convert_value(value)?;
params.insert(key.to_owned(), value);
}
Ok(params)
}
fn convert_value(value: serde_yaml::Value) -> Result<Value> {
Ok(match value {
serde_yaml::Value::Null => Value::Null,
serde_yaml::Value::Bool(val) => Value::Bool(val),
serde_yaml::Value::Number(n) => {
if n.is_i64() {
Value::Number(Number::Integer(
n.as_i64().ok_or(ConversionError::InvalidNumberFormat)?,
))
} else if n.is_u64() {
Value::Number(Number::Integer(
n.as_u64()
.ok_or(ConversionError::InvalidNumberFormat)?
.try_into()?,
))
} else if n.is_f64() {
Value::Number(Number::Float(
n.as_f64()
.ok_or(ConversionError::InvalidNumberFormat)?
.into(),
))
} else {
return Err(ConversionError::InvalidNumberFormat.into());
}
}
serde_yaml::Value::String(s) => Value::String(s),
serde_yaml::Value::Sequence(arr) => Value::Array(
arr
.into_iter()
.map(convert_value)
.collect::<Result<Vec<Value>>>()?,
),
serde_yaml::Value::Mapping(m) => Value::Object(convert_params(m)?),
})
}
#[derive(Error, Debug)]
pub enum ConversionError {
#[error("invalid key format")]
InvalidKeyFormat,
#[error("invalid number format")]
InvalidNumberFormat,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_value_null() {
assert_eq!(convert_value(serde_yaml::Value::Null).unwrap(), Value::Null);
}
#[test]
fn convert_value_bool() {
assert_eq!(
convert_value(serde_yaml::Value::Bool(true)).unwrap(),
Value::Bool(true)
);
assert_eq!(
convert_value(serde_yaml::Value::Bool(false)).unwrap(),
Value::Bool(false)
);
}
#[test]
fn convert_value_number() {
assert_eq!(
convert_value(serde_yaml::Value::Number(0.into())).unwrap(),
Value::Number(Number::Integer(0))
);
assert_eq!(
convert_value(serde_yaml::Value::Number((-100).into())).unwrap(),
Value::Number(Number::Integer(-100))
);
assert_eq!(
convert_value(serde_yaml::Value::Number(1.5.into())).unwrap(),
Value::Number(Number::Float(1.5.into()))
);
}
#[test]
fn convert_value_string() {
assert_eq!(
convert_value(serde_yaml::Value::String("hello".to_string())).unwrap(),
Value::String("hello".to_string())
);
}
#[test]
fn convert_value_array() {
assert_eq!(
convert_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Bool(true),
serde_yaml::Value::Null,
]))
.unwrap(),
Value::Array(vec![Value::Bool(true), Value::Null,])
);
}
#[test]
fn convert_value_params() {
let mut mapping = serde_yaml::Mapping::new();
mapping.insert(
serde_yaml::Value::String("test".to_string()),
serde_yaml::Value::Null,
);
let mut expected = Params::new();
expected.insert("test".to_string(), Value::Null);
assert_eq!(
convert_value(serde_yaml::Value::Mapping(mapping)).unwrap(),
Value::Object(expected)
);
}
#[test]
fn convert_params_works_correctly() {
let mut mapping = serde_yaml::Mapping::new();
mapping.insert(
serde_yaml::Value::String("test".to_string()),
serde_yaml::Value::Null,
);
let mut expected = Params::new();
expected.insert("test".to_string(), Value::Null);
assert_eq!(convert_params(mapping).unwrap(), expected);
}
#[test]
fn convert_params_invalid_key_type() {
let mut mapping = serde_yaml::Mapping::new();
mapping.insert(serde_yaml::Value::Null, serde_yaml::Value::Null);
assert!(convert_params(mapping).is_err());
}
}

View File

@ -0,0 +1,52 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Result;
use std::path::Path;
use crate::error::NonFatalErrorSet;
use super::{Match, Variable};
pub(crate) mod loader;
mod path;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct MatchGroup {
pub imports: Vec<String>,
pub global_vars: Vec<Variable>,
pub matches: Vec<Match>,
}
impl Default for MatchGroup {
fn default() -> Self {
Self {
imports: Vec::new(),
global_vars: Vec::new(),
matches: Vec::new(),
}
}
}
impl MatchGroup {
// TODO: test
pub fn load(group_path: &Path) -> Result<(Self, Option<NonFatalErrorSet>)> {
loader::load_match_group(group_path)
}
}

View File

@ -0,0 +1,164 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::error::ErrorRecord;
pub fn resolve_imports(
group_path: &Path,
imports: &[String],
) -> Result<(Vec<String>, Vec<ErrorRecord>)> {
let mut paths = Vec::new();
// Get the containing directory
let current_dir = if group_path.is_file() {
if let Some(parent) = group_path.parent() {
parent
} else {
return Err(
ResolveImportError::Failed(format!(
"unable to resolve imports for match group starting from current path: {:?}",
group_path
))
.into(),
);
}
} else {
group_path
};
let mut non_fatal_errors = Vec::new();
for import in imports.iter() {
let import_path = PathBuf::from(import);
// Absolute or relative import
let full_path = if import_path.is_relative() {
current_dir.join(import_path)
} else {
import_path
};
match dunce::canonicalize(&full_path)
.with_context(|| format!("unable to canonicalize import path: {:?}", full_path))
{
Ok(canonical_path) => {
if canonical_path.exists() && canonical_path.is_file() {
paths.push(canonical_path)
} else {
// Best effort imports
non_fatal_errors.push(ErrorRecord::error(anyhow!(
"unable to resolve import at path: {:?}",
canonical_path
)))
}
}
Err(error) => non_fatal_errors.push(ErrorRecord::error(error)),
}
}
let string_paths = paths
.into_iter()
.map(|path| path.to_string_lossy().to_string())
.collect();
Ok((string_paths, non_fatal_errors))
}
#[derive(Error, Debug)]
pub enum ResolveImportError {
#[error("resolve import failed: `{0}`")]
Failed(String),
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::util::tests::use_test_directory;
use std::fs::create_dir_all;
#[test]
fn resolve_imports_works_correctly() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let another_file = match_dir.join("another.yml");
std::fs::write(&another_file, "test").unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "test").unwrap();
let absolute_file = sub_dir.join("absolute.yml");
std::fs::write(&absolute_file, "test").unwrap();
let imports = vec![
"another.yml".to_string(),
"sub/sub.yml".to_string(),
absolute_file.to_string_lossy().to_string(),
"sub/invalid.yml".to_string(), // Should be skipped
];
let (resolved_imports, errors) = resolve_imports(&base_file, &imports).unwrap();
assert_eq!(
resolved_imports,
vec![
another_file.to_string_lossy().to_string(),
sub_file.to_string_lossy().to_string(),
absolute_file.to_string_lossy().to_string(),
]
);
// The "sub/invalid.yml" should generate an error
assert_eq!(errors.len(), 1);
});
}
#[test]
fn resolve_imports_parent_relative_path() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(&base_file, "test").unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(&sub_file, "test").unwrap();
let imports = vec!["../base.yml".to_string()];
let (resolved_imports, errors) = resolve_imports(&sub_file, &imports).unwrap();
assert_eq!(
resolved_imports,
vec![base_file.to_string_lossy().to_string(),]
);
assert_eq!(errors.len(), 0);
});
}
}

View File

@ -0,0 +1,237 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 enum_as_inner::EnumAsInner;
use ordered_float::OrderedFloat;
use std::collections::BTreeMap;
use crate::counter::StructId;
pub(crate) mod group;
pub mod store;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Match {
pub id: StructId,
pub cause: MatchCause,
pub effect: MatchEffect,
// Metadata
pub label: Option<String>,
}
impl Default for Match {
fn default() -> Self {
Self {
cause: MatchCause::None,
effect: MatchEffect::None,
label: None,
id: 0,
}
}
}
impl Match {
// TODO: test
pub fn description(&self) -> &str {
if let Some(label) = &self.label {
label
} else if let MatchEffect::Text(text_effect) = &self.effect {
&text_effect.replace
} else if let MatchEffect::Image(_) = &self.effect {
"Image content"
} else {
"No description available for this match"
}
}
// TODO: test
pub fn cause_description(&self) -> Option<&str> {
self.cause.description()
}
}
// Causes
#[derive(Debug, Clone, Eq, Hash, PartialEq, EnumAsInner)]
pub enum MatchCause {
None,
Trigger(TriggerCause),
Regex(RegexCause),
// TODO: shortcut
}
impl MatchCause {
// TODO: test
pub fn description(&self) -> Option<&str> {
if let MatchCause::Trigger(trigger_cause) = &self {
trigger_cause.triggers.first().map(|s| s.as_str())
} else {
None
}
// TODO: insert rendering for hotkey/shortcut
// TODO: insert rendering for regex? I'm worried it might be too long
}
// TODO: test
pub fn long_description(&self) -> String {
if let MatchCause::Trigger(trigger_cause) = &self {
format!("triggers: {:?}", trigger_cause.triggers)
} else {
"No description available".to_owned()
}
// TODO: insert rendering for hotkey/shortcut
// TODO: insert rendering for regex? I'm worried it might be too long
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TriggerCause {
pub triggers: Vec<String>,
pub left_word: bool,
pub right_word: bool,
pub propagate_case: bool,
pub uppercase_style: UpperCasingStyle,
}
impl Default for TriggerCause {
fn default() -> Self {
Self {
triggers: Vec::new(),
left_word: false,
right_word: false,
propagate_case: false,
uppercase_style: UpperCasingStyle::Uppercase,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum UpperCasingStyle {
Uppercase,
Capitalize,
CapitalizeWords,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RegexCause {
pub regex: String,
}
impl Default for RegexCause {
fn default() -> Self {
Self {
regex: String::new(),
}
}
}
// Effects
#[derive(Debug, Clone, PartialEq, Eq, Hash, EnumAsInner)]
pub enum MatchEffect {
None,
Text(TextEffect),
Image(ImageEffect),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TextEffect {
pub replace: String,
pub vars: Vec<Variable>,
pub format: TextFormat,
pub force_mode: Option<TextInjectMode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TextFormat {
Plain,
Markdown,
Html,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TextInjectMode {
Keys,
Clipboard,
}
impl Default for TextEffect {
fn default() -> Self {
Self {
replace: String::new(),
vars: Vec::new(),
format: TextFormat::Plain,
force_mode: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ImageEffect {
pub path: String,
}
impl Default for ImageEffect {
fn default() -> Self {
Self {
path: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Variable {
pub id: StructId,
pub name: String,
pub var_type: String,
pub params: Params,
}
impl Default for Variable {
fn default() -> Self {
Self {
id: 0,
name: String::new(),
var_type: String::new(),
params: Params::new(),
}
}
}
pub type Params = BTreeMap<String, Value>;
#[derive(Debug, Clone, PartialEq, Eq, Hash, EnumAsInner)]
pub enum Value {
Null,
Bool(bool),
Number(Number),
String(String),
Array(Vec<Value>),
Object(Params),
}
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub enum Number {
Integer(i64),
Float(OrderedFloat<f64>),
}

View File

@ -0,0 +1,738 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 super::{MatchSet, MatchStore};
use crate::{
counter::StructId,
error::NonFatalErrorSet,
matches::{group::MatchGroup, Match, Variable},
};
use anyhow::Context;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
};
pub(crate) struct DefaultMatchStore {
pub groups: HashMap<String, MatchGroup>,
}
impl DefaultMatchStore {
pub fn load(paths: &[String]) -> (Self, Vec<NonFatalErrorSet>) {
let mut groups = HashMap::new();
let mut non_fatal_error_sets = Vec::new();
// Because match groups can imports other match groups,
// we have to load them recursively starting from the
// top-level ones.
load_match_groups_recursively(&mut groups, paths, &mut non_fatal_error_sets);
(Self { groups }, non_fatal_error_sets)
}
}
impl MatchStore for DefaultMatchStore {
fn query(&self, paths: &[String]) -> MatchSet {
let mut matches: Vec<&Match> = Vec::new();
let mut global_vars: Vec<&Variable> = Vec::new();
let mut visited_paths = HashSet::new();
let mut visited_matches = HashSet::new();
let mut visited_global_vars = HashSet::new();
query_matches_for_paths(
&self.groups,
&mut visited_paths,
&mut visited_matches,
&mut visited_global_vars,
&mut matches,
&mut global_vars,
paths,
);
MatchSet {
matches,
global_vars,
}
}
fn loaded_paths(&self) -> Vec<String> {
self.groups.keys().cloned().collect()
}
}
fn load_match_groups_recursively(
groups: &mut HashMap<String, MatchGroup>,
paths: &[String],
non_fatal_error_sets: &mut Vec<NonFatalErrorSet>,
) {
for path in paths.iter() {
if !groups.contains_key(path) {
let group_path = PathBuf::from(path);
match MatchGroup::load(&group_path)
.with_context(|| format!("unable to load match group {:?}", group_path))
{
Ok((group, non_fatal_error_set)) => {
let imports = group.imports.clone();
groups.insert(path.clone(), group);
if let Some(non_fatal_error_set) = non_fatal_error_set {
non_fatal_error_sets.push(non_fatal_error_set);
}
load_match_groups_recursively(groups, &imports, non_fatal_error_sets);
}
Err(err) => {
non_fatal_error_sets.push(NonFatalErrorSet::single_error(&group_path, err));
}
}
}
}
}
fn query_matches_for_paths<'a>(
groups: &'a HashMap<String, MatchGroup>,
visited_paths: &mut HashSet<String>,
visited_matches: &mut HashSet<StructId>,
visited_global_vars: &mut HashSet<StructId>,
matches: &mut Vec<&'a Match>,
global_vars: &mut Vec<&'a Variable>,
paths: &[String],
) {
for path in paths.iter() {
if !visited_paths.contains(path) {
visited_paths.insert(path.clone());
if let Some(group) = groups.get(path) {
query_matches_for_paths(
groups,
visited_paths,
visited_matches,
visited_global_vars,
matches,
global_vars,
&group.imports,
);
for m in group.matches.iter() {
if !visited_matches.contains(&m.id) {
matches.push(m);
visited_matches.insert(m.id);
}
}
for var in group.global_vars.iter() {
if !visited_global_vars.contains(&var.id) {
global_vars.push(var);
visited_global_vars.insert(var.id);
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
matches::{MatchCause, MatchEffect, TextEffect, TriggerCause},
util::tests::use_test_directory,
};
use std::fs::create_dir_all;
fn create_match(trigger: &str, replace: &str) -> Match {
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec![trigger.to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: replace.to_string(),
..Default::default()
}),
..Default::default()
}
}
fn create_matches(matches: &[(&str, &str)]) -> Vec<Match> {
matches
.iter()
.map(|(trigger, replace)| create_match(trigger, replace))
.collect()
}
fn create_test_var(name: &str) -> Variable {
Variable {
name: name.to_string(),
var_type: "test".to_string(),
..Default::default()
}
}
fn create_vars(vars: &[&str]) -> Vec<Variable> {
vars.iter().map(|var| create_test_var(var)).collect()
}
#[test]
fn match_store_loads_correctly() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- "_another.yml"
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let another_file = match_dir.join("_another.yml");
std::fs::write(
&another_file,
r#"
imports:
- "sub/sub.yml"
matches:
- trigger: "hello"
replace: "world2"
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(
&sub_file,
r#"
matches:
- trigger: "hello"
replace: "world3"
"#,
)
.unwrap();
let (match_store, non_fatal_error_sets) =
DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]);
assert_eq!(non_fatal_error_sets.len(), 0);
assert_eq!(match_store.groups.len(), 3);
let base_group = &match_store
.groups
.get(&base_file.to_string_lossy().to_string())
.unwrap()
.matches;
let base_group: Vec<Match> = base_group
.iter()
.map(|m| {
let mut copy = m.clone();
copy.id = 0;
copy
})
.collect();
assert_eq!(base_group, create_matches(&[("hello", "world")]));
let another_group = &match_store
.groups
.get(&another_file.to_string_lossy().to_string())
.unwrap()
.matches;
let another_group: Vec<Match> = another_group
.iter()
.map(|m| {
let mut copy = m.clone();
copy.id = 0;
copy
})
.collect();
assert_eq!(
another_group,
create_matches(&[("hello", "world2"), ("foo", "bar")])
);
let sub_group = &match_store
.groups
.get(&sub_file.to_string_lossy().to_string())
.unwrap()
.matches;
let sub_group: Vec<Match> = sub_group
.iter()
.map(|m| {
let mut copy = m.clone();
copy.id = 0;
copy
})
.collect();
assert_eq!(sub_group, create_matches(&[("hello", "world3")]));
});
}
#[test]
fn match_store_handles_circular_dependency() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- "_another.yml"
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let another_file = match_dir.join("_another.yml");
std::fs::write(
&another_file,
r#"
imports:
- "sub/sub.yml"
matches:
- trigger: "hello"
replace: "world2"
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(
&sub_file,
r#"
imports:
- "../_another.yml"
matches:
- trigger: "hello"
replace: "world3"
"#,
)
.unwrap();
let (match_store, non_fatal_error_sets) =
DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]);
assert_eq!(match_store.groups.len(), 3);
assert_eq!(non_fatal_error_sets.len(), 0);
});
}
#[test]
fn match_store_query_single_path_with_imports() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- "_another.yml"
global_vars:
- name: var1
type: test
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let another_file = match_dir.join("_another.yml");
std::fs::write(
&another_file,
r#"
imports:
- "sub/sub.yml"
matches:
- trigger: "hello"
replace: "world2"
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(
&sub_file,
r#"
global_vars:
- name: var2
type: test
matches:
- trigger: "hello"
replace: "world3"
"#,
)
.unwrap();
let (match_store, non_fatal_error_sets) =
DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]);
assert_eq!(non_fatal_error_sets.len(), 0);
let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]);
assert_eq!(
match_set
.matches
.into_iter()
.cloned()
.map(|mut m| {
m.id = 0;
m
})
.collect::<Vec<Match>>(),
create_matches(&[
("hello", "world3"),
("hello", "world2"),
("foo", "bar"),
("hello", "world"),
])
);
assert_eq!(
match_set
.global_vars
.into_iter()
.cloned()
.map(|mut v| {
v.id = 0;
v
})
.collect::<Vec<Variable>>(),
create_vars(&["var2", "var1"])
);
});
}
#[test]
fn match_store_query_handles_circular_depencencies() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- "_another.yml"
global_vars:
- name: var1
type: test
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let another_file = match_dir.join("_another.yml");
std::fs::write(
&another_file,
r#"
imports:
- "sub/sub.yml"
matches:
- trigger: "hello"
replace: "world2"
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(
&sub_file,
r#"
imports:
- "../_another.yml" # Circular import
global_vars:
- name: var2
type: test
matches:
- trigger: "hello"
replace: "world3"
"#,
)
.unwrap();
let (match_store, non_fatal_error_sets) =
DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]);
assert_eq!(non_fatal_error_sets.len(), 0);
let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]);
assert_eq!(
match_set
.matches
.into_iter()
.cloned()
.map(|mut m| {
m.id = 0;
m
})
.collect::<Vec<Match>>(),
create_matches(&[
("hello", "world3"),
("hello", "world2"),
("foo", "bar"),
("hello", "world"),
])
);
assert_eq!(
match_set
.global_vars
.into_iter()
.cloned()
.map(|mut v| {
v.id = 0;
v
})
.collect::<Vec<Variable>>(),
create_vars(&["var2", "var1"])
);
});
}
#[test]
fn match_store_query_multiple_paths() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- "_another.yml"
global_vars:
- name: var1
type: test
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let another_file = match_dir.join("_another.yml");
std::fs::write(
&another_file,
r#"
matches:
- trigger: "hello"
replace: "world2"
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(
&sub_file,
r#"
global_vars:
- name: var2
type: test
matches:
- trigger: "hello"
replace: "world3"
"#,
)
.unwrap();
let (match_store, non_fatal_error_sets) = DefaultMatchStore::load(&[
base_file.to_string_lossy().to_string(),
sub_file.to_string_lossy().to_string(),
]);
assert_eq!(non_fatal_error_sets.len(), 0);
let match_set = match_store.query(&[
base_file.to_string_lossy().to_string(),
sub_file.to_string_lossy().to_string(),
]);
assert_eq!(
match_set
.matches
.into_iter()
.cloned()
.map(|mut m| {
m.id = 0;
m
})
.collect::<Vec<Match>>(),
create_matches(&[
("hello", "world2"),
("foo", "bar"),
("hello", "world"),
("hello", "world3"),
])
);
assert_eq!(
match_set
.global_vars
.into_iter()
.cloned()
.map(|mut v| {
v.id = 0;
v
})
.collect::<Vec<Variable>>(),
create_vars(&["var1", "var2"])
);
});
}
#[test]
fn match_store_query_handle_duplicates_when_imports_and_paths_overlap() {
use_test_directory(|_, match_dir, _| {
let sub_dir = match_dir.join("sub");
create_dir_all(&sub_dir).unwrap();
let base_file = match_dir.join("base.yml");
std::fs::write(
&base_file,
r#"
imports:
- "_another.yml"
global_vars:
- name: var1
type: test
matches:
- trigger: "hello"
replace: "world"
"#,
)
.unwrap();
let another_file = match_dir.join("_another.yml");
std::fs::write(
&another_file,
r#"
imports:
- "sub/sub.yml"
matches:
- trigger: "hello"
replace: "world2"
- trigger: "foo"
replace: "bar"
"#,
)
.unwrap();
let sub_file = sub_dir.join("sub.yml");
std::fs::write(
&sub_file,
r#"
global_vars:
- name: var2
type: test
matches:
- trigger: "hello"
replace: "world3"
"#,
)
.unwrap();
let (match_store, non_fatal_error_sets) =
DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]);
assert_eq!(non_fatal_error_sets.len(), 0);
let match_set = match_store.query(&[
base_file.to_string_lossy().to_string(),
sub_file.to_string_lossy().to_string(),
]);
assert_eq!(
match_set
.matches
.into_iter()
.cloned()
.map(|mut m| {
m.id = 0;
m
})
.collect::<Vec<Match>>(),
create_matches(&[
("hello", "world3"), // This appears only once, though it appears 2 times
("hello", "world2"),
("foo", "bar"),
("hello", "world"),
])
);
assert_eq!(
match_set
.global_vars
.into_iter()
.cloned()
.map(|mut v| {
v.id = 0;
v
})
.collect::<Vec<Variable>>(),
create_vars(&["var2", "var1"])
);
});
}
// TODO: add fatal and non-fatal error cases
}

View File

@ -0,0 +1,41 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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::error::NonFatalErrorSet;
use super::{Match, Variable};
mod default;
pub trait MatchStore: Send {
fn query(&self, paths: &[String]) -> MatchSet;
fn loaded_paths(&self) -> Vec<String>;
}
#[derive(Debug, Clone, PartialEq)]
pub struct MatchSet<'a> {
pub matches: Vec<&'a Match>,
pub global_vars: Vec<&'a Variable>,
}
pub fn load(paths: &[String]) -> (impl MatchStore, Vec<NonFatalErrorSet>) {
// TODO: here we can replace the DefaultMatchStore with a caching wrapper
// that returns the same response for the given "paths" query
default::DefaultMatchStore::load(paths)
}

View File

@ -0,0 +1,74 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
/// Check if the given string represents an empty YAML.
/// In other words, it checks if the document is only composed
/// of spaces and/or comments
pub fn is_yaml_empty(yaml: &str) -> bool {
for line in yaml.lines() {
let trimmed_line = line.trim();
if !trimmed_line.starts_with('#') && !trimmed_line.is_empty() {
return false;
}
}
true
}
#[cfg(test)]
pub mod tests {
use super::*;
use std::{fs::create_dir_all, path::Path};
use tempdir::TempDir;
pub fn use_test_directory(callback: impl FnOnce(&Path, &Path, &Path)) {
let dir = TempDir::new("tempconfig").unwrap();
let match_dir = dir.path().join("match");
create_dir_all(&match_dir).unwrap();
let config_dir = dir.path().join("config");
create_dir_all(&config_dir).unwrap();
callback(
&dunce::canonicalize(&dir.path()).unwrap(),
&dunce::canonicalize(match_dir).unwrap(),
&dunce::canonicalize(config_dir).unwrap(),
);
}
#[test]
fn is_yaml_empty_document_empty() {
assert!(is_yaml_empty(""));
}
#[test]
fn is_yaml_empty_document_with_comments() {
assert!(is_yaml_empty("\n#comment \n \n"));
}
#[test]
fn is_yaml_empty_document_with_comments_and_content() {
assert!(!is_yaml_empty("\n#comment \n field: true\n"));
}
#[test]
fn is_yaml_empty_document_with_content() {
assert!(!is_yaml_empty("\nfield: true\n"));
}
}

33
espanso-detect/Cargo.toml Normal file
View File

@ -0,0 +1,33 @@
[package]
name = "espanso-detect"
version = "0.1.0"
authors = ["Federico Terzi <federico-terzi@users.noreply.github.com>"]
edition = "2018"
build="build.rs"
[features]
# If the wayland feature is enabled, all X11 dependencies will be dropped
# and only EVDEV-based methods will be supported.
wayland = ["sctk"]
[dependencies]
log = "0.4.14"
lazycell = "1.3.0"
anyhow = "1.0.38"
thiserror = "1.0.23"
regex = "1.4.3"
lazy_static = "1.4.0"
[target.'cfg(windows)'.dependencies]
widestring = "0.4.3"
[target.'cfg(target_os="linux")'.dependencies]
libc = "0.2.85"
scopeguard = "1.1.0"
sctk = { package = "smithay-client-toolkit", version = "0.14.0", optional = true }
[build-dependencies]
cc = "1.0.66"
[dev-dependencies]
enum-as-inner = "0.3.3"

83
espanso-detect/build.rs Normal file
View File

@ -0,0 +1,83 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#[cfg(target_os = "windows")]
fn cc_config() {
println!("cargo:rerun-if-changed=src/win32/native.cpp");
println!("cargo:rerun-if-changed=src/win32/native.h");
cc::Build::new()
.cpp(true)
.include("src/win32/native.h")
.file("src/win32/native.cpp")
.compile("espansodetect");
println!("cargo:rustc-link-lib=static=espansodetect");
println!("cargo:rustc-link-lib=dylib=user32");
#[cfg(target_env = "gnu")]
println!("cargo:rustc-link-lib=dylib=stdc++");
}
#[cfg(target_os = "linux")]
fn cc_config() {
println!("cargo:rerun-if-changed=src/x11/native.cpp");
println!("cargo:rerun-if-changed=src/x11/native.h");
println!("cargo:rerun-if-changed=src/evdev/native.cpp");
println!("cargo:rerun-if-changed=src/evdev/native.h");
if cfg!(not(feature = "wayland")) {
cc::Build::new()
.cpp(true)
.include("src/x11")
.file("src/x11/native.cpp")
.compile("espansodetect");
println!("cargo:rustc-link-lib=static=espansodetect");
println!("cargo:rustc-link-lib=dylib=X11");
println!("cargo:rustc-link-lib=dylib=Xtst");
}
cc::Build::new()
.cpp(true)
.include("src/evdev")
.file("src/evdev/native.cpp")
.compile("espansodetectevdev");
println!("cargo:rustc-link-search=native=/usr/lib/x86_64-linux-gnu/");
println!("cargo:rustc-link-lib=static=espansodetectevdev");
println!("cargo:rustc-link-lib=dylib=xkbcommon");
}
#[cfg(target_os = "macos")]
fn cc_config() {
println!("cargo:rerun-if-changed=src/mac/native.mm");
println!("cargo:rerun-if-changed=src/mac/native.h");
cc::Build::new()
.cpp(true)
.include("src/mac/native.h")
.file("src/mac/native.mm")
.compile("espansodetect");
println!("cargo:rustc-link-lib=dylib=c++");
println!("cargo:rustc-link-lib=static=espansodetect");
println!("cargo:rustc-link-lib=framework=Cocoa");
println!("cargo:rustc-link-lib=framework=Carbon");
}
fn main() {
cc_config();
}

View File

@ -0,0 +1,3 @@
This module is used to detect keyboard and mouse input using EVDEV layer, which is necessary on Wayland.
The module started as a port of this xkbcommon example
https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c

View File

@ -0,0 +1,47 @@
// This code is a port of the libxkbcommon "interactive-evdev.c" example
// https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
use scopeguard::ScopeGuard;
use super::ffi::{xkb_context, xkb_context_new, xkb_context_unref, XKB_CONTEXT_NO_FLAGS};
use anyhow::Result;
use thiserror::Error;
pub struct Context {
context: *mut xkb_context,
}
impl Context {
pub fn new() -> Result<Context> {
let raw_context = unsafe { xkb_context_new(XKB_CONTEXT_NO_FLAGS) };
let context = scopeguard::guard(raw_context, |raw_context| unsafe {
xkb_context_unref(raw_context);
});
if raw_context.is_null() {
return Err(ContextError::FailedCreation().into());
}
Ok(Self {
context: ScopeGuard::into_inner(context),
})
}
pub fn get_handle(&self) -> *mut xkb_context {
self.context
}
}
impl Drop for Context {
fn drop(&mut self) {
unsafe {
xkb_context_unref(self.context);
}
}
}
#[derive(Error, Debug)]
pub enum ContextError {
#[error("could not create xkb context")]
FailedCreation(),
}

View File

@ -0,0 +1,326 @@
// This code is a port of the libxkbcommon "interactive-evdev.c" example
// https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
use anyhow::Result;
use libc::{input_event, size_t, ssize_t, EWOULDBLOCK, O_CLOEXEC, O_NONBLOCK, O_RDONLY};
use log::trace;
use scopeguard::ScopeGuard;
use std::collections::HashMap;
use std::os::unix::io::AsRawFd;
use std::{
ffi::{c_void, CStr},
fs::OpenOptions,
};
use std::{fs::File, os::unix::fs::OpenOptionsExt};
use thiserror::Error;
use super::sync::ModifiersState;
use super::{
ffi::{
is_keyboard_or_mouse, xkb_key_direction, xkb_keycode_t, xkb_keymap_key_repeats, xkb_state,
xkb_state_get_keymap, xkb_state_key_get_one_sym, xkb_state_key_get_utf8, xkb_state_new,
xkb_state_unref, xkb_state_update_key, EV_KEY,
},
keymap::Keymap,
};
const EVDEV_OFFSET: i32 = 8;
pub const KEY_STATE_RELEASE: i32 = 0;
pub const KEY_STATE_PRESS: i32 = 1;
pub const KEY_STATE_REPEAT: i32 = 2;
#[derive(Debug)]
pub enum RawInputEvent {
Keyboard(RawKeyboardEvent),
Mouse(RawMouseEvent),
}
#[derive(Debug)]
pub struct RawKeyboardEvent {
pub sym: u32,
pub code: u32,
pub value: String,
pub state: i32,
}
#[derive(Debug)]
pub struct RawMouseEvent {
pub code: u16,
pub is_down: bool,
}
pub struct Device {
path: String,
file: File,
state: *mut xkb_state,
}
impl Device {
pub fn from(path: &str, keymap: &Keymap) -> Result<Device> {
let file = OpenOptions::new()
.read(true)
.custom_flags(O_NONBLOCK | O_CLOEXEC | O_RDONLY)
.open(&path)?;
if unsafe { is_keyboard_or_mouse(file.as_raw_fd()) == 0 } {
return Err(DeviceError::InvalidDevice(path.to_string()).into());
}
let raw_state = unsafe { xkb_state_new(keymap.get_handle()) };
// Automatically close the state if the function does not return correctly
let state = scopeguard::guard(raw_state, |raw_state| unsafe {
xkb_state_unref(raw_state);
});
if raw_state.is_null() {
return Err(DeviceError::InvalidState(path.to_string()).into());
}
Ok(Self {
path: path.to_string(),
file,
// Release the state without freeing it
state: ScopeGuard::into_inner(state),
})
}
pub fn get_state(&self) -> *mut xkb_state {
self.state
}
pub fn get_raw_fd(&self) -> i32 {
self.file.as_raw_fd()
}
pub fn get_path(&self) -> String {
self.path.to_string()
}
pub fn read(&self) -> Result<Vec<RawInputEvent>> {
let errno_ptr = unsafe { libc::__errno_location() };
let mut len: ssize_t;
let mut evs: [input_event; 16] = unsafe { std::mem::zeroed() };
let mut events = Vec::new();
loop {
len = unsafe {
libc::read(
self.file.as_raw_fd(),
evs.as_mut_ptr() as *mut c_void,
std::mem::size_of_val(&evs),
)
};
if len <= 0 {
break;
}
let nevs: size_t = len as usize / std::mem::size_of::<input_event>();
#[allow(clippy::needless_range_loop)]
for i in 0..nevs {
let event = self.process_event(evs[i].type_, evs[i].code, evs[i].value);
if let Some(event) = event {
events.push(event);
}
}
}
if len < 0 && unsafe { *errno_ptr } != EWOULDBLOCK {
return Err(DeviceError::BlockingReadOperation().into());
}
Ok(events)
}
fn process_event(&self, _type: u16, code: u16, value: i32) -> Option<RawInputEvent> {
if _type != EV_KEY {
return None;
}
let is_down = value == KEY_STATE_PRESS;
// Check if the current event originated from a mouse
if (0x110..=0x117).contains(&code) {
// Mouse event
return Some(RawInputEvent::Mouse(RawMouseEvent { code, is_down }));
}
// Keyboard event
let keycode: xkb_keycode_t = EVDEV_OFFSET as u32 + code as u32;
let keymap = unsafe { xkb_state_get_keymap(self.get_state()) };
if value == KEY_STATE_REPEAT && unsafe { xkb_keymap_key_repeats(keymap, keycode) } == 0 {
return None;
}
let sym = unsafe { xkb_state_key_get_one_sym(self.get_state(), keycode) };
if sym == 0 {
return None;
}
// Extract the utf8 char
let mut buffer: [u8; 16] = [0; 16];
unsafe {
xkb_state_key_get_utf8(
self.get_state(),
keycode,
buffer.as_mut_ptr() as *mut i8,
std::mem::size_of_val(&buffer),
)
};
let content_raw = unsafe { CStr::from_ptr(buffer.as_ptr() as *mut i8) };
let content = content_raw.to_string_lossy().to_string();
let event = RawKeyboardEvent {
state: value,
code: keycode,
sym,
value: content,
};
if value == KEY_STATE_RELEASE {
unsafe { xkb_state_update_key(self.get_state(), keycode, xkb_key_direction::UP) };
} else {
unsafe { xkb_state_update_key(self.get_state(), keycode, xkb_key_direction::DOWN) };
}
Some(RawInputEvent::Keyboard(event))
}
pub fn update_key(&mut self, code: u32, pressed: bool) {
let direction = if pressed {
super::ffi::xkb_key_direction::DOWN
} else {
super::ffi::xkb_key_direction::UP
};
unsafe {
xkb_state_update_key(self.get_state(), code, direction);
}
}
pub fn update_modifier_state(
&mut self,
modifiers_state: &ModifiersState,
modifiers_map: &HashMap<String, u32>,
) {
if modifiers_state.alt {
self.update_key(
*modifiers_map
.get("alt")
.expect("unable to find modifiers key in map"),
true,
);
}
if modifiers_state.ctrl {
self.update_key(
*modifiers_map
.get("ctrl")
.expect("unable to find modifiers key in map"),
true,
);
}
if modifiers_state.meta {
self.update_key(
*modifiers_map
.get("meta")
.expect("unable to find modifiers key in map"),
true,
);
}
if modifiers_state.num_lock {
self.update_key(
*modifiers_map
.get("num_lock")
.expect("unable to find modifiers key in map"),
true,
);
self.update_key(
*modifiers_map
.get("num_lock")
.expect("unable to find modifiers key in map"),
false,
);
}
if modifiers_state.shift {
self.update_key(
*modifiers_map
.get("shift")
.expect("unable to find modifiers key in map"),
true,
);
}
if modifiers_state.caps_lock {
self.update_key(
*modifiers_map
.get("caps_lock")
.expect("unable to find modifiers key in map"),
true,
);
self.update_key(
*modifiers_map
.get("caps_lock")
.expect("unable to find modifiers key in map"),
false,
);
}
}
}
impl Drop for Device {
fn drop(&mut self) {
unsafe {
xkb_state_unref(self.state);
}
}
}
pub fn get_devices(keymap: &Keymap) -> Result<Vec<Device>> {
let mut keyboards = Vec::new();
let dirs = std::fs::read_dir("/dev/input/")?;
for entry in dirs {
match entry {
Ok(device) => {
// Skip non-eventX devices
if !device.file_name().to_string_lossy().starts_with("event") {
continue;
}
let path = device.path().to_string_lossy().to_string();
let keyboard = Device::from(&path, keymap);
match keyboard {
Ok(keyboard) => {
keyboards.push(keyboard);
}
Err(error) => {
trace!("error opening keyboard: {}", error);
}
}
}
Err(error) => {
trace!("could not read keyboard device: {}", error);
}
}
}
if keyboards.is_empty() {
return Err(DeviceError::NoDevicesFound().into());
}
Ok(keyboards)
}
#[derive(Error, Debug)]
pub enum DeviceError {
#[error("could not create xkb state for `{0}`")]
InvalidState(String),
#[error("`{0}` is not a valid device")]
InvalidDevice(String),
#[error("no devices found")]
NoDevicesFound(),
#[error("read operation can't block device")]
BlockingReadOperation(),
}

View File

@ -0,0 +1,78 @@
// Bindings taken from: https://github.com/rtbo/xkbcommon-rs/blob/master/src/xkb/ffi.rs
use std::os::raw::c_int;
use libc::c_char;
#[allow(non_camel_case_types)]
pub enum xkb_context {}
#[allow(non_camel_case_types)]
pub enum xkb_state {}
#[allow(non_camel_case_types)]
pub enum xkb_keymap {}
#[allow(non_camel_case_types)]
pub type xkb_keycode_t = u32;
#[allow(non_camel_case_types)]
pub type xkb_keysym_t = u32;
#[repr(C)]
pub struct xkb_rule_names {
pub rules: *const c_char,
pub model: *const c_char,
pub layout: *const c_char,
pub variant: *const c_char,
pub options: *const c_char,
}
#[repr(C)]
#[allow(clippy::upper_case_acronyms)]
pub enum xkb_key_direction {
UP,
DOWN,
}
#[allow(non_camel_case_types)]
pub type xkb_keymap_compile_flags = u32;
pub const XKB_KEYMAP_COMPILE_NO_FLAGS: u32 = 0;
#[allow(non_camel_case_types)]
pub type xkb_context_flags = u32;
pub const XKB_CONTEXT_NO_FLAGS: u32 = 0;
#[allow(non_camel_case_types)]
pub type xkb_state_component = u32;
pub const EV_KEY: u16 = 0x01;
#[link(name = "xkbcommon")]
extern "C" {
pub fn xkb_state_unref(state: *mut xkb_state);
pub fn xkb_state_new(keymap: *mut xkb_keymap) -> *mut xkb_state;
pub fn xkb_keymap_new_from_names(
context: *mut xkb_context,
names: *const xkb_rule_names,
flags: xkb_keymap_compile_flags,
) -> *mut xkb_keymap;
pub fn xkb_keymap_unref(keymap: *mut xkb_keymap);
pub fn xkb_context_new(flags: xkb_context_flags) -> *mut xkb_context;
pub fn xkb_context_unref(context: *mut xkb_context);
pub fn xkb_state_get_keymap(state: *mut xkb_state) -> *mut xkb_keymap;
pub fn xkb_keymap_key_repeats(keymap: *mut xkb_keymap, key: xkb_keycode_t) -> c_int;
pub fn xkb_state_update_key(
state: *mut xkb_state,
key: xkb_keycode_t,
direction: xkb_key_direction,
) -> xkb_state_component;
pub fn xkb_state_key_get_utf8(
state: *mut xkb_state,
key: xkb_keycode_t,
buffer: *mut c_char,
size: usize,
) -> c_int;
pub fn xkb_state_key_get_one_sym(state: *mut xkb_state, key: xkb_keycode_t) -> xkb_keysym_t;
}
#[link(name = "espansodetectevdev", kind = "static")]
extern "C" {
pub fn is_keyboard_or_mouse(fd: i32) -> i32;
}

View File

@ -0,0 +1,149 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 log::error;
use crate::{
event::{KeyboardEvent, Status},
hotkey::HotKey,
};
use std::{collections::HashMap, time::Instant};
use super::state::State;
// Number of milliseconds that define how long the hotkey memory
// should retain pressed keys
const HOTKEY_WINDOW_TIMEOUT: u128 = 5000;
pub type KeySym = u32;
pub type KeyCode = u32;
pub type HotkeyMemoryMap = Vec<(KeyCode, Instant)>;
pub struct HotKeyFilter {
map: HashMap<KeySym, KeyCode>,
memory: HotkeyMemoryMap,
hotkey_raw_map: HashMap<i32, Vec<KeyCode>>,
}
impl HotKeyFilter {
pub fn new() -> Self {
Self {
map: HashMap::new(),
memory: HotkeyMemoryMap::new(),
hotkey_raw_map: HashMap::new(),
}
}
pub fn initialize(&mut self, state: &State, hotkeys: &[HotKey]) {
// First load the map
self.map = HashMap::new();
for code in 0..256 {
if let Some(sym) = state.get_sym(code) {
self.map.insert(sym, code);
}
}
// Then the actual hotkeys
self.hotkey_raw_map = hotkeys
.iter()
.filter_map(|hk| {
let codes = Self::convert_hotkey_to_codes(self, hk);
if codes.is_none() {
error!("unable to register hotkey {:?}", hk);
}
Some((hk.id, codes?))
})
.collect();
}
pub fn process_event(&mut self, event: &KeyboardEvent) -> Option<i32> {
let mut hotkey = None;
let mut key_code = None;
let mut to_be_removed = Vec::new();
if event.status == Status::Released {
// Remove from the memory all the key occurrences
to_be_removed.extend(self.memory.iter().enumerate().filter_map(|(i, (code, _))| {
if *code == event.code {
Some(i)
} else {
None
}
}));
} else {
key_code = Some(event.code)
}
// Remove the old entries
to_be_removed.extend(
self
.memory
.iter()
.enumerate()
.filter_map(|(i, (_, instant))| {
if instant.elapsed().as_millis() > HOTKEY_WINDOW_TIMEOUT {
Some(i)
} else {
None
}
}),
);
// Remove duplicates and revert
if !to_be_removed.is_empty() {
#[allow(clippy::stable_sort_primitive)]
to_be_removed.sort();
to_be_removed.dedup();
to_be_removed.reverse();
to_be_removed.into_iter().for_each(|index| {
self.memory.remove(index);
})
}
if let Some(code) = key_code {
self.memory.push((code, Instant::now()));
for (id, codes) in self.hotkey_raw_map.iter() {
if codes
.iter()
.all(|hk_code| self.memory.iter().any(|(m_code, _)| m_code == hk_code))
{
hotkey = Some(*id);
break;
}
}
}
hotkey
}
fn convert_hotkey_to_codes(&self, hk: &HotKey) -> Option<Vec<KeyCode>> {
let mut codes = Vec::new();
let key_code = self.map.get(&hk.key.to_code()?)?;
codes.push(*key_code);
for modifier in hk.modifiers.iter() {
let code = self.map.get(&modifier.to_code()?)?;
codes.push(*code);
}
Some(codes)
}
}

View File

@ -0,0 +1,112 @@
// This code is a port of the libxkbcommon "interactive-evdev.c" example
// https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
use std::ffi::CString;
use scopeguard::ScopeGuard;
use anyhow::Result;
use thiserror::Error;
use crate::KeyboardConfig;
use super::{
context::Context,
ffi::{
xkb_keymap, xkb_keymap_new_from_names, xkb_keymap_unref, xkb_rule_names,
XKB_KEYMAP_COMPILE_NO_FLAGS,
},
};
pub struct Keymap {
keymap: *mut xkb_keymap,
}
impl Keymap {
pub fn new(context: &Context, rmlvo: Option<KeyboardConfig>) -> Result<Keymap> {
let owned_rmlvo = Self::generate_owned_rmlvo(rmlvo);
let names = Self::generate_names(&owned_rmlvo);
let raw_keymap = unsafe {
xkb_keymap_new_from_names(context.get_handle(), &names, XKB_KEYMAP_COMPILE_NO_FLAGS)
};
let keymap = scopeguard::guard(raw_keymap, |raw_keymap| unsafe {
xkb_keymap_unref(raw_keymap);
});
if raw_keymap.is_null() {
return Err(KeymapError::FailedCreation().into());
}
Ok(Self {
keymap: ScopeGuard::into_inner(keymap),
})
}
pub fn get_handle(&self) -> *mut xkb_keymap {
self.keymap
}
fn generate_owned_rmlvo(rmlvo: Option<KeyboardConfig>) -> OwnedRawKeyboardConfig {
let rules = rmlvo
.as_ref()
.and_then(|config| config.rules.clone())
.unwrap_or_default();
let model = rmlvo
.as_ref()
.and_then(|config| config.model.clone())
.unwrap_or_default();
let layout = rmlvo
.as_ref()
.and_then(|config| config.layout.clone())
.unwrap_or_default();
let variant = rmlvo
.as_ref()
.and_then(|config| config.variant.clone())
.unwrap_or_default();
let options = rmlvo
.as_ref()
.and_then(|config| config.options.clone())
.unwrap_or_default();
OwnedRawKeyboardConfig {
rules: CString::new(rules).expect("unable to create CString for keymap"),
model: CString::new(model).expect("unable to create CString for keymap"),
layout: CString::new(layout).expect("unable to create CString for keymap"),
variant: CString::new(variant).expect("unable to create CString for keymap"),
options: CString::new(options).expect("unable to create CString for keymap"),
}
}
fn generate_names(owned_config: &OwnedRawKeyboardConfig) -> xkb_rule_names {
xkb_rule_names {
rules: owned_config.rules.as_ptr(),
model: owned_config.model.as_ptr(),
layout: owned_config.layout.as_ptr(),
variant: owned_config.variant.as_ptr(),
options: owned_config.options.as_ptr(),
}
}
}
impl Drop for Keymap {
fn drop(&mut self) {
unsafe {
xkb_keymap_unref(self.keymap);
}
}
}
#[derive(Error, Debug)]
pub enum KeymapError {
#[error("could not create xkb keymap")]
FailedCreation(),
}
struct OwnedRawKeyboardConfig {
rules: CString,
model: CString,
layout: CString,
variant: CString,
options: CString,
}

View File

@ -0,0 +1,422 @@
// This code is heavily inspired by the libxkbcommon "interactive-evdev.c" example
// https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
mod context;
mod device;
mod ffi;
mod hotkey;
mod keymap;
mod state;
mod sync;
use std::cell::RefCell;
use std::collections::HashMap;
use anyhow::{Context as AnyhowContext, Result};
use context::Context;
use device::{get_devices, Device};
use keymap::Keymap;
use lazycell::LazyCell;
use libc::{
__errno_location, close, epoll_ctl, epoll_event, epoll_wait, EINTR, EPOLLIN, EPOLL_CTL_ADD,
};
use log::{debug, error, info, trace};
use thiserror::Error;
use crate::event::{InputEvent, Key, KeyboardEvent, Variant};
use crate::event::{Key::*, MouseButton, MouseEvent};
use crate::{event::HotKeyEvent, event::Variant::*, hotkey::HotKey};
use crate::{event::Status::*, KeyboardConfig, Source, SourceCallback, SourceCreationOptions};
use self::{
device::{DeviceError, RawInputEvent, KEY_STATE_PRESS, KEY_STATE_RELEASE},
hotkey::HotKeyFilter,
state::State,
};
const BTN_LEFT: u16 = 0x110;
const BTN_RIGHT: u16 = 0x111;
const BTN_MIDDLE: u16 = 0x112;
const BTN_SIDE: u16 = 0x113;
const BTN_EXTRA: u16 = 0x114;
// Offset between evdev keycodes (where KEY_ESCAPE is 1), and the evdev XKB
// keycode set (where ESC is 9).
const EVDEV_OFFSET: u32 = 8;
// List of modifier keycodes, as defined in the "input-event-codes.h" header
// TODO: create an option to override them if needed
const KEY_CTRL: u32 = 29;
const KEY_SHIFT: u32 = 42;
const KEY_ALT: u32 = 56;
const KEY_META: u32 = 125;
const KEY_CAPSLOCK: u32 = 58;
const KEY_NUMLOCK: u32 = 69;
pub struct EVDEVSource {
devices: Vec<Device>,
hotkeys: Vec<HotKey>,
_keyboard_rmlvo: Option<KeyboardConfig>,
_context: LazyCell<Context>,
_keymap: LazyCell<Keymap>,
_hotkey_filter: RefCell<HotKeyFilter>,
_modifiers_map: HashMap<String, u32>,
}
#[allow(clippy::new_without_default)]
impl EVDEVSource {
pub fn new(options: SourceCreationOptions) -> EVDEVSource {
let mut modifiers_map = HashMap::new();
modifiers_map.insert("ctrl".to_string(), KEY_CTRL + EVDEV_OFFSET);
modifiers_map.insert("shift".to_string(), KEY_SHIFT + EVDEV_OFFSET);
modifiers_map.insert("alt".to_string(), KEY_ALT + EVDEV_OFFSET);
modifiers_map.insert("meta".to_string(), KEY_META + EVDEV_OFFSET);
modifiers_map.insert("caps_lock".to_string(), KEY_CAPSLOCK + EVDEV_OFFSET);
modifiers_map.insert("num_lock".to_string(), KEY_NUMLOCK + EVDEV_OFFSET);
Self {
devices: Vec::new(),
hotkeys: options.hotkeys,
_context: LazyCell::new(),
_keymap: LazyCell::new(),
_keyboard_rmlvo: options.evdev_keyboard_rmlvo,
_hotkey_filter: RefCell::new(HotKeyFilter::new()),
_modifiers_map: modifiers_map,
}
}
}
impl Source for EVDEVSource {
fn initialize(&mut self) -> Result<()> {
let context = Context::new().expect("unable to obtain xkb context");
let keymap =
Keymap::new(&context, self._keyboard_rmlvo.clone()).expect("unable to create xkb keymap");
match get_devices(&keymap) {
Ok(devices) => self.devices = devices,
Err(error) => {
if let Some(device_error) = error.downcast_ref::<DeviceError>() {
if matches!(device_error, DeviceError::NoDevicesFound()) {
error!("Unable to open EVDEV devices, this usually has to do with permissions.");
error!(
"You can either add the current user to the 'input' group or run espanso as root"
);
return Err(EVDEVSourceError::PermissionDenied().into());
}
}
return Err(error);
}
}
let state = State::new(&keymap)?;
info!("Querying modifier status...");
if let Some(modifiers_state) =
sync::get_modifiers_state().context("EVDEV modifier context state synchronization")?
{
debug!("Updating device modifier state: {:?}", modifiers_state);
for device in &mut self.devices {
device.update_modifier_state(&modifiers_state, &self._modifiers_map);
}
}
// Initialize the hotkeys
self
._hotkey_filter
.borrow_mut()
.initialize(&state, &self.hotkeys);
if self._context.fill(context).is_err() {
return Err(EVDEVSourceError::InitFailure().into());
}
if self._keymap.fill(keymap).is_err() {
return Err(EVDEVSourceError::InitFailure().into());
}
Ok(())
}
fn eventloop(&self, event_callback: SourceCallback) -> Result<()> {
if self.devices.is_empty() {
error!("can't start eventloop without evdev devices");
return Err(EVDEVSourceError::NoDevices().into());
}
let raw_epfd = unsafe { libc::epoll_create1(0) };
let epfd = scopeguard::guard(raw_epfd, |raw_epfd| unsafe {
close(raw_epfd);
});
if *epfd < 0 {
error!("could not create epoll instance");
return Err(EVDEVSourceError::Internal().into());
}
// Setup epoll for all input devices
let errno_ptr = unsafe { __errno_location() };
for (i, device) in self.devices.iter().enumerate() {
let mut ev: epoll_event = unsafe { std::mem::zeroed() };
ev.events = EPOLLIN as u32;
ev.u64 = i as u64;
if unsafe { epoll_ctl(*epfd, EPOLL_CTL_ADD, device.get_raw_fd(), &mut ev) } != 0 {
error!(
"Could not add {} to epoll, errno {}",
device.get_path(),
unsafe { *errno_ptr }
);
return Err(EVDEVSourceError::Internal().into());
}
}
let mut hotkey_filter = self._hotkey_filter.borrow_mut();
// Read events indefinitely
let mut evs: [epoll_event; 16] = unsafe { std::mem::zeroed() };
loop {
let ret = unsafe { epoll_wait(*epfd, evs.as_mut_ptr(), 16, -1) };
if ret < 0 {
if unsafe { *errno_ptr } == EINTR {
continue;
} else {
error!("Could not poll for events, {}", unsafe { *errno_ptr });
return Err(EVDEVSourceError::Internal().into());
}
}
for ev in evs.iter() {
let device = &self.devices[ev.u64 as usize];
match device.read() {
Ok(events) if !events.is_empty() => {
// Convert raw events to the common format and invoke the callback
events.into_iter().for_each(|raw_event| {
let event: Option<InputEvent> = raw_event.into();
if let Some(event) = event {
// On Wayland we need to detect the global shortcuts manually
if let InputEvent::Keyboard(key_event) = &event {
if let Some(hotkey) = (*hotkey_filter).process_event(key_event) {
event_callback(InputEvent::HotKey(HotKeyEvent { hotkey_id: hotkey }))
}
}
event_callback(event);
} else {
trace!("unable to convert raw event to input event");
}
});
}
Ok(_) => { /* SKIP EMPTY */ }
Err(err) => error!("Can't read from device {}: {}", device.get_path(), err),
}
}
}
}
}
#[derive(Error, Debug)]
pub enum EVDEVSourceError {
#[error("initialization failed")]
InitFailure(),
#[error("permission denied")]
PermissionDenied(),
#[error("no devices")]
NoDevices(),
#[error("internal error")]
Internal(),
}
impl From<RawInputEvent> for Option<InputEvent> {
fn from(raw: RawInputEvent) -> Option<InputEvent> {
match raw {
RawInputEvent::Keyboard(keyboard_event) => {
let (key, variant) = key_sym_to_key(keyboard_event.sym as i32);
let value = if keyboard_event.value.is_empty() {
None
} else {
Some(keyboard_event.value)
};
let status = if keyboard_event.state == KEY_STATE_PRESS {
Pressed
} else if keyboard_event.state == KEY_STATE_RELEASE {
Released
} else {
// Filter out the "repeated" events
return None;
};
return Some(InputEvent::Keyboard(KeyboardEvent {
key,
value,
status,
variant,
code: keyboard_event.code,
}));
}
RawInputEvent::Mouse(mouse_event) => {
let button = raw_to_mouse_button(mouse_event.code);
let status = if mouse_event.is_down {
Pressed
} else {
Released
};
if let Some(button) = button {
return Some(InputEvent::Mouse(MouseEvent { button, status }));
}
}
}
None
}
}
// Mappings from: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
fn key_sym_to_key(key_sym: i32) -> (Key, Option<Variant>) {
match key_sym {
// Modifiers
0xFFE9 => (Alt, Some(Left)),
0xFFEA => (Alt, Some(Right)),
0xFFE5 => (CapsLock, None),
0xFFE3 => (Control, Some(Left)),
0xFFE4 => (Control, Some(Right)),
0xFFE7 | 0xFFEB => (Meta, Some(Left)),
0xFFE8 | 0xFFEC => (Meta, Some(Right)),
0xFF7F => (NumLock, None),
0xFFE1 => (Shift, Some(Left)),
0xFFE2 => (Shift, Some(Right)),
// Whitespace
0xFF0D => (Enter, None),
0xFF09 => (Tab, None),
0x20 => (Space, None),
// Navigation
0xFF54 => (ArrowDown, None),
0xFF51 => (ArrowLeft, None),
0xFF53 => (ArrowRight, None),
0xFF52 => (ArrowUp, None),
0xFF57 => (End, None),
0xFF50 => (Home, None),
0xFF56 => (PageDown, None),
0xFF55 => (PageUp, None),
// UI
0xFF1B => (Escape, None),
// Editing keys
0xFF08 => (Backspace, None),
// Function keys
0xFFBE => (F1, None),
0xFFBF => (F2, None),
0xFFC0 => (F3, None),
0xFFC1 => (F4, None),
0xFFC2 => (F5, None),
0xFFC3 => (F6, None),
0xFFC4 => (F7, None),
0xFFC5 => (F8, None),
0xFFC6 => (F9, None),
0xFFC7 => (F10, None),
0xFFC8 => (F11, None),
0xFFC9 => (F12, None),
0xFFCA => (F13, None),
0xFFCB => (F14, None),
0xFFCC => (F15, None),
0xFFCD => (F16, None),
0xFFCE => (F17, None),
0xFFCF => (F18, None),
0xFFD0 => (F19, None),
0xFFD1 => (F20, None),
// Other keys, includes the raw code provided by the operating system
_ => (Other(key_sym), None),
}
}
// These codes can be found in the "input-event-codes.h" header file
fn raw_to_mouse_button(raw: u16) -> Option<MouseButton> {
match raw {
BTN_LEFT => Some(MouseButton::Left),
BTN_RIGHT => Some(MouseButton::Right),
BTN_MIDDLE => Some(MouseButton::Middle),
BTN_SIDE => Some(MouseButton::Button1),
BTN_EXTRA => Some(MouseButton::Button2),
_ => None,
}
}
#[cfg(test)]
mod tests {
use device::RawMouseEvent;
use crate::event::{InputEvent, Key::Other, KeyboardEvent};
use super::{
device::{RawInputEvent, RawKeyboardEvent},
*,
};
#[test]
fn raw_to_input_event_keyboard_works_correctly() {
let raw = RawInputEvent::Keyboard(RawKeyboardEvent {
sym: 0x4B,
value: "k".to_owned(),
state: KEY_STATE_RELEASE,
code: 0,
});
let result: Option<InputEvent> = raw.into();
assert_eq!(
result.unwrap(),
InputEvent::Keyboard(KeyboardEvent {
key: Other(0x4B),
status: Released,
value: Some("k".to_string()),
variant: None,
code: 0,
})
);
}
#[test]
fn raw_to_input_event_mouse_works_correctly() {
let raw = RawInputEvent::Mouse(RawMouseEvent {
code: BTN_RIGHT,
is_down: false,
});
let result: Option<InputEvent> = raw.into();
assert_eq!(
result.unwrap(),
InputEvent::Mouse(MouseEvent {
status: Released,
button: MouseButton::Right,
})
);
}
}

View File

@ -0,0 +1,90 @@
// A good portion of the following code has been taken by the "interactive-evdev.c"
// example of "libxkbcommon" by Ran Benita. The original license is included as follows:
// https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
/*
* Copyright © 2012 Ran Benita <ran234@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice (including the next
* paragraph) shall be included in all copies or substantial portions of the
* Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*/
#include "native.h"
#include <assert.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <fnmatch.h>
#include <getopt.h>
#include <limits.h>
#include <locale.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <string>
#include <sys/epoll.h>
#include <linux/input.h>
#include "xkbcommon/xkbcommon.h"
#define NLONGS(n) (((n) + LONG_BIT - 1) / LONG_BIT)
static bool
evdev_bit_is_set(const unsigned long *array, int bit)
{
return array[bit / LONG_BIT] & (1LL << (bit % LONG_BIT));
}
/* Some heuristics to see if the device is a keyboard. */
int32_t is_keyboard_or_mouse(int fd)
{
int i;
unsigned long evbits[NLONGS(EV_CNT)] = {0};
unsigned long keybits[NLONGS(KEY_CNT)] = {0};
errno = 0;
ioctl(fd, EVIOCGBIT(0, sizeof(evbits)), evbits);
if (errno)
return false;
if (!evdev_bit_is_set(evbits, EV_KEY))
return false;
errno = 0;
ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(keybits)), keybits);
if (errno)
return false;
// Test for keyboard keys
for (i = KEY_RESERVED; i <= KEY_MIN_INTERESTING; i++)
if (evdev_bit_is_set(keybits, i))
return true;
// Test for mouse keys
for (i = BTN_MOUSE; i <= BTN_TASK; i++)
if (evdev_bit_is_set(keybits, i))
return true;
return false;
}

View File

@ -0,0 +1,27 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#ifndef ESPANSO_DETECT_EVDEV_H
#define ESPANSO_DETECT_EVDEV_H
#include <stdint.h>
extern "C" int32_t is_keyboard_or_mouse(int fd);
#endif //ESPANSO_DETECT_EVDEV_H

View File

@ -0,0 +1,56 @@
// This code is a port of the libxkbcommon "interactive-evdev.c" example
// https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
use scopeguard::ScopeGuard;
use anyhow::Result;
use thiserror::Error;
use super::{
ffi::{xkb_state, xkb_state_key_get_one_sym, xkb_state_new, xkb_state_unref},
keymap::Keymap,
};
pub struct State {
state: *mut xkb_state,
}
impl State {
pub fn new(keymap: &Keymap) -> Result<State> {
let raw_state = unsafe { xkb_state_new(keymap.get_handle()) };
let state = scopeguard::guard(raw_state, |raw_state| unsafe {
xkb_state_unref(raw_state);
});
if raw_state.is_null() {
return Err(StateError::FailedCreation().into());
}
Ok(Self {
state: ScopeGuard::into_inner(state),
})
}
pub fn get_sym(&self, code: u32) -> Option<u32> {
let sym = unsafe { xkb_state_key_get_one_sym(self.state, code) };
if sym == 0 {
None
} else {
Some(sym)
}
}
}
impl Drop for State {
fn drop(&mut self) {
unsafe {
xkb_state_unref(self.state);
}
}
}
#[derive(Error, Debug)]
pub enum StateError {
#[error("could not create xkb state")]
FailedCreation(),
}

View File

@ -0,0 +1,39 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#[derive(Debug, Clone, Copy)]
pub struct ModifiersState {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub caps_lock: bool,
pub meta: bool,
pub num_lock: bool,
}
#[cfg(feature = "wayland")]
mod wayland;
#[cfg(feature = "wayland")]
pub use wayland::get_modifiers_state;
#[cfg(not(feature = "wayland"))]
pub fn get_modifiers_state() -> anyhow::Result<Option<ModifiersState>> {
// Fallback for non-wayland systems
Ok(None)
}

View File

@ -0,0 +1,248 @@
// This module was implemented starting from this wonderful example:
// https://github.com/Smithay/client-toolkit/blob/master/examples/kbd_input.rs
use std::cell::RefCell;
use std::cmp::min;
use std::rc::Rc;
use anyhow::{Context, Result};
use log::error;
use sctk::reexports::calloop;
use sctk::reexports::client::protocol::{wl_keyboard, wl_shm, wl_surface};
use sctk::seat::keyboard::{map_keyboard_repeat, Event as KbEvent, RepeatKind};
use sctk::shm::AutoMemPool;
use sctk::window::{Event as WEvent, FallbackFrame};
sctk::default_environment!(EspansoModifiersSync, desktop);
pub fn get_modifiers_state() -> Result<Option<super::ModifiersState>> {
let (env, display, queue) = sctk::new_default_environment!(EspansoModifiersSync, desktop)
.context("Unable to connect to a Wayland compositor")?;
let result = Rc::new(RefCell::new(None));
/*
* Prepare a calloop event loop to handle key repetion
*/
// Here `Option<WEvent>` is the type of a global value that will be shared by
// all callbacks invoked by the event loop.
let mut event_loop = calloop::EventLoop::<Option<WEvent>>::try_new().unwrap();
/*
* Create a buffer with window contents
*/
let mut dimensions = (1u32, 1u32);
/*
* Init wayland objects
*/
let surface = env.create_surface().detach();
let mut window = env
.create_window::<FallbackFrame, _>(surface, None, dimensions, move |evt, mut dispatch_data| {
let next_action = dispatch_data.get::<Option<WEvent>>().unwrap();
// Keep last event in priority order : Close > Configure > Refresh
let replace = matches!(
(&evt, &*next_action),
(_, &None)
| (_, &Some(WEvent::Refresh))
| (&WEvent::Configure { .. }, &Some(WEvent::Configure { .. }))
| (&WEvent::Close, _)
);
if replace {
*next_action = Some(evt);
}
})
.context("Failed to create a window !")?;
window.set_title("Espanso Sync Tool".to_string());
let mut pool = env
.create_auto_pool()
.context("Failed to create a memory pool !")?;
/*
* Keyboard initialization
*/
let mut seats = Vec::<(
String,
Option<(wl_keyboard::WlKeyboard, calloop::RegistrationToken)>,
)>::new();
// first process already existing seats
for seat in env.get_all_seats() {
if let Some((has_kbd, name)) = sctk::seat::with_seat_data(&seat, |seat_data| {
(
seat_data.has_keyboard && !seat_data.defunct,
seat_data.name.clone(),
)
}) {
if has_kbd {
let result_clone = result.clone();
match map_keyboard_repeat(
event_loop.handle(),
&seat,
None,
RepeatKind::System,
move |event, _, _| keyboard_event_handler(event, &result_clone),
) {
Ok((kbd, repeat_source)) => {
seats.push((name, Some((kbd, repeat_source))));
}
Err(e) => {
error!("Failed to map keyboard on seat {} : {:?}.", name, e);
seats.push((name, None));
}
}
} else {
seats.push((name, None));
}
}
}
// then setup a listener for changes
let loop_handle = event_loop.handle();
let result_clone = result.clone();
let _seat_listener = env.listen_for_seats(move |seat, seat_data, _| {
let result_clone = result_clone.clone();
// find the seat in the vec of seats, or insert it if it is unknown
let idx = seats.iter().position(|(name, _)| name == &seat_data.name);
let idx = idx.unwrap_or_else(|| {
seats.push((seat_data.name.clone(), None));
seats.len() - 1
});
let (_, ref mut opt_kbd) = &mut seats[idx];
// we should map a keyboard if the seat has the capability & is not defunct
if seat_data.has_keyboard && !seat_data.defunct {
if opt_kbd.is_none() {
// we should initalize a keyboard
match map_keyboard_repeat(
loop_handle.clone(),
&seat,
None,
RepeatKind::System,
move |event, _, _| keyboard_event_handler(event, &result_clone),
) {
Ok((kbd, repeat_source)) => {
*opt_kbd = Some((kbd, repeat_source));
}
Err(e) => {
eprintln!(
"Failed to map keyboard on seat {} : {:?}.",
seat_data.name, e
)
}
}
}
} else if let Some((kbd, source)) = opt_kbd.take() {
// the keyboard has been removed, cleanup
kbd.release();
loop_handle.remove(source);
}
});
if !env.get_shell().unwrap().needs_configure() {
// initial draw to bootstrap on wl_shell
redraw(&mut pool, window.surface(), dimensions).expect("Failed to draw");
window.refresh();
}
sctk::WaylandSource::new(queue)
.quick_insert(event_loop.handle())
.unwrap();
let mut next_action = None;
loop {
match next_action.take() {
Some(WEvent::Close) => break,
Some(WEvent::Refresh) => {
window.refresh();
window.surface().commit();
}
Some(WEvent::Configure {
new_size,
states: _,
}) => {
if let Some((w, h)) = new_size {
window.resize(w, h);
dimensions = (w, h)
}
window.refresh();
redraw(&mut pool, window.surface(), dimensions).expect("Failed to draw");
}
None => {
let result_clone = result.clone();
let result_ref = result_clone.borrow();
if let Some(result) = &*result_ref {
return Ok(Some(*result));
}
}
}
// always flush the connection before going to sleep waiting for events
display.flush().unwrap();
event_loop
.dispatch(Some(std::time::Duration::from_millis(10)), &mut next_action)
.unwrap();
}
Ok(None)
}
fn keyboard_event_handler(
event: KbEvent,
result_clone: &Rc<RefCell<Option<super::ModifiersState>>>,
) {
if let KbEvent::Modifiers { modifiers } = event {
let mut result_mut = (**result_clone).borrow_mut();
*result_mut = Some(super::ModifiersState {
ctrl: modifiers.ctrl,
alt: modifiers.alt,
shift: modifiers.shift,
caps_lock: modifiers.caps_lock,
meta: modifiers.logo,
num_lock: modifiers.num_lock,
})
}
}
#[allow(clippy::many_single_char_names)]
fn redraw(
pool: &mut AutoMemPool,
surface: &wl_surface::WlSurface,
(buf_x, buf_y): (u32, u32),
) -> Result<(), ::std::io::Error> {
let (canvas, new_buffer) = pool.buffer(
buf_x as i32,
buf_y as i32,
4 * buf_x as i32,
wl_shm::Format::Argb8888,
)?;
for (i, dst_pixel) in canvas.chunks_exact_mut(4).enumerate() {
let x = i as u32 % buf_x;
let y = i as u32 / buf_x;
let r: u32 = min(((buf_x - x) * 0xFF) / buf_x, ((buf_y - y) * 0xFF) / buf_y);
let g: u32 = min((x * 0xFF) / buf_x, ((buf_y - y) * 0xFF) / buf_y);
let b: u32 = min(((buf_x - x) * 0xFF) / buf_x, (y * 0xFF) / buf_y);
let pixel: [u8; 4] = ((0xFF << 24) + (r << 16) + (g << 8) + b).to_ne_bytes();
dst_pixel[0] = pixel[0];
dst_pixel[1] = pixel[1];
dst_pixel[2] = pixel[2];
dst_pixel[3] = pixel[3];
}
surface.attach(Some(&new_buffer), 0, 0);
if surface.as_ref().version() >= 4 {
surface.damage_buffer(0, 0, buf_x as i32, buf_y as i32);
} else {
surface.damage(0, 0, buf_x as i32, buf_y as i32);
}
surface.commit();
Ok(())
}

130
espanso-detect/src/event.rs Normal file
View File

@ -0,0 +1,130 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#[cfg(test)]
use enum_as_inner::EnumAsInner;
#[derive(Debug, PartialEq)]
#[cfg_attr(test, derive(EnumAsInner))]
pub enum InputEvent {
Mouse(MouseEvent),
Keyboard(KeyboardEvent),
HotKey(HotKeyEvent),
}
#[derive(Debug, PartialEq)]
pub enum MouseButton {
Left,
Right,
Middle,
Button1,
Button2,
Button3,
Button4,
Button5,
}
#[derive(Debug, PartialEq)]
pub struct MouseEvent {
pub button: MouseButton,
pub status: Status,
}
#[derive(Debug, PartialEq)]
pub enum Status {
Pressed,
Released,
}
#[derive(Debug, PartialEq)]
pub enum Variant {
Left,
Right,
}
#[derive(Debug, PartialEq)]
pub struct KeyboardEvent {
pub key: Key,
pub value: Option<String>,
pub status: Status,
pub variant: Option<Variant>,
pub code: u32,
}
// A subset of the Web's key values: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
#[derive(Debug, PartialEq)]
pub enum Key {
// Modifiers
Alt,
CapsLock,
Control,
Meta,
NumLock,
Shift,
// Whitespace
Enter,
Tab,
Space,
// Navigation
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
End,
Home,
PageDown,
PageUp,
// UI
Escape,
// Editing keys
Backspace,
// Function keys
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
F13,
F14,
F15,
F16,
F17,
F18,
F19,
F20,
// Other keys, includes the raw code provided by the operating system
Other(i32),
}
#[derive(Debug, PartialEq)]
pub struct HotKeyEvent {
pub hotkey_id: i32,
}

View File

@ -0,0 +1,628 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::fmt::Display;
use regex::Regex;
lazy_static! {
static ref RAW_PARSER: Regex = Regex::new(r"^RAW\((\d+)\)$").unwrap();
}
#[derive(Debug, PartialEq, Clone)]
pub enum ShortcutKey {
Alt,
Control,
Meta,
Shift,
Enter,
Tab,
Space,
Insert,
// Navigation
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
End,
Home,
PageDown,
PageUp,
// Function ShortcutKeys
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
F13,
F14,
F15,
F16,
F17,
F18,
F19,
F20,
// Alphabet
A,
B,
C,
D,
E,
F,
G,
H,
I,
J,
K,
L,
M,
N,
O,
P,
Q,
R,
S,
T,
U,
V,
W,
X,
Y,
Z,
// Numbers
N0,
N1,
N2,
N3,
N4,
N5,
N6,
N7,
N8,
N9,
// Numpad
Numpad0,
Numpad1,
Numpad2,
Numpad3,
Numpad4,
Numpad5,
Numpad6,
Numpad7,
Numpad8,
Numpad9,
// Specify the raw platform-specific virtual key code.
Raw(u32),
}
impl Display for ShortcutKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
ShortcutKey::Alt => write!(f, "ALT"),
ShortcutKey::Control => write!(f, "CTRL"),
ShortcutKey::Meta => write!(f, "META"),
ShortcutKey::Shift => write!(f, "SHIFT"),
ShortcutKey::Enter => write!(f, "ENTER"),
ShortcutKey::Tab => write!(f, "TAB"),
ShortcutKey::Space => write!(f, "SPACE"),
ShortcutKey::Insert => write!(f, "INSERT"),
ShortcutKey::ArrowDown => write!(f, "DOWN"),
ShortcutKey::ArrowLeft => write!(f, "LEFT"),
ShortcutKey::ArrowRight => write!(f, "RIGHT"),
ShortcutKey::ArrowUp => write!(f, "UP"),
ShortcutKey::End => write!(f, "END"),
ShortcutKey::Home => write!(f, "HOME"),
ShortcutKey::PageDown => write!(f, "PAGEDOWN"),
ShortcutKey::PageUp => write!(f, "PAGEUP"),
ShortcutKey::F1 => write!(f, "F1"),
ShortcutKey::F2 => write!(f, "F2"),
ShortcutKey::F3 => write!(f, "F3"),
ShortcutKey::F4 => write!(f, "F4"),
ShortcutKey::F5 => write!(f, "F5"),
ShortcutKey::F6 => write!(f, "F6"),
ShortcutKey::F7 => write!(f, "F7"),
ShortcutKey::F8 => write!(f, "F8"),
ShortcutKey::F9 => write!(f, "F9"),
ShortcutKey::F10 => write!(f, "F10"),
ShortcutKey::F11 => write!(f, "F11"),
ShortcutKey::F12 => write!(f, "F12"),
ShortcutKey::F13 => write!(f, "F13"),
ShortcutKey::F14 => write!(f, "F14"),
ShortcutKey::F15 => write!(f, "F15"),
ShortcutKey::F16 => write!(f, "F16"),
ShortcutKey::F17 => write!(f, "F17"),
ShortcutKey::F18 => write!(f, "F18"),
ShortcutKey::F19 => write!(f, "F19"),
ShortcutKey::F20 => write!(f, "F20"),
ShortcutKey::A => write!(f, "A"),
ShortcutKey::B => write!(f, "B"),
ShortcutKey::C => write!(f, "C"),
ShortcutKey::D => write!(f, "D"),
ShortcutKey::E => write!(f, "E"),
ShortcutKey::F => write!(f, "F"),
ShortcutKey::G => write!(f, "G"),
ShortcutKey::H => write!(f, "H"),
ShortcutKey::I => write!(f, "I"),
ShortcutKey::J => write!(f, "J"),
ShortcutKey::K => write!(f, "K"),
ShortcutKey::L => write!(f, "L"),
ShortcutKey::M => write!(f, "M"),
ShortcutKey::N => write!(f, "N"),
ShortcutKey::O => write!(f, "O"),
ShortcutKey::P => write!(f, "P"),
ShortcutKey::Q => write!(f, "Q"),
ShortcutKey::R => write!(f, "R"),
ShortcutKey::S => write!(f, "S"),
ShortcutKey::T => write!(f, "T"),
ShortcutKey::U => write!(f, "U"),
ShortcutKey::V => write!(f, "V"),
ShortcutKey::W => write!(f, "W"),
ShortcutKey::X => write!(f, "X"),
ShortcutKey::Y => write!(f, "Y"),
ShortcutKey::Z => write!(f, "Z"),
ShortcutKey::N0 => write!(f, "0"),
ShortcutKey::N1 => write!(f, "1"),
ShortcutKey::N2 => write!(f, "2"),
ShortcutKey::N3 => write!(f, "3"),
ShortcutKey::N4 => write!(f, "4"),
ShortcutKey::N5 => write!(f, "5"),
ShortcutKey::N6 => write!(f, "6"),
ShortcutKey::N7 => write!(f, "7"),
ShortcutKey::N8 => write!(f, "8"),
ShortcutKey::N9 => write!(f, "9"),
ShortcutKey::Numpad0 => write!(f, "NUMPAD0"),
ShortcutKey::Numpad1 => write!(f, "NUMPAD1"),
ShortcutKey::Numpad2 => write!(f, "NUMPAD2"),
ShortcutKey::Numpad3 => write!(f, "NUMPAD3"),
ShortcutKey::Numpad4 => write!(f, "NUMPAD4"),
ShortcutKey::Numpad5 => write!(f, "NUMPAD5"),
ShortcutKey::Numpad6 => write!(f, "NUMPAD6"),
ShortcutKey::Numpad7 => write!(f, "NUMPAD7"),
ShortcutKey::Numpad8 => write!(f, "NUMPAD8"),
ShortcutKey::Numpad9 => write!(f, "NUMPAD9"),
ShortcutKey::Raw(code) => write!(f, "RAW({})", code),
}
}
}
impl ShortcutKey {
pub fn parse(key: &str) -> Option<ShortcutKey> {
let parsed = match key {
"ALT" | "OPTION" => Some(ShortcutKey::Alt),
"CTRL" => Some(ShortcutKey::Control),
"META" | "CMD" => Some(ShortcutKey::Meta),
"SHIFT" => Some(ShortcutKey::Shift),
"ENTER" => Some(ShortcutKey::Enter),
"TAB" => Some(ShortcutKey::Tab),
"SPACE" => Some(ShortcutKey::Space),
"INSERT" => Some(ShortcutKey::Insert),
"DOWN" => Some(ShortcutKey::ArrowDown),
"LEFT" => Some(ShortcutKey::ArrowLeft),
"RIGHT" => Some(ShortcutKey::ArrowRight),
"UP" => Some(ShortcutKey::ArrowUp),
"END" => Some(ShortcutKey::End),
"HOME" => Some(ShortcutKey::Home),
"PAGEDOWN" => Some(ShortcutKey::PageDown),
"PAGEUP" => Some(ShortcutKey::PageUp),
"F1" => Some(ShortcutKey::F1),
"F2" => Some(ShortcutKey::F2),
"F3" => Some(ShortcutKey::F3),
"F4" => Some(ShortcutKey::F4),
"F5" => Some(ShortcutKey::F5),
"F6" => Some(ShortcutKey::F6),
"F7" => Some(ShortcutKey::F7),
"F8" => Some(ShortcutKey::F8),
"F9" => Some(ShortcutKey::F9),
"F10" => Some(ShortcutKey::F10),
"F11" => Some(ShortcutKey::F11),
"F12" => Some(ShortcutKey::F12),
"F13" => Some(ShortcutKey::F13),
"F14" => Some(ShortcutKey::F14),
"F15" => Some(ShortcutKey::F15),
"F16" => Some(ShortcutKey::F16),
"F17" => Some(ShortcutKey::F17),
"F18" => Some(ShortcutKey::F18),
"F19" => Some(ShortcutKey::F19),
"F20" => Some(ShortcutKey::F20),
"A" => Some(ShortcutKey::A),
"B" => Some(ShortcutKey::B),
"C" => Some(ShortcutKey::C),
"D" => Some(ShortcutKey::D),
"E" => Some(ShortcutKey::E),
"F" => Some(ShortcutKey::F),
"G" => Some(ShortcutKey::G),
"H" => Some(ShortcutKey::H),
"I" => Some(ShortcutKey::I),
"J" => Some(ShortcutKey::J),
"K" => Some(ShortcutKey::K),
"L" => Some(ShortcutKey::L),
"M" => Some(ShortcutKey::M),
"N" => Some(ShortcutKey::N),
"O" => Some(ShortcutKey::O),
"P" => Some(ShortcutKey::P),
"Q" => Some(ShortcutKey::Q),
"R" => Some(ShortcutKey::R),
"S" => Some(ShortcutKey::S),
"T" => Some(ShortcutKey::T),
"U" => Some(ShortcutKey::U),
"V" => Some(ShortcutKey::V),
"W" => Some(ShortcutKey::W),
"X" => Some(ShortcutKey::X),
"Y" => Some(ShortcutKey::Y),
"Z" => Some(ShortcutKey::Z),
"0" => Some(ShortcutKey::N0),
"1" => Some(ShortcutKey::N1),
"2" => Some(ShortcutKey::N2),
"3" => Some(ShortcutKey::N3),
"4" => Some(ShortcutKey::N4),
"5" => Some(ShortcutKey::N5),
"6" => Some(ShortcutKey::N6),
"7" => Some(ShortcutKey::N7),
"8" => Some(ShortcutKey::N8),
"9" => Some(ShortcutKey::N9),
"NUMPAD0" => Some(ShortcutKey::Numpad0),
"NUMPAD1" => Some(ShortcutKey::Numpad1),
"NUMPAD2" => Some(ShortcutKey::Numpad2),
"NUMPAD3" => Some(ShortcutKey::Numpad3),
"NUMPAD4" => Some(ShortcutKey::Numpad4),
"NUMPAD5" => Some(ShortcutKey::Numpad5),
"NUMPAD6" => Some(ShortcutKey::Numpad6),
"NUMPAD7" => Some(ShortcutKey::Numpad7),
"NUMPAD8" => Some(ShortcutKey::Numpad8),
"NUMPAD9" => Some(ShortcutKey::Numpad9),
_ => None,
};
if parsed.is_none() {
// Attempt to parse raw ShortcutKeys
if RAW_PARSER.is_match(key) {
if let Some(caps) = RAW_PARSER.captures(key) {
let code_str = caps.get(1).map_or("", |m| m.as_str());
let code = code_str.parse::<u32>();
if let Ok(code) = code {
return Some(ShortcutKey::Raw(code));
}
}
}
}
parsed
}
// macOS keycodes
#[cfg(target_os = "macos")]
pub fn to_code(&self) -> Option<u32> {
match self {
ShortcutKey::Alt => Some(0x3A),
ShortcutKey::Control => Some(0x3B),
ShortcutKey::Meta => Some(0x37),
ShortcutKey::Shift => Some(0x38),
ShortcutKey::Enter => Some(0x24),
ShortcutKey::Tab => Some(0x30),
ShortcutKey::Space => Some(0x31),
ShortcutKey::ArrowDown => Some(0x7D),
ShortcutKey::ArrowLeft => Some(0x7B),
ShortcutKey::ArrowRight => Some(0x7C),
ShortcutKey::ArrowUp => Some(0x7E),
ShortcutKey::End => Some(0x77),
ShortcutKey::Home => Some(0x73),
ShortcutKey::PageDown => Some(0x79),
ShortcutKey::PageUp => Some(0x74),
ShortcutKey::Insert => None,
ShortcutKey::F1 => Some(0x7A),
ShortcutKey::F2 => Some(0x78),
ShortcutKey::F3 => Some(0x63),
ShortcutKey::F4 => Some(0x76),
ShortcutKey::F5 => Some(0x60),
ShortcutKey::F6 => Some(0x61),
ShortcutKey::F7 => Some(0x62),
ShortcutKey::F8 => Some(0x64),
ShortcutKey::F9 => Some(0x65),
ShortcutKey::F10 => Some(0x6D),
ShortcutKey::F11 => Some(0x67),
ShortcutKey::F12 => Some(0x6F),
ShortcutKey::F13 => Some(0x69),
ShortcutKey::F14 => Some(0x6B),
ShortcutKey::F15 => Some(0x71),
ShortcutKey::F16 => Some(0x6A),
ShortcutKey::F17 => Some(0x40),
ShortcutKey::F18 => Some(0x4F),
ShortcutKey::F19 => Some(0x50),
ShortcutKey::F20 => Some(0x5A),
ShortcutKey::A => Some(0x00),
ShortcutKey::B => Some(0x0B),
ShortcutKey::C => Some(0x08),
ShortcutKey::D => Some(0x02),
ShortcutKey::E => Some(0x0E),
ShortcutKey::F => Some(0x03),
ShortcutKey::G => Some(0x05),
ShortcutKey::H => Some(0x04),
ShortcutKey::I => Some(0x22),
ShortcutKey::J => Some(0x26),
ShortcutKey::K => Some(0x28),
ShortcutKey::L => Some(0x25),
ShortcutKey::M => Some(0x2E),
ShortcutKey::N => Some(0x2D),
ShortcutKey::O => Some(0x1F),
ShortcutKey::P => Some(0x23),
ShortcutKey::Q => Some(0x0C),
ShortcutKey::R => Some(0x0F),
ShortcutKey::S => Some(0x01),
ShortcutKey::T => Some(0x11),
ShortcutKey::U => Some(0x20),
ShortcutKey::V => Some(0x09),
ShortcutKey::W => Some(0x0D),
ShortcutKey::X => Some(0x07),
ShortcutKey::Y => Some(0x10),
ShortcutKey::Z => Some(0x06),
ShortcutKey::N0 => Some(0x1D),
ShortcutKey::N1 => Some(0x12),
ShortcutKey::N2 => Some(0x13),
ShortcutKey::N3 => Some(0x14),
ShortcutKey::N4 => Some(0x15),
ShortcutKey::N5 => Some(0x17),
ShortcutKey::N6 => Some(0x16),
ShortcutKey::N7 => Some(0x1A),
ShortcutKey::N8 => Some(0x1C),
ShortcutKey::N9 => Some(0x19),
ShortcutKey::Numpad0 => Some(0x52),
ShortcutKey::Numpad1 => Some(0x53),
ShortcutKey::Numpad2 => Some(0x54),
ShortcutKey::Numpad3 => Some(0x55),
ShortcutKey::Numpad4 => Some(0x56),
ShortcutKey::Numpad5 => Some(0x57),
ShortcutKey::Numpad6 => Some(0x58),
ShortcutKey::Numpad7 => Some(0x59),
ShortcutKey::Numpad8 => Some(0x5B),
ShortcutKey::Numpad9 => Some(0x5C),
ShortcutKey::Raw(code) => Some(*code),
}
}
// Windows key codes
#[cfg(target_os = "windows")]
pub fn to_code(&self) -> Option<u32> {
let vkey = match self {
ShortcutKey::Alt => 0x12,
ShortcutKey::Control => 0x11,
ShortcutKey::Meta => 0x5B,
ShortcutKey::Shift => 0xA0,
ShortcutKey::Enter => 0x0D,
ShortcutKey::Tab => 0x09,
ShortcutKey::Space => 0x20,
ShortcutKey::ArrowDown => 0x28,
ShortcutKey::ArrowLeft => 0x25,
ShortcutKey::ArrowRight => 0x27,
ShortcutKey::ArrowUp => 0x26,
ShortcutKey::End => 0x23,
ShortcutKey::Home => 0x24,
ShortcutKey::PageDown => 0x22,
ShortcutKey::PageUp => 0x21,
ShortcutKey::Insert => 0x2D,
ShortcutKey::F1 => 0x70,
ShortcutKey::F2 => 0x71,
ShortcutKey::F3 => 0x72,
ShortcutKey::F4 => 0x73,
ShortcutKey::F5 => 0x74,
ShortcutKey::F6 => 0x75,
ShortcutKey::F7 => 0x76,
ShortcutKey::F8 => 0x77,
ShortcutKey::F9 => 0x78,
ShortcutKey::F10 => 0x79,
ShortcutKey::F11 => 0x7A,
ShortcutKey::F12 => 0x7B,
ShortcutKey::F13 => 0x7C,
ShortcutKey::F14 => 0x7D,
ShortcutKey::F15 => 0x7E,
ShortcutKey::F16 => 0x7F,
ShortcutKey::F17 => 0x80,
ShortcutKey::F18 => 0x81,
ShortcutKey::F19 => 0x82,
ShortcutKey::F20 => 0x83,
ShortcutKey::A => 0x41,
ShortcutKey::B => 0x42,
ShortcutKey::C => 0x43,
ShortcutKey::D => 0x44,
ShortcutKey::E => 0x45,
ShortcutKey::F => 0x46,
ShortcutKey::G => 0x47,
ShortcutKey::H => 0x48,
ShortcutKey::I => 0x49,
ShortcutKey::J => 0x4A,
ShortcutKey::K => 0x4B,
ShortcutKey::L => 0x4C,
ShortcutKey::M => 0x4D,
ShortcutKey::N => 0x4E,
ShortcutKey::O => 0x4F,
ShortcutKey::P => 0x50,
ShortcutKey::Q => 0x51,
ShortcutKey::R => 0x52,
ShortcutKey::S => 0x53,
ShortcutKey::T => 0x54,
ShortcutKey::U => 0x55,
ShortcutKey::V => 0x56,
ShortcutKey::W => 0x57,
ShortcutKey::X => 0x58,
ShortcutKey::Y => 0x59,
ShortcutKey::Z => 0x5A,
ShortcutKey::N0 => 0x30,
ShortcutKey::N1 => 0x31,
ShortcutKey::N2 => 0x32,
ShortcutKey::N3 => 0x33,
ShortcutKey::N4 => 0x34,
ShortcutKey::N5 => 0x35,
ShortcutKey::N6 => 0x36,
ShortcutKey::N7 => 0x37,
ShortcutKey::N8 => 0x38,
ShortcutKey::N9 => 0x39,
ShortcutKey::Numpad0 => 0x60,
ShortcutKey::Numpad1 => 0x61,
ShortcutKey::Numpad2 => 0x62,
ShortcutKey::Numpad3 => 0x63,
ShortcutKey::Numpad4 => 0x64,
ShortcutKey::Numpad5 => 0x65,
ShortcutKey::Numpad6 => 0x66,
ShortcutKey::Numpad7 => 0x67,
ShortcutKey::Numpad8 => 0x68,
ShortcutKey::Numpad9 => 0x69,
ShortcutKey::Raw(code) => *code,
};
Some(vkey)
}
// Linux mappings
// NOTE: on linux, this method returns the KeySym and not the KeyCode
// which should be obtained in other ways depending on the backend.
// (X11 or Wayland)
#[cfg(target_os = "linux")]
pub fn to_code(&self) -> Option<u32> {
match self {
ShortcutKey::Alt => Some(0xFFE9),
ShortcutKey::Control => Some(0xFFE3),
ShortcutKey::Meta => Some(0xFFEB),
ShortcutKey::Shift => Some(0xFFE1),
ShortcutKey::Enter => Some(0xFF0D),
ShortcutKey::Tab => Some(0xFF09),
ShortcutKey::Space => Some(0x20),
ShortcutKey::ArrowDown => Some(0xFF54),
ShortcutKey::ArrowLeft => Some(0xFF51),
ShortcutKey::ArrowRight => Some(0xFF53),
ShortcutKey::ArrowUp => Some(0xFF52),
ShortcutKey::End => Some(0xFF57),
ShortcutKey::Home => Some(0xFF50),
ShortcutKey::PageDown => Some(0xFF56),
ShortcutKey::PageUp => Some(0xFF55),
ShortcutKey::Insert => Some(0xff63),
ShortcutKey::F1 => Some(0xFFBE),
ShortcutKey::F2 => Some(0xFFBF),
ShortcutKey::F3 => Some(0xFFC0),
ShortcutKey::F4 => Some(0xFFC1),
ShortcutKey::F5 => Some(0xFFC2),
ShortcutKey::F6 => Some(0xFFC3),
ShortcutKey::F7 => Some(0xFFC4),
ShortcutKey::F8 => Some(0xFFC5),
ShortcutKey::F9 => Some(0xFFC6),
ShortcutKey::F10 => Some(0xFFC7),
ShortcutKey::F11 => Some(0xFFC8),
ShortcutKey::F12 => Some(0xFFC9),
ShortcutKey::F13 => Some(0xFFCA),
ShortcutKey::F14 => Some(0xFFCB),
ShortcutKey::F15 => Some(0xFFCC),
ShortcutKey::F16 => Some(0xFFCD),
ShortcutKey::F17 => Some(0xFFCE),
ShortcutKey::F18 => Some(0xFFCF),
ShortcutKey::F19 => Some(0xFFD0),
ShortcutKey::F20 => Some(0xFFD1),
ShortcutKey::A => Some(0x0061),
ShortcutKey::B => Some(0x0062),
ShortcutKey::C => Some(0x0063),
ShortcutKey::D => Some(0x0064),
ShortcutKey::E => Some(0x0065),
ShortcutKey::F => Some(0x0066),
ShortcutKey::G => Some(0x0067),
ShortcutKey::H => Some(0x0068),
ShortcutKey::I => Some(0x0069),
ShortcutKey::J => Some(0x006a),
ShortcutKey::K => Some(0x006b),
ShortcutKey::L => Some(0x006c),
ShortcutKey::M => Some(0x006d),
ShortcutKey::N => Some(0x006e),
ShortcutKey::O => Some(0x006f),
ShortcutKey::P => Some(0x0070),
ShortcutKey::Q => Some(0x0071),
ShortcutKey::R => Some(0x0072),
ShortcutKey::S => Some(0x0073),
ShortcutKey::T => Some(0x0074),
ShortcutKey::U => Some(0x0075),
ShortcutKey::V => Some(0x0076),
ShortcutKey::W => Some(0x0077),
ShortcutKey::X => Some(0x0078),
ShortcutKey::Y => Some(0x0079),
ShortcutKey::Z => Some(0x007a),
ShortcutKey::N0 => Some(0x0030),
ShortcutKey::N1 => Some(0x0031),
ShortcutKey::N2 => Some(0x0032),
ShortcutKey::N3 => Some(0x0033),
ShortcutKey::N4 => Some(0x0034),
ShortcutKey::N5 => Some(0x0035),
ShortcutKey::N6 => Some(0x0036),
ShortcutKey::N7 => Some(0x0037),
ShortcutKey::N8 => Some(0x0038),
ShortcutKey::N9 => Some(0x0039),
ShortcutKey::Numpad0 => Some(0xffb0),
ShortcutKey::Numpad1 => Some(0xffb1),
ShortcutKey::Numpad2 => Some(0xffb2),
ShortcutKey::Numpad3 => Some(0xffb3),
ShortcutKey::Numpad4 => Some(0xffb4),
ShortcutKey::Numpad5 => Some(0xffb5),
ShortcutKey::Numpad6 => Some(0xffb6),
ShortcutKey::Numpad7 => Some(0xffb7),
ShortcutKey::Numpad8 => Some(0xffb8),
ShortcutKey::Numpad9 => Some(0xffb9),
ShortcutKey::Raw(code) => Some(*code as u32),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_works_correctly() {
assert!(matches!(
ShortcutKey::parse("ALT").unwrap(),
ShortcutKey::Alt
));
assert!(matches!(
ShortcutKey::parse("META").unwrap(),
ShortcutKey::Meta
));
assert!(matches!(
ShortcutKey::parse("CMD").unwrap(),
ShortcutKey::Meta
));
assert!(matches!(
ShortcutKey::parse("RAW(1234)").unwrap(),
ShortcutKey::Raw(1234)
));
}
#[test]
fn parse_invalid_keys() {
assert!(ShortcutKey::parse("INVALID").is_none());
assert!(ShortcutKey::parse("RAW(a)").is_none());
}
}

View File

@ -0,0 +1,150 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
pub mod keys;
use std::fmt::Display;
use anyhow::Result;
use keys::ShortcutKey;
use thiserror::Error;
static MODIFIERS: &[ShortcutKey; 4] = &[
ShortcutKey::Control,
ShortcutKey::Alt,
ShortcutKey::Shift,
ShortcutKey::Meta,
];
#[derive(Debug, PartialEq, Clone)]
pub struct HotKey {
pub id: i32,
pub key: ShortcutKey,
pub modifiers: Vec<ShortcutKey>,
}
impl HotKey {
pub fn new(id: i32, shortcut: &str) -> Result<Self> {
let tokens: Vec<String> = shortcut
.split('+')
.map(|token| token.trim().to_uppercase())
.collect();
let mut modifiers = Vec::new();
let mut main_key = None;
for token in tokens {
let key = ShortcutKey::parse(&token);
match key {
Some(key) => {
if MODIFIERS.contains(&key) {
modifiers.push(key)
} else {
main_key = Some(key)
}
}
None => return Err(HotKeyError::InvalidKey(token).into()),
};
}
if modifiers.is_empty() || main_key.is_none() {
return Err(HotKeyError::InvalidShortcut(shortcut.to_string()).into());
}
Ok(Self {
id,
modifiers,
key: main_key.unwrap(),
})
}
#[allow(dead_code)]
pub(crate) fn has_ctrl(&self) -> bool {
self.modifiers.contains(&ShortcutKey::Control)
}
#[allow(dead_code)]
pub(crate) fn has_meta(&self) -> bool {
self.modifiers.contains(&ShortcutKey::Meta)
}
#[allow(dead_code)]
pub(crate) fn has_alt(&self) -> bool {
self.modifiers.contains(&ShortcutKey::Alt)
}
#[allow(dead_code)]
pub(crate) fn has_shift(&self) -> bool {
self.modifiers.contains(&ShortcutKey::Shift)
}
}
impl Display for HotKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str_modifiers: Vec<String> = self.modifiers.iter().map(|m| m.to_string()).collect();
let modifiers = str_modifiers.join("+");
write!(f, "{}+{}", &modifiers, &self.key)
}
}
#[derive(Error, Debug)]
pub enum HotKeyError {
#[error("invalid hotkey shortcut, `{0}` is not a valid key")]
InvalidKey(String),
#[error("invalid hotkey shortcut `{0}`")]
InvalidShortcut(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_correctly() {
assert_eq!(
HotKey::new(1, "CTRL+V").unwrap(),
HotKey {
id: 1,
key: ShortcutKey::V,
modifiers: vec![ShortcutKey::Control],
}
);
assert_eq!(
HotKey::new(2, "SHIFT + Ctrl + v").unwrap(),
HotKey {
id: 2,
key: ShortcutKey::V,
modifiers: vec![ShortcutKey::Shift, ShortcutKey::Control],
}
);
assert!(HotKey::new(3, "invalid").is_err());
}
#[test]
fn modifiers_detected_correcty() {
assert!(HotKey::new(1, "CTRL+V").unwrap().has_ctrl());
assert!(HotKey::new(1, "ALT + V").unwrap().has_alt());
assert!(HotKey::new(1, "CMD + V").unwrap().has_meta());
assert!(HotKey::new(1, "SHIFT+ V").unwrap().has_shift());
assert!(!HotKey::new(1, "SHIFT+ V").unwrap().has_ctrl());
assert!(!HotKey::new(1, "SHIFT+ V").unwrap().has_alt());
assert!(!HotKey::new(1, "SHIFT+ V").unwrap().has_meta());
}
}

View File

@ -0,0 +1,56 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::process::Command;
use log::error;
use regex::Regex;
use std::path::PathBuf;
lazy_static! {
static ref LAYOUT_EXTRACT_REGEX: Regex = Regex::new(r"^\[\('.*?', '(.*?)'\)").unwrap();
}
pub fn get_active_layout() -> Option<String> {
match Command::new("gsettings")
.arg("get")
.arg("org.gnome.desktop.input-sources")
.arg("mru-sources")
.output()
{
Ok(output) => {
let output_str = String::from_utf8_lossy(&output.stdout);
let captures = LAYOUT_EXTRACT_REGEX.captures(&output_str)?;
let layout = captures.get(1)?.as_str();
Some(layout.to_string())
}
Err(err) => {
error!(
"unable to retrieve current keyboard layout with 'gsettings': {}",
err
);
None
}
}
}
pub fn is_gnome() -> bool {
let target_session_file = PathBuf::from("/usr/bin/gnome-session");
target_session_file.exists()
}

View File

@ -0,0 +1,49 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#[cfg(target_os = "linux")]
#[cfg(not(feature = "wayland"))]
mod x11;
#[cfg(target_os = "linux")]
#[cfg(feature = "wayland")]
mod gnome;
#[cfg(target_os = "linux")]
#[cfg(not(feature = "wayland"))]
pub fn get_active_layout() -> Option<String> {
x11::get_active_layout()
}
#[cfg(target_os = "linux")]
#[cfg(feature = "wayland")]
pub fn get_active_layout() -> Option<String> {
if gnome::is_gnome() {
gnome::get_active_layout()
} else {
log::warn!("unable to determine the currently active layout, you might need to explicitly specify the layout in the config for espanso to work correctly.");
None
}
}
#[cfg(not(target_os = "linux"))]
pub fn get_active_layout() -> Option<String> {
// Not available on Windows and macOS yet
None
}

View File

@ -0,0 +1,40 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::process::Command;
use log::error;
pub fn get_active_layout() -> Option<String> {
match Command::new("setxkbmap").arg("-query").output() {
Ok(output) => {
let output_str = String::from_utf8_lossy(&output.stdout);
let layout_line = output_str.lines().find(|line| line.contains("layout:"))?;
let layout_raw: Vec<&str> = layout_line.split("layout:").collect();
Some(layout_raw.get(1)?.trim().to_string())
}
Err(err) => {
error!(
"unable to retrieve current keyboard layout with 'setxkbmap': {}",
err
);
None
}
}
}

128
espanso-detect/src/lib.rs Normal file
View File

@ -0,0 +1,128 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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 anyhow::Result;
use hotkey::HotKey;
use log::info;
pub mod event;
pub mod hotkey;
pub mod layout;
#[cfg(target_os = "windows")]
pub mod win32;
#[cfg(target_os = "linux")]
#[cfg(not(feature = "wayland"))]
pub mod x11;
#[cfg(target_os = "linux")]
pub mod evdev;
#[cfg(target_os = "macos")]
pub mod mac;
#[macro_use]
extern crate lazy_static;
pub type SourceCallback = Box<dyn Fn(event::InputEvent)>;
pub trait Source {
fn initialize(&mut self) -> Result<()>;
fn eventloop(&self, event_callback: SourceCallback) -> Result<()>;
}
#[allow(dead_code)]
pub struct SourceCreationOptions {
// Only relevant in X11 Linux systems, use the EVDEV backend instead of X11.
pub use_evdev: bool,
// Can be used to overwrite the keymap configuration
// used by espanso to inject key presses.
pub evdev_keyboard_rmlvo: Option<KeyboardConfig>,
// List of global hotkeys the detection module has to register
// NOTE: Hotkeys don't work under the EVDEV backend yet (Wayland)
pub hotkeys: Vec<HotKey>,
// If true, filter out keyboard events without an explicit HID device source on Windows.
// This is needed to filter out the software-generated events, including
// those from espanso, but might need to be disabled when using some software-level keyboards.
// Disabling this option might conflict with the undo feature.
pub win32_exclude_orphan_events: bool,
}
// This struct identifies the keyboard layout that
// should be used by EVDEV when loading the keymap.
// For more information: https://xkbcommon.org/doc/current/structxkb__rule__names.html
#[derive(Debug, Clone)]
pub struct KeyboardConfig {
pub rules: Option<String>,
pub model: Option<String>,
pub layout: Option<String>,
pub variant: Option<String>,
pub options: Option<String>,
}
impl Default for SourceCreationOptions {
fn default() -> Self {
Self {
use_evdev: false,
evdev_keyboard_rmlvo: None,
hotkeys: Vec::new(),
win32_exclude_orphan_events: true,
}
}
}
#[cfg(target_os = "windows")]
pub fn get_source(options: SourceCreationOptions) -> Result<Box<dyn Source>> {
info!("using Win32Source");
Ok(Box::new(win32::Win32Source::new(
&options.hotkeys,
options.win32_exclude_orphan_events,
)))
}
#[cfg(target_os = "macos")]
pub fn get_source(options: SourceCreationOptions) -> Result<Box<dyn Source>> {
info!("using CocoaSource");
Ok(Box::new(mac::CocoaSource::new(&options.hotkeys)))
}
#[cfg(target_os = "linux")]
#[cfg(not(feature = "wayland"))]
pub fn get_source(options: SourceCreationOptions) -> Result<Box<dyn Source>> {
if options.use_evdev {
info!("using EVDEVSource");
Ok(Box::new(evdev::EVDEVSource::new(options)))
} else {
info!("using X11Source");
Ok(Box::new(x11::X11Source::new(&options.hotkeys)))
}
}
#[cfg(target_os = "linux")]
#[cfg(feature = "wayland")]
pub fn get_source(options: SourceCreationOptions) -> Result<Box<dyn Source>> {
info!("using EVDEVSource");
Ok(Box::new(evdev::EVDEVSource::new(options)))
}
pub use layout::get_active_layout;

View File

@ -0,0 +1,426 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{
convert::TryInto,
ffi::CStr,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex,
},
};
use lazycell::LazyCell;
use log::{error, trace, warn};
use anyhow::Result;
use thiserror::Error;
use crate::event::{HotKeyEvent, InputEvent, Key, KeyboardEvent, Variant};
use crate::event::{Key::*, MouseButton, MouseEvent};
use crate::{event::Status::*, Source, SourceCallback};
use crate::{event::Variant::*, hotkey::HotKey};
const INPUT_EVENT_TYPE_KEYBOARD: i32 = 1;
const INPUT_EVENT_TYPE_MOUSE: i32 = 2;
const INPUT_EVENT_TYPE_HOTKEY: i32 = 3;
const INPUT_STATUS_PRESSED: i32 = 1;
const INPUT_STATUS_RELEASED: i32 = 2;
const INPUT_MOUSE_LEFT_BUTTON: i32 = 1;
const INPUT_MOUSE_RIGHT_BUTTON: i32 = 2;
const INPUT_MOUSE_MIDDLE_BUTTON: i32 = 3;
// Take a look at the native.h header file for an explanation of the fields
#[repr(C)]
pub struct RawInputEvent {
pub event_type: i32,
pub buffer: [u8; 24],
pub buffer_len: i32,
pub key_code: i32,
pub status: i32,
}
#[repr(C)]
pub struct RawHotKey {
pub id: i32,
pub code: u16,
pub flags: u32,
}
#[repr(C)]
pub struct RawInitializationOptions {
pub hotkeys: *const RawHotKey,
pub hotkeys_count: i32,
}
#[allow(improper_ctypes)]
#[link(name = "espansodetect", kind = "static")]
extern "C" {
pub fn detect_initialize(
callback: extern "C" fn(event: RawInputEvent),
options: RawInitializationOptions,
);
}
lazy_static! {
static ref CURRENT_SENDER: Arc<Mutex<Option<Sender<InputEvent>>>> = Arc::new(Mutex::new(None));
}
extern "C" fn native_callback(raw_event: RawInputEvent) {
let lock = CURRENT_SENDER
.lock()
.expect("unable to acquire CocoaSource sender lock");
if let Some(sender) = lock.as_ref() {
let event: Option<InputEvent> = raw_event.into();
if let Some(event) = event {
if let Err(error) = sender.send(event) {
error!("Unable to send event to Cocoa Sender: {}", error);
}
} else {
trace!("Unable to convert raw event to input event");
}
} else {
warn!("Lost raw event, as Cocoa Sender is not available");
}
}
pub struct CocoaSource {
receiver: LazyCell<Receiver<InputEvent>>,
hotkeys: Vec<HotKey>,
}
#[allow(clippy::new_without_default)]
impl CocoaSource {
pub fn new(hotkeys: &[HotKey]) -> CocoaSource {
Self {
receiver: LazyCell::new(),
hotkeys: hotkeys.to_vec(),
}
}
}
impl Source for CocoaSource {
fn initialize(&mut self) -> Result<()> {
let (sender, receiver) = channel();
// Set the global sender
{
let mut lock = CURRENT_SENDER
.lock()
.expect("unable to acquire CocoaSource sender lock during initialization");
*lock = Some(sender);
}
// Generate the options
let hotkeys: Vec<RawHotKey> = self
.hotkeys
.iter()
.filter_map(|hk| {
let raw = convert_hotkey_to_raw(hk);
if raw.is_none() {
error!("unable to register hotkey: {:?}", hk);
}
raw
})
.collect();
let options = RawInitializationOptions {
hotkeys: hotkeys.as_ptr(),
hotkeys_count: hotkeys.len() as i32,
};
unsafe { detect_initialize(native_callback, options) };
if self.receiver.fill(receiver).is_err() {
error!("Unable to set CocoaSource receiver");
return Err(CocoaSourceError::Unknown().into());
}
Ok(())
}
fn eventloop(&self, event_callback: SourceCallback) -> Result<()> {
if let Some(receiver) = self.receiver.borrow() {
loop {
let event = receiver.recv();
match event {
Ok(event) => {
event_callback(event);
}
Err(error) => {
error!("CocoaSource receiver reported error: {}", error);
break;
}
}
}
} else {
error!("Unable to start event loop if CocoaSource receiver is null");
return Err(CocoaSourceError::Unknown().into());
}
Ok(())
}
}
impl Drop for CocoaSource {
fn drop(&mut self) {
// Reset the global sender
{
let mut lock = CURRENT_SENDER
.lock()
.expect("unable to acquire CocoaSource sender lock during initialization");
*lock = None;
}
}
}
fn convert_hotkey_to_raw(hk: &HotKey) -> Option<RawHotKey> {
let key_code = hk.key.to_code()?;
let code: Result<u16, _> = key_code.try_into();
if let Ok(code) = code {
let mut flags = 0;
if hk.has_ctrl() {
flags |= 1 << 12;
}
if hk.has_alt() {
flags |= 1 << 11;
}
if hk.has_meta() {
flags |= 1 << 8;
}
if hk.has_shift() {
flags |= 1 << 9;
}
Some(RawHotKey {
id: hk.id,
code,
flags,
})
} else {
error!("unable to generate raw hotkey, the key_code is overflowing");
None
}
}
#[derive(Error, Debug)]
pub enum CocoaSourceError {
#[error("unknown error")]
Unknown(),
}
impl From<RawInputEvent> for Option<InputEvent> {
fn from(raw: RawInputEvent) -> Option<InputEvent> {
let status = match raw.status {
INPUT_STATUS_RELEASED => Released,
INPUT_STATUS_PRESSED => Pressed,
_ => Pressed,
};
match raw.event_type {
// Keyboard events
INPUT_EVENT_TYPE_KEYBOARD => {
let (key, variant) = key_code_to_key(raw.key_code);
let value = if raw.buffer_len > 0 {
let raw_string_result =
CStr::from_bytes_with_nul(&raw.buffer[..((raw.buffer_len + 1) as usize)]);
match raw_string_result {
Ok(c_string) => {
let string_result = c_string.to_str();
match string_result {
Ok(value) => Some(value.to_string()),
Err(err) => {
warn!("CocoaSource event utf8 conversion error: {}", err);
None
}
}
}
Err(err) => {
trace!("Received malformed event buffer: {}", err);
None
}
}
} else {
None
};
return Some(InputEvent::Keyboard(KeyboardEvent {
key,
value,
status,
variant,
code: raw
.key_code
.try_into()
.expect("unable to convert keycode to u32"),
}));
}
// Mouse events
INPUT_EVENT_TYPE_MOUSE => {
let button = raw_to_mouse_button(raw.key_code);
if let Some(button) = button {
return Some(InputEvent::Mouse(MouseEvent { button, status }));
}
}
// HOTKEYS
INPUT_EVENT_TYPE_HOTKEY => {
let id = raw.key_code;
return Some(InputEvent::HotKey(HotKeyEvent { hotkey_id: id }));
}
_ => {}
}
None
}
}
// Mappings from: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
fn key_code_to_key(key_code: i32) -> (Key, Option<Variant>) {
match key_code {
// Modifiers
0x3A => (Alt, Some(Left)),
0x3D => (Alt, Some(Right)),
0x39 => (CapsLock, None), // TODO
0x3B => (Control, Some(Left)),
0x3E => (Control, Some(Right)),
0x37 => (Meta, Some(Left)),
0x36 => (Meta, Some(Right)),
0x38 => (Shift, Some(Left)),
0x3C => (Shift, Some(Right)),
// Whitespace
0x24 => (Enter, None),
0x30 => (Tab, None),
0x31 => (Space, None),
// Navigation
0x7D => (ArrowDown, None),
0x7B => (ArrowLeft, None),
0x7C => (ArrowRight, None),
0x7E => (ArrowUp, None),
0x77 => (End, None),
0x73 => (Home, None),
0x79 => (PageDown, None),
0x74 => (PageUp, None),
// UI
0x35 => (Escape, None),
// Editing keys
0x33 => (Backspace, None),
// Function keys
0x7A => (F1, None),
0x78 => (F2, None),
0x63 => (F3, None),
0x76 => (F4, None),
0x60 => (F5, None),
0x61 => (F6, None),
0x62 => (F7, None),
0x64 => (F8, None),
0x65 => (F9, None),
0x6D => (F10, None),
0x67 => (F11, None),
0x6F => (F12, None),
0x69 => (F13, None),
0x6B => (F14, None),
0x71 => (F15, None),
0x6A => (F16, None),
0x40 => (F17, None),
0x4F => (F18, None),
0x50 => (F19, None),
0x5A => (F20, None),
// Other keys, includes the raw code provided by the operating system
_ => (Other(key_code), None),
}
}
fn raw_to_mouse_button(raw: i32) -> Option<MouseButton> {
match raw {
INPUT_MOUSE_LEFT_BUTTON => Some(MouseButton::Left),
INPUT_MOUSE_RIGHT_BUTTON => Some(MouseButton::Right),
INPUT_MOUSE_MIDDLE_BUTTON => Some(MouseButton::Middle),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::ffi::CString;
use super::*;
fn default_raw_input_event() -> RawInputEvent {
RawInputEvent {
event_type: INPUT_EVENT_TYPE_KEYBOARD,
buffer: [0; 24],
buffer_len: 0,
key_code: 0,
status: INPUT_STATUS_PRESSED,
}
}
#[test]
fn raw_to_input_event_keyboard_works_correctly() {
let c_string = CString::new("k".to_string()).unwrap();
let mut buffer: [u8; 24] = [0; 24];
buffer[..1].copy_from_slice(c_string.as_bytes());
let mut raw = default_raw_input_event();
raw.buffer = buffer;
raw.buffer_len = 1;
raw.status = INPUT_STATUS_RELEASED;
raw.key_code = 40;
let result: Option<InputEvent> = raw.into();
assert_eq!(
result.unwrap(),
InputEvent::Keyboard(KeyboardEvent {
key: Other(40),
status: Released,
value: Some("k".to_string()),
variant: None,
code: 40,
})
);
}
#[test]
fn raw_to_input_event_mouse_works_correctly() {
let mut raw = default_raw_input_event();
raw.event_type = INPUT_EVENT_TYPE_MOUSE;
raw.status = INPUT_STATUS_RELEASED;
raw.key_code = INPUT_MOUSE_RIGHT_BUTTON;
let result: Option<InputEvent> = raw.into();
assert_eq!(
result.unwrap(),
InputEvent::Mouse(MouseEvent {
status: Released,
button: MouseButton::Right,
})
);
}
}

View File

@ -0,0 +1,73 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 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/>.
*/
#ifndef ESPANSO_DETECT_H
#define ESPANSO_DETECT_H
#include <stdint.h>
#define INPUT_EVENT_TYPE_KEYBOARD 1
#define INPUT_EVENT_TYPE_MOUSE 2
#define INPUT_EVENT_TYPE_HOTKEY 3
#define INPUT_STATUS_PRESSED 1
#define INPUT_STATUS_RELEASED 2
#define INPUT_LEFT_VARIANT 1
#define INPUT_RIGHT_VARIANT 2
#define INPUT_MOUSE_LEFT_BUTTON 1
#define INPUT_MOUSE_RIGHT_BUTTON 2
#define INPUT_MOUSE_MIDDLE_BUTTON 3
typedef struct {
// Keyboard, Mouse or Hotkey event
int32_t event_type;
// Contains the string corresponding to the key, if any
char buffer[24];
// Length of the extracted string. Equals 0 if no string is extracted
int32_t buffer_len;
// Virtual key code of the pressed key in case of keyboard events
// Mouse button code if mouse_event.
// Hotkey ID in case of hotkeys
int32_t key_code;
// Pressed or Released status
int32_t status;
} InputEvent;
typedef void (*EventCallback)(InputEvent data);
typedef struct {
int32_t hk_id;
uint16_t key_code;
uint32_t flags;
} HotKey;
typedef struct {
HotKey *hotkeys;
int32_t hotkeys_count;
} InitializeOptions;
// Initialize the event global monitor
extern "C" void * detect_initialize(EventCallback callback, InitializeOptions options);
#endif //ESPANSO_DETECT_H

Some files were not shown because too many files have changed in this diff Show More