From 4fe29f263093e36ad56f558dc2edca0ee8f14ae0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 3 May 2026 22:18:18 +0530 Subject: [PATCH] dd basic drag thumbnail support to dnd kitten --- kittens/dnd/drag.go | 74 ++++++++++++++++++++++++++++++++++++++- kittens/dnd/main.go | 9 +++++ kittens/dnd/main.py | 13 +++++++ kitty/dnd.c | 6 ++++ kitty_tests/dnd_kitten.py | 7 +++- kitty_tests/graphics.py | 3 +- 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/kittens/dnd/drag.go b/kittens/dnd/drag.go index ec90245db..e9d8cecc7 100644 --- a/kittens/dnd/drag.go +++ b/kittens/dnd/drag.go @@ -3,6 +3,7 @@ package dnd import ( "errors" "fmt" + "image" "io" "maps" "os" @@ -11,6 +12,7 @@ import ( "strings" "github.com/emmansun/base64" + "github.com/kovidgoyal/imaging" "github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/streaming_base64" @@ -50,6 +52,72 @@ type drag_status struct { remote_item_write_id loop.IdType } +func find_drag_image(drag_sources map[string]*drag_source) image.Image { + for mime, ds := range drag_sources { + if strings.HasPrefix(mime, "image/") && ds.path != "" { + q, err := imaging.Open(ds.path) + if err == nil { + return q + } + } + } + var uri_list []string + if ds := drag_sources["text/uri-list"]; ds != nil && len(ds.data) > 0 { + if q, err := parse_uri_list(string(ds.data)); err == nil { + for _, path := range q { + if path != "" { + uri_list = append(uri_list, path) + } + } + } + } + for _, path := range uri_list { + q, err := imaging.Open(path) + if err == nil { + return q + } + } + // TODO: Try to generate an image based preview using the machinery from the choose-files kitten + return nil +} + +func (dnd *dnd) set_drag_image() (err error) { + img := dnd.drag_thumbnail + if img == nil { + img = find_drag_image(dnd.drag_sources) + } + if img == nil { + return + } + num_channels := utils.IfElse(imaging.IsOpaque(img), 3, 4) + sz := dnd.opts.DragThumbnailSize + if max(img.Bounds().Dx(), img.Bounds().Dy()) > sz { + w, h := 0, 0 + if img.Bounds().Dx() >= img.Bounds().Dy() { + w = sz + } else { + h = sz + } + img = imaging.ResizeWithOpacity(img, w, h, imaging.Lanczos, num_channels == 3) + if dnd.drag_thumbnail != nil { + dnd.drag_thumbnail = img + } + } + var pix []byte + if imaging.IsOpaque(img) { + _, pix = 3, imaging.AsRGBData8(img) + } else { + pix = imaging.AsRGBAData8(img) + } + cmd := DC{ + Type: 'p', X: -1, Y: utils.IfElse(num_channels == 3, 24, 32), Xp: img.Bounds().Dx(), Yp: img.Bounds().Dy(), + Payload: pix} + dnd.lp.QueueDnDData(cmd) + cmd.Payload = nil + dnd.lp.QueueDnDData(cmd) + return nil +} + func (dnd *dnd) on_potential_drag_start(cell_x, cell_y int) (err error) { if !dnd.allow_drags || dnd.drag_status.active { return @@ -72,7 +140,11 @@ func (dnd *dnd) on_potential_drag_start(cell_x, cell_y int) (err error) { } } dnd.drag_status.offered_mimes = mimes - // TODO: set the drag image + err = dnd.set_drag_image() + if err != nil { + dnd.finish_drag("EIO") + return err + } dnd.lp.QueueDnDData(DC{Type: 'P', X: -1}) // start drag dnd.drag_status.active = true diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index 7eb2ea27e..3e42a3630 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -5,6 +5,7 @@ package dnd import ( "bytes" "fmt" + "image" "io" "maps" "net/url" @@ -15,6 +16,7 @@ import ( "strings" "sync/atomic" + "github.com/kovidgoyal/imaging" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/tty" "github.com/kovidgoyal/kitty/tools/tui/loop" @@ -78,6 +80,7 @@ type dnd struct { opts *Options drop_dests map[string]*drop_dest drag_sources map[string]*drag_source + drag_thumbnail image.Image allow_drops, allow_drags bool lp *loop.Loop @@ -382,6 +385,12 @@ func dnd_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error } } dnd := dnd{opts: opts, drop_dests: drop_dests, drag_sources: drag_sources} + if opts.DragThumbnail != "" { + if dnd.drag_thumbnail, err = imaging.Open(opts.DragThumbnail); err != nil { + return 1, err + } + } + defer func() { dnd.end_drop(false) if dnd.confirm_drop.staging_dir != nil { diff --git a/kittens/dnd/main.py b/kittens/dnd/main.py index 3e675f53a..fcb8dd0a3 100644 --- a/kittens/dnd/main.py +++ b/kittens/dnd/main.py @@ -33,6 +33,19 @@ default=disallowed type=choices Allow dropping anywhere, not just on the Copy or Move drop regions. Dropping anywhere will perform the specified action. + + +--drag-thumbnail +Path to an image to use as the drag icon when starting a drag. Must be in PNG/JPEG/GIF/WEBP formats. +Other formats will require the presence of ImageMagick on the system to load the image. +If not specified, an image is derived based on the data being dragged. + + +--drag-thumbnail-size +default=512 +type=int +The thumbnail size for the image used as the drag icon. Images larger than this size are downscaled. +Note that the terminal may reject the drag if the image is too large. '''.format diff --git a/kitty/dnd.c b/kitty/dnd.c index eb6dab52e..3dd3825af 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1405,6 +1405,8 @@ expand_png_data(Window *w, size_t idx) { } #undef fail +static size_t last_total_image_size = 0; + void drag_start(Window *w) { if (ds.state != DRAG_SOURCE_BEING_BUILT) abrt(EINVAL); @@ -1424,6 +1426,7 @@ drag_start(Window *w) { if (img.sz != (size_t)img.width * (size_t)img.height * 4u) abrt(EINVAL); } } + last_total_image_size = total_size; int err = start_window_drag(w, dnd_is_test_mode()); if (err != 0) { abrt(err); @@ -2261,6 +2264,9 @@ dnd_test_probe_state(PyObject *self UNUSED, PyObject *args) { ans, i, PyUnicode_FromString(w->drag_source.items[i].mime_type)); return ans; } + if (strcmp(q, "drag_thumbnail_size") == 0) { + return PyLong_FromSize_t(last_total_image_size); + } Py_RETURN_NONE; } diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index 570c66052..8bb003532 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -328,6 +328,10 @@ class TestDnDKitten(BaseTest): self.assertEqual(fa.read(), fb.read(), f'{a} ({os.path.getsize(a)}) != {b} ({os.path.getsize(b)})') def test_dnd_kitten_drag(self): + from .graphics import png_data + drag_thumbnail = os.path.join(self.test_dir, 'drag.png') + with open(drag_thumbnail, 'wb') as f: + f.write(png_data) img_drag_path = 'image.png' def create_files(): with open(os.path.join(self.kitten_wd, img_drag_path), 'wb') as f: @@ -336,7 +340,7 @@ class TestDnDKitten(BaseTest): create_fs(self.src_data_dir) create_files() tl = tuple(os.path.join(self.src_data_dir, x) for x in os.listdir(self.src_data_dir)) - self.finish_setup(cli_args=(f'--drag=image/png:{img_drag_path}', ) + tl) # ))) + self.finish_setup(cli_args=(f'--drag-thumbnail={drag_thumbnail}', f'--drag=image/png:{img_drag_path}') + tl) # ))) with self.subTest(remote_client=False): self.dnd_kitten_drag(False, img_drag_path) self.reset_kitten(True) @@ -378,6 +382,7 @@ class TestDnDKitten(BaseTest): dnd_test_start_drag_offer(self.capture.window_id, x, y) wait_for_drag_active() self.wait_for_state('drag_operations', expected) + self.wait_for_state('drag_thumbnail_size', 4) def end_drag(canceled=True): dnd_test_drag_finish(self.capture.window_id, canceled) wait_for_drag_active(False) diff --git a/kitty_tests/graphics.py b/kitty_tests/graphics.py index aebd00328..eeaafd23b 100644 --- a/kitty_tests/graphics.py +++ b/kitty_tests/graphics.py @@ -19,7 +19,7 @@ try: from PIL import Image except ImportError: Image = None - +png_data = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==') def send_command(screen, cmd, payload=b''): cmd = '\033_G' + cmd @@ -505,7 +505,6 @@ class TestGraphics(BaseTest): def test_load_png_simple(self): # 1x1 transparent PNG - png_data = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==') expected = b'\x00\xff\xff\x7f' self.ae(load_png_data(png_data), (expected, 1, 1)) s, g, pl, sl = load_helpers(self)