diff options
Diffstat (limited to 'desktop/modules/hyprland/BindsPage.qml')
| -rw-r--r-- | desktop/modules/hyprland/BindsPage.qml | 334 |
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 } + } + } + } +} |
