Skip to content

Support double-press actions in Hyprland bindings - #13576

Open
l1qu1d wants to merge 1 commit into
omacom:quattrofrom
l1qu1d:delayed-keyboard-shortcuts
Open

l1qu1d wants to merge 1 commit into
omacom:quattrofrom
l1qu1d:delayed-keyboard-shortcuts

Conversation

@l1qu1d

@l1qu1d l1qu1d commented Sep 28, 2026 •

Copy link
Copy Markdown

Adds optional double-press actions to o.bind and o.rebind, allowing two actions on one shortcut without custom timer logic.

o.rebind("SUPER + K", "My shortcut", "single-command", {
  double_press = "double-command",
  timeout = 250,
})

The first press waits 250 ms by default. A second press within that window cancels the pending action and schedules double_press through a 1 ms one-shot timer. Dispatching outside the key event prevents global shortcuts from re-entering the binding on key release. Both actions accept the same commands, helpers, Lua functions, and dispatchers as ordinary bindings.

Pending actions check that their binding is still enabled before running. The keybindings menu resolves timed shortcuts to their single action; arbitrary Lua callbacks require command metadata, as with ordinary bindings. Existing default shortcuts are unchanged.

Menu metadata uses weak callback keys so it does not keep removed callbacks alive.

Testing

Added coverage for timing boundaries, cancellation, triple presses, binding lifecycle, supported action types, callback collection, release-event re-entry, cancellation of deferred double actions, and resolving timed shortcuts in the keybindings menu. The focused double-press, keybindings-menu, default-config, and binding-conflict suites pass, along with Lua/Bash syntax and whitespace checks. Verified that an intentional Lua assertion failure causes the new test script to fail, and that the script passes when invoked from outside the repository root.

The revised helper passed ten automated cases in a running Hyprland 0.56.2 session using real timers, native binding handles, and command execution. A 1,000-cycle handle removal/replacement test retained only the live metadata entry and released it after removal. Physical keyboard testing confirmed single-press toggle and double-press clear before the metadata cleanup; the revised helper was retested through registered callbacks in the live compositor.

A physical keyboard comparison reproduced the release-event bug with the previous revision: a double press dispatched the double action and then the single action. With the revised helper, the same press/release sequence dispatched only the double action. The automated review's supplied reproducer also fails on the previous revision and passes on this one.

Known limitations

Hyprland 0.56.2 retains Lua callback registry references when using hl.unbind; explicit handle:remove() releases them. This was reproduced using the native API without the Omarchy helper. Weak metadata keys avoid additional retention but do not fix the native behavior, including during repeated o.rebind calls.

The keybindings-menu test uses a minimal configuration because the existing scanner failure tracked by #13276 prevents the full default configuration from reaching personal bindings. That fix is independent of the double-press implementation.

@l1qu1d
l1qu1d force-pushed the delayed-keyboard-shortcuts branch from f47f33f to 1137aba Compare September 28, 2026 18:29
@llstrk

llstrk commented Sep 28, 2026

Copy link
Copy Markdown

Automated AI review

Community review: Independent automated community review, unaffiliated with the Omarchy team, intended to help prepare PRs for their review.

Outcome: When the double action is a shell helper that dispatches a registered shell shortcut ({ ipc = … }, { menu = … } and similar), a double press also runs the single action afterwards.

Double action re-arms the single press on key release

run(double_press) runs inside the key-press callback. In Hyprland 0.56.2, hl.dsp.global (also pass, send_shortcut, mouse drag/resize) marks the running bind releasePending, so the wrapper is called again on key release while the modifier is held; pending is already cleared, so it starts a new timer. Impact: with { ipc = "notifications.showHistory" } and double_press = { ipc = "notifications.dismissAll" }, a double press runs dismissAll, then showHistory.

Suggested change: run the double action from a 1 ms one-shot hl.timer, outside the key event like the single action.

