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>
16 KiB
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 — start with SDL_Event, SDL_Keycode and 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.
#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:
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
/** @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 0–7 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
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.
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.
#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 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.
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 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
controlmapidis rejected. Bothakgl_controller_pushmapandakgl_controller_defaultcheckcontrolmapid < 0as well as the upper bound and raiseAKERR_OUTOFBOUNDS.tests/controller.cpasses-1and-4096to each. The@warningblocks ininclude/akgl/controller.hsaying only the upper bound is checked are stale;TODO.md, "Known and still open" item 11. - The
akgl_controller_handle_*functions exist.controller.honce declared four names that were defined under different spellings, so they linked nowhere. Fixed in 0.5.0, andscripts/check_api_surface.shruns as theapi_surfacetest to stop that class of drift coming back.
Statuses
The ones this subsystem raises, with their libakgl meanings in Chapter 4:
| 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 |