pragma ComponentBehavior: Bound import QtQuick import Quickshell.Wayland import qs.Common import qs.Services import qs.Widgets import qs.Modules.Settings.Widgets import "../../Common/KeyUtils.js" as KeyUtils import "../../Common/KeybindActions.js" as Actions DankDialog { id: root readonly property var log: Log.scoped("KeybindEditorDialog") property var bindData: ({}) property bool isNew: false property bool readOnly: false property bool locked: false property bool saveBlocked: false property bool busy: false property var panelWindow: null property alias topContent: topSlot.data property bool recording: false property int editingKeyIndex: -1 property bool addingNewKey: false property string editKey: "" property string editAction: "" property string editDesc: "" property int editCooldownMs: 0 property string editFlags: "" property bool editAllowWhenLocked: false property var editRepeat: undefined property var editAllowInhibiting: undefined property bool hasChanges: false property bool useCustomCompositor: false property bool _altShiftGhost: false readonly property var keys: bindData.keys || [] readonly property var editingKey: keys[editingKeyIndex] ?? null readonly property string originalKey: editingKey?.key ?? "" readonly property string actionType: Actions.getActionType(editAction) readonly property string actionLabel: KeybindsService.getActionLabel(editAction) || I18n.tr("Select", "verb, dropdown placeholder or option that opens a picker") + "…" readonly property var configConflict: bindData.conflict || null readonly property var conflicts: editKey ? KeyUtils.getConflictingBinds(editKey, bindData.action, KeybindsService.getFlatBinds(), KeybindsService.modKey, KeybindsService.modSymbol) : [] readonly property string editKeysym: KeyUtils.keyFromToken(editKey) readonly property bool keysymUnreachable: KeyUtils.keysymUnreachable(editKeysym, KeybindsService.firstLevelKeymap) readonly property bool canReset: !isNew && !readOnly && editingKey?.isOverride === true && editingKey?.hasDefault === true readonly property bool canSave: !readOnly && !saveBlocked && editKey !== "" && Actions.isValidAction(editAction) readonly property bool canSubmit: canSave && (isNew || hasChanges) && !busy readonly property bool formEnabled: !locked && !readOnly readonly property bool wideForm: actionWidth + padding * 2 >= maximumWidth readonly property var argConfig: Actions.getActionArgConfig(KeybindsService.currentProvider, editAction) readonly property var dmsParsedArgs: argConfig?.type === "dms" ? Actions.parseDmsActionArgs(editAction) : null readonly property var dmsArgDefs: dmsParsedArgs?.base ? (Actions.getDmsActionArgs()[dmsParsedArgs.base]?.args ?? []) : [] readonly property var dmsFlagArgs: dmsArgDefs.filter(arg => arg.type === "flag") readonly property bool showDmsArgs: actionType === "dms" && argConfig?.type === "dms" readonly property bool showCompositorPicker: actionType === "compositor" && !useCustomCompositor readonly property var compositorParsedArgs: Actions.parseCompositorActionArgs(KeybindsService.currentProvider, editAction) readonly property var compositorArgDefs: argConfig?.config?.args ?? [] readonly property var compositorFlags: compositorFlagOptions() readonly property var bindOptionList: bindOptions() readonly property string customCategory: I18n.tr("Custom") readonly property string repeatTooltip: I18n.tr("Repeats while the key is held", "keybind option tooltip") readonly property string lockedTooltip: I18n.tr("Also works on the lock screen", "keybind option tooltip") readonly property string pointerTooltip: I18n.tr("Includes the mouse pointer", "keybind screenshot option tooltip") readonly property string saveTooltip: I18n.tr("Also saves the screenshot to disk", "keybind screenshot option tooltip") readonly property var typeTooltips: ({ "dms": I18n.tr("DMS shell actions (launcher, clipboard, etc.)"), "compositor": I18n.tr("Compositor actions (focus, move, etc.)", "keybind action type tooltip"), "spawn": I18n.tr("Run a program (e.g., firefox, kitty)"), "shell": I18n.tr("Run a shell command (e.g., notify-send)") }) signal saveRequested(string originalKey, var data) signal resetRequested(string key) signal editChanged embedded: false opened: false maximumWidth: SettingsMetrics.formDialogWidth surfaceColor: Theme.hostSurface title: isNew ? I18n.tr("Add shortcut") : I18n.tr("Edit shortcut", "keybind editor dialog title") closeEnabled: !busy acceptEnabled: canSubmit onAccepted: save() function present(bind, keyIndex, newBind, retained) { // Needed to tell a key the layout cannot reach from one it can, and // the layout may have changed since the editor was last open. KeybindsService.loadFirstLevelKeymap(); recording = false; bindData = bind; isNew = newBind; loadKey(keyIndex); if (retained) restoreRetainedEdit(retained); opened = true; forceActiveFocus(); } function loadKey(index) { const key = keys[index] ?? null; addingNewKey = false; editingKeyIndex = key ? index : -1; editKey = key?.key ?? ""; editAction = bindData.action || ""; editDesc = key?.desc || bindData.desc || ""; editCooldownMs = key?.cooldownMs || 0; editFlags = key?.flags || ""; editAllowWhenLocked = key?.allowWhenLocked || false; editRepeat = key?.repeat; editAllowInhibiting = key?.allowInhibiting; useCustomCompositor = actionType === "compositor" && editAction !== "" && !KeybindsService.isKnownCompositorAction(editAction); hasChanges = false; editChanged(); } function restoreRetainedEdit(retained) { editingKeyIndex = keys.findIndex(key => key.key === retained.originalKey); addingNewKey = !retained.originalKey; updateEdit(retained.data); } function startAddingNewKey() { if (readOnly) { KeybindsService.showHyprlandReadOnlyWarning(); return; } addingNewKey = true; editingKeyIndex = -1; editKey = ""; hasChanges = true; editChanged(); } function changedFrom(key) { if (editKey !== (key?.key ?? "")) return true; if (editAction !== (bindData.action || "")) return true; if (editDesc !== (key?.desc || bindData.desc || "")) return true; if (editCooldownMs !== (key?.cooldownMs || 0)) return true; if (editFlags !== (key?.flags || "")) return true; if (editAllowWhenLocked !== (key?.allowWhenLocked || false)) return true; return editRepeat !== key?.repeat || editAllowInhibiting !== key?.allowInhibiting; } function updateEdit(changes) { if (readOnly) return; if (changes.key !== undefined) editKey = changes.key; if (changes.action !== undefined) editAction = changes.action; if (changes.desc !== undefined) editDesc = changes.desc; if (changes.cooldownMs !== undefined) editCooldownMs = changes.cooldownMs; if (changes.flags !== undefined) editFlags = changes.flags; if (changes.allowWhenLocked !== undefined) editAllowWhenLocked = changes.allowWhenLocked; if (changes.repeat !== undefined) editRepeat = changes.repeat; if (changes.allowInhibiting !== undefined) editAllowInhibiting = changes.allowInhibiting; hasChanges = addingNewKey || changedFrom(editingKey); editChanged(); } function selectType(type) { if (readOnly || type === actionType) return; switch (type) { case "dms": updateEdit({ "action": KeybindsService.dmsActions[0].id, "desc": KeybindsService.dmsActions[0].label }); return; case "compositor": useCustomCompositor = false; updateEdit({ "action": "close-window", "desc": "Close Window" }); return; case "spawn": updateEdit({ "action": "spawn ", "desc": "" }); return; case "shell": updateEdit({ "action": "spawn sh -c \"\"", "desc": "" }); return; } } function typeLabel(type) { switch (type) { case "dms": return "DMS"; case "compositor": return I18n.tr("Compositor"); case "spawn": return I18n.tr("Command"); case "shell": return I18n.tr("Shell", "noun, keybind editor field label for a shell command"); } return type; } function selectCompositorCategory(category) { if (category === customCategory) { useCustomCompositor = true; return; } if (!useCustomCompositor) return; useCustomCompositor = false; const first = KeybindsService.getCompositorActions(category)[0]; if (!first) return; updateEdit({ "action": first.id, "desc": first.label }); } function save() { if (readOnly) { KeybindsService.showHyprlandReadOnlyWarning(); return; } if (!canSubmit) return; recording = false; saveRequested(addingNewKey ? "" : originalKey, { "key": editKey, "action": editAction, "desc": editDesc, "cooldownMs": editCooldownMs, "flags": editFlags, "allowWhenLocked": editAllowWhenLocked, "repeat": editRepeat, "allowInhibiting": editAllowInhibiting }); } function startRecording() { if (readOnly) { KeybindsService.showHyprlandReadOnlyWarning(); return; } recording = true; } function stopRecording() { recording = false; } function captureKey(event) { switch (event.key) { case Qt.Key_Control: case Qt.Key_Shift: case Qt.Key_Alt: case Qt.Key_Meta: // lock keys are toggles; NumLock picks the numpad keysym (KP_7 vs KP_Home) and must not become the bind case Qt.Key_NumLock: case Qt.Key_CapsLock: case Qt.Key_ScrollLock: return; } if (event.key === 0 && (event.modifiers & Qt.AltModifier)) { _altShiftGhost = true; return; } let mods = KeyUtils.modsFromEvent(event.modifiers); let qtKey = event.key; if (_altShiftGhost && (event.modifiers & Qt.AltModifier) && !mods.includes("Shift")) mods.push("Shift"); _altShiftGhost = false; if (qtKey === Qt.Key_Backtab) { qtKey = Qt.Key_Tab; if (!mods.includes("Shift")) mods.push("Shift"); } const hasShift = mods.includes("Shift"); mods = KeyUtils.withSymbolicMod(mods, KeybindsService.modKey, KeybindsService.modSymbol); const key = KeyUtils.xkbKeyFromQtKey(qtKey, !!(event.modifiers & Qt.KeypadModifier), hasShift, event.nativeScanCode); if (!key) { log.warn("Unknown key:", event.key, "mods:", event.modifiers); return; } updateEdit({ "key": KeyUtils.formatToken(mods, key) }); stopRecording(); } function captureWheel(wheel) { let mods = []; if (wheel.modifiers & Qt.ControlModifier) mods.push("Ctrl"); if (wheel.modifiers & Qt.ShiftModifier) mods.push("Shift"); if (wheel.modifiers & Qt.AltModifier) mods.push("Alt"); if (wheel.modifiers & Qt.MetaModifier) mods.push("Super"); mods = KeyUtils.withSymbolicMod(mods, KeybindsService.modKey, KeybindsService.modSymbol); const wheelKey = wheelKeyName(wheel.angleDelta); if (!wheelKey) return; updateEdit({ "key": KeyUtils.formatToken(mods, wheelKey) }); stopRecording(); } function wheelKeyName(delta) { if (delta.y > 0) return "WheelScrollUp"; if (delta.y < 0) return "WheelScrollDown"; if (delta.x > 0) return "WheelScrollRight"; if (delta.x < 0) return "WheelScrollLeft"; return ""; } function setDmsArgs(changes) { if (!dmsParsedArgs) return; const args = Object.assign({}, dmsParsedArgs.args, changes); updateEdit({ "action": Actions.buildDmsAction(dmsParsedArgs.base, args) }); } function commitDmsAmount(text) { if (!dmsParsedArgs) return; const oldAction = editAction; const args = Object.assign({}, dmsParsedArgs.args, { "amount": text || "5" }); const newAction = Actions.buildDmsAction(dmsParsedArgs.base, args); const changes = { "action": newAction }; if (editDesc === "" || editDesc === KeybindsService.getActionLabel(oldAction)) changes.desc = KeybindsService.getActionLabel(newAction); updateEdit(changes); } function dashTabLabel(tab) { switch (tab) { case "media": return I18n.tr("Media"); case "wallpaper": return I18n.tr("Wallpaper"); case "weather": return I18n.tr("Weather"); default: return I18n.tr("Overview"); } } function dashTabValue(label) { switch (label) { case I18n.tr("Media"): return "media"; case I18n.tr("Wallpaper"): return "wallpaper"; case I18n.tr("Weather"): return "weather"; default: return ""; } } function dmsFlagLabel(name) { switch (name) { case "no-file": return I18n.tr("Save"); case "no-clipboard": return I18n.tr("Clipboard"); case "cursor": return I18n.tr("Pointer"); default: return name; } } function dmsFlagTooltip(name) { switch (name) { case "no-file": return saveTooltip; case "no-clipboard": return I18n.tr("Copies the screenshot to the clipboard", "keybind screenshot option tooltip"); case "cursor": return pointerTooltip; default: return ""; } } function dmsFlagChecked(flag) { const set = dmsParsedArgs?.args[flag.name] === true; return flag.inverted ? !set : set; } function setDmsFlag(flag, checked) { const changes = {}; changes[flag.name] = flag.inverted ? !checked : checked; setDmsArgs(changes); } function compositorCategory() { const base = editAction.split(" ")[0]; const categories = KeybindsService.getCompositorCategories(); for (const category of categories) { if (KeybindsService.getCompositorActions(category).some(action => action.id === base)) return category; } return categories[0] || "Window"; } function showArgEditor(index, argDef) { if (!argDef) return false; if (argDef.type !== "text" && argDef.type !== "number") return false; if (KeybindsService.currentProvider === "mangowc") return true; return index === 0; } function displayArgValue(argDef) { if (!argDef || !compositorParsedArgs?.args) return ""; const value = compositorParsedArgs.args[argDef.name]; if (value === undefined || value === null) return ""; if (argDef.emptyValue !== undefined && value === argDef.emptyValue) return ""; return String(value); } function commitArgValue(argDef, textValue) { if (!argDef || !argConfig) return; const args = Object.assign({}, compositorParsedArgs?.args || {}); args[argDef.name] = textValue; updateEdit({ "action": Actions.buildCompositorAction(KeybindsService.currentProvider, compositorParsedArgs?.base || argConfig.base, args) }); } function compositorFlagOptions() { const base = argConfig?.base ?? ""; switch (base) { case "move-column-to-workspace": case "move-column-to-workspace-down": case "move-column-to-workspace-up": return [ { "label": I18n.tr("Follow focus"), "value": "focus", "tooltip": I18n.tr("Moves focus along with the column", "keybind option tooltip, niri move column to workspace") } ]; case "quit": return [ { "label": I18n.tr("Skip confirmation"), "value": "skip-confirmation" } ]; } if (!base.startsWith("screenshot")) return []; const options = []; if (base !== "screenshot-window") options.push({ "label": I18n.tr("Pointer", "noun, mouse cursor, keybind screenshot option to include it"), "value": "show-pointer", "tooltip": pointerTooltip }); if (base !== "screenshot") options.push({ "label": I18n.tr("Save"), "value": "write-to-disk", "tooltip": saveTooltip }); return options; } function compositorFlagSelected() { const args = compositorParsedArgs?.args ?? {}; return compositorFlags.filter(flag => { if (flag.value === "focus") return args.focus !== false; return args[flag.value] === true; }).map(flag => flag.value); } function setCompositorFlag(name, checked) { const base = argConfig?.base; if (!base) return; updateEdit({ "action": compositorFlagAction(base, name, checked) }); } function compositorFlagAction(base, name, checked) { const provider = KeybindsService.currentProvider; if (name === "skip-confirmation") return Actions.buildCompositorAction(provider, "quit", checked ? { "skip-confirmation": true } : {}); if (name === "focus") { const focusArgs = {}; if (base === "move-column-to-workspace") focusArgs.index = compositorParsedArgs?.args?.index || ""; if (!checked) focusArgs.focus = false; return Actions.buildCompositorAction(provider, base, focusArgs); } const args = Object.assign({}, compositorParsedArgs?.args || {}); args[name] = checked; return Actions.buildCompositorAction(provider, compositorParsedArgs?.base || base, args); } function bindOptions() { switch (KeybindsService.currentProvider) { case "niri": return [ { "label": I18n.tr("Repeat"), "value": "repeat", "tooltip": repeatTooltip }, { "label": I18n.tr("When locked"), "value": "locked", "tooltip": lockedTooltip }, { "label": I18n.tr("Inhibitable", "adjective, niri keybind option, apps may inhibit this shortcut"), "value": "inhibit", "tooltip": I18n.tr("Apps that capture the keyboard can block it", "keybind option tooltip, niri allow inhibiting") } ]; case "hyprland": return [ { "label": I18n.tr("Repeat", "noun, keybind option, action repeats while key is held"), "value": "e", "tooltip": repeatTooltip }, { "label": I18n.tr("Locked", "adjective, hyprland bind flag toggle, bind works on lock screen"), "value": "l", "tooltip": lockedTooltip }, { "label": I18n.tr("Release", "noun, hyprland bind flag toggle, trigger on key release"), "value": "r", "tooltip": I18n.tr("Triggers when the key is released", "keybind option tooltip, hyprland release flag") }, { "label": I18n.tr("Long press"), "value": "o", "tooltip": I18n.tr("Triggers after holding the key", "keybind option tooltip, hyprland long press flag") } ]; } return []; } function selectedBindOptions() { if (KeybindsService.currentProvider === "hyprland") return editFlags.split(""); const selected = []; if (editRepeat !== false) selected.push("repeat"); if (editAllowWhenLocked) selected.push("locked"); if (editAllowInhibiting !== false) selected.push("inhibit"); return selected; } function setBindOption(value, checked) { switch (value) { case "repeat": updateEdit({ "repeat": checked ? undefined : false }); return; case "locked": updateEdit({ "allowWhenLocked": checked }); return; case "inhibit": updateEdit({ "allowInhibiting": checked ? undefined : false }); return; } const flags = editFlags.split("").filter(flag => flag !== value); if (checked) flags.push(value); updateEdit({ "flags": flags.join("") }); } ShortcutInhibitor { window: root.panelWindow enabled: root.recording } Binding { target: keyChips property: "currentIndex" value: root.addingNewKey ? -1 : root.editingKeyIndex } Binding { target: dmsActionDropdown property: "currentValue" value: root.actionLabel } Binding { target: compositorCategoryDropdown property: "currentValue" value: root.useCustomCompositor ? root.customCategory : root.compositorCategory() } Binding { target: compositorActionDropdown property: "currentValue" value: root.actionLabel } Binding { target: dashTabDropdown property: "currentValue" value: root.dashTabLabel(root.dmsParsedArgs?.args?.tab || "") } actions: [ DankButton { text: I18n.tr("Reset to default") visible: root.canReset enabled: !root.busy && !root.locked backgroundColor: "transparent" textColor: Theme.primary onClicked: root.resetRequested(root.originalKey) }, DankButton { text: I18n.tr("Cancel") enabled: root.closeEnabled backgroundColor: "transparent" textColor: Theme.primary onClicked: root.rejected() }, DankButton { text: root.isNew ? I18n.tr("Add") : I18n.tr("Save") visible: !root.readOnly enabled: root.canSubmit busy: root.busy onClicked: root.save() } ] Column { id: topSlot width: parent.width spacing: root.contentSpacing } SettingsGroup { visible: root.configConflict !== null SettingsRow { iconName: "warning" title: I18n.tr("This bind is overridden by config.kdl") subtitle: I18n.tr("Config action: %1", "keybind conflict notice, %1 is the action from the compositor config").arg(root.configConflict?.action ?? "") + "\n" + I18n.tr("To use this DMS bind, remove or change the keybind in your config.kdl") } } SettingsCard { title: I18n.tr("Key", "noun, keybind editor row label for the key combination") enabled: root.formEnabled headerActions: DankButton { visible: !root.isNew && !root.readOnly && !root.addingNewKey text: I18n.tr("New key") iconName: "add" buttonHeight: Theme.buttonHeightXS backgroundColor: "transparent" textColor: Theme.primary onClicked: root.startAddingNewKey() } SettingsRow { visible: !root.isNew && (root.keys.length > 1 || root.addingNewKey) body: DankFilterChips { id: keyChips width: parent.width showCounts: false model: root.keys.map(key => ({ "label": key.key })) onSelectionChanged: index => root.loadKey(index) } } SettingsRow { body: Column { width: parent.width spacing: Theme.spacingS FocusScope { id: captureScope readonly property bool rootRecording: root.recording width: parent.width height: Theme.iconButtonSize Accessible.role: Accessible.Button Accessible.name: I18n.tr("Key", "noun, keybind editor row label for the key combination") Accessible.description: captureHint.visible ? captureHint.text : root.editKey Accessible.onPressAction: root.startRecording() onRootRecordingChanged: { if (rootRecording) forceActiveFocus(); } onActiveFocusChanged: { if (!activeFocus) root.stopRecording(); } Keys.onPressed: event => { if (!root.recording) return; event.accepted = true; root.captureKey(event); } Rectangle { anchors.fill: parent radius: Theme.cornerRadiusXS color: "transparent" border.color: root.recording ? Theme.primary : Theme.outline border.width: root.recording ? Theme.outlineWidthFocused : Theme.outlineWidth } MouseArea { anchors.fill: parent cursorShape: root.recording ? Qt.CrossCursor : Qt.PointingHandCursor onClicked: { if (!root.recording) root.startRecording(); } onWheel: wheel => { if (!root.recording) { wheel.accepted = false; return; } wheel.accepted = true; root.captureWheel(wheel); } } Row { anchors.left: parent.left anchors.leftMargin: Theme.spacingM anchors.right: recordButton.left anchors.rightMargin: Theme.spacingS anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingM DankIcon { name: "keyboard" size: Theme.iconSize color: Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter } DankKeycap { text: root.editKey visible: root.editKey !== "" && !root.recording anchors.verticalCenter: parent.verticalCenter } StyledText { id: captureHint text: root.recording ? I18n.tr("Press key...") : I18n.tr("Click to capture") font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceVariantText visible: root.editKey === "" || root.recording anchors.verticalCenter: parent.verticalCenter } } DankActionButton { id: recordButton anchors.right: parent.right anchors.rightMargin: Theme.spacingXS anchors.verticalCenter: parent.verticalCenter focusPolicy: Qt.TabFocus iconName: root.recording ? "close" : "radio_button_checked" iconColor: root.recording ? Theme.error : Theme.primary tooltipText: root.recording ? I18n.tr("Stop") : I18n.tr("Record", "verb, tooltip on button that captures a key combination") onClicked: root.recording ? root.stopRecording() : root.startRecording() } } Row { width: parent.width spacing: Theme.spacingS visible: root.conflicts.length > 0 DankIcon { id: conflictIcon name: "warning" size: Theme.iconSizeSmall color: Theme.primary } StyledText { width: parent.width - conflictIcon.width - parent.spacing text: I18n.tr("Conflicts with: %1", "keybind warning, %1 is a list of conflicting bind descriptions").arg(root.conflicts.map(bind => bind.desc).join(", ")) font.pixelSize: Theme.fontSizeSmall color: Theme.primary wrapMode: Text.WordWrap horizontalAlignment: Text.AlignLeft } } Row { width: parent.width spacing: Theme.spacingS visible: root.keysymUnreachable DankIcon { id: unreachableIcon name: "warning" size: Theme.iconSizeSmall color: Theme.error } StyledText { width: parent.width - unreachableIcon.width - parent.spacing text: I18n.tr("Your keyboard layout puts %1 above the first level, so this bind will never fire. Pick a key the layout types without an extra layer.", "keybind warning, %1 is a key name such as bracketleft").arg(root.editKeysym) font.pixelSize: Theme.fontSizeSmall color: Theme.error wrapMode: Text.WordWrap horizontalAlignment: Text.AlignLeft } } } } } SettingsCard { title: I18n.tr("Action") enabled: root.formEnabled SettingsRow { body: DankButtonGroup { width: parent.width fillWidth: true maximumWidth: width buttonPadding: Theme.spacingM checkEnabled: false iconOnly: !root.wideForm model: KeybindsService.actionTypes.map(type => ({ "text": root.typeLabel(type.id), "icon": root.wideForm ? "" : type.icon, "tooltip": root.typeTooltips[type.id] })) currentIndex: KeybindsService.actionTypes.findIndex(type => type.id === root.actionType) onSelectionChanged: (index, selected) => { if (selected) root.selectType(KeybindsService.actionTypes[index].id); } } } SettingsRow { visible: root.actionType === "dms" body: DankDropdown { id: dmsActionDropdown width: parent.width compactMode: true enableFuzzySearch: true options: KeybindsService.getDmsActions().map(action => action.label) onValueChanged: value => { const action = KeybindsService.getDmsActions().find(entry => entry.label === value); if (!action) return; root.updateEdit({ "action": action.id, "desc": KeybindsService.getActionLabel(action.id) }); } } } SettingsRow { visible: root.actionType === "compositor" body: Row { width: parent.width spacing: Theme.spacingS DankDropdown { id: compositorCategoryDropdown width: Math.round((parent.width - parent.spacing) / 3) compactMode: true options: KeybindsService.getCompositorCategories().concat([root.customCategory]) onValueChanged: value => root.selectCompositorCategory(value) } DankDropdown { id: compositorActionDropdown visible: !root.useCustomCompositor width: parent.width - compositorCategoryDropdown.width - parent.spacing compactMode: true enableFuzzySearch: true options: KeybindsService.getCompositorActions(compositorCategoryDropdown.currentValue).map(action => action.label) onValueChanged: value => { const action = KeybindsService.getCompositorActions(compositorCategoryDropdown.currentValue).find(entry => entry.label === value); if (!action) return; root.updateEdit({ "action": action.id, "desc": action.label }); } } DankTextField { visible: root.useCustomCompositor width: parent.width - compositorCategoryDropdown.width - parent.spacing outlined: true placeholderText: KeybindsService.currentProvider === "hyprland" ? I18n.tr("e.g., hl.dsp.focus({ workspace = \"3\" })") : I18n.tr("e.g., focus-workspace 3, resize-column -10") text: root.actionType === "compositor" ? root.editAction : "" onTextChanged: { if (root.actionType !== "compositor") return; root.updateEdit({ "action": text }); } } } } SettingsRow { visible: root.actionType === "spawn" body: DankTextField { readonly property var parsedCommand: root.actionType === "spawn" ? Actions.parseSpawnCommand(root.editAction) : null width: parent.width outlined: true leftIconName: "terminal" labelText: I18n.tr("Command") placeholderText: I18n.tr("e.g., firefox, kitty --title foo") text: parsedCommand ? (parsedCommand.command + " " + parsedCommand.args.join(" ")).trim() : "" onTextChanged: { if (root.actionType !== "spawn") return; const parts = text.trim().split(" ").filter(part => part); root.updateEdit({ "action": "spawn " + parts.join(" ") }); } } } SettingsRow { visible: root.actionType === "shell" body: DankTextField { width: parent.width outlined: true leftIconName: "terminal" labelText: I18n.tr("Shell", "noun, keybind editor field label for a shell command") placeholderText: I18n.tr("e.g., notify-send 'Hello' && sleep 1") text: root.actionType === "shell" ? Actions.parseShellCommand(root.editAction) : "" onTextChanged: { if (root.actionType !== "shell") return; root.updateEdit({ "action": Actions.buildShellAction(KeybindsService.currentProvider, text, Actions.getShellFromAction(root.editAction)) }); } } } SettingsRow { visible: root.showDmsArgs && root.dmsArgDefs.some(arg => arg.name === "amount") body: DankTextField { id: amountField readonly property string amount: root.dmsParsedArgs?.args?.amount || "" width: parent.width outlined: true leftIconName: "tune" labelText: I18n.tr("Amount", "keybind editor field, numeric amount argument of a dms action") placeholderText: "5" rightAccessoryWidth: amountUnit.width + Theme.spacingS onAmountChanged: { if (text !== amount) text = amount; } Component.onCompleted: text = amount onEditingFinished: root.commitDmsAmount(text) FieldUnit { id: amountUnit field: amountField text: "%" } } } SettingsRow { visible: root.showDmsArgs && root.dmsArgDefs.some(arg => arg.name === "device") body: DankTextField { readonly property string device: root.dmsParsedArgs?.args?.device || "" width: parent.width outlined: true leftIconName: "devices" labelText: I18n.tr("Device") placeholderText: I18n.tr("leave empty for default") onDeviceChanged: { if (text !== device) text = device; } Component.onCompleted: text = device onEditingFinished: root.setDmsArgs({ "device": text }) } } SettingsRow { visible: root.showDmsArgs && root.dmsArgDefs.some(arg => arg.name === "tab") title: I18n.tr("Tab", "noun, keybind argument label for a dashboard tab") DankDropdown { id: dashTabDropdown compactMode: true options: [I18n.tr("Overview"), I18n.tr("Media"), I18n.tr("Wallpaper"), I18n.tr("Weather")] onValueChanged: value => root.setDmsArgs({ "tab": root.dashTabValue(value) }) } } SettingsRow { visible: root.showDmsArgs && root.dmsFlagArgs.length > 0 body: DankFilterChips { width: parent.width multiSelect: true showCounts: false model: root.dmsFlagArgs.map(flag => ({ "label": root.dmsFlagLabel(flag.name), "value": flag.name, "tooltip": root.dmsFlagTooltip(flag.name) })) selectedValues: root.dmsFlagArgs.filter(flag => root.dmsFlagChecked(flag)).map(flag => flag.name) onSelectionToggled: (index, selected) => root.setDmsFlag(root.dmsFlagArgs[index], selected) } } Repeater { model: root.showCompositorPicker ? root.compositorArgDefs.length : 0 delegate: SettingsRow { id: argRow required property int index readonly property var argDef: root.compositorArgDefs[index] ?? null visible: root.showArgEditor(index, argDef) body: DankTextField { id: argField property bool syncing: false readonly property string actionValue: root.displayArgValue(argRow.argDef) function syncFromAction() { if (text === actionValue) return; syncing = true; text = actionValue; syncing = false; } width: parent.width outlined: true leftIconName: "tune" labelText: I18n.tr(argRow.argDef?.label || "", "keybind option label") placeholderText: I18n.tr(argRow.argDef?.placeholder || "", "keybind option placeholder") onActionValueChanged: syncFromAction() Component.onCompleted: syncFromAction() onTextChanged: { if (!syncing) root.commitArgValue(argRow.argDef, text); } } } } SettingsRow { visible: root.showCompositorPicker && root.compositorFlags.length > 0 body: DankFilterChips { width: parent.width multiSelect: true showCounts: false model: root.compositorFlags selectedValues: root.compositorFlagSelected() onSelectionToggled: (index, selected) => root.setCompositorFlag(root.compositorFlags[index].value, selected) } } SettingsRow { visible: KeybindsService.currentProvider !== "aqueous" body: DankTextField { width: parent.width outlined: true leftIconName: "title" labelText: I18n.tr("Title") placeholderText: I18n.tr("Hotkey overlay title (optional)") text: root.editDesc onTextChanged: root.updateEdit({ "desc": text }) } } } SettingsCard { title: I18n.tr("Options") enabled: root.formEnabled visible: KeybindsService.currentProvider === "niri" || KeybindsService.currentProvider === "hyprland" SettingsRow { body: DankFilterChips { width: parent.width multiSelect: true showCounts: false model: root.bindOptionList selectedValues: root.selectedBindOptions() onSelectionToggled: (index, selected) => root.setBindOption(root.bindOptionList[index].value, selected) } } SettingsRow { visible: KeybindsService.currentProvider === "niri" body: DankTextField { id: cooldownField readonly property int cooldownMs: root.editCooldownMs width: parent.width outlined: true leftIconName: "timer" labelText: I18n.tr("Cooldown", "noun, niri keybind cooldown time field label") placeholderText: "0" rightAccessoryWidth: cooldownUnit.width + Theme.spacingS onCooldownMsChanged: { const newText = cooldownMs > 0 ? String(cooldownMs) : ""; if (text !== newText) text = newText; } Component.onCompleted: text = cooldownMs > 0 ? String(cooldownMs) : "" onTextChanged: { const value = parseInt(text) || 0; if (value !== root.editCooldownMs) root.updateEdit({ "cooldownMs": value }); } FieldUnit { id: cooldownUnit field: cooldownField text: I18n.tr("ms", "milliseconds unit suffix after a number field") } } } } component FieldUnit: StyledText { required property DankTextField field anchors.right: parent.right anchors.rightMargin: field.contentPadding y: field.containerTop + (field.containerHeight - height) / 2 font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceVariantText } }