From 36171d123305c2e269a61a88ef35c52f10eea900 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 19 Apr 2026 10:21:01 +0530 Subject: [PATCH] More work on dnd kitten --- docs/dnd-protocol.rst | 2 +- kittens/dnd/main.go | 229 ++++++++++++++++++++++++++++++++++++++++++ kittens/dnd/main.py | 8 +- tools/tui/loop/api.go | 14 +++ 4 files changed, 249 insertions(+), 4 deletions(-) diff --git a/docs/dnd-protocol.rst b/docs/dnd-protocol.rst index ce52dcfbc..7e53c312c 100644 --- a/docs/dnd-protocol.rst +++ b/docs/dnd-protocol.rst @@ -249,7 +249,7 @@ The ``machine id`` is optional and is used to enable dragging from remote machines. See :ref:`below ` 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 diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index bcc6196d0..38b677338 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -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 } diff --git a/kittens/dnd/main.py b/kittens/dnd/main.py index ba5d337d2..ad7fac127 100644 --- a/kittens/dnd/main.py +++ b/kittens/dnd/main.py @@ -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. ''' diff --git a/tools/tui/loop/api.go b/tools/tui/loop/api.go index 865730c08..d3f7aa502 100644 --- a/tools/tui/loop/api.go +++ b/tools/tui/loop/api.go @@ -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) +}