Add a keybindings manager widget
This commit is contained in:
1 parent
2df1454219
commit
010ab5fd4c
15 files changed
+926
-1
No files matched your search
@@ -0,0 +1,264 @@
|
||||
local action = arg[1] or "list"
|
||||
local config_path = os.getenv("BLOB_KEYBINDS_CONFIG")
|
||||
local user_file = os.getenv("BLOB_KEYBINDS_USER_FILE")
|
||||
local personal_prefix = (os.getenv("HOME") or "") .. "/.config/hypr/"
|
||||
|
||||
local modifier_names = { SUPER = true, SHIFT = true, CTRL = true, ALT = true }
|
||||
|
||||
local function fail(message)
|
||||
io.stderr:write(message, "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
local trimmed = value:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return trimmed
|
||||
end
|
||||
|
||||
local function canonical_keys(keys)
|
||||
local modifiers, key = {}, ""
|
||||
for part in tostring(keys):gmatch("[^+]+") do
|
||||
local token = trim(part):upper()
|
||||
if token == "CONTROL" then token = "CTRL" end
|
||||
if modifier_names[token] then
|
||||
modifiers[#modifiers + 1] = token
|
||||
elseif token ~= "" then
|
||||
key = token
|
||||
end
|
||||
end
|
||||
table.sort(modifiers)
|
||||
modifiers[#modifiers + 1] = key
|
||||
return table.concat(modifiers, "+"), key
|
||||
end
|
||||
|
||||
local bindings = {}
|
||||
|
||||
local function caller_source()
|
||||
for level = 2, 12 do
|
||||
local info = debug.getinfo(level, "S")
|
||||
if not info then break end
|
||||
local source = info.source or ""
|
||||
if source:sub(1, 1) == "@" and not source:match("helpers%.lua$") then
|
||||
return source:sub(2)
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function dispatcher_proxy(path)
|
||||
return setmetatable({ path = path }, {
|
||||
__index = function(self, key)
|
||||
return dispatcher_proxy(self.path .. "." .. tostring(key))
|
||||
end,
|
||||
__call = function(self, first)
|
||||
if self.path == "hl.dsp.exec_cmd" and type(first) == "string" then
|
||||
return { blob_dispatcher = true, kind = "exec", text = first }
|
||||
end
|
||||
return { blob_dispatcher = true, kind = "dispatcher", text = (self.path:gsub("^hl%.dsp%.", "")) }
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function describe_dispatcher(dispatcher)
|
||||
if type(dispatcher) == "table" and dispatcher.blob_dispatcher then
|
||||
return dispatcher.kind, dispatcher.text
|
||||
elseif type(dispatcher) == "string" then
|
||||
return "exec", dispatcher
|
||||
end
|
||||
return "dispatcher", "Lua function"
|
||||
end
|
||||
|
||||
local noop
|
||||
noop = setmetatable({}, {
|
||||
__index = function() return noop end,
|
||||
__call = function() return noop end,
|
||||
})
|
||||
|
||||
hl = setmetatable({
|
||||
dsp = dispatcher_proxy("hl.dsp"),
|
||||
bind = function(keys, dispatcher, options)
|
||||
local opts = options or {}
|
||||
local kind, text = describe_dispatcher(dispatcher)
|
||||
local source = caller_source()
|
||||
bindings[#bindings + 1] = {
|
||||
keys = tostring(keys),
|
||||
canonical = canonical_keys(keys),
|
||||
description = opts.description or "",
|
||||
kind = kind,
|
||||
action = text,
|
||||
origin = source:sub(1, #personal_prefix) == personal_prefix and "personal" or "default",
|
||||
state = "active",
|
||||
}
|
||||
return noop
|
||||
end,
|
||||
unbind = function(keys)
|
||||
local canonical = canonical_keys(keys)
|
||||
for _, binding in ipairs(bindings) do
|
||||
if binding.canonical == canonical and binding.state == "active" then
|
||||
binding.state = "disabled"
|
||||
end
|
||||
end
|
||||
return noop
|
||||
end,
|
||||
get_config = function() return nil end,
|
||||
}, {
|
||||
__index = function() return noop end,
|
||||
})
|
||||
|
||||
local function load_bindings()
|
||||
local config_file = config_path and io.open(config_path, "r")
|
||||
if not config_file then
|
||||
fail("Hyprland config not found: " .. tostring(config_path))
|
||||
end
|
||||
config_file:close()
|
||||
local ok, message = pcall(dofile, config_path)
|
||||
if not ok then
|
||||
fail("Could not read the Hyprland config: " .. tostring(message))
|
||||
end
|
||||
|
||||
local personal_active = {}
|
||||
for _, binding in ipairs(bindings) do
|
||||
if binding.origin == "personal" and binding.state == "active" then
|
||||
personal_active[binding.canonical] = true
|
||||
end
|
||||
end
|
||||
for _, binding in ipairs(bindings) do
|
||||
if binding.origin == "default" and binding.state == "disabled" and personal_active[binding.canonical] then
|
||||
binding.state = "overridden"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function clean(value)
|
||||
return (tostring(value):gsub("[\t\n]", " "))
|
||||
end
|
||||
|
||||
local function print_bindings()
|
||||
for _, binding in ipairs(bindings) do
|
||||
if binding.description ~= "" then
|
||||
print(table.concat({
|
||||
binding.origin, binding.state, clean(binding.keys),
|
||||
clean(binding.description), binding.kind, clean(binding.action),
|
||||
}, "\t"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function read_lines(path)
|
||||
local lines = {}
|
||||
local file = io.open(path, "r")
|
||||
if not file then return lines end
|
||||
for line in file:lines() do lines[#lines + 1] = line end
|
||||
file:close()
|
||||
return lines
|
||||
end
|
||||
|
||||
local function write_lines(path, lines)
|
||||
local temporary = path .. ".tmp"
|
||||
local file = io.open(temporary, "w") or fail("Cannot write " .. temporary)
|
||||
file:write(table.concat(lines, "\n"), "\n")
|
||||
file:close()
|
||||
local ok, message = os.rename(temporary, path)
|
||||
if not ok then fail("Cannot replace " .. path .. ": " .. tostring(message)) end
|
||||
end
|
||||
|
||||
local function bind_line_keys(line)
|
||||
return line:match('^%s*o%.bind_toggle%(%s*"(.-)"')
|
||||
or line:match('^%s*o%.bind%(%s*"(.-)"')
|
||||
or line:match('^%s*hl%.bind%(%s*"(.-)"')
|
||||
end
|
||||
|
||||
local function unbind_line_keys(line)
|
||||
return line:match('^%s*hl%.unbind%(%s*"(.-)"%s*%)')
|
||||
end
|
||||
|
||||
local function without_lines(lines, canonical, drop_binds, drop_unbinds)
|
||||
local kept, removed = {}, 0
|
||||
for _, line in ipairs(lines) do
|
||||
local bind_keys = bind_line_keys(line)
|
||||
local unbind_keys = unbind_line_keys(line)
|
||||
if drop_binds and bind_keys and canonical_keys(bind_keys) == canonical then
|
||||
removed = removed + 1
|
||||
elseif drop_unbinds and unbind_keys and canonical_keys(unbind_keys) == canonical then
|
||||
removed = removed + 1
|
||||
else
|
||||
kept[#kept + 1] = line
|
||||
end
|
||||
end
|
||||
return kept, removed
|
||||
end
|
||||
|
||||
local function has_unbind(lines, canonical)
|
||||
for _, line in ipairs(lines) do
|
||||
local keys = unbind_line_keys(line)
|
||||
if keys and canonical_keys(keys) == canonical then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function has_active_default(canonical)
|
||||
for _, binding in ipairs(bindings) do
|
||||
if binding.origin == "default" and binding.canonical == canonical and binding.state == "active" then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function require_key(keys)
|
||||
local canonical, key = canonical_keys(keys)
|
||||
if key == "" then fail("Keys need a key besides the modifiers, such as SUPER + SHIFT + K") end
|
||||
return canonical
|
||||
end
|
||||
|
||||
local function reject_newlines(...)
|
||||
for index = 1, select("#", ...) do
|
||||
if tostring(select(index, ...)):find("\n") then fail("Values must be a single line") end
|
||||
end
|
||||
end
|
||||
|
||||
local function add_binding(keys, description, command, replaced_keys)
|
||||
reject_newlines(keys, description, command)
|
||||
if trim(description) == "" or trim(command) == "" then fail("A binding needs a description and a command") end
|
||||
local canonical = require_key(keys)
|
||||
local lines = read_lines(user_file)
|
||||
|
||||
if replaced_keys and replaced_keys ~= "" then
|
||||
lines = without_lines(lines, canonical_keys(replaced_keys), true, true)
|
||||
end
|
||||
lines = without_lines(lines, canonical, true, false)
|
||||
|
||||
if has_active_default(canonical) and not has_unbind(lines, canonical) then
|
||||
lines[#lines + 1] = string.format("hl.unbind(%q)", trim(keys))
|
||||
end
|
||||
lines[#lines + 1] = string.format("o.bind(%q, %q, %q)", trim(keys), trim(description), trim(command))
|
||||
write_lines(user_file, lines)
|
||||
end
|
||||
|
||||
local function reset_binding(keys)
|
||||
local lines, removed = without_lines(read_lines(user_file), require_key(keys), true, true)
|
||||
if removed == 0 then fail("No personal binding or disabled default for " .. keys) end
|
||||
write_lines(user_file, lines)
|
||||
end
|
||||
|
||||
local function disable_binding(keys)
|
||||
local canonical = require_key(keys)
|
||||
if not has_active_default(canonical) then fail("No active default binding for " .. keys) end
|
||||
local lines = read_lines(user_file)
|
||||
lines[#lines + 1] = string.format("hl.unbind(%q)", trim(keys))
|
||||
write_lines(user_file, lines)
|
||||
end
|
||||
|
||||
load_bindings()
|
||||
|
||||
if action == "list" then
|
||||
print_bindings()
|
||||
elseif action == "add" then
|
||||
add_binding(arg[2] or "", arg[3] or "", arg[4] or "", arg[5])
|
||||
elseif action == "reset" then
|
||||
reset_binding(arg[2] or "")
|
||||
elseif action == "disable" then
|
||||
disable_binding(arg[2] or "")
|
||||
else
|
||||
fail("Unknown action: " .. action)
|
||||
end
|
||||
Reference in new issue
Block a user