277 lines
9.7 KiB
QML
277 lines
9.7 KiB
QML
// 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 recognizes; 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;
|
||
}
|
||
}
|