From 66dca3cde14ed97f202a94d64c38eca5b6654e92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 02:49:17 +0000 Subject: [PATCH] Fix dnd kitten: restrict drop to boxes matching drag source allowed operations Agent-Logs-Url: https://github.com/kovidgoyal/kitty/sessions/41b8254d-fc79-4f41-9775-67d1ddfceb5b Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com> --- kittens/dnd/drop.go | 29 ++++++++++++++++++++--------- kittens/dnd/main.go | 4 ++-- kitty/dnd.c | 19 +++++++++++++------ kitty/dnd.h | 2 +- kitty/glfw.c | 10 ++++++++-- kitty_tests/dnd_kitten.py | 29 +++++++++++++++++++++++++++++ 6 files changed, 73 insertions(+), 20 deletions(-) diff --git a/kittens/dnd/drop.go b/kittens/dnd/drop.go index d8ad636a1..31ba9acc2 100644 --- a/kittens/dnd/drop.go +++ b/kittens/dnd/drop.go @@ -379,14 +379,15 @@ func parse_uri_list(src string) (ans []string, err error) { } type drop_status struct { - offered_mimes []string - accepted_mimes []string - uri_list []string - cell_x, cell_y int - action int - in_window bool - reading_data bool - is_remote_client bool + offered_mimes []string + accepted_mimes []string + uri_list []string + cell_x, cell_y int + action int + in_window bool + reading_data bool + is_remote_client bool + source_allowed_ops int dropping_to *dir_handle root_remote_dir *remote_dir_entry @@ -594,7 +595,7 @@ func (dnd *dnd) request_mime_data() { var offered_mimes_buf strings.Builder -func (dnd *dnd) on_drop_move(cell_x, cell_y int, has_more bool, offered_mimes string, is_drop bool) (needs_rerender bool) { +func (dnd *dnd) on_drop_move(cell_x, cell_y int, has_more bool, offered_mimes string, is_drop bool, source_allowed_ops int) (needs_rerender bool) { prev_status := dnd.drop_status dnd.drop_status.cell_x, dnd.drop_status.cell_y = cell_x, cell_y if offered_mimes != "" { @@ -614,6 +615,9 @@ func (dnd *dnd) on_drop_move(cell_x, cell_y int, has_more bool, offered_mimes st } } offered_mimes_buf.Reset() + if source_allowed_ops != 0 { + dnd.drop_status.source_allowed_ops = source_allowed_ops + } if dnd.copy_button_region.has(cell_x, cell_y) { dnd.drop_status.action = copy_on_drop } else if dnd.move_button_region.has(cell_x, cell_y) { @@ -629,6 +633,13 @@ func (dnd *dnd) on_drop_move(cell_x, cell_y int, has_more bool, offered_mimes st dnd.drop_status.action = move_on_drop } } + // Restrict to operations allowed by the drag source. + if sao := dnd.drop_status.source_allowed_ops; sao != 0 && dnd.drop_status.action != 0 { + if sao&dnd.drop_status.action == 0 { + dnd.drop_status.action = 0 + dnd.drop_status.accepted_mimes = nil + } + } dnd.drop_status.in_window = cell_x > -1 && cell_y > -1 if !dnd.drop_status.in_window || dnd.drag_status.active { // disallow self drag and drop dnd.reset_drop() diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index bc9272212..5036b746c 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -259,11 +259,11 @@ func (dnd *dnd) run_loop() (err error) { if cmd.Payload != nil { payload = utils.UnsafeBytesToString(cmd.Payload) } - if dnd.on_drop_move(cmd.X, cmd.Y, cmd.Has_more, payload, false) { + if dnd.on_drop_move(cmd.X, cmd.Y, cmd.Has_more, payload, false, cmd.Operation) { dnd.render_screen() } case 'M': - if dnd.on_drop_move(cmd.X, cmd.Y, cmd.Has_more, utils.UnsafeBytesToString(cmd.Payload), true) { + if dnd.on_drop_move(cmd.X, cmd.Y, cmd.Has_more, utils.UnsafeBytesToString(cmd.Payload), true, cmd.Operation) { dnd.render_screen() } case 'R': diff --git a/kitty/dnd.c b/kitty/dnd.c index edd14bb58..c33730e1a 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -505,7 +505,7 @@ drop_register_machine_id(Window *w, const uint8_t *machine_id, size_t sz) { } void -drop_move_on_child(Window *w, const char** mimes, size_t num_mimes, bool is_drop) { +drop_move_on_child(Window *w, const char** mimes, size_t num_mimes, bool is_drop, int allowed_ops) { if (!w->drop.hovered) { reset_drop(w); w->drop.hovered = true; @@ -530,9 +530,15 @@ drop_move_on_child(Window *w, const char** mimes, size_t num_mimes, bool is_drop // we simply drop this event if there is too much data being written to the child if (w->drop.pending.count && !is_drop) return; char buf[128]; - int header_size = snprintf(buf, sizeof(buf), "\x1b]%d;t=%c:x=%u:y=%u:X=%d:Y=%d", DND_CODE, - is_drop ? 'M' : 'm', w->mouse_pos.cell_x, w->mouse_pos.cell_y, - (int)w->mouse_pos.global_x, (int)w->mouse_pos.global_y); + int header_size; + if (allowed_ops) + header_size = snprintf(buf, sizeof(buf), "\x1b]%d;t=%c:x=%u:y=%u:X=%d:Y=%d:o=%d", DND_CODE, + is_drop ? 'M' : 'm', w->mouse_pos.cell_x, w->mouse_pos.cell_y, + (int)w->mouse_pos.global_x, (int)w->mouse_pos.global_y, allowed_ops); + else + header_size = snprintf(buf, sizeof(buf), "\x1b]%d;t=%c:x=%u:y=%u:X=%d:Y=%d", DND_CODE, + is_drop ? 'M' : 'm', w->mouse_pos.cell_x, w->mouse_pos.cell_y, + (int)w->mouse_pos.global_x, (int)w->mouse_pos.global_y); if (w->drop.offered_mimes_total_size) { const size_t mimes_total_size = 1 + w->drop.offered_mimes_total_size; RAII_ALLOC(char, mbuf, malloc(mimes_total_size)); @@ -2409,7 +2415,8 @@ dnd_test_fake_drop_event(PyObject *self UNUSED, PyObject *args) { int is_drop; PyObject *mimes_seq = Py_None; int x = -2, y = -2; - if (!PyArg_ParseTuple(args, "Kp|Oii", &window_id, &is_drop, &mimes_seq, &x, &y)) return NULL; + int allowed_ops = 0; + if (!PyArg_ParseTuple(args, "Kp|Oiii", &window_id, &is_drop, &mimes_seq, &x, &y, &allowed_ops)) return NULL; Window *w = window_for_window_id((id_type)window_id); if (!w) { PyErr_SetString(PyExc_ValueError, "Window not found"); return NULL; } if (mimes_seq == Py_None) { @@ -2427,7 +2434,7 @@ dnd_test_fake_drop_event(PyObject *self UNUSED, PyObject *args) { } if (x > -1) w->mouse_pos.cell_x = x; if (y > -1) w->mouse_pos.cell_y = y; - drop_move_on_child(w, mimes, (size_t)num_mimes, is_drop ? true : false); + drop_move_on_child(w, mimes, (size_t)num_mimes, is_drop ? true : false, allowed_ops); Py_RETURN_NONE; } diff --git a/kitty/dnd.h b/kitty/dnd.h index 86833d549..6e001c609 100644 --- a/kitty/dnd.h +++ b/kitty/dnd.h @@ -12,7 +12,7 @@ void dnd_query(Window *w, uint32_t client_id); void drop_register_window(Window *w, const uint8_t *payload, size_t payload_sz, bool on, uint32_t client_id, bool more); void drop_register_machine_id(Window *w, const uint8_t *machine_id, size_t sz); -void drop_move_on_child(Window *w, const char **mimes, size_t num_mimes, bool is_drop); +void drop_move_on_child(Window *w, const char **mimes, size_t num_mimes, bool is_drop, int allowed_ops); void drop_left_child(Window *w); void drop_free_data(Window *w); void drop_send_einval(Window *w, const char *desc); diff --git a/kitty/glfw.c b/kitty/glfw.c index a75ee14af..c163ef50e 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -802,7 +802,10 @@ drop_dest_callback(GLFWwindow *window, GLFWDropEvent *ev) { on_mouse_position_update(ev->xpos, ev->ypos); if (is_client_drop) { if (ev->type == GLFW_DROP_ENTER) w->drop.accepted_operation = GLFW_DRAG_OPERATION_NONE; - drop_move_on_child(w, ev->mimes, ev->num_mimes, false); + int kitty_allowed_ops = 0; + if (ev->operation.allowed & GLFW_DRAG_OPERATION_COPY) kitty_allowed_ops |= 1; + if (ev->operation.allowed & GLFW_DRAG_OPERATION_MOVE) kitty_allowed_ops |= 2; + drop_move_on_child(w, ev->mimes, ev->num_mimes, false, kitty_allowed_ops); ev->num_mimes = drop_update_mimes(w, ev->mimes, ev->num_mimes); ev->operation.allowed = w->drop.accepted_operation; ev->operation.preferred = w->drop.accepted_operation; @@ -851,7 +854,10 @@ drop_dest_callback(GLFWwindow *window, GLFWDropEvent *ev) { global_state.drop_dest.client_window_data_request = 0; global_state.drop_dest.os_window_id = os_window->id; if (is_client_drop) { - drop_move_on_child(w, ev->mimes, ev->num_mimes, true); + int kitty_allowed_ops = 0; + if (ev->operation.allowed & GLFW_DRAG_OPERATION_COPY) kitty_allowed_ops |= 1; + if (ev->operation.allowed & GLFW_DRAG_OPERATION_MOVE) kitty_allowed_ops |= 2; + drop_move_on_child(w, ev->mimes, ev->num_mimes, true, kitty_allowed_ops); ev->num_mimes = 0; // we wait for the client to request MIMEs } else { if (ev->from_self) { diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index dae659ddd..55fa8ffc0 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -197,6 +197,35 @@ class TestDnDKitten(BaseTest): self.screen = None self.pty = None + def test_dnd_kitten_drop_allowed_ops(self): + # Test that the drop destination respects the drag source's allowed operations. + # When the drag source only allows copy (allowed_ops=1), dropping on the move + # box must be rejected, and vice versa. + self.finish_setup() + copy, move = self.get_button_geometry() + mimes = ['text/uri-list'] + wid = self.capture.window_id + # allowed_ops=1 means copy-only in kitten format + dnd_test_fake_drop_event(wid, False, mimes, copy[0] + 1, copy[1] + 1, 1) + self.wait_for_state('drop_action', GLFW_DRAG_OPERATION_COPY) + dnd_test_fake_drop_event(wid, False) + dnd_test_fake_drop_event(wid, False, mimes, move[0] + 1, move[1] + 1, 1) + self.wait_for_state('drop_action', 0) # GLFW_DRAG_OPERATION_NONE + dnd_test_fake_drop_event(wid, False) + # allowed_ops=2 means move-only in kitten format + dnd_test_fake_drop_event(wid, False, mimes, move[0] + 1, move[1] + 1, 2) + self.wait_for_state('drop_action', GLFW_DRAG_OPERATION_MOVE) + dnd_test_fake_drop_event(wid, False) + dnd_test_fake_drop_event(wid, False, mimes, copy[0] + 1, copy[1] + 1, 2) + self.wait_for_state('drop_action', 0) # GLFW_DRAG_OPERATION_NONE + dnd_test_fake_drop_event(wid, False) + # allowed_ops=3 means both copy and move allowed (default) + for b, expected in ((copy, GLFW_DRAG_OPERATION_COPY), (move, GLFW_DRAG_OPERATION_MOVE)): + dnd_test_fake_drop_event(wid, False, mimes, b[0] + 1, b[1] + 1, 3) + self.wait_for_state('drop_action', expected) + dnd_test_fake_drop_event(wid, False) + self.exit_kitten() + def test_dnd_kitten_drop(self): img_drop_path = 'images/image.png' self.finish_setup(cli_args=(f'--drop=image/png:{img_drop_path}', '--confirm-drop-overwrite'))