Skip to content

feat(layer): add hold passthrough output emitted while a layer is active (#332)#358

Merged
BANANASJIM merged 4 commits into
mainfrom
feat/332-layer-hold
Jun 2, 2026
Merged

feat(layer): add hold passthrough output emitted while a layer is active (#332)#358
BANANASJIM merged 4 commits into
mainfrom
feat/332-layer-hold

Conversation

@BANANASJIM

@BANANASJIM BANANASJIM commented Jun 2, 2026

Copy link
Copy Markdown
Owner

What

Adds an optional hold field to [[layer]]: while the layer is active, padctl continuously emits a chosen output in addition to the layer's other effects (remap/gyro/stick). This lets a button that triggers a layer also keep emitting an output — e.g. Witcher-3 "Sense": LB activates a layer and still sends LB.

[[layer]]
name = "sense"
trigger = "LB"
activation = "hold"
hold = "LB"          # emitted continuously while the layer is active
[layer.remap]
A = "KEY_Q"

Semantics are uniform "held while the layer is active" across all activation modes (hold / hold_toggle / toggle) — it hangs off the single active-state chokepoint, with no per-mode special-casing.

Changes

  • src/config/mapping.zig — new LayerConfig.hold: ?[]const u8 (auto-accepted by the structFieldNames schema linter); validate() rejects a macro: target (error.LayerHoldCannotBeMacro).
  • src/core/mapper.zig — pre-resolve each layer's hold target (resolved_layer_holds, parallel to resolved_layers). Gamepad target: re-assert the bit into injected_buttons every frame while active, and into currentMappedGamepadFrame for the sticky/timer frame. Key/mouse target: explicit .press/.release edges via the single updateLayerHold chokepoint. Held output is released on every deactivation path: trigger release, layer switch, mapping/profile switch (quiesceOutputsreleaseMapperAux), and resetRuntimeState.
  • docs/src/mapping-guide.md, docs/src/mapping-config.md — document hold next to tap, with the "fires only after activation, not on a short tap" note.

Notes

  • hold and tap are orthogonal: tap = short-press output, hold = held-while-active output.
  • Fixes a held-output leak found during review: switching from a hold-carrying layer to a layer without a hold target now correctly releases the prior held output instead of leaking it.

Tests (Layer-0, no device I/O)

  • parse/round-trip; macro: target rejected
  • gamepad hold present every frame while active, gone on release
  • key/mouse hold: exactly one press on activation, no dup mid-hold, one release on deactivation
  • short tap emits no hold output (regression guard)
  • hold_toggle sticky on/off; toggle latched on/off
  • release on mapping switch + resetRuntimeState + quiesceOutputs aux path (DeviceInstance integration)
  • hold == trigger nets a single clean press (suppression ordering, both emit sites)
  • two-layer switch leak regression
  • mutation guards for the per-frame re-assert and the timer-frame ordering

Each test was confirmed falsifiable by mutation testing (reverting each load-bearing line makes a specific test fail).

Test plan

  • ./scripts/padctl-docker test: exit 0
  • zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl -Dlibusb=false: exit 0
  • zig fmt --check: clean

Refs #332

Summary by CodeRabbit

  • New Features

    • Added layer-level "hold" so a layer can continuously emit a chosen target while active across all activation modes; releases when the layer deactivates.
  • Documentation

    • Updated mapping guide and reference with examples, validation rules for "hold" (single key/mouse/gamepad target; "macro:" targets disallowed), and usage notes.
  • Bug Fixes

    • Ensures held outputs are reliably released on layer switches, mapping/profile changes, and during quiesce/reset operations.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@BANANASJIM, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 18 minutes and 48 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b4c1b10b-ff02-4bd8-802a-d4d1ab19eaee

📥 Commits

Reviewing files that changed from the base of the PR and between 2cc26a3 and 433ab02.

📒 Files selected for processing (5)
  • docs/src/mapping-config.md
  • docs/src/mapping-guide.md
  • src/config/mapping.zig
  • src/core/mapper.zig
  • src/device_instance.zig
📝 Walkthrough

Walkthrough

This PR adds a per-layer hold field that continuously emits a configured target while the layer is active, touching configuration schema/validation, documentation, mapper runtime state and lifecycle behavior, and integration tests.

Changes

Layer hold passthrough feature

Layer / File(s) Summary
Config schema and validation
src/config/mapping.zig
LayerConfig gains an optional hold: ?[]const u8 field; validate() rejects macro:-prefixed hold targets returning error.LayerHoldCannotBeMacro; tests cover parsing, round-trip of tap+hold, and validation failure.
Documentation and examples
docs/src/mapping-guide.md, docs/src/mapping-config.md
Mapping guide and config reference document hold semantics: continuous passthrough while active across activation modes, single ButtonId/KEY_/mouse_/BTN_* target required, macro: disallowed, and no hold emitted when the press resolves to tap; TOML examples show hold on hold-activated and toggle layers.
Mapper state and initialization
src/core/mapper.zig
Mapper gains layer_held_gamepad: u64, layer_hold_aux_down: ?AuxDownTarget, and resolved_layer_holds: []?RemapTargetResolved; init allocates and pre-resolves per-layer holds; deinit frees the slice; resolveLayerHold validates/resolves hold targets (errors on macro:).
Mapper lifecycle and hold emission
src/core/mapper.zig
resetRuntimeState clears hold runtime state; releaseHeldAux releases held aux and clears gamepad hold bits; apply ORs layer_held_gamepad into injected buttons each frame; onLayerTimerExpiredAt and handleLayerActiveChanged call updateLayerHold; updateLayerHold centralizes releasing prior aux down, setting gamepad bits or emitting key/mouse press edges, and tracking aux-down for later release.
Integration and regression tests
src/core/mapper.zig, src/device_instance.zig
Expanded mapper tests validate frame-by-frame gamepad-bit persistence, correct key/mouse press/release edge counts with no duplicates, absence of hold on short taps, correct hold/toggle/latch semantics, release-on-mapping-switch via releaseHeldAux, reset clearing, timer-expiry ordering regressions; device instance tests add pipe-backed aux helpers and two quiesceOutputs tests to assert held-key releases and state cleanup; OOM regression test updated for extra allocations.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • BANANASJIM/padctl#231: Related changes around layer timer and active state transitions that affect when hold/active_changed behaviors fire.
  • BANANASJIM/padctl#317: Related quiesce/reset behavior and mapper state clearing that interact with layer-hold aux release semantics.
  • BANANASJIM/padctl#342: Overlapping edits to handleLayerActiveChanged() and related layer deactivation/drain logic.

Poem

🐰 I held a key beneath my paw,
A layer whispered, "Hold it, raw."
Each frame I hum the same small tune,
Release on quiesce, beneath the moon.
Hop, tap, and tidy — carrot-croon.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature addition: a hold passthrough output field for layers that emits continuously while a layer is active.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/332-layer-hold

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Add layer hold passthrough output emitted while layer is active

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add optional hold field to layer config for passthrough output while layer is active
• Implement layer hold state management with gamepad bit re-assertion and key/mouse press/release
  edges
• Release held output on all deactivation paths (trigger release, layer switch, mapping switch,
  reset)
• Add comprehensive test coverage including regression guards for held-output leaks and suppression
  ordering
• Document hold field semantics in mapping guide and config reference
Diagram
flowchart LR
  Config["Layer Config<br/>hold field"] -->|parse & validate| Mapper["Mapper State<br/>layer_held_gamepad<br/>layer_hold_aux_down"]
  Mapper -->|apply frame| ReAssert["Re-assert gamepad bit<br/>or skip key/mouse"]
  Mapper -->|layer activates| UpdateHold["updateLayerHold<br/>press key/mouse"]
  Mapper -->|layer deactivates| Release["Release held output<br/>releaseHeldAux"]
  Release -->|all exit paths| Clean["Clear state<br/>layer_held_gamepad=0"]

Loading

Grey Divider

File Changes

1. src/config/mapping.zig ✨ Enhancement +57/-0

Add layer hold field to config with validation

• Add hold: ?[]const u8 field to LayerConfig struct
• Add validation to reject macro: prefix in hold target with error.LayerHoldCannotBeMacro
• Add three parsing/validation tests for hold field: basic parse, tap + hold orthogonality,
 and macro rejection

src/config/mapping.zig


2. src/core/mapper.zig ✨ Enhancement +506/-3

Implement layer hold state management and re-assertion

• Add layer_held_gamepad and layer_hold_aux_down state fields to track active layer's hold
 output
• Add resolved_layer_holds array to pre-resolve each layer's hold target during init
• Implement updateLayerHold() chokepoint to release prior hold and activate new layer's hold on
 every deactivation path
• Re-assert gamepad hold bits every frame in apply() and currentMappedGamepadFrame()
• Release held output in releaseHeldAux() and resetRuntimeState()
• Add 13 comprehensive tests covering gamepad/key hold activation, deactivation, short-tap
 regression, toggle/hold_toggle modes, layer switching, and suppression ordering
• Add mutation guards to verify load-bearing code paths

src/core/mapper.zig


3. src/device_instance.zig 🧪 Tests +120/-0

Add DeviceInstance integration tests for layer hold

• Add helper functions pipeHasShiftRelease() and primeLayerHoldActive() for testing layer hold
 integration
• Add two integration tests verifying layer hold KEY release through quiesceOutputs() aux path and
 reset path
• Tests confirm held output is properly released when mapping/profile switches or reset occurs

src/device_instance.zig


View more (2)
4. docs/src/mapping-config.md 📝 Documentation +2/-0

Document layer hold field in config reference

• Add hold field to layer config table with description of passthrough output semantics
• Document that hold fires only after layer activation, never on short tap
• Note that hold cannot be macro: and is released on all exit paths
• Add example showing hold = "RB" in layer config

docs/src/mapping-config.md


5. docs/src/mapping-guide.md 📝 Documentation +27/-0

Document layer hold semantics with examples

• Add section explaining hold field as optional passthrough output emitted while layer is active
• Document that hold applies uniformly to all three activation modes and fires only after
 activation
• Provide two practical examples: Witcher-3 "Sense" (hold LB to layer and emit LB) and toggle layer
 with held modifier key
• Clarify orthogonality with tap field and automatic release on all exit paths

docs/src/mapping-guide.md


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Jun 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Qodo Logo

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/config/mapping.zig (1)

301-306: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire layer.hold into aux capability derivation.

Adding hold here without updating deriveAuxFromMapping() means configs like hold = "KEY_LEFTSHIFT" or hold = "mouse_left" can parse and validate, but still fail to provision keyboard/mouse aux support because the layer scan only looks at remap/gyro/stick/dpad. That breaks the new feature for hold-only aux outputs.

Possible fix
if (cfg.layer) |layers| {
    for (layers) |*layer| {
        if (layer.tap) |target| scanTarget(&caps, target);
        if (layer.hold) |target| scanTarget(&caps, target);

        if (layer.gyro) |g| {
            if (std.mem.eql(u8, g.mode, "mouse")) caps.needs_rel = true;
        }
        scanStick(&caps, layer.stick_left);
        scanStick(&caps, layer.stick_right);
        if (layer.dpad) |d| {
            if (std.mem.eql(u8, d.mode, "arrows")) caps.needs_keyboard = true;
        }
        if (layer.remap) |*remap| scanRemapTargets(&caps, cfg, remap);
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config/mapping.zig` around lines 301 - 306, deriveAuxFromMapping()
currently ignores LayerConfig.hold so configs with hold-only aux targets (e.g.,
hold = "KEY_LEFTSHIFT" or "mouse_left") won't mark keyboard/mouse capabilities;
update deriveAuxFromMapping() to treat layer.hold the same as layer.tap by
calling scanTarget(&caps, target) when layer.hold is present, and ensure any
mode checks (e.g., for mouse gyro -> caps.needs_rel or dpad arrows ->
caps.needs_keyboard) and existing calls to scanStick() and scanRemapTargets()
remain unchanged so hold-targets are included in the aux capability derivation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/mapper.zig`:
- Around line 785-803: The updateLayerHold currently unconditionally calls
emitAuxDownRelease(self.layer_hold_aux_down, aux) and clears layer_hold_aux_down
before resolving the next active layer, causing a release+press when the
resolved target stays the same; change the logic in updateLayerHold to first
compute the next target (use self.layer.getActiveIndex(configs) and
self.resolved_layer_holds[idx] and auxDownTarget/enum value) and compare it to
the existing self.layer_hold_aux_down (and gamepad bit in
self.layer_held_gamepad) — only call emitAuxDownRelease and clear the old state
if the new target differs, otherwise skip release/press and keep the existing
hold; ensure you still update layer_held_gamepad when switching to/from a
gamepad target and still call remap_mod.applyTarget(..., .press, ...) only when
transitioning to a different aux target.

---

Outside diff comments:
In `@src/config/mapping.zig`:
- Around line 301-306: deriveAuxFromMapping() currently ignores LayerConfig.hold
so configs with hold-only aux targets (e.g., hold = "KEY_LEFTSHIFT" or
"mouse_left") won't mark keyboard/mouse capabilities; update
deriveAuxFromMapping() to treat layer.hold the same as layer.tap by calling
scanTarget(&caps, target) when layer.hold is present, and ensure any mode checks
(e.g., for mouse gyro -> caps.needs_rel or dpad arrows -> caps.needs_keyboard)
and existing calls to scanStick() and scanRemapTargets() remain unchanged so
hold-targets are included in the aux capability derivation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8e488ae6-6725-4c94-98c9-51a34509d041

📥 Commits

Reviewing files that changed from the base of the PR and between acdbcee and 6113956.

📒 Files selected for processing (5)
  • docs/src/mapping-config.md
  • docs/src/mapping-guide.md
  • src/config/mapping.zig
  • src/core/mapper.zig
  • src/device_instance.zig

Comment thread src/core/mapper.zig
@BANANASJIM BANANASJIM force-pushed the feat/332-layer-hold branch from 2cc26a3 to 433ab02 Compare June 2, 2026 09:37
@BANANASJIM BANANASJIM merged commit 0766483 into main Jun 2, 2026
36 checks passed
@BANANASJIM BANANASJIM deleted the feat/332-layer-hold branch June 2, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant