More work on dnd kitten

This commit is contained in:
Kovid Goyal 2026-04-19 10:21:01 +05:30
parent 8e76a62815
commit 36171d1233
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
4 changed files with 249 additions and 4 deletions

View file

@ -249,7 +249,7 @@ The ``machine id`` is optional and is used to enable dragging from remote
machines. See :ref:`below <machine_id>` for its semantics.
When the user performs the platform specific gesture to start a drag operation,
the terminal will send the same escape code back to the terminal program
the terminal will send the escape code ``t=o`` back to the terminal program
informing it that it can potentially start a drag. The gesture is typically holding the
left mouse button down and dragging a short distance, but this protocol does
not mandate any particular gesture to start drag operations. The terminal, when

View file

@ -3,17 +3,246 @@
package dnd
import (
"bytes"
"fmt"
"io"
"maps"
"net/url"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/kovidgoyal/kitty/tools/cli"
"github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui/loop"
"github.com/kovidgoyal/kitty/tools/utils"
)
var _ = fmt.Append
var debugprintln = tty.DebugPrintln
var _ = debugprintln
type uri_list_item struct {
path, uri, human_name string
file *os.File
}
type drag_source struct {
human_name, path string
file *os.File
mime_type string
uri_list []uri_list_item
data []byte
}
type bufferWriteCloser struct {
*bytes.Buffer
}
// Close implements the io.Closer interface (as a no-op)
func (bwc *bufferWriteCloser) Close() error {
return nil
}
type drop_dest struct {
human_name, path string
dest io.WriteCloser
mime_type 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, drop_accepted := len(drop_dests) > 0, len(drag_sources) > 0, false
drop_copy_allowed, drop_move_allowed, drag_started := false, false, false
lp, err := loop.New()
if err != nil {
return err
}
render_screen := func() error {
lp.StartAtomicUpdate()
defer lp.EndAtomicUpdate()
lp.ClearScreen()
if allow_drags {
if drag_started {
lp.QueueWriteString("Dragging data...")
return nil
}
// TODO: Sow a message to the user saying that they can start
// dragging anywhere in this window to initiate a drag and drop
}
if drop_accepted {
// TODO: If a drop has entered the window and offers MIME types
// present in drop_dests then drop_accepted will be true. In this
// case draw two buttons with triple sized text "Copy" and "Move"
// and a message above them saying drop onto the buttons below.
// Below the buttons if there is space show the list of mime types
// in the drag. Be careful to not accept drops if drag_started is
// true, that is if the drag is coming from self.
// The buttons should only be shown if the drag allows the
// corresponding operation type. The button should consist of the
// triple sized text and a frame with rounded corners around the
// text drawn using unicode box drawing symbols.
_, _ = drop_copy_allowed, drop_move_allowed
}
return nil
}
lp.OnInitialize = func() (string, error) {
lp.AllowLineWrapping(false)
if allow_drops {
lp.StartAcceptingDrops(slices.Collect(maps.Keys(drop_dests))...)
}
if allow_drags {
lp.StartOfferingDrags()
}
return "", render_screen()
}
lp.OnFinalize = func() string {
lp.AllowLineWrapping(true)
if allow_drops {
lp.StopAcceptingDrops()
}
if allow_drags {
lp.StopOfferingDrags()
}
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
// event. Presend data for any drag_source objects that have non nil
// data fields and whose data size is <= 1MB. Set drag_started to true.
// reset drag_started at the end of the drag.
// If a drop enters the window and has one or more MIME types present
// in drop_dests, accept the drop, unless drag_started is true.
// Redraw the screen whenever drag or drop status changes.
// When a drop happens, write all data for the MIME types present in
// both drop_dests and the actual dropped data. For the text/uri-list
// type if the terminal indicates it is coming from a remote machine
// request the data for the file:// entries from the uri-list using the
// dnd protocol and write it, otherwise, copy the file URLs using
// normal file system operations. If opts.ConfirmDropOverwrite is true
// then when some data would overwrite existing file, put it into a
// temp file instead and after all data is transferred as the user for
// confirmation and overwrite or not accordingly. While a drop is in
// progress the render_screen() function should hide the drop
// destination buttons and instead show the text "Drop in progress,
// reading data..."
return nil
}
lp.OnKeyEvent = func(e *loop.KeyEvent) (err error) {
e.Handled = true
if e.MatchesPressOrRepeat("ctrl+c") || e.MatchesPressOrRepeat("esc") {
lp.Quit(0)
return
}
return nil
}
lp.OnResize = func(old_size loop.ScreenSize, new_size loop.ScreenSize) error {
return render_screen()
}
err = lp.Run()
if err != nil {
return
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return
}
return
}
func dnd_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
drop_dests := make(map[string]drop_dest)
if os.Stdout != nil && !tty.IsTerminal(os.Stdout.Fd()) {
drop_dests["text/plain"] = drop_dest{human_name: "STDOUT", dest: os.Stdout, mime_type: "text/plain"}
}
uri_list_buffer := &bytes.Buffer{}
drop_dests["text/uri-list"] = drop_dest{
human_name: "Files", mime_type: "text/uri-list", dest: &bufferWriteCloser{uri_list_buffer}}
for _, spec := range opts.Drop {
mime, dest, _ := strings.Cut(spec, ":")
if dest == "" {
delete(drop_dests, mime)
} else {
path, err := filepath.Abs(dest)
if err != nil {
return 1, err
}
drop_dests[mime] = drop_dest{human_name: dest, path: path, mime_type: mime}
}
}
drag_sources := make(map[string]drag_source)
for _, spec := range opts.Drag {
mime, src, found := strings.Cut(spec, ":")
if !found {
return 1, fmt.Errorf("invalid drag source %s, must be of the form mime-type:path", spec)
}
s := drag_source{human_name: src, mime_type: mime}
if src == "-" || src == "/dev/stdin" {
s.human_name = "STDIN"
s.file = os.Stdin
} else {
path, err := filepath.Abs(src)
if err != nil {
return 1, err
}
s.path = path
}
drag_sources[mime] = s
}
if _, has_plain := drag_sources["text/plain"]; os.Stdin != nil && !has_plain && !tty.IsTerminal(os.Stdin.Fd()) {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return 1, err
}
if len(data) > 0 {
drag_sources["text/plain"] = drag_source{human_name: "STDIN", mime_type: "text/plain", data: data}
}
}
var uri_list []uri_list_item
for _, arg := range args {
st, err := os.Stat(arg)
if err != nil {
return 1, err
}
if st.IsDir() || st.Mode().IsRegular() {
path, err := filepath.Abs(arg)
if err != nil {
return 1, err
}
upath := filepath.ToSlash(path)
if runtime.GOOS == "windows" && !strings.HasPrefix(upath, "/") {
upath = "/" + upath
}
u := &url.URL{Scheme: "file", Path: upath}
uri_list = append(uri_list, uri_list_item{path: path, uri: u.String(), human_name: arg})
} else {
return 1, fmt.Errorf("%s is not a directory or regular file", arg)
}
}
if len(uri_list) > 0 {
uris := make([]string, len(uri_list))
for i, u := range uri_list {
uris[i] = u.uri
}
payload := strings.Join(uris, "\r\n") + "\r\n"
drag_sources["text/uri-list"] = drag_source{
human_name: "Files", mime_type: "text/uri-list", uri_list: uri_list, data: utils.UnsafeStringToBytes(payload),
}
}
err = run_loop(opts, drop_dests, drag_sources, uri_list_buffer)
if err != nil {
return 1, err
}
return 0, nil
}

