Files
libakgl/docs/16-input.md
Andrew Kesterson bace818998 Renumber chapters 15 to 21 up by one, to open a slot for collision
A pure rename plus link rewrite and nothing else. Collision is a pluggable
subsystem with two implementations, shapes, a response hook and a tile binding;
that is a chapter, and burying it inside the physics chapter is the shape this
manual otherwise avoids -- rendering and drawing are 8 and 9, spritesheets and
characters and actors are 10, 11 and 12.

Kept separate from writing the chapter because **nothing validates a
cross-reference target.** The example harness reads fenced blocks; it does not
follow links. A rename mixed into five hundred lines of new prose is not
reviewable, and a broken link would land silently. As its own commit it is
reviewable as a rename, and a grep for dangling `](NN-` targets is clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:15:25 -04:00

18 KiB
Raw Permalink Blame History

16. 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 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 — akgl_controller_list_keyboards logs every attached keyboard and its id, which is how you find the number to pass.

Bind device 0 unless you mean a specific device

A kbid or jsid of 0 matches every device of that kind, and it is what a single-player game wants.

Do not reach for SDL_GetKeyboards() to fill it in. That call reports what is attached; the id a key event actually carries is chosen by the video backend, and the two need not agree. On X11 without XInput2 every key event carries SDL_GLOBAL_KEYBOARD_ID, which is 0, while SDL registers the attached keyboard as SDL_DEFAULT_KEYBOARD_ID, which is 1. With XInput2 the events carry the physical slave device's sourceid and SDL_GetKeyboards()[0] may be the master. Binding SDL_GetKeyboards()[0] therefore gives you a map that matches nothing at all, and controls that silently do nothing.

That is not hypothetical — the sidescroller in Chapter 20 shipped exactly that bug, and its smoke test passed anyway because the test called the handlers instead of dispatching events.

0 is the right spelling for "any" because SDL documents which as 0 when the source is unknown or virtual, and joystick ids start at 1 — so no real device is 0 and nothing is given up by spending it.

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 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:

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