From a899d24b6499c1830137486026d4180e3826095c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Apr 2026 19:19:51 +0530 Subject: [PATCH] More work on DnD kitten --- kittens/dnd/main.go | 190 ++++++++++++++++++++++++++------------ kitty/dnd.c | 7 +- kitty_tests/dnd_kitten.py | 21 +++-- 3 files changed, 148 insertions(+), 70 deletions(-) diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index f2856d0a0..047b747c1 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -75,6 +75,17 @@ func truncate_at_space(text string, width int) (string, string) { return text[:p], text[p:] } +type drop_status struct { + offered_mimes []string + accepted_mimes []string + cell_x, cell_y int + action int + in_window bool + reading_data bool + requesting_mime_idx_plus_one int + is_remote_client bool +} + func paragraph_as_lines(text string, width int) (ans []string) { for text != "" { var line string @@ -87,6 +98,7 @@ func paragraph_as_lines(text string, width int) (ans []string) { func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[string]drag_source, uri_list_buffer *bytes.Buffer) (err error) { allow_drops, allow_drags := len(drop_dests) > 0, len(drag_sources) > 0 + data_has_been_dropped := false drag_started := false in_test_mode := false lp, err := loop.New() @@ -98,13 +110,8 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s lp.DebugPrintln(payload) } - var drop_status struct { - offered_mimes []string - accepted_mimes []string - cell_x, cell_y int - action int - in_window bool - } + drop_status := drop_status{cell_x: -1, cell_y: -1} + reset_drop_status := drop_status drop_status.cell_x, drop_status.cell_y = -1, -1 const copy_on_drop = 1 const move_on_drop = 2 @@ -112,58 +119,6 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s var copy_button_region, move_button_region button_region var offered_mimes_buf strings.Builder - on_drop_move := func(cell_x, cell_y int, has_more bool, offered_mimes string) (needs_rerender bool) { - prev_status := drop_status - drop_status.cell_x, drop_status.cell_y = cell_x, cell_y - if offered_mimes != "" { - offered_mimes_buf.WriteString(offered_mimes) - if has_more { - return - } - offered_mimes := offered_mimes_buf.String() - drop_status.offered_mimes = strings.Fields(offered_mimes) - drop_status.accepted_mimes = make([]string, 0, len(drop_status.offered_mimes)) - seen := utils.NewSet[string](len(drop_status.offered_mimes)) - for _, x := range drop_status.offered_mimes { - if _, found := drop_dests[x]; found && !seen.Has(x) { - drop_status.accepted_mimes = append(drop_status.accepted_mimes, x) - seen.Add(x) - } - } - } - offered_mimes_buf.Reset() - if copy_button_region.has(cell_x, cell_y) { - drop_status.action = copy_on_drop - } else if move_button_region.has(cell_x, cell_y) { - drop_status.action = move_on_drop - } else { - switch opts.DropAnywhere { - case "disallowed": - drop_status.action = 0 - drop_status.accepted_mimes = nil - case "copy": - drop_status.action = copy_on_drop - case "move": - drop_status.action = move_on_drop - } - } - drop_status.in_window = cell_x > -1 && cell_y > -1 - if !drop_status.in_window { - drop_status.offered_mimes = nil - } - mimes_changed := !slices.Equal(prev_status.accepted_mimes, drop_status.accepted_mimes) - needs_rerender = prev_status.action != drop_status.action || mimes_changed - if needs_rerender { - c := DC{Type: 'm', Operation: drop_status.action} - if drop_status.action != 0 && len(drop_status.accepted_mimes) > 0 { - c.Payload = utils.UnsafeStringToBytes(strings.Join(drop_status.accepted_mimes, " ")) - } - lp.QueueDnDData(c) - } - needs_rerender = needs_rerender || drop_status.in_window != prev_status.in_window - return - } - render_screen := func() error { // {{{ if !in_test_mode { lp.StartAtomicUpdate() @@ -175,6 +130,10 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s lp.Println("Dragging data...") return nil } + if drop_status.reading_data { + lp.Println("Reading dropped data, please wait...") + return nil + } y := 0 sz, _ := lp.ScreenSize() render_paragraph := func(text string) { @@ -204,7 +163,11 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s next_line() } if allow_drops { - render_paragraph(`Drag some data from another application into this window to transfer the files here.`) + if data_has_been_dropped { + render_paragraph(`Data has been successfully dropped. You can drop more data or press Esc to quit.`) + } else { + render_paragraph(`Drag some data from another application into this window to transfer the files here.`) + } } } frame_width, padding_width := 4, 8 @@ -265,6 +228,99 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s return nil } // }}} + // Drop handling {{{ + end_drop := func() { + lp.QueueDnDData(DC{Type: 'r'}) // end drop + drop_status = reset_drop_status + render_screen() + } + + all_mime_data_dropped := func() { + if _, found := drop_dests["text/uri-list"]; found && drop_status.is_remote_client { + // TODO: Handle remote client + } else { + drop_status = reset_drop_status + data_has_been_dropped = true + render_screen() + } + } + + request_mime_data := func() { + drop_status.requesting_mime_idx_plus_one++ + idx := drop_status.requesting_mime_idx_plus_one - 1 + if idx >= len(drop_status.accepted_mimes) { + all_mime_data_dropped() + return + } + lp.QueueDnDData(DC{Type: 'r', X: idx + 1}) + } + + on_drop_move := func(cell_x, cell_y int, has_more bool, offered_mimes string, is_drop bool) (needs_rerender bool) { + prev_status := drop_status + drop_status.cell_x, drop_status.cell_y = cell_x, cell_y + if offered_mimes != "" { + offered_mimes_buf.WriteString(offered_mimes) + if has_more { + return + } + offered_mimes := offered_mimes_buf.String() + drop_status.offered_mimes = strings.Fields(offered_mimes) + drop_status.accepted_mimes = make([]string, 0, len(drop_status.offered_mimes)) + seen := utils.NewSet[string](len(drop_status.offered_mimes)) + for _, x := range drop_status.offered_mimes { + if _, found := drop_dests[x]; found && !seen.Has(x) { + drop_status.accepted_mimes = append(drop_status.accepted_mimes, x) + seen.Add(x) + } + } + } + offered_mimes_buf.Reset() + if copy_button_region.has(cell_x, cell_y) { + drop_status.action = copy_on_drop + } else if move_button_region.has(cell_x, cell_y) { + drop_status.action = move_on_drop + } else { + switch opts.DropAnywhere { + case "disallowed": + drop_status.action = 0 + drop_status.accepted_mimes = nil + case "copy": + drop_status.action = copy_on_drop + case "move": + drop_status.action = move_on_drop + } + } + drop_status.in_window = cell_x > -1 && cell_y > -1 + if !drop_status.in_window || drag_started { // disallow self drag and drop + drop_status = reset_drop_status + } + mimes_changed := !slices.Equal(prev_status.accepted_mimes, drop_status.accepted_mimes) + needs_rerender = prev_status.action != drop_status.action || mimes_changed + if needs_rerender && !is_drop { + c := DC{Type: 'm', Operation: drop_status.action} + if drop_status.action != 0 && len(drop_status.accepted_mimes) > 0 { + c.Payload = utils.UnsafeStringToBytes(strings.Join(drop_status.accepted_mimes, " ")) + } + lp.QueueDnDData(c) + } + needs_rerender = needs_rerender || drop_status.in_window != prev_status.in_window + if is_drop { + needs_rerender = true + if drop_status.action == 0 || len(drop_status.accepted_mimes) == 0 || drag_started { + end_drop() + return + } + drop_status.reading_data = true + request_mime_data() + } + return + } + + on_drop_data := func(cmd DC) error { + return nil + } + // }}} + lp.OnInitialize = func() (string, error) { lp.AllowLineWrapping(false) lp.SetCursorVisible(false) @@ -277,6 +333,7 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s lp.SetWindowTitle("Drag and drop") return "", render_screen() } + lp.OnFinalize = func() string { lp.AllowLineWrapping(true) lp.SetCursorVisible(true) @@ -288,6 +345,7 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s } return "" } + lp.OnDnDData = func(cmd loop.DndCommand) error { // TODO: Use lp.QueueDnDData to implement drag and drop protocol // If allow_drags, start a drag when the terminal sends the t=o @@ -350,9 +408,19 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s if cmd.Payload != nil { payload = utils.UnsafeBytesToString(cmd.Payload) } - if on_drop_move(cmd.X, cmd.Y, cmd.Has_more, payload) { + if on_drop_move(cmd.X, cmd.Y, cmd.Has_more, payload, false) { render_screen() } + case 'M': + if on_drop_move(cmd.X, cmd.Y, cmd.Has_more, utils.UnsafeBytesToString(cmd.Payload), true) { + render_screen() + } + case 'R': + return fmt.Errorf("error from the terminal while reading dropped data: %s", string(cmd.Payload)) + case 'r': + err := on_drop_data(cmd) + render_screen() + return err } return nil } diff --git a/kitty/dnd.c b/kitty/dnd.c index e418d702f..357552517 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -2061,8 +2061,9 @@ dnd_test_fake_drop_event(PyObject *self UNUSED, PyObject *args) { // None to simulate a leave event. unsigned long long window_id; int is_drop; - PyObject *mimes_seq; - if (!PyArg_ParseTuple(args, "KpO", &window_id, &is_drop, &mimes_seq)) return NULL; + 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; 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) { @@ -2078,6 +2079,8 @@ dnd_test_fake_drop_event(PyObject *self UNUSED, PyObject *args) { mimes[i] = PyUnicode_AsUTF8(PySequence_Fast_GET_ITEM(fast_seq, i)); if (!mimes[i]) return NULL; } + 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); Py_RETURN_NONE; } diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index eab06ed11..203d9c950 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -13,6 +13,7 @@ from kitty.fast_data_types import ( dnd_set_test_write_func, dnd_test_cleanup_fake_window, dnd_test_create_fake_window, + dnd_test_fake_drop_event, dnd_test_probe_state, ) @@ -23,7 +24,7 @@ from .dnd import WriteCapture class Capture(WriteCapture): def __call__(self, window_id: int, data: bytes) -> None: - self.pty.write(data) + self.pty.write_to_child(data) class TestDnDKitten(BaseTest): @@ -71,6 +72,7 @@ class TestDnDKitten(BaseTest): if remote_client: cmd.append('--machine-id=remote-client-for-test') self.pty = self.enterContext(PTY(argv=cmd, cwd=self.kitten_wd, rows=25, columns=80, window_id=self.capture.window_id)) + self.capture.pty = self.pty self.pty.callbacks.printbuf = self self.screen = self.pty.screen self.pty.wait_till(lambda: bool(self.pty.callbacks.titlebuf)) @@ -137,20 +139,25 @@ class TestDnDKitten(BaseTest): copy, move = self.get_button_geometry() all_mimes = 'text/uri-list a/b c/d' for b, expected in ((copy, GLFW_DRAG_OPERATION_COPY), (move, GLFW_DRAG_OPERATION_MOVE)): - self.send_dnd_command_to_kitten(all_mimes, t='m', x=str(b[0] + 1), y=str(b[1] + 1)) + dnd_test_fake_drop_event(self.capture.window_id, False, all_mimes.split(), b[0] + 1, b[1] + 1) self.wait_for_state('drop_action', expected) self.assertEqual('text/uri-list', self.probe_state('drop_mimes').rstrip('\x00')) self.send_dnd_command_to_kitten('DROP_MIMES') self.wait_for_responses(all_mimes) - self.send_dnd_command_to_kitten(t='m', x='-1', y='-1') + dnd_test_fake_drop_event(self.capture.window_id, False) self.send_dnd_command_to_kitten('DROP_MIMES') self.wait_for_responses('') - large_mimes = (all_mimes + ' ') * 300 + large_mimes = ((all_mimes + ' ') * 300).rstrip() self.assertGreater(len(large_mimes), 4096) - self.send_dnd_command_to_kitten(all_mimes, t='m', x=str(copy[0] + 1), y=str(copy[1] + 1)) + dnd_test_fake_drop_event(self.capture.window_id, False, large_mimes.split(), copy[0] + 1, copy[1] + 1) self.wait_for_state('drop_action', GLFW_DRAG_OPERATION_COPY) self.send_dnd_command_to_kitten('DROP_MIMES') - self.wait_for_responses(all_mimes) - self.send_dnd_command_to_kitten(t='m', x='-1', y='-1') + self.wait_for_responses(large_mimes) + dnd_test_fake_drop_event(self.capture.window_id, False) self.send_dnd_command_to_kitten('DROP_MIMES') self.wait_for_responses('') + dnd_test_fake_drop_event(self.capture.window_id, False, all_mimes.split(), copy[0] + 1, copy[1] + 1) + self.wait_for_state('drop_action', GLFW_DRAG_OPERATION_COPY) + dnd_test_fake_drop_event(self.capture.window_id, True, all_mimes.split(), copy[0] + 1, copy[1] + 1) + self.send_dnd_command_to_kitten('DROP_MIMES') + self.wait_for_responses(all_mimes)