diff --git a/README.md b/README.md index a304d63..cd1c5b2 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ blob-docs-keybinds | `Super + Ctrl + Q` | Quick settings | | `Super + Ctrl + M` | System monitor | | `Super + Alt + W` | Wallpaper picker | +| `Super + Shift + K` | Manage keybindings | | `Super + Ctrl + L` | Lock | The rest are in [keybinds.md](docs/keybinds.md), or run `blob menu keybindings`. diff --git a/bin/blob-keybinds b/bin/blob-keybinds new file mode 100755 index 0000000..f000e9f --- /dev/null +++ b/bin/blob-keybinds @@ -0,0 +1,37 @@ +#!/bin/bash + +# blob:summary=List, add, reset and disable Hyprland keybindings +# blob:args=list | add [replaced-keys] | reset | disable +# 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 [replaced-keys] | reset | disable " >&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 diff --git a/default/blob/blob-menu.jsonc b/default/blob/blob-menu.jsonc index 07d0748..2f4376b 100644 --- a/default/blob/blob-menu.jsonc +++ b/default/blob/blob-menu.jsonc @@ -200,7 +200,7 @@ // Setup "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.network": {"icon":"󰛳","label":"Network","aliases":["network"]}, "setup.network.dns": {"icon":"󰇖","label":"DNS","aliases":["dns"]}, diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 1d41ec8..d2e4f5f 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -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("XF86PowerOff", "Power menu", "blob-menu toggle system", { locked = true }) 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("SUPER + CTRL + SPACE", "Background switcher", "blob-menu toggle background") diff --git a/default/keybinds/manager.lua b/default/keybinds/manager.lua new file mode 100644 index 0000000..dc0e4a2 --- /dev/null +++ b/default/keybinds/manager.lua @@ -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 diff --git a/docs/commands.md b/docs/commands.md index 668f6e3..672c235 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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-launch` | Install a packaged app and launch it once it finishes | | +## keybinds + +| Command | Does | Arguments | +| --- | --- | --- | +| `blob-keybinds` | List, add, reset and disable Hyprland keybindings | list \| add [replaced-keys] \| reset \| disable | + ## launch | Command | Does | Arguments | diff --git a/docs/keybinds.md b/docs/keybinds.md index 76792e3..a06d75a 100644 --- a/docs/keybinds.md +++ b/docs/keybinds.md @@ -193,6 +193,7 @@ Personal overrides in `hypr/bindings.lua` win over the defaults, and | `SUPER + SHIFT + comma` | Dismiss all notifications | | `SUPER + SHIFT + CTRL + R` | Clear reminders | | `SUPER + SHIFT + CTRL + SPACE` | Theme menu | +| `SUPER + SHIFT + K` | Manage keybindings | | `SUPER + SHIFT + SPACE` | Toggle top bar | | `SUPER + SPACE` | Blob menu | | `XF86PowerOff` | Power menu | diff --git a/docs/widgets.md b/docs/widgets.md index 027a411..678df45 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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` | | 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` | +| 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 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 `, the bell button toggles silencing, and the trash button clears the whole history with `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 The tiles still poll shell commands the way the GTK widgets did - `pamixer` for diff --git a/packages/blob.packages b/packages/blob.packages index 14d2008..1d5307b 100644 --- a/packages/blob.packages +++ b/packages/blob.packages @@ -167,6 +167,7 @@ lazygit btop fastfetch inxi +lua dua-cli tldr whois diff --git a/shell/plugins/panels/keybinds/KeybindEditor.qml b/shell/plugins/panels/keybinds/KeybindEditor.qml new file mode 100644 index 0000000..d874603 --- /dev/null +++ b/shell/plugins/panels/keybinds/KeybindEditor.qml @@ -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() + } + } + } +} diff --git a/shell/plugins/panels/keybinds/KeybindRow.qml b/shell/plugins/panels/keybinds/KeybindRow.qml new file mode 100644 index 0000000..7ccc5ec --- /dev/null +++ b/shell/plugins/panels/keybinds/KeybindRow.qml @@ -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() + } + } +} diff --git a/shell/plugins/panels/keybinds/Keybinds.qml b/shell/plugins/panels/keybinds/Keybinds.qml new file mode 100644 index 0000000..71bd93a --- /dev/null +++ b/shell/plugins/panels/keybinds/Keybinds.qml @@ -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 + } + } + } +} diff --git a/shell/plugins/panels/keybinds/KeybindsHeader.qml b/shell/plugins/panels/keybinds/KeybindsHeader.qml new file mode 100644 index 0000000..1efb2c4 --- /dev/null +++ b/shell/plugins/panels/keybinds/KeybindsHeader.qml @@ -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() + } + } +} diff --git a/shell/plugins/panels/keybinds/KeybindsModel.js b/shell/plugins/panels/keybinds/KeybindsModel.js new file mode 100644 index 0000000..9de95ad --- /dev/null +++ b/shell/plugins/panels/keybinds/KeybindsModel.js @@ -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 +} diff --git a/shell/plugins/panels/keybinds/manifest.json b/shell/plugins/panels/keybinds/manifest.json new file mode 100644 index 0000000..b6feec2 --- /dev/null +++ b/shell/plugins/panels/keybinds/manifest.json @@ -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" + } +}