Stop a stationary pointer from pinning the menu selection

A pointer parked over a menu row re-claimed the selection on every frame's
declaration, so keyboard navigation fought the mouse sixty times a second
and lost -- press Up, and the hover put the selection straight back. Found
by the UI demo's scripted tour: its mouse click leaves the pointer resting
on the menu, and the Up keystroke that followed did nothing.

Hover now moves the selection only on frames the pointer actually moved (or
clicked), through a frame-latched motion edge beside the existing press
edge. Parking the mouse claims nothing; moving it onto a row still selects,
clicking still selects and activates. Regression test drives the exact
sequence: click a row, move the selection away by keyboard, redeclare, and
assert the stationary pointer stole nothing back.

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:24:57 -04:00
parent 00ca9d0e57
commit ae7ac3a3e4
3 changed files with 49 additions and 9 deletions

View File

@@ -80,6 +80,18 @@ static bool ui_pointer_down;
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 Motion arrived since the last frame_begin latched. Companion to the press edge. */
static bool ui_pointer_pending_moved;
/**
* @brief The frame-stable motion edge: the pointer moved before this frame.
*
* What keeps a menu's hover-selection polite. A stationary pointer parked
* over a row must not re-claim the selection every frame -- the keyboard
* would fight it and lose, sixty times a second -- so hover only moves the
* selection on frames the mouse actually moved. Found the way these things
* are found: the demo script pressed Up and the selection did not move.
*/
static bool ui_pointer_moved;
/** @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. */
@@ -262,6 +274,8 @@ akerr_ErrorContext *akgl_ui_shutdown(void)
ui_pointer_down = false;
ui_pointer_pending_pressed = false;
ui_pointer_pressed = false;
ui_pointer_pending_moved = false;
ui_pointer_moved = false;
ui_wheel_x = 0.0f;
ui_wheel_y = 0.0f;
ui_last_frame_ns = 0;
@@ -679,6 +693,7 @@ akerr_ErrorContext *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool
case SDL_EVENT_MOUSE_MOTION:
ui_pointer_x = event->motion.x;
ui_pointer_y = event->motion.y;
ui_pointer_pending_moved = true;
position.x = ui_pointer_x;
position.y = ui_pointer_y;
// Fed per event, not per frame, which is the usage clay documents:
@@ -743,10 +758,12 @@ akerr_ErrorContext *akgl_ui_frame_begin(void)
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.
// Latch the press and motion edges for the widgets this frame declares,
// then let the event loop start accumulating the next ones.
ui_pointer_pressed = ui_pointer_pending_pressed;
ui_pointer_pending_pressed = false;
ui_pointer_moved = ui_pointer_pending_moved;
ui_pointer_pending_moved = false;
now = SDL_GetTicksNS();
if ( ui_last_frame_ns != 0 ) {
@@ -1015,11 +1032,14 @@ akerr_ErrorContext *akgl_ui_menu(akgl_UiMenu *menu)
},
.backgroundColor = rowfill
}) {
// Mouse selection: hovering a row makes it the selection, and
// the frame-latched press edge on a hovered row confirms it.
// The row highlight catches up next frame, which at any
// playable frame rate is invisible.
if ( Clay_Hovered() ) {
// Mouse selection: a row claims the selection when the
// pointer moves onto it or clicks it -- not merely by being
// under a stationary pointer, which would re-claim it every
// frame and the keyboard would fight it and lose. The
// frame-latched press edge on a hovered row confirms. The
// row highlight catches up next frame, which at any playable
// frame rate is invisible.
if ( Clay_Hovered() && (ui_pointer_moved || ui_pointer_pressed) ) {
menu->selected = i;
if ( ui_pointer_pressed ) {
menu->activated = true;