dd basic drag thumbnail support to dnd kitten

This commit is contained in:
Kovid Goyal 2026-05-03 22:18:18 +05:30
parent ee38d3f0c8
commit 4fe29f2630
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
6 changed files with 108 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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