View file

@ -15,7 +15,9 @@ Can be specified multiple times to drag multiple MIME types.
type=list
When receiving a drop, use the specified file as the data destination for the specified
MIME type. Syntax is: mime-type:path/to/file. For example image/jpeg:mypic.jpg
Can be specified multiple times to enable receiving multiple MIME types.
Can be specified multiple times to enable receiving multiple MIME types. If no path is specified,
it will prevent that MIME type being dropped, useful to disable accepting text/plain and
text/uri-list.
--confirm-drop-overwrite
@ -35,8 +37,8 @@ file manager or similar program to copy the files.
If the text/uri-list MIME type is dropped onto this window, the files and directories in it are
copied into the current working directory.
If data is present on STDIN it is set as text/plain when dragging. Any text/plain data that is
dropped onto this window is output to STDOUT, if STDOUT is connected to a file, otherwise it
If data is present on STDIN it is set as text/plain when dragging, unless text/plain is specified via --drag.
Any text/plain data that is dropped onto this window is output to STDOUT, if STDOUT is connected to a file, otherwise it
is discarded.
'''

View file

@ -720,3 +720,17 @@ func (self *Loop) StartAcceptingDrops(mime_types ...string) {
func (self *Loop) StopAcceptingDrops() {
self.QueueDnDData(map[string]string{"t": "A"}, "", false)
}
func (self *Loop) StartOfferingDrags() {
payload := ""
if ans, err := machine_id.MachineId(); err == nil {
mac := hmac.New(sha256.New, []byte("tty-dnd-protocol-machine-id"))
mac.Write(utils.UnsafeStringToBytes(ans))
payload = "1:" + hex.EncodeToString(mac.Sum(nil))
}
self.QueueDnDData(map[string]string{"t": "o", "x": "1"}, payload, false)
}
func (self *Loop) StopOfferingDrags() {
self.QueueDnDData(map[string]string{"t": "o", "x": "2"}, "", false)
}