diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index 59ee03488..f2856d0a0 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -110,19 +110,28 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s const move_on_drop = 2 var copy_button_region, move_button_region button_region + var offered_mimes_buf strings.Builder - on_drop_move := func(cell_x, cell_y int, offered_mimes string) (needs_rerender bool) { + 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 { + 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) { @@ -139,10 +148,13 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s } } 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'} + 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, " ")) } @@ -152,12 +164,13 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s return } - render_screen := func() error { + render_screen := func() error { // {{{ if !in_test_mode { lp.StartAtomicUpdate() defer lp.EndAtomicUpdate() } lp.ClearScreen() + copy_button_region, move_button_region = button_region{}, button_region{} if drag_started { lp.Println("Dragging data...") return nil @@ -196,7 +209,7 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s } frame_width, padding_width := 4, 8 text_width := len("copymove") - scale := 4 + scale := 5 for scale > 1 && frame_width+padding_width+text_width*scale > int(sz.WidthCells) { scale-- } @@ -241,16 +254,17 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s lp.Printf("\x1b[%dm", fg) } render_box(1, "Copy", ©_button_region) - lp.QueueWriteString("\x1b[m") + lp.QueueWriteString("\x1b[39m") box_width := 6 + len("move")*scale if drop_status.action == move_on_drop { lp.Printf("\x1b[%dm", fg) } render_box(1+int(sz.WidthCells)-box_width, "Move", &move_button_region) - lp.QueueWriteString("\x1b[m") + lp.QueueWriteString("\x1b[39m") _ = in_test_mode return nil - } + } // }}} + lp.OnInitialize = func() (string, error) { lp.AllowLineWrapping(false) lp.SetCursorVisible(false) @@ -319,6 +333,14 @@ func run_loop(opts *Options, drop_dests map[string]drop_dest, drag_sources map[s case "SETUP": in_test_mode = true lp.NoRoundtripToTerminalOnExit() + case "GEOMETRY": + send_test_response(fmt.Sprintf("GEOMETRY:%d:%d:%d:%d:%d:%d:%d:%d", copy_button_region.left, copy_button_region.top, copy_button_region.width, copy_button_region.height, move_button_region.left, move_button_region.top, move_button_region.width, move_button_region.height)) + case "DROP_MIMES": + if drop_status.offered_mimes != nil { + send_test_response(strings.Join(drop_status.offered_mimes, " ")) + } else { + send_test_response("") + } default: send_test_response("UNKNOWN TEST COMMAND: " + string(cmd.Payload)) } @@ -328,7 +350,7 @@ 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, payload) { + if on_drop_move(cmd.X, cmd.Y, cmd.Has_more, payload) { render_screen() } } diff --git a/kitty/dnd.c b/kitty/dnd.c index 1f4c813ba..e418d702f 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -2196,6 +2196,13 @@ dnd_test_probe_state(PyObject *self UNUSED, PyObject *args) { if (strcmp(q, "drag_is_remote_client") == 0) { return Py_NewRef(w->drag_source.is_remote_client ? Py_True : Py_False); } + if (strcmp(q, "drop_action") == 0) { + return PyLong_FromLong((long)w->drop.accepted_operation); + } + if (strcmp(q, "drop_mimes") == 0) { + if (w->drop.accepted_mimes == NULL) return PyUnicode_FromString(""); + return PyUnicode_FromStringAndSize(w->drop.accepted_mimes, w->drop.accepted_mimes_sz); + } Py_RETURN_NONE; } diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index 0da2a2b45..eab06ed11 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -1,12 +1,15 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2026, Kovid Goyal +import os import tempfile from base64 import standard_b64encode from kitty.constants import kitten_exe from kitty.fast_data_types import ( DND_CODE, + GLFW_DRAG_OPERATION_COPY, + GLFW_DRAG_OPERATION_MOVE, dnd_set_test_write_func, dnd_test_cleanup_fake_window, dnd_test_create_fake_window, @@ -33,11 +36,16 @@ class TestDnDKitten(BaseTest): capture.os_window_id = os_window_id self.capture = capture self.test_dir = self.enterContext(tempfile.TemporaryDirectory()) + self.kitten_wd = os.path.join(self.test_dir, 'kitten') + os.mkdir(self.kitten_wd) + self.src_data_dir = os.path.join(self.test_dir, 'src') + os.mkdir(self.src_data_dir) self.messages_from_kitten = '' self.set_options({'tab_bar_style': 'hidden'}) - def send_dnd_command_to_kitten(self, payload=b'', as_base64=False, flush=False, **metadata): + def send_dnd_command_to_kitten(self, payload=b'', as_base64=False, flush=False, t='T', **metadata): header = f'\x1b]{DND_CODE};' + metadata['t'] = t for k, v in metadata.items(): header = header + f'{k}={v}:' self.pty.write_to_child(header.encode()) @@ -62,7 +70,7 @@ class TestDnDKitten(BaseTest): cmd = [kitten_exe(), 'dnd'] if remote_client: cmd.append('--machine-id=remote-client-for-test') - self.pty = self.enterContext(PTY(argv=cmd, rows=25, columns=80, window_id=self.capture.window_id)) + self.pty = self.enterContext(PTY(argv=cmd, cwd=self.kitten_wd, rows=25, columns=80, window_id=self.capture.window_id)) self.pty.callbacks.printbuf = self self.screen = self.pty.screen self.pty.wait_till(lambda: bool(self.pty.callbacks.titlebuf)) @@ -70,7 +78,23 @@ class TestDnDKitten(BaseTest): self.assertEqual(remote_client, self.probe_state('drop_is_remote_client')) if self.probe_state('drag_can_offer'): self.assertEqual(remote_client, self.probe_state('drag_is_remote_client')) - self.send_dnd_command_to_kitten('SETUP', t='T') + self.send_dnd_command_to_kitten('SETUP') + + def get_button_geometry(self, are_present: bool = True): + self.send_dnd_command_to_kitten('GEOMETRY') + self.pty.wait_till(lambda: bool(self.messages_from_kitten)) + self.assertTrue(self.messages_from_kitten.startswith('GEOMETRY') and self.messages_from_kitten.endswith('\n'), + f'Unexpected messages from kitten: {self.messages_from_kitten!r}') + q, self.messages_from_kitten = self.messages_from_kitten.rstrip(), '' + parts = tuple(map(int, q.split(':')[1:])) + copy, move = parts[:4], parts[4:] + if are_present: + self.assertGreater(copy[2], 4) + self.assertGreater(move[2], 4) + else: + self.assertEqual(copy, (0,0,0,0)) + self.assertEqual(move, (0,0,0,0)) + return copy, move def append(self, text): self.messages_from_kitten += text @@ -79,12 +103,21 @@ class TestDnDKitten(BaseTest): q = '\n'.join(responses) def wait_till(): return q == self.messages_from_kitten.strip() - self.pty.wait_till(wait_till, timeout, lambda: f'Responses so far: {self.messages_from_kitten!r}') - self.messages_from_kitten = '' + try: + self.pty.wait_till(wait_till, timeout, lambda: f'Responses so far: {self.messages_from_kitten!r}') + finally: + self.messages_from_kitten = '' + + def wait_for_state(self, q, expected, timeout=10): + self.pty.wait_till(lambda: self.probe_state(q) == expected, timeout, lambda: f'{q}: {self.probe_state(q)!r}') def probe_state(self, which: str): return dnd_test_probe_state(self.capture.window_id, which) + def roundtrip(self): + self.send_dnd_command_to_kitten('PING') + self.wait_for_responses('PONG') + def tearDown(self): dnd_set_test_write_func(None) dnd_test_cleanup_fake_window(self.capture.os_window_id) @@ -93,6 +126,31 @@ class TestDnDKitten(BaseTest): self.pty = None def test_dnd_kitten_drop(self): - for remote_client in (False, True): - with self.subTest(remote_client=remote_client): - self.finish_setup(remote_client=remote_client) + self.finish_setup(remote_client=False) + self.dnd_kitten_drop(False) + + def test_dnd_kitten_drop_remote(self): + self.finish_setup(remote_client=True) + self.dnd_kitten_drop(True) + + def dnd_kitten_drop(self, remote_client): + 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)) + 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') + self.send_dnd_command_to_kitten('DROP_MIMES') + self.wait_for_responses('') + large_mimes = (all_mimes + ' ') * 300 + 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)) + 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.send_dnd_command_to_kitten('DROP_MIMES') + self.wait_for_responses('')