From 9f2a4ad2ddc28dde16a2ed2b7309e0d458b6d6b8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 22 Feb 2026 16:24:46 +0530 Subject: [PATCH] Wayland: Implement ridiculous workaround for broken dnd Wayland is *the worst* window system I have ever had the displeasure of trying to support. --- glfw/glfw3.h | 2 +- glfw/input.c | 3 ++- glfw/internal.h | 1 + glfw/wl_init.c | 2 +- glfw/wl_window.c | 6 +++--- kitty/boss.py | 12 +++++++++--- kitty/glfw-wrapper.h | 2 +- kitty/glfw.c | 20 ++++++++++++++------ kitty/options/definition.py | 20 +++++++++++++------- kitty/state.h | 2 +- kitty/tabs.py | 6 ++++-- 11 files changed, 50 insertions(+), 26 deletions(-) diff --git a/glfw/glfw3.h b/glfw/glfw3.h index 997133454..87438627d 100644 --- a/glfw/glfw3.h +++ b/glfw/glfw3.h @@ -4984,7 +4984,7 @@ GLFWAPI GLFWdragsourcefun glfwSetDragSourceCallback(GLFWwindow* window, GLFWdrag // Start a drag. If called with operations == -1 indicates that previously // requested data via GLFW_DRAG_DATA_REQUEST is ready. operations == -2 means // that the drag image is changed. -GLFWAPI int glfwStartDrag(GLFWwindow* window, const GLFWDragSourceItem *items, size_t mime_count, const GLFWimage* thumbnail, int operations); +GLFWAPI int glfwStartDrag(GLFWwindow* window, const GLFWDragSourceItem *items, size_t mime_count, const GLFWimage* thumbnail, int operations, bool needs_toplevel_on_wayland); /*! @brief Returns whether the specified joystick is present. * diff --git a/glfw/input.c b/glfw/input.c index 0518a39dc..ba84a54b6 100644 --- a/glfw/input.c +++ b/glfw/input.c @@ -1161,7 +1161,7 @@ _glfwFreeDragSourceData(void) { } GLFWAPI int -glfwStartDrag(GLFWwindow* handle, const GLFWDragSourceItem *items, size_t item_count, const GLFWimage* thumbnail, int operations) { +glfwStartDrag(GLFWwindow* handle, const GLFWDragSourceItem *items, size_t item_count, const GLFWimage* thumbnail, int operations, bool needs_toplevel_on_wayland) { _GLFWwindow* window = (_GLFWwindow*) handle; assert(window != NULL); _GLFW_REQUIRE_INIT_OR_RETURN(EINVAL); @@ -1188,6 +1188,7 @@ glfwStartDrag(GLFWwindow* handle, const GLFWDragSourceItem *items, size_t item_c } _glfw.drag.window_id = window->id; _glfw.drag.operations = operations; + _glfw.drag.needs_toplevel_on_wayland = needs_toplevel_on_wayland; int ans = _glfwPlatformStartDrag(window, thumbnail); if (ans != 0) _glfwFreeDragSourceData(); return ans; diff --git a/glfw/internal.h b/glfw/internal.h index d7e58e50a..71a647def 100644 --- a/glfw/internal.h +++ b/glfw/internal.h @@ -668,6 +668,7 @@ struct _GLFWlibrary struct { GLFWDragSourceItem *items; size_t item_count; GLFWid window_id, instance_id; int operations; + bool needs_toplevel_on_wayland; } drag; }; diff --git a/glfw/wl_init.c b/glfw/wl_init.c index fbbc402fe..e38f651ae 100644 --- a/glfw/wl_init.c +++ b/glfw/wl_init.c @@ -789,7 +789,7 @@ get_compositor_missing_capabilities(void) { C(single_pixel_buffer, wp_single_pixel_buffer_manager_v1); C(preferred_scale, has_preferred_buffer_scale); C(idle_inhibit, idle_inhibit_manager); C(icon, xdg_toplevel_icon_manager_v1); C(bell, xdg_system_bell_v1); C(window-tag, xdg_toplevel_tag_manager_v1); C(keyboard_shortcuts_inhibit, keyboard_shortcuts_inhibit_manager); - C(key-repeat, has_key_repeat_events); + C(key-repeat, has_key_repeat_events); C(top_level_drag, xdg_toplevel_drag_manager_v1); #define P(x) p += snprintf(p, sizeof(buf) - (p - buf), "%s ", x); if (_glfw.wl.xdg_wm_base_version < 6) P("window-state-suspended"); if (_glfw.wl.xdg_wm_base_version < 5) P("window-capabilities"); diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 989ca8167..143b75c90 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -3325,8 +3325,8 @@ drag_source_cancelled(void *data UNUSED, struct wl_data_source *source UNUSED) { // Uber competent Wayland people contravene their own spec and make it // impossible to distinguish between drag cancelled and dropped but not // accepted. https://gitlab.freedesktop.org/wayland/wayland/-/issues/140 - // so we assume this is a drop. Sigh. - cancel_drag(GLFW_DRAG_DROPPED); + // so we assume this is a drop unless we are in top-level mode. Sigh. + cancel_drag(_glfw.wl.drag.toplevel_xdg_toplevel ? GLFW_DRAG_CANCELLED : GLFW_DRAG_DROPPED); } @@ -3411,7 +3411,7 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) { wl_surface_set_buffer_scale(_glfw.wl.drag.drag_icon, scale); } - if (_glfw.wl.xdg_toplevel_drag_manager_v1) { + if (_glfw.drag.needs_toplevel_on_wayland && _glfw.wl.xdg_toplevel_drag_manager_v1) { _glfw.wl.drag.toplevel_drag = xdg_toplevel_drag_manager_v1_get_xdg_toplevel_drag( _glfw.wl.xdg_toplevel_drag_manager_v1, _glfw.wl.drag.source); if (!_glfw.wl.drag.toplevel_drag) return ENOMEM; diff --git a/kitty/boss.py b/kitty/boss.py index 480b010b2..6d6832194 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1945,14 +1945,20 @@ def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bo w.on_drop(drop) def on_drag_source_finished( - self, was_dropped: bool, was_canceled: bool, accepted_mime_type: str, action: int, data: dict[str, bytes] | None + self, was_dropped: bool, was_canceled: bool, accepted_mime_type: str, action: int, data: dict[str, bytes] | None, + needs_toplevel_on_wayland: bool ) -> None: 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 get_tab_being_dragged()[0] == tab_id and (tab := self.tab_for_id(tab_id)): + if 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) + return set_tab_being_dragged() for tm in self.all_tab_managers: tm.on_tab_drop_move() - if was_dropped and (tab := self.tab_for_id(tab_id)): # detach tab into new OS Window + if was_dropped: # detach tab into new OS Window self._move_tab_to(tab) @ac('win', ''' diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index cacfe442c..8769d6b5c 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -2302,7 +2302,7 @@ typedef GLFWdragsourcefun (*glfwSetDragSourceCallback_func)(GLFWwindow*, GLFWdra GFW_EXTERN glfwSetDragSourceCallback_func glfwSetDragSourceCallback_impl; #define glfwSetDragSourceCallback glfwSetDragSourceCallback_impl -typedef int (*glfwStartDrag_func)(GLFWwindow*, const GLFWDragSourceItem*, size_t, const GLFWimage*, int); +typedef int (*glfwStartDrag_func)(GLFWwindow*, const GLFWDragSourceItem*, size_t, const GLFWimage*, int, bool); GFW_EXTERN glfwStartDrag_func glfwStartDrag_impl; #define glfwStartDrag glfwStartDrag_impl diff --git a/kitty/glfw.c b/kitty/glfw.c index d7bc8a9da..1f34f1e24 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -645,11 +645,13 @@ window_focus_callback(GLFWwindow *w, int focused) { #undef osw } +#define TAB_DRAG_MIME_NUMBER 400 + static int is_droppable_mime(const char *mime) { static char tab_mime[64] = {0}; if (!tab_mime[0]) snprintf(tab_mime, sizeof(tab_mime), "application/net.kovidgoyal.kitty-tab-%d", getpid()); - if (strcmp(mime, tab_mime) == 0) return 4; + if (strcmp(mime, tab_mime) == 0) return TAB_DRAG_MIME_NUMBER; if (strcmp(mime, "text/uri-list") == 0) return 3; if (strcmp(mime, "text/plain;charset=utf-8") == 0) return 2; if (strcmp(mime, "text/plain") == 0) return 1; @@ -794,10 +796,10 @@ free_drag_source(void) { static void drag_source_callback(GLFWwindow *window UNUSED, GLFWDragEvent *ev) { #define finish \ - call_boss(on_drag_source_finished, "OOsiO", \ + call_boss(on_drag_source_finished, "OOsiOO", \ ds.was_dropped ? Py_True : Py_False, ds.was_canceled ? Py_True: Py_False, \ ds.accepted_mime_type ? ds.accepted_mime_type : "", \ - ds.action, ds.drag_data ? ds.drag_data : Py_None); \ + ds.action, ds.drag_data ? ds.drag_data : Py_None, ds.needs_toplevel_on_wayland ? Py_True : Py_False); \ free_drag_source(); switch (ev->type) { @@ -816,7 +818,9 @@ drag_source_callback(GLFWwindow *window UNUSED, GLFWDragEvent *ev) { ds.action = ev->action; break; case GLFW_DRAG_DROPPED: ds.was_dropped = true; - if (ev->action == GLFW_DRAG_OPERATION_NONE) { finish } + if (ev->action == GLFW_DRAG_OPERATION_NONE) { + finish + } break; case GLFW_DRAG_CANCELLED: ds.was_canceled = true; @@ -2812,7 +2816,7 @@ change_drag_thumbnail(PyObject *self UNUSED, PyObject *args) { if (!get_thumbnail(global_state.drag_source.thumbnails, &thumbnail, idx)) return NULL; global_state.drag_source.thumbnail_idx = idx; } else global_state.drag_source.thumbnail_idx = -1; - errno = glfwStartDrag(w->handle, NULL, 0, thumbnail.pixels ? &thumbnail : NULL, -2); + errno = glfwStartDrag(w->handle, NULL, 0, thumbnail.pixels ? &thumbnail : NULL, -2, false); if (errno != 0) { PyErr_SetFromErrno(PyExc_OSError); return NULL; @@ -2836,19 +2840,23 @@ start_drag_with_data(PyObject *self UNUSED, PyObject *args, PyObject *kw) { RAII_ALLOC(GLFWDragSourceItem, items, calloc(PyDict_Size(data_map), sizeof(GLFWDragSourceItem))); if (!items) { PyErr_NoMemory(); return NULL; } PyObject *key, *value; Py_ssize_t pos = 0; size_t num = 0; + bool needs_toplevel_on_wayland = false; while (PyDict_Next(data_map, &pos, &key, &value)) { if (!PyUnicode_Check(key)) { PyErr_SetString(PyExc_TypeError, "data_map must have string keys"); return NULL; } if (!PyBytes_Check(value)) { PyErr_SetString(PyExc_TypeError, "data_map must have bytes values"); return NULL; } GLFWDragSourceItem *item = items + num++; item->mime_type = PyUnicode_AsUTF8(key); item->optional_data = PyBytes_AS_STRING(value); item->data_size = PyBytes_GET_SIZE(value); + if (global_state.is_wayland && is_droppable_mime(item->mime_type) == TAB_DRAG_MIME_NUMBER) + needs_toplevel_on_wayland = true; } free_drag_source(); global_state.drag_source.is_active = true; + global_state.drag_source.needs_toplevel_on_wayland = needs_toplevel_on_wayland; global_state.drag_source.drag_data = Py_NewRef(data_map); if (thumbnails) global_state.drag_source.thumbnails = Py_NewRef(thumbnails); global_state.drag_source.thumbnail_idx = thumbnail.pixels ? 0 : -1; - errno = glfwStartDrag(w->handle, items, num, thumbnail.pixels ? &thumbnail : NULL, operations); + errno = glfwStartDrag(w->handle, items, num, thumbnail.pixels ? &thumbnail : NULL, operations, needs_toplevel_on_wayland); if (errno != 0) { PyErr_SetFromErrno(PyExc_OSError); return NULL; diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 1daff9be3..00cb41ced 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1698,17 +1698,23 @@ color is chosen to match the background color of the neighboring tab. ''') -opt('tab_bar_drag_threshold', '5', option_type='positive_int', - long_text=''' +opt( + 'tab_bar_drag_threshold', + '5', + option_type='positive_int', + long_text=""" Control when dragging of tabs to re-order them happens. The value is the drag threshold in pixels, the distance the mouse must move before a drag begins. A value of zero disables tab dragging entirely. Dragging a tab to another kitty window moves it there, while dragging -outside any kitty window detaches it into a new OS window. Note that on -Wayland, :link:`because of poor design -` drag and cancel -will still detach tabs. -''') +outside any kitty window detaches it into a new OS window. + +Note that on Wayland, :link:`because of poor design +` cancelling +a drag will detach the tab. This is worked around for compositors that support +:link:`xdg-toplevel-drag `. +""", +) egr() # }}} diff --git a/kitty/state.h b/kitty/state.h index a2690a5ac..75000bf89 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -384,7 +384,7 @@ typedef struct GlobalState { } drop_dest; struct { - bool is_active, was_dropped, was_canceled; + bool is_active, was_dropped, was_canceled, needs_toplevel_on_wayland; char *accepted_mime_type; int action, thumbnail_idx; PyObject *drag_data, *thumbnails; diff --git a/kitty/tabs.py b/kitty/tabs.py index 42b7ae4b3..4d371b873 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1608,14 +1608,16 @@ def on_tab_drop_move(self, tab_id: int = 0, is_dest: bool = False, x: int = 0, y self.layout_tab_bar() @update_tab_bar_visibility - def on_tab_drop(self, x: int, y: int) -> None: + def on_tab_drop(self, x: int, y: int, bypass_move: bool = False) -> None: if (td := self.tab_being_dropped) is None: return if (tab := get_boss().tab_for_id(td.data.tab_id)) is None: return - self.on_tab_drop_move(td.data.tab_id, True, x, y) + if not bypass_move: + self.on_tab_drop_move(td.data.tab_id, True, x, y) if (td := self.tab_being_dropped) is None: return + print(3333333333) self.tab_being_dropped = None atid = self.active_tab.id if self.active_tab else 0 set_tab_being_dragged()