// Copyright (C) 2026 Danilo M. // // 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 } } } } }