Files
DankMaterialShell/quickshell/Common/KeyUtils.js
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

536 lines
14 KiB
JavaScript

.pragma library
// qmlformat off
const KEY_MAP = {
16777234: "Left",
16777236: "Right",
16777235: "Up",
16777237: "Down",
44: "Comma",
46: "Period",
47: "Slash",
59: "Semicolon",
39: "Apostrophe",
91: "BracketLeft",
93: "BracketRight",
92: "Backslash",
45: "Minus",
61: "Equal",
96: "grave",
32: "space",
16777225: "Print",
16777226: "Print",
16777220: "Return",
16777221: "Return",
16777217: "Tab",
16777219: "BackSpace",
16777223: "Delete",
16777222: "Insert",
16777232: "Home",
16777233: "End",
16777238: "Page_Up",
16777239: "Page_Down",
16777216: "Escape",
16777252: "Caps_Lock",
16777253: "Num_Lock",
16777254: "Scroll_Lock",
16777224: "Pause",
16777330: "XF86AudioRaiseVolume",
16777328: "XF86AudioLowerVolume",
16777329: "XF86AudioMute",
16842808: "XF86AudioMicMute",
16777344: "XF86AudioPlay",
16777345: "XF86AudioStop",
16777346: "XF86AudioPrev",
16777347: "XF86AudioNext",
16777348: "XF86AudioPause",
16777349: "XF86AudioMedia",
16777350: "XF86AudioRecord",
16842798: "XF86MonBrightnessUp",
16777394: "XF86MonBrightnessUp",
16842797: "XF86MonBrightnessDown",
16777395: "XF86MonBrightnessDown",
16842800: "XF86KbdBrightnessUp",
16842799: "XF86KbdBrightnessDown",
16842796: "XF86PowerOff",
16842803: "XF86Sleep",
16842804: "XF86WakeUp",
16842802: "XF86Eject",
16842791: "XF86Calculator",
16842806: "XF86Explorer",
16777360: "XF86HomePage",
16842794: "XF86HomePage",
16777362: "XF86Search",
16777426: "XF86Search",
16777376: "XF86Mail",
16777427: "XF86Mail",
16777377: "XF86AudioMedia",
16777419: "XF86Calculator",
16777429: "XF86Explorer",
16777442: "XF86Launch0",
16777443: "XF86Launch1",
196: "Adiaeresis",
214: "Odiaeresis",
220: "Udiaeresis",
228: "adiaeresis",
246: "odiaeresis",
252: "udiaeresis",
223: "ssharp",
201: "Eacute",
233: "eacute",
200: "Egrave",
232: "egrave",
202: "Ecircumflex",
234: "ecircumflex",
203: "Ediaeresis",
235: "ediaeresis",
192: "Agrave",
224: "agrave",
194: "Acircumflex",
226: "acircumflex",
199: "Ccedilla",
231: "ccedilla",
206: "Icircumflex",
238: "icircumflex",
207: "Idiaeresis",
239: "idiaeresis",
212: "Ocircumflex",
244: "ocircumflex",
217: "Ugrave",
249: "ugrave",
219: "Ucircumflex",
251: "ucircumflex",
209: "Ntilde",
241: "ntilde",
191: "questiondown",
161: "exclamdown"
};
// qmlformat on
// Preserve unshifted symbols from the active layout
const SYMBOL_KEYSYM = {
33: "exclam",
34: "quotedbl",
35: "numbersign",
36: "dollar",
37: "percent",
38: "ampersand",
40: "parenleft",
41: "parenright",
42: "asterisk",
43: "plus",
58: "colon",
60: "less",
62: "greater",
63: "question",
64: "at",
94: "asciicircum",
95: "underscore",
123: "braceleft",
124: "bar",
125: "braceright",
126: "asciitilde"
};
// Preserve the existing shifted-US physical-key mapping
const SHIFTED_US_FALLBACK = {
33: "1",
34: "Apostrophe",
35: "3",
36: "4",
37: "5",
38: "7",
40: "9",
41: "0",
42: "8",
43: "Equal",
58: "Semicolon",
60: "Comma",
62: "Period",
63: "Slash",
64: "2",
94: "6",
95: "Minus",
123: "BracketLeft",
124: "Backslash",
125: "BracketRight",
126: "grave"
};
// Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and
// the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and
// the other compositors bind against the xkb KP_* keysym names, so we must emit
// those instead of the collapsed twin. With NumLock off the numpad sends the
// navigation keysyms (KP_Home, KP_End, ...); with NumLock on it sends KP_0..KP_9
// (handled by the digit range in xkbKeyFromQtKey). Operators/Enter are the same
// in both states.
// qmlformat off
const KP_MAP = {
16777232: "KP_Home",
16777235: "KP_Up",
16777238: "KP_Prior",
16777234: "KP_Left",
16777227: "KP_Begin",
16777236: "KP_Right",
16777233: "KP_End",
16777237: "KP_Down",
16777239: "KP_Next",
16777222: "KP_Insert",
16777223: "KP_Delete",
16777221: "KP_Enter",
43: "KP_Add",
45: "KP_Subtract",
42: "KP_Multiply",
47: "KP_Divide",
46: "KP_Decimal"
};
// qmlformat on
// Shift+digit arrives as the layout's shifted symbol (latam Shift+0 is "="),
// but compositors match binds on the unshifted keysym, so resolve the digit
// row from the physical key instead. xkb keycodes 10..19 are evdev KEY_1..KEY_0.
function digitFromScanCode(scanCode) {
if (!(scanCode >= 10 && scanCode <= 19))
return "";
return String((scanCode - 9) % 10);
}
function xkbKeyFromQtKey(qk, isKeypad, hasShift, scanCode) {
if (isKeypad) {
if (qk >= 48 && qk <= 57)
return "KP_" + (qk - 48);
if (KP_MAP[qk])
return KP_MAP[qk];
}
if (hasShift) {
const digit = digitFromScanCode(scanCode);
if (digit)
return digit;
}
if (!hasShift && SYMBOL_KEYSYM[qk])
return SYMBOL_KEYSYM[qk];
if (hasShift && SHIFTED_US_FALLBACK[qk])
return SHIFTED_US_FALLBACK[qk];
if (qk >= 65 && qk <= 90)
return String.fromCharCode(qk);
if (qk >= 97 && qk <= 122)
return String.fromCharCode(qk - 32);
if (qk >= 48 && qk <= 57)
return String.fromCharCode(qk);
if (qk >= 16777264 && qk <= 16777298)
return "F" + (qk - 16777264 + 1);
if (qk >= 16777378 && qk <= 16777387)
return "XF86Launch" + (qk - 16777378);
if (qk >= 16777388 && qk <= 16777393)
return "XF86Launch" + String.fromCharCode(65 + qk - 16777388);
return KEY_MAP[qk] || "";
}
function keyFromToken(token) {
const parts = String(token || "").split("+");
return parts[parts.length - 1];
}
// A compositor resolves a bind's keysym on the first level of the active layout,
// so a keysym that lives only above it can never match: de:neo puts every
// bracket on layer 4, which is how "Mod+bracketleft" gets stored and then never
// fires (#3585). keymap is what `dms keybinds keymap` reports, and a key outside
// its `named` vocabulary is one we could not name, so it counts as fine.
function keysymUnreachable(key, keymap) {
if (!key || !keymap)
return false;
const vocabulary = keymap.named ?? [];
const byKeycode = keymap.keysyms ?? {};
if (vocabulary.length === 0)
return false;
const wanted = String(key).toLowerCase();
if (!vocabulary.some(name => String(name).toLowerCase() === wanted))
return false;
const keycodes = Object.keys(byKeycode);
for (let i = 0; i < keycodes.length; i++) {
const syms = byKeycode[keycodes[i]] ?? [];
for (let j = 0; j < syms.length; j++) {
if (String(syms[j]).toLowerCase() === wanted)
return false;
}
}
return true;
}
function modsFromEvent(mods) {
var result = [];
if (mods & 0x10000000)
result.push("Super");
if (mods & 0x08000000)
result.push("Alt");
if (mods & 0x04000000)
result.push("Ctrl");
if (mods & 0x02000000)
result.push("Shift");
return result;
}
function formatToken(mods, key) {
return (mods.length ? mods.join("+") + "+" : "") + key;
}
const MODIFIER_ALIASES = {
"control": "ctrl",
"win": "super",
"meta": "super",
"logo": "super",
"mod4": "super",
"mod1": "alt"
};
function canonicalModifier(modifier) {
var normalized = (modifier || "").toLowerCase();
return MODIFIER_ALIASES[normalized] || normalized;
}
function withSymbolicMod(mods, modKey, modSymbol) {
var configuredMod = canonicalModifier(modKey);
if (!configuredMod || !modSymbol)
return mods;
return mods.map(function (modifier) {
return canonicalModifier(modifier) === configuredMod ? modSymbol : modifier;
});
}
function normalizeKeyCombo(keyCombo, modKey, modSymbol) {
if (!keyCombo)
return "";
var configuredMod = canonicalModifier(modKey) || "super";
var symbol = (modSymbol || "mod").toLowerCase();
return keyCombo.toLowerCase().split("+").map(function (part) {
var token = part.trim();
return token === symbol ? configuredMod : canonicalModifier(token);
}).join("+");
}
function getConflictingBinds(keyCombo, currentAction, allBinds, modKey, modSymbol) {
if (!keyCombo)
return [];
var conflicts = [];
var normalizedKey = normalizeKeyCombo(keyCombo, modKey, modSymbol);
for (var i = 0; i < allBinds.length; i++) {
var bind = allBinds[i];
if (bind.action === currentAction)
continue;
for (var k = 0; k < bind.keys.length; k++) {
if (normalizeKeyCombo(bind.keys[k].key, modKey, modSymbol) === normalizedKey) {
conflicts.push({
action: bind.action,
desc: bind.desc || bind.action
});
break;
}
}
}
return conflicts;
}
function qtKeyFromName(name) {
var n = (name || "").toUpperCase();
if (n.length === 1 && n >= "A" && n <= "Z")
return Qt.Key_A + (n.charCodeAt(0) - 65);
if (n.length === 1 && n >= "0" && n <= "9")
return Qt.Key_0 + (n.charCodeAt(0) - 48);
if (n.length >= 2 && n[0] === "F") {
var f = parseInt(n.slice(1), 10);
if (f >= 1 && f <= 12)
return Qt.Key_F1 + (f - 1);
}
var named = {
"SPACE": Qt.Key_Space,
"TAB": Qt.Key_Tab,
"RETURN": Qt.Key_Return,
"ENTER": Qt.Key_Enter,
"BACKSPACE": Qt.Key_Backspace,
"DELETE": Qt.Key_Delete,
"HOME": Qt.Key_Home,
"END": Qt.Key_End,
"UP": Qt.Key_Up,
"DOWN": Qt.Key_Down,
"LEFT": Qt.Key_Left,
"RIGHT": Qt.Key_Right
};
return named[n] || 0;
}
function isModifierKey(qk) {
return qk === Qt.Key_Control || qk === Qt.Key_Shift || qk === Qt.Key_Alt || qk === Qt.Key_Meta || qk === Qt.Key_NumLock || qk === Qt.Key_CapsLock || qk === Qt.Key_ScrollLock;
}
function eventMatchesCombo(event, combo) {
if (!combo)
return false;
var parts = combo.split("+");
var keyName = parts[parts.length - 1].trim().toUpperCase();
var wantsShift = false;
var hasCtrl = false;
for (var i = 0; i < parts.length - 1; i++) {
var mod = parts[i].trim().toLowerCase();
if (mod === "shift")
wantsShift = true;
else if (mod === "ctrl" || mod === "control")
hasCtrl = true;
else
return false;
}
if (hasCtrl && !(event.modifiers & Qt.ControlModifier))
return false;
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier))
return false;
if (((event.modifiers & Qt.ShiftModifier) !== 0) !== wantsShift)
return false;
return event.key === qtKeyFromName(keyName);
}
const KEY_GLYPH_MAP = {
"shift": "⇧",
"ctrl": "⌃",
"control": "⌃",
"alt": "⌥",
"super": "⌘",
"meta": "⌘",
"win": "⌘",
"return": "⏎",
"enter": "⏎",
"tab": "⇥",
"backspace": "⌫",
"space": "␣",
"escape": "Esc",
"esc": "Esc",
"up": "↑",
"down": "↓",
"left": "←",
"right": "→",
"page_up": "⇞",
"pageup": "⇞",
"page_down": "⇟",
"pagedown": "⇟",
"home": "Home",
"end": "End",
"delete": "Del",
"del": "Del",
"insert": "Ins",
"print": "PrtSc",
"printscreen": "PrtSc",
"prtscr": "PrtSc",
"prtsc": "PrtSc",
"sysrq": "PrtSc",
"plus": "+",
"minus": "-",
"equal": "=",
"slash": "/",
"backslash": "\\",
"bracketleft": "[",
"bracketright": "]",
"semicolon": ";",
"apostrophe": "'",
"comma": ",",
"period": ".",
"grave": "`",
"wheelscrolldown": "󰍽 ↓",
"wheelscrollup": "󰍽 ↑",
"wheelscrollleft": "󰍽 ←",
"wheelscrollright": "󰍽 →",
"mousescrolldown": "󰍽 ↓",
"mousescrollup": "󰍽 ↑",
"mouse_down": "󰍽 ↓",
"mouse_up": "󰍽 ↑",
"mouse_left": "󰍽 ←",
"mouse_right": "󰍽 →",
"scrolldown": "󰍽 ↓",
"scrollup": "󰍽 ↑",
// Audio & Volume
"xf86audioraisevolume": "󰕾 +",
"audioraisevolume": "󰕾 +",
"xf86audiolowervolume": "󰕾 -",
"audiolowervolume": "󰕾 -",
"xf86audiomute": "󰖁",
"audiomute": "󰖁",
"xf86audiomicmute": "󰍭",
"audiomicmute": "󰍭",
// Media Playback
"xf86audioplay": "󰐊",
"audioplay": "󰐊",
"xf86audiopause": "󰏤",
"audiopause": "󰏤",
"xf86audioplaypause": "󰐎",
"audioplaypause": "󰐎",
"xf86audionext": "󰒭",
"audionext": "󰒭",
"xf86audioprev": "󰒮",
"audioprev": "󰒮",
"xf86audiostop": "󰓛",
"audiostop": "󰓛",
"xf86audiorecord": "Rec",
"audiorecord": "Rec",
"xf86audiomedia": "Media",
"audiomedia": "Media",
// Display & Brightness
"xf86monbrightnessup": "󰃠 +",
"monbrightnessup": "󰃠 +",
"xf86monbrightnessdown": "󰃠 -",
"monbrightnessdown": "󰃠 -",
"xf86kbdbrightnessup": "󰌌 +",
"kbdbrightnessup": "󰌌 +",
"xf86kbdbrightnessdown": "󰌌 -",
"kbdbrightnessdown": "󰌌 -",
// Hardware & System
"xf86calculator": "Calc",
"xf86search": "Search",
"xf86mail": "Mail",
"xf86homepage": "Web",
"xf86explorer": "Files",
"xf86poweroff": "Power",
"xf86sleep": "Sleep",
"xf86wakeup": "Wake",
"xf86eject": "Eject",
"xf86launch0": "Launch 0",
"xf86launch1": "PrtSc",
"launch1": "PrtSc"
};
function formatKeyTokens(keyString, modKey, modSymbol) {
if (!keyString)
return [];
var symbol = (modSymbol || "mod").toLowerCase();
var resolvedMod = canonicalModifier(modKey) || "super";
var str = keyString;
var plusEnds = false;
if (str.endsWith("++")) {
plusEnds = true;
str = str.slice(0, -2);
}
var parts = str.split("+");
var tokens = [];
for (var i = 0; i < parts.length; i++) {
var part = parts[i].trim();
if (!part)
continue;
var lower = part.toLowerCase();
if (lower === symbol)
lower = resolvedMod;
if (KEY_GLYPH_MAP[lower] !== undefined) {
tokens.push(KEY_GLYPH_MAP[lower]);
} else if (part.length === 1) {
tokens.push(part.toUpperCase());
} else {
tokens.push(part);
}
}
if (plusEnds) {
tokens.push("+");
}
return tokens;
}