Feed the mouse to the UI and tell the host which events it consumed

akgl_ui_handle_event is the mouse half of the input story -- keyboard and
gamepad stay with the control maps, because keyboard focus is something an
application declares rather than something a pointer position implies. It
handles motion, left-button presses and releases, the wheel and window
resizes; everything else, and everything while the subsystem is inert,
reports consumed=false and succeeds, the same pass-everything contract
akgl_controller_handle_event already has. The documented call order is UI
first, control maps second, skipping on consumed.

A press, release or wheel is consumed when the pointer is over any UI
element. The hit test asks clay against the layout the previous frame
retained -- this frame's does not exist while events are polled, and one
frame of staleness is the standard model's accepted cost -- with clay's
internal full-screen Clay__RootContainer excluded, because being inside the
window is not being over the interface (unexcluded, every click anywhere was
consumed; the test that pins this caught it).

Pointer state is fed to clay per event, as clay documents; the press edge is
latched once per frame_begin for the widgets to come, rather than trusting
Clay_PointerData's per-SetPointerState edge, which advances per mouse event.
frame_begin also drives Clay_UpdateScrollContainers from the accumulated
wheel (32 pixels per notch) and a wall-clock dt.

Tests pin the whole contract: pass-through before init, no consumption
before any layout exists, press/release inside vs beside a panel, right
button ignored, motion never consumed, wheel consumed only over the UI,
resize reaching the layout engine, and key events left alone.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
This commit is contained in:
2026-08-02 11:10:35 -04:00
parent f11b9c5603
commit 606fc3364a
3 changed files with 354 additions and 0 deletions

View File

@@ -482,6 +482,154 @@ akerr_ErrorContext *test_ui_layout_text(void)
SUCCEED_RETURN(e);
}
/** @brief Build a left-button mouse event at a position, down or up. */
static void make_button_event(SDL_Event *event, uint32_t type, float x, float y)
{
SDL_memset(event, 0x00, sizeof(SDL_Event));
event->type = type;
event->button.button = SDL_BUTTON_LEFT;
event->button.x = x;
event->button.y = y;
}
/**
* @brief The consumed contract: clicks on the UI are the UI's, everything else passes.
*
* The hit test runs against the layout the previous frame retained, so the
* test lays one panel out, then throws events at it: a click inside is
* consumed, a click outside is not, motion never is, and -- the case most
* likely to surprise -- a click before any layout exists is not consumed,
* because there is nothing to be over.
*/
akerr_ErrorContext *test_ui_handle_event(void)
{
PREPARE_ERROR(e);
SDL_Event event;
bool consumed = true;
int token = 1;
ATTEMPT {
// Uninitialized: everything passes through untouched.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"an event before init was refused");
TEST_ASSERT(e, consumed == false, "an event before init was consumed");
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the event test failed");
// Initialized but nothing laid out yet: a click lands on no tree.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a click before any layout was refused");
TEST_ASSERT(e, consumed == false, "a click was consumed before any layout existed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release before any layout was refused");
// Lay out one 20x20 panel at (8,8) for the hit tests that follow.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the hit-test frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(20),
.height = CLAY_SIZING_FIXED(20)
}
},
.backgroundColor = { 255, 0, 0, 255 }
}) {}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the hit-test frame failed");
// A press on the panel is the UI's; its release is too.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the press on the panel was refused");
TEST_ASSERT(e, consumed == true, "a press on a UI panel was not consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release on the panel was refused");
TEST_ASSERT(e, consumed == true, "a release on a UI panel was not consumed");
// A press beside the panel belongs to the game.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 50.0f, 50.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the press beside the panel was refused");
TEST_ASSERT(e, consumed == false, "a press beside the panel was consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 50.0f, 50.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release beside the panel was refused");
// A right-button press is not the UI's business at all.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
event.button.button = SDL_BUTTON_RIGHT;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a right-button press was refused");
TEST_ASSERT(e, consumed == false, "a right-button press was consumed");
// Motion is never consumed, wherever it lands.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_MOTION;
event.motion.x = 10.0f;
event.motion.y = 10.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"motion over the panel was refused");
TEST_ASSERT(e, consumed == false, "motion over a panel was consumed");
// The wheel goes to the UI only while the pointer is over it. The
// motion event above left the pointer on the panel.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_WHEEL;
event.wheel.y = 1.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the wheel over the panel was refused");
TEST_ASSERT(e, consumed == true, "the wheel over a panel was not consumed");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_MOTION;
event.motion.x = 50.0f;
event.motion.y = 50.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"motion off the panel was refused");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_WHEEL;
event.wheel.y = 1.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the wheel beside the panel was refused");
TEST_ASSERT(e, consumed == false, "the wheel beside the panel was consumed");
// A resize reaches the layout engine and still passes through.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_WINDOW_RESIZED;
event.window.data1 = TEST_TARGET_SIZE;
event.window.data2 = TEST_TARGET_SIZE;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a resize event was refused");
TEST_ASSERT(e, consumed == false, "a resize event was consumed");
// A key event is not the UI's; keyboard focus is the application's.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_RETURN;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a key event was refused");
TEST_ASSERT(e, consumed == false, "a key event was consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(NULL, &event, &consumed),
"a NULL appstate was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, NULL, &consumed),
"a NULL event was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, &event, NULL),
"a NULL consumed destination was accepted");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief A layout error inside clay must surface from frame_end, not vanish.
*
@@ -565,6 +713,7 @@ int main(void)
CATCH(errctx, test_ui_frame_bracket());
CATCH(errctx, test_ui_layout_pixels());
CATCH(errctx, test_ui_layout_text());
CATCH(errctx, test_ui_handle_event());
CATCH(errctx, test_ui_layout_error_surfaces());
} CLEANUP {
IGNORE(akgl_text_unloadallfonts());