Add a keybindings manager widget

This commit is contained in:
SirBlob committed 2026-09-24 04:58:56 +00:00
1 parent 2df1454219
commit 010ab5fd4c
15 files changed
+926 -1

No files matched your search

+1
View File
@@ -114,6 +114,7 @@ blob-docs-keybinds
| `Super + Ctrl + Q` | Quick settings | | `Super + Ctrl + Q` | Quick settings |
| `Super + Ctrl + M` | System monitor | | `Super + Ctrl + M` | System monitor |
| `Super + Alt + W` | Wallpaper picker | | `Super + Alt + W` | Wallpaper picker |
| `Super + Shift + K` | Manage keybindings |
| `Super + Ctrl + L` | Lock | | `Super + Ctrl + L` | Lock |
The rest are in [keybinds.md](docs/keybinds.md), or run `blob menu keybindings`. The rest are in [keybinds.md](docs/keybinds.md), or run `blob menu keybindings`.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# blob:summary=List, add, reset and disable Hyprland keybindings
# blob:args=list | add <keys> <description> <command> [replaced-keys] | reset <keys> | disable <keys>
# blob:examples=blob keybinds add "SUPER + SHIFT + B" "Browser" "blob-launch-browser"
set -euo pipefail
blob_path="${BLOB_PATH:-$HOME/.local/share/blob}"
manager="$blob_path/default/keybinds/manager.lua"
action="${1:-list}"
usage() {
echo "Usage: blob-keybinds list | add <keys> <description> <command> [replaced-keys] | reset <keys> | disable <keys>" >&2
exit 1
}
if ! command -v lua &>/dev/null; then
echo "blob-keybinds needs lua. Install it with: sudo pacman -S lua" >&2
exit 1
fi
case $action in
list) (( $# <= 1 )) || usage ;;
add) (( $# == 4 || $# == 5 )) || usage ;;
reset | disable) (( $# == 2 )) || usage ;;
*) usage ;;
esac
BLOB_PATH="$blob_path" \
BLOB_KEYBINDS_CONFIG="$HOME/.config/hypr/hyprland.lua" \
BLOB_KEYBINDS_USER_FILE="$HOME/.config/hypr/bindings.lua" \
lua "$manager" "$@"
if [[ $action != list ]]; then
hyprctl reload >/dev/null 2>&1 || true
fi
+1 -1
View File
@@ -200,7 +200,7 @@
// Setup // Setup
"setup.monitors": {"icon":"󰍹","label":"Monitors","action":"blob-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""}, "setup.monitors": {"icon":"󰍹","label":"Monitors","action":"blob-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""},
"setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"blob-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","action":"blob-shell shell toggle blob.keybinds"},
"setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"blob-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"blob-launch-config-editor \"$HOME/.config/hypr/input.lua\""},
"setup.network": {"icon":"󰛳","label":"Network","aliases":["network"]}, "setup.network": {"icon":"󰛳","label":"Network","aliases":["network"]},
"setup.network.dns": {"icon":"󰇖","label":"DNS","aliases":["dns"]}, "setup.network.dns": {"icon":"󰇖","label":"DNS","aliases":["dns"]},
+1
View File
@@ -8,6 +8,7 @@ o.bind("SUPER + SHIFT + code:201", "Blob menu", "blob-menu toggle root")
o.bind("SUPER + ESCAPE", "System menu", "blob-menu toggle system") o.bind("SUPER + ESCAPE", "System menu", "blob-menu toggle system")
o.bind("XF86PowerOff", "Power menu", "blob-menu toggle system", { locked = true }) o.bind("XF86PowerOff", "Power menu", "blob-menu toggle system", { locked = true })
o.bind("SUPER + K", "Keybindings", "blob-menu-keybindings") o.bind("SUPER + K", "Keybindings", "blob-menu-keybindings")
o.bind("SUPER + SHIFT + K", "Manage keybindings", "blob-shell shell toggle blob.keybinds")
o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar") o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar")
o.bind("SUPER + CTRL + SPACE", "Background switcher", "blob-menu toggle background") o.bind("SUPER + CTRL + SPACE", "Background switcher", "blob-menu toggle background")
+264
View File
@@ -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
+6
View File
@@ -269,6 +269,12 @@ of the `blob` listing.
| `blob-install-gaming-xbox-controllers` | Install support for using Xbox controllers with Steam/RetroArch/etc. | - | | `blob-install-gaming-xbox-controllers` | Install support for using Xbox controllers with Steam/RetroArch/etc. | - |
| `blob-install-launch` | Install a packaged app and launch it once it finishes | <display-name> <packages> <desktop-id> | | `blob-install-launch` | Install a packaged app and launch it once it finishes | <display-name> <packages> <desktop-id> |
## keybinds
| Command | Does | Arguments |
| --- | --- | --- |
| `blob-keybinds` | List, add, reset and disable Hyprland keybindings | list \| add <keys> <description> <command> [replaced-keys] \| reset <keys> \| disable <keys> |
## launch ## launch
| Command | Does | Arguments | | Command | Does | Arguments |
+1
View File
@@ -193,6 +193,7 @@ Personal overrides in `hypr/bindings.lua` win over the defaults, and
| `SUPER + SHIFT + comma` | Dismiss all notifications | | `SUPER + SHIFT + comma` | Dismiss all notifications |
| `SUPER + SHIFT + CTRL + R` | Clear reminders | | `SUPER + SHIFT + CTRL + R` | Clear reminders |
| `SUPER + SHIFT + CTRL + SPACE` | Theme menu | | `SUPER + SHIFT + CTRL + SPACE` | Theme menu |
| `SUPER + SHIFT + K` | Manage keybindings |
| `SUPER + SHIFT + SPACE` | Toggle top bar | | `SUPER + SHIFT + SPACE` | Toggle top bar |
| `SUPER + SPACE` | Blob menu | | `SUPER + SPACE` | Blob menu |
| `XF86PowerOff` | Power menu | | `XF86PowerOff` | Power menu |
+26
View File
@@ -40,6 +40,7 @@ falling back to `accent` for themes that define neither. `color0` is
| Quick settings | left-click the bar clock, or `Super + Ctrl + Q` | | Quick settings | left-click the bar clock, or `Super + Ctrl + Q` |
| Notification centre | left-click the bar bell; right-click silences notifications, and the bell is crossed out while they are | | Notification centre | left-click the bar bell; right-click silences notifications, and the bell is crossed out while they are |
| System monitor | `Super + Ctrl + M` | | System monitor | `Super + Ctrl + M` |
| Keybindings | `Super + Shift + K`, or Setup > Keybindings in the Blob menu |
Both new bar click actions are already in `shell.json`. The system monitor had no Both new bar click actions are already in `shell.json`. The system monitor had no
bar entry point in the AGS setup either, so the keybinding is how it is reached. bar entry point in the AGS setup either, so the keybinding is how it is reached.
@@ -63,6 +64,31 @@ removes it through `blob-shell notifications remove <file>`, the bell button
toggles silencing, and the trash button clears the whole history with toggles silencing, and the trash button clears the whole history with
`blob-shell notifications clear`. `blob-shell notifications clear`.
## Keybindings
`blob.keybinds` lists every binding Hyprland loads, your own first, with a
search field and filters for all, mine, and disabled or replaced. The list comes
from `blob keybinds list`, which evaluates the real Lua config with a stubbed
`hl`, so bindings made in loops or with Hyprland dispatchers show up too, each
marked by the file it came from.
| Button | On | Does |
| --- | --- | --- |
| + | header | add a binding |
| pencil | your binding | edit it; changing the keys replaces the old line |
| pencil | a default | replace it with your own command on the same keys |
| trash | your binding | remove it, bringing back any default it replaced |
| ban | a default | disable it with `hl.unbind` |
| undo | a disabled or replaced default | restore it |
The editor warns when the keys are already taken and says whether saving will
replace your binding or disable a default. Every change is a line added to or
removed from `~/.config/hypr/bindings.lua`, which is written through a temporary
file, and Hyprland reloads straight after. Default bindings that call a Hyprland
dispatcher rather than a command open the editor with an empty command, since
only commands can be written from the widget. It needs `lua`, which is in the
package list.
## Deliberately not ported ## Deliberately not ported
The tiles still poll shell commands the way the GTK widgets did - `pamixer` for The tiles still poll shell commands the way the GTK widgets did - `pamixer` for
+1
View File
@@ -167,6 +167,7 @@ lazygit
btop btop
fastfetch fastfetch
inxi inxi
lua
dua-cli dua-cli
tldr tldr
whois whois
@@ -0,0 +1,111 @@
import QtQuick
import qs.Commons
import qs.Ui
Rectangle {
id: root
property var binding: null
property var conflictLookup: null
signal saveRequested(string keys, string description, string command)
signal cancelRequested()
readonly property var conflict: conflictLookup ? conflictLookup(keysField.text) : null
readonly property bool canSave: keysField.text.trim().length > 0
&& descriptionField.text.trim().length > 0
&& commandField.text.trim().length > 0
function load(source) {
root.binding = source
keysField.text = source ? source.keys : ""
descriptionField.text = source ? source.description : ""
commandField.text = source && source.kind === "exec" ? source.action : ""
keysField.forceActiveFocus()
}
function save() {
if (!root.canSave) return
root.saveRequested(keysField.text.trim(), descriptionField.text.trim(), commandField.text.trim())
}
implicitHeight: form.implicitHeight + Style.spaceReal(24)
radius: Style.cardRadius
color: Util.alpha(Color.foreground, 0.06)
border.width: 1
border.color: Util.alpha(Color.accent, 0.6)
Column {
id: form
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.spaceReal(12)
spacing: Style.spaceReal(8)
Text {
text: !root.binding ? "New binding"
: (root.binding.origin === "personal" ? "Edit binding" : "Replace default binding")
textFormat: Text.PlainText
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
font.bold: true
}
TextField {
id: keysField
width: parent.width
placeholderText: "Keys, e.g. SUPER + SHIFT + B"
onAccepted: descriptionField.forceActiveFocus()
Keys.onEscapePressed: root.cancelRequested()
}
TextField {
id: descriptionField
width: parent.width
placeholderText: "Description, e.g. Browser"
onAccepted: commandField.forceActiveFocus()
Keys.onEscapePressed: root.cancelRequested()
}
TextField {
id: commandField
width: parent.width
placeholderText: "Command, e.g. blob-launch-browser"
onAccepted: root.save()
Keys.onEscapePressed: root.cancelRequested()
}
Text {
width: parent.width
visible: root.conflict !== null
wrapMode: Text.WordWrap
text: !root.conflict ? ""
: (root.conflict.origin === "personal"
? "Replaces your binding: " + root.conflict.description
: "Disables the default: " + root.conflict.description)
textFormat: Text.PlainText
color: Color.urgent
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
Flow {
width: parent.width
spacing: Style.spaceReal(6)
Chip {
label: "Save"
active: root.canSave
enabled: root.canSave
onActivated: root.save()
}
Chip {
label: "Cancel"
onActivated: root.cancelRequested()
}
}
}
}
@@ -0,0 +1,121 @@
import QtQuick
import qs.Commons
import qs.Ui
Rectangle {
id: root
property var binding: null
signal editRequested()
signal resetRequested()
signal disableRequested()
readonly property bool personal: binding ? binding.origin === "personal" : false
readonly property bool active: binding ? binding.state === "active" : false
readonly property string stateText: {
if (!binding) return ""
if (binding.state === "overridden") return "Replaced by your binding"
if (binding.state === "disabled") return "Disabled"
return binding.kind === "exec" ? binding.action : "Hyprland: " + binding.action
}
implicitHeight: Style.spaceReal(46)
radius: Style.cardRadius
color: hover.hovered ? Util.alpha(Color.foreground, 0.08) : Util.alpha(Color.background, Style.cardFillAlpha)
HoverHandler {
id: hover
}
Rectangle {
id: keysChip
anchors.left: parent.left
anchors.leftMargin: Style.spaceReal(8)
anchors.verticalCenter: parent.verticalCenter
width: Style.spaceReal(200)
height: Style.spaceReal(26)
radius: Style.cardRadius
color: root.personal ? Util.alpha(Color.accent, 0.25) : Util.alpha(Color.foreground, 0.1)
border.width: 1
border.color: root.personal ? Color.accent : Util.alpha(Color.foreground, 0.2)
opacity: root.active ? 1 : 0.5
Text {
anchors.fill: parent
anchors.leftMargin: Style.spaceReal(8)
anchors.rightMargin: Style.spaceReal(8)
verticalAlignment: Text.AlignVCenter
text: root.binding ? root.binding.keys : ""
textFormat: Text.PlainText
elide: Text.ElideRight
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
font.bold: true
}
}
Column {
anchors.left: keysChip.right
anchors.leftMargin: Style.spaceReal(12)
anchors.right: buttons.left
anchors.rightMargin: Style.spaceReal(8)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.spaceReal(2)
opacity: root.active ? 1 : 0.6
Text {
width: parent.width
text: root.binding ? root.binding.description : ""
textFormat: Text.PlainText
elide: Text.ElideRight
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
font.bold: true
}
Text {
width: parent.width
text: root.stateText
textFormat: Text.PlainText
elide: Text.ElideRight
color: root.active ? Util.alpha(Color.foreground, 0.6) : Color.urgent
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
}
Row {
id: buttons
anchors.right: parent.right
anchors.rightMargin: Style.spaceReal(8)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.spaceReal(4)
CardIconButton {
visible: root.active
icon: ""
onActivated: root.editRequested()
}
CardIconButton {
visible: root.personal
icon: ""
onActivated: root.resetRequested()
}
CardIconButton {
visible: !root.personal && root.active
icon: ""
onActivated: root.disableRequested()
}
CardIconButton {
visible: !root.personal && !root.active
icon: ""
onActivated: root.resetRequested()
}
}
}
+223
View File
@@ -0,0 +1,223 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "KeybindsModel.js" as KeybindsModel
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
readonly property string pluginId: (manifest && manifest.id) || "blob.keybinds"
property var bindings: []
property string searchText: ""
property string filterMode: "all"
property string statusText: ""
property bool statusIsError: false
property bool editorOpen: false
readonly property var visibleBindings: KeybindsModel.filterBindings(root.bindings, root.searchText, root.filterMode)
function open(payloadJson) {
root.opened = true
root.refresh()
Qt.callLater(function() { searchField.forceActiveFocus() })
}
function close() {
root.opened = false
root.editorOpen = false
}
function dismiss() {
root.close()
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide(root.pluginId)
}
function refresh() {
if (!listProc.running) listProc.running = true
}
function runChange(args, successMessage) {
if (changeProc.running) return
root.statusText = ""
root.statusIsError = false
changeProc.successMessage = successMessage
changeProc.command = ["blob-keybinds"].concat(args)
changeProc.running = true
}
function openEditor(binding) {
root.editorOpen = true
editor.load(binding)
}
function saveBinding(keys, description, command) {
var args = ["add", keys, description, command]
var editing = editor.binding
if (editing && editing.origin === "personal" && editing.canonical !== KeybindsModel.canonicalKeys(keys))
args.push(editing.keys)
root.runChange(args, "Saved " + keys)
root.editorOpen = false
}
function conflictFor(keys) {
return KeybindsModel.conflictFor(root.bindings, keys, editor.binding)
}
Process {
id: listProc
command: ["blob-keybinds", "list"]
stdout: StdioCollector {
onStreamFinished: root.bindings = KeybindsModel.parseBindings(text)
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim().length === 0) return
root.statusText = text.trim()
root.statusIsError = true
}
}
}
Process {
id: changeProc
property string successMessage: ""
stderr: StdioCollector {
onStreamFinished: {
if (text.trim().length === 0) return
root.statusText = text.trim()
root.statusIsError = true
}
}
onExited: function(exitCode) {
if (exitCode === 0) {
root.statusText = changeProc.successMessage
root.statusIsError = false
} else if (root.statusText.length === 0) {
root.statusText = "The change could not be saved"
root.statusIsError = true
}
root.refresh()
}
}
PanelWindow {
id: panelWindow
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-keybinds"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
exclusionMode: ExclusionMode.Ignore
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
MouseArea {
anchors.fill: card
}
Card {
id: card
readonly property real screenMargin: Style.spaceReal(20)
anchors.centerIn: parent
implicitWidth: Math.min(Style.spaceReal(760), panelWindow.width - screenMargin * 2)
implicitHeight: Math.min(Style.spaceReal(640), panelWindow.height - screenMargin * 2)
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
spacing: Style.spaceReal(10)
KeybindsHeader {
id: header
width: parent.width
onAddRequested: root.openEditor(null)
onCloseRequested: root.dismiss()
}
TextField {
id: searchField
width: parent.width
placeholderText: "Search keys, descriptions or commands"
onTextChanged: root.searchText = text
Keys.onEscapePressed: root.dismiss()
}
Flow {
id: filters
width: parent.width
spacing: Style.spaceReal(4)
Chip {
label: "All"
active: root.filterMode === "all"
onActivated: root.filterMode = "all"
}
Chip {
label: "Mine"
active: root.filterMode === "personal"
onActivated: root.filterMode = "personal"
}
Chip {
label: "Disabled or replaced"
active: root.filterMode === "changed"
onActivated: root.filterMode = "changed"
}
}
KeybindEditor {
id: editor
width: parent.width
visible: root.editorOpen
conflictLookup: root.conflictFor
onSaveRequested: function(keys, description, command) { root.saveBinding(keys, description, command) }
onCancelRequested: root.editorOpen = false
}
ListView {
id: list
width: parent.width
height: Math.max(Style.spaceReal(80), card.height - card.padding * 2 - y - status.height - card.spacing)
clip: true
spacing: Style.spaceReal(4)
boundsBehavior: Flickable.StopAtBounds
model: root.visibleBindings
delegate: KeybindRow {
required property var modelData
width: ListView.view.width
binding: modelData
onEditRequested: root.openEditor(modelData)
onResetRequested: root.runChange(["reset", modelData.keys],
modelData.origin === "personal" ? "Removed " + modelData.keys : "Restored " + modelData.keys)
onDisableRequested: root.runChange(["disable", modelData.keys], "Disabled " + modelData.keys)
}
}
Text {
id: status
width: parent.width
wrapMode: Text.WordWrap
text: root.statusText.length > 0 ? root.statusText
: root.visibleBindings.length + " bindings. Changes go to ~/.config/hypr/bindings.lua and Hyprland reloads."
textFormat: Text.PlainText
color: root.statusIsError ? Color.urgent : Util.alpha(Color.foreground, 0.6)
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
}
}
}
@@ -0,0 +1,44 @@
import QtQuick
import qs.Commons
import qs.Ui
Item {
id: root
signal addRequested()
signal closeRequested()
implicitHeight: Math.max(title.implicitHeight, buttons.implicitHeight)
Text {
id: title
anchors.left: parent.left
anchors.right: buttons.left
anchors.rightMargin: Style.spaceReal(8)
anchors.verticalCenter: parent.verticalCenter
text: "Keybindings"
textFormat: Text.PlainText
elide: Text.ElideRight
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.body
font.bold: true
}
Row {
id: buttons
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.spaceReal(4)
CardIconButton {
icon: ""
onActivated: root.addRequested()
}
CardIconButton {
icon: ""
onActivated: root.closeRequested()
}
}
}
@@ -0,0 +1,74 @@
.pragma library
var modifierNames = { SUPER: true, SHIFT: true, CTRL: true, CONTROL: true, ALT: true }
function canonicalKeys(keys) {
var modifiers = []
var key = ""
var parts = String(keys || "").split("+")
for (var i = 0; i < parts.length; i++) {
var token = parts[i].trim().toUpperCase()
if (token === "CONTROL") token = "CTRL"
if (modifierNames[token]) modifiers.push(token)
else if (token.length > 0) key = token
}
modifiers.sort()
modifiers.push(key)
return modifiers.join("+")
}
function parseBindings(raw) {
var personal = []
var defaults = []
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var fields = lines[i].split("\t")
if (fields.length < 6) continue
var binding = {
origin: fields[0],
state: fields[1],
keys: fields[2],
description: fields[3],
kind: fields[4],
action: fields[5],
canonical: canonicalKeys(fields[2])
}
if (binding.origin === "personal") personal.push(binding)
else defaults.push(binding)
}
return personal.concat(defaults)
}
function matchesFilter(binding, filterMode) {
if (filterMode === "personal") return binding.origin === "personal"
if (filterMode === "changed") return binding.state !== "active"
return true
}
function matchesSearch(binding, query) {
if (!query) return true
return binding.keys.toLowerCase().indexOf(query) !== -1
|| binding.description.toLowerCase().indexOf(query) !== -1
|| binding.action.toLowerCase().indexOf(query) !== -1
}
function filterBindings(bindings, searchText, filterMode) {
var query = String(searchText || "").trim().toLowerCase()
var result = []
for (var i = 0; i < bindings.length; i++) {
if (matchesFilter(bindings[i], filterMode) && matchesSearch(bindings[i], query)) result.push(bindings[i])
}
return result
}
function conflictFor(bindings, keys, editingBinding) {
var canonical = canonicalKeys(keys)
if (canonical.charAt(canonical.length - 1) === "+" || canonical === "") return null
for (var i = 0; i < bindings.length; i++) {
var binding = bindings[i]
if (binding.state !== "active" || binding.canonical !== canonical) continue
if (editingBinding && editingBinding.origin === "personal" && editingBinding.canonical === canonical) continue
return binding
}
return null
}
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "Keybindings",
"version": "1.0.0",
"author": "Blob",
"description": "Search, add, edit, disable and restore Hyprland keybindings",
"id": "blob.keybinds",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Keybinds.qml"
}
}