From 77b661194a2fdd9e8fddf3bba06dc4ae71373358 Mon Sep 17 00:00:00 2001 From: Geoffrey McRae Date: Tue, 11 Aug 2026 14:42:01 +1000 Subject: [PATCH] [client] input: fix automatic keyboard capture Treat autoCapture as keyboard ownership while the pointer is inside the guest viewport. Predict the next local position for both absolute and relative motion, then release the keyboard before the pointer crosses an edge. Keep capture, focus, and deferred evdev transitions consistent while retaining the latest local position as the prediction baseline. --- client/src/app.c | 34 ++++--- client/src/config.c | 2 +- client/src/core.c | 188 ++++++++++++++++++++++++++---------- client/src/core.h | 1 + client/src/evdev.c | 2 + client/src/main.h | 9 +- client/tests/CMakeLists.txt | 1 + client/tests/mouse_test.c | 160 +++++++++++++++++++++++++++++- doc/usage.rst | 2 +- 9 files changed, 327 insertions(+), 72 deletions(-) diff --git a/client/src/app.c b/client/src/app.c index 8dc25784..ebaf9355 100644 --- a/client/src/app.c +++ b/client/src/app.c @@ -130,17 +130,22 @@ bool app_isOverlayMode(void) void app_updateCursorPos(double x, double y) { - g_cursor.pos.x = x; - g_cursor.pos.y = y; - g_cursor.valid = true; + const bool overlay = app_isOverlayMode(); + if (overlay) + { + g_cursor.pos.x = x; + g_cursor.pos.y = y; + g_cursor.valid = true; + g_cursor.motionValid = false; + } + else + core_handleMousePosition(x, y); MTRACE("pos pos=%.3f,%.3f inWin=%d inView=%d grab=%d", x, y, g_cursor.inWindow, g_cursor.inView, g_cursor.grab); - if (app_isOverlayMode()) + if (overlay) g_state.io->MousePos = (ImVec2) { x, y }; - else - core_handleMouseAbsolute(); } void app_updateMouseState(void) @@ -220,13 +225,21 @@ void app_handleFocusEvent(bool focused) if (g_state.focused == focused) return; - g_state.focused = focused; + g_state.focused = focused; + g_cursor.motionValid = false; + const bool inputEnabled = core_inputEnabled(); // release any imgui buttons/keys if we lost focus if (!focused && app_isOverlayMode()) core_resetOverlayInputState(); - if (!core_inputEnabled()) + if (!focused) + { + core_setGrabQuiet(false); + core_setCursorInView(false); + } + + if (!inputEnabled) { if (!focused && g_params.minimizeOnFocusLoss && app_getFullscreen()) g_state.ds->minimize(); @@ -235,9 +248,6 @@ void app_handleFocusEvent(bool focused) if (!focused) { - core_setGrabQuiet(false); - core_setCursorInView(false); - if (g_params.releaseKeysOnFocusLoss) lgInput_releaseKeys(); @@ -262,6 +272,8 @@ void app_handleEnterEvent(bool entered) MTRACE("enter set=%d inWin=%d inView=%d grab=%d", entered, g_cursor.inWindow, g_cursor.inView, g_cursor.grab); + g_cursor.motionValid = false; + if (entered) { g_cursor.inWindow = true; diff --git a/client/src/config.c b/client/src/config.c index 141fb5f9..0c29da49 100644 --- a/client/src/config.c +++ b/client/src/config.c @@ -412,7 +412,7 @@ static struct Option options[] = { .module = "input", .name = "autoCapture", - .description = "Try to keep the mouse captured when needed", + .description = "Grab the keyboard in the guest view and release it before exit", .type = OPTION_TYPE_BOOL, .value.x_bool = false }, diff --git a/client/src/core.c b/client/src/core.c index 9363b371..39dbe199 100644 --- a/client/src/core.c +++ b/client/src/core.c @@ -137,6 +137,34 @@ bool core_inputEnabled(void) ((g_cursor.grab && g_params.captureInputOnly) || !g_params.captureInputOnly); } +static void updateKeyboardGrab(void) +{ + const bool capture = g_cursor.grab && g_params.grabKeyboard; + const bool view = g_cursor.inView && g_params.grabKeyboardOnFocus; + const bool automatic = !g_cursor.grab && + g_cursor.autoCaptureActive && !g_params.captureInputOnly; + const bool active = g_state.focused && core_inputEnabled() && + !app_isOverlayMode() && (capture || view || automatic); + + if (active) + g_state.ds->grabKeyboard(); + else + g_state.ds->ungrabKeyboard(); +} + +static void setAutoCapture(bool active) +{ + active = active && g_params.autoCapture && g_cursor.inView && + !g_cursor.grab && !g_params.captureInputOnly; + if (g_cursor.autoCaptureActive == active) + return; + + MTRACE("auto capture active=%d old=%d", active, + g_cursor.autoCaptureActive); + g_cursor.autoCaptureActive = active; + updateKeyboardGrab(); +} + static void applyView(bool active, bool force) { if (active && !g_cursor.viewReq) @@ -146,33 +174,36 @@ static void applyView(bool active, bool force) } if (!force && g_cursor.inView == active) + { + updateKeyboardGrab(); return; + } MTRACE("view active=%d old=%d req=%d force=%d", active, g_cursor.inView, g_cursor.viewReq, force); - g_cursor.inView = active; - g_cursor.draw = (g_params.alwaysShowCursor || g_params.captureInputOnly) + g_cursor.inView = active; + g_cursor.draw = + (g_params.alwaysShowCursor || g_params.captureInputOnly) ? true : g_cursor.inView; - g_cursor.redraw = true; + g_cursor.redraw = true; + g_cursor.motionValid = false; + g_cursor.autoCaptureActive = active && g_params.autoCapture && + !g_cursor.grab && !g_params.captureInputOnly; g_cursor.warpState = g_cursor.inView ? WARP_STATE_ON : WARP_STATE_OFF; if (g_cursor.inView) { if (g_params.hideMouse) g_state.ds->setPointer(LG_POINTER_NONE); - - if (g_params.grabKeyboardOnFocus) - g_state.ds->grabKeyboard(); } else { if (g_params.hideMouse) g_state.ds->setPointer(LG_POINTER_SQUARE); - - g_state.ds->ungrabKeyboard(); } + updateKeyboardGrab(); g_cursor.warpState = WARP_STATE_ON; } @@ -277,9 +308,9 @@ void core_setGrabQuiet(bool enable) return; cancelSurfaceExit("capture"); - g_cursor.grab = enable; - g_cursor.acc.x = 0.0; - g_cursor.acc.y = 0.0; + g_cursor.acc.x = 0.0; + g_cursor.acc.y = 0.0; + g_cursor.motionValid = false; /* if the display server does not support warp we need to ungrab the pointer * here instead of in the move handler */ @@ -288,8 +319,11 @@ void core_setGrabQuiet(bool enable) if (enable) { + g_state.ignoreInput = false; + g_cursor.grab = true; + g_cursor.autoCaptureActive = false; + updateKeyboardGrab(); core_setCursorInView(true); - g_state.ignoreInput = false; /* ensure the local mouse is inside the window before we capture, this fixes * odd UI behaviour if the user is using focus follows mouse and the window @@ -302,29 +336,21 @@ void core_setGrabQuiet(bool enable) core_warpPointer(local.x, local.y, true); } - if (g_params.grabKeyboard) - g_state.ds->grabKeyboard(); - g_state.ds->capturePointer(); } else { - if (g_params.grabKeyboard) - { - if (!g_params.grabKeyboardOnFocus || - !g_state.focused || g_params.captureInputOnly) - g_state.ds->ungrabKeyboard(); - } - g_state.ds->uncapturePointer(); + g_cursor.grab = false; + g_cursor.autoCaptureActive = g_params.autoCapture && + g_cursor.inView && !g_params.captureInputOnly; + updateKeyboardGrab(); if (warpSupport == LG_DS_WARP_NONE) core_handleMouseAbsolute(); else { - if (!g_params.captureInputOnly) - applyView(g_state.ds->isPointerGrabbed(), false); - + g_cursor.warpState = WARP_STATE_ON; core_alignToGuest(); } } @@ -369,6 +395,7 @@ bool core_warpPointer(int x, int y, bool exiting) MTRACE("warp send target=%d,%d exit=%d pos=%.3f,%.3f", x, y, exiting, g_cursor.pos.x, g_cursor.pos.y); + g_cursor.motionValid = false; g_state.ds->warpPointer(x, y, exiting); return true; } @@ -397,6 +424,7 @@ void core_onWindowSizeChanged(unsigned width, unsigned height) void core_updatePositionInfo(void) { + g_cursor.motionValid = false; cancelExit("geometry"); if (g_params.setGuestRes && @@ -679,13 +707,12 @@ void core_handleGuestMouseUpdate(void) ); } -void core_handleMouseAbsolute(void) +static void handleMouseAbsolute(bool inputEnabled, bool absolute) { if (g_cursor.grab || !g_cursor.inWindow || !g_cursor.valid || !g_state.haveSrcSize || !g_state.posInfoValid || !g_state.focused || g_cursor.realigning || - app_isOverlayMode() || !core_inputEnabled() || - !lgInput_supports(LG_INPUT_SUPPORT_MOUSE_ABSOLUTE)) + app_isOverlayMode() || !inputEnabled || !absolute) return; const bool inView = isInView(); @@ -711,6 +738,79 @@ void core_handleMouseAbsolute(void) g_cursor.realign = false; } +void core_handleMouseAbsolute(void) +{ + const bool inputEnabled = core_inputEnabled(); + const bool absolute = inputEnabled && + lgInput_supports(LG_INPUT_SUPPORT_MOUSE_ABSOLUTE); + handleMouseAbsolute(inputEnabled, absolute); +} + +void core_handleMousePosition(double x, double y) +{ + bool allowPrediction = g_cursor.motionValid && !g_cursor.realign; + const double ex = allowPrediction ? x - g_cursor.pos.x : 0.0; + const double ey = allowPrediction ? y - g_cursor.pos.y : 0.0; + + g_cursor.pos.x = x; + g_cursor.pos.y = y; + g_cursor.valid = true; + g_cursor.motionValid = true; + + const bool inputEnabled = core_inputEnabled(); + const bool absolute = inputEnabled && + lgInput_supports(LG_INPUT_SUPPORT_MOUSE_ABSOLUTE); + const bool canTrack = !g_cursor.grab && g_cursor.inWindow && + g_state.haveSrcSize && g_state.posInfoValid && + g_state.dstRect.valid && g_state.focused && + !app_isOverlayMode() && inputEnabled; + if (canTrack && !g_cursor.realigning) + { + const bool inView = isInView(); + if (g_cursor.viewReq != inView || g_cursor.inView != inView) + { + core_setCursorInView(inView); + allowPrediction = false; + g_cursor.motionValid = true; + } + } + + handleMouseAbsolute(inputEnabled, absolute); + + if (!canTrack) + { + g_cursor.motionValid = false; + setAutoCapture(false); + return; + } + + if (g_cursor.realigning) + { + g_cursor.motionValid = false; + return; + } + + bool active = g_cursor.inView && g_cursor.viewReq; + if (!allowPrediction || !g_params.autoCapture || + !active || g_cursor.buttons) + { + setAutoCapture(active); + return; + } + + const double nextX = x + ex; + const double nextY = y + ey; + active = + nextX >= g_state.dstRect.x && + nextX < g_state.dstRect.x + g_state.dstRect.w && + nextY >= g_state.dstRect.y && + nextY < g_state.dstRect.y + g_state.dstRect.h; + + MTRACE("auto capture move=%.3f,%.3f next=%.3f,%.3f inView=%d", + ex, ey, nextX, nextY, active); + setAutoCapture(active); +} + void core_handleMouseGrabbed(double ex, double ey) { const bool active = g_cursor.grab && g_state.ds->isPointerCaptured(); @@ -911,11 +1011,6 @@ fallback: testExit = false; } - /* if we are in "autoCapture" and the delta was large don't test for exit */ - if (g_params.autoCapture && - (fabs(ex) > 20.0 / g_cursor.scale.x || fabs(ey) > 20.0 / g_cursor.scale.y)) - testExit = false; - /* if any buttons are held we should not allow exit to happen */ if (g_cursor.buttons) testExit = false; @@ -989,6 +1084,9 @@ fallback: } } + if (g_params.autoCapture && testExit) + setAutoCapture(!didExit); + if (absolute) return; @@ -998,25 +1096,11 @@ fallback: if (x == 0 && y == 0) return; - if (g_params.autoCapture) - { - g_cursor.delta.x += x; - g_cursor.delta.y += y; - - if (fabs(g_cursor.delta.x) > 50.0 || fabs(g_cursor.delta.y) > 50.0) - { - g_cursor.delta.x = 0; - g_cursor.delta.y = 0; - } - } - else - { - /* assume the mouse will move to the location we attempt to move it to so we - * avoid warp out of window issues. The cursorThread will correct this if - * wrong after the movement has ocurred on the guest */ - g_cursor.guest.x += x; - g_cursor.guest.y += y; - } + /* assume the mouse will move to the location we attempt to move it to so we + * avoid warp out of window issues. The cursorThread will correct this if + * wrong after the movement has ocurred on the guest */ + g_cursor.guest.x += x; + g_cursor.guest.y += y; MTRACE("motion delta=%d,%d guest=%d,%d exit=%d test=%d warp=%d", x, y, g_cursor.guest.x, g_cursor.guest.y, didExit, testExit, diff --git a/client/src/core.h b/client/src/core.h index 1cc92a24..82be6879 100644 --- a/client/src/core.h +++ b/client/src/core.h @@ -40,6 +40,7 @@ bool core_startFrameThread(void); void core_stopFrameThread(void); void core_handleGuestMouseUpdate(void); void core_handleMouseAbsolute(void); +void core_handleMousePosition(double x, double y); void core_handleMouseGrabbed(double ex, double ey); void core_handleMouseNormal(double ex, double ey); void core_resetOverlayInputState(void); diff --git a/client/src/evdev.c b/client/src/evdev.c index 9909962a..cf977ea7 100644 --- a/client/src/evdev.c +++ b/client/src/evdev.c @@ -381,6 +381,7 @@ void evdev_stop(void) void evdev_grabKeyboard(void) { + state.pending = PENDING_NONE; if (state.grabbed) return; @@ -405,6 +406,7 @@ void evdev_grabKeyboard(void) void evdev_ungrabKeyboard(void) { + state.pending = PENDING_NONE; if (!state.grabbed) return; diff --git a/client/src/main.h b/client/src/main.h index 85e8cd80..4c04f98c 100644 --- a/client/src/main.h +++ b/client/src/main.h @@ -316,12 +316,15 @@ struct CursorState /* true if the position is valid */ bool valid; + /* true if the last local position can be used to predict motion */ + bool motionValid; + + /* true if auto capture currently requests the keyboard */ + bool autoCaptureActive; + /* the button state */ unsigned int buttons; - /* the delta since last warp when in auto capture mode */ - struct DoublePoint delta; - /* the scale factor for the mouse sensitiviy */ int sens; diff --git a/client/tests/CMakeLists.txt b/client/tests/CMakeLists.txt index bd6bcdf1..6a6f7e7f 100644 --- a/client/tests/CMakeLists.txt +++ b/client/tests/CMakeLists.txt @@ -121,6 +121,7 @@ set(MOUSE_CASES exit-immediate exit-guest absolute-sync + auto-capture exit-reentry view-immediate capture-pending diff --git a/client/tests/mouse_test.c b/client/tests/mouse_test.c index bf517699..a9b802df 100644 --- a/client/tests/mouse_test.c +++ b/client/tests/mouse_test.c @@ -42,6 +42,8 @@ enum EvType EV_GUEST, EV_MOTION, EV_VALID, + EV_KEY_GRAB, + EV_KEY_UNGRAB, }; struct Ev @@ -68,6 +70,7 @@ static struct struct Trap lock; bool valid; bool captureGrab; + bool keyboardGrabbed; struct Ev ev[256]; unsigned int count; } @@ -119,8 +122,22 @@ static void setPointer(LG_DSPointer pointer) (void)pointer; } -static void keyNoop(void) +static void keyGrab(void) { + if (m.keyboardGrabbed) + return; + + m.keyboardGrabbed = true; + push(EV_KEY_GRAB, 0, 0, false); +} + +static void keyUngrab(void) +{ + if (!m.keyboardGrabbed) + return; + + m.keyboardGrabbed = false; + push(EV_KEY_UNGRAB, 0, 0, false); } static void grab(void) @@ -209,8 +226,8 @@ static struct LG_DisplayServerOps ds = { .name = "test", .guestPointerUpdated = guest, .setPointer = setPointer, - .grabKeyboard = keyNoop, - .ungrabKeyboard = keyNoop, + .grabKeyboard = keyGrab, + .ungrabKeyboard = keyUngrab, .grabPointer = grab, .ungrabPointer = ungrab, .isPointerGrabbed = grabbed, @@ -511,6 +528,141 @@ static void testAbsoluteSync(void) lgInput_setFallback(&inputOps, NULL); } +static void testAutoCapture(void) +{ + static const enum LG_DSWarpSupport supports[] = { + LG_DS_WARP_SURFACE, + LG_DS_WARP_SCREEN, + }; + + for (unsigned int i = 0; i < sizeof(supports) / sizeof(supports[0]); ++i) + { + reset(); + m.support = supports[i]; + g_params.autoCapture = true; + g_cursor.inView = false; + g_cursor.viewReq = false; + setLocal(50, 50); + + core_invalidatePointer(true); + CHECK(m.keyboardGrabbed); + CHECK(count(EV_KEY_GRAB) == 1); + + setLocal(109, 50); + m.count = 0; + core_handleMouseNormal(1, 0); + CHECK(!m.keyboardGrabbed); + CHECK(count(EV_KEY_UNGRAB) == 1); + CHECK(g_cursor.exit); + CHECK(!g_cursor.inView); + CHECK(!g_cursor.viewReq); + + setLocal(108, 50); + m.count = 0; + core_handleMouseNormal(-1, 0); + CHECK(m.keyboardGrabbed); + CHECK(count(EV_KEY_GRAB) == 1); + CHECK(!g_cursor.exit); + CHECK(g_cursor.inView); + CHECK(g_cursor.viewReq); + } + + bool absolute = true; + lgInput_setFallback(&inputOps, &absolute); + reset(); + g_params.autoCapture = true; + g_cursor.inView = false; + g_cursor.viewReq = false; + g_cursor.valid = true; + setLocal(50, 50); + + core_handleMousePosition(50, 50); + CHECK(m.keyboardGrabbed); + CHECK(count(EV_KEY_GRAB) == 1); + + g_cursor.motionValid = false; + core_handleMousePosition(108, 50); + m.count = 0; + core_handleMousePosition(109, 50); + CHECK(!m.keyboardGrabbed); + CHECK(count(EV_KEY_UNGRAB) == 1); + CHECK(g_cursor.inView); + CHECK(g_cursor.viewReq); + + m.count = 0; + core_handleMousePosition(108, 50); + CHECK(m.keyboardGrabbed); + CHECK(count(EV_KEY_GRAB) == 1); + CHECK(g_cursor.inView); + CHECK(g_cursor.viewReq); + + reset(); + g_params.autoCapture = true; + g_cursor.inView = false; + g_cursor.viewReq = false; + g_cursor.valid = true; + g_cursor.motionValid = true; + setLocal(9, 50); + + core_handleMousePosition(109, 50); + CHECK(m.keyboardGrabbed); + CHECK(count(EV_KEY_GRAB) == 1); + CHECK(g_cursor.motionValid); + + m.count = 0; + core_handleMousePosition(109.5, 50); + CHECK(!m.keyboardGrabbed); + CHECK(count(EV_KEY_UNGRAB) == 1); + + reset(); + g_params.autoCapture = true; + g_cursor.inView = false; + g_cursor.viewReq = false; + g_cursor.valid = true; + g_state.posInfoValid = false; + setLocal(50, 50); + + core_handleMousePosition(50, 50); + CHECK(!m.keyboardGrabbed); + CHECK(!g_cursor.inView); + CHECK(!g_cursor.viewReq); + + g_state.posInfoValid = true; + g_cursor.realigning = true; + core_handleMousePosition(50, 50); + CHECK(!m.keyboardGrabbed); + CHECK(!g_cursor.inView); + CHECK(!g_cursor.viewReq); + + lgInput_setFallback(&inputOps, NULL); + + reset(); + g_params.grabKeyboard = true; + g_state.ignoreInput = true; + + core_setGrabQuiet(true); + CHECK(!g_state.ignoreInput); + CHECK(m.keyboardGrabbed); + CHECK(count(EV_KEY_GRAB) == 1); + + reset(); + m.support = LG_DS_WARP_SCREEN; + m.captureGrab = true; + m.conf.req = false; + m.conf.on = false; + g_params.autoCapture = true; + g_params.grabKeyboard = true; + g_cursor.autoCaptureActive = true; + keyGrab(); + + core_setGrabQuiet(true); + m.count = 0; + core_setGrabQuiet(false); + CHECK(m.keyboardGrabbed); + CHECK(count(EV_KEY_GRAB) == 0); + CHECK(count(EV_KEY_UNGRAB) == 0); +} + static void testExitReentry(void) { startExit(); @@ -624,7 +776,6 @@ static void testCapAlign(void) m.lock.on = true; g_cursor.guest.x += 10; - g_cursor.warpState = WARP_STATE_ON; m.count = 0; core_setGrabQuiet(false); @@ -925,6 +1076,7 @@ static const struct Test tests[] = { { "exit-immediate" , testExitImmediate}, { "exit-guest" , testExitGuest }, { "absolute-sync" , testAbsoluteSync }, + { "auto-capture" , testAutoCapture }, { "exit-reentry" , testExitReentry }, { "view-immediate" , testViewImmediate}, { "capture-pending" , testCapWait }, diff --git a/doc/usage.rst b/doc/usage.rst index 9c923ddd..364333e7 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -328,7 +328,7 @@ All command line options +------------------------------+-------+---------------------+----------------------------------------------------------------------------------------------------------+ | input:mouseTrace | | no | Enable mouse input diagnostics | +------------------------------+-------+---------------------+----------------------------------------------------------------------------------------------------------+ - | input:autoCapture | | no | Try to keep the mouse captured when needed | + | input:autoCapture | | no | Grab the keyboard in the guest view and release it before exit | +------------------------------+-------+---------------------+----------------------------------------------------------------------------------------------------------+ | input:captureOnly | | no | Only enable input via SPICE if in capture mode | +------------------------------+-------+---------------------+----------------------------------------------------------------------------------------------------------+