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()