Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
509 changes: 117 additions & 392 deletions bin/omarchy-menu-keybindings

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions default/hypr/bindings/tiling.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
o.bind("SUPER + W", "Close window", hl.dsp.window.close())
o.bind("SUPER + Q", "Close window", hl.dsp.window.close())
local move_to_scratchpad = hl.dsp.window.move({ workspace = "special:scratchpad", follow = false })
local toggle_scratchpad = hl.dsp.workspace.toggle_special("scratchpad")
local close_window = hl.dsp.window.close()
o.bind("SUPER + W", "Close window", close_window)
o.bind("SUPER + Q", "Close window", close_window)
o.bind("CTRL + ALT + DELETE", "Close all windows", "omarchy-hyprland-window-close-all")

o.bind("SUPER + J", "Toggle window split", hl.dsp.layout("togglesplit"))
Expand All @@ -25,10 +28,10 @@ for workspace = 1, 10 do
o.bind("SUPER + SHIFT + ALT + " .. key, "Move window silently to workspace " .. workspace, hl.dsp.window.move({ workspace = tostring(workspace), follow = false }))
end

o.bind("SUPER + S", "Toggle scratchpad", hl.dsp.workspace.toggle_special("scratchpad"))
o.bind("SUPER + ALT + S", "Move window to scratchpad", hl.dsp.window.move({ workspace = "special:scratchpad", follow = false }))
o.bind("SUPER + grave", "Toggle scratchpad", hl.dsp.workspace.toggle_special("scratchpad"))
o.bind("SUPER + SHIFT + grave", "Move window to scratchpad", hl.dsp.window.move({ workspace = "special:scratchpad", follow = false }))
o.bind("SUPER + S", "Toggle scratchpad", toggle_scratchpad)
o.bind("SUPER + ALT + S", "Move window to scratchpad", move_to_scratchpad)
o.bind("SUPER + grave", "Toggle scratchpad", toggle_scratchpad)
o.bind("SUPER + SHIFT + grave", "Move window to scratchpad", move_to_scratchpad)

o.bind("SUPER + TAB", "Next workspace", hl.dsp.focus({ workspace = "e+1" }))
o.bind("SUPER + SHIFT + TAB", "Previous workspace", hl.dsp.focus({ workspace = "e-1" }))
Expand Down
5 changes: 3 additions & 2 deletions default/hypr/bindings/utilities.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ o.bind("XF86PowerOff", "Power menu", { menu = "system" }, { locked = true })
o.bind("SUPER + K", "Keybindings", "omarchy-menu-keybindings")
o.bind("SUPER + ALT + K", "Tmux keybindings", "omarchy-menu-tmux-keybindings")
o.bind("SUPER + CTRL + K", "Herdr keybindings", "omarchy-menu-herdr-keybindings")
o.bind("SUPER + CTRL + Q", "Calculator", "omacalc")
o.bind("XF86Calculator", "Calculator", "omacalc")
local calculator = hl.dsp.exec_cmd("omacalc")
o.bind("SUPER + CTRL + Q", "Calculator", calculator)
o.bind("XF86Calculator", "Calculator", calculator)

o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar")
o.bind("SUPER + CTRL + SPACE", "Background switcher", { menu = "background" })
Expand Down
5 changes: 5 additions & 0 deletions default/hypr/bootstrap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,8 @@ package.path = home
.. (os.getenv("OMARCHY_PATH") or "/usr/share/omarchy")
.. "/?.lua;"
.. package.path

-- Install before any default or user module can register a binding.
if hl then
require("default.hypr.keybindings").install()
end
84 changes: 84 additions & 0 deletions default/hypr/keybindings.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
-- Collect bindings in the real compositor, as they are registered. The menu
-- reads metadata and invokes the retained action; it never reloads user Lua.
local M = {}

local function json_string(value)
return '"' .. tostring(value):gsub('[%z\1-\31\\"]', function(char)
return string.format('\\u%04x', string.byte(char))
end) .. '"'
end

-- Hyprland 0.56.2 dereferences expired handles in is_enabled(). Its tostring
-- implementation checks the weak reference first, so test expiry before any
-- property/method access. Removed bindings must also release their closures.
local function enabled(handle)
if tostring(handle) == "HL.Keybind(expired)" then return nil end
return handle:is_enabled()
end

function M.install()
local previous = _G.omarchy_keybindings
local bind = hl.bind
if previous and bind == previous.bind then
bind = previous.original_bind
end

-- A new Lua state can reuse both callback IDs and counters after a reload.
-- A per-load token also rejects selections from a previous compositor.
local file = assert(io.open('/proc/sys/kernel/random/uuid', 'r'))
local generation = assert(file:read('*l'))
file:close()
local entries, action_ids = {}, setmetatable({}, { __mode = "k" })
local next_id = 0
local next_action = 0
local registry = { original_bind = bind }

function registry.bind(keys, action, options)
local handle = bind(keys, action, options)
if handle then
if not action_ids[action] then
next_action = next_action + 1
action_ids[action] = next_action
end
next_id = next_id + 1
entries[next_id] = { handle = handle, action = action, identity = action_ids[action] }
end
return handle
end

