Files
omarchy/default/hypr/helpers.lua
T
54fc12db76 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 <noreply@anthropic.com>
2026-10-09 10:04:59 -04:00

288 lines
8.3 KiB
Lua

-- Shared helpers for Hyprland Lua configuration.
local paths = require("default.hypr.paths")
o = o or {}
local function shell_quote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
o.shell_quote = shell_quote
local function file_exists(path)
local file = io.open(path, "r")
if file then
file:close()
return true
end
return false
end
-- Hyprland reaps its own children, so os.execute() can't retrieve an exit status
-- from inside the compositor. Read a marker off stdout instead.
function o.shell_succeeds(command)
-- Subshell, so the redirection covers every command rather than binding to
-- the last one and letting an earlier one write its own OK into the pipe.
local pipe = io.popen("( " .. command .. " ) >/dev/null 2>&1 && echo OK")
if not pipe then
return false
end
local output = pipe:read("*a") or ""
pipe:close()
return output:find("OK", 1, true) ~= nil
end
function o.cmd_present(command)
if command:find("/", 1, true) then
return file_exists(command)
end
local path = os.getenv("PATH") or "/usr/local/bin:/usr/bin"
for directory in (path .. ":"):gmatch("([^:]*):") do
if file_exists((directory ~= "" and directory or ".") .. "/" .. command) then
return true
end
end
return false
end
function o.cmd_missing(command)
return not o.cmd_present(command)
end
-- The global shortcuts the shell registers, read from the same list it reads.
local shell_shortcuts = nil
local function shell_shortcut_registered(name)
if not shell_shortcuts then
shell_shortcuts = {}
local file = io.open(paths.omarchy_path .. "/default/omarchy/shortcuts", "r")
if file then
for line in file:lines() do
local kind, target = line:match("^(%a+)%s+(%S+)%s*$")
if kind then
shell_shortcuts[kind .. "." .. target] = true
end
end
file:close()
end
end
return shell_shortcuts[name] == true
end
-- Reach the shell through its global shortcut when it registers one, so the
-- keypress spawns nothing. Anything else runs the command as before.
local function shell_dispatcher(kind, target, command)
local name = kind .. "." .. target
if shell_shortcut_registered(name) then
return hl.dsp.global("omarchy:" .. name)
end
return command
end
local function command_from(value, description)
if type(value) ~= "table" then
return value
end
if value.omarchy then
return "omarchy-launch-" .. value.omarchy
elseif value.menu then
return shell_dispatcher("menu", value.menu, "omarchy-menu toggle " .. shell_quote(value.menu))
elseif value.panel then
return shell_dispatcher("panel", value.panel, "omarchy-shell shell toggle " .. shell_quote(value.panel))
elseif value.audio then
return shell_dispatcher("audio", value.audio, "omarchy-audio-output-volume " .. shell_quote(value.audio))
elseif value.brightness then
local step = value.brightness == "raise" and "+5%" or "5%-"
return shell_dispatcher("brightness", value.brightness, "omarchy-brightness-display " .. step)
elseif value.ipc then
local target, method = value.ipc:match("^([^.]+)%.(.+)$")
return shell_dispatcher("ipc", value.ipc, "omarchy-shell " .. shell_quote(target) .. " " .. shell_quote(method))
elseif value.focus and value.launch then
return o.launch_sole(value.focus, value.launch)
elseif value.launch then
return o.launch(value.launch)
elseif value.webapp then
if value.focus then
return o.launch_webapp_sole(description, value.webapp)
else
return o.launch_webapp(value.webapp)
end
elseif value.tui then
if value.focus then
return "omarchy-launch-or-focus-tui " .. shell_quote(value.tui)
else
return "omarchy-launch-tui " .. shell_quote(value.tui)
end
end
return value
end
function o.preinstalled_bindings_enabled()
if _G.omarchy_preinstalled_bindings ~= nil then
return _G.omarchy_preinstalled_bindings == true
end
return not file_exists((os.getenv("HOME") or "") .. "/.local/state/omarchy/preinstalls-removed")
end
function o.bind(keys, description, dispatcher, options)
local opts = options or {}
if description then
opts.description = description
end
dispatcher = command_from(dispatcher, description)
if type(dispatcher) == "string" then
dispatcher = hl.dsp.exec_cmd(dispatcher)
end
hl.bind(keys, dispatcher, opts)
end
function o.rebind(keys, description, dispatcher, options)
hl.unbind(keys)
o.bind(keys, description, dispatcher, options)
end
function o.launch(command)
return "uwsm-app -- " .. command
end
-- The command each function bind stands for, so the keybindings menu can still
-- run a bind that Hyprland only reports as Lua.
o.bind_commands = {}
-- Hand the launcher the focused window's pid, which it would otherwise ask
-- Hyprland for, to open the new terminal in that terminal's directory.
function o.launch_terminal()
local function launch()
local window = hl.get_active_window()
if window and window.pid then
hl.exec_cmd("omarchy-launch-terminal --pid=" .. window.pid)
else
hl.exec_cmd("omarchy-launch-terminal")
end
end
o.bind_commands[launch] = "omarchy-launch-terminal"
return launch
end
function o.exec_on_start(command)
hl.on("hyprland.start", function()
hl.exec_cmd(command)
end)
end
function o.launch_on_start(command)
o.exec_on_start(o.launch(command))
end
function o.launch_webapp(url)
return "omarchy-launch-webapp " .. shell_quote(url)
end
function o.launch_webapp_sole(name, url)
return "omarchy-launch-or-focus-webapp " .. shell_quote(name) .. " " .. shell_quote(url)
end
function o.launch_sole(match, command)
return "omarchy-launch-or-focus " .. shell_quote(match) .. " " .. shell_quote(o.launch(command))
end
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
function o.window(match, rules)
rules.match = rules.match or {}
if type(match) == "string" then
rules.match.class = match
else
for key, value in pairs(match) do
rules.match[key] = value
end
end
hl.window_rule(rules)
end
-- Opt a window in to Omarchy's standard active/inactive transparency.
function o.transparent_window(match, opacity)
o.window(match, { opacity = opacity or "0.985 0.96" })
end