From 566b870a5c93ba4f1018444271376e0bc680e25b Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:10:35 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- include/akgl/ui.h | 46 ++++++++++++++ src/ui.c | 159 ++++++++++++++++++++++++++++++++++++++++++++++ tests/ui.c | 149 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+) diff --git a/include/akgl/ui.h b/include/akgl/ui.h index b03a0b8..26a8217 100644 --- a/include/akgl/ui.h +++ b/include/akgl/ui.h @@ -38,6 +38,8 @@ #include #include +#include + #include #include @@ -205,6 +207,50 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_resize(int width, int height); */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_font_register(char *name, uint16_t *fontid); +/** + * @brief Feed one SDL event to the UI, and learn whether the UI claimed it. + * + * The mouse half of the input story -- keyboard and gamepad stay with the + * control maps and the widget helpers, because keyboard focus is something + * the application declares, not something a pointer position implies. This + * handles mouse motion, left-button presses and releases, the wheel, and + * window resizes; every other event, and every event while the subsystem is + * not initialized, reports @p consumed false and succeeds, so a host passes + * every event through unconditionally -- the same contract + * akgl_controller_handle_event() has. + * + * Call it *before* akgl_controller_handle_event() in the event loop, and skip + * the rest of the chain when it consumed: + * + * while ( SDL_PollEvent(&event) ) { + * PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed)); + * if ( consumed ) { continue; } + * PASS(errctx, akgl_controller_handle_event(&app, &event)); + * } + * + * A press or release is consumed when the pointer is over any UI element; + * motion and resizes never are (the game may care where the mouse is, and + * certainly cares about its window). The hit test runs against the layout + * the *previous* frame declared, because this frame's does not exist while + * events are being polled -- clay retains the last tree for exactly this + * purpose, and one frame of staleness is the accepted cost of the standard + * model. Before any frame has been laid out, nothing is over anything and + * nothing is consumed. + * + * @param appstate The application state pointer the event loop owns. + * Required, though this implementation does not read it -- + * the parameter exists so the signature matches + * akgl_controller_handle_event() and can grow the same way. + * @param event The event SDL handed the loop. Required. + * @param consumed Receives whether the UI claimed the event. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p appstate, @p event, or @p consumed is + * `NULL`. + * @throws AKERR_OUTOFBOUNDS If a resize event carries a non-positive size, + * from akgl_ui_resize(). + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed); + /** * @brief Open the UI frame: clear the error stash and begin the clay layout. * diff --git a/src/ui.c b/src/ui.c index f7df8df..8c5b833 100644 --- a/src/ui.c +++ b/src/ui.c @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -53,6 +54,39 @@ static int ui_font_count; /** @brief Scratch one TEXT render command's line is copied through, so SDL_ttf gets a C string. */ static char ui_text_scratch[AKGL_UI_MAX_TEXT_BYTES]; +/** + * @brief Pixels one wheel notch scrolls a clay scroll container. + * + * SDL reports the wheel in notches; clay wants pixels. 32 is three lines of + * 16pt text per notch, which is what desktop toolkits converge on. + */ +#define UI_WHEEL_NOTCH_PIXELS 32.0f + +/** @brief Where the pointer last was, in layout pixels. Fed to clay per event. */ +static float ui_pointer_x; +/** @brief Vertical half of the pointer position. */ +static float ui_pointer_y; +/** @brief Whether the left button is currently held. */ +static bool ui_pointer_down; +/** + * @brief A left press arrived since the last frame_begin latched. + * + * Widgets read the *latched* copy (#ui_pointer_pressed) rather than clay's + * `Clay_PointerData.state`, because clay advances its press-edge per + * Clay_SetPointerState call and several of those can land between two + * layouts -- one per motion event -- which turns "pressed this frame" into + * "pressed since the last mouse event", an edge no widget can trust. + */ +static bool ui_pointer_pending_pressed; +/** @brief The frame-stable press edge: one left press happened before this frame. */ +static bool ui_pointer_pressed; +/** @brief Wheel notches accumulated since the last frame_begin, horizontal. */ +static float ui_wheel_x; +/** @brief Wheel notches accumulated since the last frame_begin, vertical. */ +static float ui_wheel_y; +/** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */ +static uint64_t ui_last_frame_ns; + /** * @brief First error clay reported since the stash was last cleared. * @@ -199,6 +233,14 @@ akerr_ErrorContext *akgl_ui_shutdown(void) ui_font_count = 0; ui_clay_error_text[0] = '\0'; ui_clay_error_count = 0; + ui_pointer_x = 0.0f; + ui_pointer_y = 0.0f; + ui_pointer_down = false; + ui_pointer_pending_pressed = false; + ui_pointer_pressed = false; + ui_wheel_x = 0.0f; + ui_wheel_y = 0.0f; + ui_last_frame_ns = 0; SUCCEED_RETURN(errctx); } @@ -561,8 +603,108 @@ akerr_ErrorContext *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_Rend SUCCEED_RETURN(errctx); } +/** + * @brief Report whether the pointer is over any element of the retained layout. + * + * The one question the consumed contract turns on. Asked of clay rather than + * answered from our own geometry, so floating elements, z-order and culling + * all mean whatever clay says they mean -- with one exception: clay wraps + * every layout in an internal `Clay__RootContainer` sized to the whole + * screen, so "over anything" naively answers yes for every pixel of the + * window. The root is excluded here; being inside the window is not being + * over the interface. + */ +static bool ui_pointer_over_anything(void) +{ + static uint32_t rootid = 0; + Clay_ElementIdArray over = Clay_GetPointerOverIds(); + int32_t i = 0; + + if ( rootid == 0 ) { + rootid = Clay_GetElementId(CLAY_STRING("Clay__RootContainer")).id; + } + for ( i = 0; i < over.length; i++ ) { + if ( over.internalArray[i].id != rootid ) { + return true; + } + } + return false; +} + +akerr_ErrorContext *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed) +{ + Clay_Vector2 position; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); + FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination"); + *consumed = false; + // Inert until initialized, like the rest of the subsystem: the host is + // told "not mine" rather than "not ready", so its event loop needs no + // knowledge of whether the UI is up. + if ( ui_context == NULL ) { + SUCCEED_RETURN(errctx); + } + + switch ( event->type ) { + case SDL_EVENT_MOUSE_MOTION: + ui_pointer_x = event->motion.x; + ui_pointer_y = event->motion.y; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + // Fed per event, not per frame, which is the usage clay documents: + // hover tracks the pointer through fast motion instead of sampling + // wherever it happened to be when the frame began. + Clay_SetPointerState(position, ui_pointer_down); + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + if ( event->button.button != SDL_BUTTON_LEFT ) { + break; + } + ui_pointer_x = event->button.x; + ui_pointer_y = event->button.y; + ui_pointer_down = true; + ui_pointer_pending_pressed = true; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + Clay_SetPointerState(position, true); + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_MOUSE_BUTTON_UP: + if ( event->button.button != SDL_BUTTON_LEFT ) { + break; + } + ui_pointer_x = event->button.x; + ui_pointer_y = event->button.y; + ui_pointer_down = false; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + Clay_SetPointerState(position, false); + // The release is consumed on the same terms as the press: a click + // that began on a panel must not leak its other half to the game. + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_MOUSE_WHEEL: + ui_wheel_x += event->wheel.x; + ui_wheel_y += event->wheel.y; + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_WINDOW_RESIZED: + PASS(errctx, akgl_ui_resize(event->window.data1, event->window.data2)); + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + akerr_ErrorContext *akgl_ui_frame_begin(void) { + Clay_Vector2 scroll; + uint64_t now = 0; + float dt = 0.0f; + PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); FAIL_NONZERO_RETURN( @@ -572,6 +714,23 @@ akerr_ErrorContext *akgl_ui_frame_begin(void) "A UI frame is already open; a frame_begin/frame_begin sequence means a frame_end went missing"); ui_clay_error_text[0] = '\0'; ui_clay_error_count = 0; + + // Latch the press edge for the widgets this frame declares, then let the + // event loop start accumulating the next one. + ui_pointer_pressed = ui_pointer_pending_pressed; + ui_pointer_pending_pressed = false; + + now = SDL_GetTicksNS(); + if ( ui_last_frame_ns != 0 ) { + dt = (float)(now - ui_last_frame_ns) / (float)AKGL_TIME_ONESEC_NS; + } + ui_last_frame_ns = now; + scroll.x = ui_wheel_x * UI_WHEEL_NOTCH_PIXELS; + scroll.y = ui_wheel_y * UI_WHEEL_NOTCH_PIXELS; + ui_wheel_x = 0.0f; + ui_wheel_y = 0.0f; + Clay_UpdateScrollContainers(false, scroll, dt); + Clay_BeginLayout(); ui_in_frame = true; SUCCEED_RETURN(errctx); diff --git a/tests/ui.c b/tests/ui.c index b547b19..f0a3c8f 100644 --- a/tests/ui.c +++ b/tests/ui.c @@ -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());