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
This commit is contained in:
2026-08-02 11:10:35 -04:00
parent 3b3bfcbe10
commit 566b870a5c
3 changed files with 354 additions and 0 deletions

159
src/ui.c
View File

@@ -15,6 +15,7 @@
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
@@ -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);