// The two-step flow behind "+ Add shortcut". // // Step one records the chord, because a shortcut whose keys are already taken // is not worth choosing an action for -- the conflict is reported here, before // anything is stored. Step two picks what it does, from three fixed // vocabularies: an installed application, one of the shell's own actions, or // one of the compositor's window verbs. // // Nothing typed here becomes a command. The editor reports an enum kind and a // target that Keybinds.describeAction() already recognises; hypr/actions.lua // resolves that to something runnable through whitelist tables of its own. That // is the whole reason there is no "run this command" option: settings.json has // to stay a file it is safe to hand somebody. import QtQuick import Quickshell import qs.config import qs.services import qs.modules.clipboard Column { id: root // { chord, kind, target, label } signal committed(string chord, string kind, string target, string label) signal canceled property string chord: "" property string kind: "app" property string target: "" property string targetLabel: "" // The action already holding a chord somebody just pressed, and the chord // itself, so the refusal can name both. property string conflict: "" property string conflictChord: "" readonly property bool capturing: root.chord === "" readonly property bool complete: root.chord !== "" && root.target !== "" && root.targetLabel !== "" function reset(): void { root.chord = ""; root.kind = "app"; root.target = ""; root.targetLabel = ""; root.conflict = ""; root.conflictChord = ""; appSearch.text = ""; } width: parent ? parent.width : 620 spacing: 0 SectionLabel { text: root.capturing ? "New shortcut · press the keys" : "New shortcut · pick what it does" count: root.chord === "" ? "" : root.chord } // ── Step one: the chord ───────────────────────────────────────────────── Item { width: parent.width height: root.capturing ? 56 : 0 visible: root.capturing clip: true ShortcutCapture { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 300 height: 32 focus: root.visible && root.capturing message: root.conflict === "" ? "" : root.conflictChord + " is already " + root.conflict onCaptured: chord => { const taken = Keybinds.boundTo(chord, ""); if (taken !== "") { root.conflict = taken; root.conflictChord = chord; return; } if (Keybinds.isCustomChord(chord)) { root.conflict = "one of your own shortcuts"; root.conflictChord = chord; return; } root.conflict = ""; root.chord = chord; } onCanceled: root.canceled() } Text { anchors.left: parent.left anchors.leftMargin: 312 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: "A modifier is required. Esc cancels. A chord another action holds is reported, never taken." color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } } // ── Step two: the action ──────────────────────────────────────────────── Column { width: parent.width visible: !root.capturing spacing: 0 ChoiceGrid { width: parent.width label: "What it does" detail: "Three kinds, and only three: Panama resolves the name you pick when you press the keys, so no shortcut ever stores a command." current: root.kind options: [ { value: "app", label: "Launch an application" }, { value: "shell", label: "Shell action" }, { value: "window", label: "Window & workspace" } ] onPicked: value => { root.kind = String(value); root.target = ""; root.targetLabel = ""; } } // Applications are searched rather than listed: a normal machine has // several hundred desktop entries, and a flow of pills for all of them // is a page nobody can read. Column { width: parent.width visible: root.kind === "app" spacing: 0 SearchField { id: appSearch width: parent.width placeholder: "Search installed applications" } Repeater { model: root.appMatches SettingRow { id: candidate required property var modelData required property int index label: String(candidate.modelData.name ?? "") detail: String(candidate.modelData.id ?? "") value: candidate.modelData.id === root.target ? "Chosen" : "" controlWidth: 96 divider: candidate.index < root.appMatches.length - 1 activatable: true onActivated: { root.target = String(candidate.modelData.id ?? ""); root.targetLabel = "Launch " + String(candidate.modelData.name ?? ""); } } } Text { width: parent.width visible: root.appMatches.length === 0 text: appSearch.text.trim() === "" ? "Type to find an application." : "Nothing installed matches that." color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall topPadding: 10 bottomPadding: 10 } } ChoiceGrid { width: parent.width visible: root.kind === "shell" label: "Shell action" detail: "The shell's own surfaces, each one something Panama already answers over IPC." current: root.target options: root.shellOptions divider: false onPicked: value => { root.target = String(value); root.targetLabel = Keybinds.shellActionLabel(root.target); } } ChoiceGrid { width: parent.width visible: root.kind === "window" label: "Window & workspace" detail: "Compositor verbs: the first three act on the focused window, the rest go to a workspace." current: root.target options: root.windowOptions divider: false onPicked: value => { root.target = String(value); root.targetLabel = Keybinds.windowActionLabel(root.target); } } Item { width: parent.width height: 50 Row { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { tone: "accent" text: "Add it" enabled: root.complete && !Keybinds.reloading onClicked: root.committed(root.chord, root.kind, root.target, root.targetLabel) } SettingsButton { text: "Cancel" onClicked: root.canceled() } } Text { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: parent.width - 200 horizontalAlignment: Text.AlignRight text: root.complete ? root.targetLabel + " · saves, then the compositor reloads — same as rebinding" : "Pick what the shortcut does." color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } } // ── The vocabularies ──────────────────────────────────────────────────── // Shell and window targets come from Keybinds, which is the single QML // authority on what hypr/actions.lua will resolve. Listing them here would // be a second opinion. readonly property var shellOptions: Keybinds.shellActions.map(action => ({ value: action.target, label: action.label })) readonly property var windowOptions: Keybinds.windowActions.map(action => ({ value: action.target, label: action.label })) readonly property var appMatches: { const needle = appSearch.text.trim().toLowerCase(); if (needle === "") return []; const out = []; for (const entry of DesktopEntries.applications.values) { if (entry.noDisplay) continue; if (String(entry.name).toLowerCase().indexOf(needle) >= 0) out.push(entry); if (out.length >= 8) break; } return out; } }