[Bugfix] Cursor leaving the window never fires on_cell_exit; hovered_cell stays stuck #363

Closed
opened 2026-07-12 22:04:50 +00:00 by john · 2 comments
Owner

Found by adversarial review of the #355 branch (feat/355-grid-input-revival, 85e4bbe). Reported as minor; not fixed there.

Defect

UIGridView::updateHover() is driven exclusively by PyScene::do_mouse_hover, which is only invoked from sf::Event::MouseMoved. There is no sf::Event::MouseLeft handling anywhere in the scene input path.

So if the cursor moves off the window while it is over a grid cell:

  • no further MouseMoved events arrive,
  • updateHover is never called again,
  • on_cell_exit never fires for the cell the cursor was on, and
  • grid.hovered_cell remains stuck reporting that cell indefinitely, even though the mouse is nowhere near the window.

Consequence

Any hover-driven affordance built on on_cell_enter/on_cell_exit (tile highlight, tooltip, range preview) gets stranded in its "hovered" visual state when the player's cursor leaves the window, and only clears if they re-enter and move across a boundary. hovered_cell is a lying read.

Fix

Handle sf::Event::MouseLeft in PyScene and dispatch a hover-exit to the drawable tree (an updateHover with a sentinel/"no position", or a dedicated clearHover() virtual on UIDrawable). Whatever the shape, it must clear hovered_cell and fire on_cell_exit for whichever view was hovering.

Note this is a pre-existing gap in the input layer that #355 merely made observable — before #355 no grid hover fired at all.

Test

Needs an injectable MouseLeft in the automation API (check whether one exists; if not, that is part of the work). Assert on_cell_exit fires and hovered_cell is None after the cursor leaves.

Found by adversarial review of the #355 branch (`feat/355-grid-input-revival`, 85e4bbe). Reported as minor; not fixed there. ## Defect `UIGridView::updateHover()` is driven exclusively by `PyScene::do_mouse_hover`, which is only invoked from `sf::Event::MouseMoved`. There is no `sf::Event::MouseLeft` handling anywhere in the scene input path. So if the cursor moves off the window while it is over a grid cell: - no further `MouseMoved` events arrive, - `updateHover` is never called again, - `on_cell_exit` **never fires** for the cell the cursor was on, and - `grid.hovered_cell` remains stuck reporting that cell indefinitely, even though the mouse is nowhere near the window. ## Consequence Any hover-driven affordance built on `on_cell_enter`/`on_cell_exit` (tile highlight, tooltip, range preview) gets stranded in its "hovered" visual state when the player's cursor leaves the window, and only clears if they re-enter and move across a boundary. `hovered_cell` is a lying read. ## Fix Handle `sf::Event::MouseLeft` in `PyScene` and dispatch a hover-exit to the drawable tree (an `updateHover` with a sentinel/"no position", or a dedicated `clearHover()` virtual on `UIDrawable`). Whatever the shape, it must clear `hovered_cell` and fire `on_cell_exit` for whichever view was hovering. Note this is a pre-existing gap in the input layer that #355 merely made *observable* — before #355 no grid hover fired at all. ## Test Needs an injectable `MouseLeft` in the automation API (check whether one exists; if not, that is part of the work). Assert `on_cell_exit` fires and `hovered_cell is None` after the cursor leaves.
Author
Owner

Fixed on feat/355-grid-input-revival (4bb1966). Suite 325/325.

The injection question, answered

The issue asked whether an injectable MouseLeft exists. It does not — but less was needed than expected, because sf::Event::MouseLeft already exists in all three backends, and SDL2Renderer.cpp:774 already translates SDL_WINDOWEVENT_LEAVE into it. The event was being emitted and simply had nobody listening. injectMouseEvent is also already type-generic (MouseLeft carries no payload, so it falls through the default: arm straight to processEvent).

So the automation surface addition is one function, automation.mouseLeave(), and the only real design question was what position it takes. Answer: none. The cursor is outside the window; there is no position. Taking an (x, y) would have been an invented coordinate dressed up as a real one. MouseLeft carries no coordinates for exactly this reason, and the exit callbacks instead receive the last position seen by do_mouse_hover — the same convention the DOM's mouseleave uses. simulated_mouse_pos is deliberately not moved by a leave.

I considered and rejected having moveTo() synthesize a leave when the target falls outside the window bounds. That is magic-by-inference: it would fire on a coordinate that is merely off-screen, and it would silently do nothing in headless mode (no window, no bounds). An explicit call is honest.

The fix

Rather than write a parallel teardown path, the leave reuses the hover walk. PyScene::do_mouse_hover's body became dispatch_hover(pos, in_window), and a window-leave is that same walk with hit_allowed=false at the root — which is precisely "the mouse is over nothing". Every hovered drawable then takes its ordinary exit path and none can enter. Grid cells, Frames, and grid overlay children all clear through the code that already knows how to clear them, so there is no second implementation of "what exiting means" to drift out of sync.

  • GameEngine::processEvent routes MouseLeftPyScene::do_mouse_leave().
  • PyScene::last_mouse_pos records the last hovered position for the exit callbacks.

