Files
libakgl/docs/15-input.md
Andrew Kesterson b938460127 Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.

The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.

Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.

Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.

README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.

Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:37 -04:00

352 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 15. Input
libakgl does not have an input system so much as a *binding* system. SDL3 owns events,
keycodes, keymods, gamepad enumeration and text composition, and it documents all of them
in the [SDL3 wiki](https://wiki.libsdl.org/SDL3/) — start with
[SDL_Event](https://wiki.libsdl.org/SDL3/SDL_Event),
[SDL_Keycode](https://wiki.libsdl.org/SDL3/SDL_Keycode) and
[CategoryGamepad](https://wiki.libsdl.org/SDL3/CategoryGamepad). Nothing here replaces any
of it.
What libakgl adds is two things: a table that says *"when event X arrives from device Y
carrying key or button Z, call this handler on this actor"*, and a small ring buffer of
keystrokes for a caller who wants to ask "is there a key waiting" without owning the event
loop.
## Input is pushed, not polled
**The host pumps every SDL event into `akgl_controller_handle_event`.** There is no
`akgl_input_update()` that reads the keyboard state for you, and there is no internal event
loop. Whatever drains the SDL event queue — your `while ( SDL_PollEvent(&e) )`, or SDL's
`SDL_AppEvent` callback — hands each event over, unconditionally.
Unconditionally is the point: **an event nothing binds is not an error.** It returns
success having done nothing, which is what makes the blanket call correct and keeps the
host from having to know which events matter.
```c
#include <SDL3/SDL.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
* Bind WASD on top of control map 0, which akgl_controller_default has already
* filled with the four arrow keys and the four D-pad directions.
*
* Bindings are appended and scanned in push order, and the first match stops
* the scan -- so these fire only for keys the eight defaults do not claim.
* There is no way to remove one; zeroing akgl_controlmaps[id] is the only reset.
*/
akerr_ErrorContext AKERR_NOIGNORE *bind_wasd(int mapid, SDL_KeyboardID kbid)
{
akgl_Control control;
PREPARE_ERROR(errctx);
PASS(errctx, akgl_controller_default(mapid, "player", kbid, 0));
/* Copied in by value, so one stack local does for every push. */
SDL_memset(&control, 0x00, sizeof(akgl_Control));
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_A;
control.handler_on = &akgl_actor_cmhf_left_on;
control.handler_off = &akgl_actor_cmhf_left_off;
PASS(errctx, akgl_controller_pushmap(mapid, &control));
control.key = SDLK_D;
control.handler_on = &akgl_actor_cmhf_right_on;
control.handler_off = &akgl_actor_cmhf_right_off;
PASS(errctx, akgl_controller_pushmap(mapid, &control));
SUCCEED_RETURN(errctx);
}
/* Every event, unconditionally. An event nothing binds is success, not a
* failure, which is what makes the unconditional call correct. */
akerr_ErrorContext AKERR_NOIGNORE *pump(void *appstate, SDL_Event *event)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_controller_handle_event(appstate, event));
SUCCEED_RETURN(errctx);
}
```
## Two independent paths out of one call
`akgl_controller_handle_event` does two unrelated things with the event it is given, and
**neither one suppresses the other**:
```text
akgl_controller_handle_event(appstate, event)
|
+----------------------+----------------------+
| |
(a) the keystroke ring, FIRST (b) the control maps, SECOND
| |
KEY_DOWN -> push {key, mod, ""} for map 0..7 (target != NULL):
TEXT_INPUT -> attach text to the for control 0..31, in push order:
newest press, or push event type == event_on/off ?
a text-only entry device id == kbid / jsid ?
| key/button == this binding ?
| -> call the handler and
akgl_controller_poll_key() STOP the entire scan
akgl_controller_poll_keystroke()
akgl_controller_flush_keys()
```
The ring is filled **first**, before the maps are consulted, and *whether or not* a map
claims the key. A key that drives an actor is still a key an embedded interpreter polling
with `akgl_controller_poll_key()` wants to see.
### The control-map scan
```c excerpt=include/akgl/controller.h
/** @brief How many control maps exist -- effectively the local player limit. */
#define AKGL_MAX_CONTROL_MAPS 8
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
#define AKGL_MAX_CONTROLS 32
```
Eight maps means up to eight locally controlled players, each on its own keyboard and
gamepad. A map with a `NULL` `target` is skipped entirely — that is how an unused slot is
spelled.
**Maps are scanned in index order, bindings within a map in push order, and the first
match wins and stops the whole scan.** A key bound in two maps only ever fires in the
lower-numbered one. A key bound twice in one map only fires on the earlier push. This is
what makes `akgl_controller_default` followed by your own pushes behave sanely: the
defaults are already in slots 07 and your additions land after them.
A match requires **all three** of: the event type equals the binding's `event_on` or
`event_off`; the device id on the event equals the map's `kbid` (keyboard events) or `jsid`
(gamepad events); and the key or button equals the binding's. The device-id half is what
keeps two players on two keyboards apart, and it is exact — `akgl_controller_list_keyboards`
logs every attached keyboard and its id, which is how you find the number to pass.
There is no way to remove a binding. `akgl_controller_pushmap` appends and that is the
whole API; the only reset is zeroing `akgl_controlmaps[id]` yourself. **Calling
`akgl_controller_default` twice on one map leaves sixteen bindings**, and the first eight
are the ones that fire.
### The keystroke ring
```c excerpt=src/controller.c
static void keybuffer_push_key(SDL_Keycode key, SDL_Keymod mod)
{
int tail = 0;
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
keybuffer_awaiting_text = false;
return;
}
tail = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
keybuffer[tail].key = key;
keybuffer[tail].mod = mod;
keybuffer[tail].text[0] = '\0';
keybuffer_count += 1;
keybuffer_awaiting_text = true;
}
```
Thirty-two entries (`AKGL_CONTROLLER_KEY_BUFFER`), one fixed array in the library's data
segment, **one ring for the whole process** — it is a file-scope static, not per map and
not per actor.
**A full buffer drops the newest keystroke, not the oldest.** A caller reading a line of
input wants the characters that were typed first; overwriting the head would hand it the
tail of what the user typed and silently lose the beginning.
Two pollers drain the same ring, and a keystroke taken by one is not waiting for the
other:
| Call | Hands back | Discards |
|---|---|---|
| `akgl_controller_poll_key(&keycode, &available)` | The SDL keycode, as an `int` | Entries carrying only composed text and no keycode — there is no key to report |
| `akgl_controller_poll_keystroke(&dest, &available)` | `key`, `mod` and the composed `text` | Nothing |
| `akgl_controller_flush_keys()` | — | Everything waiting |
An empty ring is success, not a failure: `available` comes back `false` and the caller
polls again next frame. **Check `available`, not the keycode against 0** — a text-only
entry legitimately has a keycode of 0.
**The `text` field needs `SDL_StartTextInput()` on your window.** SDL reports one keystroke
as two events — the key going down, and then the character it composed to, if it composed
to anything — and it only sends the second while text input is started. Without that call
`key` and `mod` still arrive and `text` is always the empty string. That is not a libakgl
limitation to work around; **it is the only correct way to get a character out of SDL**, and
it is what makes a keyboard layout, a compose key, a dead key and an IME commit work. `"`,
`!`, `(`, `)`, `:` and `;` are all unreachable from a keycode alone, and so is every
lower-case letter. See
[SDL_StartTextInput](https://wiki.libsdl.org/SDL3/SDL_StartTextInput).
An entry with composed text and **no** keycode is not a degenerate case: an input method or
a dead key finishes a character with no key press of its own, and a line editor wants that
character. `akgl_controller_poll_keystroke` reports it; `akgl_controller_poll_key` cannot,
and drops it on the way past.
Text longer than `AKGL_CONTROLLER_KEYSTROKE_TEXT` (8 bytes) — an IME committing a whole
word at once — is truncated **on a code point boundary**, never through the middle of a
UTF-8 sequence.
```c
#include <SDL3/SDL.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
* Drain the keystroke ring into a line buffer, the way an embedded interpreter
* or a name-entry screen wants it.
*
* The `text` field is only populated while SDL text input is started on the
* window, so a caller that wants punctuation, lower case, dead keys or an IME
* commit calls SDL_StartTextInput() first. Without it `key` and `mod` still
* arrive and `text` is always empty.
*/
akerr_ErrorContext AKERR_NOIGNORE *read_line(char *dest, size_t destsize, bool *done)
{
akgl_Keystroke stroke;
bool available = false;
size_t used = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_ZERO_RETURN(errctx, done, AKERR_NULLPOINTER, "done");
used = SDL_strlen(dest);
*done = false;
/* The ATTEMPT block goes outside the loop: CATCH reports failure by
* break-ing, which would bind to the loop instead of the block. */
ATTEMPT {
for ( ;; ) {
CATCH(errctx, akgl_controller_poll_keystroke(&stroke, &available));
if ( available == false ) {
break;
}
if ( stroke.key == SDLK_RETURN ) {
*done = true;
break;
}
if ( (stroke.text[0] != '\0') && ((used + 1) < destsize) ) {
dest[used] = stroke.text[0];
used += 1;
dest[used] = '\0';
}
}
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
The ring is filled on whichever thread pumps events and **is not synchronized**. Poll it
from that thread.
## The default bindings
`akgl_controller_default(controlmapid, actorname, kbid, jsid)` points a map at the actor
registered under `actorname` and pushes **eight** bindings — the four arrow keys and the
four D-pad directions — each wired to the matching `akgl_actor_cmhf_*_on`/`_off` pair:
| Key | Gamepad button | `handler_on` / `handler_off` |
|---|---|---|
| `SDLK_DOWN` | `SDL_GAMEPAD_BUTTON_DPAD_DOWN` | `akgl_actor_cmhf_down_on` / `_down_off` |
| `SDLK_UP` | `SDL_GAMEPAD_BUTTON_DPAD_UP` | `akgl_actor_cmhf_up_on` / `_up_off` |
| `SDLK_LEFT` | `SDL_GAMEPAD_BUTTON_DPAD_LEFT` | `akgl_actor_cmhf_left_on` / `_left_off` |
| `SDLK_RIGHT` | `SDL_GAMEPAD_BUTTON_DPAD_RIGHT` | `akgl_actor_cmhf_right_on` / `_right_off` |
It is the "just give me something that works" path. A game wanting different keys builds
its own bindings with `akgl_controller_pushmap`, as in the WASD example above.
**The default bindings cannot produce a diagonal.** Every `akgl_actor_cmhf_*_on` handler
clears `AKGL_ACTOR_STATE_MOVING_ALL` before setting its own bit, so pressing Left while Up
is held cancels Up rather than adding to it. That is fine for a four-way JRPG and wrong for
anything that wants eight-way movement — and it means the thrust-vector ellipse cap in
[Chapter 14](14-physics.md) is unreachable through these handlers. Eight-way movement wants
your own handlers that `AKGL_BITMASK_ADD` the movement bit without clearing the others.
`akgl_controller_handle_button_down` / `_up` are a different thing again, and are **not**
control-map bindings: they look the actor up by the literal name `"player"` in
`AKGL_REGISTRY_ACTOR` rather than being told which actor to act on, so they only ever drive
one. The `akgl_actor_cmhf_*` handlers are the general form; see
[Chapter 12](12-actors.md).
## Gamepads
**SDL delivers no button events from a gamepad nobody has opened.** `akgl_game_init` calls
`akgl_controller_open_gamepads()` for the devices present at startup; a device plugged in
mid-session arrives as `SDL_EVENT_GAMEPAD_ADDED`, and `akgl_controller_handle_added` opens
it. Route that event (and `SDL_EVENT_GAMEPAD_REMOVED`, to `akgl_controller_handle_removed`)
or hot-plugged pads stay silent. No gamepads attached at all is success, not an error.
`akgl_controller_handle_added` logs the mapping it found, deliberately: **a controller with
no entry in the mapping database produces no button events at all**, which is otherwise
indistinguishable from a broken binding.
Which is why libakgl ships a database. `include/akgl/SDL_GameControllerDB.h` carries
`AKGL_SDL_GAMECONTROLLER_DB_LEN` (2255) mappings from the community
[SDL_GameControllerDB](https://github.com/mdqinc/SDL_GameControllerDB) project, and
`akgl_game_init` feeds every one to `SDL_AddGamepadMapping` before opening anything.
That header is **generated, and tracked on purpose**. It is the offline fallback that keeps
the library buildable when upstream is renamed, rate-limited or unreachable. Never
hand-edit it; regeneration is `cmake --build build --target controllerdb` and nothing else
runs the script. The full contract, including why a failed fetch used to destroy the very
file it exists to protect, is in `AGENTS.md` under "Generated and Vendored Sources".
## Known defects
**A matched binding's handler pointer is not checked.** `akgl_controller_handle_event`
calls `curcontrol->handler_on(...)` the moment the event type, device id and key all match,
with no `NULL` test. A control pushed with a `NULL` `handler_on` or `handler_off` crashes
when its event arrives — and since `akgl_controller_pushmap` copies whatever it is given,
a `memset`-zeroed `akgl_Control` with only `event_on` filled in is a loaded gun. **Set both
handlers on every binding you push**, even if one of them does nothing. There is no
diagnostic for this at any layer.
**`appstate` is required and never read.** Every entry point in this subsystem —
`akgl_controller_handle_event`, `handle_button_down`, `handle_button_up`, `handle_added`,
`handle_removed` — raises `AKERR_NULLPOINTER` on a `NULL` `appstate` and then never looks
at it. Pass any non-`NULL` pointer if your program has no app state.
**The analogue-axis, mouse and pen fields are declared and never consulted.**
`akgl_Control::axis`, `axis_range_min` and `axis_range_max`, and `akgl_ControlMap::mouseid`
and `penid`, are in the structs and `akgl_controller_handle_event` does not read any of
them. Setting them has no effect; analogue sticks, mice and pens do not reach the control
maps at all today. Handle those events yourself before calling
`akgl_controller_handle_event`.
**Key auto-repeat is not filtered.** The ring push tests only `event->type ==
SDL_EVENT_KEY_DOWN` and does not consult `event->key.repeat`, so a held key fills the
32-entry ring at the platform's repeat rate. Check `repeat` in your event pump if you are
polling for discrete presses.
Two defects this chapter used to carry are **fixed** and are recorded here so nobody
re-derives them from the header prose:
- **A negative `controlmapid` is rejected.** Both `akgl_controller_pushmap` and
`akgl_controller_default` check `controlmapid < 0` as well as the upper bound and raise
`AKERR_OUTOFBOUNDS`. `tests/controller.c` passes `-1` and `-4096` to each. The
`@warning` blocks in `include/akgl/controller.h` saying only the upper bound is checked
are stale; `TODO.md`, "Known and still open" item 11.
- **The `akgl_controller_handle_*` functions exist.** `controller.h` once declared four
names that were defined under different spellings, so they linked nowhere. Fixed in
0.5.0, and `scripts/check_api_surface.sh` runs as the `api_surface` test to stop that
class of drift coming back.
## Statuses
The ones this subsystem raises, with their libakgl meanings in
[Chapter 4](04-errors.md):
| Status | When |
|---|---|
| `AKERR_NULLPOINTER` | A `NULL` `appstate`, `event`, `control`, or poller destination; SDL cannot enumerate or open a device; no actor registered as `"player"` |
| `AKERR_OUTOFBOUNDS` | `controlmapid` outside `0..AKGL_MAX_CONTROL_MAPS - 1`, or the map already holds `AKGL_MAX_CONTROLS` bindings |
| `AKGL_ERR_REGISTRY` | `akgl_controller_default` could not find `actorname` — usually because the actor has not been created yet |
| anything | Whatever the matched binding's handler raises, propagated unchanged |