Input and shortcut notes
August 10, 2026 · View on GitHub
Reference material behind the design decisions in this project. Verified on 2026-08-10 against KDE Plasma 6.6.6 / KWin 6.6.6 on Wayland, on a Lenovo ThinkPad E15 Gen 3 running Ubuntu. Marked where something is inferred rather than observed.
Why a leader key rather than one binding per macro
Every global shortcut you create consumes its key everywhere. On a laptop the supply of keys that nothing else wants is small, so binding a dozen macros directly means a dozen arguments with applications, or a dozen unmemorable three-modifier chords.
A leader key spends that scarce resource exactly once. Everything behind it is a single unmodified keystroke, because while the pad has focus it is an ordinary application reading ordinary key events — no global grabs involved.
Numpad keys are distinct, but only above the terminal
A recurring question when picking a leader key is whether the keypad + is a
different key from the + on the main row. It is, at every layer except the one
people usually try first:
| Layer | Numpad + | Main + | Distinguishable? |
|---|---|---|---|
| evdev / kernel | KEY_KPPLUS (78, scancode 4e) | Shift + KEY_EQUAL (13) | yes |
| xkb keysym | KP_Add | plus | yes |
| Qt / KDE shortcut string | Num++ | + | yes |
| Terminal byte stream | + | + | no |
A numeric keypad only sends distinguishing escape sequences in keypad
application mode. In normal mode numpad + sends the bare byte +, identical
to the main-row key, so a readline or terminal-level binding cannot tell them
apart. Anything that needs to distinguish them has to sit at the compositor
level or below.
Confirm what a key really emits by reading evdev directly — you need to be in the
input group:
import struct, glob, os
# find the device under /sys/class/input/event*/device by its 'name' file,
# then read 24-byte "llHHi" records from /dev/input/eventN.
# type 0x01 is EV_KEY; value 1 is press, 0 release, 2 autorepeat.
NumLock does not enter into it. Capturing on this machine with NumLock off
still produced KEY_KPPLUS. NumLock only affects how the compositor interprets
the keypad, so an evdev-level reader is immune to its state.
KWin 6.6 does not fire global shortcuts on numpad keys
Binding numpad + as a KDE global shortcut, with the correct key encoding,
registered cleanly and then never fired. The key reached the kernel — confirmed
by evdev capture — and KWin never launched the action, confirmed by the absence
of any activation in the journal.
So: test a numpad leader key before relying on it. If it does not work,
either use a normal key, or move the trigger below the compositor entirely
(keyd, or a udev hwdb rewrite feeding a userspace reader).
The correct encoding, for the record, since getting it wrong looks identical to the bug above:
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence
c = Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Plus
QKeySequence(c).toString(QKeySequence.SequenceFormat.PortableText) # 'Num++'
c.toCombined() # 536870955
Num+ is Qt's rendering of KeypadModifier, so it prefixes like a modifier —
numpad 7 is Num+7, and numpad + is the odd-looking Num++.
Registering a shortcut on Plasma 6
Plasma 6.6 has no separate kglobalacceld process. The org.kde.kglobalaccel
bus name is served by KWin itself — introspecting it returns /KWin,
/Compositor and /Effects alongside /kglobalaccel. /usr/bin/kglobalaccel5
may exist but is the Plasma 5 daemon and is irrelevant.
Consequence: editing ~/.config/kglobalshortcutsrc by hand does nothing until
the next login. There is no config-reload method, and KWin cannot be restarted
on Wayland without ending the session.
Register over D-Bus instead, which is what the Custom Shortcuts KCM does. The
action id is a four-element list — [componentUnique, actionUnique, componentFriendly, actionFriendly] — and for a .desktop launcher the component
is the desktop file name and the action is always _launch:
ACTION="['virtualmacropad.desktop','_launch','Virtual Macropad','Launch']"
gdbus call --session --dest org.kde.kglobalaccel --object-path /kglobalaccel \
--method org.kde.KGlobalAccel.doRegister "$ACTION"
gdbus call --session --dest org.kde.kglobalaccel --object-path /kglobalaccel \
--method org.kde.KGlobalAccel.setForeignShortcut "$ACTION" "[16777272]"
setForeignShortcut is the variant for actions owned by another process, which
is exactly the case for a desktop-file service. kglobalaccel then persists the
entry to kglobalshortcutsrc itself — a useful way to confirm you got the key
encoding right, since it writes back its own rendering of the key.
Read back what is bound to a key:
gdbus call --session --dest org.kde.kglobalaccel --object-path /kglobalaccel \
--method org.kde.KGlobalAccel.getGlobalShortcutsByKey 16777272
Do not put a hyphen in the .desktop filename
kglobalaccel derives a D-Bus object path from the component name
(virtualmacropad.desktop → /component/virtualmacropad_desktop). Dots become
underscores, but hyphens are not valid in a D-Bus object path element at all,
and every component registered on a stock system uses underscores only. Hence
virtualmacropad.desktop rather than the more natural virtual-macropad.desktop.
The executable can have a hyphen; only the desktop file matters. (The failure
mode is inferred — the name was chosen defensively rather than tested.)
Choosing an injection backend
wayland-info | grep virtual_keyboard tells you whether wtype can work.
On the KWin session tested, it returned nothing: KWin does not advertise
zwp_virtual_keyboard_manager_v1, so wtype has nothing to talk to and
ydotool is the only real option.
ydotool needs its daemon. Check for the socket, not just the binary:
pgrep -a ydotoold
ls -l /run/user/$(id -u)/.ydotool_socket
If the socket is user-owned you do not need root. ydotool key takes raw evdev
keycodes as <code>:<1|0> pairs, not names — Enter is 28:1 28:0.
Layout sensitivity
Because ydotool emits keycodes rather than characters, the compositor maps them
through the active xkb layout. With a Hebrew layout selected, typing claude
produces Hebrew letters. KWin exposes layout switching on D-Bus:
qdbus6 org.kde.KWin /Layouts org.kde.KeyboardLayouts.getLayout # current index
qdbus6 org.kde.KWin /Layouts org.kde.KeyboardLayouts.setLayout 0 # force index 0
Indices count from zero in the order of the LayoutList line in
~/.config/kxkbrc. virtual_macropad/layout.py wraps this as a context manager
and restores the previous layout afterwards.
Focus handoff
The pad must not fire a macro while it still holds focus, or the synthetic
keystrokes land in the pad instead of the window the user was working in. The
sequence is: highlight the chosen slot, hide the window, pump the event loop,
wait focus_delay_ms, then inject.
The delay is timing based rather than event based, which is unsatisfying but practical — there is no portable way to be told "the previous window has focus again". 120 ms was reliable in testing; raise it if leading characters go missing.