From dc36e2165458af235a257c662e421cad135aeda0 Mon Sep 17 00:00:00 2001 From: ttyS3 <41882455+ttys3@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:36:19 +0000 Subject: [PATCH] fix(tabs): mouse handling stuck after aborted tab drag on Wayland A quick click-and-flick on a tab could leave all of kitty with mouse input permanently redirected to the tab bar, making every window unclickable and text selection impossible. Starting a tab drag is asynchronous: the drag thumbnail is rendered on the next frame before glfwStartDrag is called. If the button is released in that window, wl_data_device_start_drag is sent with a stale serial that no longer matches an active pointer implicit grab, so the compositor silently ignores it. The wl_data_source then never receives any event, on_drag_source_finished never runs, and the tab_being_dragged state is stuck forever, hijacking all mouse events. Fix in layers: - glfw/Wayland: track the implicit grab (serial of the first button press and pressed-button count), use that serial for start_drag and refuse with EAGAIN when there is no active implicit grab instead of letting the compositor silently drop the request - mouse.c: a left button release arriving while a tab drag is marked started but no system DND is active means the drag never launched (an active DND consumes the release on all platforms), so clear the drag state instead of waiting for DND events that will never come - tabs.py: handle OSError from start_drag_with_data for tab drags the same way window drags already do; clear the potential-drag state when the release lands on the new-tab button or empty tab bar area - tabs.py/boss.py: clear drag state on drag finish/drop even when the dragged tab has already been closed Co-Authored-By: Claude Fable 5 --- glfw/wl_init.c | 8 ++++++++ glfw/wl_platform.h | 6 ++++++ glfw/wl_window.c | 13 ++++++++++++- kitty/boss.py | 7 ++++--- kitty/mouse.c | 13 +++++++++++++ kitty/tabs.py | 17 +++++++++++++++-- 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/glfw/wl_init.c b/glfw/wl_init.c index edfe97a96..6741fa528 100644 --- a/glfw/wl_init.c +++ b/glfw/wl_init.c @@ -102,6 +102,11 @@ pointerHandleEnter( static void pointerHandleLeave(void* data UNUSED, struct wl_pointer* pointer UNUSED, uint32_t serial, struct wl_surface* surface) { + // The pointer never leaves the surface during an implicit grab, so a + // leave event means any implicit grab is over (e.g. the compositor took + // over the pointer for drag-and-drop). The matching button releases will + // never be delivered to us. + _glfw.wl.pointer_button_count = 0; _GLFWwindow* window = _glfw.wl.pointerFocus; if (!window) return; _glfw.wl.serial = serial; @@ -138,6 +143,9 @@ static void pointerHandleButton(void* data UNUSED, { glfw_cancel_momentum_scroll(); _glfw.wl.serial = serial; _glfw.wl.input_serial = serial; _glfw.wl.pointer_serial = serial; + if (state == WL_POINTER_BUTTON_STATE_PRESSED) { + if (_glfw.wl.pointer_button_count++ == 0) _glfw.wl.pointer_grab_serial = serial; + } else if (_glfw.wl.pointer_button_count > 0) _glfw.wl.pointer_button_count--; _GLFWwindow* window = _glfw.wl.pointerFocus; if (!window) return; diff --git a/glfw/wl_platform.h b/glfw/wl_platform.h index e294a06ad..af17d12a4 100644 --- a/glfw/wl_platform.h +++ b/glfw/wl_platform.h @@ -374,6 +374,12 @@ typedef struct _GLFWlibraryWayland struct wl_surface* cursorSurface; GLFWCursorShape cursorPreviousShape; uint32_t serial, input_serial, pointer_serial, pointer_enter_serial, keyboard_enter_serial; + // serial of the button press that started the current pointer implicit + // grab, and the number of currently pressed pointer buttons. Requests + // such as wl_data_device.start_drag are silently ignored by compositors + // unless made with the serial of an active implicit grab. + uint32_t pointer_grab_serial; + unsigned pointer_button_count; int32_t keyboardRepeatRate; monotonic_t keyboardRepeatDelay; diff --git a/glfw/wl_window.c b/glfw/wl_window.c index d8cc01a6a..abb586f41 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -3496,6 +3496,17 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) { return ENOTSUP; } + if (_glfw.wl.pointer_button_count == 0) { + // start_drag requires the serial of an active pointer implicit grab, + // without one the compositor silently ignores the request and the + // data source never receives any events, so fail early instead. + // This can happen as drags are started asynchronously and the button + // may have been released by the time we get here. EPERM matches what + // start_window_drag() in kitty/glfw.c reports for this situation. + _glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: Refusing to start drag without an active pointer implicit grab"); + return EPERM; + } + // Create the data source _glfw.wl.drag.source = wl_data_device_manager_create_data_source(_glfw.wl.dataDeviceManager); if (!_glfw.wl.drag.source) { @@ -3568,7 +3579,7 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) { wl_data_device_start_drag( _glfw.wl.dataDevice, _glfw.wl.drag.source, window->wl.surface, _glfw.wl.drag.toplevel_drag ? NULL : _glfw.wl.drag.drag_icon, - _glfw.wl.pointer_serial); + _glfw.wl.pointer_grab_serial); if (_glfw.wl.drag.toplevel_drag) { // Attach the toplevel AFTER start_drag, otherwise doesnt work on mutter diff --git a/kitty/boss.py b/kitty/boss.py index 2e5cbe8cd..ece948347 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2046,8 +2046,9 @@ class Boss: self._move_window_to(window, target_os_window_id='new') return if (tab_id := int((data or {}).get(f'application/net.kovidgoyal.kitty-tab-{os.getpid()}', b'0').decode()) - ) and get_tab_being_dragged()[0] == tab_id and (tab := self.tab_for_id(tab_id)): - if needs_toplevel_on_wayland: + ) and get_tab_being_dragged()[0] == tab_id: + tab = self.tab_for_id(tab_id) + if tab is not None and needs_toplevel_on_wayland: for tm in self.all_tab_managers: if tm.tab_being_dropped: tm.on_tab_drop(0, 0, bypass_move=True) @@ -2055,7 +2056,7 @@ class Boss: set_tab_being_dragged() for tm in self.all_tab_managers: tm.on_tab_drop_move() - if was_dropped: # detach tab into new OS Window + if was_dropped and tab is not None: # detach tab into new OS Window self._move_tab_to(tab) @ac('win', ''' diff --git a/kitty/mouse.c b/kitty/mouse.c index 4e6682ecb..5c7557d92 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -957,6 +957,19 @@ static void handle_tab_bar_mouse(int button, int modifiers, int action) { set_currently_hovered_window(0, modifiers); OSWindow *w = global_state.callback_os_window; + if (button == GLFW_MOUSE_BUTTON_LEFT && action == GLFW_RELEASE && global_state.tab_being_dragged.id + && global_state.tab_being_dragged.drag_started && !global_state.drag_source.is_active) { + // Once a system drag and drop is active the release is consumed by it + // and never delivered to us, so getting one here means the drag never + // became a system DND: either glfwStartDrag failed/was not called yet + // or the compositor silently ignored it (Wayland with a stale serial). + // Clear the drag state so mouse handling is not redirected to the tab + // bar forever, and swallow the release as it ended an aborted drag. + zero_at_ptr(&global_state.tab_being_dragged); + // re-render the tab bar in case it was drawn without the dragged tab + if (w) w->tab_bar_data_updated = false; + return; + } // dont report motion events, as they are expensive and useless if (w && (button > -1 || global_state.tab_being_dragged.id)) { call_boss(handle_tab_bar_mouse, "Kddiii", w->id, w->mouse_x, w->mouse_y, button, modifiers, action); diff --git a/kitty/tabs.py b/kitty/tabs.py index 28f86a5e0..04e8a8266 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1732,6 +1732,9 @@ class TabManager: # {{{ if (td := self.tab_being_dropped) is None: return if (tab := get_boss().tab_for_id(td.data.tab_id)) is None: + self.tab_being_dropped = None + set_tab_being_dragged() + self.layout_tab_bar() return if not bypass_move: self.on_tab_drop_move(td.data.tab_id, True, x, y) @@ -1779,7 +1782,12 @@ class TabManager: # {{{ drag_data = { f'application/net.kovidgoyal.kitty-tab-{os.getpid()}': str(tab.id).encode(), } - start_drag_with_data(self.os_window_id, drag_data, thumbnails) + try: + start_drag_with_data(self.os_window_id, drag_data, thumbnails) + except OSError as e: + log_error(f'Failed to start tab drag: {e}') + set_tab_being_dragged() + self.mark_tab_bar_dirty() # re-render the tab bar in case it was drawn without the dragged tab break else: set_tab_being_dragged() @@ -1797,16 +1805,21 @@ class TabManager: # {{{ tab_id_at_x = self.tab_bar.tab_id_at(int(x)) self.recent_tab_bar_mouse_events.add(button, modifiers, action, x, y, tab_id_at_x) + drag_started = get_tab_being_dragged()[1] + is_left_release = button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE if tab_id_at_x < 0: # synthetic tab (e.g. "+" new-tab button) + if is_left_release and not drag_started: + set_tab_being_dragged() # clear potential drag from a press on a tab if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1: self.new_tab() self.recent_tab_bar_mouse_events.clear() return - drag_started = get_tab_being_dragged()[1] if drag_started: return tab = self.tab_for_id(tab_id_at_x) if tab is None: + if is_left_release: + set_tab_being_dragged() # clear potential drag from a press on a tab if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 2: self.new_tab() self.recent_tab_bar_mouse_events.clear()