From 54fc12db76fbb6ed06575ff57839e73c7dbbf817 Mon Sep 17 00:00:00 2001 From: Neil Chudleigh Date: Fri, 9 Oct 2026 10:04:59 -0400 Subject: [PATCH] Stop push-to-talk even when other keys are pressed during the hold (#14645) Hyprland skips a release bind once any other key or mouse button is released while its key is held (KeybindManager::shadowKeybinds), and when the modifiers changed since the press. That is what makes a bind like "bindr = SUPER, SUPER_L" tap-only, but it means typing or clicking while holding F9 or Right Alt never runs omarchy-dictation stop, so the recording keeps going. o.bind_hold binds a press and a release action together. The release half is transparent (it can't be shadowed), ignores modifiers, and is non-consuming, so it doesn't take the key from apps when pressed with other modifiers (Shift+F9 still reaches the app). It runs only after its own press ran, so releasing the key without the rest of a chord does nothing. Dictation's push-to-talk keys use it. Co-authored-by: superduck00 <294879536+superduck00@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- default/hypr/bindings/dictation.lua | 6 +-- default/hypr/helpers.lua | 57 +++++++++++++++++++++++ test/shell.d/bind-hold-test.sh | 71 +++++++++++++++++++++++++++++ test/shell.d/dictation-test.sh | 13 ++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 test/shell.d/bind-hold-test.sh diff --git a/default/hypr/bindings/dictation.lua b/default/hypr/bindings/dictation.lua index b486d794..aa3e2c69 100644 --- a/default/hypr/bindings/dictation.lua +++ b/default/hypr/bindings/dictation.lua @@ -1,9 +1,7 @@ if o.shell_succeeds("omarchy-default-dictation") then o.bind("SUPER + CTRL + X", "Toggle dictation", "omarchy-dictation toggle") - o.bind("F9", "Start dictation (push-to-talk)", "omarchy-dictation start") - o.bind("F9", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true }) + o.bind_hold("F9", "Start dictation (push-to-talk)", "omarchy-dictation start", "Stop dictation (push-to-talk)", "omarchy-dictation stop") -- A modifier's mask changes between its press and release. Match the keysym -- independently of that mask; AltGr layouts use a different keysym. - o.bind("ALT + Alt_R", "Start dictation (push-to-talk)", "omarchy-dictation start", { ignore_mods = true }) - o.bind("ALT + Alt_R", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true, ignore_mods = true }) + o.bind_hold("ALT + Alt_R", "Start dictation (push-to-talk)", "omarchy-dictation start", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { ignore_mods = true }) end diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index 64e13c75..bd5e109f 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -206,6 +206,63 @@ function o.bind_toggle(keys, description, toggle, options) o.bind(keys, description, "omarchy-toggle-" .. toggle, options) end +-- Bind one action to a key's press and another to its release, as for +-- push-to-talk. Hyprland skips a plain release bind once another key or mouse +-- button is released during the hold, or once the modifiers have changed, so +-- typing while holding the key would never run the release. The release half +-- here is transparent and ignores modifiers, and runs only after its own press +-- did, so letting go of the key without the rest of the chord does nothing. It +-- is also non-consuming: a release bind that ignores modifiers would otherwise +-- take the key from apps whenever it is pressed with other modifiers, such as +-- Shift+F9, or without them, such as a plain x for SUPER + X. +function o.bind_hold(keys, press_description, press, release_description, release, options) + local held = false + press, release = command_from(press, press_description), command_from(release, release_description) + + local function run(dispatcher) + if type(dispatcher) == "string" then + hl.exec_cmd(dispatcher) + elseif type(dispatcher) == "function" then + dispatcher() + else + hl.dispatch(dispatcher) + end + end + + local function on_press() + held = true + run(press) + end + + local function on_release() + if held then + held = false + run(release) + end + end + + if type(press) == "string" then + o.bind_commands[on_press] = press + end + if type(release) == "string" then + o.bind_commands[on_release] = release + end + + local press_options, release_options = {}, {} + for key, value in pairs(options or {}) do + press_options[key], release_options[key] = value, value + end + -- Options that change when the press half fires would break the release half. + for _, key in ipairs({ "repeating", "long_press", "click", "drag" }) do + release_options[key] = nil + end + release_options.release, release_options.transparent = true, true + release_options.ignore_mods, release_options.non_consuming = true, true + + o.bind(keys, press_description, on_press, press_options) + o.bind(keys, release_description, on_release, release_options) +end + function o.notify(message) return "omarchy-notification-send -u low " .. shell_quote(message) end diff --git a/test/shell.d/bind-hold-test.sh b/test/shell.d/bind-hold-test.sh new file mode 100644 index 00000000..a28f90bc --- /dev/null +++ b/test/shell.d/bind-hold-test.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +HOME="$(mktemp -d)" OMARCHY_PATH="$ROOT" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path + +local binds, ran = {}, {} +hl = setmetatable({ + dsp = setmetatable({}, { + __index = function(_, name) + return function(arg) return { dsp = name, arg = arg } end + end, + }), + bind = function(keys, dispatcher, opts) + table.insert(binds, { keys = keys, dispatcher = dispatcher, opts = opts }) + end, + exec_cmd = function(command) table.insert(ran, command) end, + dispatch = function(dispatcher) table.insert(ran, dispatcher.dsp .. " " .. dispatcher.arg) end, +}, { + __index = function() + return function() return {} end + end, +}) + +require("default.hypr.helpers") + +local function ran_since(fn) + local before = #ran + fn() + return table.concat({ table.unpack(ran, before + 1) }, "\n") +end + +local options = { locked = true } +o.bind_hold("SUPER + X", "Start", "start-cmd", "Stop", "stop-cmd", options) +local press, release = binds[1], binds[2] +assert(press.keys == "SUPER + X" and release.keys == "SUPER + X", "both halves bind the same keys") +assert(press.opts.description == "Start" and release.opts.description == "Stop", "each half keeps its description") +assert(not press.opts.release and not press.opts.transparent, "the press half is an ordinary bind") +assert(release.opts.release and release.opts.transparent and release.opts.ignore_mods, + "the release half survives keys typed and modifiers changed during the hold") +assert(release.opts.non_consuming, "the release half leaves the key to apps when its press did not run") +assert(press.opts.locked and release.opts.locked, "caller options apply to both halves") +assert(options.release == nil and options.description == nil, "caller options are not modified") +assert(o.bind_commands[press.dispatcher] == "start-cmd" and o.bind_commands[release.dispatcher] == "stop-cmd", + "the keybindings menu can run each half") + +assert(ran_since(release.dispatcher) == "", "a release without its press does nothing") +assert(ran_since(press.dispatcher) == "start-cmd", "the press runs its command") +assert(ran_since(release.dispatcher) == "stop-cmd", "the release ends what the press started") +assert(ran_since(release.dispatcher) == "", "a release runs once per press") + +o.bind_hold("SUPER + R", "Start", "start-cmd", "Stop", "stop-cmd", + { repeating = true, long_press = true, click = true, drag = true, locked = true }) +local repeat_release, repeat_press = table.remove(binds), table.remove(binds) +assert(repeat_press.opts.repeating, "the press half keeps its options") +for _, key in ipairs({ "repeating", "long_press", "click", "drag" }) do + assert(repeat_release.opts[key] == nil, "the release half drops " .. key) +end +assert(repeat_release.opts.locked, "the release half keeps other options") + +o.bind_hold("SUPER + Z", "Start", hl.dsp.exec_cmd("start-dsp"), "Stop", function() table.insert(ran, "stop-fn") end) +assert(ran_since(binds[3].dispatcher) == "exec_cmd start-dsp", "dispatchers are dispatched") +assert(ran_since(binds[4].dispatcher) == "stop-fn", "functions are called") +assert(ran_since(binds[2].dispatcher) == "", "each hold binding tracks its own press") +LUA +pass "hold bindings pair each release with its press" diff --git a/test/shell.d/dictation-test.sh b/test/shell.d/dictation-test.sh index 57c67638..f8793310 100644 --- a/test/shell.d/dictation-test.sh +++ b/test/shell.d/dictation-test.sh @@ -219,12 +219,25 @@ o = { return configured end, bind = function(...) table.insert(bindings, {...}) end, + bind_hold = function(keys, _, press, _, release, options) + table.insert(bindings, { keys, press, options }) + table.insert(bindings, { keys, release, options }) + end, } dofile(root .. "/default/hypr/bindings/dictation.lua") assert(#bindings == 0, "unconfigured dictation leaves application keys available") configured = true dofile(root .. "/default/hypr/bindings/dictation.lua") assert(#bindings == 5, "any configured backend receives the same bindings") +local function binds(keys, command) + for _, binding in ipairs(bindings) do + if binding[1] == keys and binding[2] == command then return true end + end +end +for _, keys in ipairs({ "F9", "ALT + Alt_R" }) do + assert(binds(keys, "omarchy-dictation start") and binds(keys, "omarchy-dictation stop"), + keys .. " starts dictation on press and stops it on release") +end LUA pass "dictation shortcuts require a selection without limiting backend names"