diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0053ed..16fbbc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: for attempt in 1 2 3 4 5; do sudo apt-get update && sudo apt-get install -y \ libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \ - libxkbcommon-dev libspeechd-dev && exit 0 + libxkbcommon-dev libspeechd-dev libudev-dev && exit 0 echo "apt attempt $attempt failed; retrying in 20s"; sleep 20 done echo "apt failed after 5 attempts"; exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f1e65f3..a4a10e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: for attempt in 1 2 3 4 5; do sudo apt-get update && sudo apt-get install -y \ libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \ - libxkbcommon-dev libspeechd-dev && exit 0 + libxkbcommon-dev libspeechd-dev libudev-dev && exit 0 echo "apt attempt $attempt failed; retrying in 20s"; sleep 20 done echo "apt failed after 5 attempts"; exit 1 diff --git a/README.md b/README.md index b30d39a..1a13275 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ GitHub stars

-**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.

YAKC showing keystrokes and mouse clicks as customizable on-screen popups in real time @@ -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> { + let toggle = MenuItem::with_id(app, "toggle", "Toggle Capturing", true, None::<&str>)?; + let move_item = MenuItem::with_id(app, "move", "Move overlay position", true, None::<&str>)?; + + // Profiles submenu: one entry per saved profile + a Manage… shortcut. + let active = profiles::get_active_profile(app.clone()); + let names = profiles::list_profiles(app.clone()); + let mut owned: Vec> = Vec::new(); + for name in &names { + let label = if *name == active { + format!("● {name}") + } else { + format!(" {name}") + }; + owned.push(MenuItem::with_id( + app, + format!("profile:{name}"), + label, + true, + None::<&str>, + )?); + } + let separator = PredefinedMenuItem::separator(app)?; + let manage = MenuItem::with_id(app, "manage_profiles", "Manage profiles…", true, None::<&str>)?; + let mut items: Vec<&dyn IsMenuItem> = owned + .iter() + .map(|item| item as &dyn IsMenuItem) + .collect(); + if !owned.is_empty() { + items.push(&separator); + } + items.push(&manage); + let profiles_menu = Submenu::with_items(app, "Profiles", true, &items)?; + + let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + Menu::with_items( + app, + &[&toggle, &move_item, &profiles_menu, &settings, &quit], + ) +} + fn main() { // On Wayland, run the overlay through XWayland: always-on-top, global // positioning and click-through all work there on every compositor @@ -88,8 +220,23 @@ fn main() { get_config, get_config_path, get_known_keys, + get_running_processes, + get_monitors, + get_key_labels, get_pending_errors, - save_config + save_config, + begin_overlay_move, + end_overlay_move, + profiles::list_profiles, + profiles::get_active_profile, + profiles::save_profile, + profiles::load_profile, + profiles::delete_profile, + profiles::rename_profile, + profiles::list_presets, + profiles::apply_preset, + profiles::export_config, + profiles::import_config ]) .setup(|app| { let handle = app.handle().clone(); @@ -101,6 +248,10 @@ fn main() { app.manage(capturing.clone()); app.manage(input::PendingErrors::default()); + // OBS browser-source server (opt-in; starts on demand from settings). + app.manage(Arc::new(obs_server::ObsHub::new())); + obs_server::ensure_started(&handle, &cfg); + overlay::create(&handle, &cfg)?; overlay::create_settings(&handle)?; if std::env::var("YAKC_SHOW_SETTINGS").is_ok() { @@ -108,29 +259,70 @@ fn main() { } // Tray - let toggle_item = - MenuItem::with_id(app, "toggle", "Toggle Capturing", true, None::<&str>)?; - let settings_item = - MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&toggle_item, &settings_item, &quit_item])?; - + let menu = build_tray_menu(&handle)?; let tray_capturing = capturing.clone(); TrayIconBuilder::with_id("main") .icon(app.default_window_icon().cloned().expect("bundled icon")) .tooltip("YAKC - Yet Another Key Caster") .menu(&menu) .show_menu_on_left_click(true) - .on_menu_event(move |app, event| match event.id.as_ref() { - "toggle" => toggle_capturing(&tray_capturing), - "settings" => overlay::show_settings(app), - "quit" => app.exit(0), - _ => {} + .on_menu_event(move |app, event| { + let id = event.id.as_ref(); + match id { + "toggle" => toggle_capturing(&tray_capturing), + "move" => overlay::begin_move(app), + "settings" => overlay::show_settings(app), + "manage_profiles" => { + overlay::show_settings(app); + let _ = app.emit("open-profiles-tab", ()); + } + "quit" => app.exit(0), + _ if id.starts_with("profile:") => { + let name = id.trim_start_matches("profile:").to_string(); + let _ = profiles::load_profile(app.clone(), name); + } + _ => {} + } }) .build(app)?; + // Rebuild the tray menu whenever profiles change, so the submenu and + // the active-profile marker stay current. + let tray_handle = handle.clone(); + handle.listen("profiles-updated", move |_| { + if let (Some(tray), Ok(menu)) = ( + tray_handle.tray_by_id("main"), + build_tray_menu(&tray_handle), + ) { + let _ = tray.set_menu(Some(menu)); + } + }); + input::start(handle.clone(), shared.clone(), capturing.clone()); - filter::spawn(shared, capturing); + + // Process filter. On a Wayland session the focused window is hidden + // from X11 tools, so we take a push-based source (KDE exposes focus + // via KWin's D-Bus scripting); elsewhere the filter polls the focused + // window itself. + let wayland_active: Option = { + #[cfg(target_os = "linux")] + { + if std::env::var("WAYLAND_DISPLAY").is_ok() { + Some(Arc::new(std::sync::Mutex::new(None))) + } else { + None + } + } + #[cfg(not(target_os = "linux"))] + { + None + } + }; + let filter_engine = filter::Engine::new(shared, capturing, wayland_active); + app.manage(filter_engine.clone()); + #[cfg(target_os = "linux")] + input::kwin_focus::start(filter_engine.clone()); + filter::spawn(filter_engine); Ok(()) }) diff --git a/src-tauri/src/obs_server.rs b/src-tauri/src/obs_server.rs new file mode 100644 index 0000000..1203616 --- /dev/null +++ b/src-tauri/src/obs_server.rs @@ -0,0 +1,237 @@ +//! OBS browser-source output. +//! +//! A tiny hand-rolled HTTP/1.1 server (std::net only) that serves the *same* +//! overlay frontend (`overlay.js` / `devices.js` / `style.css`) as the native +//! window, plus a Server-Sent-Events stream of the input events. A small +//! `window.__TAURI__` shim (`tauri-shim.js`) lets that unmodified frontend run +//! in a plain browser, so OBS can point a Browser source at +//! `http://localhost:/overlay` and get exactly what the desktop overlay +//! shows — transparent and live. +//! +//! We write the socket directly (rather than via a crate) so SSE frames are +//! flushed the instant they are produced; buffered HTTP servers break SSE. +//! +//! Localhost-only; only the overlay assets and the current config are served. +//! Opt-in via `obsServerEnabled`. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tauri::{AppHandle, Manager}; + +use crate::config::{Config, SharedConfig}; + +// Frontend assets, embedded so the server needs no filesystem access and stays +// consistent with the binary. Paths are relative to this source file. +const OBS_HTML: &str = include_str!("../../src/obs.html"); +const SHIM_JS: &str = include_str!("../../src/tauri-shim.js"); +const OVERLAY_JS: &str = include_str!("../../src/overlay.js"); +const DEVICES_JS: &str = include_str!("../../src/devices.js"); +const KEYBOARD_LAYOUT_JS: &str = include_str!("../../src/keyboard-layout.js"); +const KEYBOARD_JS: &str = include_str!("../../src/keyboard.js"); +const STYLE_CSS: &str = include_str!("../../src/style.css"); + +/// How often an idle SSE connection gets a heartbeat comment — keeps the +/// connection alive and lets us notice a client that has gone away. +const HEARTBEAT: Duration = Duration::from_secs(15); + +/// Fan-out of overlay events to every connected browser (OBS) client. Each +/// client is an SSE connection draining an mpsc channel of pre-framed bytes. +#[derive(Default)] +pub struct ObsHub { + clients: Mutex>>>, + started: AtomicBool, +} + +impl ObsHub { + pub fn new() -> Self { + Self::default() + } + + pub fn has_clients(&self) -> bool { + self.clients + .lock() + .map(|clients| !clients.is_empty()) + .unwrap_or(false) + } + + /// Sends `{event, payload}` to every connected client as one SSE frame. + /// `payload_json` must already be valid JSON. Dead clients are dropped. + pub fn broadcast(&self, event: &str, payload_json: &str) { + let frame = + format!("data: {{\"event\":\"{event}\",\"payload\":{payload_json}}}\n\n").into_bytes(); + if let Ok(mut clients) = self.clients.lock() { + clients.retain(|tx| tx.send(frame.clone()).is_ok()); + } + } + + fn subscribe(&self) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + if let Ok(mut clients) = self.clients.lock() { + clients.push(tx); + } + rx + } +} + +/// True when at least one browser client is connected — lets hot paths skip +/// serialization when nobody is listening. +pub fn has_clients(app: &AppHandle) -> bool { + app.try_state::>() + .map(|hub| hub.has_clients()) + .unwrap_or(false) +} + +/// Broadcasts an overlay event to browser clients, mirroring an `app.emit`. +pub fn broadcast(app: &AppHandle, event: &str, payload_json: &str) { + if let Some(hub) = app.try_state::>() { + hub.broadcast(event, payload_json); + } +} + +/// Starts the server if the config enables it and it isn't already running. +/// Called at boot and after every settings save, so enabling the OBS source +/// takes effect immediately (no restart). Port changes still need a restart. +pub fn ensure_started(app: &AppHandle, config: &Config) { + if !config.obs_server_enabled { + return; + } + let (Some(hub), Some(shared)) = ( + app.try_state::>(), + app.try_state::(), + ) else { + return; + }; + // Start at most once. + if hub.started.swap(true, Ordering::SeqCst) { + return; + } + spawn((*shared).clone(), (*hub).clone(), config.obs_server_port); +} + +/// Binds `127.0.0.1:` and serves connections, one thread per connection. +pub fn spawn(config: SharedConfig, hub: Arc, port: u16) { + std::thread::spawn(move || { + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => listener, + Err(err) => { + eprintln!("YAKC: OBS server could not bind port {port}: {err}"); + return; + } + }; + eprintln!("YAKC: OBS browser source ready at http://localhost:{port}/overlay"); + + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let config = config.clone(); + let hub = hub.clone(); + // One thread per connection: SSE streams block for their lifetime. + std::thread::spawn(move || handle(stream, &config, &hub)); + } + }); +} + +fn handle(stream: TcpStream, config: &SharedConfig, hub: &Arc) { + // Read just the request line ("GET /path HTTP/1.1"); we don't need headers. + let Ok(peek) = stream.try_clone() else { return }; + let mut request_line = String::new(); + if BufReader::new(peek).read_line(&mut request_line).is_err() { + return; + } + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("/") + .split('?') + .next() + .unwrap_or("/") + .to_string(); + + match path.as_str() { + "/" | "/overlay" => respond(stream, "text/html; charset=utf-8", OBS_HTML), + "/tauri-shim.js" => respond(stream, "text/javascript; charset=utf-8", SHIM_JS), + "/overlay.js" => respond(stream, "text/javascript; charset=utf-8", OVERLAY_JS), + "/devices.js" => respond(stream, "text/javascript; charset=utf-8", DEVICES_JS), + "/keyboard-layout.js" => respond(stream, "text/javascript; charset=utf-8", KEYBOARD_LAYOUT_JS), + "/keyboard.js" => respond(stream, "text/javascript; charset=utf-8", KEYBOARD_JS), + "/style.css" => respond(stream, "text/css; charset=utf-8", STYLE_CSS), + "/config" => { + let json = config + .read() + .ok() + .and_then(|cfg| serde_json::to_string(&*cfg).ok()) + .unwrap_or_else(|| "{}".to_string()); + respond(stream, "application/json; charset=utf-8", &json); + } + "/key_labels" => { + let layout = config + .read() + .ok() + .map(|cfg| cfg.keyboard_layout.clone()) + .filter(|layout| !layout.trim().is_empty()); + let labels = crate::input::key_labels(layout.as_deref()); + let json = serde_json::to_string(&labels).unwrap_or_else(|_| "{}".to_string()); + respond(stream, "application/json; charset=utf-8", &json); + } + "/events" => stream_events(stream, hub), + _ => { + let _ = write!( + &stream, + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + } + } +} + +/// Writes a complete (finite) response with a Content-Length, then closes. +fn respond(mut stream: TcpStream, content_type: &str, body: &str) { + let head = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {len}\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Connection: close\r\n\r\n", + len = body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); +} + +/// Holds the connection open and forwards broadcast frames as they arrive, +/// flushing each one immediately so events reach the browser with no delay. +fn stream_events(mut stream: TcpStream, hub: &Arc) { + let head = "HTTP/1.1 200 OK\r\n\ + Content-Type: text/event-stream\r\n\ + Cache-Control: no-cache\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Connection: keep-alive\r\n\r\n"; + if stream.write_all(head.as_bytes()).is_err() || stream.flush().is_err() { + return; + } + // Open the stream immediately so EventSource fires `onopen`. + if stream.write_all(b": connected\n\n").is_err() || stream.flush().is_err() { + return; + } + + let rx = hub.subscribe(); + loop { + match rx.recv_timeout(HEARTBEAT) { + Ok(frame) => { + if stream.write_all(&frame).is_err() || stream.flush().is_err() { + break; // client gone; dropping rx prunes it from the hub + } + } + Err(RecvTimeoutError::Timeout) => { + if stream.write_all(b": ping\n\n").is_err() || stream.flush().is_err() { + break; + } + } + Err(RecvTimeoutError::Disconnected) => break, + } + } +} diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index fcac506..754871d 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -1,5 +1,5 @@ use tauri::{ - AppHandle, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, + AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; @@ -25,6 +25,7 @@ pub fn create(app: &AppHandle, config: &Config) -> tauri::Result window.set_ignore_cursor_events(true)?; place_on_monitor(app, &window, config); + apply_visibility(&window, config); #[cfg(target_os = "macos")] raise_above_fullscreen(&window); @@ -32,6 +33,17 @@ pub fn create(app: &AppHandle, config: &Config) -> tauri::Result Ok(window) } +/// Shows or hides the on-screen overlay per `showOverlayOnScreen`. When hidden, +/// the OBS browser source still streams — letting users go OBS-only. +fn apply_visibility(window: &WebviewWindow, config: &Config) { + if config.show_overlay_on_screen { + let _ = window.show(); + let _ = window.set_always_on_top(true); + } else { + let _ = window.hide(); + } +} + /// Moves/sizes the overlay to fill the monitor selected by `showOnMonitor`. pub fn place_on_monitor(app: &AppHandle, window: &WebviewWindow, config: &Config) { let monitor = app @@ -53,6 +65,7 @@ pub fn place_on_monitor(app: &AppHandle, window: &WebviewWindow, config: &Config pub fn apply_placement(app: &AppHandle, config: &Config) { if let Some(window) = app.get_webview_window("overlay") { place_on_monitor(app, &window, config); + apply_visibility(&window, config); } } @@ -66,6 +79,27 @@ pub fn create_settings(app: &AppHandle) -> tauri::Result { .build() } +/// Enters "move" mode: makes the overlay interactive (not click-through) so the +/// user can drag the popup anchor to a new spot. Paired with `end_move`. +pub fn begin_move(app: &AppHandle) { + if let Some(window) = app.get_webview_window("overlay") { + let _ = window.show(); + let _ = window.set_ignore_cursor_events(false); + let _ = window.set_always_on_top(true); + let _ = window.set_focus(); + let _ = window.emit("overlay-move", true); + } +} + +/// Leaves "move" mode: restores click-through and the configured visibility. +pub fn end_move(app: &AppHandle, config: &Config) { + if let Some(window) = app.get_webview_window("overlay") { + let _ = window.set_ignore_cursor_events(true); + apply_visibility(&window, config); + let _ = window.emit("overlay-move", false); + } +} + pub fn show_settings(app: &AppHandle) { if let Some(window) = app.get_webview_window("settings") { let _ = window.show(); diff --git a/src-tauri/src/profiles.rs b/src-tauri/src/profiles.rs new file mode 100644 index 0000000..d6ad8bc --- /dev/null +++ b/src-tauri/src/profiles.rs @@ -0,0 +1,224 @@ +//! Named config profiles + bundled presets. +//! +//! A profile is a full [`Config`] snapshot saved as `.json` under a +//! `profiles/` directory next to config.json. Presets are built-in starter +//! looks that tweak the *current* config (so infrastructure like the OBS port, +//! monitor and hotkey are preserved). Switching a profile / applying a preset +//! goes through [`crate::apply_active`], so it live-updates like a normal save. + +use std::path::PathBuf; + +use tauri::{AppHandle, Emitter, State}; +use tauri_plugin_dialog::DialogExt; + +use crate::config::{Config, SharedConfig}; + +/// Directory holding profile files, alongside the active config.json. +fn profiles_dir(app: &AppHandle) -> PathBuf { + crate::config::config_path(app) + .parent() + .map(|dir| dir.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + .join("profiles") +} + +/// Sanitizes a display name into a safe file stem (no path traversal, no odd +/// characters). Returns `None` for names that reduce to nothing. +fn safe_name(name: &str) -> Option { + let cleaned: String = name + .chars() + .filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) + .collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { + None + } else { + Some(cleaned.to_string()) + } +} + +fn profile_path(app: &AppHandle, name: &str) -> Option { + safe_name(name).map(|stem| profiles_dir(app).join(format!("{stem}.json"))) +} + +fn active_file(app: &AppHandle) -> PathBuf { + profiles_dir(app).join(".active") +} + +fn set_active(app: &AppHandle, name: &str) { + let _ = std::fs::create_dir_all(profiles_dir(app)); + let _ = std::fs::write(active_file(app), name); + let _ = app.emit("profiles-updated", ()); +} + +/// The currently-selected profile name (empty if none / custom edits). +#[tauri::command] +pub fn get_active_profile(app: AppHandle) -> String { + std::fs::read_to_string(active_file(&app)) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Names of saved profiles, sorted case-insensitively. +#[tauri::command] +pub fn list_profiles(app: AppHandle) -> Vec { + let mut names: Vec = std::fs::read_dir(profiles_dir(&app)) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + path.file_stem().map(|s| s.to_string_lossy().to_string()) + } else { + None + } + }) + .collect(); + names.sort_by_key(|n| n.to_lowercase()); + names +} + +/// Saves the current config as a profile (creating or overwriting it). +#[tauri::command] +pub fn save_profile(app: AppHandle, state: State, name: String) -> Result<(), String> { + let path = profile_path(&app, &name).ok_or("Please enter a valid profile name")?; + let config = state.read().map(|c| c.clone()).unwrap_or_default(); + let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + std::fs::create_dir_all(profiles_dir(&app)).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| e.to_string())?; + set_active(&app, &safe_name(&name).unwrap_or_default()); + Ok(()) +} + +/// Loads a profile and makes it the live config. Returns it so the UI refreshes. +#[tauri::command] +pub fn load_profile(app: AppHandle, name: String) -> Result { + let path = profile_path(&app, &name).ok_or("Invalid profile name")?; + let raw = std::fs::read_to_string(&path).map_err(|e| format!("Couldn't read profile: {e}"))?; + let config: Config = serde_json::from_str(&raw).map_err(|e| format!("Invalid profile: {e}"))?; + crate::apply_active(&app, &config)?; + set_active(&app, &safe_name(&name).unwrap_or_default()); + Ok(config) +} + +#[tauri::command] +pub fn delete_profile(app: AppHandle, name: String) -> Result<(), String> { + let path = profile_path(&app, &name).ok_or("Invalid profile name")?; + std::fs::remove_file(&path).map_err(|e| e.to_string())?; + if get_active_profile(app.clone()) == safe_name(&name).unwrap_or_default() { + let _ = std::fs::remove_file(active_file(&app)); + let _ = app.emit("profiles-updated", ()); + } + Ok(()) +} + +#[tauri::command] +pub fn rename_profile(app: AppHandle, old: String, new: String) -> Result<(), String> { + let from = profile_path(&app, &old).ok_or("Invalid profile name")?; + let to = profile_path(&app, &new).ok_or("Please enter a valid new name")?; + std::fs::rename(&from, &to).map_err(|e| e.to_string())?; + if get_active_profile(app.clone()) == safe_name(&old).unwrap_or_default() { + set_active(&app, &safe_name(&new).unwrap_or_default()); + } else { + let _ = app.emit("profiles-updated", ()); + } + Ok(()) +} + +/// Names of the bundled presets, in display order. +#[tauri::command] +pub fn list_presets() -> Vec { + vec!["Minimal".into(), "Streamer".into(), "Gamer".into()] +} + +/// Applies a bundled preset on top of the current config (keeping infrastructure +/// like OBS port, monitor, hotkey and layout), then makes it live. +#[tauri::command] +pub fn apply_preset(app: AppHandle, state: State, name: String) -> Result { + let mut config = state.read().map(|c| c.clone()).unwrap_or_default(); + match name.to_lowercase().as_str() { + "minimal" => { + config.display_style = "popups".into(); + config.display_mode = "text".into(); + config.popup_font_size = 16.0; + config.popup_opacity = 0.7; + config.popup_font_color = "#ffffff".into(); + config.popup_background_color = "#000000".into(); + config.position = "bottom-center".into(); + config.show_mouse_click = false; + config.show_mouse_movement = false; + config.show_gamepad = false; + } + "streamer" => { + config.display_style = "popups".into(); + config.display_mode = "raw".into(); + config.popup_font_size = 24.0; + config.popup_opacity = 0.9; + config.popup_font_color = "#ffffff".into(); + config.popup_background_color = "#1e1e2e".into(); + config.position = "bottom-center".into(); + config.show_keyboard_click = true; + config.show_mouse_click = true; + config.show_mouse_movement = true; + config.show_mouse_scroll = true; + } + "gamer" => { + config.display_style = "keyboard".into(); + config.keyboard_visible_keys = [ + "KeyQ", "KeyW", "KeyE", "KeyR", "KeyA", "KeyS", "KeyD", "KeyF", "ShiftLeft", + "Space", "ControlLeft", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + config.position = "bottom-center".into(); + config.show_mouse_movement = true; + config.show_mouse_click = true; + config.show_gamepad = true; + } + _ => return Err(format!("Unknown preset: {name}")), + } + crate::apply_active(&app, &config)?; + // A preset is not a named profile. + let _ = std::fs::remove_file(active_file(&app)); + let _ = app.emit("profiles-updated", ()); + Ok(config) +} + +/// Exports the current config to a user-chosen JSON file. Returns false if the +/// save dialog was cancelled. +#[tauri::command] +pub fn export_config(app: AppHandle, state: State) -> Result { + let config = state.read().map(|c| c.clone()).unwrap_or_default(); + let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + let picked = app + .dialog() + .file() + .add_filter("YAKC config", &["json"]) + .set_file_name("yakc-config.json") + .blocking_save_file(); + let Some(path) = picked else { return Ok(false) }; + let path = path.into_path().map_err(|e| e.to_string())?; + std::fs::write(path, json).map_err(|e| e.to_string())?; + Ok(true) +} + +/// Imports a config from a user-chosen JSON file and makes it live. Returns the +/// new config, or None if the dialog was cancelled. +#[tauri::command] +pub fn import_config(app: AppHandle) -> Result, String> { + let picked = app + .dialog() + .file() + .add_filter("YAKC config", &["json"]) + .blocking_pick_file(); + let Some(path) = picked else { return Ok(None) }; + let path = path.into_path().map_err(|e| e.to_string())?; + let raw = std::fs::read_to_string(&path).map_err(|e| format!("Couldn't read file: {e}"))?; + let config: Config = serde_json::from_str(&raw).map_err(|e| format!("Invalid config file: {e}"))?; + crate::apply_active(&app, &config)?; + let _ = std::fs::remove_file(active_file(&app)); + let _ = app.emit("profiles-updated", ()); + Ok(Some(config)) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a068161..ed67bf8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "YAKC", - "version": "2.0.0", + "version": "2.8.0", "identifier": "dev.iammodev.yakc", "build": { "frontendDist": "../src" diff --git a/src/devices.js b/src/devices.js new file mode 100644 index 0000000..9b67926 --- /dev/null +++ b/src/devices.js @@ -0,0 +1,159 @@ +/** + * YAKC device widgets: renders mouse-movement (a dot inside a ring) and + * gamepad sticks/triggers from the `device-state` events the Rust side emits + * at ~60 Hz. Discrete inputs (scroll, gamepad buttons, mouse buttons) are shown + * as popups by overlay.js; this file only handles the analog widgets. + * + * Wrapped in an IIFE: overlay.js and devices.js are classic scripts sharing one + * global scope, so top-level `const core` / `tauriEvent` would collide and this + * file would fail to load. + */ + +(() => { +const { event: tauriEvent, core } = window.__TAURI__; + +// How much the mouse dot deflects per pixel moved, and the ring/stick radii. +const MOUSE_GAIN = 0.02; +const MOUSE_MAX_OFFSET = 33; // px, matches #mouseRing radius minus dot size +const STICK_MAX_OFFSET = 26; // px, matches .stick radius minus dot size + +const flags = { mouse: false, gamepad: false, connected: false }; +const params = { sensitivity: 1, decay: 0.4, scale: 1 }; + +let moveMode = false; +let lastConfig = null; + +// Default anchors (opposite bottom corners) when a widget has no saved position, +// so the mouse and gamepad widgets don't stack on each other or the keyboard. +const DEFAULT_POS = { + mouse: { right: "90px", bottom: "90px" }, + gamepad: { left: "90px", bottom: "90px" }, +}; + +// Mouse dot offset, normalized to -1..1; springs back to center when idle. +let mx = 0; +let my = 0; +// Latest gamepad analog values. +const gp = { lsx: 0, lsy: 0, rsx: 0, rsy: 0, lt: 0, rt: 0 }; + +let deviceLayer, mouseWidget, gamepadWidget, mouseDot, leftDot, rightDot, ltFill, rtFill; + +function clamp01(v) { + return Math.max(0, Math.min(1, v)); +} + +function applyVisibility() { + // In drag-to-position mode, show every enabled widget so it can be placed, + // regardless of live activity or controller connection. + const showMouse = flags.mouse; + const showGamepad = moveMode ? flags.gamepad : flags.gamepad && flags.connected; + mouseWidget.hidden = !showMouse; + gamepadWidget.hidden = !showGamepad; + deviceLayer.hidden = !(showMouse || showGamepad); +} + +// Place a widget at its saved position (or a default corner) and apply scale. +function placeWidget(el, id) { + const pos = lastConfig && lastConfig.widgetPositions && lastConfig.widgetPositions[id]; + el.style.left = el.style.top = el.style.right = el.style.bottom = "auto"; + if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.y)) { + el.style.left = `${pos.x}px`; + el.style.top = `${pos.y}px`; + } else { + const def = DEFAULT_POS[id]; + for (const k in def) el.style[k] = def[k]; + } + el.style.transform = `scale(${params.scale})`; +} + +function applyWidgetPositions() { + placeWidget(mouseWidget, "mouse"); + placeWidget(gamepadWidget, "gamepad"); +} + +function onDeviceState(s) { + if (!s) return; + flags.mouse = s.showMouseMovement; + flags.gamepad = s.showGamepad; + flags.connected = s.gamepadConnected; + params.sensitivity = s.sensitivity || 1; + params.decay = s.decaySeconds > 0 ? s.decaySeconds : 0.0001; + + // Accumulate mouse motion as an impulse toward the movement direction. + mx = Math.max(-1, Math.min(1, mx + s.mouseDx * MOUSE_GAIN * params.sensitivity)); + my = Math.max(-1, Math.min(1, my + s.mouseDy * MOUSE_GAIN * params.sensitivity)); + + gp.lsx = s.lsX; + gp.lsy = s.lsY; + gp.rsx = s.rsX; + gp.rsy = s.rsY; + gp.lt = s.lt; + gp.rt = s.rt; + + applyVisibility(); +} + +/** Config drives initial + toggle-off visibility (device-state only fires on activity). */ +function onConfig(c) { + if (!c) return; + lastConfig = c; + flags.mouse = c.showMouseMovement; + flags.gamepad = c.showGamepad; + params.scale = c.deviceWidgetScale || 1; + deviceLayer.style.setProperty("--dev-color", c.popupFontColor || "#ffffff"); + applyWidgetPositions(); + applyVisibility(); +} + +let lastTs = 0; +function frame(ts) { + const dt = lastTs ? (ts - lastTs) / 1000 : 0; + lastTs = ts; + + // Spring the mouse dot back to center with the configured time constant. + const factor = Math.exp(-dt / params.decay); + mx *= factor; + my *= factor; + if (Math.abs(mx) < 0.001) mx = 0; + if (Math.abs(my) < 0.001) my = 0; + mouseDot.style.transform = `translate(${mx * MOUSE_MAX_OFFSET}px, ${my * MOUSE_MAX_OFFSET}px)`; + + // Gamepad: invert Y so pushing the stick up moves the dot up. + leftDot.style.transform = `translate(${gp.lsx * STICK_MAX_OFFSET}px, ${-gp.lsy * STICK_MAX_OFFSET}px)`; + rightDot.style.transform = `translate(${gp.rsx * STICK_MAX_OFFSET}px, ${-gp.rsy * STICK_MAX_OFFSET}px)`; + ltFill.style.height = `${clamp01(gp.lt) * 100}%`; + rtFill.style.height = `${clamp01(gp.rt) * 100}%`; + + requestAnimationFrame(frame); +} + +async function init() { + deviceLayer = document.getElementById("deviceLayer"); + mouseWidget = document.getElementById("mouseWidget"); + gamepadWidget = document.getElementById("gamepadWidget"); + mouseDot = document.getElementById("mouseDot"); + leftDot = document.querySelector("#leftStick .stick-dot"); + rightDot = document.querySelector("#rightStick .stick-dot"); + ltFill = document.getElementById("ltFill"); + rtFill = document.getElementById("rtFill"); + + try { + onConfig(await core.invoke("get_config")); + } catch { + // overlay.js surfaces config errors; widgets just stay hidden. + } + + await tauriEvent.listen("device-state", (e) => onDeviceState(e.payload)); + await tauriEvent.listen("config-updated", (e) => onConfig(e.payload)); + // Drag-to-position: reveal enabled widgets so they can be placed; on exit, + // re-place them (undoing any unsaved drag) and restore normal visibility. + await tauriEvent.listen("overlay-move", (e) => { + moveMode = !!e.payload; + applyWidgetPositions(); + applyVisibility(); + }); + requestAnimationFrame(frame); +} + +window.addEventListener("DOMContentLoaded", init); +})(); diff --git a/src/index.html b/src/index.html index bdf90fa..4811020 100644 --- a/src/index.html +++ b/src/index.html @@ -6,9 +6,26 @@ YAKC - Yet Another Key Caster + + +

+ + diff --git a/src/key_labels.js b/src/key_labels.js index 233f2c4..3cbfe58 100644 --- a/src/key_labels.js +++ b/src/key_labels.js @@ -22,6 +22,10 @@ function groupLabel(group) { return "Function keys (F1–F12)"; case "system": return "System (Escape, Caps Lock, Print Screen, …)"; + case "mouse": + return "Mouse (scroll wheel)"; + case "gamepad": + return "Gamepad / controller"; default: return "Other"; } @@ -37,7 +41,7 @@ function buildForm() { (groups[key.group] ??= []).push(key); } - const groupOrder = ["modifier", "editing", "navigation", "numpad", "function", "system", "other"]; + const groupOrder = ["modifier", "editing", "navigation", "numpad", "function", "system", "mouse", "gamepad", "other"]; for (const g of groupOrder) { const keys = groups[g]; diff --git a/src/key_select.css b/src/key_select.css new file mode 100644 index 0000000..bdb1ddf --- /dev/null +++ b/src/key_select.css @@ -0,0 +1,154 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; +} + +body { + margin: 0; + display: flex; + flex-direction: column; + height: 100vh; +} + +header { + padding: 12px 16px 0; +} + +#backLink { + font-size: 13px; + text-decoration: none; + opacity: 0.7; +} + +#backLink:hover { + opacity: 1; + text-decoration: underline; +} + +header h1 { + margin: 8px 0 4px; + font-size: 18px; +} + +header p { + margin: 0 0 8px; + font-size: 12px; + opacity: 0.7; + max-width: 640px; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0 10px; +} + +.toolbar button { + padding: 5px 12px; + border-radius: 7px; + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + background: transparent; + color: inherit; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.toolbar button:hover { + background: color-mix(in srgb, currentColor 10%, transparent); +} + +#count { + font-size: 12px; + opacity: 0.6; + margin-left: auto; +} + +main { + flex: 1; + overflow: auto; + padding: 8px 16px 16px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +/* A dark panel that mimics the live overlay so the preview reads as the real + on-screen keyboard. */ +#preview { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px; + border-radius: 12px; + background: #111; + --kb-unit: 40px; +} + +.kb-row { + display: flex; + gap: 6px; + justify-content: center; +} + +.kb-key { + flex-basis: 0; + min-width: var(--kb-unit); + height: var(--kb-unit); + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.45); + font: 13px/1 system-ui, sans-serif; + cursor: pointer; + transition: background-color 90ms ease-out, color 90ms ease-out, border-color 90ms ease-out; +} + +/* The arrow row is narrower and never stretches. */ +#preview .kb-row:last-child .kb-key { + flex-grow: 0 !important; + min-width: var(--kb-unit); +} + +.kb-key:hover { + border-color: rgba(255, 255, 255, 0.4); +} + +.kb-key.selected { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; +} + +footer { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); +} + +footer button { + padding: 8px 18px; + border-radius: 8px; + border: none; + background: #3b82f6; + color: white; + font: inherit; + cursor: pointer; +} + +footer button:hover { + background: #2f6fe0; +} + +#status { + font-size: 12px; + opacity: 0.8; +} diff --git a/src/key_select.html b/src/key_select.html new file mode 100644 index 0000000..353af78 --- /dev/null +++ b/src/key_select.html @@ -0,0 +1,41 @@ + + + + + + + YAKC — Pick Keys to Show + + + + +
+ ← Back to Settings +

Pick which keys to show

+

+ Click keys to include or exclude them from the on-screen keyboard. + Caps are shown for your real layout. With every key selected the whole + keyboard shows. +

+
+ + + + +
+
+ +
+
+
+ + + + + + + + diff --git a/src/key_select.js b/src/key_select.js new file mode 100644 index 0000000..5dbda23 --- /dev/null +++ b/src/key_select.js @@ -0,0 +1,121 @@ +/** + * YAKC "pick which keys to show" page: a clickable mock-up of the on-screen + * keyboard. Each cap toggles whether that physical key appears on the live + * overlay keyboard. Saved as `keyboardVisibleKeys` (W3C codes); an empty list + * means "show the whole keyboard", so selecting every key stores [] to stay + * future-proof as the layout grows. + */ + +const { core } = window.__TAURI__; + +const ROWS = window.YAKC_KEYBOARD_ROWS || []; +const ALL_CODES = ROWS.flat().map(([code]) => code); + +const selected = new Set(); // physical codes currently included +const capById = new Map(); // code -> cap element + +function build() { + const preview = document.getElementById("preview"); + preview.textContent = ""; + for (const row of ROWS) { + const rowEl = document.createElement("div"); + rowEl.className = "kb-row"; + for (const [code, label, width] of row) { + const key = document.createElement("button"); + key.type = "button"; + key.className = "kb-key"; + key.textContent = label; + key.style.flexGrow = String(width || 1); + key.addEventListener("click", () => toggle(code)); + rowEl.appendChild(key); + capById.set(code, key); + } + preview.appendChild(rowEl); + } + render(); +} + +function toggle(code) { + if (selected.has(code)) selected.delete(code); + else selected.add(code); + render(); +} + +function render() { + for (const [code, el] of capById) { + el.classList.toggle("selected", selected.has(code)); + } + document.getElementById("count").textContent = + `${selected.size} of ${ALL_CODES.length} keys shown`; +} + +// Relabel caps to the user's real OS layout, matching the live keyboard. +async function applyLayout() { + try { + const labels = await core.invoke("get_key_labels"); + for (const [code, label] of Object.entries(labels || {})) { + const el = capById.get(code); + if (el && label && label !== " ") { + el.textContent = label.length === 1 ? label.toUpperCase() : label; + } + } + } catch { + // defaults are fine if the OS layout can't be read + } +} + +async function save() { + const status = document.getElementById("status"); + try { + // Every key selected → store [] ("show all"); otherwise the explicit set, + // in layout order for a stable, readable config file. + const list = + selected.size === ALL_CODES.length ? [] : ALL_CODES.filter((c) => selected.has(c)); + + const fullConfig = await core.invoke("get_config"); + fullConfig.keyboardVisibleKeys = list; + await core.invoke("save_config", { config: fullConfig }); + status.textContent = "Saved ✓"; + } catch (err) { + status.textContent = `Failed to save: ${err}`; + } + clearTimeout(save._timer); + save._timer = setTimeout(() => (status.textContent = ""), 4000); +} + +async function init() { + let list = []; + try { + const config = await core.invoke("get_config"); + list = Array.isArray(config.keyboardVisibleKeys) ? config.keyboardVisibleKeys : []; + } catch { + // treat as "show all" if config can't load + } + // Empty selection means "show everything" — reflect that as all-selected. + const initial = list.length ? list : ALL_CODES; + for (const code of initial) { + if (ALL_CODES.includes(code)) selected.add(code); + } + + build(); + await applyLayout(); + + document.getElementById("saveBtn").addEventListener("click", save); + document.getElementById("selectAll").addEventListener("click", () => { + for (const code of ALL_CODES) selected.add(code); + render(); + }); + document.getElementById("clearAll").addEventListener("click", () => { + selected.clear(); + render(); + }); + document.getElementById("invert").addEventListener("click", () => { + for (const code of ALL_CODES) { + if (selected.has(code)) selected.delete(code); + else selected.add(code); + } + render(); + }); +} + +window.addEventListener("DOMContentLoaded", init); diff --git a/src/keyboard-layout.js b/src/keyboard-layout.js new file mode 100644 index 0000000..bdb4c0c --- /dev/null +++ b/src/keyboard-layout.js @@ -0,0 +1,19 @@ +/** + * Shared physical-keyboard layout for YAKC's on-screen keyboard. + * + * Single source of truth for both the live overlay (keyboard.js) and the + * "pick which keys to show" selector (key_select.js), so the two can never + * drift out of sync. Keyed by PHYSICAL key position (W3C KeyboardEvent.code), + * so the right cap lights up on any layout (QWERTY/QWERTZ/AZERTY/…). + * + * Each entry: [code, default label, widthUnits?] + */ +window.YAKC_KEYBOARD_ROWS = [ + [["Escape", "Esc", 1.5], ["F1", "F1"], ["F2", "F2"], ["F3", "F3"], ["F4", "F4"], ["F5", "F5"], ["F6", "F6"], ["F7", "F7"], ["F8", "F8"], ["F9", "F9"], ["F10", "F10"], ["F11", "F11"], ["F12", "F12"]], + [["Backquote", "`"], ["Digit1", "1"], ["Digit2", "2"], ["Digit3", "3"], ["Digit4", "4"], ["Digit5", "5"], ["Digit6", "6"], ["Digit7", "7"], ["Digit8", "8"], ["Digit9", "9"], ["Digit0", "0"], ["Minus", "-"], ["Equal", "="], ["Backspace", "⌫", 2]], + [["Tab", "Tab", 1.5], ["KeyQ", "Q"], ["KeyW", "W"], ["KeyE", "E"], ["KeyR", "R"], ["KeyT", "T"], ["KeyY", "Y"], ["KeyU", "U"], ["KeyI", "I"], ["KeyO", "O"], ["KeyP", "P"], ["BracketLeft", "["], ["BracketRight", "]"], ["Backslash", "\\", 1.5]], + [["CapsLock", "Caps", 1.75], ["KeyA", "A"], ["KeyS", "S"], ["KeyD", "D"], ["KeyF", "F"], ["KeyG", "G"], ["KeyH", "H"], ["KeyJ", "J"], ["KeyK", "K"], ["KeyL", "L"], ["Semicolon", ";"], ["Quote", "'"], ["Enter", "Enter", 2.25]], + [["ShiftLeft", "Shift", 2.25], ["KeyZ", "Z"], ["KeyX", "X"], ["KeyC", "C"], ["KeyV", "V"], ["KeyB", "B"], ["KeyN", "N"], ["KeyM", "M"], ["Comma", ","], ["Period", "."], ["Slash", "/"], ["ShiftRight", "Shift", 2.25]], + [["ControlLeft", "Ctrl", 1.5], ["MetaLeft", "Super", 1.25], ["AltLeft", "Alt", 1.25], ["Space", "", 6.25], ["AltRight", "Alt", 1.25], ["MetaRight", "Super", 1.25], ["ControlRight", "Ctrl", 1.5]], + [["ArrowLeft", "←"], ["ArrowUp", "↑"], ["ArrowDown", "↓"], ["ArrowRight", "→"]], +]; diff --git a/src/keyboard.js b/src/keyboard.js new file mode 100644 index 0000000..fc891c5 --- /dev/null +++ b/src/keyboard.js @@ -0,0 +1,258 @@ +/** + * YAKC on-screen keyboard ("keyboard" display style). + * + * Driven by the structured `key-flash` event from Rust, keyed by PHYSICAL key + * position (W3C KeyboardEvent.code style, e.g. "KeyY", "ShiftLeft"), so the + * correct cap lights up on any layout (QWERTY/QWERTZ/AZERTY/…) and left/right + * modifiers are distinct. Cap labels default to a US layout but are relabeled + * live from the character the OS actually produced, so the displayed keyboard + * matches the user's real layout as they type. + * + * Payloads: { code, label? } → a regular key press (flash + relabel) + * { code, pressed } → a modifier down(true)/up(false) (hold-highlight) + * + * Wrapped in an IIFE (classic script sharing global scope with overlay.js). + */ + +(() => { + const { event: tauriEvent, core } = window.__TAURI__; + + // Physical layout: [code, default label, widthUnits?]. Shared with the + // key-selector via keyboard-layout.js so the two never drift. + const ROWS = window.YAKC_KEYBOARD_ROWS || []; + + const FLASH_MS = 180; + const capById = new Map(); // code -> element + let keyboard; + let builtSignature = null; // which visible-key set is currently rendered + + function num(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + // Labels for otherwise-blank caps, shown only in compact mode where the wide + // Space bar needs a glyph to read as a key. + const COMPACT_LABEL = { Space: "␣" }; + + // Keys at least this wide (only Space) stretch to fill to their row's right + // edge instead of being a lone square. + const STRETCH_MIN_UNITS = 3; + + // Build the keyboard. With no selection the whole keyboard renders (flex rows, + // as designed). With a selection we switch to a compact grid of uniform square + // caps, packed tightly, so a trimmed keyboard has no wide-key gaps and no + // empty panel — just the keys you picked. + function build(visible) { + keyboard.textContent = ""; + capById.clear(); + keyboard.classList.toggle("kb-grid", Boolean(visible)); + if (visible) buildTrimmed(visible); + else buildFull(); + } + + function buildFull() { + for (const row of ROWS) { + const rowEl = document.createElement("div"); + rowEl.className = "kb-row"; + if (row.some(([code]) => code.startsWith("Arrow"))) rowEl.classList.add("kb-arrows"); + for (const [code, label, width] of row) { + const key = document.createElement("div"); + key.className = "kb-key"; + key.textContent = label; + key.style.flexGrow = String(width || 1); + rowEl.appendChild(key); + capById.set(code, key); + } + keyboard.appendChild(rowEl); + } + } + + function buildTrimmed(visible) { + // Collect selected caps tagged with their column INDEX in the original row + // (aligning by index, not pixel width, so Tab & Caps are both "column 0", + // Q & A both "column 1", …). + const selected = []; + const usedCols = new Set(); + for (const row of ROWS) { + row.forEach(([code, label, width], index) => { + if (visible.has(code)) { + selected.push({ code, label, index, wide: (width || 1) >= STRETCH_MIN_UNITS }); + usedCols.add(index); + } + }); + } + if (!selected.length) return; + + // Horizontal: drop columns nobody uses so unselected keys leave no gap. + const colMap = new Map([...usedCols].sort((a, b) => a - b).map((c, i) => [c, i])); + const cols = colMap.size; + + // Vertical gravity: each column stacks its caps from the top, so an empty + // slot above (e.g. an unselected Caps over Shift) is removed and rows merge + // when they don't collide. A cap's visual row = how many selected caps are + // above it in the same column. Preserves "A under Q" (same column) while + // pulling everything as tight as it goes. + const depth = new Map(); + const rows = []; + for (const p of selected) { + const r = depth.get(p.index) || 0; + depth.set(p.index, r + 1); + (rows[r] ||= []).push(p); + } + keyboard.style.setProperty("--kb-cols", String(cols)); + + rows.forEach((picks, rowIndex) => { + picks.sort((a, b) => colMap.get(a.index) - colMap.get(b.index)); + picks.forEach((p, i) => { + const key = document.createElement("div"); + key.className = "kb-key"; + key.textContent = p.label || COMPACT_LABEL[p.code] || ""; + let start = colMap.get(p.index) + 1; // 1-based grid line + let span = 1; + // A trailing wide key (Space) packs against the previous cap and fills to + // the right edge, reading as a spacebar rather than a lone square. + if (p.wide && i === picks.length - 1) { + const prev = i > 0 ? colMap.get(picks[i - 1].index) + 1 : 0; + start = prev + 1; + span = Math.max(1, cols - prev); + } + key.style.gridRow = String(rowIndex + 1); + key.style.gridColumn = `${start} / span ${span}`; + keyboard.appendChild(key); + capById.set(p.code, key); + }); + }); + } + + // A stable signature of the visible-key selection, so we only rebuild the DOM + // (and re-fetch layout labels) when the selection actually changes. + function visibleFrom(config) { + const list = Array.isArray(config.keyboardVisibleKeys) ? config.keyboardVisibleKeys : []; + return list.length ? new Set(list) : null; // null = show everything + } + + function signatureOf(visible) { + return visible ? [...visible].sort().join(",") : "*"; + } + + function relabel(code, label) { + const el = capById.get(code); + if (!el || label == null || label === " ") return; + el.textContent = label.length === 1 ? label.toUpperCase() : label; + } + + function onKeyFlash(s) { + if (!s || !s.code) return; + if (s.label != null) relabel(s.code, s.label); + const el = capById.get(s.code); + if (!el) return; + + if (s.pressed === true) { + clearTimeout(el._flashTimer); + el.classList.add("active"); // held: stays lit until release + return; + } + if (s.pressed === false) { + el.classList.remove("active"); + return; + } + // Regular key: brief flash. + el.classList.add("active"); + clearTimeout(el._flashTimer); + el._flashTimer = setTimeout(() => el.classList.remove("active"), FLASH_MS); + } + + // Anchor the keyboard per the shared position/offsets (so drag-to-position + // works), mirroring overlay.js, plus the widget scale. + function applyPosition(config) { + const scale = config.deviceWidgetScale || 1; + const [vertical, horizontal] = (config.position || "top-left").split("-"); + + keyboard.style.bottom = "auto"; + if (vertical === "top") { + keyboard.style.top = `${num(config.topOffset, 0)}px`; + } else if (vertical === "bottom") { + keyboard.style.top = "auto"; + keyboard.style.bottom = `${num(config.bottomOffset, 0)}px`; + } else { + keyboard.style.top = "50%"; + } + + keyboard.style.right = "auto"; + if (horizontal === "left") { + keyboard.style.left = `${num(config.leftOffset, 0)}px`; + } else if (horizontal === "right") { + keyboard.style.left = "auto"; + keyboard.style.right = `${num(config.rightOffset, 0)}px`; + } else { + keyboard.style.left = "50%"; + } + + let anchor = ""; + if (horizontal === "center") anchor = "translateX(-50%)"; + else if (!horizontal) anchor = "translate(-50%, -50%)"; + keyboard.style.transform = `${anchor} scale(${scale})`.trim(); + const vpart = vertical === "bottom" ? "bottom" : vertical === "top" ? "top" : "center"; + const hpart = horizontal === "right" ? "right" : horizontal === "left" ? "left" : "center"; + keyboard.style.transformOrigin = `${vpart} ${hpart}`; + } + + let lastConfig = null; + + // Returns true if the keyboard DOM was rebuilt (selection changed), so the + // caller knows to re-fetch OS layout labels for the freshly created caps. + function applyConfig(config) { + if (!config) return false; + lastConfig = config; + keyboard.hidden = config.displayStyle !== "keyboard"; + keyboard.style.setProperty("--kb-color", config.popupFontColor || "#ffffff"); + keyboard.style.setProperty("--kb-bg", config.popupBackgroundColor || "#000000"); + + const visible = visibleFrom(config); + const signature = signatureOf(visible); + let rebuilt = false; + if (signature !== builtSignature) { + build(visible); + builtSignature = signature; + rebuilt = true; + } + applyPosition(config); + return rebuilt; + } + + // Pre-label caps from the OS layout so QWERTZ/AZERTY/etc. render correctly + // right away (no relabel-on-press flicker). Falls back silently if empty. + async function applyLayout() { + try { + const labels = await core.invoke("get_key_labels"); + for (const [code, label] of Object.entries(labels || {})) { + relabel(code, label); + } + } catch { + // live relabeling still handles it as keys are pressed + } + } + + async function init() { + keyboard = document.getElementById("keyboard"); + try { + applyConfig(await core.invoke("get_config")); + } catch { + build(null); // config unavailable: show the full keyboard, stays hidden + } + await applyLayout(); + await tauriEvent.listen("key-flash", (e) => onKeyFlash(e.payload)); + await tauriEvent.listen("config-updated", (e) => { + applyConfig(e.payload); + applyLayout(); // re-detect if the layout override / visible-key set changed + }); + // Leaving move mode (e.g. after Cancel) re-applies the configured position, + // since overlay.js may have moved the keyboard live during the drag. + await tauriEvent.listen("overlay-move", (e) => { + if (!e.payload && lastConfig) applyPosition(lastConfig); + }); + } + + window.addEventListener("DOMContentLoaded", init); +})(); diff --git a/src/obs.html b/src/obs.html new file mode 100644 index 0000000..11238cb --- /dev/null +++ b/src/obs.html @@ -0,0 +1,33 @@ + + + + + + YAKC — OBS overlay + + + + + + + + + +
+ + + + + diff --git a/src/overlay.js b/src/overlay.js index c41ac2b..204c3ba 100644 --- a/src/overlay.js +++ b/src/overlay.js @@ -82,6 +82,9 @@ function applyConfigStyles() { } else { popupArea.style.transform = "none"; } + + // Keyboard-skin mode hides the popup stack (the keyboard renders instead). + popupArea.style.display = config.displayStyle === "keyboard" ? "none" : "flex"; } function renderPopup(popup) { @@ -128,6 +131,8 @@ function removePopup(popup) { function onPopupOp(payload) { if (!config || !payload || !payload.op) return; + // In keyboard-skin mode the on-screen keyboard (keyboard.js) renders instead. + if (config.displayStyle === "keyboard") return; const now = Date.now(); const inactiveMs = num(config.popupInactiveAfterSeconds, 0.5) * 1000; @@ -181,6 +186,246 @@ function showNotice(message) { notice._timer = setTimeout(() => (notice.hidden = true), 30000); } +// Drag-to-position: the overlay becomes interactive (Rust drops click-through) +// and EVERY visible overlay object — the keys display (on-screen keyboard or a +// popup handle), the mouse widget and the gamepad widget — becomes an +// independently draggable target with a name tag and a hover highlight, so +// overlapping widgets can be told apart and moved apart. Save persists each +// object's position; the keys display uses position=top-left + offsets, the +// device widgets use config.widgetPositions[id]. Only ever runs in the native +// overlay — the OBS browser page never receives the "overlay-move" event. +let moveState = null; + +// Makes one element a draggable move-target: adds the outline/label, converts it +// to left/top so it can be moved freely, and wires drag + hover handlers. +function makeMoveTarget(el, kind, label, state) { + const rect = el.getBoundingClientRect(); + el.classList.add("move-target"); + el.dataset.moveLabel = label; + el.style.pointerEvents = "auto"; + el.style.cursor = "move"; + el.style.zIndex = "13"; + el.style.right = "auto"; + el.style.bottom = "auto"; + el.style.left = `${rect.left}px`; + el.style.top = `${rect.top}px`; + + const onDown = (e) => { + const r = el.getBoundingClientRect(); + state.active = el; + state.offX = e.clientX - r.left; + state.offY = e.clientY - r.top; + e.preventDefault(); + e.stopPropagation(); + }; + const onEnter = () => el.classList.add("move-hover"); + const onLeave = () => el.classList.remove("move-hover"); + el.addEventListener("mousedown", onDown); + el.addEventListener("mouseenter", onEnter); + el.addEventListener("mouseleave", onLeave); + state.targets.push({ el, kind, onDown, onEnter, onLeave }); +} + +function enterMoveMode() { + if (moveState) return; + const state = { targets: [], active: null, offX: 0, offY: 0 }; + + const backdrop = document.createElement("div"); + backdrop.id = "moveBackdrop"; + document.body.appendChild(backdrop); + + // The keys display: the real keyboard, or a labeled handle for the popup stack. + const keyboardEl = document.getElementById("keyboard"); + let handle = null; + if (config.displayStyle === "keyboard" && keyboardEl) { + keyboardEl.style.transformOrigin = "top left"; + keyboardEl.style.transform = `scale(${config.deviceWidgetScale || 1})`; + makeMoveTarget(keyboardEl, "keys", "Keyboard", state); + } else { + handle = document.createElement("div"); + handle.id = "moveHandle"; + handle.textContent = "Keys / popups appear here"; + const rect = popupArea.getBoundingClientRect(); + handle.style.left = `${Math.min(Math.max(rect.left || 40, 0), window.innerWidth - 240)}px`; + handle.style.top = `${Math.min(Math.max(rect.top || 40, 0), window.innerHeight - 80)}px`; + document.body.appendChild(handle); + makeMoveTarget(handle, "keys", "Keys / popups", state); + } + + // Device widgets as separate targets. Force them visible (devices.js also does + // this on the overlay-move event, but not depending on listener order lets us + // read their real position) before grabbing them. + const deviceLayer = document.getElementById("deviceLayer"); + if (config.showMouseMovement) { + const el = document.getElementById("mouseWidget"); + if (el) { + deviceLayer.hidden = false; + el.hidden = false; + makeMoveTarget(el, "mouse", "Mouse", state); + } + } + if (config.showGamepad) { + const el = document.getElementById("gamepadWidget"); + if (el) { + deviceLayer.hidden = false; + el.hidden = false; + makeMoveTarget(el, "gamepad", "Gamepad", state); + } + } + + // Alignment guides: snap the dragged object's edges/center to the other + // objects' edges/centers and the screen center, drawing a guide line on snap. + state.snap = config.snapToGuides !== false; + const guideV = document.createElement("div"); + guideV.className = "move-guide v"; + guideV.hidden = true; + const guideH = document.createElement("div"); + guideH.className = "move-guide h"; + guideH.hidden = true; + document.body.append(guideV, guideH); + state.guideV = guideV; + state.guideH = guideH; + + const onMove = (e) => { + if (!state.active) return; + const rect = state.active.getBoundingClientRect(); + let x = Math.max(e.clientX - state.offX, 0); + let y = Math.max(e.clientY - state.offY, 0); + + let lineX = null; + let lineY = null; + if (state.snap) { + const SNAP = 8; + // Candidate lines from every other object + the screen center. + const vLines = [window.innerWidth / 2]; + const hLines = [window.innerHeight / 2]; + for (const t of state.targets) { + if (t.el === state.active) continue; + const r = t.el.getBoundingClientRect(); + vLines.push(r.left, r.left + r.width / 2, r.right); + hLines.push(r.top, r.top + r.height / 2, r.bottom); + } + // Snap the nearest of {left, center, right} to the nearest vertical line. + let bestX = SNAP + 1; + for (const line of vLines) { + for (const anchor of [x, x + rect.width / 2, x + rect.width]) { + const d = Math.abs(anchor - line); + if (d < bestX) { + bestX = d; + x += line - anchor; + lineX = line; + } + } + } + let bestY = SNAP + 1; + for (const line of hLines) { + for (const anchor of [y, y + rect.height / 2, y + rect.height]) { + const d = Math.abs(anchor - line); + if (d < bestY) { + bestY = d; + y += line - anchor; + lineY = line; + } + } + } + } + + state.active.style.left = `${x}px`; + state.active.style.top = `${y}px`; + guideV.hidden = lineX === null; + if (lineX !== null) guideV.style.left = `${lineX}px`; + guideH.hidden = lineY === null; + if (lineY !== null) guideH.style.top = `${lineY}px`; + }; + const onUp = () => { + state.active = null; + guideV.hidden = true; + guideH.hidden = true; + }; + state.onMove = onMove; + state.onUp = onUp; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + + const toolbar = document.createElement("div"); + toolbar.id = "moveToolbar"; + const snapBtn = document.createElement("button"); + snapBtn.className = "move-snap"; + const updateSnapBtn = () => { + snapBtn.textContent = `Snapping: ${state.snap ? "On" : "Off"}`; + snapBtn.classList.toggle("off", !state.snap); + }; + updateSnapBtn(); + snapBtn.addEventListener("click", () => { + state.snap = !state.snap; + updateSnapBtn(); + if (!state.snap) { + guideV.hidden = true; + guideH.hidden = true; + } + }); + const saveBtn = document.createElement("button"); + saveBtn.textContent = "Save positions"; + saveBtn.className = "move-save"; + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Cancel"; + cancelBtn.className = "move-cancel"; + toolbar.append(snapBtn, saveBtn, cancelBtn); + document.body.appendChild(toolbar); + + saveBtn.addEventListener("click", async () => { + const positions = { ...(config.widgetPositions || {}) }; + for (const t of state.targets) { + const rect = t.el.getBoundingClientRect(); + if (t.kind === "keys") { + config.position = "top-left"; + config.leftOffset = Math.round(rect.left); + config.topOffset = Math.round(rect.top); + config.rightOffset = 0; + config.bottomOffset = 0; + } else { + positions[t.kind] = { x: Math.round(rect.left), y: Math.round(rect.top) }; + } + } + config.widgetPositions = positions; + config.snapToGuides = state.snap; + try { + await core.invoke("save_config", { config }); + } catch { + // config-updated won't fire; leaving move mode still restores state. + } + await core.invoke("end_overlay_move"); + }); + cancelBtn.addEventListener("click", () => core.invoke("end_overlay_move")); + + moveState = { backdrop, handle, toolbar, state }; +} + +function exitMoveMode() { + if (!moveState) return; + const { backdrop, handle, toolbar, state } = moveState; + window.removeEventListener("mousemove", state.onMove); + window.removeEventListener("mouseup", state.onUp); + for (const t of state.targets) { + t.el.removeEventListener("mousedown", t.onDown); + t.el.removeEventListener("mouseenter", t.onEnter); + t.el.removeEventListener("mouseleave", t.onLeave); + t.el.classList.remove("move-target", "move-hover"); + delete t.el.dataset.moveLabel; + // Reset interaction styles; keyboard.js / devices.js re-apply real positions + // on the config-updated (save) or overlay-move (cancel) events. + t.el.style.pointerEvents = ""; + t.el.style.cursor = ""; + t.el.style.zIndex = ""; + } + if (state.guideV) state.guideV.remove(); + if (state.guideH) state.guideH.remove(); + backdrop.remove(); + if (handle) handle.remove(); + toolbar.remove(); + moveState = null; +} + async function init() { popupArea = document.getElementById("popupArea"); config = await core.invoke("get_config"); @@ -192,6 +437,9 @@ async function init() { applyConfigStyles(); }); await tauriEvent.listen("yakc-error", (e) => showNotice(e.payload)); + await tauriEvent.listen("overlay-move", (e) => + e.payload ? enterMoveMode() : exitMoveMode() + ); // Errors raised before this page was listening (e.g. missing input-device // permission detected during the first device scan). diff --git a/src/settings.css b/src/settings.css index 3494bed..1d88bed 100644 --- a/src/settings.css +++ b/src/settings.css @@ -29,7 +29,56 @@ header p { main { flex: 1; overflow-y: auto; - padding: 8px 16px; + padding: 0 16px 8px; +} + +/* Tab bar: sticky at the top of the scroll area. */ +.tabs { + position: sticky; + top: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 10px 0 8px; + background: Canvas; + border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); +} + +.tab-btn { + padding: 6px 14px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + font: inherit; + font-size: 13px; + cursor: pointer; + opacity: 0.7; +} + +.tab-btn:hover { + background: color-mix(in srgb, currentColor 8%, transparent); + opacity: 1; +} + +.tab-btn.active { + background: color-mix(in srgb, #3b82f6 22%, transparent); + border-color: color-mix(in srgb, #3b82f6 45%, transparent); + opacity: 1; +} + +.panels { + padding-top: 4px; +} + +.tab-panel[hidden] { + display: none; +} + +/* First heading in a panel shouldn't add a big top gap under the tabs. */ +.tab-panel > h2:first-child { + margin-top: 10px; } h2 { @@ -112,6 +161,202 @@ footer button:hover { opacity: 0.8; } +/* Wide fields (process picker) drop the two-column grid and stack. */ +.field--wide { + grid-template-columns: 1fr; +} + +.field--wide > label { + margin-bottom: 4px; +} + +/* Color input + preset swatches. */ +.color-field { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.swatches { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.swatch { + width: 18px; + height: 18px; + padding: 0; + border-radius: 4px; + border: 1px solid color-mix(in srgb, currentColor 30%, transparent); + cursor: pointer; +} + +.swatch:hover { + outline: 2px solid color-mix(in srgb, currentColor 40%, transparent); +} + +/* Profiles tab. */ +.profiles-active { + font-size: 13px; + margin: 4px 0 12px; + opacity: 0.8; +} + +.profiles-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} + +.profiles-select, +.profiles-name { + flex: 1; + min-width: 140px; + box-sizing: border-box; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + background: transparent; + color: inherit; + font: inherit; +} + +.profiles-btn { + padding: 6px 12px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + background: transparent; + color: inherit; + font: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.profiles-btn:hover { + background: color-mix(in srgb, currentColor 8%, transparent); +} + +.preset-btn { + background: color-mix(in srgb, #3b82f6 16%, transparent); + border-color: color-mix(in srgb, #3b82f6 40%, transparent); +} + +.preset-btn:hover { + background: color-mix(in srgb, #3b82f6 28%, transparent); +} + +/* Process picker: selected chips, a search box, and a scrollable checklist. */ +.proc-picker { + display: flex; + flex-direction: column; + gap: 8px; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + min-height: 24px; +} + +.chips-empty { + font-size: 12px; + opacity: 0.5; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 6px 3px 9px; + border-radius: 999px; + font-size: 12px; + background: color-mix(in srgb, #3b82f6 22%, transparent); + border: 1px solid color-mix(in srgb, #3b82f6 45%, transparent); +} + +.chip-x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} + +.chip-x:hover { + opacity: 1; + background: color-mix(in srgb, currentColor 20%, transparent); +} + +.proc-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.proc-controls .proc-search { + flex: 1; + box-sizing: border-box; + padding: 5px 7px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + background: transparent; + color: inherit; + font: inherit; +} + +.proc-controls .link-btn { + flex: none; +} + +.proc-list { + max-height: 200px; + overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 2px 12px; + padding: 8px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, currentColor 15%, transparent); + font-size: 12px; +} + +.proc-item { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.proc-item input { + width: 15px; + height: 15px; + flex: none; +} + +.proc-none { + grid-column: 1 / -1; + opacity: 0.6; + padding: 2px; +} + .link-btn { justify-self: start; padding: 6px 14px; diff --git a/src/settings.js b/src/settings.js index 7da5737..8d952fa 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,17 +1,44 @@ /** - * YAKC settings window: a plain form over every config field. + * YAKC settings window: a tabbed form over every config field. * Saving persists config.json and live-applies to the overlay. */ -const { core } = window.__TAURI__; +const { core, event: tauriEvent } = window.__TAURI__; -// Field schema: key must match the config JSON keys (camelCase). +const PROFILES_TAB = "Profiles"; + +// Cross-platform-safe font stacks offered in the font picker (free text still +// allowed via the combobox). +const FONT_CHOICES = [ + "Tahoma, sans-serif", + "Arial, sans-serif", + "Helvetica, sans-serif", + "Verdana, sans-serif", + "Segoe UI, sans-serif", + "system-ui, sans-serif", + "Georgia, serif", + "Times New Roman, serif", + "Courier New, monospace", + "Consolas, monospace", + "Menlo, monospace", + "Impact, sans-serif", +]; + +// Quick color presets shown as swatches beside each color input. +const COLOR_SWATCHES = [ + "#ffffff", "#000000", "#ff5555", "#ffb86c", "#f1fa8c", + "#50fa7b", "#8be9fd", "#bd93f9", "#ff79c6", "#6272a4", +]; + +// Field schema: key must match the config JSON keys (camelCase). `tab` groups +// sections into the tab bar (several sections can share one tab). const SECTIONS = [ { + tab: "Appearance", title: "Appearance", fields: [ { key: "popupFontSize", type: "number", label: "Font size (px)", min: 6 }, - { key: "popupFontFamily", type: "text", label: "Font family" }, + { key: "popupFontFamily", type: "combobox", label: "Font family", options: FONT_CHOICES, hint: "Pick one or type any CSS font stack" }, { key: "popupFontWeight", type: "select", label: "Font weight", options: ["normal", "bold", "bolder", "lighter"] }, { key: "popupFontColor", type: "color", label: "Font color" }, { key: "popupBackgroundColor", type: "color", label: "Background color" }, @@ -21,10 +48,21 @@ const SECTIONS = [ ], }, { + tab: "Appearance", + title: "Timing", + fields: [ + { key: "popupFadeInSeconds", type: "number", label: "Fade duration (s)", min: 0, step: 0.1 }, + { key: "popupRemoveAfterSeconds", type: "number", label: "Remove popup after (s)", min: 0.1, step: 0.1 }, + { key: "popupInactiveAfterSeconds", type: "number", label: "New popup after inactivity (s)", min: 0, step: 0.1 }, + ], + }, + { + tab: "Position", title: "Position", fields: [ - { key: "showOnMonitor", type: "number", label: "Monitor index", hint: "0 = first monitor", min: 0, step: 1 }, + { key: "showOnMonitor", type: "monitor", label: "Monitor", hint: "Which screen to show the overlay on" }, { key: "position", type: "select", label: "Screen position", options: ["top-left", "top-center", "top-right", "center", "bottom-left", "bottom-center", "bottom-right"] }, + { key: "moveOverlay", type: "action", label: "Drag to position", buttonLabel: "Drag on screen…", command: "begin_overlay_move", hint: "Opens a draggable handle on the overlay; drop it where you want, then Save." }, { key: "topOffset", type: "number", label: "Top offset (px)" }, { key: "bottomOffset", type: "number", label: "Bottom offset (px)" }, { key: "leftOffset", type: "number", label: "Left offset (px)" }, @@ -32,17 +70,12 @@ const SECTIONS = [ ], }, { - title: "Timing", - fields: [ - { key: "popupFadeInSeconds", type: "number", label: "Fade duration (s)", min: 0, step: 0.1 }, - { key: "popupRemoveAfterSeconds", type: "number", label: "Remove popup after (s)", min: 0.1, step: 0.1 }, - { key: "popupInactiveAfterSeconds", type: "number", label: "New popup after inactivity (s)", min: 0, step: 0.1 }, - ], - }, - { + tab: "Input", title: "Input", fields: [ - { key: "displayMode", type: "select", label: "Display mode", options: ["text", "raw"], hint: "text: like a text editor — only typed characters, Backspace deletes. raw: every key (modifiers, ⌫, arrows, …)" }, + { key: "displayStyle", type: "select", label: "Overlay style", options: ["popups", "keyboard"], hint: "popups: fading key popups. keyboard: an on-screen keyboard that lights up as you type (great for tutorials/streams)." }, + { key: "keyboardVisibleKeys", type: "link", href: "key_select.html", buttonLabel: "Pick keys…", label: "Keys to show on keyboard", hint: "Keyboard style only: pick exactly which keys appear (e.g. just WASD + binds). Default shows the whole keyboard." }, + { key: "displayMode", type: "select", label: "Display mode", options: ["text", "raw"], hint: "Popups only. text: like a text editor — only typed characters, Backspace deletes. raw: every key (modifiers, ⌫, arrows, …)" }, { key: "showKeyboardClick", type: "bool", label: "Show keyboard clicks" }, { key: "showMouseClick", type: "bool", label: "Show mouse clicks" }, { key: "showMouseCoordinates", type: "bool", label: "Show mouse coordinates", hint: "Not available on Wayland (the compositor hides the cursor position)" }, @@ -55,6 +88,34 @@ const SECTIONS = [ ], }, { + tab: "Devices", + title: "Mouse (movement & scroll)", + fields: [ + { key: "showMouseMovement", type: "bool", label: "Show mouse movement", hint: "A dot-in-a-ring widget that reacts to how you move the mouse (works on Wayland too)" }, + { key: "showMouseScroll", type: "bool", label: "Show scroll wheel", hint: "Scroll ticks appear as popup tokens (Scroll↑ / Scroll↓)" }, + { key: "mouseMovementSensitivity", type: "number", label: "Movement sensitivity", min: 0.1, step: 0.1 }, + { key: "mouseMovementDecaySeconds", type: "number", label: "Spring-back time (s)", min: 0.05, step: 0.05, hint: "How long the dot takes to return to center once the mouse stops" }, + { key: "deviceWidgetScale", type: "number", label: "Widget size (scale)", min: 0.3, max: 5, step: 0.1, hint: "Size of the mouse & gamepad widgets" }, + ], + }, + { + tab: "Devices", + title: "Gamepad / controller", + fields: [ + { key: "showGamepad", type: "bool", label: "Show gamepad input", hint: "Buttons as popups; sticks & triggers in a widget. Works with any XInput / DualShock-style controller." }, + ], + }, + { + tab: "Streaming", + title: "OBS / browser source", + fields: [ + { key: "obsServerEnabled", type: "bool", label: "Enable OBS browser source", hint: "Serves the overlay at http://localhost:/overlay for an OBS Browser source (transparent, live). Enabling starts it immediately; changing the port needs a restart." }, + { key: "obsServerPort", type: "number", label: "Server port", min: 1, max: 65535, step: 1, hint: "Default 7238" }, + { key: "showOverlayOnScreen", type: "bool", label: "Show overlay on this screen", hint: "Turn off to display only in the OBS browser source (avoids showing twice or seeing it locally)." }, + ], + }, + { + tab: "Streaming", title: "Text-to-speech", fields: [ { key: "textToSpeech", type: "bool", label: "Speak every keystroke" }, @@ -62,97 +123,568 @@ const SECTIONS = [ ], }, { + tab: "Filter", title: "Process filter", fields: [ { key: "filter", type: "bool", label: "Enable process filter", hint: "Capture only while a listed app is focused" }, - { key: "filterProcessName", type: "list", label: "Process names", hint: "Comma-separated, e.g. obs.exe, code" }, - { key: "filterCheckEverySecond", type: "number", label: "Check interval (s)", min: 0.1, step: 0.1 }, + { key: "filterProcessName", type: "processlist", label: "Apps to capture in", hint: "Tick running apps, search to narrow the list, or type a name + Enter to add an app that isn't open yet" }, ], }, ]; +const TAB_STORAGE_KEY = "yakc.settings.tab"; + let config; +const procPickers = []; // { load } for each process picker, filled by buildForm function fieldId(key) { return `field-${key}`; } +// WebKitGTK (the Linux webview) sometimes doesn't paint DOM added after an async +// hop until the next reflow, so a freshly-loaded list looks empty until you +// interact with it. Toggling display forces an immediate re-layout + repaint. +function forceRepaint(el) { + el.style.display = "none"; + void el.offsetHeight; // read forces synchronous layout + el.style.display = ""; +} + +function makeLabel(field) { + const label = document.createElement("label"); + label.htmlFor = fieldId(field.key); + label.textContent = field.label; + if (field.hint) { + const hint = document.createElement("span"); + hint.className = "hint"; + hint.textContent = field.hint; + label.appendChild(hint); + } + return label; +} + function buildForm() { const form = document.getElementById("form"); form.textContent = ""; + procPickers.length = 0; + + const tabs = [...new Set(SECTIONS.map((s) => s.tab)), PROFILES_TAB]; + const nav = document.createElement("nav"); + nav.className = "tabs"; + const panels = document.createElement("div"); + panels.className = "panels"; + + const panelByTab = new Map(); + for (const tab of tabs) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "tab-btn"; + btn.textContent = tab; + btn.dataset.tab = tab; + btn.addEventListener("click", () => activateTab(tab)); + nav.appendChild(btn); + + const panel = document.createElement("section"); + panel.className = "tab-panel"; + panel.dataset.tab = tab; + panels.appendChild(panel); + panelByTab.set(tab, panel); + } for (const section of SECTIONS) { + const panel = panelByTab.get(section.tab); const heading = document.createElement("h2"); heading.textContent = section.title; - form.appendChild(heading); - - for (const field of section.fields) { - const row = document.createElement("div"); - row.className = "field"; - - const label = document.createElement("label"); - label.htmlFor = fieldId(field.key); - label.textContent = field.label; - if (field.hint) { - const hint = document.createElement("span"); - hint.className = "hint"; - hint.textContent = field.hint; - label.appendChild(hint); - } - row.appendChild(label); - - let input; - if (field.type === "select") { - input = document.createElement("select"); - for (const option of field.options) { - const el = document.createElement("option"); - el.value = option; - el.textContent = option; - input.appendChild(el); + panel.appendChild(heading); + for (const field of section.fields) buildField(field, panel); + } + + buildProfilesPanel(panelByTab.get(PROFILES_TAB)); + + form.append(nav, panels); + + const saved = localStorage.getItem(TAB_STORAGE_KEY); + activateTab(tabs.includes(saved) ? saved : tabs[0]); +} + +function activateTab(tab) { + for (const btn of document.querySelectorAll(".tab-btn")) { + btn.classList.toggle("active", btn.dataset.tab === tab); + } + for (const panel of document.querySelectorAll(".tab-panel")) { + panel.hidden = panel.dataset.tab !== tab; + } + // Load any process pickers on the now-visible tab. Rendering the checklist + // into a visible panel (not a display:none one) lets its grid lay out with a + // real width — otherwise the list only appears after the next reflow. + for (const picker of procPickers) { + if (picker.tab === tab) picker.load(); + } + try { + localStorage.setItem(TAB_STORAGE_KEY, tab); + } catch { + // private mode / storage disabled — non-fatal, just don't remember the tab + } +} + +// Builds one field's row(s) and appends them to `panel`. +function buildField(field, panel) { + if (field.type === "processlist") { + buildProcessPicker(field, panel); + return; + } + + const row = document.createElement("div"); + row.className = "field"; + row.appendChild(makeLabel(field)); + + let input; + if (field.type === "select") { + input = document.createElement("select"); + for (const option of field.options) { + const el = document.createElement("option"); + el.value = option; + el.textContent = option; + input.appendChild(el); + } + input.value = config[field.key]; + } else if (field.type === "combobox") { + input = document.createElement("input"); + input.type = "text"; + input.value = config[field.key]; + input.setAttribute("list", `dl-${field.key}`); + const dl = document.createElement("datalist"); + dl.id = `dl-${field.key}`; + for (const option of field.options) { + const el = document.createElement("option"); + el.value = option; + dl.appendChild(el); + } + row.appendChild(dl); + } else if (field.type === "monitor") { + // Populated live from get_monitors(); starts with the saved index so it is + // correct even before hydration and if enumeration fails. + input = document.createElement("select"); + const current = document.createElement("option"); + current.value = String(config[field.key] ?? 0); + current.textContent = `Monitor ${config[field.key] ?? 0}`; + input.appendChild(current); + input.value = current.value; + } else { + input = document.createElement("input"); + switch (field.type) { + case "bool": + input.type = "checkbox"; + input.checked = Boolean(config[field.key]); + break; + case "number": + input.type = "number"; + if (field.min !== undefined) input.min = field.min; + if (field.max !== undefined) input.max = field.max; + input.step = field.step ?? "any"; + input.value = config[field.key]; + break; + case "color": { + input.type = "color"; + input.value = config[field.key]; + const swatches = document.createElement("div"); + swatches.className = "swatches"; + for (const color of COLOR_SWATCHES) { + const sw = document.createElement("button"); + sw.type = "button"; + sw.className = "swatch"; + sw.style.background = color; + sw.title = color; + sw.addEventListener("click", () => (input.value = color)); + swatches.appendChild(sw); } + const wrap = document.createElement("div"); + wrap.className = "color-field"; + tagInput(input, field); + wrap.append(input, swatches); + row.appendChild(wrap); + panel.appendChild(row); + return; + } + case "list": + input.type = "text"; + input.value = (config[field.key] || []).join(", "); + break; + case "link": + input = document.createElement("button"); + input.className = "link-btn"; + input.textContent = field.buttonLabel || "Edit"; + input.addEventListener("click", () => { + window.location.href = field.href || "key_labels.html"; + }); + break; + case "action": + input = document.createElement("button"); + input.className = "link-btn"; + input.textContent = field.buttonLabel || "Run"; + input.addEventListener("click", () => core.invoke(field.command)); + break; + default: + input.type = "text"; input.value = config[field.key]; - } else { - input = document.createElement("input"); - switch (field.type) { - case "bool": - input.type = "checkbox"; - input.checked = Boolean(config[field.key]); - break; - case "number": - input.type = "number"; - if (field.min !== undefined) input.min = field.min; - if (field.max !== undefined) input.max = field.max; - input.step = field.step ?? "any"; - input.value = config[field.key]; - break; - case "color": - input.type = "color"; - input.value = config[field.key]; - break; - case "list": - input.type = "text"; - input.value = (config[field.key] || []).join(", "); - break; - case "link": - input = document.createElement("button"); - input.className = "link-btn"; - input.textContent = "Edit"; - input.addEventListener("click", () => { - window.location.href = "key_labels.html"; - }); - break; - default: - input.type = "text"; - input.value = config[field.key]; + } + } + + tagInput(input, field); + row.appendChild(input); + panel.appendChild(row); +} + +function tagInput(input, field) { + input.id = fieldId(field.key); + input.dataset.type = field.type; + input.dataset.key = field.key; +} + +// A searchable process picker: selected apps as removable chips (the saved +// value), a search box that both filters the running-app list AND adds a typed +// name on Enter, and a checklist of running apps. All three stay in sync. +function buildProcessPicker(field, panel) { + const labelRow = document.createElement("div"); + labelRow.className = "field field--wide"; + labelRow.appendChild(makeLabel(field)); + panel.appendChild(labelRow); + + const wrap = document.createElement("div"); + wrap.className = "field field--wide proc-picker"; + + // Hidden element is the source of truth read by collectForm. + const hidden = document.createElement("input"); + hidden.type = "hidden"; + tagInput(hidden, field); + + const selected = new Set((config[field.key] || []).map(String)); + const lowerSelected = () => new Set([...selected].map((s) => s.toLowerCase())); + const hasName = (name) => [...selected].some((s) => s.toLowerCase() === name.toLowerCase()); + + const chips = document.createElement("div"); + chips.className = "chips"; + + const search = document.createElement("input"); + search.type = "text"; + search.className = "proc-search"; + search.placeholder = "Search running apps, or type a name + Enter to add…"; + + const refresh = document.createElement("button"); + refresh.type = "button"; + refresh.className = "link-btn"; + refresh.textContent = "↻ Refresh"; + + const controls = document.createElement("div"); + controls.className = "proc-controls"; + controls.append(search, refresh); + + const list = document.createElement("div"); + list.className = "proc-list"; + list.textContent = "Loading running apps…"; + + wrap.append(hidden, chips, controls, list); + panel.appendChild(wrap); + + let running = []; + + const sync = () => (hidden.value = [...selected].join(", ")); + + function renderChips() { + chips.textContent = ""; + if (selected.size === 0) { + const empty = document.createElement("span"); + empty.className = "chips-empty"; + empty.textContent = "No apps selected yet."; + chips.appendChild(empty); + return; + } + for (const name of selected) { + const chip = document.createElement("span"); + chip.className = "chip"; + chip.appendChild(document.createTextNode(name)); + const x = document.createElement("button"); + x.type = "button"; + x.className = "chip-x"; + x.textContent = "×"; + x.title = `Remove ${name}`; + x.addEventListener("click", () => removeName(name)); + chip.appendChild(x); + chips.appendChild(chip); + } + } + + function addName(name) { + const n = name.trim(); + if (n && !hasName(n)) selected.add(n); + sync(); + renderChips(); + syncListChecks(); + } + + function removeName(name) { + for (const s of [...selected]) { + if (s.toLowerCase() === name.toLowerCase()) selected.delete(s); + } + sync(); + renderChips(); + syncListChecks(); + } + + function renderList() { + list.textContent = ""; + if (running.length === 0) { + list.textContent = "No running apps found."; + return; + } + const sel = lowerSelected(); + const q = search.value.trim().toLowerCase(); + let shown = 0; + for (const name of running) { + if (q && !name.toLowerCase().includes(q)) continue; + const item = document.createElement("label"); + item.className = "proc-item"; + item.dataset.name = name.toLowerCase(); + const box = document.createElement("input"); + box.type = "checkbox"; + box.checked = sel.has(name.toLowerCase()); + box.addEventListener("change", () => (box.checked ? addName(name) : removeName(name))); + item.append(box, document.createTextNode(name)); + list.appendChild(item); + shown++; + } + if (shown === 0) { + const none = document.createElement("div"); + none.className = "proc-none"; + none.textContent = q + ? `No running app matches “${search.value.trim()}”. Press Enter to add it anyway.` + : ""; + list.appendChild(none); + } + forceRepaint(list); + } + + // Re-check the visible list against the selection without rebuilding it. + function syncListChecks() { + const sel = lowerSelected(); + for (const item of list.querySelectorAll(".proc-item")) { + item.querySelector("input").checked = sel.has(item.dataset.name); + } + } + + search.addEventListener("input", renderList); + search.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + const value = search.value; + search.value = ""; + addName(value); + renderList(); + } + }); + refresh.addEventListener("click", () => load()); + + async function load() { + list.textContent = "Loading running apps…"; + try { + running = await core.invoke("get_running_processes"); + } catch { + running = []; + } + renderList(); + } + + sync(); + renderChips(); + procPickers.push({ tab: panel.dataset.tab, load }); +} + +// Rebuild the whole form after the config changed underneath us (profile load, +// preset, import), staying on the given tab. +async function reloadForm(activeTab) { + config = await core.invoke("get_config"); + buildForm(); + hydrateMonitors(); + if (activeTab) activateTab(activeTab); +} + +// The Profiles tab: named snapshots + bundled presets + import/export. Custom +// UI (not schema-driven), all wired to the profile backend commands. +function buildProfilesPanel(panel) { + const setStatus = (msg) => { + const status = document.getElementById("status"); + if (!status) return; + status.textContent = msg; + clearTimeout(setStatus._t); + setStatus._t = setTimeout(() => (status.textContent = ""), 4000); + }; + + const guard = async (fn) => { + try { + await fn(); + } catch (err) { + setStatus(`Error: ${err}`); + } + }; + + const heading = (text) => { + const h = document.createElement("h2"); + h.textContent = text; + return h; + }; + + const button = (label, cls, onClick) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = cls; + b.textContent = label; + b.addEventListener("click", onClick); + return b; + }; + + panel.appendChild(heading("Profiles")); + + const active = document.createElement("p"); + active.className = "profiles-active"; + panel.appendChild(active); + + // Row: profile dropdown + Load + Delete. + const select = document.createElement("select"); + select.className = "profiles-select"; + const pickRow = document.createElement("div"); + pickRow.className = "profiles-row"; + pickRow.append( + select, + button("Load", "profiles-btn", () => + guard(async () => { + const name = select.value; + if (!name) return; + await core.invoke("load_profile", { name }); + await reloadForm(PROFILES_TAB); + setStatus(`Loaded “${name}” ✓`); + }) + ), + button("Delete", "profiles-btn", () => + guard(async () => { + const name = select.value; + if (!name) return; + await core.invoke("delete_profile", { name }); + await refreshProfiles(); + setStatus(`Deleted “${name}”`); + }) + ) + ); + panel.appendChild(pickRow); + + // Row: name box + Save-as + Rename. + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.className = "profiles-name"; + nameInput.placeholder = "Profile name"; + const nameRow = document.createElement("div"); + nameRow.className = "profiles-row"; + nameRow.append( + nameInput, + button("Save current as", "profiles-btn", () => + guard(async () => { + const name = nameInput.value.trim(); + if (!name) return setStatus("Enter a profile name first"); + await core.invoke("save_profile", { name }); + nameInput.value = ""; + await refreshProfiles(); + setStatus(`Saved “${name}” ✓`); + }) + ), + button("Rename selected", "profiles-btn", () => + guard(async () => { + const old = select.value; + const next = nameInput.value.trim(); + if (!old || !next) return setStatus("Pick a profile and type a new name"); + await core.invoke("rename_profile", { old, new: next }); + nameInput.value = ""; + await refreshProfiles(); + setStatus(`Renamed to “${next}”`); + }) + ) + ); + panel.appendChild(nameRow); + + // Row: import / export. + const ioRow = document.createElement("div"); + ioRow.className = "profiles-row"; + ioRow.append( + button("Import JSON…", "profiles-btn", () => + guard(async () => { + const imported = await core.invoke("import_config"); + if (imported) { + await reloadForm(PROFILES_TAB); + setStatus("Imported config ✓"); } + }) + ), + button("Export JSON…", "profiles-btn", () => + guard(async () => { + const ok = await core.invoke("export_config"); + setStatus(ok ? "Exported config ✓" : "Export cancelled"); + }) + ) + ); + panel.appendChild(ioRow); + + // Bundled presets. + panel.appendChild(heading("Starter presets")); + const presetHint = document.createElement("p"); + presetHint.className = "hint"; + presetHint.textContent = + "Apply a starter look on top of your current settings, then tweak and save it as a profile."; + panel.appendChild(presetHint); + const presetRow = document.createElement("div"); + presetRow.className = "profiles-row"; + panel.appendChild(presetRow); + guard(async () => { + const presets = await core.invoke("list_presets"); + for (const name of presets) { + presetRow.appendChild( + button(name, "profiles-btn preset-btn", () => + guard(async () => { + await core.invoke("apply_preset", { name }); + await reloadForm(PROFILES_TAB); + setStatus(`Applied “${name}” preset ✓`); + }) + ) + ); + } + }); + + // Populate (and re-populate) the dropdown + active label. + async function refreshProfiles() { + const [names, current] = await Promise.all([ + core.invoke("list_profiles"), + core.invoke("get_active_profile"), + ]); + select.textContent = ""; + if (names.length === 0) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "(no saved profiles)"; + select.appendChild(opt); + select.disabled = true; + } else { + select.disabled = false; + for (const name of names) { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + select.appendChild(opt); } - input.id = fieldId(field.key); - input.dataset.type = field.type; - input.dataset.key = field.key; - row.appendChild(input); - form.appendChild(row); + if (current) select.value = current; } + active.textContent = current ? `Active profile: ${current}` : "No profile selected (custom settings)"; } + + // Expose so external events (tray, profiles-updated) can refresh it. + buildProfilesPanel._refresh = refreshProfiles; + guard(refreshProfiles); } function collectForm() { @@ -164,14 +696,22 @@ function collectForm() { updated[key] = input.checked; break; case "number": + case "monitor": updated[key] = Number(input.value) || 0; break; case "list": + case "processlist": updated[key] = input.value .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); break; + case "link": + case "action": + // Not inputs — a page link / a command button. Leave the config value + // untouched (spread above); collecting the button's empty string here + // would clobber real fields (e.g. keyLabelOverrides, a map). + break; default: updated[key] = input.value; } @@ -179,6 +719,29 @@ function collectForm() { return updated; } +// Fill each monitor dropdown with the real connected monitors, keeping the +// saved index selected. +async function hydrateMonitors() { + let monitors; + try { + monitors = await core.invoke("get_monitors"); + } catch { + return; // leave the saved-index fallback option in place + } + if (!Array.isArray(monitors) || monitors.length === 0) return; + for (const select of document.querySelectorAll('[data-type="monitor"]')) { + const chosen = select.value; + select.textContent = ""; + monitors.forEach((label, index) => { + const el = document.createElement("option"); + el.value = String(index); + el.textContent = label; + select.appendChild(el); + }); + select.value = Number(chosen) < monitors.length ? chosen : "0"; + } +} + async function save() { const status = document.getElementById("status"); try { @@ -199,8 +762,22 @@ async function init() { } catch { document.getElementById("configPath").textContent = "config.json"; } - buildForm(); + buildForm(); // activateTab() lazily loads the process picker when its tab shows + hydrateMonitors(); document.getElementById("saveBtn").addEventListener("click", save); + // Ctrl/Cmd+S saves from anywhere in the window. + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + save(); + } + }); + // The tray "Manage profiles…" opens settings on the Profiles tab. + tauriEvent.listen("open-profiles-tab", () => activateTab(PROFILES_TAB)); + // Keep the Profiles list fresh when a profile is switched from the tray. + tauriEvent.listen("profiles-updated", () => { + if (buildProfilesPanel._refresh) buildProfilesPanel._refresh(); + }); } window.addEventListener("DOMContentLoaded", init); diff --git a/src/style.css b/src/style.css index 5df5143..7f10e35 100644 --- a/src/style.css +++ b/src/style.css @@ -29,6 +29,288 @@ body { /* font, colors, radius, fade duration and max-width come from config via overlay.js */ } +/* Analog device widgets (mouse movement + gamepad sticks/triggers), rendered + by devices.js from `device-state` events. Centered near the bottom edge. */ +/* A logical group only — each widget positions itself, so they can be dragged + independently. Hiding the layer still hides both widgets. */ +#deviceLayer { + display: contents; +} + +[hidden] { + display: none !important; +} + +/* Each device widget is independently placed (left/top + scale set by + devices.js from its saved position, defaulting to opposite bottom corners). */ +#mouseWidget, +#gamepadWidget { + position: fixed; + display: flex; + align-items: center; + gap: 16px; + pointer-events: none; + transform-origin: top left; +} + +#mouseRing, +.stick { + position: relative; + border-radius: 50%; + border: 3px solid var(--dev-color, #ffffff); + background: rgba(0, 0, 0, 0.4); + opacity: 0.85; +} + +#mouseRing { + width: 90px; + height: 90px; +} + +.stick { + width: 76px; + height: 76px; +} + +#mouseDot, +.stick-dot { + position: absolute; + left: 50%; + top: 50%; + border-radius: 50%; + background: var(--dev-color, #ffffff); + box-shadow: 0 0 8px var(--dev-color, #ffffff); +} + +#mouseDot { + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; +} + +.stick-dot { + width: 14px; + height: 14px; + margin: -7px 0 0 -7px; +} + +.triggers { + display: flex; + gap: 10px; +} + +.trigger { + position: relative; + width: 28px; + height: 64px; + border-radius: 6px; + border: 2px solid var(--dev-color, #ffffff); + background: rgba(0, 0, 0, 0.4); + overflow: hidden; + display: flex; + align-items: flex-end; + justify-content: center; + opacity: 0.85; +} + +.trigger-fill { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 0%; + background: var(--dev-color, #ffffff); + opacity: 0.55; +} + +.trigger span { + position: relative; + font: 11px sans-serif; + color: var(--dev-color, #ffffff); + padding-bottom: 4px; +} + +/* On-screen keyboard ("keyboard" display style), rendered by keyboard.js and + flashed as keys are pressed. Anchored bottom-center. */ +#keyboard { + /* position/transform are set inline by keyboard.js (applyPosition). */ + position: fixed; + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--kb-bg, #000000) 55%, transparent); + pointer-events: none; + --kb-unit: 44px; +} + +.kb-row { + display: flex; + gap: 6px; + justify-content: center; +} + +.kb-key { + flex-basis: 0; + min-width: var(--kb-unit); + height: var(--kb-unit); + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--kb-color, #ffffff) 30%, transparent); + background: color-mix(in srgb, var(--kb-color, #ffffff) 8%, transparent); + color: var(--kb-color, #ffffff); + font: 13px/1 system-ui, sans-serif; + opacity: 0.9; + transition: background-color 60ms ease-out, box-shadow 60ms ease-out, transform 60ms ease-out; +} + +/* The arrow row is narrower and centered. */ +#keyboard .kb-row.kb-arrows .kb-key { + flex-grow: 0 !important; + min-width: var(--kb-unit); +} + +/* Trimmed keyboard (user picked a subset of keys): a compact grid of uniform + square caps aligned by column, so rows line up (A under Q) with the left-edge + modifiers sharing a column. Space stretches across its row. A spanning key + absorbs the internal gaps, so it stays one continuous cap. */ +#keyboard.kb-grid { + display: grid; + grid-template-columns: repeat(var(--kb-cols, 5), var(--kb-unit)); + grid-auto-rows: var(--kb-unit); + gap: 4px; + padding: 8px; +} + +#keyboard.kb-grid .kb-key { + flex-basis: auto; + min-width: 0; + margin: 0; +} + +.kb-key.active { + background: var(--kb-color, #ffffff); + color: var(--kb-bg, #000000); + box-shadow: 0 0 12px var(--kb-color, #ffffff); + transform: translateY(1px); +} + +/* Drag-to-position mode (overlay.js): a dimmed, interactive layer with a + draggable handle and a Save/Cancel toolbar. */ +#moveBackdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.25); + z-index: 10; +} + +#moveHandle { + position: fixed; + z-index: 12; + min-width: 220px; + padding: 14px 18px; + border-radius: 10px; + background: rgba(59, 130, 246, 0.92); + color: #ffffff; + font: 14px/1.3 system-ui, sans-serif; + cursor: move; + user-select: none; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); +} + +/* Each draggable object in move mode: a dashed outline + a name tag, so + overlapping widgets are tellable apart; the hovered one lights up blue. */ +.move-target { + outline: 2px dashed rgba(255, 255, 255, 0.55); + outline-offset: 4px; +} + +.move-target::before { + content: attr(data-move-label); + position: absolute; + top: -24px; + left: 0; + z-index: 13; + padding: 2px 7px; + border-radius: 5px; + background: rgba(0, 0, 0, 0.72); + color: #ffffff; + font: 11px/1.4 system-ui, sans-serif; + white-space: nowrap; + pointer-events: none; +} + +.move-target.move-hover { + outline: 2px solid #3b82f6; + outline-offset: 4px; + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.25); +} + +.move-target.move-hover::before { + background: #3b82f6; +} + +#moveToolbar { + position: fixed; + z-index: 14; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 12px; +} + +#moveToolbar button { + padding: 9px 18px; + border-radius: 8px; + border: none; + font: 14px system-ui, sans-serif; + cursor: pointer; + color: #ffffff; +} + +#moveToolbar .move-save { + background: #22c55e; +} + +#moveToolbar .move-cancel { + background: #6b7280; +} + +#moveToolbar .move-snap { + background: #3b82f6; +} + +#moveToolbar .move-snap.off { + background: #6b7280; +} + +/* Alignment guide lines shown while dragging in move mode. */ +.move-guide { + position: fixed; + z-index: 13; + background: #ff3ea5; + pointer-events: none; + box-shadow: 0 0 4px rgba(255, 62, 165, 0.7); +} + +.move-guide.v { + top: 0; + bottom: 0; + width: 1px; +} + +.move-guide.h { + left: 0; + right: 0; + height: 1px; +} + /* Error/permission notice (e.g. missing input-device permission on Linux) */ #notice { position: fixed; diff --git a/src/tauri-shim.js b/src/tauri-shim.js new file mode 100644 index 0000000..eae1122 --- /dev/null +++ b/src/tauri-shim.js @@ -0,0 +1,52 @@ +/** + * Tauri API shim for the OBS browser source. + * + * overlay.js and devices.js talk to the Rust side through `window.__TAURI__` + * (config via core.invoke, live input via event.listen). In a plain browser + * that object doesn't exist, so this shim provides it, backed by the YAKC OBS + * server: config over fetch(/config), live events over an SSE stream (/events). + * + * Loaded as a NON-deferred script before the deferred overlay.js / devices.js, + * so `window.__TAURI__` is ready before they run. + */ + +(() => { + const bus = new EventTarget(); + + // One shared SSE connection; the Rust hub sends {event, payload} frames. + const source = new EventSource("/events"); + source.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + bus.dispatchEvent(new CustomEvent(msg.event, { detail: msg.payload })); + } catch { + // Ignore malformed frames. + } + }; + + window.__TAURI__ = { + core: { + invoke: async (cmd) => { + switch (cmd) { + case "get_config": + return (await fetch("/config")).json(); + case "get_key_labels": + return (await fetch("/key_labels")).json(); + case "get_pending_errors": + return []; + case "get_config_path": + return "config.json"; + default: + return null; + } + }, + }, + event: { + listen: async (name, cb) => { + const handler = (e) => cb({ payload: e.detail }); + bus.addEventListener(name, handler); + return () => bus.removeEventListener(name, handler); + }, + }, + }; +})();