kanata-switcher provides support for switching Kanata layers and pressing virtual keys based on the current application 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
- Linux console
There's also a manual QA checklist in ./qa folder.
If you have tested it in other environments, and it did/didn't work, open a PR to change the README or QA checklist!
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/). Backends are event-driven, with one-shot focus queries on startup and unpause.
| 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 |
| Linux console (VT switch) | Daemon monitors session state via systemd-logind DBus interface |
-
Kanata running with TCP server enabled:
kanata -c your-config.kbd -p 10000
-
Config file at
~/.config/kanata/kanata-switcher.json(or in applicable$XDG_CONFIG_HOMElocation)
Example config:
[
{
"default": "default"
},
{
"on_native_terminal": "terminal"
},
{
"class": "^firefox$",
"layer": "browser"
},
{
"class": "jetbrains|codium|code|dev.zed.Zed",
"layer": "editor"
},
{
"class": "kitty|alacritty|com.mitchellh.ghostty|wezterm",
"layer": "terminal",
"fallthrough": true
},
{
"class": "kitty|alacritty|com.mitchellh.ghostty|wezterm",
"title": "vim",
"virtual_key": "vim_vk"
}
]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
On Native Terminal rule:
{ "on_native_terminal": "layer_name" }- Layer to use when switching to Linux console (Ctrl+Alt+F*)- Must not include
class,title, orlayer - Can include
virtual_keyand/orraw_vk_action - Can appear at most once (multiple = error), position doesn't matter
- When absent, daemon switches to layer specified in Default layer rule or the auto-detected initial layer
- If systemd-logind is unavailable (no system bus, permissions, etc.), the daemon keeps running but Linux console-based switching is disabled; a warning will be logged on startup.
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.
kanata-switcher ships with a tray indicator showing your current Kanata layer - the first letter of its name - and pressed virtual keys.
GNOME, "default" layer active:
GNOME, "terminal" layer active and "alt" virtual key held:
GNOME, "leftcmd" layer active and 2 virtual keys are held:
KDE, "terminal" layer active and "alt" virtual key held:
On GNOME, the indicator is provided by the (auto-)installed GNOME extension, on all other DEs it's implemented via Freedesktop.org StatusNotifierItem (SNI).
The tray menu's "Show app layer only" setting is persisted via dconf, both on GNOME and non-GNOME desktops. Use --indicator-focus-only <true|false> to override it at startup.
Run the following command (anywhere), replacing 10000 with the expected Kanata port:
nix run github:7mind/kanata-switcher -- -p 10000Clone the repository and run the following command in the project folder, replacing 10000 with the expected Kanata port:
cargo 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 installed from the filesystem (<install-dir>/gnome/) if available (e.g. if installed via Nix or a distro-specific package),
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.homeModules.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)
# Logging for systemd unit:
# logging = "quiet-focus"; # default: quiet-focus, options: quiet, quiet-focus, none
};
}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)
# Logging for systemd unit:
# logging = "quiet-focus"; # default: quiet-focus, options: quiet, quiet-focus, none
};
}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
If you don't use systemd user services or prefer GUI-managed autostart entries, install a user-level autostart entry:
~/.cargo/bin/kanata-switcher --quiet-focus -p 10000 --install-autostartThis writes ~/.config/autostart/kanata-switcher.desktop with an absolute Exec path and the same daemon options you
passed on the command line. To update the entry, rerun the install command with new options. To remove it:
~/.cargo/bin/kanata-switcher --uninstall-autostart-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
--quiet-focus Suppress focus messages only
--install-autostart Install autostart desktop entry and exit
--uninstall-autostart Uninstall autostart desktop entry and exit
--install-gnome-extension Auto-install GNOME extension if missing (default)
--no-install-gnome-extension Do not auto-install GNOME extension
--no-indicator Disable the StatusNotifier (SNI) indicator on non-GNOME desktops
--indicator-focus-only true|false Override StatusNotifier (SNI) indicator focus-only mode
--restart Send Restart request to an existing daemon and exit
--pause Send Pause request to an existing daemon and exit
--unpause Send Unpause request to an existing daemon and exit
-h, --help Show help
Systemd units use --quiet-focus by default to reduce log noise.



