Carry modifiers and composed text in the keystroke ring

The ring held a keycode and dropped the rest of the event, so a line editor
built on akgl_controller_poll_key() could not reach a single shifted
character: no double quote, no colon, no parenthesis, and no lower case.
The modifier state was discarded two layers below the caller, and polling
SDL_GetModState() at read time cannot recover it -- the key is long
released by then, which is the point of a ring.

akgl_Keystroke carries the keycode, the modifiers and the UTF-8 text SDL
composed, and akgl_controller_poll_keystroke() hands back all three.
akgl_controller_poll_key() is unchanged for its callers: a game asking
whether the up arrow was pressed already gets what it wants.

The text comes from SDL_EVENT_TEXT_INPUT, which is the only correct way to
get a character out of SDL, and is attached to the press still waiting for
it -- tracked with a flag rather than by "whatever entry is newest", so a
press dropped by a full buffer cannot hand its text to an older key. Text
with no press behind it (an IME commit, a dead key) is buffered on its own
with keycode 0, and the keycode-only poller discards those on the way past.
Oversized text is cut on a code point boundary, and a sequence the string
ends in the middle of is dropped rather than read past its terminator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:52:38 -04:00
parent 34a076b851
commit a3eada1b3f
3 changed files with 406 additions and 22 deletions

View File

@@ -12,9 +12,16 @@
* bound in two maps only fires once, in the lower-numbered one.
*
* Alongside that, and independent of it, every key press is pushed into a small
* ring buffer that akgl_controller_poll_key() drains. That serves a caller that
* wants "is there a key waiting" without owning an event loop, and it sees
* keys whether or not a control map also claimed them.
* ring buffer that akgl_controller_poll_key() and akgl_controller_poll_keystroke()
* drain. That serves a caller that wants "is there a key waiting" without owning
* an event loop, and it sees keys whether or not a control map also claimed them.
*
* The two pollers read the same buffer and differ in what they hand back. A game
* asking "was the up arrow pressed" wants a keycode and nothing else, which is
* akgl_controller_poll_key(). A line editor asking "what did the user type" needs
* the modifier state and the character SDL composed -- there is no `"` keycode,
* and no keycode at all for a dead key or an IME commit -- which is
* akgl_controller_poll_keystroke().
*/
#ifndef _CONTROLLER_H_
@@ -39,6 +46,23 @@
*/
#define AKGL_CONTROLLER_KEY_BUFFER 32
/**
* @brief Bytes of composed text one buffered keystroke can carry, NUL included.
*
* A UTF-8 code point is at most four bytes, so this holds one character and its
* terminator with room to spare. An input event carrying more than fits -- an
* IME committing a whole word at once -- is truncated on a code point boundary
* rather than split through the middle of one.
*/
#define AKGL_CONTROLLER_KEYSTROKE_TEXT 8
/** @brief One buffered keystroke: which key, which modifiers, and what it typed. */
typedef struct {
SDL_Keycode key; /**< The keycode, or 0 for an entry that carries only composed text -- an IME commit, or a character finished by a dead key. */
SDL_Keymod mod; /**< Modifier state when the key went down. 0 on a text-only entry, whose text already reflects them. */
char text[AKGL_CONTROLLER_KEYSTROKE_TEXT]; /**< What the keystroke composed to, UTF-8. The empty string for a key that produces no character, such as an arrow or a bare modifier. */
} akgl_Keystroke;
/** @brief Maps one SDL input to pressed and released callbacks. */
typedef struct {
uint32_t event_on; /**< SDL event type that fires `handler_on`, e.g. `SDL_EVENT_KEY_DOWN`. */
@@ -85,7 +109,8 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_list_keyboards(void);
*
* The entry point for the whole subsystem -- call it for every event the host
* pumps. A key press is recorded in the poll buffer first, whether or not any
* map wants it; then the maps are scanned in index order and, within a map,
* map wants it, and an `SDL_EVENT_TEXT_INPUT` is attached to the press it
* belongs to; then the maps are scanned in index order and, within a map,
* bindings in the order they were pushed. The **first** binding whose event type
* and device id and button/key all match wins, and the scan stops there.
*
@@ -244,6 +269,11 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_open_gamepads(void);
* was typed first is what is read first. This runs on whichever thread pumps
* events; it is not synchronized.
*
* Entries that carry only composed text and no keycode are discarded on the way
* past rather than reported as keycode 0: this form is for a caller that acts on
* keys, and there is no key to report. A caller that wants those characters uses
* akgl_controller_poll_keystroke() instead.
*
* @param keycode Receives the SDL keycode, or 0 when nothing was waiting.
* Required -- the return value is the error context.
* @param available Receives `true` when a keystroke was taken, `false` when the
@@ -256,6 +286,36 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_open_gamepads(void);
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_key(int *keycode, bool *available);
/**
* @brief Take the oldest waiting keystroke whole: key, modifiers and text.
*
* akgl_controller_poll_key() with nothing thrown away. A line editor needs all
* three: the keycode to recognise Backspace and Return, the modifier state to
* tell Ctrl-C from a `c`, and the composed text because a keycode cannot express
* what a shifted key produces on the user's own layout. `"`, `!`, `(`, `)`, `:`
* and `;` are all unreachable from a keycode alone, and so is every lower-case
* letter.
*
* The text is what SDL composed, taken from the `SDL_EVENT_TEXT_INPUT` that
* follows the key press -- the only correct way to get a character out of SDL,
* and what makes a keyboard layout, a compose key and a dead key work. **SDL
* only sends those events while text input is started**, so a host that wants
* the `text` field populated calls `SDL_StartTextInput()` on its window first.
* Without it, `key` and `mod` still arrive and `text` is always empty.
*
* The two pollers drain the same buffer, so a keystroke taken by one is not
* waiting for the other.
*
* @param dest Receives the keystroke. Required. Written only when a
* keystroke was waiting; zeroed otherwise, so `key` is 0 and
* `text` is the empty string.
* @param available Receives `true` when a keystroke was taken, `false` when the
* buffer was empty. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p dest or @p available is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_keystroke(akgl_Keystroke *dest, bool *available);
/**
* @brief Discard every keystroke waiting in the buffer.
*