Confirmed: this was never grid-specific

As suspected in the issue, Frame.on_exit was stranded the same way — the test covers it. #355 only made the gap observable by giving grids hover callbacks that could visibly stick.

Test

tests/regression/issue_363_hover_exit_on_window_leave_test.py. Beyond the obvious assertions (exit fires, hovered_cell is None), two that carry weight:

  • a redundant leave must not double-fire on_exit;
  • re-entering the same cell after a leave must fire on_cell_enter again. This is what separates a real clear from a faked one — if the leave had only reported the clear without resetting hovered_cell, the re-entered cell would compare equal to the stale one and the enter would be silently swallowed.

Out of scope, deliberately

sf::Event::LostFocus (alt-tab with the cursor stationary over the window) leaves hover stuck by the same mechanism, and the same one-line route would fix it. I did not do it here: it is a distinct trigger with its own semantics question (should an unfocused-but-hovered window report hover? a tooltip arguably should persist), and it is not what this issue describes. Worth its own issue if it matters.

Fixed on `feat/355-grid-input-revival` (4bb1966). Suite 325/325. ## The injection question, answered The issue asked whether an injectable `MouseLeft` exists. It does not — but less was needed than expected, because **`sf::Event::MouseLeft` already exists in all three backends**, and `SDL2Renderer.cpp:774` already translates `SDL_WINDOWEVENT_LEAVE` into it. The event was being emitted and simply had nobody listening. `injectMouseEvent` is also already type-generic (`MouseLeft` carries no payload, so it falls through the `default:` arm straight to `processEvent`). So the automation surface addition is one function, `automation.mouseLeave()`, and the only real design question was **what position it takes**. Answer: **none**. The cursor is outside the window; there is no position. Taking an `(x, y)` would have been an invented coordinate dressed up as a real one. `MouseLeft` carries no coordinates for exactly this reason, and the exit callbacks instead receive the last position seen by `do_mouse_hover` — the same convention the DOM's `mouseleave` uses. `simulated_mouse_pos` is deliberately *not* moved by a leave. I considered and rejected having `moveTo()` synthesize a leave when the target falls outside the window bounds. That is magic-by-inference: it would fire on a coordinate that is merely off-screen, and it would silently do nothing in headless mode (no window, no bounds). An explicit call is honest. ## The fix Rather than write a parallel teardown path, the leave **reuses the hover walk**. `PyScene::do_mouse_hover`'s body became `dispatch_hover(pos, in_window)`, and a window-leave is that same walk with `hit_allowed=false` at the root — which is precisely *"the mouse is over nothing"*. Every hovered drawable then takes its ordinary exit path and none can enter. Grid cells, Frames, and grid overlay children all clear through the code that already knows how to clear them, so there is no second implementation of "what exiting means" to drift out of sync. - `GameEngine::processEvent` routes `MouseLeft` → `PyScene::do_mouse_leave()`. - `PyScene::last_mouse_pos` records the last hovered position for the exit callbacks. ## Confirmed: this was never grid-specific As suspected in the issue, `Frame.on_exit` was stranded the same way — the test covers it. #355 only made the gap *observable* by giving grids hover callbacks that could visibly stick. ## Test `tests/regression/issue_363_hover_exit_on_window_leave_test.py`. Beyond the obvious assertions (exit fires, `hovered_cell is None`), two that carry weight: - a **redundant** leave must not double-fire `on_exit`; - **re-entering the same cell** after a leave must fire `on_cell_enter` again. This is what separates a real clear from a faked one — if the leave had only *reported* the clear without resetting `hovered_cell`, the re-entered cell would compare equal to the stale one and the enter would be silently swallowed. ## Out of scope, deliberately `sf::Event::LostFocus` (alt-tab with the cursor stationary over the window) leaves hover stuck by the same mechanism, and the same one-line route would fix it. I did not do it here: it is a distinct trigger with its own semantics question (should an unfocused-but-hovered window report hover? a tooltip arguably should persist), and it is not what this issue describes. Worth its own issue if it matters.
Author
Owner

The deferred LostFocus half is now filed as #367 — it reuses the do_mouse_leave() / dispatch_hover(pos, in_window) machinery this issue introduced, but carries a policy question this one did not.

The deferred `LostFocus` half is now filed as #367 — it reuses the `do_mouse_leave()` / `dispatch_hover(pos, in_window)` machinery this issue introduced, but carries a policy question this one did not.
john closed this issue 2026-07-13 11:44:13 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
john/McRogueFace#363
No description provided.