21 KiB
05 — Input Handling (Python xkbcommon + Browser WASM)
This chapter covers input handling in the MIT approach: keyboard (with xkbcommon), pointer, touch, focus management, and how input events travel between browser and native apps.
Architecture (same as Greenfield, but with improvements)
The browser is the source of truth for the keymap (same as Greenfield).
The keymap is compiled in the browser using @gfld/compositor-wasm's xkbcommon
WASM module, then shipped to the Python proxy as a real native shm fd via
the /mkstemp-mmap WebSocket endpoint. The proxy then sends it to native
apps via libwayland-server's SCM_RIGHTS FD passing.
Improvements over Greenfield:
- Real touch input — Greenfield has a 46-line stub that never fires
- Primary selection — middle-click paste, which Greenfield lacks
- Adaptive keyboard layout — easier to switch layouts at runtime
Browser side — Custom TypeScript (clean-room)
Seat abstraction
Same architecture as Greenfield (clean-room from spec + Wayland docs):
Seat.ts—wl_seatglobal, multi-seat support, focus managementKeyboard.ts—wl_keyboardimpl, keymap, modifiers, key eventsPointer.ts—wl_pointerimpl, motion, buttons, axisTouch.ts—wl_touch(REAL implementation, not stub)Xkb.ts— xkbcommon WASM wrapper (REUSED @gfld/compositor-wasm)InputQueue.ts— event coalescing + RAF dispatchInputOutput.ts— FD abstractionevents.ts— KeyEvent, ButtonEvent, AxisEvent wrappers
xkbcommon WASM (REUSED)
@gfld/compositor-wasm (MIT) bundles:
- libxkbcommon 1.5.0 compiled to WASM via emsdk 3.1.46
- Embedded xkeyboard-config database
- Exports
init()+lib.xkbcommonsingleton with keymap/state functions
This is REUSED AS-IS. No custom code needed.
DOM → Wayland key event translation (clean-room)
Same approach as Greenfield (verified correct Wayland behavior):
event.code(e.g."KeyA") looked up in EvDevKeyCode reverse-name-map → evdev keycode (e.g. 30)x11KeyCode = evdevKeyCode + 8(Linux input protocol offset)capsLock/numLockread fromevent.getModifierState(...)- DOM
.keyglyph IGNORED — only physical.codeused (mirrors evdev)
Modifier tracking (REUSED WASM)
Via @gfld/compositor-wasm's xkbcommon state machine:
Seat.updateModifierStatecallsxkb.keyDown(x11)orxkb.keyUp(x11)_xkb_state_update_keyinvoked internallynotifyModifiersreadsxkb.modsDepressed/modsLatched/modsLocked/groupKeyboard.sendModifiers(modsDepressed, modsLatched, modsLocked, group)sends to native client
Touch input (NEW vs Greenfield)
Greenfield has a 46-line stub for Touch.ts. Our MIT approach implements
real touch:
// Pseudocode for Touch.ts (clean-room)
export class Touch {
private touchPoints: Map<number, TouchPoint> = new Map();
constructor(public seat: Seat) {
// Listen to DOM pointer events with pointerType === 'touch'
document.addEventListener('pointerdown', this.onPointerDown.bind(this));
document.addEventListener('pointermove', this.onPointerMove.bind(this));
document.addEventListener('pointerup', this.onPointerUp.bind(this));
document.addEventListener('pointercancel', this.onPointerCancel.bind(this));
}
private onPointerDown(ev: PointerEvent) {
if (ev.pointerType !== 'touch') return;
// Map to wl_touch.down event
this.touchPoints.set(ev.pointerId, { x: ev.offsetX, y: ev.offsetY });
this.sendDown(ev.pointerId, ev.offsetX, ev.offsetY);
}
// ... etc
}
Keymap FD workaround (REUSED approach)
Same as Greenfield (see ../greenfield-analysis/05-input-handling.md):
- Browser compiles keymap via @gfld/compositor-wasm's xkbcommon
inputOutput.mkstempMmap(new Blob([utf8(keymapString)]))opens a/mkstemp-mmapWebSocket, sends the blob- Python proxy creates a real native shm fd via
os.memfd_create()(replaces Greenfield'screateMemoryMappedFilenative addon) - Returns
ProxyFD { handle: <native_fd>, type: 'shm', host: baseURL } - Browser sends
wl_keyboard.keymap(format, ProxyFD, size) - Proxy deserializes, looks up native fd via
proxyFDtoNativeFD, ships via libwayland-server's SCM_RIGHTS
Python side — pywayland + python-xkbcommon
python-xkbcommon (MIT)
The xkbcommon PyPI package (by Stephen Early, MIT-licensed) provides
Python bindings to libxkbcommon. Useful if the proxy needs to compile
keymaps (e.g., for XWayland apps that don't speak Wayland natively).
For most use cases, the proxy doesn't need xkbcommon — it just forwards the keymap from browser to native apps. The browser does the compilation.
Pointer (clean-room)
Same approach as Greenfield:
- DOM
pointermove/pointerdown/pointerup/wheelevents - Mapped to
ButtonEvent/AxisEventwrappers InputQueuecoalesces and dispatches on next RAFDefaultPointerGrab.focus()runs on every motion:Renderer.pickViewwalks the view stack front-to-back, returns first view whoseSurface.isWithinInputRegion(point)(pixman-WASM region) is true- On change,
Pointer.setFocusemitswl_pointer.leave+frameto old surface, thenwl_pointer.enter+frame(+ modifiers if keyboard is grabbed) to new surface
Pointer capture / window leave (REUSED approach)
canvas.setPointerCaptureonpointerdownpointerleavecallsnotifyKeyboardFocusOut(saves keyboard focus, releases depressed keys, setsneedFocusInit=truefor re-sync), resetsbuttonCount=0- DnD suppresses this handler mid-drag
Cursor images (REUSED approach)
CSS cursor: url(data:...) x y, auto:
- Native client calls
wl_pointer.set_cursor - Cursor surface commits a buffer
CursorRoledraws the bitmap to a hidden canvastoDataURL()produces a data URL- Set on
document.body.style.cursor
OS composites the cursor. No rotation/scale transforms.
NEW: Use SVG cursor sprites (e.g., from the Phosphor icon set, MIT)
instead of bundled PNGs (Greenfield's bundled PNGs are AGPL-contaminated
since they're inside @gfld/compositor).
Scroll wheel / touchpad (REUSED approach)
WheelEvent.deltaMode:
DOM_DELTA_LINE × 12— discrete wheel clicksDOM_DELTA_PAGE × surface size— page-sized scrollsDOM_DELTA_PIXEL ÷ 3— fine-grained touchpad
Each non-zero axis → wl_pointer.axis(time, axis, Fixed). Wayland version
≥5 also gets axis_source(wheel) + frame() (groups events).
Linux button codes (REUSED approach)
DOM event.button (0=left, 1=middle, 2=right, 3=Back, 4=forward) mapped
to Linux input protocol button codes via the linuxInput table.
Focus management (REUSED approach)
DOM focus
The <canvas> owns keyboard focus permanently (tabIndex=1).
Keyboard.setFocus(surface) emits wl_keyboard.leave / enter between
native surfaces. Activation is driven by XdgToplevel / ShellSurface
activation handlers.
DOM → Wayland focus
- Browser DOM
focusevent →seat.notifyKeyboardFocusIn()(restores saved focus, re-syncs modifiers) - Browser DOM
blurevent →seat.notifyKeyboardFocusOut()(saves focus, releases depressed keys, setsneedFocusInit=true)
Surface-level focus
- Click on a surface →
XdgToplevelactivation request →desktopSurface.activate()→gainFocus()(refcounted viafocusCount, firessurfaceActivationUpdatedevent to the user shell on 0↔1 transitions) - Popup grabs take keyboard + pointer focus via
seat.popupGrabStart, validating the serial against the keyboard/pointergrabSerial
Input queue (REUSED approach)
InputQueue.ts coalesces events and dispatches them on the next RAF
(InputQueue.drainQueues). Batches bursts of input events (e.g. fast
mouse motion) into a single dispatch, avoiding redundant work.
FIFO per event type (motion, axis, key, button). Each dispatch calls the
appropriate seat.notify* method.
ASCII event-flow diagram
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ BROWSER (DOM) │
│ │
│ <canvas tabIndex=1> ─┐ │
│ pointermove/dn/up ───┤── createButtonEventFromMouseEvent() ── ButtonEvent │
│ wheel ────┤── createAxisEventFromWheelEvent() ── AxisEvent │
│ keydown / keyup ────┤── createKeyEventFromKeyboardEvent() ── KeyEvent │
│ pointerdown (touch) ─┤── createTouchEventFromPointerEvent() ── TouchEvent (NEW) │
│ │ • evdevKeyCode = EvDevKeyCode[event.code] │
│ │ • x11KeyCode = evdevKeyCode + 8 │
│ │ • capsLock/numLock = event.getModifierState(...) │
│ pointerleave / blur ──┤── seat.notifyKeyboardFocusOut() (saves focus, resets mods) │
│ focus ────┴── seat.notifyKeyboardFocusIn() (restores saved focus) │
└──────────────────────────────┬──────────────────────────────────────────────────────┘
│ session.inputQueue.queue{Motion,Axis,Key,Button,Touch}
▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ BROWSER COMPOSITOR (InputQueue.drainQueues, scheduled on next animation frame) │
│ │
│ seat.notifyMotion / notifyAxis / notifyButton / notifyKey / notifyFrame / notifyTouch│
│ │ │
│ ├─► Seat.notifyKey ─► Keyboard.grab.key(event) │
│ │ └─ Keyboard.sendKey(event) │
│ │ └─ keyboardResource.key(serial,time,evdev,st) │
│ │ │
│ ├─► Seat.notifyButton ─► runButtonBindings + grab.button(event) │
│ │ └─ Pointer.sendButton │
│ │ └─ pointerResource.button(serial,time, │
│ │ linuxInput[btn], state) │
│ │ │
│ ├─► Seat.notifyMotion ─► grab.motion ─► Pointer.moveTo / sendMotion │
│ │ └─ pointerResource.motion(time, sx_f, sy_f) │
│ │ │
│ ├─► Seat.notifyAxis ─► grab.axis ─► Pointer.sendAxis │
│ │ deltaMode→scale: line=×12, page=×surface-size, pixel=÷3 │
│ │ └─ pointerResource.axis(time, axis, Fixed) │
│ │ + axisSource(wheel) + frame() if v>=5 │
│ │ │
│ ├─► Seat.notifyTouch (NEW) ─► Touch.sendDown/Motion/Up │
│ │ └─ touchResource.down/motion/up/cancel │
│ │ │
│ ├─► Seat.notifyFrame ─► pointerResource.frame() (groups events) │
│ │ │
│ └─► Seat.notifyModifiers ─ xkb.keyDown/Up(x11) ─► xkb_state_serialize_mods │
│ └─ Keyboard.sendModifiers(modsDepressed,Latched, │
│ Locked,group) │
│ │
│ Keymap path (once per WlKeyboardResource): │
│ Seat.notifyUpdateKeymap(nrmlvo) → createFromNames(rules,model,layout,variant,opt) │
│ → xkb_context_new + xkb_keymap_new_from_names (@gfld/compositor-wasm, MIT) │
│ → xkb_keymap_get_as_string ──► keymapString │
│ Keyboard.sendKeymap(resource): │
│ inputOutput.mkstempMmap(new Blob([utf8(keymapString)])) ──► ProxyFD │
│ (Python proxy creates memfd via os.memfd_create()) │
│ keyboardResource.keymap(xkbV1, ProxyFD, size) │
└──────────────────────────────┬──────────────────────────────────────────────────────┘
│ client.connection.onFlush(wireMessages)
│ ─ one big Uint32Array, FDs serialized as ProxyFD JSON
▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ WIRE (ARQChannel = KCP-over-WebSocket, custom TS, clean-room) │
│ Browser ⇄ ReconnectingWebSocket ⇄ Internet ⇄ aiohttp on proxy :8081 │
│ Message layout (Uint32Array): │
│ [0] outOfBandOpcode (0 = in-band) │
│ [1] eventSerial │
│ [2] = nFds, then for each fd: [len][JSON bytes, 4-byte aligned] │
│ then concatenated Wayland wire messages │
│ + Authorization: Bearer <jwt> │
└──────────────────────────────┬──────────────────────────────────────────────────────┘
│ NativeWaylandClientSession.onMessage (Python)
▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PYTHON PROXY (aiohttp + pywayland) │
│ │
│ SessionController endpoints: /mkfifo /mkstemp-mmap /read-fd /close-fd │
│ /read-fd-as-stream /write-fd-as-stream │
│ /signal /channel /request-keyframe │
│ │
│ mkstempMmap(ws): │
│ receives Blob → os.memfd_create() → write bytes → return fd │
│ returns ProxyFD { handle: <native_fd>, type: 'shm', host: baseURL } │
│ │
│ onWireMessageEvents: │
│ for each ProxyFD in message: │
│ nativeFd = webFS.proxyFDtoNativeFD(proxyFD) # identity when host matches │
│ └─ if foreign host → spawn bridging pipe + WebSocket │
│ pywayland per-resource dispatcher fires │
│ # For seat events, dispatcher is a no-op (events just pass through) │
│ # libwayland-server sends via wl_resource_post_event_array │
│ flush_clients() │
└──────────────────────────────┬──────────────────────────────────────────────────────┘
│ libwayland-server sends wire msg + SCM_RIGHTS FDs
▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ NATIVE (Wayland client, sandboxed in bubblewrap) │
│ wl_display.fd readable → libwayland-client demarshals │
│ wl_keyboard.keymap(fd) → mmap(fd) → xkb_keymap_new_from_string (native xkbcommon) │
│ wl_keyboard.key / enter / leave / modifiers → client app │
│ wl_pointer.enter / motion / button / axis / frame → client app │
│ wl_touch.down / motion / up / cancel → client app (NEW: actually works) │
└──────────────────────────────────────────────────────────────────────────────────────┘
Key answers (quick reference)
| Question | Answer |
|---|---|
| DOM→Wayland key event translation | event.code → EvDevKeyCode → evdev keycode → +8 = x11KeyCode. DOM .key glyph ignored. |
| Modifier tracking | @gfld/compositor-wasm xkbcommon state machine (REUSED MIT) |
| Focus: DOM ↔ native surfaces | <canvas> owns keyboard focus. Keyboard.setFocus(surface) emits leave/enter between native surfaces. |
| Pointer capture / window leave | canvas.setPointerCapture on pointerdown. pointerleave saves focus + releases keys. |
| Touch event mapping | NEW: REAL implementation via pointerdown/pointermove/pointerup with pointerType === 'touch'. Greenfield has only a stub. |
| Pointer enter/leave between surfaces | DefaultPointerGrab.focus() runs on every motion. Renderer.pickView walks view stack. |
| Pointer acceleration / cursor images | No acceleration. CSS cursor: url(data:...) x y, auto. NEW: SVG sprites (MIT) instead of bundled PNGs (AGPL). |
Scroll wheel / touchpad → wl_pointer.axis |
WheelEvent.deltaMode: LINE×12, PAGE×size, PIXEL÷3. v≥5 gets axis_source(wheel) + frame(). |
| WASM xkbcommon? | Yes — @gfld/compositor-wasm (MIT, REUSED). libxkbcommon 1.5.0 + embedded xkeyboard-config. |
| Keymap FD workaround for WebSocket | Browser → /mkstemp-mmap WebSocket → Python proxy os.memfd_create() → ProxyFD JSON → libwayland SCM_RIGHTS to native app. |
| Primary selection (middle-click paste)? | NEW: implemented via zwp_primary_selection_device_manager_v1 (Greenfield lacks). |
File reference
| File (custom, clean-room) | Role |
|---|---|
packages/compositor/src/input/Seat.ts |
wl_seat global |
packages/compositor/src/input/Keyboard.ts |
wl_keyboard impl |
packages/compositor/src/input/Pointer.ts |
wl_pointer impl |
packages/compositor/src/input/Touch.ts |
wl_touch impl (REAL, not stub) |
packages/compositor/src/input/Xkb.ts |
@gfld/compositor-wasm wrapper (REUSED MIT) |
packages/compositor/src/input/InputQueue.ts |
Event coalescing |
packages/compositor/src/input/InputOutput.ts |
FD abstraction |
packages/compositor/src/input/events.ts |
Event wrappers |
packages/compositor/src/browser/input.ts |
DOM keyboard events |
packages/compositor/src/browser/pointer.ts |
DOM pointer events |
packages/compositor/src/browser/touch.ts |
DOM touch events (NEW) |
Reused (MIT)
| File | Role |
|---|---|
libs/compositor-wasm/src/index.ts |
xkbcommon + pixman WASM (REUSED) |
Python side
| Component | Role |
|---|---|
pywayland.server.Client |
wl_client wrapper |
pywayland.server.Resource |
wl_resource base (per-resource dispatchers) |
xkbcommon PyPI package |
Optional, only needed for XWayland keymap compilation |