Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/src/mapping-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ name = "fps"
trigger = "LM"
activation = "hold"
tap = "mouse_side"
hold = "RB"
hold_timeout = 200
```

Expand All @@ -198,6 +199,7 @@ hold_timeout = 200
| `trigger` | string | yes | Button name that activates this layer |
| `activation` | string | no | `"hold"` (default), `"toggle"`, or `"hold_toggle"`. `hold_toggle` starts like `hold`, but holding past `hold_timeout` toggles the layer sticky on/off instead of making it momentary. |
| `tap` | string | no | Button/key emitted on short press (when using `hold` or `hold_toggle` activation). May be a `ButtonId`, `KEY_*`, `mouse_*`, or `disabled`. **Cannot be `macro:<name>`** — the layer tap dispatch path does not run macros, so `tap = "macro:foo"` is rejected at validate time (`error.LayerTapCannotBeMacro`). Use `macro:<name>` from `[remap]` / `[layer.remap]` instead. |
| `hold` | string | no | Passthrough output emitted continuously while the layer is **active**, for every activation mode (`hold` / `hold_toggle` / `toggle`). A single `ButtonId`, `KEY_*`, `mouse_*`, or `BTN_*` target. Fires only after the layer activates — never on a short tap (`tap` still fires for the short press). **Cannot be `macro:<name>`** (`error.LayerHoldCannotBeMacro`). Released on every exit path (trigger release, layer/mapping switch, reset). |
| `hold_timeout` | integer | no | Hold detection threshold in ms (1–5000); default 200 |

### `[layer.remap]`
Expand Down
27 changes: 27 additions & 0 deletions docs/src/mapping-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,33 @@ Three activation modes:

The `tap` + `hold_timeout` combination lets a button do double duty: if released before `hold_timeout` ms, it fires `tap` instead of activating the layer.

The optional `hold` field emits a chosen output continuously *while the layer is active* — the trigger does double duty as both a layer switch and a passthrough button. It applies uniformly to all three activation modes, and fires only after the layer activates (a short press that resolves to `tap` emits no `hold` output). Released automatically on every exit path. `hold` takes a single `ButtonId`, `KEY_*`, `mouse_*`, or `BTN_*` target (not `macro:<name>`).

```toml
# Witcher-3 "Sense": hold LB to enter a layer AND still emit LB.
[[layer]]
name = "sense"
trigger = "LB"
activation = "hold"
hold = "LB"
hold_timeout = 200

[layer.remap]
A = "KEY_Q"
```

```toml
# Keyboard modifier held while a toggle layer is latched on.
[[layer]]
name = "fn"
trigger = "Select"
activation = "toggle"
hold = "KEY_LEFTSHIFT"

[layer.remap]
A = "KEY_F1"
```

```toml
# "aim" layer: hold LM to enable gyro + mouse aim
[[layer]]
Expand Down
57 changes: 57 additions & 0 deletions src/config/mapping.zig
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ pub const LayerConfig = struct {
trigger: []const u8,
activation: []const u8 = "hold",
tap: ?[]const u8 = null,
hold: ?[]const u8 = null,
hold_timeout: ?i64 = null,
remap: ?RemapMap = null,
gyro: ?GyroConfig = null,
Expand Down Expand Up @@ -941,6 +942,12 @@ pub fn validate(cfg: *const MappingConfig) !void {
}
}

if (layer.hold) |hold| {
if (std.mem.startsWith(u8, hold, "macro:")) {
return error.LayerHoldCannotBeMacro;
}
}

if (layer.remap) |*m| {
try checkRemapMacros(cfg, m);
try checkRemapChords(m);
Expand Down Expand Up @@ -1813,6 +1820,56 @@ test "validate: layer tap to gamepad button works (regression)" {
try validate(&result.value);
}

test "mapping: layer hold parses into LayerConfig.hold" {
const allocator = std.testing.allocator;
const toml_str =
\\[[layer]]
\\name = "sense"
\\trigger = "LB"
\\activation = "hold"
\\hold = "RB"
;
const result = try parseString(allocator, toml_str);
defer result.deinit();
try validate(&result.value);
try std.testing.expectEqualStrings("RB", result.value.layer.?[0].hold.?);
}

test "mapping: layer tap and hold both set round-trip" {
const allocator = std.testing.allocator;
const toml_str =
\\[[layer]]
\\name = "sense"
\\trigger = "LB"
\\activation = "hold"
\\tap = "KEY_F13"
\\hold = "KEY_LEFTSHIFT"
;
const result = try parseString(allocator, toml_str);
defer result.deinit();
try validate(&result.value);
try std.testing.expectEqualStrings("KEY_F13", result.value.layer.?[0].tap.?);
try std.testing.expectEqualStrings("KEY_LEFTSHIFT", result.value.layer.?[0].hold.?);
}

test "mapping: validate: layer hold macro: prefix rejected" {
const allocator = std.testing.allocator;
const toml_str =
\\[[layer]]
\\name = "sense"
\\trigger = "LB"
\\activation = "hold"
\\hold = "macro:x"
\\
\\[[macro]]
\\name = "x"
\\steps = [{ tap = "A" }]
;
const result = try parseString(allocator, toml_str);
defer result.deinit();
try std.testing.expectError(error.LayerHoldCannotBeMacro, validate(&result.value));
}

// --- chord remap ---

test "chord remap: 2-key array parses into chord_names" {
Expand Down
Loading
Loading