This ZMK module provides runtime configurable input processors for pointing devices. You can adjust scaling and rotation parameters dynamically through a web interface without rebuilding firmware.
- Runtime Configuration: Adjust input processor parameters without rebuilding firmware
- Web Interface: Configure settings through a browser-based UI
- Scaling Support: Configure speed multipliers (e.g., x2 faster, x0.5 slower)
- Rotation Support: Apply rotation transformations in degrees (fully implemented with paired X/Y handling)
- Axis Reversing: Invert X and/or Y axis independently to reverse input direction
- Axis Snapping: Lock scrolling to X or Y axis with threshold-based unlock
- Temp-Layer Layer: Automatically activate a layer when using pointing device, deactivate on key press or timeout
- Active Layers: Specify which layers the processor should be active on using a bitmask
- Temporary Changes: Hold a key to temporarily change settings (perfect for DPI toggle)
- Persistent Settings: Settings saved to non-volatile storage
- Multiple Processors: Support for multiple input processors with individual configuration
manifest:
remotes:
- name: cormoran
url-base: https://github.com/cormoran
projects:
- name: zmk-module-runtime-input-processor
remote: cormoran
revision: main # or latest commit hash
# Below setting required to use unofficial studio custom RPC feature
- name: zmk
remote: cormoran
revision: v0.3+custom-studio-protocol
import:
file: app/west.yml
# Persistent settings storage backend used by this module
- name: zmk-feature-custom-settings
remote: cormoran
revision: mainCONFIG_ZMK_POINTING=y
# Enable runtime input processor
CONFIG_ZMK_RUNTIME_INPUT_PROCESSOR=y
# Enable studio custom RPC features for web UI
CONFIG_ZMK_STUDIO=y
CONFIG_ZMK_RUNTIME_INPUT_PROCESSOR_STUDIO_RPC=y
# Required when the Studio RPC is enabled: "Refresh List" paces its
# per-processor notifications on ZMK's shared low-priority work queue, and
# encoding a notification through the Studio RPC core needs more than that
# thread's 768-byte default stack. A build without this raise will fault
# (MPU stack guard) the first time the web UI lists processors.
CONFIG_ZMK_LOW_PRIORITY_THREAD_STACK_SIZE=2048A module cannot raise this shared thread's stack for you (a ZMK Kconfig
defaultin your config wins over the module's), so it must live in your keyboard config as shown above.
CONFIG_ZMK_RUNTIME_INPUT_PROCESSOR automatically selects CONFIG_ZMK_CUSTOM_SETTINGS
(from zmk-feature-custom-settings),
which this module uses purely as a typed persistence backend for the settings described
below - zmk-feature-custom-settings's own Studio RPC surface is not required and is not
enabled by this module (the RPC/web UI in this repo is unaffected and unofficial-Studio-RPC
compatible as before).
#include <dt-bindings/zmk/input.h>
#include <input/processors.dtsi>
#include <input/processors/runtime-input-processor.dtsi>
// The .dtsi provides default device definitions
// - mouse_runtime_input_processor
// - scroll_runtime_input_processor
/ {
// Then use it in your input device configuration
my_input_listener {
// ... other config ...
input-processors = <&mouse_runtime_input_processor>;
scroller {
// layers = <9>;
input-processors = <&zip_xy_to_scroll_mapper &scroll_runtime_input_processor>;
};
};
// For split keyboard, you can configure input processor in central
split_input: split_input {
compatible = "zmk,input-split"
};
split_listener: split_listener {
compatible = "zmk,input-listener";
status = <disabled>;
device = <&split_input>;
};
my_rip: my_rip {
compatible = "zmk,input-processor-runtime";
processor-label = "custom";
type = <INPUT_EV_REL>;
x-codes = <INPUT_REL_X>;
y-codes = <INPUT_REL_Y>;
scale-multiplier = <1>;
scale-divisor = <1>;
rotation-degrees = <0>;
track-remainders;
// Optional: Temp-layer layer default settings
temp-layer-enabled; // Enable temp-layer by default
temp-layer = <1>; // Default to layer 1
temp-layer-activation-delay-ms = <100>; // 100ms activation delay
temp-layer-deactivation-delay-ms = <500>; // 500ms deactivation delay
// Optional: Active layers configuration
// Bitmask of layers where processor should be active (0 = all layers)
// Each bit represents a layer: bit 0 = layer 0, bit 1 = layer 1, etc.
// Example: active-layers = <0x00000003> activates on layers 0 and 1 only
active-layers = <0>; // Default: active on all layers
// Optional: Performance optimization
temp-layer-transparent-behavior = <&trans>;
temp-layer-kp-behavior = <&kp>;
temp-layer-keep-keycodes = <LEFT_CONTROL LEFT_SHIFT LEFT_ALT LEFT_GUI RIGHT_CONTROL RIGHT_SHIFT RIGHT_ALT RIGHT_GUI>;
#input-processor-cells = <0>;
};
};
// <central>.overlay
&split_listener {
status = "okay";
input-processors = <&my_rip>;
};
- Build and flash your firmware with the runtime input processor enabled
- Connect to your keyboard via USB (Web Serial) or Bluetooth (Web Bluetooth) using a Chromium-based browser (Chrome, Edge, ...) over HTTPS or localhost
- The web interface will automatically detect available input processors
- Adjust scaling and rotation parameters
- Changes are applied immediately without restarting
Each setting write chooses where the value is stored, mirroring
zmk-feature-custom-settings:
- Persist to flash (default): the value is applied and saved to non-volatile storage, so it survives a reboot. This is the historical behavior — clients that do not select a mode always persist.
- Memory only: the value is applied and kept as the current baseline in RAM, but is not written to flash. It is lost on reboot unless you save it.
The web UI exposes this as the Storage selector next to "Apply Settings". Three whole-keyboard operations sit next to "Refresh List":
- Save All — flush every processor's current settings to flash (persist any "memory only" changes).
- Discard All — drop unsaved (memory-only) changes and reload the last saved values from flash; processors with nothing saved return to their devicetree defaults.
- Reset All — reset every processor to its devicetree defaults and persist them.
The equivalent firmware API is
zmk_input_processor_runtime_save_all() / _discard_all() / _reset_all(),
and the setters take a zmk_input_processor_runtime_write_mode
(PERSIST / MEMORY / TEMPORARY).
- Scaling Multiplier/Divisor: Controls pointer speed
- Example:
2/1= 2x faster,1/2= 0.5x slower - Values are applied as:
output = input * multiplier / divisor - Remainders are tracked for precise scaling
- Example:
2x Speed:
scale-multiplier = 2
scale-divisor = 1
Half Speed:
scale-multiplier = 1
scale-divisor = 2
You can temporarily change input processor settings while holding a key, useful for temporary DPI changes:
#include <behaviors/runtime-input-processor.dtsi>
/ {
keymap {
compatible = "zmk,keymap";
default_layer {
bindings = <
// Use &temp_fast in your keymap
&hdpi // Hold this key for 1.5x mouse speed
&ldpi // Hold this key for 0.5x mouse speed
&hscr // High scroll speed
&lscr // Low scroll speed
// ... other keys
>;
};
};
};
// Customization
&hdpi {
scale-multiplier = <3>;
scale-divisor = <2>;
}
When you press and hold a key with the temporary config behavior:
- Current settings are saved
- Temporary settings are applied
- When you release the key, original settings are restored
The temp-layer layer feature automatically activates a specified layer when you use your pointing device (trackpad, trackball, etc.) and deactivates it after a period of inactivity or when you press a key.
Configuration via Device Tree (Optional):
You can configure default temp-layer settings in your device tree:
my_pointer_processor: my_pointer_processor {
compatible = "zmk,input-processor-runtime";
processor-label = "trackpad";
// ... basic config ...
// Enable temp-layer with default settings
temp-layer-enabled; // Boolean property - presence enables it
temp-layer = <1>; // Target layer (default: 0)
temp-layer-activation-delay-ms = <100>; // Activation delay (default: 100)
temp-layer-deactivation-delay-ms = <500>; // Deactivation delay (default: 500)
};
Configuration via Web UI:
Temp-layer layer settings can also be configured through the web interface:
- Enable/Disable: Toggle the temp-layer layer feature
- Target Layer: The layer number to activate (e.g., layer 1, 2, etc.)
- Activation Delay: Time to wait after input starts before activating the layer (milliseconds)
- Deactivation Delay: Time to wait after input stops before deactivating the layer (milliseconds)
Behavior:
- When you move the pointing device, the layer activates after the activation delay
- The layer stays active while you continue using the pointing device
- When you press any keyboard key, the layer deactivates immediately (unless it's a modifier or the key is on the temp-layer layer)
- If you stop moving the pointing device, the layer deactivates after the deactivation delay
- If a key press occurs before the activation delay expires, the layer won't activate
Keep Temp-Layer Layer Active:
You can create a behavior to prevent the temp-layer layer from deactivating while holding a key:
#include <behaviors/runtime-input-processor.dtsi>
/ {
keymap {
compatible = "zmk,keymap";
default_layer {
bindings = <
// temp-layer keep active
&amka // Hold this key to keep temp-layer layer active
// ... other keys
>;
};
};
};
When holding the keep-active behavior key, the temp-layer layer will not deactivate when you press other keys or after the timeout period.
The active layers feature allows you to specify which layers the input processor should be active on. This is useful when you want the processor to only apply transformations (scaling, rotation) on specific layers.
Configuration via Device Tree:
my_pointer_processor: my_pointer_processor {
compatible = "zmk,input-processor-runtime";
processor-label = "trackpad";
// ... basic config ...
// Active on layers 0 and 1 only (bitmask: 0x00000003)
active-layers = <0x00000003>;
};
Configuration via Web UI:
The web interface provides two ways to configure active layers:
- Hex Input: Enter the layer bitmask directly as a hexadecimal value (e.g.,
0x00000003for layers 0 and 1) - Layer Checkboxes: Click individual layer checkboxes to build the bitmask visually
Bitmask Format:
- Each bit represents a layer: bit 0 = layer 0, bit 1 = layer 1, etc.
0x00000000(default): Processor is active on all layers0x00000001: Active only on layer 00x00000003: Active on layers 0 and 10x0000000F: Active on layers 0-30xFFFFFFFF: Active on all 32 layers
Behavior:
- If at least one of the specified layers is active, the processor works normally
- If none of the specified layers are active, the processor skips processing (no transformation applied)
- This allows you to have different pointer speeds or behaviors on different layers
The axis snapping feature locks scrolling to a specific axis (X or Y), preventing unwanted diagonal scrolling. Movement on the locked axis is suppressed unless it exceeds a configurable threshold within a timeout window.
Configuration via Device Tree:
#include <dt-bindings/zmk/runtime_input_processor.h>
scroll_runtime_input_processor: scroll_runtime_input_processor {
compatible = "zmk,input-processor-runtime";
processor-label = "scroll";
// ... basic config ...
// Lock to Y axis for vertical scrolling only
axis-snap-mode = <AXIS_SNAP_MODE_Y>;
axis-snap-threshold = <100>; // Unlock if cross-axis movement > 100
axis-snap-timeout-ms = <1000>; // Decay period
};
Available axis snap mode constants:
AXIS_SNAP_MODE_NONE(0): No snappingAXIS_SNAP_MODE_X(1): Snap to X axis (horizontal only)AXIS_SNAP_MODE_Y(2): Snap to Y axis (vertical only)
Configuration via Web UI:
The web interface provides controls for axis snapping:
- Snap Mode: Select no-snap, snap to X axis, or snap to Y axis
- Unlock Threshold: Set how much cross-axis movement is needed to unlock the snap
- Timeout Window: Set the time window for checking the threshold
Snap Modes:
- No Snap (0): Normal operation, no axis locking
- Snap to X Axis (1): Only horizontal movement, vertical suppressed unless threshold exceeded
- Snap to Y Axis (2): Only vertical movement, horizontal suppressed unless threshold exceeded
Behavior:
- When snap is enabled, movement on the locked axis proceeds normally
- Movement on the cross-axis is accumulated but suppressed (value set to 0)
- The accumulator decays over time (threshold amount over the timeout period)
- If accumulated cross-axis movement exceeds the threshold, the snap lock is released
- If no cross-axis movement occurs, the accumulator decays to zero after the timeout period
Example Use Cases:
- Wheel Scroll: Set
axis-snap-mode = <2>on scroll processor to ensure wheel only scrolls vertically - Text Selection: Use the temporary snap behavior to lock Y-axis while selecting text with mouse
Temporary Snap Behavior:
You can temporarily enable axis snapping while holding a key using binding parameters:
#include <behaviors/runtime-input-processor.dtsi>
#include <dt-bindings/zmk/runtime_input_processor.h>
/ {
keymap {
compatible = "zmk,keymap";
default_layer {
bindings = <
&ysnap AXIS_SNAP_MODE_Y 100 // Hold for Y-axis snap (threshold=100)
&xsnap AXIS_SNAP_MODE_X 50 // Hold for X-axis snap (threshold=50)
// ... other keys
>;
};
};
};
The behavior takes two parameters:
- param1: Snap mode (use constants:
AXIS_SNAP_MODE_NONE,AXIS_SNAP_MODE_X,AXIS_SNAP_MODE_Y) - param2: Threshold for unlocking snap
You can also configure the timeout in the behavior definition:
&ysnap {
timeout-ms = <500>; // Custom timeout (default: 1000ms)
};
When you press and hold the snap behavior key:
- Current snap settings are saved
- Temporary snap settings are applied (with 1000ms timeout)
- When you release the key, original settings are restored
There are two west workspace layout options.
This option is west's standard way. Choose this option if you want to re-use dependent projects in other zephyr module development.
mkdir west-workspace
cd west-workspace # this directory becomes west workspace root (topdir)
git clone <this repository>
# rm -r .west # if exists to reset workspace
west init -l . --mf tests/west-test.yml
west update --narrow
west zephyr-exportThe directory structure becomes like below:
west-workspace
- .west/config
- build : build output directory
- <this repository>
# other dependencies
- zmk
- zephyr
- ...
# You can develop other zephyr modules in this workspace
- your-other-repo
You can switch between modules by removing west-workspace/.west and re-executing west init ....
Choose this option if you want to download dependencies under this directory (like node_modules in npm). This option is useful for specifying cache target in CI. The layout is relatively easy to recognize if you want to isolate dependencies.
git clone <this repository>
cd <cloned directory>
west init -l west --mf west-test-standalone.yml
# If you use dev container, start from below commands. Above commands are executed
# automatically.
west update --narrow
west zephyr-exportThe directory structure becomes like below:
<this repository>
- .west/config
- build : build output directory
- dependencies
- zmk
- zephyr
- ...
Dev container is configured for setup option2. The container creates below volumes to re-use resources among containers.
- zmk-dependencies: dependencies dir for setup option2
- zmk-build: build output directory
- zmk-root-user: /root, the same to ZMK's official dev container
Please refer ./web/README.md.
ZMK firmware test
./tests directory contains test config for posix to confirm module functionality and config for xiao board to confirm build works.
Tests can be executed by below command:
# Run all test case and verify results
python -m unittestIf you want to execute west command manually, run below. (for zmk-build, the result is not verified.)
# Build test firmware for xiao
# `-m tests/zmk-config .` means tests/zmk-config and this repo are added as additional zephyr module
west zmk-build tests/zmk-config/config -m tests/zmk-config .
# Run zmk test cases
# -m . is required to add this module to build
west zmk-test tests -m .
Web UI test
The ./web directory includes Jest tests. See ./web/README.md for more details.
cd web
npm testCI also boots this module's firmware in the Renode
emulator (no physical board needed) and exercises it functionally: the real
ZMK boot banner, a core Studio RPC GetDeviceInfo round trip, and this
module's own custom Studio RPC subsystem (cormoran_rip). This is a step
in the Build job in .github/workflows/zmk-module.yml (not a separate
job) -- it tests the renode_smoke_test artifact python3 -m unittest -v
already built (see tests/zmk-config/build.yaml) using a reusable action,
cormoran/zmk-workspace's
zmk-renode-test.
That action does not build firmware -- this module's own build flow
does, using the renode-studio-uart Zephyr snippet that zmk-workspace
provides as a west dependency (see
west/west-dependency/west-test-dependency.yml); the action only boots the
resulting ELF and runs tests against it.
To reproduce locally (after the usual west update, which also fetches
zmk-workspace into dependencies/zmk-workspace):
# 1. Build the Renode-testable artifact (Studio-RPC-over-UART overlay + the
# Renode-only transport that bypasses the USB-gated real one -- real
# hardware still uses the studio-rpc-usb-uart snippet as normal). This
# builds every tests/zmk-config/build.yaml artifact; -af filters to just
# the Renode one by (substring) artifact name.
west zmk-build tests/zmk-config -af renode
# (equivalent to letting the full `python3 -m unittest -v` build sweep run)
# 2. Generic smoke test (boot banner + core Studio RPC). --elf must be
# absolute: Renode is launched with the skill's own directory as its
# cwd, so a relative ELF path resolves against the wrong location and
# silently fails to boot (Renode logs are suppressed by --hide-log).
python3 dependencies/zmk-workspace/skills/test-zmk-renode/scripts/renode_smoke.py \
--elf "$PWD/build/renode_smoke_test/zephyr/zmk.elf" --west-topdir "$PWD"
# 3. This module's own Renode test (custom Studio RPC subsystem). PYTHONPATH
# is optional -- tests/renode/renode_test.py falls back to
# dependencies/zmk-workspace/skills/test-zmk-renode/scripts automatically.
ZMK_RENODE_ELF="$PWD/build/renode_smoke_test/zephyr/zmk.elf" \
python3 tests/renode/renode_test.py -vtests/renode/renode_test.py's own module docstring documents a known
Renode-only limitation (custom-subsystem responses carrying real data time
out past a few tens of bytes) and how each test in that file relates to it.
Github actions are pre-configured to publish web UI to github pages.
- Visit Settings>Pages
- Set source as "Github Actions"
- Visit Actions>"Test and Build Web UI"
- Click "Run workflow"
Then, the Web UI will be available in
https://<your github account>.github.io/<repository name>/ like https://cormoran.github.io/zmk-module-template-with-custom-studio-rpc.
For previewing web UI changes in pull requests:
-
Create a Cloudflare Workers project and configure secrets:
CLOUDFLARE_API_TOKEN: API token with Cloudflare Pages edit permissionCLOUDFLARE_ACCOUNT_ID: Your Cloudflare account ID- (Optional)
CLOUDFLARE_PROJECT_NAME: Project name (defaults tozmk-module-web-ui) - Enable "Preview URLs" feature in cloudflare the project
-
Optionally set up an
approval-requiredenvironment in github repository settings requiring approval from repository owners -
Create a pull request with web UI changes - the preview deployment will trigger automatically and wait for approval
By running Actions > Sync Changes in Template > Run workflow, pull request is created to your repository to reflect changes in template repository.
If the template contains changes in .github/workflows/*, registering your github personal access token as GH_TOKEN to repository secret is required.
The fine-grained token requires write to contents, pull-requests and workflows.
Please see detail in actions-template-sync.
For more info on modules, you can read through through the Zephyr modules page and ZMK's page on using modules. Zephyr's west manifest page may also be of use.