feat(misc): release alpha v2.0.1
v2.0.1 alpha release
This commit is contained in:
commit
aa9465490b
1
.dockerignore
Normal file
1
.dockerignore
Normal file
|
@ -0,0 +1 @@
|
|||
target/*
|
|
@ -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
18
.github/scripts/ubuntu/build_appimage.sh
vendored
Executable 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
82
.github/workflows/ci.yml
vendored
Normal 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
199
.github/workflows/release.yml
vendored
Normal 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
2856
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
74
Cargo.toml
74
Cargo.toml
|
@ -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
67
Compilation.md
Normal 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
105
Makefile.toml
Normal 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"]
|
21
README.md
21
README.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
59
build.rs
59
build.rs
|
@ -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();
|
||||
}
|
|
@ -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"
|
||||
|
|
@ -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"
|
|
@ -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"
|
||||
|
|
@ -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'))
|
|
@ -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
|
|
@ -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'))
|
21
ci/test.yml
21
ci/test.yml
|
@ -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')
|
|
@ -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
|
31
espanso-clipboard/Cargo.toml
Normal file
31
espanso-clipboard/Cargo.toml
Normal 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"
|
82
espanso-clipboard/build.rs
Normal file
82
espanso-clipboard/build.rs
Normal 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();
|
||||
}
|
28
espanso-clipboard/src/cocoa/ffi.rs
Normal file
28
espanso-clipboard/src/cocoa/ffi.rs
Normal 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;
|
||||
}
|
103
espanso-clipboard/src/cocoa/mod.rs
Normal file
103
espanso-clipboard/src/cocoa/mod.rs
Normal 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),
|
||||
}
|
30
espanso-clipboard/src/cocoa/native.h
Normal file
30
espanso-clipboard/src/cocoa/native.h
Normal 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
|
87
espanso-clipboard/src/cocoa/native.mm
Normal file
87
espanso-clipboard/src/cocoa/native.mm
Normal 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;
|
||||
}
|
97
espanso-clipboard/src/lib.rs
Normal file
97
espanso-clipboard/src/lib.rs
Normal 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,
|
||||
)?))
|
||||
}
|
33
espanso-clipboard/src/wayland/README.md
Normal file
33
espanso-clipboard/src/wayland/README.md
Normal 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
|
201
espanso-clipboard/src/wayland/fallback/mod.rs
Normal file
201
espanso-clipboard/src/wayland/fallback/mod.rs
Normal 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),
|
||||
}
|
|
@ -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;
|
28
espanso-clipboard/src/win32/ffi.rs
Normal file
28
espanso-clipboard/src/win32/ffi.rs
Normal 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;
|
||||
}
|
147
espanso-clipboard/src/win32/mod.rs
Normal file
147
espanso-clipboard/src/win32/mod.rs
Normal 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),
|
||||
}
|
184
espanso-clipboard/src/win32/native.cpp
Normal file
184
espanso-clipboard/src/win32/native.cpp
Normal 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;
|
||||
}
|
30
espanso-clipboard/src/win32/native.h
Normal file
30
espanso-clipboard/src/win32/native.h
Normal 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
|
20
espanso-clipboard/src/x11/mod.rs
Normal file
20
espanso-clipboard/src/x11/mod.rs
Normal 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;
|
4
espanso-clipboard/src/x11/native/README.md
Normal file
4
espanso-clipboard/src/x11/native/README.md
Normal 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.
|
20
espanso-clipboard/src/x11/native/clip/LICENSE.txt
Normal file
20
espanso-clipboard/src/x11/native/clip/LICENSE.txt
Normal 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.
|
174
espanso-clipboard/src/x11/native/clip/clip.cpp
Normal file
174
espanso-clipboard/src/x11/native/clip/clip.cpp
Normal 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
|
178
espanso-clipboard/src/x11/native/clip/clip.h
Normal file
178
espanso-clipboard/src/x11/native/clip/clip.h
Normal 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
|
76
espanso-clipboard/src/x11/native/clip/clip_common.h
Normal file
76
espanso-clipboard/src/x11/native/clip/clip_common.h
Normal 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
|
33
espanso-clipboard/src/x11/native/clip/clip_lock_impl.h
Normal file
33
espanso-clipboard/src/x11/native/clip/clip_lock_impl.h
Normal 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
|
1088
espanso-clipboard/src/x11/native/clip/clip_x11.cpp
Normal file
1088
espanso-clipboard/src/x11/native/clip/clip_x11.cpp
Normal file
File diff suppressed because it is too large
Load Diff
225
espanso-clipboard/src/x11/native/clip/clip_x11_png.h
Normal file
225
espanso-clipboard/src/x11/native/clip/clip_x11_png.h
Normal 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
|
83
espanso-clipboard/src/x11/native/clip/image.cpp
Normal file
83
espanso-clipboard/src/x11/native/clip/image.cpp
Normal 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
|
28
espanso-clipboard/src/x11/native/ffi.rs
Normal file
28
espanso-clipboard/src/x11/native/ffi.rs
Normal 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;
|
||||
}
|
108
espanso-clipboard/src/x11/native/mod.rs
Normal file
108
espanso-clipboard/src/x11/native/mod.rs
Normal 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),
|
||||
}
|
76
espanso-clipboard/src/x11/native/native.cpp
Normal file
76
espanso-clipboard/src/x11/native/native.cpp
Normal 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;
|
||||
}
|
31
espanso-clipboard/src/x11/native/native.h
Normal file
31
espanso-clipboard/src/x11/native/native.h
Normal 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
25
espanso-config/Cargo.toml
Normal 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"
|
23
espanso-config/src/config/default.rs
Normal file
23
espanso-config/src/config/default.rs
Normal 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;
|
297
espanso-config/src/config/mod.rs
Normal file
297
espanso-config/src/config/mod.rs
Normal 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),
|
||||
}
|
85
espanso-config/src/config/parse/mod.rs
Normal file
85
espanso-config/src/config/parse/mod.rs
Normal 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),
|
||||
}
|
328
espanso-config/src/config/parse/yaml.rs
Normal file
328
espanso-config/src/config/parse/yaml.rs
Normal 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()),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
182
espanso-config/src/config/path.rs
Normal file
182
espanso-config/src/config/path.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
962
espanso-config/src/config/resolve.rs
Normal file
962
espanso-config/src/config/resolve.rs
Normal 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"),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
196
espanso-config/src/config/store.rs
Normal file
196
espanso-config/src/config/store.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
80
espanso-config/src/config/util.rs
Normal file
80
espanso-config/src/config/util.rs
Normal 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"));
|
||||
}
|
||||
}
|
34
espanso-config/src/counter.rs
Normal file
34
espanso-config/src/counter.rs
Normal 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))
|
||||
}
|
71
espanso-config/src/error.rs
Normal file
71
espanso-config/src/error.rs
Normal 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,
|
||||
}
|
1871
espanso-config/src/legacy/config.rs
Normal file
1871
espanso-config/src/legacy/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
690
espanso-config/src/legacy/mod.rs
Normal file
690
espanso-config/src/legacy/mod.rs
Normal 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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
62
espanso-config/src/legacy/model.rs
Normal file
62
espanso-config/src/legacy/model.rs
Normal 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
319
espanso-config/src/lib.rs
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
179
espanso-config/src/matches/group/loader/mod.rs
Normal file
179
espanso-config/src/matches/group/loader/mod.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
720
espanso-config/src/matches/group/loader/yaml/mod.rs
Normal file
720
espanso-config/src/matches/group/loader/yaml/mod.rs
Normal 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());
|
||||
})
|
||||
}
|
||||
}
|
132
espanso-config/src/matches/group/loader/yaml/parse.rs
Normal file
132
espanso-config/src/matches/group/loader/yaml/parse.rs
Normal 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()
|
||||
}
|
176
espanso-config/src/matches/group/loader/yaml/util.rs
Normal file
176
espanso-config/src/matches/group/loader/yaml/util.rs
Normal 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());
|
||||
}
|
||||
}
|
52
espanso-config/src/matches/group/mod.rs
Normal file
52
espanso-config/src/matches/group/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
164
espanso-config/src/matches/group/path.rs
Normal file
164
espanso-config/src/matches/group/path.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
237
espanso-config/src/matches/mod.rs
Normal file
237
espanso-config/src/matches/mod.rs
Normal 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>),
|
||||
}
|
738
espanso-config/src/matches/store/default.rs
Normal file
738
espanso-config/src/matches/store/default.rs
Normal 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
|
||||
}
|
41
espanso-config/src/matches/store/mod.rs
Normal file
41
espanso-config/src/matches/store/mod.rs
Normal 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)
|
||||
}
|
74
espanso-config/src/util.rs
Normal file
74
espanso-config/src/util.rs
Normal 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
33
espanso-detect/Cargo.toml
Normal 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
83
espanso-detect/build.rs
Normal 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();
|
||||
}
|
3
espanso-detect/src/evdev/README.md
Normal file
3
espanso-detect/src/evdev/README.md
Normal 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
|
47
espanso-detect/src/evdev/context.rs
Normal file
47
espanso-detect/src/evdev/context.rs
Normal 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(),
|
||||
}
|
326
espanso-detect/src/evdev/device.rs
Normal file
326
espanso-detect/src/evdev/device.rs
Normal 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(),
|
||||
}
|
78
espanso-detect/src/evdev/ffi.rs
Normal file
78
espanso-detect/src/evdev/ffi.rs
Normal 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;
|
||||
}
|
149
espanso-detect/src/evdev/hotkey.rs
Normal file
149
espanso-detect/src/evdev/hotkey.rs
Normal 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)
|
||||
}
|
||||
}
|
112
espanso-detect/src/evdev/keymap.rs
Normal file
112
espanso-detect/src/evdev/keymap.rs
Normal 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,
|
||||
}
|
422
espanso-detect/src/evdev/mod.rs
Normal file
422
espanso-detect/src/evdev/mod.rs
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
90
espanso-detect/src/evdev/native.cpp
Normal file
90
espanso-detect/src/evdev/native.cpp
Normal 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;
|
||||
}
|
27
espanso-detect/src/evdev/native.h
Normal file
27
espanso-detect/src/evdev/native.h
Normal 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
|
56
espanso-detect/src/evdev/state.rs
Normal file
56
espanso-detect/src/evdev/state.rs
Normal 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(),
|
||||
}
|
39
espanso-detect/src/evdev/sync/mod.rs
Normal file
39
espanso-detect/src/evdev/sync/mod.rs
Normal 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)
|
||||
}
|
248
espanso-detect/src/evdev/sync/wayland.rs
Normal file
248
espanso-detect/src/evdev/sync/wayland.rs
Normal 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
130
espanso-detect/src/event.rs
Normal 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,
|
||||
}
|
628
espanso-detect/src/hotkey/keys.rs
Normal file
628
espanso-detect/src/hotkey/keys.rs
Normal 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());
|
||||
}
|
||||
}
|
150
espanso-detect/src/hotkey/mod.rs
Normal file
150
espanso-detect/src/hotkey/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
56
espanso-detect/src/layout/gnome.rs
Normal file
56
espanso-detect/src/layout/gnome.rs
Normal 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()
|
||||
}
|
49
espanso-detect/src/layout/mod.rs
Normal file
49
espanso-detect/src/layout/mod.rs
Normal 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
|
||||
}
|
40
espanso-detect/src/layout/x11.rs
Normal file
40
espanso-detect/src/layout/x11.rs
Normal 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
128
espanso-detect/src/lib.rs
Normal 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;
|
426
espanso-detect/src/mac/mod.rs
Normal file
426
espanso-detect/src/mac/mod.rs
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
73
espanso-detect/src/mac/native.h
Normal file
73
espanso-detect/src/mac/native.h
Normal 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
Loading…
Reference in New Issue
Block a user