Reproducer (fails at 1137aba, model of Hyprland's release rule)

Save as double-press-release-repro.lua in the repository root and run OMARCHY_PATH="$PWD" lua double-press-release-repro.lua (Lua 5.5.1 Copyright (C) 1994-2026 Lua.org, PUC-Rio). It uses the same kind of fake hl as test/shell.d/hyprland-double-press-test.sh, plus the Hyprland 0.56.2 rule that test does not model: a bind that dispatched hl.dsp.global during a key press is called again on key release. This is a model built from Hyprland's source, not a run in a live compositor.

-- Run from the repository root:  OMARCHY_PATH="$PWD" lua double-press-release-repro.lua
--
-- Same fake-Hyprland style as test/shell.d/hyprland-double-press-test.sh, plus
-- the one piece of Hyprland 0.56.2 key handling that test does not model:
--
--   * hl.dsp.global (also pass, send_shortcut, and the mouse forms of window
--     drag/resize) marks the bind being handled as releasePending when it is
--     dispatched during a key press (src/config/lua/bindings/
--     LuaBindingsDispatchers.cpp, dsp_global).
--   * On key release, a bind with releasePending counts as a special
--     dispatcher, so its Lua callback is called again instead of the release
--     being swallowed (src/managers/KeybindManager.cpp, handleKeybinds:
--     SPECIALDISPATCHER includes k->releasePending; the !pressed branch only
--     swallows "!k->release && !SPECIALDISPATCHER").
--   * Outside a key event (for example in an hl.timer callback) there is no
--     current keybind, and global sends a "pressed" event.
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path

local events, timers, clock = {}, {}, 0
local current_keybind = nil

local function expect_events(wanted, message)
  local got, expected = table.concat(events, "|"), table.concat(wanted, "|")
  if got ~= expected then
    print("not ok - " .. message)
    print("  expected: " .. expected)
    print("  got:      " .. got)
    return false
  end
  print("ok - " .. message)
  return true
end

local function advance(ms)
  local target = clock + ms
  while true do
    local index, due
    for i, timer in ipairs(timers) do
      if timer.due <= target and (not due or timer.due < due.due) then
        index, due = i, timer
      end
    end
    if not due then break end
    table.remove(timers, index)
    clock = due.due
    due.callback()
  end
  clock = target
end

hl = {
  dsp = {
    exec_cmd = function(command)
      return { run = function() table.insert(events, "exec:" .. command) end }
    end,
    global = function(name)
      return {
        run = function()
          if current_keybind then
            current_keybind.release_pending = true
          end
          table.insert(events, "global:" .. name)
        end,
      }
    end,
  },
  bind = function(keys, callback, options)
    local handle = { keys = keys, callback = callback, options = options or {}, release_pending = false }
    return setmetatable(handle, {
      __index = { is_enabled = function() return true end },
      __tostring = function() return "HL.Keybind(" .. keys .. ")" end,
    })
  end,
  unbind = function() end,
  timer = function(callback, options)
    table.insert(timers, { callback = callback, due = clock + options.timeout })
  end,
  dispatch = function(action)
    action.run()
  end,
}

local function invoke(handle)
  current_keybind = handle
  handle.release_pending = false
  if type(handle.callback) == "function" then
    handle.callback()
  else
    hl.dispatch(handle.callback)
  end
  current_keybind = nil
end

-- Key press, then key release with the modifier still held.
local function tap(handle)
  invoke(handle)
  if handle.release_pending then
    invoke(handle)
  end
end

dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/helpers.lua")

local ok = true

-- Control: plain commands.
local commands = o.bind("SUPER + grave", "History", "omarchy-shell notifications showHistory", {
  double_press = "omarchy-shell notifications dismissAll",
})
events, clock = {}, 0
tap(commands)
tap(commands)
advance(250)
ok = expect_events({ "exec:omarchy-shell notifications dismissAll" },
  "a double press with command actions runs only the double action") and ok

-- The same shortcut written with Omarchy's shell helpers, which dispatch the
-- shell's registered global shortcuts (default/omarchy/shortcuts).
local helpers = o.bind("SUPER + grave", "History", { ipc = "notifications.showHistory" }, {
  double_press = { ipc = "notifications.dismissAll" },
})
events, clock = {}, 0
tap(helpers)
tap(helpers)
advance(250)
ok = expect_events({ "global:omarchy:ipc.notifications.dismissAll" },
  "a double press with shell-shortcut actions runs only the double action") and ok

os.exit(ok and 0 or 1)

Output at 1137aba:

ok - a double press with command actions runs only the double action
not ok - a double press with shell-shortcut actions runs only the double action
  expected: global:omarchy:ipc.notifications.dismissAll
  got:      global:omarchy:ipc.notifications.dismissAll|global:omarchy:ipc.notifications.showHistory

With the double action moved into a 1 ms one-shot hl.timer (the suggested change), the same script prints:

ok - a double press with command actions runs only the double action
ok - a double press with shell-shortcut actions runs only the double action
Details
  • Source: LuaBindingsDispatchers.cpp dsp_global sets releasePending; KeybindManager.cpp counts it as a special dispatcher, so the !pressed branch does not swallow the release and dispatches the bind again. Not run in a live compositor.
  • A release re-dispatch case in the test would cover this. With the suggested timer, the PR's own test needs an advance(1) after each second press.

Verified:

  • Timing boundaries, triple presses, independent bindings and the expired-handle guard (is_enabled on an expired 0.56.2 handle dereferences null, so the tostring check is needed).
  • Existing binds scan identically in the keybindings menu at base and head.
  • The new double-press test fails on the base and passes on the head; 11 of 11 targeted mutations were caught.
  • The manual's string-command example is unaffected by the finding above.

Optional notes:

More findings: new test depends on the working directory

hyprland-double-press-test.sh loads helpers.lua without adding OMARCHY_PATH to package.path, so require("default.hypr.paths") only resolves from the repository root. Impact: run from another directory, the test fails before any assertion.

Suggested change: add package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path before the dofile, as hyprland-paths-test.sh does.

Run the new test from any directory other than the repository root, for example cd /tmp && bash <checkout>/test/shell.d/hyprland-double-press-test.sh. First lines of the output at 1137aba (the checkout path is shown as <checkout>; the remaining lines list the other paths require tried), exit status 1:

lua: <checkout>/default/hypr/helpers.lua:3: module 'default.hypr.paths' not found:
	no field package.preload['default.hypr.paths']
	no file '/usr/local/share/lua/5.5/default/hypr/paths.lua'

hyprland-binding-conflicts-test.sh, which prepends OMARCHY_PATH to package.path before loading the helpers, exits 0 from the same directory. From the repository root the new test passes.


Review information

Test scope: Source reading against Hyprland 0.56.2 and sandboxed Lua tests at 1137aba; not run in a live compositor.

AI process: Opus 5.5 Medium coordination and synthesis, Opus 5.5 Xhigh technical review and final fact check, GPT 6 Sol Xhigh search for related issues, Opus 5.5 Medium editorial check.

Opt out: To stop receiving these reviews, reply to this comment saying so.

@l1qu1d
l1qu1d force-pushed the delayed-keyboard-shortcuts branch from 1137aba to 32f436d Compare September 29, 2026 06:09
@omarchybot omarchybot added the enhancement New feature or request label Oct 1, 2026
@l1qu1d

l1qu1d commented Oct 3, 2026

Copy link
Copy Markdown
Author

@greptileai Please review the latest revision of this PR.

@greptile-apps

greptile-apps Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Adds double-press action support to keyboard bindings.

The latest revision appears safe to merge.

Summary

The PR adds timed single- and double-press actions to Hyprland bindings, resolves their single actions in the keybindings menu, and adds documentation and focused tests.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[First press] --> B[Start timeout]
  B -->|Timeout expires| C[Run single action if enabled]
  B -->|Second press| D[Cancel pending single action]
  D --> E[Defer double action by 1 ms]
  E --> F[Run double action if enabled]
Loading

Reviews (1) · Last reviewed commit: "Support double-press actions in Hyprland..."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants