Skip to content

Bug: one-shot-release does not terminate when the activator's keycode is re-pressed for a different action #2049

Description

@andre-guerreiro

Requirements

Describe the bug

When a one-shot-release modifier is activated by pressing key K on layer L1 (where K is mapped to the one-shot action), then the active layer changes to L2 (where K is mapped to a regular key), pressing and releasing K on L2 does not terminate the one-shot. The modifier remains active across all subsequent keypresses until either (a) a key whose keycode differs from the activator's is released, or (b) the timeout fires.

The bug affects both EndOnFirstRelease (one-shot-release) and EndOnFirstReleaseOrRepress (one-shot-release-pcancel). EndOnFirstPress (one-shot) and EndOnFirstPressOrRepress (one-shot-press-pcancel) are not affected.

Root cause

OneShotState::handle_release in keyberon/src/layout.rs (~line 1100):

fn handle_release(&mut self, (i, j): KCoord) -> (bool, Option<KCoord>) {
    if self.keys.is_empty() {
        return (true, None);
    }
    if !self.keys.contains(&(i, j)) {
        if matches!(
            self.end_config,
            OneShotEndConfig::EndOnFirstRelease | OneShotEndConfig::EndOnFirstReleaseOrRepress
        ) && self.other_pressed_keys.contains(&(i, j))
        {
            self.release_on_next_tick = true;
        }
        (true, None)
    } else {
        // delay release for one shot keys
        (false, self.released_keys.push_back((i, j)))
    }
}

When the user re-presses the activator's physical key on a different layer where it produces a normal key event:

  • handle_press(Other(coord_D)) correctly pushes coord_D into other_pressed_keys.
  • But keys (the activator-tracking list) still contains coord_D from the original activation; it's only cleared by tick_osh on timeout/explicit termination.

So at release time both lists contain coord_D. The current ordering checks keys.contains(&(i, j)) first, which is true, so execution falls into the else branch ("delay release for one shot keys") at line 1115 and the other_pressed_keys check at line 1108 is never reached. Termination doesn't fire.

For EndOnFirstPress the equivalent termination check is in handle_press(Other(...)) (around line 1081), which doesn't consult keys membership and triggers correctly even when the second press's coord matches the activator's. That's why EndOnFirstPress is not affected.

Suggested fix

In EndOnFirstRelease mode, prefer the other_pressed_keys check over the keys membership check. A release that satisfies "non-oneshot key was pressed after activation" should win over "this coord is also tracked as the activator":

fn handle_release(&mut self, (i, j): KCoord) -> (bool, Option<KCoord>) {
    if self.keys.is_empty() {
        return (true, None);
    }
    // A coord may legitimately appear in both `keys` (as the original activator)
    // and `other_pressed_keys` (if the same physical key was re-pressed for a
    // non-oneshot action after the activator's lifecycle began, on a different
    // layer). In that case, the release should terminate the one-shot, not be
    // filtered as "activator release."
    if matches!(
        self.end_config,
        OneShotEndConfig::EndOnFirstRelease | OneShotEndConfig::EndOnFirstReleaseOrRepress
    ) && self.other_pressed_keys.contains(&(i, j))
    {
        self.release_on_next_tick = true;
        return (true, None);
    }
    if !self.keys.contains(&(i, j)) {
        (true, None)
    } else {
        (false, self.released_keys.push_back((i, j)))
    }
}

Relevant kanata config

(defcfg
  process-unmapped-keys yes
)

(defsrc
  tab d
)

(deflayer base
  (layer-while-held nav)  d
)

(deflayer nav
  _  (one-shot-release 2000 lalt)
)

To Reproduce

Trace 1: Single re-press

Sequence: hold tab, press d, release d, release tab, press d, release d.

TAB Press
D Press     →  key press LAlt          (one-shot active)
D Release   →  (no LAlt release; activator)
TAB Release →  (no emission)
D Press     →  key press D             (D emitted with LAlt held)
D Release   →  key release D
            →  *** no LAlt release ***
~2s elapse →  key release LAlt        (timeout fired)

lalt is released only by the 2000ms timeout, not by d's release.

Trace 2: Modifier leak across non-activator keys

Sequence: hold tab, press d, release d, release tab, press d, release d, press p, release p.

TAB Press
D Press     →  key press LAlt
D Release
TAB Release
D Press     →  key press D            (with LAlt held)
D Release   →  key release D          (no LAlt release — BUG)
P Press     →  key press P            (with LAlt held — UNINTENDED)
P Release   →  key release P
            →  key release LAlt        (terminates here)

The unrelated p keypress receives the modifier even though the user clearly intended only alt+d. The modifier persists across every keypress between the bogus d release and the next non-activator-keycode release.

Trace 3: Same scenario with one-shot (EndOnFirstPress) instead

Same minimal config, but with (one-shot 2000 lalt) substituted for (one-shot-release 2000 lalt).

Same sequence as Trace 2: hold tab, d, release, release tab, d, release, p, release.

Result: only the first key after activation receives the modifier. p is plain.

EndOnFirstPress terminates the one-shot in handle_press (when the second d is pressed), not in handle_release, and does not consult keys membership for that termination, so the bug doesn't manifest. The bug is exclusive to release-path termination, which pins it to handle_release rather than to one-shot tracking generally.

Expected behavior

With one-shot-release activated, the modifier should remain active until the next non-activator key is released, then deactivate. Pressing and releasing the activator's physical keycode after the activating layer is gone (i.e., as a normal key event on a different layer) should terminate the one-shot just like any other release would.

Kanata version

1.10.1 and 1.12.0-prerelease-2

Debug logs


Operating system and I/O mechanism

macOS 26.4.1 (Apple Silicon, ARM64)

Additional context

Discussion #1218: Same root mechanism, narrower symptom (a one-shot accent layer where the activator was re-pressed on the secondary layer). Resolved with a (unicode <char>) workaround. The underlying bug was not addressed.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions