Welcome to my ZMK wireless keyboard firmware config! The repository is forked from urob's amazing config, mainly for the super convenient nix-based build environment. Big thanks to urob for making a setup that was super easy to start building off of!
Currently, I only own one ZMK keyboard, a 6 column choc Corne, so that board is what this configuration is optimized for. I've made a bunch of changes to the layout itself, including a custom display module for the nice!view.
I've added a tiny bit of functionality to the Justfile as well: a just flash <left|right|both(default)>
command, and an
improved just draw
command that styles the output a little nicer and also
immediately converts it to png
format.
I'll probably get around to documenting my own layout and design choices at some point. For now, please see urob's original readme (from the time of my fork) below.
This is my personal ZMK firmware configuration. It consists of a 34-keys base layout that is re-used for various boards, including my Corneish Zen and my Planck.
My configuration builds against v0.1
of upstream ZMK plus a backport of the
merged pointer PR. Custom functionality is added through various ZMK modules.
The state of the entire firmware is pinned in my west
manifest.
Homerow mods (aka "HRMs") can be
a game changer -- at least in theory. In practice, they require some finicky
timing: In its most naive implementation, in order to produce a "mod", they must
be held longer than tapping-term-ms
. In order to produce a "tap", they must
be held less than tapping-term-ms
. This requires very consistent typing
speeds that, alas, I do not possess. Hence my quest for a "timer-less" HRM
setup.[^1]
After months of tweaking, I eventually ended up with a HRM setup that is essentially timer-less, resulting in virtually no misfires. Yet it provides a fluent typing experience with mostly no delays.
Let's suppose for a moment we set tapping-term-ms
to something ridiculously
large, say 5 seconds. This makes the configuration timer-less of sorts. But it
has two problems: (1) To activate a mod we will have to hold the HRM keys for
what feels like eternity. (2) During regular typing, there are delays between
the press of a key and the time it appears on the screen.[^2] Enter two of my
favorite ZMK features:
balanced
flavor, which produces a
"hold" if another key is both pressed and released within the tapping-term.
Because that is exactly what I normally do with HRMs, there is virtually never
a need to wait past my long tapping term (see below for two exceptions).require-prior-idle-ms
property,
which immediately resolves a HRM as "tap" when it is pressed shortly after
another key has been tapped. This all but completely eliminates the delay.This is great but there are still a few rough edges:
key 1
down, key 2
down and up, key 1
up. Because of the
balanced
flavor, this would falsely register key 1
as a mod. As a remedy,
I use ZMK's positional hold-tap
feature to force HRMs to always resolve as
"tap" when the next key is on the same side of the keyboard. Problem solved.hold-trigger-on-release
setting, which delays the positional-hold-tap
decision until the next key's release. With the setting, multiple mods can
be combined when held, while I still get the benefit from positional-hold-tap
when keys are tapped.tapping-term-ms
. In practice, there are two reasons why I don't set it to
infinity:
mod
+ alpha
shortcuts by holding the
mod for just a little while before tapping the alpha-key.Win
opens the search menu). Because the balanced
flavour only kicks in when another key is pressed, this also requires
waiting past tapping-term-ms
.require-prior-idle-ms
, which may result in
false negatives when typing fast.Here's my configuration (I use a bunch of helper macros to simplify the syntax, but they are not necessary):
/* use helper macros to define left and right hand keys */
#include "zmk-helpers/key-labels/36.h" // key-position labels
#define KEYS_L LT0 LT1 LT2 LT3 LT4 LM0 LM1 LM2 LM3 LM4 LB0 LB1 LB2 LB3 LB4 // left-hand keys
#define KEYS_R RT0 RT1 RT2 RT3 RT4 RM0 RM1 RM2 RM3 RM4 RB0 RB1 RB2 RB3 RB4 // right-hand keys
#define THUMBS LH2 LH1 LH0 RH0 RH1 RH2 // thumb keys
/* left-hand HRMs */
ZMK_HOLD_TAP(hml,
flavor = "balanced";
tapping-term-ms = <280>;
quick-tap-ms = <175>; // repeat on tap-into-hold
require-prior-idle-ms = <150>;
bindings = <&kp>, <&kp>;
hold-trigger-key-positions = <KEYS_R THUMBS>;
hold-trigger-on-release; // delay positional check until key-release
)
/* right-hand HRMs */
ZMK_HOLD_TAP(hmr,
flavor = "balanced";
tapping-term-ms = <280>;
quick-tap-ms = <175>; // repeat on tap-into-hold
require-prior-idle-ms = <150>;
bindings = <&kp>, <&kp>;
hold-trigger-key-positions = <KEYS_L THUMBS>;
hold-trigger-on-release; // delay positional check until key-release
)
After a recent round of patches, the above configuration now works with upstream ZMK.
Other parts of my configuration still require a few PRs that aren't yet in upstream ZMK. My personal ZMK fork includes all PRs needed to compile my configuration. If you prefer to maintain your own fork with a custom selection of PRs, you might find this ZMK-centric introduction to Git helpful.
Hopefully, the above configuration "just works". If it doesn't, here's a few smaller (and larger) things to try.
require-prior-idle-ms
. As a
rule of thumb, you want to set it to at least 10500/x
where x
is your
(relaxed) WPM for English prose.[^3]tapping-term-ms
(or disable
hold-trigger-key-positions
)require-prior-idle-ms
(or set
flavor to hold-preferred
-- to continue using hold-trigger-on-release
, you
must also
patch ZMK
or use an already patched branch)tapping-term-ms
require-prior-idle-ms
(or set
flavor to tap-preferred
, which requires holding HRMs past tapping term to
activate)I am a big fan of combos for all sort of things. In terms of comfort, I much prefer them over accessing layers that involve lateral thumb movements to be activated, especially when switching between different layers in rapid succession.
One common concern about overloading the layout with combos is that they lead to
misfires. Fortunately, the above-mentioned require-prior-idle-ms
option also
works for combos, which in my experience all but completely eliminates the
problem -- even when rolling keys on the home row!
My combo layout aims to place the most used symbols in easy-to-access locations while also making them easy to remember. Specifically:
+
and &
being swapped)_
aligns with superscript ^
; minus -
aligns with +
; division /
aligns
with multiplication *
; logical-or |
aligns with logical-and &
)<
, >
, {
and }
being accessed from the Navigation layer (or when
combined with Shift
)tap
, esc
, enter
, cut
(on X +
D), copy
and paste
that go well with right-handed mouse usageInspired by Jonas Hietala's Numword for QMK, I implemented my own Auto-layer behavior for ZMK to set up Numword. It is triggered via a single tap on "Smart-Num". Numword continues to be activated as long as I type numbers, and deactivates automatically on any other keypress (holding it activates a non-sticky num layer).
After using Numword for more than a year now, I have been overall very happy with it. When typing single digits, it effectively is a sticky-layer but with the added advantage that I can also use it to type multiple digits.
The main downside is that if a sequence of numbers is immediately followed by
any of the letters on which my numpad is located (WFPRSTXCD), then the automatic
deactivation won't work. But this is rare -- most number sequences are
terminated by space
, return
or some form of punctuation/delimination. To
deal with the rare cases where they aren't, there is a CANCEL
key on the
navigation-layer that deactivates Numword, Capsword and Smart-mouse. (It also
toggles off when pressing Numword
again, but I find it cognitively easier to
have a dedicated "off-switch" than keeping track of which modes are currently
active.)
Similarly to Numword, I have a smart-mouse layer (activated by comboing W + P), which replaces the navigation cluster with scroll and mouse-movements, and replaces the right thumbs with mouse buttons. Pressing any other key automatically deactivates the layer.
My right thumb triggers three variations of shift: Tapping yields sticky-shift (used to capitalize alphas), holding activates a regular shift, and double-tapping (or equivalently shift + tap) activates ZMK's Caps-word behavior.
One minor technical detail: While it would be possible to implement the double-tap functionality as a tap-dance, this would add a delay when using single taps. To avoid the delays, I instead implemented the double-tap functionality as a mod-morph.
To economize on keys, I am using hold-taps on my navigation cluster, which yield
home
, end
, begin/end of document
, and delete word forward/backward
on
long-presses. The exact implementation is tweaked so that Ctrl
is silently
absorbed in combination with home
and end
to avoid accidental document-wide
operations (which are accessible via the dedicated begin/end document keys
.)
I am using Nick Conway's fantastic
tri-state behavior for a
one-handed Alt-Tab switcher (PWin
and NWin
).
I am using my own implementation of a Leader key to bind less common functionality without giving up dedicated keys. Currently, I am using leader sequences for various system and output controls. I am also using leader key sequences for convenient access to various Unicode math symbols and international characters.
I streamline my local build process using nix
, direnv
and just
. This
automatically sets up a virtual development environment with west
, the
zephyr-sdk
and all its dependencies when cd
-ing into the ZMK-workspace. The
environment is completely isolated and won't pollute your system.
Install the nix
package manager:
# Install Nix with flake support enabled
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix |
sh -s -- install --no-confirm
# Start the nix daemon without restarting the shell
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
Install direnv
(and optionally but recommended
nix-direnv
[^4]) using your
package manager of choice. E.g., using the nix
package manager that we just
installed[^5]:
nix profile install nixpkgs#direnv nixpkgs#nix-direnv
Set up the direnv
shell-hook for your
shell. E.g., for bash
:
# Install the shell-hook
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
# Enable nix-direnv (if installed in the previous step)
mkdir -p ~/.config/direnv
echo 'source $HOME/.nix-profile/share/nix-direnv/direnvrc' >> ~/.config/direnv/direnvrc
# Optional: make direnv less verbose
echo '[global]\nwarn_timeout = "2m"\nhide_env_diff = true' >> ~/.config/direnv/direnv.toml
# Source the bashrc to activate the hook (or start a new shell)
source ~/.bashrc
Clone your fork of this repository. I like to name my local clone
zmk-workspace
as it will be the toplevel of the development environment.
# Replace `urob` with your username
git clone https://github.com/urob/zmk-config zmk-workspace
Enter the workspace and set up the environment.
# The first time you enter the workspace, you will be prompted to allow direnv
cd zmk-workspace
# Allow direnv for the workspace, which will set up the environment
direnv allow
# Initialize the Zephyr workspace and pull in the ZMK dependencies
# (same as `west init -l config && west update && west zephyr-export`)
just init
After following the steps above your workspace should look like this:
zmk-workspace
├── config
├── firmware (created after building)
├── modules
│ ├── auto-layer
│ ├── helpers
│ └── tri-state
└── zmk
└── ...
To build the firmware, simply type just build all
from anywhere within the
workspace. This will parse build.yaml
and build the firmware for all board and
shield combinations listed there.
To only build the firmware for a specific target, use just build <target>
.
This will build the firmware for all matching board and shield combinations. For
instance, to build the firmware for my Corneish Zen, I can type
just build zen
, which builds both corneish_zen_v2_left
and
corneish_zen_v2_right
. (just list
shows all valid build targets.)
Additional arguments to just build
are passed on to west
. For instance, a
pristine build can be triggered with just build all -p
.
(For this particular example, there is also a just clean
recipe, which clears
the build cache. To list all available recipes, type just
. Bonus tip: just
provides
completion scripts
for many shells.)
The build environment packages
keymap-drawer. just draw
parses
base.keymap
and draws it to draw/base.svg
. I haven't gotten around to
tweaking the output yet, so for now this is just a demonstration of how to set
things up.
To make changes to the ZMK source or any of the modules, simply edit the files
or use git
to pull in changes.
To switch to any remote branches or tags, use git fetch
inside a module
directory to make the remote refs locally available. Then switch to the desired
branch with git checkout <branch>
as usual. You may also want to register
additional remotes to work with or consider making them the default in
config/west.yml
.
To update the ZMK dependencies, use just update
. This will pull in the latest
version of ZMK and all modules specified in config/west.yml
. Make sure to
commit and push all local changes you have made to ZMK and the modules before
running this command, as this will overwrite them.
To upgrade the Zephyr SDK and Python build dependencies, use just upgrade-sdk
.
Since I switched from QMK to ZMK I have been very impressed with how easy it is to set up relatively complex layouts in ZMK. For the most parts I don't miss any functionality (to the contrary, I found that ZMK supports many features natively that would require complex user-space implementations in QMK). Below are a few remaining issues:
&bootloader
doesn't work with stm32 boards like the Planck
(#1086)[^1]: I call it "timer-less", because the large tapping-term makes the behavior
insensitive to the precise timings. One may say that there is still the
require-prior-idle
timeout. However, with both a large tapping-term and
positional-hold-taps, the behavior is not actually sensitive to the
require-prior-idle
timing: All it does is reduce the delay in typing;
i.e., variations in typing speed won't affect what is being typed but
merely how fast it appears on the screen.
[^2]: The delay is determined by how quickly a key is released and is not directly related to the tapping-term. But regardless of its length, most people still find it noticable and disruptive.
[^3]: E.g, if your WPM is 70 or larger, then the default of 150ms (=10500/70)
should work well. The rule of thumb is based on an average character length
of 4.7 for English words. Taking into account 1 extra tap for space
, this
yields a minimum require-prior-idle-ms
of (60 _ 1000) / (5.7 _ x) ≈ 10500
/ x milliseconds. The approximation errs on the safe side, as in practice
home row taps tend to be faster than average.
[^4]: nix-direnv
provides a vastly improved caching experience compared to only
having direnv
, making entering and exiting the workspace instantaneous
after the first time.
[^5]: This will permanently install the packages into your local profile, forgoing
many of the benefits that make Nix uniquely powerful. A better approach,
though beyond the scope of this document, is to use home-manager
to
maintain your user environment.