Automatic Keyboard Layer Switching Based on Vim Mode
The Problem: Vim Keybindings vs Colemak-DH
I use Colemak-DH as my daily driver layout. It’s ergonomic, comfortable, and after the learning curve, significantly better for typing. But there’s a catch: Vim’s keybindings were designed for QWERTY.
The magic of hjkl for navigation, w for word-forward, b for back-these all assume QWERTY positioning. On Colemak-DH:
his wheremshould bejandkare scattered- Muscle memory fights against layout
Some people remap Vim entirely. Others stick with QWERTY. I wanted both: Colemak-DH for typing, QWERTY for Vim commands.
The Solution: Raw HID Layer Switching
Modern QMK keyboards support Raw HID-a bidirectional communication channel between your computer and keyboard. We can use this to:
- Detect when Neovim enters insert mode
- Send a command to the keyboard
- Switch to Colemak-DH layer
- Switch back to QWERTY when leaving insert mode
The result: type in Colemak-DH, navigate in QWERTY. Automatic. Instant.
Architecture
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Neovim │─────▶│ rawtalk │─────▶│ Keyboard │
│ ModeChanged│ sock │ (daemon) │ HID │ (QMK) │
└─────────────┘ └──────────────┘ └─────────────┘
- Neovim fires
ModeChangedautocmd, writes mode to Unix socket - rawtalk daemon receives mode, maps to layer, sends Raw HID command
- QMK keyboard receives command, switches default layer
Latency is imperceptible-the switch happens before your finger leaves the key.
Setup
1. QMK Firmware Configuration
First, enable Raw HID in your keyboard’s rules.mk:
RAW_ENABLE = yes
Add the layer switching handler to keymap.c:
#include "raw_hid.h"
// Layer definitions
// Layer 0: Colemak-DH (for typing in insert mode)
// Layer 3: QWERTY (for Vim commands in normal mode)
void raw_hid_receive_kb(uint8_t *data, uint8_t length) {
uint8_t *command_id = &(data[0]);
switch (*command_id) {
case 0x00: { // Layer switch command
uint8_t target_layer = data[1];
if (target_layer <= 3) {
// Switch the default/base layer
set_single_persistent_default_layer(target_layer);
// Send response
data[0] = 0x00; // Success
data[1] = target_layer; // Confirm layer
data[2] = 0xAA; // Acknowledgment byte
} else {
*command_id = 0xFF; // Error
}
break;
}
case 0x40: { // Get current layer (for debugging)
data[0] = (uint8_t)get_highest_layer(default_layer_state);
break;
}
default:
*command_id = 0xFF; // Unhandled
break;
}
}
Your keymap should have both layouts defined:
const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
// Layer 0: Colemak-DH
[0] = LAYOUT(
KC_Q, KC_W, KC_F, KC_P, KC_B, KC_J, KC_L, KC_U, KC_Y, KC_SCLN,
KC_A, KC_R, KC_S, KC_T, KC_G, KC_M, KC_N, KC_E, KC_I, KC_O,
KC_Z, KC_X, KC_C, KC_D, KC_V, KC_K, KC_H, KC_COMM, KC_DOT, KC_SLSH,
// ... thumb keys
),
// Layer 1: Symbols/Numbers
[1] = LAYOUT( /* ... */ ),
// Layer 2: Function keys
[2] = LAYOUT( /* ... */ ),
// Layer 3: QWERTY
[3] = LAYOUT(
KC_Q, KC_W, KC_E, KC_R, KC_T, KC_Y, KC_U, KC_I, KC_O, KC_P,
KC_A, KC_S, KC_D, KC_F, KC_G, KC_H, KC_J, KC_K, KC_L, KC_SCLN,
KC_Z, KC_X, KC_C, KC_V, KC_B, KC_N, KC_M, KC_COMM, KC_DOT, KC_SLSH,
// ... thumb keys
),
};
Compile and flash:
qmk compile -kb your_keyboard -km your_keymap
qmk flash -kb your_keyboard -km your_keymap
2. rawtalk Daemon
The daemon is a small Rust program that bridges Neovim and the keyboard.
Clone and build:
git clone https://github.com/morphykuffour/rawtalk
cd rawtalk
cargo build --release
The source (src/main.rs):
use hidapi::HidApi;
use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::fs;
// Update these for your keyboard
const VID: u16 = 0xC2AB; // Vendor ID
const PID: u16 = 0x3939; // Product ID
const USAGE_PAGE: u16 = 0xFF60; // QMK Raw HID usage page
const USAGE: u16 = 0x61; // QMK Raw HID usage
const SOCKET: &str = "/tmp/rawtalk.sock";
fn find_keyboard(api: &HidApi) -> Option<hidapi::HidDevice> {
api.device_list()
.find(|d| d.vendor_id() == VID && d.product_id() == PID
&& d.usage_page() == USAGE_PAGE && d.usage() == USAGE)
.and_then(|d| d.open_device(api).ok())
}
fn send_layer(device: &hidapi::HidDevice, layer: u8) {
let mut cmd = [0u8; 33];
cmd[1] = 0x00; // layer switch command
cmd[2] = layer;
if device.write(&cmd).is_ok() {
let mut resp = [0u8; 32];
if device.read_timeout(&mut resp, 500).unwrap_or(0) > 0 && resp[2] == 0xAA {
println!("[layer {}] {}", layer, if layer == 0 { "colemak-dh" } else { "qwerty" });
}
}
}
fn mode_to_layer(mode: &str) -> u8 {
match mode {
"i" | "ic" | "ix" | "R" | "Rc" | "Rx" | "Rv" | "Rvc" | "Rvx" => 0, // Insert/Replace → Colemak
_ => 3, // Normal, Visual, Command → QWERTY
}
}
fn handle_client(stream: UnixStream, tx: mpsc::Sender<String>) {
for line in BufReader::new(stream).lines().flatten() {
let mode = line.trim().to_string();
if !mode.is_empty() && tx.send(mode).is_err() { break; }
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let api = HidApi::new()?;
let device = find_keyboard(&api).ok_or("keyboard not found")?;
println!("rawtalk: connected");
let _ = fs::remove_file(SOCKET);
let listener = UnixListener::bind(SOCKET)?;
#[cfg(unix)]
fs::set_permissions(SOCKET, std::os::unix::fs::PermissionsExt::from_mode(0o777))?;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
thread::spawn(move || handle_client(stream, tx));
}
});
println!("rawtalk: listening on {}", SOCKET);
let mut last: Option<u8> = None;
loop {
if let Ok(mode) = rx.recv_timeout(Duration::from_secs(60)) {
let layer = mode_to_layer(&mode);
if last != Some(layer) {
send_layer(&device, layer);
last = Some(layer);
}
}
}
}
3. Running rawtalk
Without Nix (systemd)
Create a systemd user service:
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/rawtalk.service << 'SERVICE'
[Unit]
Description=QMK Layer Switcher Daemon
After=graphical-session.target
[Service]
ExecStart=%h/git/rawtalk/target/release/rawtalk
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
SERVICE
systemctl --user daemon-reload
systemctl --user enable --now rawtalk
Check status:
systemctl --user status rawtalk
journalctl --user -u rawtalk -f
With Nix (Home Manager)
Add to your home.nix:
{ pkgs, ... }:
let
rawtalk = pkgs.rustPlatform.buildRustPackage {
pname = "rawtalk";
version = "0.3.0";
src = pkgs.fetchFromGitHub {
owner = "morphykuffour";
repo = "rawtalk";
rev = "main";
sha256 = "sha256-XXXX"; # Update with actual hash
};
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.hidapi ];
};
in {
home.packages = [ rawtalk ];
systemd.user.services.rawtalk = {
Unit = {
Description = "QMK Layer Switcher";
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${rawtalk}/bin/rawtalk";
Restart = "always";
};
Install.WantedBy = [ "default.target" ];
};
}
Linux: udev Rules
On Linux, you need udev rules to access the HID device without root:
sudo tee /etc/udev/rules.d/70-qmk.rules << 'RULES'
SUBSYSTEMS=="usb", ATTRS{idVendor}=="c2ab", ATTRS{idProduct}=="3939", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="c2ab", ATTRS{idProduct}=="3939", TAG+="uaccess"
RULES
sudo udevadm control --reload-rules
sudo udevadm trigger
4. Neovim Configuration
Add to your init.lua:
-- Rawtalk: Automatic keyboard layer switching
local socket_path = "/tmp/rawtalk.sock"
local uv = vim.loop
local client, connected = nil, false
local function connect()
if connected then return true end
client = uv.new_pipe()
client:connect(socket_path, function(err)
connected = not err
end)
vim.wait(50, function() return connected end)
return connected
end
local function send_mode(mode)
if not connected then connect() end
if connected then
pcall(function() client:write(mode .. "\n") end)
end
end
local group = vim.api.nvim_create_augroup("Rawtalk", { clear = true })
vim.api.nvim_create_autocmd("ModeChanged", {
group = group,
pattern = "*",
callback = function()
send_mode(vim.fn.mode())
end,
})
vim.api.nvim_create_autocmd("VimEnter", {
group = group,
callback = function()
vim.defer_fn(function()
connect()
send_mode(vim.fn.mode())
end, 100)
end,
})
vim.api.nvim_create_autocmd("VimLeave", {
group = group,
callback = function()
if client then pcall(function() client:close() end) end
end,
})
The Ergonomic Advantage
This setup gives you the best of both worlds:
Vim Commands (QWERTY)
hjklnavigation stays intuitivew,b,eword motions work as expected- All operators (
d,c,y) in familiar positions - Macros and complex commands just work
Typing (Colemak-DH)
- Most common letters on home row
- Reduced finger travel
- Lower risk of RSI
- More comfortable for prose
The Flow
- Open file, you’re in Normal mode → QWERTY
- Navigate with
hjkl, search with/, jump withgg - Press
ito insert → instant switch to Colemak-DH - Type your code/prose comfortably
- Press
Esc→ instant switch to QWERTY - Continue editing with familiar Vim bindings
The switch is fast enough that it feels like a single keyboard. Your fingers never leave home row.
Troubleshooting
Keyboard not found:
- Check VID/PID match your keyboard (use
lsusbon Linux) - Verify udev rules are installed (Linux)
- Grant Input Monitoring permission (macOS)
Layer not switching:
- Ensure
RAW_ENABLE = yesinrules.mk - Verify
raw_hid_receive_kbfunction signature isvoid, notbool - Check QMK console output:
qmk console
Socket connection failed:
- Make sure rawtalk is running:
pgrep rawtalk - Check socket exists:
ls -la /tmp/rawtalk.sock
Conclusion
This setup has transformed my editing experience. I no longer compromise between ergonomic typing and efficient Vim navigation. The automatic switching is invisible-it just works.
The complete code is available:
- rawtalk - The daemon
- ferris-sweep-qmk-keymap - Example QMK keymap
If you’re a Vim user considering an alternative layout, this approach removes the biggest barrier. You get to keep Vim’s brilliant command language exactly as designed, while gaining all the ergonomic benefits of a modern layout for actual typing.
Security Considerations
While rawtalk is a simple local daemon, it’s worth understanding the security model:
Socket Permissions
The Unix socket is created with 0o600 permissions (owner read/write only). This means:
- Only your user can send commands to the keyboard
- Other users on a shared system cannot hijack your keyboard layers
- If you need multi-user access, change to
0o660and use group permissions
Input Validation
- Mode strings are limited to 8 bytes maximum
- Invalid input is silently dropped
- No shell execution or command injection possible
Rate Limiting
A 10ms minimum interval between layer switches prevents:
- Accidental rapid switching from causing USB issues
- Potential DoS from malicious socket writes
Graceful Shutdown
The daemon handles SIGTERM/SIGINT to:
- Clean up the socket file on exit
- Prevent orphaned sockets that could be hijacked
Disclaimer
This project involves:
- Custom keyboard firmware - flashing incorrect firmware can brick your keyboard (though QMK keyboards typically have bootloader recovery)
- Raw HID communication - requires elevated permissions on some systems
- System daemon - runs continuously in the background
Use at your own risk. The code is open source and provided as-is. Always review code before running it with hardware access.
Permissions Required
| Platform | Requirement |
|---|---|
| Linux | udev rules for hidraw access |
| macOS | Input Monitoring permission |
| Windows | Usually works without special permissions |
If security is a concern in your environment, consider:
- Running rawtalk only when needed (not as a persistent service)
- Auditing the ~100 lines of Rust code
- Using a dedicated user account for keyboard access