Skip to main content

Community Spotlight Series #2: Node-free Config

· 7 min read
Cem Aksoylar
Documentation maintainer

This blog continues our series of posts where we highlight projects within the ZMK ecosystem that we think are interesting and that the users might benefit from knowing about them. You might be aware that ZMK configurations in the Devicetree format use the C preprocessor so that directives like #define RAISE 2 or #include <behaviors.dtsi> can be used in them. In this installment we are highlighting the zmk-nodefree-config project by urob that contains helper methods that utilizes this fact for users who prefer editing and maintaining their ZMK config directly using the Devicetree syntax format.

In the rest of the post we leave it to urob to introduce and explain the motivations of the project, and various ways it can be used to help maintain ZMK keymaps. Stay tuned for future installments in the series!

Overview

Loosely speaking the nodefree repo -- more on the name later -- is a collection of helper functions that simplify configuring keymap files. Unlike the graphical keymap editor covered in the previous spotlight post, it is aimed at users who edit and maintain directly the source code of their keymap files.

The provided helpers fall into roughly one of three categories:

  1. Helpers that eliminate boilerplate, reduce the complexity of keymaps, and improve readability.
  2. Helpers that improve portability of "position-based" properties such as combos.
  3. Helpers that define international and other unicode characters.

The reminder of this post details each of these three categories.

Eliminating Boilerplate

In ZMK, keymaps are configured using so-called Devicetree files. Devicetree files define a collection of nested nodes, whereas each node in turn specifies a variety of properties through which one can customize the keymap.

For example, the following snippet sets up a mod-morph behavior that sends . ("dot") when pressed by itself and sends : ("colon") when shifted:

/ {
behaviors {
dot_colon: dot_colon_behavior {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp DOT>, <&kp COLON>;
mods = <(MOD_LSFT|MOD_RSFT)>;
};
};
};

Adding this snippet to the keymap will create a new node dot_colon_behavior (nested underneath the behaviors and root / nodes), and assigns it four properties (compatible, #binding-cells, etc). Here, the crucial properties are bindings and mods, which spell out the actual functionality of the new behavior. The rest of the snippet (including the nested node-structure) is boilerplate.

The idea of the nodefree repo is to use C preprocessor macros to improve readability by eliminating as much boilerplate as possible. Besides hiding redundant behavior properties from the user, it also automatically creates and nests all required behavior nodes, making for a "node-free" and less error-prone user experience (hence the name of the repo).

For example, using ZMK_BEHAVIOR, one of the repo's helper functions, the above snippet simplifies to:

ZMK_BEHAVIOR(dot_colon, mod_morph,
bindings = <&kp DOT>, <&kp COLON>;
mods = <(MOD_LSFT|MOD_RSFT)>;
)

For complex keymap files, the gains from eliminating boilerplate can be enormous. To provide a benchmark, consider my personal config, which uses the nodefree repo to create various behaviors, set up combos, and add layers to the keymap. Without the nodefree helpers, the total size of my keymap would have been 41 kB. Using the helper macros, the actual size is instead reduced to a more sane 12 kB.1

Simplifying "Position-based" Behaviors

In ZMK, there are several features that are position-based. As of today, these are combos and positional hold-taps, with behaviors like the "Swapper" and Leader key currently developed by Nick Conway in pull requests also utilizing them.

Configuring these behaviors involves lots of key counting, which can be cumbersome and error-prone, especially on larger keyboards. It also reduces the portability of configuration files across keyboards with different layouts.

To facilitate configuring position-based behaviors, the nodefree repo comes with a community-maintained library of "key-position labels" for a variety of popular layouts. The idea is to provide a standardized naming convention that is consistent across different keyboards. For instance, the labels for a 36-key layout are as follows:

    ╭─────────────────────┬─────────────────────╮
│ LT4 LT3 LT2 LT1 LT0 │ RT0 RT1 RT2 RT3 RT4 │
│ LM4 LM3 LM2 LM1 LM0 │ RM0 RM1 RM2 RM3 RM4 │
│ LB4 LB3 LB2 LB1 LB0 │ RB0 RB1 RB2 RB3 RB4 │
╰───────╮ LH2 LH1 LH0 │ RH0 RH1 RH2 ╭───────╯
╰─────────────┴─────────────╯

The labels are all of the following form:

  • L/R for Left/Right side
  • T/M/B/H for Top/Middle/Bottom and tHumb row.
  • 0/1/2/3/4 for the finger position, counting from the inside to the outside

The library currently contains definitions for 17 physical layouts, ranging from the tiny Osprette to the large-ish Glove80. While some of these layouts contain more keys than others, the idea behind the library is that keys that for all practical purposes are in the "same" location share the same label. That is, the 3 rows containing the alpha keys are always labeled T/M/B with LM1 and RM1 defining the home position of the index fingers. For larger boards, the numbers row is always labeled N. For even larger boards, the function key row and the row below B are labeled C and F (mnemonics for Ceiling and Floor), etc.

Besides sparing the user from counting keys, the library also makes it easy to port an entire, say, combo configuration from one keyboard to the next by simply switching layout headers.

Unicode and International Keycodes

The final category of helpers is targeted at people who wish to type international characters without switching the input language of their operation system. To do so, the repo comes with helper functions that can be used to define Unicode behaviors.

In addition, the repo also ships with a community-maintained library of language-files that define Unicode behaviors for all relevant characters in a given language. For instance, after loading the German language file, one can add &de_ae to the keymap, which will send ä/Ä when pressed or shifted.

About Me

My path to ZMK and programmable keyboards started in the early pandemic, when I built a Katana60 and learned how to touch-type Colemak. Soon after I purchased a Planck, which turned out to be the real gateway drug for me.

Committed to making the best out of the Planck's 48 keys, I have since discovered my love for tinkering with tiny layouts and finding new ways of squeezing out a bit more ergonomics. Along the way, I also made the switch from QMK to ZMK, whose "object-oriented" approach to behaviors I found more appealing for complex keymaps.2

These days I mostly type on a Corne-ish Zen and are waiting for the day when I will finally put together the Hypergolic that's been sitting on my desk for months. My current keymap is designed for 34 keys, making liberal use of combos and timerless homerow mods to make up for a lack of keys.

Footnotes

  1. To compute the impact on file size, I ran pcpp --passthru-unfound-includes on the base.keymap file, comparing two variants. First, I ran the pre-processor on the actual file. Second, I ran it on a version where I commented out all the nodefree headers, preventing any of the helper functions from getting expanded. The difference isolates precisely the size gains from eliminating boilerplate, which in my ZMK config are especially large due to a vast number of behaviors used to add various Unicode characters to my keymap.

  2. I am using the term object-oriented somewhat loosely here. What I mean by that is the differentiation between abstract behavior classes (such as hold-taps) and specific behavior instances that are added to the keymap. Allowing to set up multiple, reusable instances of each behavior has been a huge time-saver compared to QMK's more limited behavior settings that are either global or key-specific.