Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save DmitrySkibitsky/d3e300f892cbe5f8a896436c8fb25cef to your computer and use it in GitHub Desktop.

Select an option

Save DmitrySkibitsky/d3e300f892cbe5f8a896436c8fb25cef to your computer and use it in GitHub Desktop.
Smart Keyboard Layout Switcher for Niri (NixOS)

Smart Keyboard Layout Switcher for Niri (NixOS)

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.


How it works

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 ":"; }

Project layout

layout-daemon/
├── Cargo.toml
├── Cargo.lock        ← must be committed (required by rustPlatform.buildRustPackage)
└── src/
    └── main.rs

Cargo.toml

[package]
name = "niri-layout-daemon"
version = "0.1.0"
edition = "2021"

[dependencies]
evdev = "0.12"

src/main.rs

//! 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));
    }
}

NixOS / Home Manager integration

home-manager/modules/layout-daemon.nix

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 /* … */ ]; }

niri/config.kdl — no-op binding

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 ":"; }

Keyboard layouts

Configure your layouts in the input { keyboard { xkb { } } } block:

input {
    keyboard {
        xkb {
            layout "us,ru,ua"
        }
    }
}

Requirements

  • Niri compositor with niri in $PATH
  • User must be in the input group 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 in

Building without Nix

cd layout-daemon
cargo generate-lockfile   # only needed once, or after changing deps
cargo build --release
# binary at: target/release/niri-layout-daemon

Run as a background process or a systemd user service pointing to the built binary.


Adjusting the tap threshold

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment