Wayland: Implement ridiculous workaround for broken dnd

Wayland is *the worst* window system I have ever had the
displeasure of trying to support.
This commit is contained in:
Kovid Goyal 2026-02-22 16:24:46 +05:30
parent 322a51da98
commit 9f2a4ad2dd
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
11 changed files with 50 additions and 26 deletions

2
glfw/glfw3.h vendored
View file

@ -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.
*

3
glfw/input.c vendored
View file

@ -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;

1
glfw/internal.h vendored
View file

@ -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;
};

2
glfw/wl_init.c vendored
View file

@ -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");

6
glfw/wl_window.c vendored
View file

@ -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;

View file

@ -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', '''

2
kitty/glfw-wrapper.h generated
View file

@ -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

View file

@ -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;

View file

@ -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
<https://gitlab.freedesktop.org/wayland/wayland/-/issues/140>` 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
<https://gitlab.freedesktop.org/wayland/wayland/-/issues/140>` cancelling
a drag will detach the tab. This is worked around for compositors that support
:link:`xdg-toplevel-drag <https://wayland.app/protocols/xdg-toplevel-drag-v1>`.
""",
)
egr() # }}}

View file

@ -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;

View file

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