-**YAKC (Yet Another Key Caster)** is a **free, open-source, cross-platform keystroke visualizer and mouse-click overlay**. It shows the keys you press and the mouse buttons you click as clean, customizable **on-screen popups**, in real time — ideal for **screencasts, live streams (OBS, Twitch, YouTube), video tutorials, presentations, screen recordings and pair programming**.
+**YAKC (Yet Another Key Caster)** is a **free, open-source, cross-platform input visualizer** for **keyboard, mouse _and_ gamepad**. It shows the keys you press, the mouse buttons you click, how you move and scroll the mouse, and your controller's buttons, sticks and triggers — as clean, customizable **on-screen popups**, a light-up **virtual keyboard**, or device widgets, in real time. Display it **on your screen or as a transparent [OBS](https://obsproject.com/) browser source** — ideal for **live streams (OBS, Twitch, YouTube), screencasts, video tutorials, presentations, screen recordings, gaming overlays and pair programming**.
-One tool for **Windows, macOS and Linux (X11 _and_ Wayland)** that shows **any keyboard layout or language automatically** — a modern, actively maintained alternative to platform-locked keycasters like **KeyCastr** (macOS only), **Carnac** (Windows only), **screenkey** and **showmethekey** (Linux only).
+One tool for **Windows, macOS and Linux (X11 _and_ Wayland)** that shows **any keyboard layout or language automatically** — a modern, actively maintained alternative to platform-locked keycasters like **KeyCastr** (macOS only), **Carnac** (Windows only), **screenkey** and **showmethekey** (Linux only), and to the Windows-centric **input-overlay** OBS plugin — with keyboard, mouse-movement and controller visuals built in, on every OS.
@@ -30,46 +30,58 @@ The Electron version needed Node.js, a native `iohook` build per platform, and s
## Features
-- **Two display modes**:
+- **Two display styles**:
+ - `popups` (default) — clean, fading key/click tokens
+ - `keyboard` — an **on-screen virtual keyboard** whose caps light up as you type; detects your **real OS layout** (QWERTZ/AZERTY/…) and lights the correct physical key even on non-US layouts. **Pick exactly which keys to show** with a visual selector (e.g. just WASD + your binds) — it stays compact and gap-free
+- **Two popup modes**:
- `text` (default) — behaves like a text editor: only the characters you type appear, **Backspace really deletes**, shortcuts and navigation keys stay hidden
- `raw` — every key shows: modifiers (`CTRL + ALT + H`), `⌫`, arrows, F-keys, …
+- **Mouse everything** — buttons/clicks (optionally with coordinates), **mouse movement** (a dot-in-a-ring widget, works on Wayland too) and **scroll wheel**
+- **Gamepad / controller** — buttons as tokens, plus a live widget for the analog sticks and triggers (XInput / DualShock / DualSense-style controllers, all platforms)
+- **OBS browser source** — serve the whole overlay as a transparent web page at `http://localhost:/overlay`; add it as a Browser source and it's captured live, or hide it from your own screen and show it **only** in OBS
- **Held keys don't spam** — they show a counter: `a (x13)`
-- Display **key** & **mouse** clicks (optionally with coordinates)
- Highly customizable popups (size, opacity, colors, font, corner radius)
-- Any screen corner + pixel offsets, on **any monitor**
+- Any screen corner + pixel offsets, on **any monitor** — or **drag the overlay to any position** with a temporary click-through toggle
- Smooth fade-out transition
- **Settings GUI** — configure everything at runtime, applies live
- **Global hotkey** to toggle capturing (default `Ctrl+Alt+Y`)
-- Tray icon: toggle capturing, open settings, quit
+- Tray icon: toggle capturing, move the overlay, open settings, quit
- **Text-to-speech** — hear each keystroke spoken aloud; adds an audible layer that's handy for accessibility, screencasts, tutorials and language practice
- **Process filter**: only capture while selected apps are focused
-## YAKC vs. other keystroke visualizers
-
-Most keycasters only run on **one** operating system. YAKC is the one that works **everywhere** — including **Wayland**, where most Linux options still fall short.
-
-| | **YAKC** | KeyCastr | Carnac | screenkey | showmethekey |
-|---|:---:|:---:|:---:|:---:|:---:|
-| **Windows** | ✅ | — | ✅ | — | — |
-| **macOS** (Intel + Apple Silicon) | ✅ | ✅ | — | — | — |
-| **Linux · X11** | ✅ | — | — | ✅ | ✅ |
-| **Linux · Wayland** | ✅ | — | — | ⚠️ limited | ✅ |
-| **Mouse clicks** | ✅ | — | — | — | ✅ |
-| **Any keyboard language, automatic** | ✅ | ✅ | — | ⚠️ | ✅ |
-| **Editor-style "text" mode** (Backspace deletes) | ✅ | — | — | — | — |
-| **Text-to-speech** | ✅ | — | — | — | — |
-| **Runtime settings GUI** | ✅ | ✅ | ✅ | — | ✅ |
-| **Open source** | ✅ MIT | ✅ | ⚠️ inactive | ✅ | ✅ |
-
-Reflects each project's primary, out-of-the-box support; details vary by version and community forks. Corrections welcome via PR.
-
-**In short:** if you want a single keystroke display that looks and behaves the same on Windows, macOS **and** Linux (X11 and Wayland), in any language, with mouse clicks and a live settings UI, YAKC is currently the most complete choice.
+## YAKC vs. other input visualizers
+
+Most keycasters only run on **one** operating system, and the streaming-oriented ones (input-overlay) are effectively Windows-first. YAKC is the one that works **everywhere** — including **Wayland**, where most Linux options still fall short — and covers keyboard, mouse **and** gamepad in a single tool.
+
+| | **YAKC** | KeyCastr | Carnac | screenkey | showmethekey | input‑overlay |
+|---|:---:|:---:|:---:|:---:|:---:|:---:|
+| **Windows** | ✅ | — | ✅ | — | — | ✅ |
+| **macOS** (Intel + Apple Silicon) | ✅ | ✅ | — | — | — | ⚠️ |
+| **Linux · X11** | ✅ | — | — | ✅ | ✅ | ⚠️ |
+| **Linux · Wayland** | ✅ | — | — | ⚠️ limited | ✅ | — |
+| **Mouse clicks** | ✅ | — | — | — | ✅ | ✅ |
+| **Mouse movement + scroll** | ✅ | — | — | — | — | ✅ |
+| **Gamepad / controller** | ✅ | — | — | — | — | ✅ |
+| **On-screen keyboard / device skins** | ✅ | — | — | — | — | ✅ |
+| **OBS browser source (transparent)** | ✅ | — | — | — | — | ✅ |
+| **Drag-to-position overlay** | ✅ | ✅ | — | — | — | ✅ |
+| **Any keyboard language, automatic** | ✅ | ✅ | — | ⚠️ | ✅ | ⚠️ |
+| **Editor-style "text" mode** (Backspace deletes) | ✅ | — | — | — | — | — |
+| **Text-to-speech** | ✅ | — | — | — | — | — |
+| **Runtime settings GUI** | ✅ | ✅ | ✅ | — | ✅ | ✅ |
+| **Works standalone (no OBS required)** | ✅ | ✅ | ✅ | ✅ | ✅ | — |
+| **Open source** | ✅ MIT | ✅ | ⚠️ inactive | ✅ | ✅ | ✅ |
+
+Reflects each project's primary, out-of-the-box support; details vary by version and community forks. input-overlay is an OBS Studio plugin (needs OBS) whose full input capture is best-supported on Windows. Corrections welcome via PR.
+
+**In short:** if you want a single input display — keyboard, mouse (clicks, movement, scroll) and gamepad — that looks and behaves the same on Windows, macOS **and** Linux (X11 and Wayland), in any language, standalone **or** as an OBS browser source, YAKC is currently the most complete choice.
## How it works per platform
| | Windows | macOS | Linux (X11) | Linux (Wayland) |
|---|---|---|---|---|
| Key/mouse capture | `WH_KEYBOARD_LL` hook | `CGEventTap` | `/dev/input` (evdev) | `/dev/input` (evdev) |
+| Gamepad capture | XInput | IOKit / GameController | evdev | evdev |
| Layout translation | OS (`ToUnicode`) | OS (`UCKeyTranslate`) | xkbcommon | xkbcommon, keymap fetched **from the compositor** — exactly the layout you configured in your desktop settings |
| Overlay | native | native (above fullscreen apps) | native | via XWayland¹ |
| Text-to-speech | SAPI (built in) | AVSpeechSynthesizer (built in) | speech-dispatcher (offered automatically²) | speech-dispatcher (offered automatically²) |
@@ -142,6 +154,8 @@ Settings live in `config.json` — edit them via the Settings window or by hand:
| Key | Description |
|---|---|
+| `displayStyle` | `popups` (default): fading key/click tokens. `keyboard`: an on-screen virtual keyboard that lights up as you type. |
+| `keyboardVisibleKeys` | `keyboard` style only: list of physical keys (W3C codes, e.g. `KeyW`, `Space`) to show. Empty = the whole keyboard. Set it visually via Settings → Input → *Pick keys…*. |
| `displayMode` | `text` (default): like a text editor — only typed characters, Backspace deletes, shortcuts hidden. `raw`: every key including modifiers and symbols. |
| `keyboardLayout` | **Linux only**: xkb layout override (`us`, `de`, `tr`, …). Empty = auto-detect from the compositor/session. Legacy values `english`/`german` still work. Other platforms always use the OS layout. |
| `showOnMonitor` | Monitor index to display popups on (0 = first) |
@@ -154,13 +168,18 @@ Settings live in `config.json` — edit them via the Settings window or by hand:
| `popupFontColor` / `popupBackgroundColor` | Popup colors |
| `popupBorderRadius` | Corner radius (`0` = sharp) |
| `showKeyboardClick` / `showMouseClick` / `showMouseCoordinates` | What to display (mouse coordinates are unavailable on Wayland and hidden there) |
+| `showMouseMovement` / `showMouseScroll` | Show mouse movement (dot-in-a-ring widget) and scroll-wheel ticks |
+| `showGamepad` | Show gamepad buttons as tokens plus a stick/trigger widget |
+| `mouseMovementSensitivity` / `mouseMovementDecaySeconds` / `deviceWidgetScale` | Tune the mouse-movement widget (travel per pixel, spring-back time) and the mouse/gamepad widget size |
+| `obsServerEnabled` / `obsServerPort` | Serve the overlay for OBS at `http://localhost:/overlay` (default port `7238`). Enabling starts it immediately; changing the port needs a restart. |
+| `showOverlayOnScreen` | Turn off to display only in the OBS browser source (so it isn't captured twice or seen locally) |
| `onlyKeysWithModifiers` | Only show keys pressed together with Ctrl/Alt/Meta |
| `showSpaceAsUnicode` | Show space as `␣` |
| `textToSymbols` | Special keys as symbols (Tab → `↹`, Backspace → `⌫`, …) |
| `textToSpeech` / `textToSpeechCancelSpeechOnNewKey` | Speak keystrokes aloud |
| `position` | `top-left`, `top-right`, `bottom-left`, `bottom-right` |
| `topOffset` / `bottomOffset` / `leftOffset` / `rightOffset` | Pixel offsets from the anchored corner |
-| `filter` / `filterProcessName` / `filterCheckEverySecond` | Capture only while listed processes are focused |
+| `filter` / `filterProcessName` | Capture only while a listed app is focused (pick from running apps in Settings). Works on Windows, macOS, Linux X11, and Linux Wayland on KDE. |
| `toggleCaptureHotkey` | Global capture toggle, e.g. `Ctrl+Alt+Y` (needs ≥ 1 modifier) |
| `keyLabelOverrides` | Override display text for any key. Key = internal key id (`backspace`, `f1`, `meta`, `ctrl`, …), value = custom text. Example: `{"meta": "MOD"}` shows `MOD` instead of `META` in combos. |
@@ -182,7 +201,16 @@ YAKC. KeyCastr is macOS-only, Carnac is Windows-only, and screenkey is Linux/X11
Yes — mouse buttons (and optional coordinates) alongside your keystrokes.
**Does it work with OBS, for streaming and screen recording?**
-Yes. YAKC draws a transparent, click-through, always-on-top overlay that OBS, other capture tools and screen recorders pick up like any other on-screen content.
+Yes, two ways. YAKC draws a transparent, click-through, always-on-top overlay that OBS, other capture tools and screen recorders pick up like any other on-screen content — **or** you can enable its built-in **browser source**: point an OBS *Browser* source at `http://localhost:/overlay` for a transparent, live feed of your keys, clicks, mouse movement and controller. You can even hide the overlay from your own screen and show it only in OBS.
+
+**Does it show gamepad / controller input?**
+Yes. YAKC displays controller buttons as tokens and draws a live widget for the analog sticks and triggers, for XInput / DualShock / DualSense-style controllers — on Windows, macOS and Linux.
+
+**What's a good input-overlay alternative?**
+YAKC. The popular `input-overlay` OBS plugin is effectively Windows-first and requires OBS. YAKC gives you keyboard, mouse (clicks, movement and scroll) and gamepad visuals on Windows, macOS **and** Linux (X11 and Wayland), works standalone **or** as an OBS browser source, and needs no plugin install.
+
+**Does it show mouse movement and scrolling, not just clicks?**
+Yes — a dot-in-a-ring widget reacts to how you move the mouse (works on Wayland too), and scroll-wheel ticks show alongside your keys.
**Does it support my keyboard language or layout?**
Yes, automatically. Characters come straight from your operating system (or, on Wayland, your compositor), so QWERTY, QWERTZ, AZERTY, Turkish, Cyrillic, Greek and more all work with zero configuration.
@@ -201,7 +229,13 @@ Yes. It's MIT-licensed, fully offline, and never stores or transmits anything yo
- [x] GUI to easily configure at runtime
- [x] Add hotkey for start/stop listening to keystrokes
- [x] Add unit tests
-- [ ] Drag and drop the popup to the desired position (needs a temporary non-click-through mode)
+- [x] Drag and drop the popup to the desired position *(temporary non-click-through move mode)*
+- [x] Mouse movement + scroll visualizer
+- [x] Gamepad / controller visualizer (buttons, sticks, triggers)
+- [x] On-screen virtual keyboard display style *(with a visual "pick which keys to show" selector)*
+- [x] OBS browser-source output *(transparent overlay over HTTP)*
+- [ ] Presets, themes & profiles (named config snapshots)
+- [ ] Settings pickers — live process list for the filter, font & monitor dropdowns
## Buy me a coffee
@@ -225,4 +259,4 @@ This project is licensed under the `MIT` License. See the [LICENSE](LICENSE) fil
---
-Also known as / related searches: keystroke visualizer · key caster · keycast · on-screen keyboard display · show keys on screen · keystroke overlay · keypress display · mouse click visualizer · screencast keystrokes · OBS keystroke display · streaming key overlay · presentation key display · Wayland keystroke visualizer · cross-platform keycaster · KeyCastr alternative · Carnac alternative · screenkey alternative · showmethekey alternative · keystroke display for Windows, macOS and Linux.
+Also known as / related searches: keystroke visualizer · input visualizer · key caster · keycast · on-screen keyboard display · virtual keyboard overlay · show keys on screen · keystroke overlay · keypress display · mouse click visualizer · mouse movement overlay · scroll wheel overlay · gamepad overlay · controller input display · controller overlay for streaming · screencast keystrokes · OBS keystroke display · OBS input overlay · OBS browser source key overlay · streaming key overlay · gaming input overlay · presentation key display · Wayland keystroke visualizer · cross-platform keycaster · KeyCastr alternative · Carnac alternative · screenkey alternative · showmethekey alternative · input-overlay alternative · keystroke, mouse and gamepad display for Windows, macOS and Linux.
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 3cd0941..21772ca 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -109,6 +109,17 @@ dependencies = [
"slab",
]
+[[package]]
+name = "async-fs"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
[[package]]
name = "async-io"
version = "2.6.0"
@@ -1170,7 +1181,7 @@ dependencies = [
"bitvec",
"cfg-if",
"libc",
- "nix",
+ "nix 0.29.0",
]
[[package]]
@@ -1531,6 +1542,40 @@ dependencies = [
"r-efi 6.0.0",
]
+[[package]]
+name = "gilrs"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8"
+dependencies = [
+ "fnv",
+ "gilrs-core",
+ "log",
+ "uuid",
+ "vec_map",
+]
+
+[[package]]
+name = "gilrs-core"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9"
+dependencies = [
+ "inotify",
+ "js-sys",
+ "libc",
+ "libudev-sys",
+ "log",
+ "nix 0.31.3",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "uuid",
+ "vec_map",
+ "wasm-bindgen",
+ "web-sys",
+ "windows 0.61.3",
+]
+
[[package]]
name = "gio"
version = "0.18.4"
@@ -2004,6 +2049,26 @@ dependencies = [
"cfb",
]
+[[package]]
+name = "inotify"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
+dependencies = [
+ "bitflags 2.13.0",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2216,6 +2281,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "libudev-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -2398,6 +2473,19 @@ name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
@@ -2415,6 +2503,15 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2590,6 +2687,17 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+ "objc2-core-foundation",
+]
+
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -2908,6 +3016,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -3046,6 +3163,36 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3501,6 +3648,17 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3642,6 +3800,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -3724,6 +3888,19 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "sysinfo"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
+dependencies = [
+ "core-foundation-sys 0.8.7",
+ "libc",
+ "memchr",
+ "ntapi",
+ "windows 0.57.0",
+]
+
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3985,7 +4162,7 @@ dependencies = [
"tokio",
"tracing",
"windows-sys 0.60.2",
- "zbus",
+ "zbus 5.17.0",
]
[[package]]
@@ -4598,6 +4775,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "vec_map"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
+
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4946,6 +5129,16 @@ dependencies = [
"windows-targets 0.48.5",
]
+[[package]]
+name = "windows"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+dependencies = [
+ "windows-core 0.57.0",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows"
version = "0.58.0"
@@ -4978,6 +5171,18 @@ dependencies = [
"windows-core 0.61.2",
]
+[[package]]
+name = "windows-core"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+dependencies = [
+ "windows-implement 0.57.0",
+ "windows-interface 0.57.0",
+ "windows-result 0.1.2",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-core"
version = "0.58.0"
@@ -5028,6 +5233,17 @@ dependencies = [
"windows-threading",
]
+[[package]]
+name = "windows-implement"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "windows-implement"
version = "0.58.0"
@@ -5050,6 +5266,17 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "windows-interface"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "windows-interface"
version = "0.58.0"
@@ -5094,6 +5321,15 @@ dependencies = [
"windows-link 0.1.3",
]
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-result"
version = "0.2.0"
@@ -5158,6 +5394,15 @@ dependencies = [
"windows-targets 0.42.2",
]
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5577,6 +5822,16 @@ dependencies = [
"quick-xml 0.30.0",
]
+[[package]]
+name = "xdg-home"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "xkbcommon"
version = "0.8.0"
@@ -5596,22 +5851,27 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yakc"
-version = "2.0.0"
+version = "2.8.0"
dependencies = [
"active-win-pos-rs",
+ "core-foundation 0.10.1",
"evdev",
+ "gilrs",
"libc",
"objc2",
"rdev",
"serde",
"serde_json",
+ "sysinfo",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-single-instance",
"tts",
"wayland-client",
+ "windows 0.58.0",
"xkbcommon",
+ "zbus 4.4.0",
]
[[package]]
@@ -5637,6 +5897,44 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-fs",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "hex",
+ "nix 0.29.0",
+ "ordered-stream",
+ "rand",
+ "serde",
+ "serde_repr",
+ "sha1",
+ "static_assertions",
+ "tracing",
+ "uds_windows",
+ "windows-sys 0.52.0",
+ "xdg-home",
+ "zbus_macros 4.4.0",
+ "zbus_names 3.0.0",
+ "zvariant 4.2.0",
+]
+
[[package]]
name = "zbus"
version = "5.17.0"
@@ -5667,9 +5965,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
- "zbus_macros",
- "zbus_names",
- "zvariant",
+ "zbus_macros 5.17.0",
+ "zbus_names 4.3.3",
+ "zvariant 5.13.0",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zvariant_utils 2.1.0",
]
[[package]]
@@ -5682,9 +5993,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
- "zbus_names",
- "zvariant",
- "zvariant_utils",
+ "zbus_names 4.3.3",
+ "zvariant 5.13.0",
+ "zvariant_utils 3.5.0",
+]
+
+[[package]]
+name = "zbus_names"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
+dependencies = [
+ "serde",
+ "static_assertions",
+ "zvariant 4.2.0",
]
[[package]]
@@ -5695,7 +6017,27 @@ checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
dependencies = [
"serde",
"winnow 1.0.4",
- "zvariant",
+ "zvariant 5.13.0",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
@@ -5758,6 +6100,19 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zvariant"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zvariant_derive 4.2.0",
+]
+
[[package]]
name = "zvariant"
version = "5.13.0"
@@ -5768,8 +6123,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 1.0.4",
- "zvariant_derive",
- "zvariant_utils",
+ "zvariant_derive 5.13.0",
+ "zvariant_utils 3.5.0",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zvariant_utils 2.1.0",
]
[[package]]
@@ -5782,7 +6150,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
- "zvariant_utils",
+ "zvariant_utils 3.5.0",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index e59bbce..27eb387 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "yakc"
-version = "2.0.0"
+version = "2.8.0"
description = "YAKC - Yet Another Key Caster"
authors = ["IAMMODEV"]
license = "MIT"
@@ -18,25 +18,45 @@ tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2"
tts = "0.26"
active-win-pos-rs = "0.9"
+# Cross-platform gamepad/controller input (XInput on Windows, IOKit/GameController
+# on macOS, evdev on Linux — works on X11 and Wayland).
+gilrs = "0.11"
+# Cross-platform process enumeration for the "pick from running apps" process
+# filter (no per-OS code; works on Windows, macOS and Linux).
+sysinfo = { version = "0.33", default-features = false, features = ["system"] }
# Windows & macOS: global hook via the OS APIs (WH_KEYBOARD_LL / CGEventTap),
# with OS-native key-to-character translation for any keyboard language.
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
rdev = "0.5"
+# Windows: read the active keyboard layout (ToUnicodeEx) to label the on-screen
+# keyboard for the user's real layout.
+[target.'cfg(target_os = "windows")'.dependencies]
+windows = { version = "0.58", features = [
+ "Win32_Foundation",
+ "Win32_UI_Input_KeyboardAndMouse",
+] }
+
# Linux: read /dev/input directly so capture works identically on X11 and
# Wayland; xkbcommon translates keycodes per the active layout.
[target.'cfg(target_os = "linux")'.dependencies]
evdev = "0.13"
xkbcommon = "0.8"
+# Read the focused window on Wayland (which hides it from X11 tools) via the
+# compositor's D-Bus interface — used for the process filter on KDE/KWin.
+zbus = "4"
# Fetch the compositor's exact keymap (wl_keyboard) so any layout the user
# configured in their desktop settings is honored on Wayland.
wayland-client = "0.31"
libc = "0.2"
-# macOS: raise the overlay to screen-saver window level (above fullscreen apps).
+# macOS: raise the overlay to screen-saver window level (above fullscreen apps);
+# read the active keyboard layout (UCKeyTranslate/TIS) to label the on-screen
+# keyboard for the user's real layout.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
+core-foundation = "0.10"
[profile.release]
strip = true
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index 8cf33d5..baeb94a 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -7,6 +7,15 @@ use tauri::{AppHandle, Manager};
pub type SharedConfig = Arc>;
+/// A widget's on-screen top-left position in pixels.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct WidgetPos {
+ #[serde(deserialize_with = "lenient_f64")]
+ pub x: f64,
+ #[serde(deserialize_with = "lenient_f64")]
+ pub y: f64,
+}
+
/// Runtime configuration. Field names serialize to the same camelCase keys the
/// Electron version used, so existing config.json files keep working.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -38,6 +47,24 @@ pub struct Config {
pub show_keyboard_click: bool,
pub show_mouse_click: bool,
pub show_mouse_coordinates: bool,
+ /// Show a dot-in-a-ring widget that reacts to mouse movement.
+ pub show_mouse_movement: bool,
+ /// Show scroll-wheel ticks as popup tokens (⤒/⤓/⇤/⇥).
+ pub show_mouse_scroll: bool,
+ /// Show gamepad/controller input: buttons as popups, sticks/triggers in a widget.
+ pub show_gamepad: bool,
+ /// How far the mouse-movement dot travels per pixel moved (higher = more sensitive).
+ #[serde(deserialize_with = "lenient_f64")]
+ pub mouse_movement_sensitivity: f64,
+ /// Seconds for the mouse-movement dot to spring back to center once still.
+ #[serde(deserialize_with = "lenient_f64")]
+ pub mouse_movement_decay_seconds: f64,
+ /// Overall scale of the mouse/gamepad widgets (1.0 = default size).
+ #[serde(deserialize_with = "lenient_f64")]
+ pub device_widget_scale: f64,
+ /// Show the overlay on this screen. Turn off to display only in the OBS
+ /// browser source (so it isn't captured twice or seen by you locally).
+ pub show_overlay_on_screen: bool,
pub only_keys_with_modifiers: bool,
pub show_space_as_unicode: bool,
pub text_to_symbols: bool,
@@ -61,10 +88,31 @@ pub struct Config {
/// "text": popups behave like a text editor — only typed characters show,
/// Backspace deletes. "raw": every key shows (modifiers, ⌫, arrows, …).
pub display_mode: String,
+ /// Overlay rendering style. "popups": fading key popups (default).
+ /// "keyboard": an on-screen keyboard whose caps light up as you type.
+ pub display_style: String,
+ /// "keyboard" style only: which physical keys (W3C codes, e.g. "KeyA",
+ /// "Space") to render. Empty = show the whole keyboard. Lets users trim it
+ /// down to just the keys they care about (e.g. WASD + a few binds).
+ pub keyboard_visible_keys: Vec,
/// Override display text for any key. Key = internal key id
/// ("backspace", "f1", "meta", "ctrl", …), value = custom display text.
/// E.g. {"meta": "MOD"} shows "MOD" instead of "META" in combos.
pub key_label_overrides: HashMap,
+ /// Independent top-left positions (px) per overlay widget, keyed by id
+ /// ("mouse", "gamepad"). Absent = the widget's default placement. Set by
+ /// drag-to-position so each widget can live anywhere without overlapping.
+ pub widget_positions: HashMap,
+ /// Show alignment guide lines and snap widgets to each other / the screen
+ /// center while dragging in move mode. On by default; toggled from the move
+ /// toolbar.
+ pub snap_to_guides: bool,
+ /// Serve the overlay over HTTP for use as an OBS Browser source. Opt-in;
+ /// changing this needs an app restart to start/stop the server.
+ pub obs_server_enabled: bool,
+ /// Port the OBS browser-source server listens on (localhost).
+ #[serde(deserialize_with = "lenient_u16")]
+ pub obs_server_port: u16,
}
impl Config {
@@ -92,6 +140,13 @@ impl Default for Config {
show_keyboard_click: true,
show_mouse_click: false,
show_mouse_coordinates: false,
+ show_mouse_movement: false,
+ show_mouse_scroll: false,
+ show_gamepad: false,
+ mouse_movement_sensitivity: 1.0,
+ mouse_movement_decay_seconds: 0.4,
+ device_widget_scale: 1.0,
+ show_overlay_on_screen: true,
only_keys_with_modifiers: false,
show_space_as_unicode: false,
text_to_symbols: true,
@@ -107,7 +162,13 @@ impl Default for Config {
filter_check_every_second: 0.5,
toggle_capture_hotkey: "Ctrl+Alt+Y".into(),
display_mode: "text".into(),
+ display_style: "popups".into(),
+ keyboard_visible_keys: Vec::new(),
key_label_overrides: HashMap::new(),
+ widget_positions: HashMap::new(),
+ snap_to_guides: true,
+ obs_server_enabled: false,
+ obs_server_port: 7238,
}
}
}
@@ -131,6 +192,10 @@ fn lenient_usize<'de, D: Deserializer<'de>>(deserializer: D) -> Result>(deserializer: D) -> Result {
+ Ok(lenient_f64(deserializer)?.clamp(0.0, 65535.0) as u16)
+}
+
/// Path of the active config file: a config.json next to the executable wins
/// (portable installs and the old Electron layout), otherwise the platform
/// config directory.
diff --git a/src-tauri/src/filter.rs b/src-tauri/src/filter.rs
index 41d03eb..50b3e9b 100644
--- a/src-tauri/src/filter.rs
+++ b/src-tauri/src/filter.rs
@@ -1,16 +1,73 @@
//! Process filter: when enabled, capture runs only while one of the configured
-//! processes owns the focused window. Port of the Electron checkActiveProcess().
+//! processes owns the focused window.
+//!
+//! Two ways focus reaches us: on Wayland the compositor *pushes* focus changes
+//! (via [`Engine::set_active`]), so evaluation is fully event-driven — no
+//! polling, no delay. Elsewhere (X11/Windows/macOS) there's no focus event, so a
+//! background thread polls the focused window on an interval.
use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::config::SharedConfig;
-pub fn spawn(config: SharedConfig, capturing: Arc) {
- let mut was_enabled = false;
- std::thread::spawn(move || loop {
- let (enabled, filters, interval) = match config.read() {
+/// Poll cadence where focus changes aren't pushed to us (X11/Windows/macOS).
+/// Tight enough to feel instant; cheap because a disabled filter skips the
+/// focus query entirely, so we only do real work while gating is active.
+const POLL_INTERVAL: Duration = Duration::from_millis(100);
+
+/// Shared holder for the focused window's class as reported by a push-based
+/// source (e.g. the KDE Wayland watcher). `None` = not yet known.
+pub type ActiveApp = Arc>>;
+
+/// Evaluates the process filter and drives the shared `capturing` flag. Shared
+/// (via `Arc`) between the push source (KWin), the settings-save path, and — on
+/// non-Wayland — the poll thread, so any of them can trigger a re-evaluation.
+pub struct Engine {
+ config: SharedConfig,
+ capturing: Arc,
+ /// Whether *we* (the filter) are the reason capture is off, so we can always
+ /// restore it when we stop gating — regardless of how we got here.
+ forced_off: AtomicBool,
+ /// Push-based focus source (Wayland). `None` on X11/Windows/macOS, where the
+ /// poll thread queries the focused window instead.
+ wayland: Option,
+}
+
+impl Engine {
+ pub fn new(
+ config: SharedConfig,
+ capturing: Arc,
+ wayland: Option,
+ ) -> Arc {
+ Arc::new(Self {
+ config,
+ capturing,
+ forced_off: AtomicBool::new(false),
+ wayland,
+ })
+ }
+
+ /// True when a push source is present, so no polling is needed.
+ pub fn is_push(&self) -> bool {
+ self.wayland.is_some()
+ }
+
+ /// Records a newly-focused window class (Wayland push) and re-evaluates now.
+ pub fn set_active(&self, class: String) {
+ if let Some(active) = &self.wayland {
+ if let Ok(mut guard) = active.lock() {
+ *guard = Some(class);
+ }
+ }
+ self.reevaluate();
+ }
+
+ /// Restore capture if the filter isn't gating, otherwise gate on the current
+ /// focus. Cheap and idempotent — safe to call from any trigger.
+ pub fn reevaluate(&self) {
+ let (enabled, filters) = match self.config.read() {
Ok(cfg) => (
cfg.filter,
cfg.filter_process_name
@@ -18,34 +75,131 @@ pub fn spawn(config: SharedConfig, capturing: Arc) {
.map(|name| name.trim().to_lowercase())
.filter(|name| !name.is_empty())
.collect::>(),
- cfg.filter_check_every_second.max(0.1),
),
- Err(_) => (false, Vec::new(), 1.0),
+ Err(_) => (false, Vec::new()),
};
- // Turning the filter off must not leave capture stuck in whatever
- // state the last focus check chose.
- if was_enabled && !enabled {
- capturing.store(true, Ordering::Relaxed);
+ if !enabled || filters.is_empty() {
+ self.restore();
+ return;
}
- was_enabled = enabled;
-
- if enabled && !filters.is_empty() {
- if let Ok(window) = active_win_pos_rs::get_active_window() {
- let process_name = window
- .process_path
- .file_name()
- .map(|name| name.to_string_lossy().to_lowercase())
- .unwrap_or_default();
- let app_name = window.app_name.to_lowercase();
-
+ match focused_names(&self.wayland) {
+ Some(names) if names.iter().any(|n| !n.is_empty()) => {
let matches = filters
.iter()
- .any(|filter| filter == &process_name || filter == &app_name);
- capturing.store(matches, Ordering::Relaxed);
+ .any(|filter| names.iter().any(|name| name_matches(filter, name)));
+ self.capturing.store(matches, Ordering::Relaxed);
+ self.forced_off.store(!matches, Ordering::Relaxed);
}
+ // Focus unknown (not reported yet, error, or unreadable): never hold
+ // capture hostage — fail open.
+ _ => self.restore(),
}
+ }
+
+ /// Undo any suppression we caused, without touching a manual (hotkey/tray) pause.
+ fn restore(&self) {
+ if self.forced_off.swap(false, Ordering::Relaxed) {
+ self.capturing.store(true, Ordering::Relaxed);
+ }
+ }
+}
- std::thread::sleep(Duration::from_secs_f64(interval));
+/// Starts the filter. On Wayland it's driven by pushes (KWin) + settings saves,
+/// so no thread is spawned; elsewhere a thread polls the focused window at a
+/// snappy fixed cadence (see [`POLL_INTERVAL`]).
+pub fn spawn(engine: Arc) {
+ if engine.is_push() {
+ engine.reevaluate(); // apply the initial state
+ return;
+ }
+ std::thread::spawn(move || loop {
+ engine.reevaluate();
+ std::thread::sleep(POLL_INTERVAL);
});
}
+
+/// Lowercased candidate names for the currently-focused window, or `None` when
+/// the focus can't be determined. On Wayland this is the compositor-reported
+/// class; otherwise it's the focused window's process file name and app name.
+fn focused_names(wayland: &Option) -> Option> {
+ if let Some(active) = wayland {
+ return active
+ .lock()
+ .ok()
+ .and_then(|guard| guard.clone())
+ .map(|class| vec![class.to_lowercase()]);
+ }
+ // `get_active_window` can panic on some platforms/compositors; a panic here
+ // would kill the thread and strand capture off forever, so contain it.
+ match std::panic::catch_unwind(active_win_pos_rs::get_active_window) {
+ Ok(Ok(window)) => {
+ let process_name = window
+ .process_path
+ .file_name()
+ .map(|name| name.to_string_lossy().to_lowercase())
+ .unwrap_or_default();
+ let app_name = window.app_name.to_lowercase();
+ Some(vec![process_name, app_name])
+ }
+ _ => None,
+ }
+}
+
+/// Drops a trailing `.exe`/`.app` so `chrome` and `chrome.exe` compare equal.
+/// Only these known suffixes — not any dot — so reverse-DNS Wayland classes like
+/// `org.kde.konsole` stay intact (and match `konsole` via containment).
+fn strip_ext(name: &str) -> &str {
+ name.strip_suffix(".exe")
+ .or_else(|| name.strip_suffix(".app"))
+ .unwrap_or(name)
+}
+
+/// Lenient, case-insensitive match between a configured filter name and an
+/// actual window's process/app name. The name a user picks from the running-app
+/// list (via `sysinfo`) doesn't always match what the focus API reports — it may
+/// carry a `.exe`, be `google-chrome` vs `chrome`, or be truncated — so besides
+/// an exact match we accept either name containing the other (guarded by length
+/// so short names don't over-match). Both inputs are already lowercased.
+fn name_matches(filter: &str, actual: &str) -> bool {
+ let f = strip_ext(filter.trim());
+ let a = strip_ext(actual.trim());
+ if f.is_empty() || a.is_empty() {
+ return false;
+ }
+ f == a || (f.len() >= 3 && a.len() >= 3 && (a.contains(f) || f.contains(a)))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::name_matches;
+
+ #[test]
+ fn matches_exact_and_extension() {
+ assert!(name_matches("code", "code"));
+ assert!(name_matches("chrome", "chrome.exe"));
+ assert!(name_matches("obs.exe", "obs"));
+ }
+
+ #[test]
+ fn matches_differing_report_styles() {
+ // sysinfo vs focus API can disagree on the exact string.
+ assert!(name_matches("chrome", "google-chrome"));
+ assert!(name_matches("code", "code - insiders")); // app_name style
+ }
+
+ #[test]
+ fn matches_wayland_reverse_dns_class() {
+ // KWin reports resourceClass like "org.kde.konsole"; the user picks
+ // "konsole" from the process list — these must still match.
+ assert!(name_matches("konsole", "org.kde.konsole"));
+ assert!(name_matches("dolphin", "org.kde.dolphin"));
+ }
+
+ #[test]
+ fn rejects_unrelated_and_too_short() {
+ assert!(!name_matches("code", "firefox"));
+ assert!(!name_matches("x", "xterm")); // too short to contain-match
+ assert!(!name_matches("", "anything"));
+ }
+}
diff --git a/src-tauri/src/input/evdev_backend.rs b/src-tauri/src/input/evdev_backend.rs
index 593de99..e745841 100644
--- a/src-tauri/src/input/evdev_backend.rs
+++ b/src-tauri/src/input/evdev_backend.rs
@@ -19,6 +19,14 @@ use super::{BackendIssue, Mods, RawInput};
const RESCAN_INTERVAL: Duration = Duration::from_secs(3);
+/// Raw event forwarded from a device reader thread to the translator thread.
+/// Keys need xkb translation (which owns non-Send state); relative-axis events
+/// (mouse movement / scroll) are forwarded straight through.
+enum RawEvent {
+ Key(evdev::KeyCode, i32),
+ Rel(evdev::RelativeAxisCode, i32),
+}
+
pub fn spawn_listener(
tx: Sender,
layout_override: Option,
@@ -26,7 +34,7 @@ pub fn spawn_listener(
) {
// xkb::State is not Send, so device reader threads forward raw
// (keycode, value) pairs to one translator thread that owns the state.
- let (raw_tx, raw_rx) = std::sync::mpsc::channel::<(evdev::KeyCode, i32)>();
+ let (raw_tx, raw_rx) = std::sync::mpsc::channel::();
std::thread::spawn({
let layout = layout_override.clone();
@@ -39,8 +47,11 @@ pub fn spawn_listener(
return;
}
};
- for (code, value) in raw_rx {
- handle_key_event(code, value, &tx, &mut state);
+ for event in raw_rx {
+ match event {
+ RawEvent::Key(code, value) => handle_key_event(code, value, &tx, &mut state),
+ RawEvent::Rel(axis, value) => handle_rel_event(axis, value, &tx),
+ }
}
}
});
@@ -106,7 +117,7 @@ fn is_mouse(device: &Device) -> bool {
fn spawn_device_reader(
path: PathBuf,
mut device: Device,
- raw_tx: Sender<(evdev::KeyCode, i32)>,
+ raw_tx: Sender,
open_paths: Arc>>,
) {
std::thread::spawn(move || {
@@ -116,8 +127,14 @@ fn spawn_device_reader(
Err(_) => break, // device unplugged or read error: drop the reader
};
for event in events {
- if let evdev::EventSummary::Key(_, code, value) = event.destructure() {
- let _ = raw_tx.send((code, value));
+ match event.destructure() {
+ evdev::EventSummary::Key(_, code, value) => {
+ let _ = raw_tx.send(RawEvent::Key(code, value));
+ }
+ evdev::EventSummary::RelativeAxis(_, axis, value) => {
+ let _ = raw_tx.send(RawEvent::Rel(axis, value));
+ }
+ _ => {}
}
}
}
@@ -147,6 +164,12 @@ fn handle_key_event(
// press (1) or autorepeat (2); repeats show popups like the original
if is_modifier(code) {
if value == 1 {
+ if let Some(mcode) = modifier_code(code) {
+ let _ = tx.send(RawInput::Modifier {
+ code: mcode,
+ pressed: true,
+ });
+ }
state.update_key(keycode, xkb::KeyDirection::Down);
}
return;
@@ -183,18 +206,101 @@ fn handle_key_event(
let _ = tx.send(RawInput::Key {
text,
named,
+ code: code_for(code),
mods,
repeat: value == 2,
});
}
}
0 => {
+ if is_modifier(code) {
+ if let Some(mcode) = modifier_code(code) {
+ let _ = tx.send(RawInput::Modifier {
+ code: mcode,
+ pressed: false,
+ });
+ }
+ }
state.update_key(keycode, xkb::KeyDirection::Up);
}
_ => {}
}
}
+/// Physical modifier position for the on-screen keyboard.
+fn modifier_code(code: evdev::KeyCode) -> Option<&'static str> {
+ use evdev::KeyCode as K;
+ Some(match code {
+ K::KEY_LEFTCTRL => "ControlLeft",
+ K::KEY_RIGHTCTRL => "ControlRight",
+ K::KEY_LEFTALT => "AltLeft",
+ K::KEY_RIGHTALT => "AltRight",
+ K::KEY_LEFTSHIFT => "ShiftLeft",
+ K::KEY_RIGHTSHIFT => "ShiftRight",
+ K::KEY_LEFTMETA => "MetaLeft",
+ K::KEY_RIGHTMETA => "MetaRight",
+ _ => return None,
+ })
+}
+
+/// Physical key → W3C KeyboardEvent.code, for the on-screen keyboard's caps
+/// (which are addressed by physical position, so any layout lights up right).
+/// One table drives both `code_for` (live events) and `key_labels` (startup
+/// layout detection).
+const CODE_TABLE: &[(evdev::KeyCode, &str)] = {
+ use evdev::KeyCode as K;
+ &[
+ (K::KEY_A, "KeyA"), (K::KEY_B, "KeyB"), (K::KEY_C, "KeyC"), (K::KEY_D, "KeyD"),
+ (K::KEY_E, "KeyE"), (K::KEY_F, "KeyF"), (K::KEY_G, "KeyG"), (K::KEY_H, "KeyH"),
+ (K::KEY_I, "KeyI"), (K::KEY_J, "KeyJ"), (K::KEY_K, "KeyK"), (K::KEY_L, "KeyL"),
+ (K::KEY_M, "KeyM"), (K::KEY_N, "KeyN"), (K::KEY_O, "KeyO"), (K::KEY_P, "KeyP"),
+ (K::KEY_Q, "KeyQ"), (K::KEY_R, "KeyR"), (K::KEY_S, "KeyS"), (K::KEY_T, "KeyT"),
+ (K::KEY_U, "KeyU"), (K::KEY_V, "KeyV"), (K::KEY_W, "KeyW"), (K::KEY_X, "KeyX"),
+ (K::KEY_Y, "KeyY"), (K::KEY_Z, "KeyZ"),
+ (K::KEY_1, "Digit1"), (K::KEY_2, "Digit2"), (K::KEY_3, "Digit3"), (K::KEY_4, "Digit4"),
+ (K::KEY_5, "Digit5"), (K::KEY_6, "Digit6"), (K::KEY_7, "Digit7"), (K::KEY_8, "Digit8"),
+ (K::KEY_9, "Digit9"), (K::KEY_0, "Digit0"),
+ (K::KEY_MINUS, "Minus"), (K::KEY_EQUAL, "Equal"),
+ (K::KEY_LEFTBRACE, "BracketLeft"), (K::KEY_RIGHTBRACE, "BracketRight"),
+ (K::KEY_BACKSLASH, "Backslash"), (K::KEY_SEMICOLON, "Semicolon"),
+ (K::KEY_APOSTROPHE, "Quote"), (K::KEY_GRAVE, "Backquote"),
+ (K::KEY_COMMA, "Comma"), (K::KEY_DOT, "Period"), (K::KEY_SLASH, "Slash"),
+ (K::KEY_SPACE, "Space"), (K::KEY_ENTER, "Enter"), (K::KEY_TAB, "Tab"),
+ (K::KEY_BACKSPACE, "Backspace"), (K::KEY_CAPSLOCK, "CapsLock"), (K::KEY_ESC, "Escape"),
+ (K::KEY_UP, "ArrowUp"), (K::KEY_DOWN, "ArrowDown"),
+ (K::KEY_LEFT, "ArrowLeft"), (K::KEY_RIGHT, "ArrowRight"),
+ (K::KEY_F1, "F1"), (K::KEY_F2, "F2"), (K::KEY_F3, "F3"), (K::KEY_F4, "F4"),
+ (K::KEY_F5, "F5"), (K::KEY_F6, "F6"), (K::KEY_F7, "F7"), (K::KEY_F8, "F8"),
+ (K::KEY_F9, "F9"), (K::KEY_F10, "F10"), (K::KEY_F11, "F11"), (K::KEY_F12, "F12"),
+ ]
+};
+
+fn code_for(code: evdev::KeyCode) -> Option<&'static str> {
+ CODE_TABLE
+ .iter()
+ .find(|(key, _)| *key == code)
+ .map(|(_, w3c)| *w3c)
+}
+
+/// Base (unshifted) character for every keyboard cap in the active layout, keyed
+/// by W3C code — so the on-screen keyboard shows the user's real layout (QWERTZ,
+/// AZERTY, …) immediately, without waiting for keys to be pressed. Uses the same
+/// OS layout (xkbcommon) as live translation.
+pub fn key_labels(layout_override: Option<&str>) -> std::collections::HashMap {
+ let mut labels = std::collections::HashMap::new();
+ let Ok(state) = build_xkb_state(layout_override) else {
+ return labels;
+ };
+ for (code, w3c) in CODE_TABLE {
+ let keycode = xkb::Keycode::new(code.0 as u32 + 8);
+ let text = state.key_get_utf8(keycode);
+ if !text.is_empty() && !text.chars().all(char::is_control) {
+ labels.insert(w3c.to_string(), text);
+ }
+ }
+ labels
+}
+
/// Compiles an xkb keymap for the active layout.
/// Priority: config override → the compositor's own keymap via the Wayland
/// protocol (authoritative: exactly what the user configured in their desktop
@@ -311,6 +417,33 @@ fn parse_kv_layout(text: &str, layout_key: &str, variant_key: &str) -> Option<(S
(layout != "n/a").then_some((layout, variant))
}
+/// Forwards mouse movement (REL_X/REL_Y) and scroll (REL_WHEEL/REL_HWHEEL).
+/// Emitted per axis; the overlay accumulates them, so a separate X and Y event
+/// is fine. Works on X11 and Wayland alike (we read the device directly).
+fn handle_rel_event(axis: evdev::RelativeAxisCode, value: i32, tx: &Sender) {
+ let input = match axis {
+ evdev::RelativeAxisCode::REL_X => RawInput::MouseMotion {
+ dx: value as f64,
+ dy: 0.0,
+ },
+ evdev::RelativeAxisCode::REL_Y => RawInput::MouseMotion {
+ dx: 0.0,
+ dy: value as f64,
+ },
+ // REL_WHEEL is positive-up; keep that convention (dy>0 = up).
+ evdev::RelativeAxisCode::REL_WHEEL => RawInput::Scroll {
+ dx: 0.0,
+ dy: value as f64,
+ },
+ evdev::RelativeAxisCode::REL_HWHEEL => RawInput::Scroll {
+ dx: value as f64,
+ dy: 0.0,
+ },
+ _ => return,
+ };
+ let _ = tx.send(input);
+}
+
fn mouse_button(code: evdev::KeyCode) -> Option {
match code {
evdev::KeyCode::BTN_LEFT => Some(1),
diff --git a/src-tauri/src/input/gamepad.rs b/src-tauri/src/input/gamepad.rs
new file mode 100644
index 0000000..932477b
--- /dev/null
+++ b/src-tauri/src/input/gamepad.rs
@@ -0,0 +1,119 @@
+//! Cross-platform gamepad/controller capture via gilrs (XInput on Windows,
+//! IOKit/GameController on macOS, evdev on Linux — works on X11 and Wayland).
+//!
+//! Like the mouse-button path, this always runs and feeds the shared RawInput
+//! channel; the consumer thread decides whether to show anything based on the
+//! `show_gamepad` config flag.
+
+use std::sync::mpsc::Sender;
+use std::time::Duration;
+
+use gilrs::{Axis, Button, Gilrs};
+
+use super::RawInput;
+
+pub fn spawn_listener(tx: Sender) {
+ std::thread::spawn(move || {
+ let mut gilrs = match Gilrs::new() {
+ Ok(gilrs) => gilrs,
+ Err(err) => {
+ eprintln!("YAKC: gamepad support unavailable: {err}");
+ return;
+ }
+ };
+
+ // A controller may already be connected at startup.
+ if gilrs.gamepads().next().is_some() {
+ let _ = tx.send(RawInput::GamepadConnection { connected: true });
+ }
+
+ loop {
+ while let Some(event) = gilrs.next_event() {
+ match event.event {
+ gilrs::EventType::Connected => {
+ let _ = tx.send(RawInput::GamepadConnection { connected: true });
+ }
+ gilrs::EventType::Disconnected => {
+ let _ = tx.send(RawInput::GamepadConnection { connected: false });
+ }
+ gilrs::EventType::ButtonPressed(button, _) => {
+ if let Some(id) = button_id(button) {
+ let _ = tx.send(RawInput::GamepadButton { id, pressed: true });
+ }
+ }
+ gilrs::EventType::ButtonReleased(button, _) => {
+ if let Some(id) = button_id(button) {
+ let _ = tx.send(RawInput::GamepadButton { id, pressed: false });
+ }
+ }
+ gilrs::EventType::ButtonChanged(button, value, _) => {
+ // Analog triggers report their travel (0.0..1.0) here.
+ if let Some(axis) = trigger_axis(button) {
+ let _ = tx.send(RawInput::GamepadAxis {
+ axis,
+ value: value as f64,
+ });
+ }
+ }
+ gilrs::EventType::AxisChanged(axis, value, _) => {
+ if let Some(axis) = stick_axis(axis) {
+ let _ = tx.send(RawInput::GamepadAxis {
+ axis,
+ value: value as f64,
+ });
+ }
+ }
+ _ => {}
+ }
+ }
+ // gilrs is poll-based; a short sleep keeps this thread near-idle
+ // while staying responsive (~250 Hz).
+ std::thread::sleep(Duration::from_millis(4));
+ }
+ });
+}
+
+/// Maps gilrs buttons to the shared ids used by keymap.rs / known_keys().
+fn button_id(button: Button) -> Option<&'static str> {
+ Some(match button {
+ Button::South => "gp_a",
+ Button::East => "gp_b",
+ Button::West => "gp_x",
+ Button::North => "gp_y",
+ Button::LeftTrigger => "gp_lb",
+ Button::RightTrigger => "gp_rb",
+ Button::LeftTrigger2 => "gp_lt",
+ Button::RightTrigger2 => "gp_rt",
+ Button::Select => "gp_back",
+ Button::Start => "gp_start",
+ Button::Mode => "gp_guide",
+ Button::LeftThumb => "gp_ls",
+ Button::RightThumb => "gp_rs",
+ Button::DPadUp => "dpad_up",
+ Button::DPadDown => "dpad_down",
+ Button::DPadLeft => "dpad_left",
+ Button::DPadRight => "dpad_right",
+ _ => return None,
+ })
+}
+
+/// Analog trigger travel is surfaced as an axis so the widget can draw a bar.
+fn trigger_axis(button: Button) -> Option<&'static str> {
+ Some(match button {
+ Button::LeftTrigger2 => "lt",
+ Button::RightTrigger2 => "rt",
+ _ => return None,
+ })
+}
+
+fn stick_axis(axis: Axis) -> Option<&'static str> {
+ Some(match axis {
+ Axis::LeftStickX => "ls_x",
+ Axis::LeftStickY => "ls_y",
+ Axis::RightStickX => "rs_x",
+ Axis::RightStickY => "rs_y",
+ Axis::LeftZ => "lt",
+ Axis::RightZ => "rt",
+ _ => return None,
+ })
+}
diff --git a/src-tauri/src/input/kwin_focus.rs b/src-tauri/src/input/kwin_focus.rs
new file mode 100644
index 0000000..cdcd2b4
--- /dev/null
+++ b/src-tauri/src/input/kwin_focus.rs
@@ -0,0 +1,89 @@
+//! KDE Wayland active-window source.
+//!
+//! Wayland deliberately hides the focused window from normal apps, so the
+//! X11-based `active-win-pos-rs` returns nothing under a Wayland session. KDE
+//! (KWin) still exposes it through its scripting D-Bus interface, so we own a
+//! tiny D-Bus service and load a KWin script that pushes the active window's
+//! class to us on every focus change. Push-based (no polling), and entirely
+//! best-effort: if anything is missing (not KDE, no session bus, …) we return
+//! `None` and the process filter simply falls back / fails open.
+
+use crate::filter::Engine;
+use std::sync::Arc;
+
+const SERVICE: &str = "org.yakc.KwinFocus";
+const PATH: &str = "/Focus";
+const PLUGIN: &str = "yakcfocus";
+
+// Runs inside KWin: reports the active window's class whenever focus changes.
+// `resourceClass` is the Wayland app id (e.g. "org.kde.konsole", "firefox").
+const SCRIPT: &str = r#"
+function report() {
+ var c = workspace.activeWindow || workspace.activeClient;
+ var name = c ? (c.resourceClass || c.resourceName || "") : "";
+ callDBus("org.yakc.KwinFocus", "/Focus", "org.yakc.KwinFocus", "Report", String(name));
+}
+if (workspace.windowActivated) workspace.windowActivated.connect(report);
+if (workspace.clientActivated) workspace.clientActivated.connect(report);
+report();
+"#;
+
+struct Focus {
+ engine: Arc,
+}
+
+#[zbus::interface(name = "org.yakc.KwinFocus")]
+impl Focus {
+ fn report(&self, app: String) {
+ // Push the new focus straight into the filter — event-driven, no polling.
+ self.engine.set_active(app);
+ }
+}
+
+/// Starts the KDE Wayland focus watcher when on a Wayland session (best-effort;
+/// a no-op on X11, where the caller's poll thread queries the focused window).
+pub fn start(engine: Arc) {
+ if std::env::var("WAYLAND_DISPLAY").is_err() {
+ return;
+ }
+ std::thread::spawn(move || {
+ if let Err(err) = run(engine) {
+ // Not fatal — the filter falls back to fail-open without this.
+ eprintln!("YAKC: KDE Wayland focus watcher unavailable: {err}");
+ }
+ });
+}
+
+fn run(engine: Arc) -> Result<(), Box> {
+ // loadScript takes a file path, so drop the script somewhere stable.
+ let path = std::env::temp_dir().join("yakc-kwin-focus.js");
+ std::fs::write(&path, SCRIPT)?;
+
+ // Own our service so the script's callDBus reaches us, then keep the
+ // connection alive so it keeps dispatching Report calls.
+ let conn = zbus::blocking::connection::Builder::session()?
+ .name(SERVICE)?
+ .serve_at(PATH, Focus { engine })?
+ .build()?;
+
+ let scripting = zbus::blocking::Proxy::new(
+ &conn,
+ "org.kde.KWin",
+ "/Scripting",
+ "org.kde.kwin.Scripting",
+ )?;
+ // Drop any leftover instance from a previous run so they don't stack up.
+ let _: zbus::Result = scripting.call("unloadScript", &(PLUGIN,));
+ let id: i32 = scripting.call("loadScript", &(path.to_string_lossy().as_ref(), PLUGIN))?;
+ let script = zbus::blocking::Proxy::new(
+ &conn,
+ "org.kde.KWin",
+ format!("/Scripting/Script{id}"),
+ "org.kde.kwin.Script",
+ )?;
+ let _: zbus::Result<()> = script.call("run", &());
+
+ loop {
+ std::thread::park();
+ }
+}
diff --git a/src-tauri/src/input/labels_macos.rs b/src-tauri/src/input/labels_macos.rs
new file mode 100644
index 0000000..0bef8c8
--- /dev/null
+++ b/src-tauri/src/input/labels_macos.rs
@@ -0,0 +1,95 @@
+//! macOS: base (unshifted) label for each physical keyboard cap in the active
+//! layout, via `UCKeyTranslate` on the current `TISInputSource` — the on-screen
+//! keyboard then shows the user's real layout immediately. Parallels the Linux
+//! `xkbcommon` path.
+
+use std::collections::HashMap;
+use std::os::raw::c_void;
+
+use core_foundation::base::{CFRelease, TCFType};
+use core_foundation::data::{CFData, CFDataRef};
+use core_foundation::string::CFStringRef;
+
+// Carbon / HIToolbox APIs for reading the active Unicode keyboard layout.
+#[link(name = "Carbon", kind = "framework")]
+extern "C" {
+ fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut c_void;
+ fn TISGetInputSourceProperty(input_source: *mut c_void, key: CFStringRef) -> *mut c_void;
+ static kTISPropertyUnicodeKeyLayoutData: CFStringRef;
+ fn LMGetKbdType() -> u8;
+ #[allow(clippy::too_many_arguments)]
+ fn UCKeyTranslate(
+ key_layout_ptr: *const u8,
+ virtual_key_code: u16,
+ key_action: u16,
+ modifier_key_state: u32,
+ keyboard_type: u32,
+ key_translate_options: u32,
+ dead_key_state: *mut u32,
+ max_string_length: u32,
+ actual_string_length: *mut u32,
+ unicode_string: *mut u16,
+ ) -> i32;
+}
+
+const K_UC_KEY_ACTION_DISPLAY: u16 = 3;
+
+/// W3C KeyboardEvent.code → macOS ANSI virtual keycode (kVK_ANSI_*), printable only.
+const KEYCODES: &[(&str, u16)] = &[
+ ("KeyA", 0x00), ("KeyS", 0x01), ("KeyD", 0x02), ("KeyF", 0x03), ("KeyH", 0x04),
+ ("KeyG", 0x05), ("KeyZ", 0x06), ("KeyX", 0x07), ("KeyC", 0x08), ("KeyV", 0x09),
+ ("KeyB", 0x0B), ("KeyQ", 0x0C), ("KeyW", 0x0D), ("KeyE", 0x0E), ("KeyR", 0x0F),
+ ("KeyY", 0x10), ("KeyT", 0x11),
+ ("Digit1", 0x12), ("Digit2", 0x13), ("Digit3", 0x14), ("Digit4", 0x15),
+ ("Digit6", 0x16), ("Digit5", 0x17), ("Equal", 0x18), ("Digit9", 0x19),
+ ("Digit7", 0x1A), ("Minus", 0x1B), ("Digit8", 0x1C), ("Digit0", 0x1D),
+ ("BracketRight", 0x1E), ("KeyO", 0x1F), ("KeyU", 0x20), ("BracketLeft", 0x21),
+ ("KeyI", 0x22), ("KeyP", 0x23), ("KeyL", 0x25), ("KeyJ", 0x26), ("Quote", 0x27),
+ ("KeyK", 0x28), ("Semicolon", 0x29), ("Backslash", 0x2A), ("Comma", 0x2B),
+ ("Slash", 0x2C), ("KeyN", 0x2D), ("KeyM", 0x2E), ("Period", 0x2F), ("Backquote", 0x32),
+];
+
+pub fn key_labels(_layout_override: Option<&str>) -> HashMap {
+ let mut labels = HashMap::new();
+ unsafe {
+ let source = TISCopyCurrentKeyboardLayoutInputSource();
+ if source.is_null() {
+ return labels;
+ }
+ let data_ref = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData);
+ if data_ref.is_null() {
+ CFRelease(source);
+ return labels;
+ }
+ // +0 (get rule) → wrap so it's retained for the duration and released on drop.
+ let layout_data: CFData = CFData::wrap_under_get_rule(data_ref as CFDataRef);
+ let layout_ptr = layout_data.bytes().as_ptr(); // UCKeyboardLayout*
+ let kbd_type = LMGetKbdType() as u32;
+
+ for (w3c, vk) in KEYCODES {
+ let mut dead_key_state: u32 = 0;
+ let mut buf = [0u16; 8];
+ let mut len: u32 = 0;
+ let status = UCKeyTranslate(
+ layout_ptr,
+ *vk,
+ K_UC_KEY_ACTION_DISPLAY,
+ 0, // no modifiers → base character
+ kbd_type,
+ 0,
+ &mut dead_key_state,
+ buf.len() as u32,
+ &mut len,
+ buf.as_mut_ptr(),
+ );
+ if status == 0 && len > 0 {
+ let text = String::from_utf16_lossy(&buf[..len as usize]);
+ if !text.is_empty() && !text.chars().all(char::is_control) {
+ labels.insert(w3c.to_string(), text);
+ }
+ }
+ }
+ CFRelease(source);
+ }
+ labels
+}
diff --git a/src-tauri/src/input/labels_windows.rs b/src-tauri/src/input/labels_windows.rs
new file mode 100644
index 0000000..b6105bf
--- /dev/null
+++ b/src-tauri/src/input/labels_windows.rs
@@ -0,0 +1,47 @@
+//! Windows: base (unshifted) label for each physical keyboard cap in the active
+//! layout, via `ToUnicodeEx` — the on-screen keyboard then shows the user's real
+//! layout immediately. Parallels the Linux `xkbcommon` path.
+
+use std::collections::HashMap;
+
+use windows::Win32::UI::Input::KeyboardAndMouse::{
+ GetKeyboardLayout, MapVirtualKeyExW, ToUnicodeEx, MAPVK_VSC_TO_VK_EX,
+};
+
+/// W3C KeyboardEvent.code → PC/AT set-1 scan code (physical), printable keys only.
+const SCANCODES: &[(&str, u16)] = &[
+ ("Digit1", 0x02), ("Digit2", 0x03), ("Digit3", 0x04), ("Digit4", 0x05),
+ ("Digit5", 0x06), ("Digit6", 0x07), ("Digit7", 0x08), ("Digit8", 0x09),
+ ("Digit9", 0x0A), ("Digit0", 0x0B), ("Minus", 0x0C), ("Equal", 0x0D),
+ ("KeyQ", 0x10), ("KeyW", 0x11), ("KeyE", 0x12), ("KeyR", 0x13), ("KeyT", 0x14),
+ ("KeyY", 0x15), ("KeyU", 0x16), ("KeyI", 0x17), ("KeyO", 0x18), ("KeyP", 0x19),
+ ("BracketLeft", 0x1A), ("BracketRight", 0x1B),
+ ("KeyA", 0x1E), ("KeyS", 0x1F), ("KeyD", 0x20), ("KeyF", 0x21), ("KeyG", 0x22),
+ ("KeyH", 0x23), ("KeyJ", 0x24), ("KeyK", 0x25), ("KeyL", 0x26),
+ ("Semicolon", 0x27), ("Quote", 0x28), ("Backquote", 0x29), ("Backslash", 0x2B),
+ ("KeyZ", 0x2C), ("KeyX", 0x2D), ("KeyC", 0x2E), ("KeyV", 0x2F), ("KeyB", 0x30),
+ ("KeyN", 0x31), ("KeyM", 0x32), ("Comma", 0x33), ("Period", 0x34), ("Slash", 0x35),
+];
+
+pub fn key_labels(_layout_override: Option<&str>) -> HashMap {
+ let mut labels = HashMap::new();
+ unsafe {
+ let hkl = GetKeyboardLayout(0);
+ let key_state = [0u8; 256]; // no modifiers held → base character
+ for (w3c, scancode) in SCANCODES {
+ let vk = MapVirtualKeyExW(*scancode as u32, MAPVK_VSC_TO_VK_EX, hkl);
+ if vk == 0 {
+ continue;
+ }
+ let mut buf = [0u16; 8];
+ let n = ToUnicodeEx(vk, *scancode as u32, &key_state, &mut buf, 0, hkl);
+ if n > 0 {
+ let text = String::from_utf16_lossy(&buf[..n as usize]);
+ if !text.is_empty() && !text.chars().all(char::is_control) {
+ labels.insert(w3c.to_string(), text);
+ }
+ }
+ }
+ }
+ labels
+}
diff --git a/src-tauri/src/input/mod.rs b/src-tauri/src/input/mod.rs
index 160c474..25e71ac 100644
--- a/src-tauri/src/input/mod.rs
+++ b/src-tauri/src/input/mod.rs
@@ -1,11 +1,15 @@
use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{mpsc, Arc};
+use std::sync::{mpsc, Arc, Mutex};
+use std::time::Duration;
+use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
use crate::config::SharedConfig;
use crate::{keymap, tts};
+mod gamepad;
+
#[cfg(any(target_os = "windows", target_os = "macos"))]
mod rdev_backend;
#[cfg(any(target_os = "windows", target_os = "macos"))]
@@ -16,8 +20,31 @@ mod evdev_backend;
#[cfg(target_os = "linux")]
mod wayland_keymap;
#[cfg(target_os = "linux")]
+pub mod kwin_focus;
+#[cfg(target_os = "linux")]
use evdev_backend as platform;
+// Per-OS enumeration of each physical key's base label in the active layout, so
+// the on-screen keyboard shows the user's real layout immediately. Each platform
+// uses its native API (Linux xkbcommon, Windows ToUnicodeEx, macOS UCKeyTranslate).
+#[cfg(target_os = "linux")]
+pub use evdev_backend::key_labels;
+
+#[cfg(target_os = "windows")]
+mod labels_windows;
+#[cfg(target_os = "windows")]
+pub use labels_windows::key_labels;
+
+#[cfg(target_os = "macos")]
+mod labels_macos;
+#[cfg(target_os = "macos")]
+pub use labels_macos::key_labels;
+
+#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
+pub fn key_labels(_layout_override: Option<&str>) -> std::collections::HashMap {
+ std::collections::HashMap::new()
+}
+
/// Modifier state at the time of a key event.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Mods {
@@ -36,13 +63,50 @@ pub enum RawInput {
text: Option,
/// Backend-normalized id for non-printable keys ("backspace", "f1", …).
named: Option<&'static str>,
+ /// Physical key position, W3C-KeyboardEvent-style ("KeyQ", "Digit1",
+ /// "Enter", …). Layout-independent, so the on-screen keyboard lights the
+ /// right cap on QWERTZ/AZERTY/etc. `None` for keys not on the keyboard.
+ code: Option<&'static str>,
mods: Mods,
/// True when this press is an auto-repeat of a held key.
repeat: bool,
},
+ /// A modifier key changed state, with its physical side ("ShiftLeft", …).
+ /// Modifiers don't produce popups; this drives the on-screen keyboard so a
+ /// held modifier lights up (and the correct left/right cap).
+ Modifier {
+ code: &'static str,
+ pressed: bool,
+ },
MouseButton {
button: u8,
},
+ /// Relative mouse movement since the last event. Emitted per axis on Linux
+ /// (evdev sends REL_X and REL_Y separately), combined on Windows/macOS.
+ MouseMotion {
+ dx: f64,
+ dy: f64,
+ },
+ /// Scroll wheel: positive dy = up, positive dx = right (one tick per notch).
+ Scroll {
+ dx: f64,
+ dy: f64,
+ },
+ /// A gamepad button changed state. `id` is a shared id ("gp_a", "dpad_up", …).
+ GamepadButton {
+ id: &'static str,
+ pressed: bool,
+ },
+ /// A gamepad analog axis moved. `axis` is a shared id ("ls_x", "rt", …);
+ /// sticks range -1.0..1.0, triggers 0.0..1.0.
+ GamepadAxis {
+ axis: &'static str,
+ value: f64,
+ },
+ /// A gamepad connected (true) or disconnected (false); drives widget visibility.
+ GamepadConnection {
+ connected: bool,
+ },
}
/// A problem a platform backend ran into that needs user-visible handling.
@@ -111,11 +175,147 @@ impl Hotkey {
}
}
+/// Latest analog device state, updated by the consumer thread and sampled by
+/// the emitter thread at ~60 Hz. Mouse motion accumulates between samples;
+/// gamepad axes hold their latest value.
+#[derive(Default)]
+struct DeviceState {
+ mouse_dx: f64,
+ mouse_dy: f64,
+ ls_x: f64,
+ ls_y: f64,
+ rs_x: f64,
+ rs_y: f64,
+ lt: f64,
+ rt: f64,
+ gamepad_connected: bool,
+}
+
+impl DeviceState {
+ fn set_axis(&mut self, axis: &str, value: f64) {
+ match axis {
+ "ls_x" => self.ls_x = value,
+ "ls_y" => self.ls_y = value,
+ "rs_x" => self.rs_x = value,
+ "rs_y" => self.rs_y = value,
+ "lt" => self.lt = value,
+ "rt" => self.rt = value,
+ _ => {}
+ }
+ }
+
+ fn reset_gamepad(&mut self) {
+ self.ls_x = 0.0;
+ self.ls_y = 0.0;
+ self.rs_x = 0.0;
+ self.rs_y = 0.0;
+ self.lt = 0.0;
+ self.rt = 0.0;
+ }
+}
+
+/// Snapshot emitted to the overlay's device widget. Carries the config knobs
+/// the widget needs so the frontend never has to fetch config separately.
+#[derive(Debug, Clone, Serialize, Default, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct DeviceSnapshot {
+ mouse_dx: f64,
+ mouse_dy: f64,
+ ls_x: f64,
+ ls_y: f64,
+ rs_x: f64,
+ rs_y: f64,
+ lt: f64,
+ rt: f64,
+ gamepad_connected: bool,
+ show_mouse_movement: bool,
+ show_gamepad: bool,
+ sensitivity: f64,
+ decay_seconds: f64,
+ scale: f64,
+}
+
+/// Emits `device-state` to the overlay at ~60 Hz, but only when something
+/// changed (mouse moved, an axis moved, or a controller connected). The
+/// overlay runs its own animation loop for smooth decay, so idle frames are
+/// unnecessary.
+fn spawn_device_emitter(app: AppHandle, config: SharedConfig, state: Arc>) {
+ std::thread::spawn(move || {
+ // Axes/connection last emitted, to detect change (mouse delta excluded).
+ let mut last = DeviceSnapshot::default();
+ loop {
+ std::thread::sleep(Duration::from_millis(16));
+ let cfg = match config.read() {
+ Ok(cfg) => cfg.clone(),
+ Err(_) => continue,
+ };
+ if !cfg.show_mouse_movement && !cfg.show_gamepad {
+ continue;
+ }
+
+ let snapshot = {
+ let mut guard = match state.lock() {
+ Ok(guard) => guard,
+ Err(_) => continue,
+ };
+ let snapshot = DeviceSnapshot {
+ mouse_dx: guard.mouse_dx,
+ mouse_dy: guard.mouse_dy,
+ ls_x: guard.ls_x,
+ ls_y: guard.ls_y,
+ rs_x: guard.rs_x,
+ rs_y: guard.rs_y,
+ lt: guard.lt,
+ rt: guard.rt,
+ gamepad_connected: guard.gamepad_connected,
+ show_mouse_movement: cfg.show_mouse_movement,
+ show_gamepad: cfg.show_gamepad,
+ sensitivity: cfg.mouse_movement_sensitivity,
+ decay_seconds: cfg.mouse_movement_decay_seconds,
+ scale: cfg.device_widget_scale,
+ };
+ // Drain the accumulated motion; axes persist.
+ guard.mouse_dx = 0.0;
+ guard.mouse_dy = 0.0;
+ snapshot
+ };
+
+ let moved = snapshot.mouse_dx != 0.0 || snapshot.mouse_dy != 0.0;
+ // Compare everything except the (already-drained) mouse delta.
+ let axes_changed = DeviceSnapshot {
+ mouse_dx: 0.0,
+ mouse_dy: 0.0,
+ ..snapshot.clone()
+ } != last;
+ if !moved && !axes_changed {
+ continue;
+ }
+ last = DeviceSnapshot {
+ mouse_dx: 0.0,
+ mouse_dy: 0.0,
+ ..snapshot.clone()
+ };
+ let _ = app.emit_to("overlay", "device-state", &snapshot);
+ if crate::obs_server::has_clients(&app) {
+ if let Ok(json) = serde_json::to_string(&snapshot) {
+ crate::obs_server::broadcast(&app, "device-state", &json);
+ }
+ }
+ }
+ });
+}
+
/// Spawns the platform input backend and the consumer thread that turns raw
/// events into popup labels, TTS, and hotkey toggles.
pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) {
let (tx, rx) = mpsc::channel::();
+ let device_state = Arc::new(Mutex::new(DeviceState::default()));
+ spawn_device_emitter(app.clone(), config.clone(), device_state.clone());
+
+ // Gamepad runs on every platform and feeds the same channel.
+ gamepad::spawn_listener(tx.clone());
+
let on_issue = {
let app = app.clone();
move |issue: BackendIssue| handle_issue(&app, issue)
@@ -149,6 +349,7 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) {
named,
mods,
repeat,
+ ..
} = &event
{
if !repeat {
@@ -166,12 +367,75 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) {
continue;
}
+ // Analog motion / gamepad axes / connection feed the device widget
+ // (via the 60 Hz emitter), never the popup stack.
+ match &event {
+ RawInput::MouseMotion { dx, dy } => {
+ if cfg.show_mouse_movement {
+ if let Ok(mut guard) = device_state.lock() {
+ guard.mouse_dx += dx;
+ guard.mouse_dy += dy;
+ }
+ }
+ continue;
+ }
+ RawInput::GamepadAxis { axis, value } => {
+ if cfg.show_gamepad {
+ if let Ok(mut guard) = device_state.lock() {
+ // Any axis activity proves a controller is present,
+ // even if we missed the connect event at startup.
+ guard.gamepad_connected = true;
+ guard.set_axis(axis, *value);
+ }
+ }
+ continue;
+ }
+ RawInput::GamepadConnection { connected } => {
+ if let Ok(mut guard) = device_state.lock() {
+ guard.gamepad_connected = *connected;
+ if !*connected {
+ guard.reset_gamepad();
+ }
+ }
+ continue;
+ }
+ // A held modifier lights (and un-lights) its exact cap.
+ RawInput::Modifier { code, pressed } => {
+ if cfg.display_style == "keyboard" && cfg.show_keyboard_click {
+ let payload = serde_json::json!({ "code": code, "pressed": pressed });
+ emit_key_flash(&app, &payload);
+ }
+ continue;
+ }
+ _ => {}
+ }
+
+ // Keyboard-skin mode: flash the pressed cap by physical position, and
+ // relabel it from the character the OS produced (so the displayed
+ // keyboard matches the user's actual layout).
+ if cfg.display_style == "keyboard" && cfg.show_keyboard_click {
+ if let RawInput::Key {
+ text,
+ code: Some(code),
+ mods,
+ repeat: false,
+ ..
+ } = &event
+ {
+ // Only relabel from an unshifted press, to capture base chars.
+ let label = if mods.shift { None } else { text.as_deref() };
+ let payload = serde_json::json!({ "code": code, "label": label });
+ emit_key_flash(&app, &payload);
+ }
+ }
+
let op = match &event {
RawInput::Key {
text,
named,
mods,
repeat,
+ ..
} => {
if !cfg.show_keyboard_click {
continue;
@@ -191,11 +455,42 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) {
text: keymap::format_mouse(*button, coords, &cfg),
})
}
+ RawInput::Scroll { dx, dy } => {
+ if !cfg.show_mouse_scroll {
+ continue;
+ }
+ Some(keymap::PopupOp::Append {
+ text: keymap::format_scroll(*dx, *dy, &cfg),
+ })
+ }
+ RawInput::GamepadButton { id, pressed } => {
+ if !cfg.show_gamepad {
+ continue;
+ }
+ if let Ok(mut guard) = device_state.lock() {
+ guard.gamepad_connected = true;
+ }
+ // Only presses produce a popup token; releases just kept the
+ // connected flag fresh above.
+ if !*pressed {
+ continue;
+ }
+ Some(keymap::PopupOp::Append {
+ text: keymap::format_gamepad_button(id, &cfg),
+ })
+ }
+ // Analog / connection variants were handled above.
+ _ => continue,
};
let Some(op) = op else { continue };
let _ = app.emit_to("overlay", "click-event", &op);
+ if crate::obs_server::has_clients(&app) {
+ if let Ok(json) = serde_json::to_string(&op) {
+ crate::obs_server::broadcast(&app, "click-event", &json);
+ }
+ }
if cfg.text_to_speech {
if let keymap::PopupOp::Append { text } = &op {
@@ -215,6 +510,14 @@ pub fn start(app: AppHandle, config: SharedConfig, capturing: Arc) {
});
}
+/// Emits a `key-flash` event to the native overlay and any OBS browser clients.
+fn emit_key_flash(app: &AppHandle, payload: &serde_json::Value) {
+ let _ = app.emit_to("overlay", "key-flash", payload);
+ if crate::obs_server::has_clients(app) {
+ crate::obs_server::broadcast(app, "key-flash", &payload.to_string());
+ }
+}
+
/// Global cursor position. Works natively on Windows/macOS/X11. On Wayland the
/// compositor hides the global cursor from applications, and the XWayland
/// fallback returns stale garbage — better to show nothing than wrong numbers.
@@ -273,6 +576,9 @@ pub fn report_error(app: &AppHandle, message: String) {
if let Some(overlay) = app.get_webview_window("overlay") {
let _ = overlay.emit("yakc-error", &message);
}
+ if let Ok(json) = serde_json::to_string(&message) {
+ crate::obs_server::broadcast(app, "yakc-error", &json);
+ }
}
#[cfg(test)]
diff --git a/src-tauri/src/input/rdev_backend.rs b/src-tauri/src/input/rdev_backend.rs
index b834c39..3bf3cbf 100644
--- a/src-tauri/src/input/rdev_backend.rs
+++ b/src-tauri/src/input/rdev_backend.rs
@@ -17,11 +17,23 @@ pub fn spawn_listener(
// Keys currently held down: a KeyPress for one of these is an
// auto-repeat (the OS hooks deliver repeats as fresh KeyPress events).
let mut held: std::collections::HashSet = std::collections::HashSet::new();
+ // rdev reports absolute cursor positions; we forward relative deltas so
+ // the movement widget behaves the same as the Linux (evdev) path.
+ let mut last_pos: Option<(f64, f64)> = None;
let callback = move |event: Event| {
match event.event_type {
EventType::KeyPress(key) => {
if update_modifier(&mut mods, key, true) {
+ // Forward once (the OS delivers held modifiers as repeats).
+ if held.insert(key) {
+ if let Some(mcode) = modifier_code(key) {
+ let _ = tx.send(RawInput::Modifier {
+ code: mcode,
+ pressed: true,
+ });
+ }
+ }
return;
}
let repeat = !held.insert(key);
@@ -40,13 +52,21 @@ pub fn spawn_listener(
let _ = tx.send(RawInput::Key {
text,
named,
+ code: code_for(key),
mods,
repeat,
});
}
EventType::KeyRelease(key) => {
held.remove(&key);
- update_modifier(&mut mods, key, false);
+ if update_modifier(&mut mods, key, false) {
+ if let Some(mcode) = modifier_code(key) {
+ let _ = tx.send(RawInput::Modifier {
+ code: mcode,
+ pressed: false,
+ });
+ }
+ }
}
EventType::ButtonPress(button) => {
let button = match button {
@@ -57,6 +77,21 @@ pub fn spawn_listener(
};
let _ = tx.send(RawInput::MouseButton { button });
}
+ EventType::MouseMove { x, y } => {
+ if let Some((px, py)) = last_pos {
+ let (dx, dy) = (x - px, y - py);
+ if dx != 0.0 || dy != 0.0 {
+ let _ = tx.send(RawInput::MouseMotion { dx, dy });
+ }
+ }
+ last_pos = Some((x, y));
+ }
+ EventType::Wheel { delta_x, delta_y } => {
+ let _ = tx.send(RawInput::Scroll {
+ dx: delta_x as f64,
+ dy: delta_y as f64,
+ });
+ }
_ => {}
}
};
@@ -88,6 +123,51 @@ fn update_modifier(mods: &mut Mods, key: Key, pressed: bool) -> bool {
true
}
+/// Physical modifier position for the on-screen keyboard.
+fn modifier_code(key: Key) -> Option<&'static str> {
+ Some(match key {
+ Key::ControlLeft => "ControlLeft",
+ Key::ControlRight => "ControlRight",
+ Key::Alt => "AltLeft",
+ Key::AltGr => "AltRight",
+ Key::ShiftLeft => "ShiftLeft",
+ Key::ShiftRight => "ShiftRight",
+ Key::MetaLeft => "MetaLeft",
+ Key::MetaRight => "MetaRight",
+ _ => return None,
+ })
+}
+
+/// Physical key position (W3C KeyboardEvent.code style) for the on-screen
+/// keyboard. Layout-independent, so the correct cap lights up on any layout.
+fn code_for(key: Key) -> Option<&'static str> {
+ Some(match key {
+ Key::KeyA => "KeyA", Key::KeyB => "KeyB", Key::KeyC => "KeyC", Key::KeyD => "KeyD",
+ Key::KeyE => "KeyE", Key::KeyF => "KeyF", Key::KeyG => "KeyG", Key::KeyH => "KeyH",
+ Key::KeyI => "KeyI", Key::KeyJ => "KeyJ", Key::KeyK => "KeyK", Key::KeyL => "KeyL",
+ Key::KeyM => "KeyM", Key::KeyN => "KeyN", Key::KeyO => "KeyO", Key::KeyP => "KeyP",
+ Key::KeyQ => "KeyQ", Key::KeyR => "KeyR", Key::KeyS => "KeyS", Key::KeyT => "KeyT",
+ Key::KeyU => "KeyU", Key::KeyV => "KeyV", Key::KeyW => "KeyW", Key::KeyX => "KeyX",
+ Key::KeyY => "KeyY", Key::KeyZ => "KeyZ",
+ Key::Num1 => "Digit1", Key::Num2 => "Digit2", Key::Num3 => "Digit3", Key::Num4 => "Digit4",
+ Key::Num5 => "Digit5", Key::Num6 => "Digit6", Key::Num7 => "Digit7", Key::Num8 => "Digit8",
+ Key::Num9 => "Digit9", Key::Num0 => "Digit0",
+ Key::Minus => "Minus", Key::Equal => "Equal",
+ Key::LeftBracket => "BracketLeft", Key::RightBracket => "BracketRight",
+ Key::BackSlash => "Backslash", Key::SemiColon => "Semicolon",
+ Key::Quote => "Quote", Key::BackQuote => "Backquote",
+ Key::Comma => "Comma", Key::Dot => "Period", Key::Slash => "Slash",
+ Key::Space => "Space", Key::Return => "Enter", Key::Tab => "Tab",
+ Key::Backspace => "Backspace", Key::CapsLock => "CapsLock", Key::Escape => "Escape",
+ Key::UpArrow => "ArrowUp", Key::DownArrow => "ArrowDown",
+ Key::LeftArrow => "ArrowLeft", Key::RightArrow => "ArrowRight",
+ Key::F1 => "F1", Key::F2 => "F2", Key::F3 => "F3", Key::F4 => "F4",
+ Key::F5 => "F5", Key::F6 => "F6", Key::F7 => "F7", Key::F8 => "F8",
+ Key::F9 => "F9", Key::F10 => "F10", Key::F11 => "F11", Key::F12 => "F12",
+ _ => return None,
+ })
+}
+
/// Maps rdev non-printable keys to the shared named-key ids in keymap.rs.
fn named_for(key: Key) -> Option<&'static str> {
Some(match key {
diff --git a/src-tauri/src/keymap.rs b/src-tauri/src/keymap.rs
index 3bd552b..6a53f71 100644
--- a/src-tauri/src/keymap.rs
+++ b/src-tauri/src/keymap.rs
@@ -1,3 +1,4 @@
+#[cfg(test)]
use std::collections::HashMap;
use serde::Serialize;
@@ -236,6 +237,71 @@ pub fn format_mouse(button: u8, coords: Option<(i32, i32)>, config: &Config) ->
format!(" MOUSE{button} ")
}
+/// Scroll-wheel directions, as (id, default label). Ids are overridable via
+/// `keyLabelOverrides`, exactly like named keys.
+const SCROLL_KEYS: &[(&str, &str)] = &[
+ ("scrollup", "Scroll↑"),
+ ("scrolldown", "Scroll↓"),
+ ("scrollleft", "Scroll←"),
+ ("scrollright", "Scroll→"),
+];
+
+/// Gamepad buttons, as (id, default label). Shared with the gamepad backend.
+const GAMEPAD_BUTTONS: &[(&str, &str)] = &[
+ ("gp_a", "A"),
+ ("gp_b", "B"),
+ ("gp_x", "X"),
+ ("gp_y", "Y"),
+ ("gp_lb", "LB"),
+ ("gp_rb", "RB"),
+ ("gp_lt", "LT"),
+ ("gp_rt", "RT"),
+ ("gp_back", "BACK"),
+ ("gp_start", "START"),
+ ("gp_guide", "GUIDE"),
+ ("gp_ls", "L3"),
+ ("gp_rs", "R3"),
+ ("dpad_up", "D↑"),
+ ("dpad_down", "D↓"),
+ ("dpad_left", "D←"),
+ ("dpad_right", "D→"),
+];
+
+/// Resolves a label for an id, honoring `keyLabelOverrides` first, then the
+/// table default, then an uppercased fallback.
+fn labeled(id: &str, table: &[(&str, &str)], config: &Config) -> String {
+ if let Some(overridden) = config.key_label_overrides.get(id) {
+ return overridden.clone();
+ }
+ table
+ .iter()
+ .find(|(key, _)| *key == id)
+ .map(|(_, label)| label.to_string())
+ .unwrap_or_else(|| id.to_uppercase())
+}
+
+/// Popup label for a scroll tick. `dy > 0` is up, `dx > 0` is right; the
+/// dominant axis wins.
+pub fn format_scroll(dx: f64, dy: f64, config: &Config) -> String {
+ let id = if dy.abs() >= dx.abs() {
+ if dy > 0.0 {
+ "scrollup"
+ } else {
+ "scrolldown"
+ }
+ } else if dx > 0.0 {
+ "scrollright"
+ } else {
+ "scrollleft"
+ };
+ format!(" {} ", labeled(id, SCROLL_KEYS, config))
+}
+
+/// Popup label for a gamepad button press.
+pub fn format_gamepad_button(id: &str, config: &Config) -> String {
+ format!(" {} ", labeled(id, GAMEPAD_BUTTONS, config))
+}
+
/// A known key that can be overridden, exposed to the settings UI.
#[derive(Debug, Clone, Serialize)]
pub struct KnownKey {
@@ -305,6 +371,22 @@ pub fn known_keys() -> Vec {
});
}
+ for (id, label) in SCROLL_KEYS {
+ keys.push(KnownKey {
+ id: id.to_string(),
+ default_label: label.to_string(),
+ group: "mouse".into(),
+ });
+ }
+
+ for (id, label) in GAMEPAD_BUTTONS {
+ keys.push(KnownKey {
+ id: id.to_string(),
+ default_label: label.to_string(),
+ group: "gamepad".into(),
+ });
+ }
+
keys
}
@@ -506,6 +588,44 @@ mod tests {
assert_eq!(format_mouse(2, None, &config), " MOUSE2 ");
}
+ #[test]
+ fn scroll_labels_pick_dominant_axis() {
+ let config = Config::default();
+ assert_eq!(format_scroll(0.0, 1.0, &config), " Scroll↑ ");
+ assert_eq!(format_scroll(0.0, -1.0, &config), " Scroll↓ ");
+ assert_eq!(format_scroll(1.0, 0.0, &config), " Scroll→ ");
+ assert_eq!(format_scroll(-1.0, 0.0, &config), " Scroll← ");
+ // Vertical wins ties / mixed input.
+ assert_eq!(format_scroll(0.5, 1.0, &config), " Scroll↑ ");
+ }
+
+ #[test]
+ fn scroll_and_gamepad_labels_honor_overrides() {
+ let config = Config {
+ key_label_overrides: HashMap::from([
+ ("scrollup".into(), "WHEEL-UP".into()),
+ ("gp_a".into(), "✕".into()),
+ ]),
+ ..Config::default()
+ };
+ assert_eq!(format_scroll(0.0, 2.0, &config), " WHEEL-UP ");
+ assert_eq!(format_gamepad_button("gp_a", &config), " ✕ ");
+ // Non-overridden buttons keep their default label.
+ assert_eq!(format_gamepad_button("gp_lt", &config), " LT ");
+ assert_eq!(format_gamepad_button("dpad_up", &config), " D↑ ");
+ }
+
+ #[test]
+ fn known_keys_include_scroll_and_gamepad_groups() {
+ let keys = known_keys();
+ assert!(keys
+ .iter()
+ .any(|k| k.id == "scrollup" && k.group == "mouse"));
+ assert!(keys
+ .iter()
+ .any(|k| k.id == "gp_a" && k.group == "gamepad" && k.default_label == "A"));
+ }
+
#[test]
fn named_key_override_takes_priority() {
let config = Config {
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 3de2581..ca50499 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -4,16 +4,18 @@ mod config;
mod filter;
mod input;
mod keymap;
+mod obs_server;
mod overlay;
+mod profiles;
mod setup;
mod tts;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
-use tauri::menu::{Menu, MenuItem};
+use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::tray::TrayIconBuilder;
-use tauri::{AppHandle, Emitter, Manager, State};
+use tauri::{AppHandle, Emitter, Listener, Manager, State, Wry};
use config::{Config, SharedConfig};
use keymap::KnownKey;
@@ -47,18 +49,104 @@ fn get_known_keys() -> Vec {
keymap::known_keys()
}
+/// Names of currently-running processes, de-duplicated and sorted, so the
+/// settings UI can offer a pick-from-a-list process filter instead of relying
+/// on the user typing exact executable names. Cross-platform via `sysinfo`.
#[tauri::command]
-fn save_config(
- app: AppHandle,
- state: State,
- config: Config,
-) -> Result<(), String> {
- if let Ok(mut guard) = state.write() {
- *guard = config.clone();
+fn get_running_processes() -> Vec {
+ use sysinfo::{ProcessRefreshKind, RefreshKind, System};
+ let sys = System::new_with_specifics(
+ RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
+ );
+ let mut names: Vec = sys
+ .processes()
+ .values()
+ .map(|p| p.name().to_string_lossy().to_string())
+ .filter(|n| !n.is_empty())
+ .collect();
+ names.sort_by_key(|n| n.to_lowercase());
+ names.dedup();
+ names
+}
+
+/// Human-readable descriptors for each connected monitor, index-aligned with
+/// `showOnMonitor`, so the settings UI can show a dropdown of real monitors
+/// instead of a bare index the user has to guess.
+#[tauri::command]
+fn get_monitors(app: AppHandle) -> Vec {
+ let Some(window) = app.get_webview_window("overlay") else {
+ return Vec::new();
+ };
+ match window.available_monitors() {
+ Ok(monitors) => monitors
+ .iter()
+ .enumerate()
+ .map(|(i, m)| {
+ let name = m
+ .name()
+ .map(|n| n.to_string())
+ .unwrap_or_else(|| "Monitor".to_string());
+ let size = m.size();
+ format!("{i}: {name} ({}×{})", size.width, size.height)
+ })
+ .collect(),
+ Err(_) => Vec::new(),
+ }
+}
+
+/// Base labels per physical key from the OS layout, so the on-screen keyboard
+/// shows the user's real layout immediately (QWERTZ/AZERTY/…), not QWERTY.
+#[tauri::command]
+fn get_key_labels(state: State) -> std::collections::HashMap {
+ let layout = state
+ .read()
+ .ok()
+ .map(|cfg| cfg.keyboard_layout.clone())
+ .filter(|layout| !layout.trim().is_empty());
+ input::key_labels(layout.as_deref())
+}
+
+/// Enters drag-to-position mode on the overlay (temporary non-click-through).
+#[tauri::command]
+fn begin_overlay_move(app: AppHandle) {
+ overlay::begin_move(&app);
+}
+
+/// Leaves drag-to-position mode, restoring click-through.
+#[tauri::command]
+fn end_overlay_move(app: AppHandle, state: State) {
+ let config = state.read().map(|cfg| cfg.clone()).unwrap_or_default();
+ overlay::end_move(&app, &config);
+}
+
+/// Makes `config` the live configuration: stores it, persists config.json, and
+/// applies every side effect (filter re-evaluation, overlay placement/visibility,
+/// OBS server, and the config-updated event to the overlay + browser source).
+/// Shared by save, profile load, preset apply, and import.
+pub fn apply_active(app: &AppHandle, config: &Config) -> Result<(), String> {
+ if let Some(state) = app.try_state::() {
+ if let Ok(mut guard) = state.write() {
+ *guard = config.clone();
+ }
+ }
+ config::save(app, config)?;
+ // Apply the (possibly toggled) process filter immediately, so enabling or
+ // disabling it takes effect at once — no wait for a poll tick.
+ if let Some(engine) = app.try_state::>() {
+ engine.reevaluate();
+ }
+ overlay::apply_placement(app, config);
+ // Start the OBS server if this just enabled it (no restart needed).
+ obs_server::ensure_started(app, config);
+ if let Ok(json) = serde_json::to_string(config) {
+ obs_server::broadcast(app, "config-updated", &json);
}
- config::save(&app, &config)?;
- overlay::apply_placement(&app, &config);
- app.emit("config-updated", &config).map_err(|e| e.to_string())
+ app.emit("config-updated", config).map_err(|e| e.to_string())
+}
+
+#[tauri::command]
+fn save_config(app: AppHandle, config: Config) -> Result<(), String> {
+ apply_active(&app, &config)
}
fn toggle_capturing(capturing: &Capturing) {
@@ -66,6 +154,50 @@ fn toggle_capturing(capturing: &Capturing) {
capturing.store(now, Ordering::Relaxed);
}
+/// Builds the tray menu, including a Profiles submenu listing saved profiles
+/// (the active one marked ●). Rebuilt whenever profiles change.
+fn build_tray_menu(app: &AppHandle) -> tauri::Result