summaryrefslogtreecommitdiffstats
path: root/desktop/modules/hyprland/BindsPage.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/hyprland/BindsPage.qml')
-rw-r--r--desktop/modules/hyprland/BindsPage.qml334
1 files changed, 334 insertions, 0 deletions
diff --git a/desktop/modules/hyprland/BindsPage.qml b/desktop/modules/hyprland/BindsPage.qml
new file mode 100644
index 0000000..eca71be
--- /dev/null
+++ b/desktop/modules/hyprland/BindsPage.qml
@@ -0,0 +1,334 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+import "Hypr.js" as H
+
+// One row per live bind: the combo as keycaps, what it does (its
+// description, then the action as the Lua source writes it), and where it is
+// set. Clicking a row opens its combo for editing when the source writes it
+// as a plain string; one built at run time (a loop) says where to change it.
+// Source shows the files that hold binds, Clashes the combos bound twice.
+Column {
+ id: page
+
+ required property QtObject mod
+ property string tab: "list"
+ property string query: ""
+ // The mod.rows index being edited, -1 for none.
+ property int editing: -1
+ property string draft: ""
+ property string error: ""
+ property bool busy: false
+
+ spacing: 10
+
+ readonly property var shown: {
+ const q = page.query.trim().toLowerCase();
+ const out = [];
+ page.mod.rows.forEach((r, i) => {
+ const hay = r.keys.join(" + ") + " " + r.desc + " " + (r.src ? r.src.action + " " + r.src.file : "");
+ if (q === "" || hay.toLowerCase().includes(q)) out.push({ r: r, i: i });
+ });
+ return out;
+ }
+ readonly property var clashes: H.clashes(page.mod.rows)
+ .reduce((a, g) => a.concat(g.map(r => ({ r: r, i: page.mod.rows.indexOf(r) }))), [])
+
+ // Other binds already on the combo being typed, in the same submap.
+ readonly property var draftClash: {
+ const c = H.parseCombo(page.draft);
+ const me = page.mod.rows[page.editing];
+ if (!c || !me) return [];
+ return page.mod.rows.filter(r => r !== me && r.bind.submap === me.bind.submap
+ && r.bind.modmask === c.mask && r.bind.key.toLowerCase() === c.key.toLowerCase());
+ }
+
+ // The files holding binds, each line numbered. Reads mod.rows so it
+ // recomputes when a file is reloaded.
+ readonly property var sourceFiles: {
+ const used = [];
+ for (const r of page.mod.rows) if (r.src && !used.includes(r.src.file)) used.push(r.src.file);
+ return page.mod.files.filter(f => used.includes(f)).map(f => ({
+ file: f,
+ text: (page.mod.texts[f] ?? "").replace(/\n$/, "").split("\n").map((l, i) => String(i + 1).padStart(4) + " " + l).join("\n")
+ }));
+ }
+
+ function toggle(i) {
+ page.error = "";
+ if (page.editing === i) { page.editing = -1; return; }
+ page.editing = i;
+ const r = page.mod.rows[i];
+ page.draft = r.src && r.src.key !== null ? r.src.key : "";
+ }
+
+ function save() {
+ if (page.busy || page.editing < 0) return;
+ page.busy = true;
+ page.error = "";
+ page.mod.rebind(page.mod.rows[page.editing], page.draft, err => {
+ page.busy = false;
+ page.error = err;
+ if (err === "") page.editing = -1;
+ });
+ }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ elide: Text.ElideRight
+ }
+
+ component Keycap: Rectangle {
+ property alias text: capLabel.text
+ width: capLabel.implicitWidth + 10
+ height: 22
+ radius: 4
+ color: Theme.surfaceAlt
+ Text {
+ id: capLabel
+ anchors.centerIn: parent
+ color: Theme.text
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4; bold: true }
+ }
+ }
+
+ component BindRow: Column {
+ id: br
+ required property var row
+ required property int idx
+ readonly property bool open: page.editing === br.idx
+ readonly property bool editable: br.row.src !== null && br.row.src.key !== null
+ objectName: "bind:" + br.idx
+ onOpenChanged: if (open && editable) comboField.focusIn()
+ width: page.width
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 46
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 6
+ color: rowArea.containsMouse || br.open ? Qt.alpha(Theme.accent, 0.12) : "transparent"
+ }
+
+ Row {
+ x: 8
+ width: parent.width * 0.3 - 16
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+ clip: true
+ Repeater {
+ model: br.row.keys
+ Row {
+ required property string modelData
+ required property int index
+ spacing: 4
+ Text {
+ visible: index > 0
+ anchors.verticalCenter: parent.verticalCenter
+ text: "+"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Keycap { text: modelData }
+ }
+ }
+ }
+
+ Column {
+ x: parent.width * 0.3
+ width: parent.width * 0.5 - 12
+ anchors.verticalCenter: parent.verticalCenter
+ Text {
+ width: parent.width
+ text: br.row.desc !== "" ? br.row.desc : "No description"
+ color: br.row.desc !== "" ? Theme.text : Theme.subtext
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Text {
+ width: parent.width
+ visible: br.row.src !== null
+ text: br.row.src ? br.row.src.action : ""
+ color: Theme.blue
+ elide: Text.ElideRight
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+
+ Small {
+ x: parent.width * 0.8
+ width: parent.width * 0.2 - 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: br.row.src ? br.row.src.file.replace(/^sections\//, "") + ":" + br.row.src.line : "outside the config"
+ }
+
+ MouseArea {
+ id: rowArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: page.toggle(br.idx)
+ }
+ }
+
+ Column {
+ visible: br.open
+ x: 8
+ width: parent.width - 16
+ spacing: 6
+ bottomPadding: 8
+
+ Small {
+ width: parent.width
+ visible: !br.editable
+ wrapMode: Text.Wrap
+ text: br.row.src
+ ? "Built at run time, " + br.row.src.keyExpr + " in " + br.row.src.file + " line " + br.row.src.line + ". Change it there."
+ : "No hl.bind call in the config files made this one: a plugin or hyprctl did."
+ }
+
+ Item {
+ visible: br.editable
+ width: parent.width
+ height: 32
+ Field {
+ id: comboField
+ anchors { left: parent.left; right: saveBtn.left; rightMargin: 8 }
+ mono: true
+ text: page.draft
+ placeholder: "SUPER + SHIFT + q"
+ onEdited: t => page.draft = t
+ onAccepted: page.save()
+ }
+ Button {
+ id: saveBtn
+ anchors { right: cancelBtn.left; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ text: page.busy ? "Checking…" : "Save"
+ enabled: !page.busy && page.draft.trim() !== "" && page.draft.trim() !== br.row.src?.key
+ onClicked: page.save()
+ }
+ Button {
+ id: cancelBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Cancel"
+ onClicked: page.toggle(br.idx)
+ }
+ }
+
+ Small {
+ width: parent.width
+ visible: br.editable && page.draftClash.length > 0
+ wrapMode: Text.Wrap
+ color: Theme.yellow
+ text: "Already bound: " + page.draftClash.map(r => r.desc || r.src?.action || "?").join(", ") + ". Saving keeps both."
+ }
+ Small {
+ width: parent.width
+ visible: br.editable && page.error !== ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ text: page.error
+ }
+ Small {
+ width: parent.width
+ visible: br.editable
+ wrapMode: Text.Wrap
+ text: "Hyprland checks the combo first. Saving rewrites line " + (br.row.src?.line ?? "") + " of "
+ + (br.row.src?.file ?? "") + ", and Hyprland reloads."
+ }
+ }
+ }
+
+ Small {
+ width: parent.width
+ text: page.mod.rows.length + " bindings, live from Hyprland."
+ }
+
+ Item {
+ width: parent.width
+ height: 32
+ Row {
+ id: tabs
+ spacing: 6
+ anchors.verticalCenter: parent.verticalCenter
+ Chip { text: "List"; selected: page.tab === "list"; onClicked: page.tab = "list" }
+ Chip { text: "Source"; selected: page.tab === "source"; onClicked: page.tab = "source" }
+ Chip {
+ text: "Clashes · " + H.clashes(page.mod.rows).length
+ selected: page.tab === "clashes"
+ onClicked: page.tab = "clashes"
+ }
+ }
+ Field {
+ visible: page.tab === "list"
+ anchors { left: tabs.right; leftMargin: 12; right: parent.right }
+ text: page.query
+ placeholder: "Search keys, actions and files"
+ onEdited: t => page.query = t
+ }
+ }
+
+ Item {
+ visible: page.tab !== "source"
+ width: parent.width
+ height: 20
+ Small { x: 8; text: "Keys" }
+ Small { x: parent.width * 0.3; text: "Does" }
+ Small { x: parent.width * 0.8; text: "Set at" }
+ }
+
+ Repeater {
+ model: page.tab === "list" ? page.shown : page.tab === "clashes" ? page.clashes : []
+ BindRow {
+ required property var modelData
+ row: modelData.r
+ idx: modelData.i
+ }
+ }
+
+ Small {
+ width: parent.width
+ visible: page.tab === "clashes" && page.clashes.length === 0
+ text: "No combo is bound twice."
+ }
+
+ Repeater {
+ model: page.tab === "source" ? page.sourceFiles : []
+ Column {
+ required property var modelData
+ width: page.width
+ spacing: 4
+ Text {
+ text: modelData.file
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ }
+ TextEdit {
+ width: parent.width
+ readOnly: true
+ selectByMouse: true
+ textFormat: TextEdit.PlainText
+ wrapMode: TextEdit.NoWrap
+ clip: true
+ text: modelData.text
+ color: Theme.text
+ selectionColor: Qt.alpha(Theme.accent, 0.4)
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+ }
+}