kanata-switcher provides support for switching Kanata layers and pressing virtual keys based on the currently
focused application window for all Linux desktop environments - for Wayland: GNOME Shell, KDE Plasma, COSMIC, wlroots-based
compositors (Sway, Hyprland, Niri, etc.), and for X11.
As of the time when the project was started, the only active project for application-based layer switching for kanata for Linux was hyprkan - which supported only wlroots-based compositors. There was no project attempting support for GNOME Shell or KDE Plasma.
This project is fully LLM-generated, it has a comprehensive automated test suite and has also been manually tested in the following environments:
- GNOME Shell
- KDE Plasma
- COSMIC
- wlroots-based compositors
- Sway
- Hyprland
- Niri
- X11
If you have tested it in other environments, and it did/didn't work, open a PR to change the README!
This project features comprehensive automated test suite and supports an unusually wide range of desktop environments in a single codebase.
All environments use the unified daemon (src/daemon/). All backends are event-driven (no polling).
| Environment | How it works |
|---|---|
| GNOME Shell | Extension pushes focus changes to daemon via DBus |
| KDE Plasma | Daemon auto-injects KWin script which pushes via DBus |
| COSMIC | Daemon receives cosmic-toplevel-info Wayland protocol events |
| wlroots (Sway, Hyprland, Niri, etc.) | Daemon receives wlr-foreign-toplevel-management protocol events |
| X11 | Daemon listens to PropertyNotify events on _NET_ACTIVE_WINDOW |
-
Kanata running with TCP server enabled:
kanata -c your-config.kbd -p 10000
-
Config file at usually
~/.config/kanata/kanata-switcher.json(or in applicable$XDG_CONFIG_HOME)
Example config:
[
{
"default": "default"
},
{
"class": "^firefox$",
"layer": "browser"
},
{
"class": "jetbrains|codium|code|dev.zed.Zed",
"layer": "vscode"
},
{
"class": "kitty|alacritty|com.mitchellh.ghostty|wezterm",
"title": "vim",
"layer": "vim"
}
]Rule entries:
class- Window class regex (optional)title- Window title regex (optional)layer- Kanata layer name to switch to (optional)virtual_key- Virtual key to press while window is focused (optional, see below)raw_vk_action- Advanced: raw virtual key actions (optional, see below)fallthrough- Advanced: continue matching subsequent rules (optional, default false)- Rules are evaluated top-to-bottom; a matching rule stops evaluation (unless it has
"fallthrough": trueattribute)- A matching rule with
"fallthrough": truecontinues to subsequent rules; non-matching rules are skipped - All matching rules' actions are collected and execute in order (without any
"fallthrough": truerules, that is exactly 0 or 1 action)
- A matching rule with
- Patterns use Rust regex syntax (Perl-like, no lookahead/lookbehind)
- Use
*as a special case to match anything
Default layer rule:
{ "default": "layer_name" }- Explicit default layer (optional)- When present, disables auto-detection from Kanata
- When absent, daemon auto-detects from Kanata's initial layer on connect
- Can appear at most once (multiple = error), position doesn't matter
Virtual keys:
virtual_key- Automatically pressed when window is focused, released when unfocused- With
"fallthrough": true, ALL matchingvirtual_keys are pressed and held simultaneously - VKs are pressed in rule order (top-to-bottom), released in reverse order (bottom-to-top)
- Example:
[ { "class": "firefox", "virtual_key": "vk_browser", "layer": "browser" }, { "class": "terminal", "virtual_key": "vk_terminal" } ]
Raw virtual key actions:
raw_vk_action- Array of[key_name, action]pairs, fired on focus only (fire-and-forget)- Actions:
Press- Press the key; remains pressed until another action triggers Release or TapRelease- Release the key; does nothing if not pressedTap- Press and release the key; if already pressed, only releases itToggle- Press if not pressed, release if pressed
- Example:
[ { "class": "firefox", "raw_vk_action": [["vk_notify", "Tap"], ["vk_browser", "Press"]], "fallthrough": true } ]
Layer switching and stacking:
-
"fallthrough": trueis only useful for virtual keys, not layers, because only the last layer wins, layer switches won't stack because kanata's TCPChangeLayercommand swaps the base layer (it doesn't stack) -
For stacked layers (e.g., browser layer + youtube-specific layer on top), use
virtual_keywithlayer-while-heldactions in your kanata config:In kanata config - define virtual keys with layer-while-held:
(defvirtualkeys vk_browser (layer-while-held browser) vk_youtube (layer-while-held youtube) )
In kanata-switcher config - stack layers via virtual keys:
[ { "class": "firefox", "virtual_key": "vk_browser", "fallthrough": true }, { "class": "firefox", "title": "YouTube", "virtual_key": "vk_youtube" } ]When focusing Firefox on YouTube, both
vk_browserandvk_youtubeare held → kanata stacksbrowserandyoutubelayers.
nix run github:7mind/kanata-switcher -- -p 10000cargo run --release -- -p 10000GNOME Shell note: The daemon automatically installs and enables the required GNOME extension on first run. After installation, restart GNOME Shell:
- X11: Press Alt+F2, type
r, press Enter - Wayland: Log out and log back in
The extension is loaded from the filesystem (<install-dir>/gnome/) if available, otherwise falls back to the embedded
copy (enabled by default via embed-gnome-extension cargo feature).
Add flake input and import module:
# flake.nix
{
inputs.kanata-switcher.url = "github:7mind/kanata-switcher";
outputs = { nixpkgs, home-manager, kanata-switcher, ... }: {
homeConfigurations."user" = home-manager.lib.homeManagerConfiguration {
modules = [
kanata-switcher.homeManagerModules.default
# ...
];
};
};
}Enable in your Home Manager config:
# home.nix
{ osConfig, ... }: # if using home-manager as NixOS module
{
services.kanata-switcher = {
enable = true;
kanataPort = 10000; # optional, default 10000
# kanataPort = osConfig.services.kanata.keyboards.default.port; # if using programs.kanata from nixpkgs
# Config - choose one:
settings = [ # inline config (recommended)
{ default = "default"; }
{ class = "^firefox$"; layer = "browser"; }
{ class = "jetbrains|codium|code"; layer = "code"; }
];
# configFile = ./kanata-switcher.json; # Nix path, or string like "~/.config/..."
# (neither) defaults to ~/.config/kanata/kanata-switcher.json
# For GNOME Shell - choose one:
gnomeExtension.enable = true; # Nix-managed extension (recommended)
# gnomeExtension.autoInstall = true; # Runtime auto-install (mutable)
# gnomeExtension.manageDconf = false; # Disable dconf management (see below)
};
}For system-wide installation without Home Manager:
# flake.nix
{
inputs.kanata-switcher.url = "github:7mind/kanata-switcher";
outputs = { nixpkgs, kanata-switcher, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
kanata-switcher.nixosModules.default
# ...
];
};
};
}# configuration.nix
{ config, ... }:
{
services.kanata-switcher = {
enable = true;
kanataPort = 10000; # optional, default 10000
# kanataPort = config.services.kanata.keyboards.default.port; # if using programs.kanata from nixpkgs
# Config - choose one:
settings = [ # inline config (recommended)
{ default = "default"; }
{ class = "^firefox$"; layer = "browser"; }
{ class = "jetbrains|codium|code"; layer = "code"; }
];
# configFile = ./kanata-switcher.json; # Nix path, or string like "~/.config/..."
# (neither) defaults to ~/.config/kanata/kanata-switcher.json
# For GNOME Shell:
gnomeExtension.enable = true; # installs extension and enables via dconf for all users
# gnomeExtension.manageDconf = false; # Disable dconf management (see below)
};
}The NixOS module creates a systemd user service (systemd.user.services) that auto-starts for all users on graphical
login. Config file still defaults to per-user ~/.config/kanata/kanata-switcher.json.
When using a centralized GNOME extensions module that manages all extensions via locked dconf settings, this module's dconf configuration will conflict - dconf databases don't merge, and locked settings take precedence.
Set gnomeExtension.manageDconf = false to disable dconf management:
# configuration.nix
{
services.kanata-switcher = {
enable = true;
gnomeExtension.enable = true;
gnomeExtension.manageDconf = false; # Don't add dconf database entry
};
}Then include the extension UUID in your dconf enabled-extensions list. The extension package is already installed when
gnomeExtension.enable = true:
# your gnome-extensions module
{ config, ... }:
let
kanataExtension = config.services.kanata-switcher.gnomeExtension.package;
in {
programs.dconf.profiles.user.databases = [{
lockAll = true;
settings."org/gnome/shell".enabled-extensions = [
# ... your other extensions ...
kanataExtension.extensionUuid
];
}];
}For non-Nix / NixOS systems, install the binary and configure the systemd user service manually as follows.
-
Install the binary:
cargo install --path . # Binary installed to ~/.cargo/bin/kanata-switcher
-
Copy the systemd unit file
kanata-switcher.service:mkdir -p ~/.config/systemd/user cp systemd/kanata-switcher.service ~/.config/systemd/user/
-
The unit file works out of the box if
cargo installinstalls to~/.cargo/bin(default) and with default kanata port 10000. Edit the systemd unit file if you use a different port or install binary to a different location. -
Enable and start the service:
systemctl --user daemon-reload systemctl --user enable --now kanata-switcher
-p, --port PORT Kanata TCP port (default: 10000)
-H, --host HOST Kanata host (default: 127.0.0.1)
-c, --config PATH Config file path
-q, --quiet Suppress focus/layer-switch messages
--install-gnome-extension Auto-install GNOME extension if missing (default)
--no-install-gnome-extension Do not auto-install GNOME extension
-h, --help Show help
Systemd units use --quiet by default to reduce log noise.