Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions core/KeyText.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
275 changes: 275 additions & 0 deletions tests/keytext.test.js
Original file line number Diff line number Diff line change
@@ -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])
})
Loading