Cross-platform keyboard cleaning utility: locks the keyboard, visualizes keypresses on a virtual ISO 105-key layout, and suppresses input to the OS so the user can wipe their physical keyboard without triggering commands.
Feasible with XGrabKeyboard. Any X11 client can call XGrabKeyboard to redirect all
keyboard events to its own window. This is the same mechanism screen lockers use.
- Library:
python-xlib(optional dependency). - Privilege: none required.
- Caveat: Alt+F4 may be intercepted by the WM before the grab takes effect if the window is
not fullscreen or
override_redirect. Running fullscreen via pygame solves this.
Best-effort only. Wayland's security model prevents clients from grabbing global input.
SDL2 (≥ 2.28) supports zwp_keyboard_shortcuts_inhibit_manager_v1, which inhibits
compositor shortcuts (Super, Alt+Tab, etc.) while the window is focused and fullscreen on
supporting compositors (GNOME/Mutter, KDE/KWin, Sway).
- Kernel-level shortcuts (Ctrl+Alt+F*) cannot be blocked from userspace.
- The app will detect Wayland via
$XDG_SESSION_TYPEand print a warning about limitations.
Feasible with pynput. Under the hood pynput uses CGEventTap from the Quartz
framework to intercept and suppress keyboard events system-wide.
- Privilege: Accessibility permission required (System Preferences → Privacy → Accessibility). The app will prompt the user on first launch.
- Touch Bar events are not covered; physical and external keyboards are.
Feasible with pynput. pynput uses SetWindowsHookEx(WH_KEYBOARD_LL) to install a
low-level keyboard hook that intercepts and can suppress all keystrokes.
- Privilege: no admin rights required.
- Caveat:
Ctrl+Alt+Del(Secure Attention Sequence) always reaches the OS. Documented as expected behavior.
If native grabbing fails or pynput is unavailable, the app falls back to "pygame-only" mode
(fullscreen + SDL event capture). A warning banner is displayed in this mode since Alt+Tab
could still escape.
USB HID keyboards poll at 125–1000 Hz. Mashing 10 keys generates up to ~20,000 events/sec on a 1000 Hz keyboard.
Strategy — decouple input from rendering:
- The event loop drains the entire
pygame.event.get()queue every frame. - Rendering is capped at 60 FPS (one render pass per frame).
- Key state is a
set[int]of currently-pressed scancodes; updated per event, rendered once. - A key that is pressed and released between two frames simply increments the counter without ever being visually highlighted — this is correct and acceptable.
- SDL2's internal event queue holds up to 65,535 events. At 60 FPS we drain it every ~16 ms. Even at 20,000 events/sec that is ~333 events per frame — well within the buffer.
- The strike counter is a plain
int(Python ints are arbitrary precision).
No throttling or debouncing is needed. The architecture is inherently safe.
keyclean/
├── .editorconfig # existing
├── .gitignore # new
├── LICENSE # new — Unlicense
├── Makefile # existing — updated
├── PLAN.md # this file
├── README.md # new — brief usage docs
├── pyproject.toml # new — uv/pip build config
├── tox.ini # new — cross-version testing
├── src/
│ └── keyclean/
│ ├── __init__.py # version string, package metadata
│ ├── __main__.py # entry point: python -m keyclean
│ ├── app.py # main App class, game loop, exit handling
│ ├── config.py # constants: colors, sizes, exit phrase, FPS
│ ├── keyboard_layout.py # ISO 105-key definitions: positions, sizes, labels, keycodes
│ ├── renderer.py # draws date and time on top "+%Y-%m-%d %H:%M:%S.%N %z %Z", keyboard in the middle, highlights, counter, help text, "Done" button
│ ├── safety_sequence.py # ring buffer of typed chars, checks for "keys are clean"
│ └── input_grabber/
│ ├── __init__.py # get_grabber() factory
│ ├── _base.py # AbstractGrabber (Protocol / ABC)
│ ├── _pynput.py # pynput-based grabber (macOS + Windows + Linux/X11 fallback)
│ ├── _x11.py # XGrabKeyboard via python-xlib
│ ├── _wayland.py # best-effort: rely on SDL2 shortcuts_inhibit, warn user
│ └── _fallback.py # pygame-only mode with warning banner
└── tests/
├── conftest.py
├── test_app.py
├── test_keyboard_layout.py
├── test_renderer.py
├── test_safety_sequence.py
└── test_input_grabber.py
| Decision | Choice | Rationale |
|---|---|---|
| UI library | pygame-ce |
Actively maintained, SDL2-backed, Wayland shortcuts_inhibit support |
| Input suppression (macOS/Windows) | pynput |
Unified API, well-maintained, avoids raw ctypes/pyobjc |
| Input suppression (Linux X11) | python-xlib |
XGrabKeyboard is the correct tool; pynput on X11 uses Xlib under the hood but doesn't expose grab |
| Input suppression (Linux Wayland) | SDL2 built-in | Only option from userspace; document limitations |
| Keyboard layout | ISO 105-key | User requirement |
| Window mode | Fullscreen | Better input suppression, user requirement |
| Exit phrase | keys are clean |
User requirement — typed literally to exit |
| Exit button | "Done" button (mouse click) | Secondary exit method |
| Build backend | hatchling |
Lightweight, modern, works with uv |
| Python version | 3.9+ | Broad compatibility |
- Create
pyproject.tomlwith all dependencies and metadata. - Create
src/keyclean/__init__.py(version string). - Create
src/keyclean/__main__.py(python -m keycleanentry point). - Create
tox.inifor Python 3.9–3.13 testing. - Create
.gitignore(Python + pygame + build artifacts). - Create
LICENSE(Unlicense text). - Update
Makefile: fixtest_upload/uploadtargets to usekeyclean-*. - Verify
uv syncanduv run python -m keycleanwork (app starts and immediately exits).
- Define the ISO 105-key layout in
keyboard_layout.py:- Each key:
(row, col, width, height, label, pygame_keycode, scancode). - Rows: Esc/F-keys, number row, QWERTY, home row, bottom row, space bar row.
- Include: numpad, arrows, Insert/Delete/Home/End/PgUp/PgDn block.
- The extra ISO key (between left Shift and Z).
- Each key:
- Define visual constants in
config.py: key size in pixels, gap, colors, font sizes.
renderer.py: pure rendering module, no input logic.draw_keyboard(surface, layout, pressed_keys)— draws all keys; pressed keys highlighted.draw_counter(surface, count)— persistent strike counter, top-right corner.draw_done_button(surface)— clickable rectangle, returns itsRectfor hit-testing.draw_warning_banner(surface, message)— for fallback/Wayland mode warnings.
- Auto-scale the layout to fit the screen resolution.
app.py:Appclass andmain()function.- Flow:
- Initialize pygame, go fullscreen, hide mouse cursor (or keep for "Done" button).
- Acquire input grabber via
get_grabber(). - Main loop (60 FPS):
- Drain all events from
pygame.event.get(). - On
KEYDOWN: add topressed_keys, increment counter, feed char to safety sequence. - On
KEYUP: remove frompressed_keys. - On
MOUSEBUTTONDOWN: check hit on "Done" button → exit. - Render: keyboard, counter, button, optional warning banner.
- Drain all events from
- On exit: release grabber, quit pygame.
safety_sequence.py:SafetySequenceclass holding the target phrase ("keys are clean").- Internal ring buffer (collections.deque with maxlen = len(phrase)).
feed(char: str) -> bool: appends char, returnsTrueif buffer matches phrase.- Case-insensitive matching (so Caps Lock doesn't break it).
- Wire into
app.py: onKEYDOWN, if the key maps to a printable char, feed it.
_base.py:AbstractGrabberwithgrab()andrelease()methods._fallback.py: no-op implementation; sets a flag for the warning banner._x11.py:XGrabKeyboardon the pygame window's X11 window ID._pynput.py:pynput.keyboard.Listenerwithsuppress=Trueon macOS/Windows._wayland.py: no-op (SDL2 handles it); sets a flag for the Wayland warning.__init__.pyfactory:platform → session type → try native grabber → fallback Linux + X11 → _x11.py (python-xlib) Linux + Wayland → _wayland.py (best-effort) macOS → _pynput.py Windows → _pynput.py any failure → _fallback.py
test_safety_sequence.py: feed exact phrase, partial, wrong chars, case variations.test_keyboard_layout.py: all 105 keys defined, no overlapping positions, all keycodes unique.test_renderer.py: mockpygame.Surface, verifydraw_*functions callblit/draw.rect.test_input_grabber.py: factory returns correct grabber type per platform (mocksys.platformand env vars); grab/release lifecycle.test_app.py: simulate a sequence ofKEYDOWN/KEYUPevents, verify counter increments and exit on safety sequence.
tox.ini: test matrix for Python 3.9–3.13.- Verify
pylintpasses with the configuredmax-line-length = 120. - Verify
autopep8formatting. - Write a brief
README.md(installation, usage, exit methods, platform notes). - Final
Makefilecleanup.
| Package | Required | Platform | Purpose |
|---|---|---|---|
pygame-ce ≥ 2.4.0 |
always | all | UI, event loop, rendering |
pynput ≥ 1.7.6 |
optional | macOS, Windows | global keyboard suppression |
python-xlib ≥ 0.33 |
optional | Linux (X11) | XGrabKeyboard |
autopep8 |
dev | all | code formatting |
pylint |
dev | all | linting |
pytest |
dev | all | testing |
pytest-mock |
dev | all | input simulation in tests |
pytest-cov |
dev | all | coverage reporting |
tox |
dev | all | cross-version test runner |
twine |
dev | all | PyPI upload |
build |
dev | all | wheel/sdist building |
| Risk | Mitigation |
|---|---|
Wayland compositor doesn't support shortcuts_inhibit |
Warning banner; document known compositors |
pynput suppress=True conflicts with pygame's own event handling |
pynput listener runs in its own thread; pygame only reads its internal SDL queue; no conflict |
| macOS Accessibility permission not granted | Detect failure, fall back to pygame-only mode with warning |
| SDL2 version too old for Wayland inhibit | Check pygame.get_sdl_version() at startup; warn if < 2.28 |
| User types exit phrase accidentally during cleaning | Phrase "keys are clean" is 14 chars, highly unlikely from random mashing |