A Mod+Space layout switcher daemon for the Niri Wayland compositor with two behaviours in one binding:
| Gesture | Result |
|---|---|
Quick tap Mod↓ Space↓ Mod↑ (< 350 ms) |
Toggle between the last two committed layouts |
Hold + tap Mod↓ Space↓ Space↓ … |
Cycle through all layouts one by one |
The toggle memory survives reboots — the daemon writes the "previous layout" to /tmp/niri-layout-daemon so after a fresh login a single tap jumps straight back to your last language.
The daemon reads raw keyboard events from /dev/input/ via evdev (no X11/Wayland protocol needed). It watches for KEY_LEFTMETA/KEY_RIGHTMETA (Super) and KEY_SPACE and issues niri msg action switch-layout via the Niri IPC socket.
Because it reads evdev directly, it works even when Niri has focus on a fullscreen app or a layer-shell surface. The daemon auto-discovers all keyboards that have both a Meta key and a Space key, so it works with external keyboards too.
To prevent Mod+Space from also being forwarded to the focused window, add a no-op binding in niri/config.kdl:
Mod+Space { spawn-sh ":"; }layout-daemon/
├── Cargo.toml
├── Cargo.lock ← must be committed (required by rustPlatform.buildRustPackage)
└── src/
└── main.rs
[package]
name = "niri-layout-daemon"
version = "0.1.0"
edition = "2021"
[dependencies]
evdev = "0.12"//! Keyboard layout switcher daemon for niri compositor.
//!
//! Quick Mod+Space tap (Super released < TAP_THRESHOLD ms after Space press)
//! → toggle between last two committed layouts
//!
//! Hold Mod + tap Space repeatedly
//! → cycle through all layouts with immediate feedback on each press
use evdev::{Device, InputEventKind, Key};
use std::path::PathBuf;
use std::process::Command;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
const TAP_THRESHOLD: Duration = Duration::from_millis(350);
const STATE_FILE: &str = "/tmp/niri-layout-daemon";
enum Ev {
SuperDown,
SuperUp,
SpaceDown,
}
struct State {
before_session: Option<String>,
super_held: bool,
space_count: u32,
last_space: Option<Instant>,
layout_at_first_space: Option<String>,
}
impl State {
fn load() -> Self {
let before_session = std::fs::read_to_string(STATE_FILE)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Self {
before_session,
super_held: false,
space_count: 0,
last_space: None,
layout_at_first_space: None,
}
}
fn save(&self) {
match &self.before_session {
Some(v) => { let _ = std::fs::write(STATE_FILE, v); }
None => { let _ = std::fs::remove_file(STATE_FILE); }
}
}
}
fn niri_switch(target: &str) {
let _ = Command::new("niri")
.args(["msg", "action", "switch-layout", target])
.status();
}
fn get_current_layout() -> Option<String> {
let out = Command::new("niri")
.args(["msg", "keyboard-layouts"])
.output()
.ok()?;
for line in String::from_utf8_lossy(&out.stdout).lines() {
let s = line.trim();
if s.starts_with('*') {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() >= 2 {
return Some(parts[1].trim_end_matches('.').to_string());
}
}
}
None
}
fn is_keyboard(dev: &Device) -> bool {
dev.supported_keys().map_or(false, |keys| {
keys.contains(Key::KEY_LEFTMETA)
&& keys.contains(Key::KEY_SPACE)
&& keys.contains(Key::KEY_A)
})
}
fn find_keyboards() -> Vec<PathBuf> {
let mut result = Vec::new();
if let Ok(rd) = std::fs::read_dir("/dev/input") {
for entry in rd.flatten() {
let path = entry.path();
if let Ok(dev) = Device::open(&path) {
if is_keyboard(&dev) {
result.push(path);
}
}
}
}
result
}
fn reader(path: PathBuf, tx: mpsc::Sender<Ev>) {
loop {
let mut dev = match Device::open(&path) {
Ok(d) => d,
Err(_) => { thread::sleep(Duration::from_secs(1)); continue; }
};
'read: loop {
let events = match dev.fetch_events() {
Ok(it) => it,
Err(_) => break 'read,
};
for ev in events {
if ev.value() == 2 { continue; }
let key = match ev.kind() {
InputEventKind::Key(k) => k,
_ => continue,
};
let msg = if key == Key::KEY_LEFTMETA || key == Key::KEY_RIGHTMETA {
if ev.value() == 1 { Ev::SuperDown } else { Ev::SuperUp }
} else if key == Key::KEY_SPACE && ev.value() == 1 {
Ev::SpaceDown
} else {
continue;
};
if tx.send(msg).is_err() { return; }
}
}
thread::sleep(Duration::from_secs(1));
}
}
fn on_event(ev: Ev, s: &mut State) {
match ev {
Ev::SuperDown => {
s.super_held = true;
s.space_count = 0;
s.last_space = None;
s.layout_at_first_space = None;
}
Ev::SuperUp => {
s.super_held = false;
let elapsed = s.last_space.map_or(Duration::MAX, |t| t.elapsed());
if s.space_count == 1 && elapsed < TAP_THRESHOLD {
let target = s.before_session.clone();
let layout = s.layout_at_first_space.clone();
if let Some(ref t) = target {
if Some(t) != layout.as_ref() {
niri_switch(t);
}
}
s.before_session = layout;
s.save();
} else if s.space_count >= 1 {
s.before_session = s.layout_at_first_space.clone();
s.save();
}
s.space_count = 0;
s.last_space = None;
s.layout_at_first_space = None;
}
Ev::SpaceDown if s.super_held => {
if s.space_count == 0 {
s.layout_at_first_space = get_current_layout();
}
s.space_count += 1;
s.last_space = Some(Instant::now());
niri_switch("next");
}
Ev::SpaceDown => {}
}
}
fn main() {
loop {
let keyboards = find_keyboards();
if keyboards.is_empty() {
thread::sleep(Duration::from_secs(2));
continue;
}
let (tx, rx) = mpsc::channel::<Ev>();
for path in keyboards {
let tx = tx.clone();
thread::spawn(move || reader(path, tx));
}
drop(tx);
let mut state = State::load();
for ev in rx {
on_event(ev, &mut state);
}
thread::sleep(Duration::from_secs(1));
}
}Builds the daemon with Nix and registers it as a systemd user service:
{ pkgs, ... }:
let
daemon = pkgs.rustPlatform.buildRustPackage {
pname = "niri-layout-daemon";
version = "0.1.0";
src = ./layout-daemon;
cargoLock.lockFile = ./layout-daemon/Cargo.lock;
};
in
{
systemd.user.services.niri-layout-daemon = {
Unit = {
Description = "Keyboard layout switcher daemon for niri";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${daemon}/bin/niri-layout-daemon";
Restart = "on-failure";
RestartSec = "2";
};
Install = {
WantedBy = [ "graphical-session.target" ];
};
};
}Import it in your Home Manager module list:
# home-manager/modules/default.nix
{ imports = [ ./layout-daemon.nix /* … */ ]; }Add this to your binds { } block so Mod+Space is consumed by Niri and not forwarded to the focused window:
// Mod+Space is handled by the niri-layout-daemon (reads evdev directly).
// This no-op binding prevents the keypress from reaching focused windows.
Mod+Space { spawn-sh ":"; }Configure your layouts in the input { keyboard { xkb { } } } block:
input {
keyboard {
xkb {
layout "us,ru,ua"
}
}
}- Niri compositor with
niriin$PATH - User must be in the
inputgroup to read/dev/input/devices without root:
# NixOS — add to configuration.nix:
users.users.<name>.extraGroups = [ "input" ];
# Other distros:
sudo usermod -aG input $USER
# then log out and back incd layout-daemon
cargo generate-lockfile # only needed once, or after changing deps
cargo build --release
# binary at: target/release/niri-layout-daemonRun as a background process or a systemd user service pointing to the built binary.
The tap/hold distinction is 350 ms by default. Change TAP_THRESHOLD in src/main.rs:
const TAP_THRESHOLD: Duration = Duration::from_millis(350);Lower values (e.g. 200 ms) make the toggle more responsive but harder to trigger accidentally. Higher values give more time to release Super before it's treated as a cycle session.