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

@@ -88,6 +88,26 @@ static void make_key_event(SDL_Event *event, uint32_t type, SDL_KeyboardID which
event->key.key = key;
}
/** @brief Build a synthetic keyboard event carrying modifier state. */
static void make_key_event_mod(SDL_Event *event, uint32_t type, SDL_KeyboardID which, SDL_Keycode key, SDL_Keymod mod)
{
make_key_event(event, type, which, key);
event->key.mod = mod;
}
/**
* @brief Build the text input event SDL sends after a key press composes.
*
* @p text is not copied by SDL or by the library at this point, so callers pass
* a string literal.
*/
static void make_text_event(SDL_Event *event, const char *text)
{
memset(event, 0x00, sizeof(SDL_Event));
event->type = SDL_EVENT_TEXT_INPUT;
event->text.text = text;
}
/** @brief Build a synthetic gamepad button event. */
static void make_button_event(SDL_Event *event, uint32_t type, SDL_JoystickID which, uint8_t button)
{
@@ -682,6 +702,151 @@ akerr_ErrorContext *test_controller_poll_key_overflow(void)
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_controller_poll_keystroke(void)
{
PREPARE_ERROR(errctx);
SDL_Event event;
akgl_Keystroke keystroke;
int keycode = -1;
bool available = true;
ATTEMPT {
reset_control_maps();
CATCH(errctx, akgl_controller_flush_keys());
// An empty buffer answers "nothing waiting" and zeroes the destination.
memset(&keystroke, 0xff, sizeof(akgl_Keystroke));
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
"polling an empty keystroke buffer");
TEST_ASSERT(errctx, available == false,
"polling an empty buffer reported a keystroke was available");
TEST_ASSERT(errctx, keystroke.key == 0,
"polling an empty buffer left a keycode of %d behind", (int)keystroke.key);
TEST_ASSERT(errctx, keystroke.text[0] == '\0',
"polling an empty buffer left text behind");
// The case the whole thing exists for: a shifted key. The keycode is
// the unshifted one, so the composed character is the only place the
// double quote can come from.
make_key_event_mod(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_APOSTROPHE, SDL_KMOD_LSHIFT);
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
make_text_event(&event, "\"");
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
"polling for a shifted key");
TEST_ASSERT(errctx, available == true, "a shifted key press never reached the buffer");
TEST_ASSERT(errctx, keystroke.key == SDLK_APOSTROPHE,
"the keystroke reported keycode %d, expected %d",
(int)keystroke.key, (int)SDLK_APOSTROPHE);
TEST_ASSERT(errctx, (keystroke.mod & SDL_KMOD_SHIFT) != 0,
"the keystroke lost its shift state (mod %d)", (int)keystroke.mod);
TEST_ASSERT(errctx, strcmp(keystroke.text, "\"") == 0,
"the keystroke composed to \"%s\", expected a double quote", keystroke.text);
// A key that composes to nothing still arrives, with empty text -- that
// is how a line editor recognises Backspace and the arrows.
CATCH(errctx, akgl_controller_flush_keys());
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_LEFT);
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
"polling for a non-printing key");
TEST_ASSERT(errctx, keystroke.key == SDLK_LEFT,
"a non-printing key reported keycode %d, expected %d",
(int)keystroke.key, (int)SDLK_LEFT);
TEST_ASSERT(errctx, keystroke.text[0] == '\0',
"a non-printing key composed to \"%s\", expected nothing", keystroke.text);
// Text with no key press behind it -- an input method, or a character
// finished by a dead key -- is buffered on its own rather than dropped.
CATCH(errctx, akgl_controller_flush_keys());
make_text_event(&event, "e");
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
"polling for composed text with no key press");
TEST_ASSERT(errctx, available == true, "composed text with no key press was dropped");
TEST_ASSERT(errctx, keystroke.key == 0,
"text with no key press reported keycode %d, expected 0", (int)keystroke.key);
TEST_ASSERT(errctx, strcmp(keystroke.text, "e") == 0,
"text with no key press composed to \"%s\", expected \"e\"", keystroke.text);
TEST_ASSERT(errctx, keystroke.mod == 0,
"a text-only entry reported modifiers %d; the character it carries already "
"reflects them", (int)keystroke.mod);
// The keycode form has nowhere to report that entry, so it discards it
// on the way past rather than handing back a keystroke with no key.
CATCH(errctx, akgl_controller_flush_keys());
make_text_event(&event, "e");
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_B);
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
TEST_EXPECT_OK(errctx, akgl_controller_poll_key(&keycode, &available),
"polling by keycode past a text-only entry");
TEST_ASSERT(errctx, available == true, "the key behind a text-only entry was lost");
TEST_ASSERT(errctx, keycode == SDLK_B,
"polling by keycode returned %d, expected %d", keycode, (int)SDLK_B);
// Both pollers drain the same buffer.
CATCH(errctx, akgl_controller_flush_keys());
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_C);
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
CATCH(errctx, akgl_controller_poll_keystroke(&keystroke, &available));
TEST_EXPECT_OK(errctx, akgl_controller_poll_key(&keycode, &available),
"polling by keycode after the same key was taken as a keystroke");
TEST_ASSERT(errctx, available == false, "one keystroke was delivered to both pollers");
// Text that arrives after its press has already been drained becomes an
// entry of its own instead of being attached to somebody else's key.
CATCH(errctx, akgl_controller_flush_keys());
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_D);
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
CATCH(errctx, akgl_controller_poll_keystroke(&keystroke, &available));
make_text_event(&event, "d");
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
"polling for text that outlived its key press");
TEST_ASSERT(errctx, available == true, "text that outlived its key press was dropped");
TEST_ASSERT(errctx, keystroke.key == 0,
"text that outlived its key press was attached to keycode %d",
(int)keystroke.key);
// More text than an entry holds is cut on a character boundary, never
// through the middle of one. Each of these is two bytes, so three fit
// in the eight-byte field and the fourth does not.
CATCH(errctx, akgl_controller_flush_keys());
make_text_event(&event, "\xce\xb1\xce\xb1\xce\xb1\xce\xb1");
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
"polling for text longer than one entry holds");
TEST_ASSERT(errctx, strcmp(keystroke.text, "\xce\xb1\xce\xb1\xce\xb1") == 0,
"oversized text was truncated to \"%s\", expected three whole characters",
keystroke.text);
// A sequence the string ends in the middle of is dropped rather than
// copied by reading past the terminator to find its other half.
CATCH(errctx, akgl_controller_flush_keys());
make_text_event(&event, "a\xce");
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
"polling for text that ends mid-character");
TEST_ASSERT(errctx, strcmp(keystroke.text, "a") == 0,
"a truncated character came back as \"%s\", expected just the whole one",
keystroke.text);
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_controller_poll_keystroke(NULL, &available),
"polling into a NULL keystroke");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_controller_poll_keystroke(&keystroke, NULL),
"polling into a NULL availability flag");
} CLEANUP {
reset_control_maps();
IGNORE(akgl_controller_flush_keys());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
@@ -711,6 +876,7 @@ int main(void)
CATCH(errctx, test_controller_device_enumeration());
CATCH(errctx, test_controller_poll_key());
CATCH(errctx, test_controller_poll_key_overflow());
CATCH(errctx, test_controller_poll_keystroke());
} CLEANUP {
SDL_Quit();
} PROCESS(errctx) {