Keyboard input: implementation decision log¶
Status: in progress. This log records the material decisions and deviations made while implementing the committed spec (requirements, design, tasks). Ordinary implementation choices delegated to the implementer are summarized here for review.
M0 — Revalidate checkout and freeze the small API¶
Repository baseline reconciliation¶
The design and research record cite baseline
6ebdf7c1f2d37840db4fcdce66489f3c263b8d2e. The implemented checkout is a later
revision whose main tip is a66f551 ("docs(input): add keyboard architecture
and Kiro implementation handoff"). The design explicitly says to treat its
repository baseline as historical. Reconciliation of what actually exists:
modules/platform/is the only windowing module. Backends: Wayland (src/window_wayland.cpp), headless null (src/window_headless.cpp), and a browser canvas backend (src/window_web.cpp, Emscripten only).WindowBase::Eventis still an empty struct, andHandleEvent({})pumps the display nonblockingly, returningfalsewhen the window closes or the pump terminally fails. The Wayland pump already implements the prepare/flush/poll/read-or-cancel discipline with EINTR/EAGAIN handling that the spec requires us to preserve. The headless pump returnsfalse.- There is no
wl_seat/wl_keyboardlistener, no input reducer, and no action map. The research record's description of the checkout matches. - A browser canvas input path already exists:
ludus/platform/browser/window.hdefines abrowser::Keyenum (physicalKeyboardEvent.codepositions),browser::InputEvent, and abrowser::WindowStatesnapshot with a held-key array and dropped-event counter, polled viaPollBrowserInput. This is a separate, working canvas-only path and is preserved untouched. The newludus_inputmodule is the backend-independent reducer the browser path does not provide; a future browser adapter can feed the sameIngestcontract (noted in the research record). We do not merge the two enums in this milestone —browser::Keyis DOM-code-shaped (uint8, ~60 values) and the newinput::Keyis the broader physical domain the spec mandates (uint16, with distinct left/right modifiers, keypad, F1–F24). No competing reducer is created on the browser side.
Reused compatible functionality:
ludus::foundation::core::StaticArrayfor all fixed storage (bounded rings, masks, binding tables). No new container is written.LUDUS_LOG_*for bounded lifecycle diagnostics; a dedicatedLOG_INPUTcategory mirroringLOG_PLATFORM.- The existing
WindowBasesink-attach seam: we add an explicitAttachKeyboardSink/DetachKeyboardSinkpair rather than repurposing the emptyEvent, exactly as the design requires.
Module layout (frozen)¶
New static target ludus_input, export Ludus::Input, under modules/input/:
- Public headers in
modules/input/include/ludus/input/:key.h,keyboard_event.h,keyboard.h,actions.h,input_debug.h. - Private helpers in
modules/input/src/internal/. - Public dependency:
Ludus::FoundationBase. Private:Ludus::FoundationContainers(StaticArray) andLudus::FoundationLogging. No Platform, graphics, native OS, or XKB dependency. Platform depends on Input (one-way) for the normalized record/sink vocabulary.
Key vocabulary (frozen)¶
enum class Key : uint16 with explicit stable values. Unknown = 0 reserved.
Dense supported domain with Count as the one-past-the-end sentinel; storage is
sized to Count, not a padded 512. The published table covers: letters A–Z,
digits 0–9 (top row), the US-position punctuation keys, Space/Enter/Escape/Tab/
Backspace, editing+navigation (Insert, Delete, Home, End, PageUp, PageDown, the
four arrows), F1–F24, the keypad (digits, operators, Enter, Decimal, NumLock),
and distinct left/right Shift/Ctrl/Alt/Super. Media/vendor keys are not in
the domain and normalize to Unknown. IsValidKey() validates every external
value (including fabricated enum values and negative native codes) before it is
used as an index. Debug labels like KeyW denote the US-reference physical
position, not the user's layout.
Status enums and public signatures (frozen)¶
enum class AdmissionStatus : uint8 { Accepted, Ignored, RejectedInvalid, RecoveredWithLoss };
enum class StepStatus : uint8 { Ok, InvalidStep, SequenceExhausted };
enum class ActionStatus : uint8 { Ok, InvalidAction };
enum class BindingStatus : uint8 { Ok, InvalidAction, InvalidKey, CapacityExceeded,
ConflictingType, DuplicateBinding };
enum class ResetReason : uint8 { None, FocusLost, WindowClosed, CapabilityLost,
SeatRemoved, BackendDisconnected, Overflow,
MapReplaced, ExplicitNeutral, ExplicitLive };
Core instance operations on ludus::input::InputSystem (main-thread only, no
singleton; caller owns lifetime):
| Operation | Signature |
|---|---|
| Ingest | AdmissionStatus Ingest(const KeyboardRecord&) noexcept |
| Live state | bool GetLiveDown(Key) const noexcept |
| Step | StepStatus ConsumeStep(uint64 stepId) noexcept |
| Snapshot | const KeyboardSnapshot& GetKeyboardSnapshot() const noexcept |
| Step events | StepEventView GetStepEvents() const noexcept |
| Action query | ActionStatus GetAction(ActionId, ActionState& out) const noexcept |
| Rebinding | BindingStatus ReplaceBindings(const BindingMap&) noexcept |
| Reset | void RequestReset(ResetReason, const FocusBaseline&) noexcept |
KeyboardRecord carries source (Native/Synthetic/Replay), transition
(Down/Up), Key, an optional native timestamp (uint32 ms, diagnostic only),
and a focus/baseline marker for enter/leave. GetKeyboardSnapshot() returns a
reference stable until the next ConsumeStep/reset publication. GetStepEvents()
is borrowed until the next ConsumeStep.
Fixed capacities (frozen, private constants)¶
- Pending transition ring: 256 accepted transitions.
- Output step-event buffer: 256 + 1 reset marker.
- Actions: 64. Bindings: 256.
- Trace ring: 256 entries (opt-in).
Capacities are template parameters on the internal storage so tests can
instantiate a tiny capacity for overflow/exhaustion coverage without touching
production sizes. Overflow returns RecoveredWithLoss and increments counters;
it never grows, drops-oldest silently, or blocks.
Sequence and step semantics (frozen)¶
A monotonically increasing uint64 ingestion sequence orders records; native
Wayland time is retained only as diagnostic metadata and never compared or
sorted on. Sequence exhaustion returns an explicit status and forces a reset
rather than wrapping. ConsumeStep requires a strictly increasing uint64
step id; a repeated or decreasing id returns InvalidStep and changes nothing;
uint64 exhaustion returns SequenceExhausted. All pending events are assigned
to the next executed step in callback order (edges on the first catch-up step,
held state retained on later catch-up steps; the queue is retained across zero
simulation steps).
Neutral/invalid results (frozen)¶
GetActionfor an id outside the configured domain →InvalidAction, output untouched / neutral.- An id removed by map replacement that was previously active → neutral value
with
Cancelled = truefor exactly the publication that computes the replacement, then neutral/no-flags afterward. - Unknown/fabricated
Key→ record rejected (RejectedInvalid/Ignored), no out-of-bounds access, no state change.
Zero-step and three-step walkthrough¶
Batch ingested while no step runs: Down(W), Up(W), Down(W) (sequences
1,2,3). Live Down(W) tracks the final physical state (true after seq 3).
- Zero steps between pumps → the three records stay queued; snapshot unchanged.
ConsumeStep(10): applies all three in order →Down(W)=true,Pressed(W)=true,Released(W)=true(two presses + one release are OR-folded into the step edge flags;GetStepEvents()preserves all three for consumers that need multiplicity).ConsumeStep(11): no new events → edges cleared,Down(W)=trueretained.ConsumeStep(12): still no events → edges cleared,Down(W)=trueretained.
Seat selection and evdev mapping assumption (frozen)¶
One wl_seat is chosen deterministically: the lowest registry name that
advertises keyboard capability, held until it is removed; a replacement seat
starts from reset state (no held-state merge). The adapter maps native Linux
evdev key positions (the convention of the selected backend) to Key via an
explicit, tested table — no unchecked cast of Wayland numbers, and XKB's +8
offset is not applied for mask indexing. If the backend/keymap cannot
establish an evdev mapping, the adapter reports unsupported input and keeps
neutral state; it never guesses characters. Keymap fds are closed on every path.
Toolchain / environment note (not a code deviation)¶
The sandbox shipped clang-15, cmake 3.22, and no conan; the repo pins clang-18,
cmake 3.29.6, ninja 1.11.1.3, conan 2.8.1. The pinned tools were provisioned
(system clang-18/lld-18/clang-tools-extra, Wayland dev packages, and
init.sh --no-system-install for the venv/conan graph). One local, gitignored
out/host-tools/bin/clang-tidy wrapper reorders clang-tidy-18 --version output
so the detector reads a parseable first line; the real binary is pinned
clang-tidy 18.1.8. No repository file was changed to accommodate the environment.
M2 — Native Wayland keyboard adapter¶
Sink seam¶
Rather than repurposing the empty WindowBase::Event, a new public header
ludus/platform/keyboard_sink.h defines a KeyboardSink of three noexcept
function pointers (OnRecord, OnReset) plus a borrowed void* UserData.
WindowBase gained AttachKeyboardSink/DetachKeyboardSink/GetKeyboardSink.
The sink is stored on the window; the Wayland backend routes native events to
the focused window's sink. Callbacks never retain borrowed Wayland payloads —
the focus-enter key array is normalized into a copied FocusBaseline before the
callback returns.
Pump preservation¶
The original HandleEvent({}) body (prepare/flush/poll(0)/read-or-cancel with
EINTR/EAGAIN) was extracted verbatim into pumpDisplayOnce(). HandleEvent
now calls it and, only when it returns false, submits a single WindowClosed
reset to the focused window's sink before returning false. Headless return
semantics are unchanged. The live ludus_platform_wayland_tests regression
(10440 assertions) confirms unchanged pump behavior against a real compositor.
evdev mapping¶
src/internal/evdev_keymap.hpp maps the raw Linux evdev code carried by
wl_keyboard.key directly to Key via a compile-time table. The XKB +8
offset is deliberately NOT applied (it is only for a future XKB keysym lookup).
Out-of-range / unmapped / vendor / media codes normalize to Key::Unknown. The
table is zero-initialized and relies on Key::Unknown == 0 (static_assert'd).
Compositor validation tooling (environment)¶
The reference compositor used for live validation here is weston 13.0.3
(headless backend), installed into the sandbox. The sandbox has no uinput /
/dev/input virtual input device, and weston's headless desktop-shell does not
grant keyboard focus to a buffer-less surface, so synthetic key injection and a
focused key-delivery assertion could not be performed in-sandbox. The live test
therefore verifies seat/keyboard binding, listener registration, and pump
stability, and records a WARN when focus is not granted. Full focus + key
delivery + held-state-on-leave is an outstanding manual gate (M5 matrix).
M3-M5 summary¶
- M3 (actions/rebinding): the capacity-parameterized reducer already carried
the button/axis aggregate from M1; M3 added the full action test suite
(
modules/input/tests/action_tests.cpp). Clarification recorded while writing the tests: a 1-D axis with separate +/- keys cannot change sign without passing through zero, so a "+1 to -1" change in one step DOES fire a real Released+Pressed (per-transition, OR-accumulated); the design's "without an intervening zero" clause describes the degenerate single-transition sign flip that these bindings cannot produce. A steady held axis across an idle step fires no repeated edge. - M4 (trace/replay/perf):
InputDebugTrace, trace recording in the reducer (accepted transitions traced at step time with their assigned StepId), replay fixtures, and theinput_benchharness. Footprint and timings:docs/development/keyboard-input-performance.md(15.8 KiB reducer + 16.0 KiB trace; 1 construction allocation, 0 hot-path allocations). - M5 (SDK/demo/validation): the installed SDK consumer links
Ludus::Inputand exercises a tap + rebind;apps/input_demodemonstrates the loop.
Manual verification matrix (M5)¶
The following require a compositor that grants keyboard focus to the demo window
and a real/virtual keyboard delivering key events. The sandbox used for
implementation has weston 13.0.3 (headless) which — verified by enumerating
the compositor's advertised globals — provides no wl_seat at all (the
headless backend creates no input devices), and the container has no
/dev/uinput / /dev/input, no modprobe, and no wlroots virtual-keyboard
protocol in the available wayland-protocols. There is therefore no in-sandbox
path to deliver real physical key events; the residual gate is strictly the
OS->compositor transport. These steps are documented for a maintainer with a
desktop Wayland session. Every step's semantics are covered by an automated test
through the production code (named in parentheses); the follow-up branch added
direct coverage of the enter-array and key-event normalization the native
listeners perform (BuildFocusBaseline/BuildKeyRecord), so only the transport
itself is unverified in-sandbox.
| Manual step | Expected | Automated analogue |
|---|---|---|
| Tap a bound key | one Pressed+Released, action inactive | action_tests "rapid tap"; wayland_keyboard_tests "Key event maps press/release" |
| Hold a bound key across frames | Pressed once, held after; one Released on up | reducer_tests "Down, repeated Down, held idle step, Up" |
| Press simultaneous opposite axis keys | axis Value 0 | action_tests "opposite directions cancel" |
| Alternative keys (W or UpArrow) | releasing one keeps action active | action_tests "multiple alternative keys" |
| Alt-tab (focus leave) while held | action cancelled, held cleared | reducer_tests "Focus leave with queued tap/held key" |
| Regain focus while key still held | key Down but suppressed; no activation | reducer_tests "Focus enter with held W"; wayland_keyboard_tests "Enter array normalizes evdev held keys" |
| Release+repress the held key | suppression clears; genuine press | wayland_keyboard_tests "Enter baseline then released keys match a reducer suppression cycle" |
| Rebind while a key is held | old action cancels; held key suppressed under new map | action_tests "Switch map while held" |
| Close the window | final WindowClosed reset to the sink | window_wayland HandleEvent close path (code) |
| Idle rendering (no keys) | pump stable; snapshot held state retained | wayland_event_tests + reducer idle-step tests (live weston) |
The native enter/key listeners call the SAME BuildFocusBaseline /
BuildKeyRecord helpers these tests exercise, so a maintainer running the matrix
on a desktop session is validating only that the compositor delivers the evdev
codes — the normalization and routing are already proven.
To run the demo on a real session:
./scripts/build linux-clang-debug
./out/build/linux-clang-debug/apps/input_demo/ludus_input_demo
Record the compositor and its version in the PR when performing this matrix.
Known limitations (as implemented)¶
- Keyboard only; mouse/gamepad/touch/text/IME/layout display are deferred.
- One attached gameplay window and one selected seat; the backend tracks multiple windows only for safe routing.
- Physical labels (
KeyW) denote US-reference positions, not the user's layout. - No libxkbcommon dependency; keymap fds are closed, never compiled.
- Only the OS->compositor key-DELIVERY transport in the manual matrix above is unverified in-sandbox (no seat/uinput here); the normalization and routing it feeds are covered by automated tests.