From f7ca7237cd8b9ea2797bd3751613d5d0e75076e9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:52:32 -0700 Subject: [PATCH] Reconcile a LEFT release missed while unfocused on focus regain Non-Windows only reconciled while focused AND captured; focus loss drops capture, so a release outside the window left activation_down_ stuck. Decision moved to LocalMousePolicy::sync_left (tested). Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit f012cf16932648797f023dbc3269f678b87eb24f) --- runtime/src/local_mouse_policy.cpp | 4 ++++ runtime/src/local_mouse_policy.h | 2 ++ runtime/src/local_mouse_sdl.cpp | 14 +++++++------- runtime/tests/test_local_mouse_policy.cpp | 21 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/runtime/src/local_mouse_policy.cpp b/runtime/src/local_mouse_policy.cpp index ba76fa4b..e5df8e04 100644 --- a/runtime/src/local_mouse_policy.cpp +++ b/runtime/src/local_mouse_policy.cpp @@ -149,6 +149,10 @@ bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, } return false; } +void LocalMousePolicy::sync_left(uint64_t now, bool physically_down) { + if (!physically_down && activation_down_) + control(0, now, MouseControl::Left, false, false); +} void LocalMousePolicy::motion(uint64_t t, uint64_t now, double dx, double dy) { if (!captured_) return; if (!allowed() || !std::isfinite(dx) || !std::isfinite(dy) || diff --git a/runtime/src/local_mouse_policy.h b/runtime/src/local_mouse_policy.h index c8eec2e8..ddb0082b 100644 --- a/runtime/src/local_mouse_policy.h +++ b/runtime/src/local_mouse_policy.h @@ -25,6 +25,8 @@ public: void reset(); void update(uint64_t now, LocalMouseHost host); bool control(uint64_t t, uint64_t now, MouseControl control, bool down, bool repeat); + // Reconcile a LEFT release the host never delivered (e.g. made while unfocused). + void sync_left(uint64_t now, bool physically_down); void motion(uint64_t t, uint64_t now, double dx, double dy); void sample(uint64_t now, uint8_t& rx, uint8_t& ry); bool captured() const { return captured_; } diff --git a/runtime/src/local_mouse_sdl.cpp b/runtime/src/local_mouse_sdl.cpp index 6014ad7e..ccfcd00c 100644 --- a/runtime/src/local_mouse_sdl.cpp +++ b/runtime/src/local_mouse_sdl.cpp @@ -105,16 +105,16 @@ void psx_local_mouse_begin(SDL_Window* window, bool live) { update(now_ns()); // Windows global state survives focus clearing SDL's cached window state. // Other backends may not support global mouse polling (e.g. Wayland), so - // there catch a lost release only while focused and captured. Neither path - // manufactures a down or unblocks a held activation across focus loss. + // there reconcile whenever focused (captured or not), so a release made + // while unfocused is caught on focus regain. Neither path manufactures a + // down or unblocks a held activation across focus loss. #if defined(_WIN32) - const bool left_released = !(SDL_GetGlobalMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)); + const bool left_down = (SDL_GetGlobalMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0; + state().sync_left(now_ns(), left_down); #else - const bool left_released = host_.focused && state().captured() && - !(SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)); + if (host_.focused) + state().sync_left(now_ns(), (SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0); #endif - if (left_released) - state().control(0, now_ns(), psx::MouseControl::Left, false, false); // Catch a lost hold release, including one made outside the game window. const auto hold = state().hold_control(); bool down = false; diff --git a/runtime/tests/test_local_mouse_policy.cpp b/runtime/tests/test_local_mouse_policy.cpp index de7c77de..04131566 100644 --- a/runtime/tests/test_local_mouse_policy.cpp +++ b/runtime/tests/test_local_mouse_policy.cpp @@ -240,6 +240,27 @@ int main() { batch.sample(33*Ms,x,y);CHECK(x==128 && y==128);batch.clear(); } + // LEFT released while unfocused is never delivered; reconcile on regain so + // the next click activates. A still-held button is not a new activation. + { + psx::LocalMousePolicy lost({nullptr,capture,suppress,notice}); + CHECK(lost.install(&callbacks)); + const uint64_t base=9000*Ms; + lost.update(base,host); + CHECK(lost.control(base,base,psx::MouseControl::Left,true,false) && lost.captured()); + auto away=host;away.focused=false; + lost.update(base+10*Ms,away);CHECK(!lost.captured()); + lost.update(base+20*Ms,host); + lost.sync_left(base+20*Ms,true); + CHECK(!lost.control(base+21*Ms,base+21*Ms,psx::MouseControl::Left,true,false)); + lost.sync_left(base+22*Ms,false); + lost.update(base+23*Ms,host); + CHECK(lost.control(base+23*Ms,base+23*Ms,psx::MouseControl::Left,true,false)); + CHECK(lost.captured()); + lost.sync_left(base+24*Ms,true);CHECK(lost.captured()); // down: no-op + lost.clear(); + } + policy.reset();host.hold_conflict=false;capture_ok=false;CHECK(acquire());CHECK(!policy.captured()); const unsigned failure_notices=notices;CHECK(acquire());CHECK(notices==failure_notices); policy.clear(); CHECK(!policy.installed() && !policy.captured());