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:

LayerNumpad +Main +Distinguishable?
evdev / kernelKEY_KPPLUS (78, scancode 4e)Shift + KEY_EQUAL (13)yes
xkb keysymKP_Addplusyes
Qt / KDE shortcut stringNum+++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.