function registry.snapshot()
local rows = {}
local ids = {}
for id, entry in pairs(entries) do
if enabled(entry.handle) == nil then
entries[id] = nil -- Release removed bindings and their captured closures.
else
ids[#ids + 1] = id
end
end
table.sort(ids)
for _, id in ipairs(ids) do
local entry = entries[id]
local handle = entry.handle
if enabled(handle) and handle.description and handle.description ~= '' then
rows[#rows + 1] = string.format(
'{"id":%d,"identity":%d,"keys":%s,"description":%s,"submap":%s}',
id, entry.identity, json_string(handle.display_key),
json_string(handle.description), json_string(handle.submap or ''))
end
end
return '{"generation":' .. json_string(generation) .. ',"bindings":[' .. table.concat(rows, ',') .. ']}'
end

function registry.invoke(token, id)
assert(token == generation, 'Keybindings changed; reopen the menu')
local entry = entries[id]
assert(entry and enabled(entry.handle), 'Keybinding was removed or disabled; reopen the menu')
return hl.dispatch(entry.action)
end

hl.bind = registry.bind
_G.omarchy_keybindings = registry
end

return M
27 changes: 27 additions & 0 deletions docs/keybindings-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Keybindings menu

The menu reads bindings collected inside the running Hyprland Lua state. It does not evaluate user configuration in a second interpreter or reconstruct action expressions.

`default/hypr/bootstrap.lua` installs `default.hypr.keybindings` before loading default and user modules. The collector wraps the real `hl.bind`, returns its original handle, and retains the original action. This covers both `o.bind` and direct `hl.bind`, including bindings registered later by a callback. Configuration loading remains Hyprland's responsibility.

`omarchy_keybindings.snapshot()` returns JSON containing a configuration-generation token and described, enabled bindings in registration order. Original key spellings come from the native handle, preserving keycodes and modifier aliases that `hyprctl binds` can omit. The menu formats the snapshot without a disk cache, so runtime enable/disable and registration changes are visible immediately. Removed handles are discarded along with their captured actions.

`omarchy_keybindings.invoke(generation, id)` validates the generation and current enabled state, then passes the retained action to `hl.dispatch`. Arbitrary Lua closures retain their captured variables. A selection from before a reload, or one removed/disabled while the menu is open, fails instead of executing a different or inactive action. A fresh UUID on every bootstrap prevents ID reuse across reloads or compositor restarts.

The collector runs after `o.bind` converts table-based menu, panel, media, brightness, and notification actions into native global dispatchers. Those shortcuts keep their direct path to the shell. The terminal binding retains `o.launch_terminal()` itself, so selecting it reads the focused window's PID at invocation time. Neither path needs the source scanner's `o.bind_commands` fallback.

## Compatibility

Hyprland 0.56.2 crashes when `is_enabled()` is called on an expired keybinding handle. Its native `tostring` implementation safely reports `HL.Keybind(expired)`, which the collector checks before accessing any handle property. This is an expiry guard for real handles, not a replacement implementation of Hyprland APIs. The VM test exercises unbind, removal, disabled state, and re-enabling.

The entrypoint must load Omarchy's bootstrap before registering bindings, as the shipped configuration already does. The upgrade migration attempts to reload an existing graphical session; a stale compositor signature does not block subsequent migrations, and an offline upgrade takes effect at the next login. For a custom entrypoint without the bootstrap, the migration prints the exact `dofile` line and explains that it must precede any bindings or module imports, leaving user Lua intact. If the collector is unavailable, the menu reports reload and bootstrap recovery instructions. Interactive failures also send a desktop notification; clicking the bootstrap notification opens the full instructions in a terminal so the notification's three-line limit cannot hide them. `--print` reports errors only on stderr. Rejected selections, including stale generations and disabled bindings, ask the user to reopen the menu.

Only bindings with descriptions appear. The small set of intentionally grouped alternative chords share the same real action object; unrelated actions with the same description remain separate. Identical display rows are disambiguated so either action can be selected. The two browser-extension shortcuts remain explicit menu entries because they are not Hyprland bindings.

## Tests

- `test/shell.d/keybindings-registry-test.sh`: retained actions, the current terminal and shell-shortcut helpers, metadata serialization, disabled/removed handles, repeated installation, stale IDs, and the upgrade migration.
- `test/shell.d/keybindings-menu-test.sh`: presentation and alternative chords, extra modifiers/keycodes, control characters and punctuation, duplicate selection, invalid IPC responses, an assertion that no external Lua interpreter is started, and visible errors for unavailable registries and failed selections.
- `test/acceptance.d/keybindings-test.sh`: real VM configuration containing live list queries, simultaneous listings without configuration side effects, native dispatchers and closures, submaps, enable/disable, removed handles, stale selections, and actual menu search/selection with screenshots. It also checks visible rejection notifications, migration behavior with offline/expired/live sessions and a custom entrypoint, opening recovery instructions from the notification, and restoring the menu with the documented bootstrap line.

Run the graphical test only in a disposable VM, following `agents/skills/acceptance-tests.md`. The committed acceptance test uses in-guest `wtype` only for menu controls. The initial PR validation also included manual QMP virtual-keyboard checks of global shortcuts; those additional checks are not automated by this test.
15 changes: 15 additions & 0 deletions migrations/1789328800.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
echo "Load the live keybindings registry into the current Hyprland session"

# Hand-edited entrypoints can still set package.path themselves. Explain the
# required bootstrap without guessing where it belongs in arbitrary Lua.
config="$HOME/.config/hypr/hyprland.lua"
if [[ -f $config ]] && ! grep -Fq '/default/hypr/bootstrap.lua' "$config"; then
echo "Keybindings menu: add this line before any bindings or module imports in $config, then reload Hyprland:"
echo 'dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/bootstrap.lua")'
fi

# Offline upgrades take effect at login. An inherited signature may refer to
# an exited compositor; that must not block the remaining migrations.
if [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then
hyprctl reload >/dev/null 2>&1 || true
fi
Loading