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>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-14 02:49:17 +00:00 committed by GitHub
parent bfc3646868
commit 66dca3cde1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 73 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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