// Pure layout parsing and list operations. Kept independent of Qt so it can // be tested with Node as well as imported from QML. function clean(value) { return String(value === undefined || value === null ? "" : value).trim() } function splitList(value) { var text = clean(value) if (text === "") return [] return text.split(",").map(clean) } function optionString(raw) { try { var parsed = JSON.parse(String(raw || "{}")) return clean(parsed.str) } catch (error) { return "" } } function entryKey(entry) { return clean(entry.layout) + "|" + clean(entry.variant) } var REMAP_COMPONENT = "omarchy-keymaps(remap)" function baseLayoutCode(token) { var text = clean(token) if (text.indexOf(REMAP_COMPONENT + "+") === 0) text = text.slice(REMAP_COMPONENT.length + 1) var plusIndex = text.indexOf("+") return plusIndex === -1 ? text : text.slice(0, plusIndex) } function layoutEntries(layoutRaw, variantRaw) { var layouts = splitList(layoutRaw).map(baseLayoutCode) var variants = splitList(variantRaw) var entries = [] for (var i = 0; i < layouts.length; i++) { entries.push({ layout: layouts[i], variant: variants[i] || "", description: layouts[i].toUpperCase() + (variants[i] ? " - " + variants[i] : "") }) } return entries } function serializeEntries(entries) { var layouts = [] var variants = [] for (var i = 0; i < entries.length; i++) { layouts.push(clean(entries[i].layout)) variants.push(clean(entries[i].variant)) } return { layouts: layouts.join(","), variants: variants.join(",") } } function parseXkb(raw) { var rows = [] var current = null var lines = String(raw || "").split("\n") function flush() { if (!current || !current.layout) return if (!current.description) { current.description = current.layout.toUpperCase() + (current.variant ? " - " + current.variant : "") } rows.push(current) } for (var i = 0; i < lines.length; i++) { var line = lines[i] var layoutMatch = line.match(/^- layout: ['"]?([^'"\s]+)['"]?\s*$/) if (layoutMatch) { flush() current = { layout: layoutMatch[1], variant: "", brief: "", description: "" } continue } if (!current) continue var variantMatch = line.match(/^\s+variant: ['"]?(.*?)['"]?\s*$/) if (variantMatch) { current.variant = variantMatch[1] continue } var briefMatch = line.match(/^\s+brief: ['"]?(.*?)['"]?\s*$/) if (briefMatch) { current.brief = briefMatch[1] continue } var descriptionMatch = line.match(/^\s+description: ['"]?(.*?)['"]?\s*$/) if (descriptionMatch) current.description = descriptionMatch[1] } flush() var unique = [] var seen = {} for (var j = 0; j < rows.length; j++) { var key = entryKey(rows[j]) if (seen[key]) continue seen[key] = true unique.push(rows[j]) } return unique.sort(function(a, b) { return String(a.description).localeCompare(String(b.description)) }) } var POPULAR_KEYS = [ "pl|", "us|", "gb|", "de|", "fr|", "es|", "it|", "cz|", "sk|", "ua|", "ru|" ] function isConfigured(row, configured) { for (var i = 0; i < configured.length; i++) { if (entryKey(row) === entryKey(configured[i])) return true } return false } function matchScore(row, needle) { var layout = String(row.layout).toLowerCase() var variant = String(row.variant).toLowerCase() var description = String(row.description).toLowerCase() if (layout === needle && variant === "") return 0 if (layout === needle) return 1 if (layout.indexOf(needle) === 0) return 2 if (variant.indexOf(needle) === 0) return 3 if (description.indexOf(needle) !== -1) return 4 return 5 } function filterAvailable(rows, configured, query) { var needle = clean(query).toLowerCase() var filtered = rows.filter(function(row) { if (isConfigured(row, configured)) return false if (needle === "") return POPULAR_KEYS.indexOf(entryKey(row)) !== -1 return String(row.layout).toLowerCase().indexOf(needle) !== -1 || String(row.variant).toLowerCase().indexOf(needle) !== -1 || String(row.description).toLowerCase().indexOf(needle) !== -1 }) if (needle !== "") { filtered.sort(function(a, b) { var scoreDifference = matchScore(a, needle) - matchScore(b, needle) if (scoreDifference !== 0) return scoreDifference var layoutDifference = String(a.layout).localeCompare(String(b.layout)) if (layoutDifference !== 0) return layoutDifference var variantDifference = String(a.variant).localeCompare(String(b.variant)) if (variantDifference !== 0) return variantDifference return String(a.description).localeCompare(String(b.description)) }) } return filtered.slice(0, 80) } function labelFor(entry) { return clean(entry.layout).toUpperCase() } // A configured layout is described twice over: the XKB catalogue names it // ("Polish"), while a reload from hyprctl only knows its code. Prefer the // catalogue name so the same layout does not read differently after a refresh, // and drop a description that only repeats the code back. function describeEntries(entries, catalogue) { var byKey = {} for (var i = 0; i < catalogue.length; i++) byKey[entryKey(catalogue[i])] = catalogue[i].description return entries.map(function(entry) { var described = clean(byKey[entryKey(entry)] || entry.description) return { layout: entry.layout, variant: entry.variant, description: described === labelFor(entry) ? "" : described } }) } // Curated, verified against `xkbcli compile-keymap --layout us` evdev // keycode names and their default keysyms. Deliberately scoped to letters, // digits, function keys, modifiers, common utility keys, and the navigation // cluster - numpad and multimedia keys are out of scope for v1. var KEY_TABLE = [ { code: "AD01", scan: 24, keysym: "q", label: "Q" }, { code: "AD02", scan: 25, keysym: "w", label: "W" }, { code: "AD03", scan: 26, keysym: "e", label: "E" }, { code: "AD04", scan: 27, keysym: "r", label: "R" }, { code: "AD05", scan: 28, keysym: "t", label: "T" }, { code: "AD06", scan: 29, keysym: "y", label: "Y" }, { code: "AD07", scan: 30, keysym: "u", label: "U" }, { code: "AD08", scan: 31, keysym: "i", label: "I" }, { code: "AD09", scan: 32, keysym: "o", label: "O" }, { code: "AD10", scan: 33, keysym: "p", label: "P" }, { code: "AC01", scan: 38, keysym: "a", label: "A" }, { code: "AC02", scan: 39, keysym: "s", label: "S" }, { code: "AC03", scan: 40, keysym: "d", label: "D" }, { code: "AC04", scan: 41, keysym: "f", label: "F" }, { code: "AC05", scan: 42, keysym: "g", label: "G" }, { code: "AC06", scan: 43, keysym: "h", label: "H" }, { code: "AC07", scan: 44, keysym: "j", label: "J" }, { code: "AC08", scan: 45, keysym: "k", label: "K" }, { code: "AC09", scan: 46, keysym: "l", label: "L" }, { code: "AB01", scan: 52, keysym: "z", label: "Z" }, { code: "AB02", scan: 53, keysym: "x", label: "X" }, { code: "AB03", scan: 54, keysym: "c", label: "C" }, { code: "AB04", scan: 55, keysym: "v", label: "V" }, { code: "AB05", scan: 56, keysym: "b", label: "B" }, { code: "AB06", scan: 57, keysym: "n", label: "N" }, { code: "AB07", scan: 58, keysym: "m", label: "M" }, { code: "AE01", scan: 10, keysym: "1", label: "1" }, { code: "AE02", scan: 11, keysym: "2", label: "2" }, { code: "AE03", scan: 12, keysym: "3", label: "3" }, { code: "AE04", scan: 13, keysym: "4", label: "4" }, { code: "AE05", scan: 14, keysym: "5", label: "5" }, { code: "AE06", scan: 15, keysym: "6", label: "6" }, { code: "AE07", scan: 16, keysym: "7", label: "7" }, { code: "AE08", scan: 17, keysym: "8", label: "8" }, { code: "AE09", scan: 18, keysym: "9", label: "9" }, { code: "AE10", scan: 19, keysym: "0", label: "0" }, { code: "FK01", scan: 67, keysym: "F1", label: "F1" }, { code: "FK02", scan: 68, keysym: "F2", label: "F2" }, { code: "FK03", scan: 69, keysym: "F3", label: "F3" }, { code: "FK04", scan: 70, keysym: "F4", label: "F4" }, { code: "FK05", scan: 71, keysym: "F5", label: "F5" }, { code: "FK06", scan: 72, keysym: "F6", label: "F6" }, { code: "FK07", scan: 73, keysym: "F7", label: "F7" }, { code: "FK08", scan: 74, keysym: "F8", label: "F8" }, { code: "FK09", scan: 75, keysym: "F9", label: "F9" }, { code: "FK10", scan: 76, keysym: "F10", label: "F10" }, { code: "FK11", scan: 95, keysym: "F11", label: "F11" }, { code: "FK12", scan: 96, keysym: "F12", label: "F12" }, { code: "LCTL", scan: 37, keysym: "Control_L", label: "Left Ctrl" }, { code: "RCTL", scan: 105, keysym: "Control_R", label: "Right Ctrl" }, { code: "LALT", scan: 64, keysym: "Alt_L", label: "Left Alt" }, { code: "RALT", scan: 108, keysym: "Alt_R", label: "Right Alt" }, { code: "LFSH", scan: 50, keysym: "Shift_L", label: "Left Shift" }, { code: "RTSH", scan: 62, keysym: "Shift_R", label: "Right Shift" }, { code: "LWIN", scan: 133, keysym: "Super_L", label: "Left Super" }, { code: "RWIN", scan: 134, keysym: "Super_R", label: "Right Super" }, { code: "CAPS", scan: 66, keysym: "Caps_Lock", label: "Caps Lock" }, { code: "ESC", scan: 9, keysym: "Escape", label: "Escape" }, { code: "TAB", scan: 23, keysym: "Tab", label: "Tab" }, { code: "RTRN", scan: 36, keysym: "Return", label: "Enter" }, { code: "BKSP", scan: 22, keysym: "BackSpace", label: "Backspace" }, { code: "SPCE", scan: 65, keysym: "space", label: "Space" }, { code: "COMP", scan: 135, keysym: "Menu", label: "Menu" }, { code: "HOME", scan: 110, keysym: "Home", label: "Home" }, { code: "END", scan: 115, keysym: "End", label: "End" }, { code: "PGUP", scan: 112, keysym: "Prior", label: "Page Up" }, { code: "PGDN", scan: 117, keysym: "Next", label: "Page Down" }, { code: "UP", scan: 111, keysym: "Up", label: "Up" }, { code: "DOWN", scan: 116, keysym: "Down", label: "Down" }, { code: "LEFT", scan: 113, keysym: "Left", label: "Left" }, { code: "RGHT", scan: 114, keysym: "Right", label: "Right" }, { code: "INS", scan: 118, keysym: "Insert", label: "Insert" }, { code: "DELE", scan: 119, keysym: "Delete", label: "Delete" } ] function keyByCode(code) { for (var i = 0; i < KEY_TABLE.length; i++) { if (KEY_TABLE[i].code === code) return KEY_TABLE[i] } return null } // Qt reports nativeScanCode as the XKB keycode, which is the evdev code // plus 8. Looking a pressed key up this way (rather than by Qt.Key_*) is what // lets the capture flow tell Left Ctrl from Right Ctrl. function keyByScan(scan) { for (var i = 0; i < KEY_TABLE.length; i++) { if (KEY_TABLE[i].scan === Number(scan)) return KEY_TABLE[i] } return null } function keyLabel(code) { var entry = keyByCode(code) return entry ? entry.label : code } function keyByKeysym(keysym) { for (var i = 0; i < KEY_TABLE.length; i++) { if (KEY_TABLE[i].keysym === keysym) return KEY_TABLE[i] } return null } // Remap targets are stored as keysyms, not keycodes, so they need their own // lookup to render a friendly label (e.g. "Caps_Lock" -> "Caps Lock"). function keysymLabel(keysym) { var entry = keyByKeysym(keysym) return entry ? entry.label : keysym } // A swap is stored as two independent one-way pairs, but reads as a single // idea ("Caps Lock and Escape trade places"), so collapse a matched forward // and reverse pair into one row. `codes` carries every source key the row // stands for, so removing it removes both halves together. function groupRemapPairs(pairs) { var rows = [] var consumed = {} for (var i = 0; i < pairs.length; i++) { var pair = pairs[i] if (consumed[pair.from]) continue var fromEntry = keyByCode(pair.from) var toEntry = keyByKeysym(pair.to) var reverse = null if (fromEntry && toEntry && toEntry.code !== fromEntry.code) { for (var j = 0; j < pairs.length; j++) { if (j === i || consumed[pairs[j].from]) continue if (pairs[j].from === toEntry.code && pairs[j].to === fromEntry.keysym) { reverse = pairs[j] break } } } consumed[pair.from] = true if (reverse) { consumed[reverse.from] = true rows.push({ from: pair.from, to: pair.to, both: true, codes: [pair.from, reverse.from] }) } else { rows.push({ from: pair.from, to: pair.to, both: false, codes: [pair.from] }) } } return rows } function isLetterKeysym(keysym) { return /^[a-z]$/.test(keysym) } // pairs: {from, to}[] of KEY_TABLE codes/keysyms. Every token comes from the // curated KEY_TABLE, never free text, so this has no injection surface. // // Each key spells out all four XKB groups explicitly. The apply script prefixes // the component to the first layout only, so it is included once - without the // explicit groups a remap would apply to group 1 alone and quietly revert // whenever another layout was selected. function remapPairsToSymbolsBody(pairs) { var lines = [] for (var i = 0; i < pairs.length; i++) { var from = clean(pairs[i].from) var to = clean(pairs[i].to) if (!from || !to) continue var symbols = isLetterKeysym(to) ? (to + ", " + to.toUpperCase()) : to var groups = [] for (var group = 1; group <= 4; group++) groups.push("symbols[" + group + "]=[" + symbols + "]") lines.push(" key <" + from + "> { " + groups.join(", ") + " };") } return lines.join("\n") } // Inverse of remapPairsToSymbolsBody. Only needs to round-trip that // function's own output format, since this plugin owns the whole file. function parseSymbolsBody(text) { var pairs = [] var re = /key\s*<(\w+)>\s*\{\s*symbols\[1\]=\[\s*([A-Za-z_][A-Za-z0-9_]*)/g var match while ((match = re.exec(String(text || ""))) !== null) { pairs.push({ from: match[1], to: match[2] }) } return pairs } if (typeof module !== "undefined") { module.exports = { clean: clean, splitList: splitList, optionString: optionString, entryKey: entryKey, baseLayoutCode: baseLayoutCode, layoutEntries: layoutEntries, serializeEntries: serializeEntries, parseXkb: parseXkb, isConfigured: isConfigured, matchScore: matchScore, filterAvailable: filterAvailable, labelFor: labelFor, describeEntries: describeEntries, KEY_TABLE: KEY_TABLE, keyByCode: keyByCode, keyByScan: keyByScan, keyLabel: keyLabel, keyByKeysym: keyByKeysym, keysymLabel: keysymLabel, groupRemapPairs: groupRemapPairs, remapPairsToSymbolsBody: remapPairsToSymbolsBody, parseSymbolsBody: parseSymbolsBody } }