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,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user