diff --git a/core/KeyText.js b/core/KeyText.js new file mode 100644 index 0000000..5341c8f --- /dev/null +++ b/core/KeyText.js @@ -0,0 +1,118 @@ +// What a key event inserts into the launcher query. +// +// Numpad keys arrive as Qt.KeypadModifier. With NumLock off they are also +// reported as arrows, Home/End, PageUp/Down, Insert, Delete and Clear. +// Treating those as list movement runs the synchronous search-history read +// on numpad 8, which is the stall, and the digit never lands. The number +// row has no keypad modifier and no navigation keysym, so it stays fast. +// +// Qt Wayland's nativeScanCode is the xkb keycode (linux evdev + 8). +// Raw evdev is not used: NumLock is evdev 69, so its xkb code is 77, and +// raw KP6 is also 77. Mapping raw 77 inserted "6" and ate the lock. + +var KEYPAD = 0x20000000 +var SHIFT = 0x02000000 + +var KEY_INSERT = 0x01000006 +var KEY_DELETE = 0x01000007 +var KEY_CLEAR = 0x0100000b +var KEY_HOME = 0x01000010 +var KEY_END = 0x01000011 +var KEY_LEFT = 0x01000012 +var KEY_UP = 0x01000013 +var KEY_RIGHT = 0x01000014 +var KEY_DOWN = 0x01000015 +var KEY_PAGEUP = 0x01000016 +var KEY_PAGEDOWN = 0x01000017 +var KEY_CAPSLOCK = 0x01000024 +var KEY_NUMLOCK = 0x01000025 +var KEY_SCROLLLOCK = 0x01000026 + +// NumLock off, when Qt still sets KeypadModifier. +var PAD_OFF = {} +PAD_OFF[KEY_INSERT] = "0" +PAD_OFF[KEY_END] = "1" +PAD_OFF[KEY_DOWN] = "2" +PAD_OFF[KEY_PAGEDOWN] = "3" +PAD_OFF[KEY_LEFT] = "4" +PAD_OFF[KEY_CLEAR] = "5" +PAD_OFF[KEY_RIGHT] = "6" +PAD_OFF[KEY_HOME] = "7" +PAD_OFF[KEY_UP] = "8" +PAD_OFF[KEY_PAGEUP] = "9" +PAD_OFF[KEY_DELETE] = "." + +// xkb keycodes (evdev + 8). "" is keypad Enter: keypad, but not text. +var PAD_SCAN = { + 63: "*", + 79: "7", + 80: "8", + 81: "9", + 82: "-", + 83: "4", + 84: "5", + 85: "6", + 86: "+", + 87: "1", + 88: "2", + 89: "3", + 90: "0", + 91: ".", + 104: "", + 106: "/", +} + +/** + * True when the event came from the numeric keypad, not the number row + * or the arrow cluster. + * @param {{modifiers?: number, key?: number, nativeScanCode?: number}} event + * @returns {boolean} + */ +function isLock(key) { + return key === KEY_CAPSLOCK || key === KEY_NUMLOCK || key === KEY_SCROLLLOCK +} + +function isKeypad(event) { + if (!event || isLock(event.key)) return false + if ((event.modifiers || 0) & KEYPAD) return true + var scan = event.nativeScanCode || 0 + return !!scan && Object.prototype.hasOwnProperty.call(PAD_SCAN, scan) +} + +/** + * The single character this event should insert, or "" if it is a shortcut + * or real navigation. + * @param {{modifiers?: number, key?: number, text?: string, nativeScanCode?: number}} event + * @param {boolean} [numLock] Keypad inserts digits only while this is true. + * @returns {string} + */ +function character(event, numLock) { + if (!event || isLock(event.key)) return "" + if (numLock === undefined) numLock = true + var mods = event.modifiers || 0 + if ((mods & ~(KEYPAD | SHIFT)) !== 0) return "" + // Unlocked keypad is navigation, even if Qt still reports a digit. + if (!numLock && isKeypad(event)) return "" + + var text = event.text || "" + if (text.length === 1) { + var code = text.charCodeAt(0) + if (code >= 32 && code !== 127) return text + } + + if ((mods & KEYPAD) && Object.prototype.hasOwnProperty.call(PAD_OFF, event.key)) { + return PAD_OFF[event.key] + } + + var scan = event.nativeScanCode || 0 + if (scan && Object.prototype.hasOwnProperty.call(PAD_SCAN, scan)) return PAD_SCAN[scan] + return "" +} + +if (typeof module !== "undefined") { + module.exports = { + KEYPAD: KEYPAD, + character: character, + isKeypad: isKeypad, + } +} diff --git a/tests/keytext.test.js b/tests/keytext.test.js new file mode 100644 index 0000000..8058215 --- /dev/null +++ b/tests/keytext.test.js @@ -0,0 +1,275 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const path = require("node:path") +const vm = require("node:vm") + +const KeyText = require("../core/KeyText.js") +const KEYPAD = KeyText.KEYPAD +const SHIFT = 0x02000000 +const CTRL = 0x04000000 + +const UP = 0x01000013 +const DOWN = 0x01000015 +const PAGEUP = 0x01000016 +const PAGEDOWN = 0x01000017 +const HOME = 0x01000010 +const END = 0x01000011 +const LEFT = 0x01000012 +const RIGHT = 0x01000014 +const INSERT = 0x01000006 +const DELETE = 0x01000007 +const CLEAR = 0x0100000b + +test("number-row digits and letters stay plain text", () => { + assert.equal(KeyText.character({ key: 0x35, text: "5", modifiers: 0 }), "5") + assert.equal(KeyText.character({ key: 0x41, text: "a", modifiers: 0 }), "a") + assert.equal(KeyText.character({ key: 0x35, text: "%", modifiers: SHIFT }), "%") + assert.equal(KeyText.isKeypad({ key: 0x35, text: "5", modifiers: 0 }), false) +}) + +test("numlock-on numpad digits keep their text despite KeypadModifier", () => { + const event = { key: 0x38, text: "8", modifiers: KEYPAD } + assert.equal(KeyText.character(event), "8") + assert.equal(KeyText.isKeypad(event), true) +}) + +test("locked numpad types digits when Qt still reports arrows", () => { + const cases = [ + [INSERT, "0"], + [END, "1"], + [DOWN, "2"], + [PAGEDOWN, "3"], + [LEFT, "4"], + [CLEAR, "5"], + [RIGHT, "6"], + [HOME, "7"], + [UP, "8"], + [PAGEUP, "9"], + [DELETE, "."], + ] + for (const [key, digit] of cases) { + const event = { key, text: "", modifiers: KEYPAD } + assert.equal(KeyText.character(event, true), digit, "key " + key.toString(16)) + assert.equal(KeyText.isKeypad(event), true) + assert.equal(KeyText.character(event, false), "") + } +}) + +test("real arrows are not numpad and insert nothing", () => { + for (const key of [UP, DOWN, LEFT, RIGHT, PAGEUP, PAGEDOWN, HOME, END]) { + const event = { key, text: "", modifiers: 0, nativeScanCode: 0 } + assert.equal(KeyText.character(event), "") + assert.equal(KeyText.isKeypad(event), false) + } + // xkb keycodes for the arrow cluster (evdev + 8): Up 111, Down 116. + assert.equal(KeyText.isKeypad({ key: UP, text: "", modifiers: 0, nativeScanCode: 111 }), false) +}) + +test("wayland scan codes still type when KeypadModifier is missing", () => { + // KP8 xkb keycode is 80. Raw evdev 80 is KP2, so this mapping is Wayland's. + const event = { key: UP, text: "", modifiers: 0, nativeScanCode: 80 } + assert.equal(KeyText.character(event), "8") + assert.equal(KeyText.isKeypad(event), true) + // KP6 xkb keycode is 85. 77 is NumLock, not KP6. + assert.equal(KeyText.character({ key: 0x36, text: "", modifiers: 0, nativeScanCode: 85 }), "6") + assert.equal(KeyText.character(event, false), "") +}) + +test("NumLock is not numpad 6", () => { + const numLock = 0x01000025 + const event = { key: numLock, text: "", modifiers: 0, nativeScanCode: 77 } + assert.equal(KeyText.character(event), "") + assert.equal(KeyText.isKeypad(event), false) + assert.equal(KeyText.character({ key: numLock, text: "6", modifiers: 0, nativeScanCode: 77 }), "") + // F6's xkb code is 72, which used to be raw KP8. + assert.equal(KeyText.character({ key: 0x01000035, text: "", modifiers: 0, nativeScanCode: 72 }), "") + assert.equal(KeyText.isKeypad({ key: 0x01000024, text: "", modifiers: KEYPAD, nativeScanCode: 0 }), false) +}) + +test("ctrl and alt chords are not typed, including numpad", () => { + assert.equal(KeyText.character({ key: UP, text: "", modifiers: KEYPAD | CTRL }), "") + assert.equal(KeyText.character({ key: 0x41, text: "a", modifiers: CTRL }), "") + assert.equal(KeyText.character({ key: 0x35, text: "5", modifiers: KEYPAD | 0x08000000 }), "") +}) + +test("numpad enter does not insert a character", () => { + assert.equal(KeyText.character({ key: 0x01000005, text: "", modifiers: KEYPAD, nativeScanCode: 104 }), "") + assert.equal(KeyText.isKeypad({ key: 0x01000005, text: "", modifiers: 0, nativeScanCode: 104 }), true) +}) + +const source = fs.readFileSync(path.join(__dirname, "../ui/LauncherWindow.qml"), "utf8") + +function body(signature) { + const start = source.indexOf(signature) + assert.ok(start >= 0, signature) + const open = source.indexOf("{", start) + 1 + const end = source.indexOf("\n }", open) + return "(function() {\n" + source.slice(open, end) + "\n})()" +} + +test("numpad 8 types into the query and does not read search history", () => { + let next = 16 + const Qt = new Proxy({ + NoModifier: 0, + ControlModifier: 0x04000000, + ShiftModifier: 0x02000000, + AltModifier: 0x08000000, + MetaModifier: 0x10000000, + Key_Up: UP, + Key_Down: DOWN, + Key_PageUp: PAGEUP, + Key_PageDown: PAGEDOWN, + Key_Left: LEFT, + Key_Right: RIGHT, + Key_Home: HOME, + Key_End: END, + Key_Insert: INSERT, + Key_Delete: DELETE, + Key_Clear: CLEAR, + }, { + get(target, key) { + if (!(key in target)) target[key] = next++ + return target[key] + }, + }) + const recalled = [] + const moved = [] + const window = { + query: "", + atRoot: true, + selectedIndex: 0, + historyIndex: -1, + actionPanelOpen: false, + argFocus: -1, + argDefs: [], + view: { kind: "root" }, + numLock: true, + vim: false, + isActionPanelKey() { return false }, + nextSelectable() { return 0 }, + recallHistory(step) { recalled.push(step); return true }, + move(step) { moved.push(step) }, + setQuery(value) { window.query = value }, + } + const context = vm.createContext({ Qt, window, KeyText, Util: { editsFilter: () => false } }) + window.direction = (event, ctrl, alt, inPopup) => { + context.event = event + context.ctrl = ctrl + context.alt = alt + context.inPopup = inPopup + return vm.runInContext(body("function direction(event, ctrl, alt, inPopup)"), context) + } + context.event = { key: UP, text: "", modifiers: KEYPAD, nativeScanCode: 80 } + assert.equal(vm.runInContext(body("function handleKey(event)"), context), true) + assert.equal(window.query, "8") + assert.deepEqual(recalled, []) + assert.deepEqual(moved, []) +}) + +test("arrow up still walks history when it is not the numpad", () => { + let next = 16 + const Qt = new Proxy({ + NoModifier: 0, + ControlModifier: 0x04000000, + ShiftModifier: 0x02000000, + AltModifier: 0x08000000, + MetaModifier: 0x10000000, + Key_Up: UP, + Key_Down: DOWN, + Key_PageUp: PAGEUP, + Key_PageDown: PAGEDOWN, + }, { + get(target, key) { + if (!(key in target)) target[key] = next++ + return target[key] + }, + }) + const recalled = [] + const window = { + query: "", + atRoot: true, + selectedIndex: 0, + historyIndex: -1, + actionPanelOpen: false, + argFocus: -1, + argDefs: [], + view: { kind: "root" }, + numLock: true, + vim: false, + isActionPanelKey() { return false }, + nextSelectable() { return 0 }, + recallHistory(step) { recalled.push(step); return true }, + move() {}, + setQuery(value) { window.query = value }, + } + const context = vm.createContext({ Qt, window, KeyText, Util: { editsFilter: () => false } }) + window.direction = (event, ctrl, alt, inPopup) => { + context.event = event + context.ctrl = ctrl + context.alt = alt + context.inPopup = inPopup + return vm.runInContext(body("function direction(event, ctrl, alt, inPopup)"), context) + } + context.event = { key: UP, text: "", modifiers: 0, nativeScanCode: 111 } + assert.equal(vm.runInContext(body("function handleKey(event)"), context), true) + assert.equal(window.query, "") + assert.deepEqual(recalled, [1]) +}) + +test("NumLock unlocks the keypad so 8 moves the list", () => { + const NUMLOCK = 0x01000025 + let next = 16 + const Qt = new Proxy({ + NoModifier: 0, + ControlModifier: 0x04000000, + ShiftModifier: 0x02000000, + AltModifier: 0x08000000, + MetaModifier: 0x10000000, + Key_NumLock: NUMLOCK, + Key_Up: UP, + Key_Down: DOWN, + Key_PageUp: PAGEUP, + Key_PageDown: PAGEDOWN, + }, { + get(target, key) { + if (!(key in target)) target[key] = next++ + return target[key] + }, + }) + const recalled = [] + const window = { + query: "", + numLock: true, + numLockTouched: false, + atRoot: true, + selectedIndex: 0, + historyIndex: -1, + actionPanelOpen: false, + argFocus: -1, + argDefs: [], + view: { kind: "root" }, + vim: false, + isActionPanelKey() { return false }, + nextSelectable() { return 0 }, + recallHistory(step) { recalled.push(step); return true }, + move() {}, + setQuery(value) { window.query = value }, + } + const context = vm.createContext({ Qt, window, KeyText, Util: { editsFilter: () => false } }) + window.direction = (event, ctrl, alt, inPopup) => { + context.event = event + context.ctrl = ctrl + context.alt = alt + context.inPopup = inPopup + return vm.runInContext(body("function direction(event, ctrl, alt, inPopup)"), context) + } + context.event = { key: NUMLOCK, text: "6", modifiers: 0, nativeScanCode: 77, isAutoRepeat: false } + assert.equal(vm.runInContext(body("function handleKey(event)"), context), true) + assert.equal(window.numLock, false) + assert.equal(window.query, "") + context.event = { key: UP, text: "8", modifiers: KEYPAD, nativeScanCode: 80 } + assert.equal(vm.runInContext(body("function handleKey(event)"), context), true) + assert.equal(window.query, "") + assert.deepEqual(recalled, [1]) +}) diff --git a/tests/launcher-paste.test.js b/tests/launcher-paste.test.js index 68e8645..0440efb 100644 --- a/tests/launcher-paste.test.js +++ b/tests/launcher-paste.test.js @@ -27,7 +27,10 @@ function harness(text) { } const actionPanel = { item: { filter: "" } } const clipboardInput = { text: "", clear() { this.text = "" }, paste() { this.text = text } } - const context = vm.createContext({ Qt, window, actionPanel, clipboardInput, Util: { editsFilter: () => false } }) + const KeyText = { character: () => "", isKeypad: () => false } + const context = vm.createContext({ + Qt, window, actionPanel, clipboardInput, KeyText, Util: { editsFilter: () => false }, + }) window.setQuery = next => { context.next = next vm.runInContext(body("function setQuery(next)"), context) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..5552956 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -1,11 +1,13 @@ import QtQuick import Quickshell +import Quickshell.Io import Quickshell.Wayland import qs.Commons import qs.Commons as Commons import qs.Ui import "../core/RootSearch.js" as RootSearch import "../core/Rows.js" as Rows +import "../core/KeyText.js" as KeyText // Omarchy 4.0.x dropped qs.Ui.OverlayWindow; this is the same full-screen // overlay layer the shell's own emoji picker uses. @@ -15,6 +17,11 @@ PanelWindow { required property var backend property var appLibrary: null property bool shown: false + // Omarchy starts with NumLock on. The launcher owns the keypad mode while + // it has exclusive focus: NumLock flips this, and a locked pad types digits + // instead of moving the list. + property bool numLock: true + property bool numLockTouched: false readonly property bool busy: window.shown && window.backend.loading readonly property int loadingFeedbackDelay: 500 property bool loadingVisible: false @@ -131,7 +138,12 @@ PanelWindow { } onShownChanged: { - if (window.shown) return + if (window.shown) { + window.numLockTouched = false + numLockProbe.running = false + numLockProbe.running = true + return + } window.searchSerial++ window.backend.cancelSearch() window.searchPending = false @@ -146,6 +158,31 @@ PanelWindow { onTriggered: window.popToRoot() } + Process { + id: numLockProbe + command: ["hyprctl", "devices", "-j"] + stdout: StdioCollector { + onStreamFinished: { + if (window.numLockTouched) return + var boards = [] + try { boards = JSON.parse(text).keyboards || [] } catch (e) { return } + var i + for (i = 0; i < boards.length; i++) { + if (boards[i].main && typeof boards[i].numLock === "boolean") { + window.numLock = boards[i].numLock + return + } + } + for (i = 0; i < boards.length; i++) { + if (typeof boards[i].numLock === "boolean") { + window.numLock = boards[i].numLock + return + } + } + } + } + } + function popToRoot() { window.stack = [{ kind: "root", title: "omacast", query: "" }] window.query = "" @@ -426,7 +463,8 @@ PanelWindow { } if (key === Qt.Key_Backspace && !value) { window.argFocus--; return true } if (Util.editsFilter(event, value)) { set(Util.editedFilter(event, value)); return true } - if (window.isTyping(event)) { set(value + event.text); return true } + var typed = KeyText.character(event, window.numLock) + if (typed) { set(value + typed); return true } return false } @@ -530,6 +568,9 @@ PanelWindow { // -1 up, 1 down or 0, per the navigation bindings. Ctrl+P only moves up // inside popups: in the list it opens the dropdown. function direction(event, ctrl, alt, inPopup) { + // Locked numpad 8/2 still arrive as Up/Down. Don't move the list then: + // Up at the top reads search history synchronously. + if (window.numLock && KeyText.isKeypad(event)) return 0 var key = event.key if (!ctrl && !alt && key === Qt.Key_Down) return 1 if (!ctrl && !alt && key === Qt.Key_Up) return -1 @@ -545,11 +586,12 @@ PanelWindow { // Keys for a popup list (action panel, dropdown, toast actions). function handlePopupKey(event, panel, ctrl, alt) { + var typed = KeyText.character(event, window.numLock) + if (typed) { panel.filter += typed; return true } var dir = window.direction(event, ctrl, alt, true) if (dir) { panel.move(dir); return true } if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { panel.activateSelected(); return true } if (Util.editsFilter(event, panel.filter)) { panel.filter = Util.editedFilter(event, panel.filter); return true } - if (window.isTyping(event)) { panel.filter += event.text; return true } return true } @@ -566,13 +608,23 @@ PanelWindow { return true } if (key === Qt.Key_Escape) { window.handleEscape(shift); return true } + if (key === Qt.Key_NumLock) { + if (!event.isAutoRepeat) { + window.numLockTouched = true + window.numLock = !window.numLock + } + return true + } if (ctrl && (key === Qt.Key_Plus || key === Qt.Key_Equal)) { window.setZoom(window.zoom + 0.1); return true } if (ctrl && key === Qt.Key_Minus) { window.setZoom(window.zoom - 0.1); return true } if (ctrl && key === Qt.Key_0) { window.setZoom(1); return true } if (ctrl && key === Qt.Key_BracketLeft) { window.closePopups(); window.pop(); return true } - // Ctrl+V, Ctrl+Shift+V (the terminal habit) and Shift+Insert paste. + // Ctrl+V, Ctrl+Shift+V, and Shift+Insert paste. + // Numpad 0 is Insert with NumLock off; that types "0" instead of pasting. var meta = (event.modifiers & Qt.MetaModifier) !== 0 - if ((key === Qt.Key_V && ctrl && !alt && !meta) || (key === Qt.Key_Insert && shift && !ctrl && !alt && !meta)) { + var insertPaste = key === Qt.Key_Insert && shift && !ctrl && !alt && !meta + && !KeyText.isKeypad(event) + if ((key === Qt.Key_V && ctrl && !alt && !meta) || insertPaste) { window.pasteClipboard() return true } @@ -622,8 +674,8 @@ PanelWindow { if (window.historyIndex >= 0) { window.recallHistory(-1); return true } window.move(1); return true } - if (key === Qt.Key_PageUp) { window.move(-8); return true } - if (key === Qt.Key_PageDown) { window.move(8); return true } + if (key === Qt.Key_PageUp && !(window.numLock && KeyText.isKeypad(event))) { window.move(-8); return true } + if (key === Qt.Key_PageDown && !(window.numLock && KeyText.isKeypad(event))) { window.move(8); return true } if (ctrl && shift && key === Qt.Key_F) { window.performIfOffered(["favorite-add", "favorite-remove"]); return true } if (ctrl && shift && key === Qt.Key_C) { window.performIfOffered(["copy-command", "copy-id", "copy-question-answer"]); return true } if (ctrl && shift && key === Qt.Key_D) { window.performIfOffered(["delete-all-entries"]); return true } @@ -638,15 +690,11 @@ PanelWindow { if (Util.editsFilter(event, window.query)) { window.setQuery(Util.editedFilter(event, window.query)); return true } if (key === Qt.Key_Backspace && !window.query && !window.atRoot) { window.pop(); return true } - if (window.isTyping(event)) { window.setQuery(window.query + event.text); return true } + var typed = KeyText.character(event, window.numLock) + if (typed) { window.setQuery(window.query + typed); return true } return false } - function isTyping(event) { - return event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 - && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier) - } - Connections { target: window.backend function onCandidatesRevisionChanged() { if (window.shown && !window.actionPanelOpen && !window.alert) window.rebuild(false) }