A Linux-native keyboard visualizer that reads key events directly from the kernel via evdev. Pressed keys are broadcast in real time over a WebSocket so any frontend can consume them. A built-in virtual keyboard UI works as an OBS browser source overlay out of the box.
Key event reading itself requires no window manager — evdev works at the kernel level. However, the privacy feature (suppressing keypresses when a non-game window is focused) requires Hyprland, as it relies on Hyprland's IPC socket to track the active window. Without Hyprland, privacy mode is unavailable and all keypresses are always broadcast.
Physical keyboard
│ /dev/input/eventN (evdev)
▼
[ evglow ]
│
├── http://localhost:7331 built-in keyboard UI (OBS browser source)
└── ws://localhost:7331/ws raw event stream for custom frontends
- Kernel-level input — reads
/dev/input/event*directly via evdev; no X11 or Wayland dependency for key capture - xremap / key-remapper aware — find the virtual device by name with
--device-name xremap - Config file — TOML config file support; CLI flags override file values
- WebSocket protocol — simple JSON stream; write your own overlay in any language
- Built-in QWERTY and QWERTZ layouts — served as a transparent browser overlay, ready for OBS
- Privacy mode (requires Hyprland) — uses Hyprland IPC to suppress keypresses when a non-allowed window is focused, preventing accidental exposure of passwords or private messages in your stream
- NixOS module — proper
nixosModules.evglowoutput; generates a config file and wraps the binary soexec-once = evglowworks in Hyprland out of the box
# run directly
nix run github:clemenscodes/evglow
# or enter the dev shell
nix develop
cargo run -- --list-devicesgit clone https://github.com/clemenscodes/evglow
cd evglow
nix develop
cargo build --releaseevglow reads from /dev/input/. Either run with sudo or add your user to the input group (preferred):
sudo usermod -aG input $USER
# log out and back inevglow [OPTIONS]
Options:
-c, --config <PATH> Path to a TOML config file. CLI flags take precedence.
--device-path <PATH> Watch this exact /dev/input path (repeatable)
--device-name <NAME> Watch devices whose name contains NAME (repeatable, case-insensitive)
-l, --list-devices Print all input devices and exit
-p, --port <PORT> Port to listen on [default: 7331]
-L, --layout <LAYOUT> Keyboard layout for the UI: qwerty, qwertz [default: qwerty]
--class <PATTERN> Only show keys when the focused window class matches (Hyprland)
--title <PATTERN> Only show keys when the focused window title matches (Hyprland)
--x <PX> Horizontal pixel offset of the keyboard widget [default: 0]
--y <PX> Vertical pixel offset of the keyboard widget [default: 0]
--scale <FACTOR> Scale factor for the keyboard widget [default: 1.0]
-h, --help Print help
Plain — show all keystrokes, auto-detect keyboard:
evglow --layout qwertzWith xremap or another key remapper:
xremap grabs the physical keyboard exclusively, so point evglow at its virtual output device by name:
evglow --device-name xremap --layout qwertzOr find and use the explicit path:
evglow --list-devices
evglow --device-path /dev/input/event28 --layout qwertzGame-only mode (Hyprland) — CS2 via Gamescope:
evglow \
--device-name xremap \
--layout qwertz \
--class gamescope \
--title "Counter-Strike 2"Key events are suppressed whenever a window that does not match both filters is focused. The keyboard stays visible but no keys glow.
All flags can be set in a TOML file. CLI flags always win over file values.
# ~/.config/evglow/config.toml
port = 7331
layout = "qwertz"
device-names = ["xremap"]
class = "gamescope"
title = "Counter-Strike 2"
# OBS overlay positioning — set these so the OBS source can be sized to the
# full scene (e.g. 3840×2160) and the widget is placed via config instead of
# being dragged around in OBS.
x = 0.0 # horizontal pixel offset from top-left
y = 900.0 # vertical pixel offset from top-left
scale = 3.0 # scale multiplier (e.g. 3× makes the widget visible on 4K)evglow --config ~/.config/evglow/config.tomlSize the browser source to your full scene resolution and let evglow handle positioning:
- Add a Browser Source in OBS.
- Set the URL to
http://127.0.0.1:7331. - Set width/height to your full scene resolution (e.g. 3840 × 2160 for 4K).
- Position the source at 0 / 0 — it covers the entire scene.
- Control where the keyboard widget appears and how large it is via the config:
x = 0.0 # pixels from left edge
y = 900.0 # pixels from top edge
scale = 3.0 # scale multiplierYou never need to touch OBS again — adjust position and scale in the config file and restart evglow.
Set width/height to roughly 800 × 220 and drag the source into position in OBS.
The overlay auto-reconnects if evglow restarts. The page background is transparent — no dark rectangle bleeds into your stream.
For a standalone browser preview (dark background, centered), append ?standalone:
http://127.0.0.1:7331/?standalone
Connect to ws://localhost:7331/ws. All messages are JSON.
The built-in UI (src/web/index.html) is a complete reference implementation of this protocol — copy it as a starting point for custom frontends. The only evglow-specific thing it does beyond the protocol is fetching GET /config to read the configured layout; a custom frontend that defines its own layout can skip that entirely.
type |
Fields | Description |
|---|---|---|
snapshot |
keys: string[] |
Full list of currently held keys. Sent on connect and when resuming from privacy mode. |
press |
key: string |
A key was pressed. |
release |
key: string |
A key was released. |
privacy |
— | A non-allowed window is focused; all held keys have been cleared server-side. |
Key names match the Linux evdev KeyCode debug representation: KEY_A, KEY_LEFTSHIFT, KEY_SPACE, KEY_102ND, etc.
← {"type":"snapshot","keys":["KEY_LEFTSHIFT"]}
← {"type":"press","key":"KEY_A"}
← {"type":"release","key":"KEY_A"}
← {"type":"release","key":"KEY_LEFTSHIFT"}
← {"type":"privacy"}
← {"type":"snapshot","keys":[]}
const ws = new WebSocket("ws://localhost:7331/ws");
const held = new Set();
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === "snapshot") {
held.clear();
msg.keys.forEach((k) => held.add(k));
} else if (msg.type === "press") {
held.add(msg.key);
} else if (msg.type === "release") {
held.delete(msg.key);
} else if (msg.type === "privacy") {
held.clear();
}
console.log("held:", [...held]);
};websocat ws://127.0.0.1:7331/ws| Endpoint | Description |
|---|---|
GET / |
Built-in virtual keyboard UI |
GET /ws |
WebSocket upgrade |
GET /config |
{"layout":"qwertz","x":0.0,"y":0.0,"scale":1.0} — consumed by the built-in UI |
When --class and/or --title are given, evglow connects to the Hyprland IPC socket and monitors focus changes. The filter is an AND: both conditions must match for the window to be considered allowed.
Patterns are regular expressions using the same syntax Hyprland uses for its window rules (Rust regex crate — RE2-compatible). The pattern is matched against the full class/title string; wrap in .* if you want substring behaviour, or use a plain string like Counter-Strike 2 which matches as a literal substring by default.
| Flag | Meaning |
|---|---|
--class PATTERN |
Window class must match regex PATTERN (Hyprland rule syntax) |
--title PATTERN |
Window title must match regex PATTERN (Hyprland rule syntax) |
When the active window stops matching:
- All currently held key state is cleared server-side
- A
{"type":"privacy"}message is broadcast to all clients - Key events are dropped silently until the allowed window regains focus
When focus returns:
- A
{"type":"snapshot","keys":[]}is broadcast - Key events resume normally
If Hyprland IPC is unavailable (wrong compositor or no HYPRLAND_INSTANCE_SIGNATURE in env), filtering is silently disabled and all keys are broadcast.
--layout |
Description |
|---|---|
qwerty |
Standard US QWERTY |
qwertz |
German QWERTZ — Y↔Z swapped, umlauts (Ö Ä Ü ß), extra ISO < key (KEY_102ND) |
The layout only affects which label is shown on each key in the built-in UI. The evdev key codes in the WebSocket stream are always physical-position based (KEY_Y is always the physical Y position regardless of layout).
main.rs
├── evdev reader threads one OS thread per device, blocking fetch_events()
│ drops events while muted (privacy mode)
├── Hyprland monitor thread connects to .socket2.sock, watches activewindow events
│ calls set_muted() on focus changes
├── AppState
│ ├── keys: Mutex<HashSet> currently held keys
│ ├── tx: broadcast::Sender<String> fan-out channel to all WS clients
│ ├── layout: String
│ └── muted: AtomicBool
└── axum HTTP server (tokio)
├── GET / serve embedded index.html
├── GET /config serve layout JSON
└── GET /ws WebSocket — snapshot on connect, then stream from tx
Each WebSocket client subscribes to the broadcast channel. The evdev threads push serialised JSON into the channel; axum tasks forward it to connected clients. No polling, no timers.
nix/
├── package.nix callPackage-able derivation (crane build)
└── module.nix NixOS module — generates config file, wraps binary
Import the module in your flake:
{
inputs.evglow.url = "github:clemenscodes/evglow";
outputs = { self, evglow, nixpkgs, ... }: {
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
modules = [
evglow.nixosModules.evglow
{
services.evglow = {
enable = true;
user = "clemens";
layout = "qwertz";
deviceNames = [ "xremap" ];
# Privacy mode — only show keys in CS2 (requires Hyprland)
class = "gamescope";
title = "Counter-Strike 2";
# OBS overlay positioning — size the source to the full scene,
# position at 0/0, and control placement here instead.
x = 0.0;
y = 900.0;
scale = 3.0;
};
}
];
};
};
}The module:
- Generates
/nix/store/…-evglow.tomlfrom your options - Wraps the binary so it always loads that config (
evglow --config /nix/store/…) - Adds the wrapped binary to
environment.systemPackages - Adds
userto theinputgroup
Then in hyprland.conf — no systemd required:
exec-once = evglow
Because the binary is pre-configured by the module, no extra flags are needed. Any CLI flag you pass still overrides the baked-in config.
nix develop # enters shell with rust-analyzer, cargo-watch, evtest, websocat
cargo watch -x run # rebuild and restart on file changes
evtest # inspect raw evdev events for any device
websocat ws://127.0.0.1:7331/ws # watch the raw WebSocket streamnix flake check # runs cargo fmt + clippy
nix build # production build (symbols stripped)