Files
DankMaterialShell/quickshell/Modules/Settings/KeybindEditorDialog.qml
T
Mario Mohar 53b8de2714 fix(keybinds): warn when a bind's key sits above the layout's first level (#3591)
A compositor resolves a bind's keysym on the first level of the active
layout. Layouts that put punctuation higher up therefore cannot reach it
at all: de:neo has bracketleft on layer 4 and plain de on AltGr, so
Mod+bracketleft is a bind that can never fire. The editor recorded and
stored it anyway and gave no sign that anything was wrong (#3585).

`dms keybinds keymap` now reports what the seat's xkb keymap puts on the
first level of every physical key, and the editor says so instead of
letting a dead bind through.

The keymap a compositor hands out writes its keysyms as hex, so only the
range the keymap package can name back is usable for the comparison.
That is the ASCII range, which is where the problem lives, and the
command reports the vocabulary alongside the keys so the caller can tell
"the layout cannot reach this" from "we could not name it" and stay
quiet in the second case.
2026-09-29 16:57:13 -04:00

1252 lines
45 KiB
QML

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
}
}