Files
kitty/kittens/dnd/main.go
2026-04-20 09:16:53 +05:30

434 lines
12 KiB
Go

// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
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"
"github.com/kovidgoyal/kitty/tools/wcswidth"
)
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
}
type button_region struct {
left, width, top, height int
}
func (r button_region) has(x, y int) bool {
return r.left <= x && x < r.left+r.width && r.top <= y && y < r.top+r.height
}
type DC = loop.DndCommand
func truncate_at_space(text string, width int) (string, string) {
truncated, p := wcswidth.TruncateToVisualLengthWithWidth(text, width)
if len(truncated) == len(text) {
return text, ""
}
i := strings.LastIndexByte(truncated, ' ')
if i > 0 && p-i < 12 {
p = i + 1
}
return text[:p], text[p:]
}
func paragraph_as_lines(text string, width int) (ans []string) {
for text != "" {
var line string
if line, text = truncate_at_space(text, width); line != "" {
ans = append(ans, line)
}
}
return
}
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 := len(drop_dests) > 0, len(drag_sources) > 0
drag_started := false
in_test_mode := false
lp, err := loop.New()
if err != nil {
return err
}
send_test_response := func(payload string) {
lp.DebugPrintln(payload)
}
var drop_status struct {
offered_mimes []string
accepted_mimes []string
cell_x, cell_y int
action int
in_window bool
}
drop_status.cell_x, drop_status.cell_y = -1, -1
const copy_on_drop = 1
const move_on_drop = 2
var copy_button_region, move_button_region button_region
on_drop_move := func(cell_x, cell_y int, 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 != "" {
drop_status.offered_mimes = strings.Fields(offered_mimes)
drop_status.accepted_mimes = make([]string, 0, len(drop_status.offered_mimes))
for _, x := range drop_status.offered_mimes {
if _, found := drop_dests[x]; found {
drop_status.accepted_mimes = append(drop_status.accepted_mimes, x)
}
}
}
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) {
drop_status.action = move_on_drop
} else {
drop_status.action = 0
}
drop_status.in_window = cell_x > -1 && cell_y > -1
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'}
if drop_status.action != 0 && len(drop_status.accepted_mimes) > 0 {
c.Payload = utils.UnsafeStringToBytes(strings.Join(drop_status.accepted_mimes, " "))
}
lp.QueueDnDData(c)
}
needs_rerender = needs_rerender || drop_status.in_window != prev_status.in_window
return
}
render_screen := func() error {
if !in_test_mode {
lp.StartAtomicUpdate()
defer lp.EndAtomicUpdate()
}
lp.ClearScreen()
if drag_started {
lp.Println("Dragging data...")
return nil
}
y := 0
sz, _ := lp.ScreenSize()
render_paragraph := func(text string) {
for _, line := range paragraph_as_lines(text, int(sz.WidthCells)) {
lp.Println(line)
y++
}
}
next_line := func() {
lp.Println()
y++
}
if drop_status.in_window {
if drop_status.action == 0 {
render_paragraph("A drag is active. Drop it into one of the boxes below to perform that action on the dragged data. Available MIME types in the drag:")
next_line()
render_paragraph(strings.Join(drop_status.offered_mimes, " "))
} else {
render_paragraph("The drag can be dropped. Supported MIME types:")
next_line()
render_paragraph(strings.Join(drop_status.accepted_mimes, " "))
}
} else {
// Neither active drag nor drop over window
if allow_drags {
render_paragraph(`Start dragging anywhere in this window to initiate a drag and drop. If you start the drag in one of the Copy or Move boxes below, only that action will be allowed when dropping, otherwise, the drop destination can pick either copy or move.`)
next_line()
}
if allow_drops {
render_paragraph(`Drag some data from another application into this window to transfer the files here.`)
}
}
frame_width, padding_width := 4, 8
text_width := len("copymove")
scale := 4
for scale > 1 && frame_width+padding_width+text_width*scale > int(sz.WidthCells) {
scale--
}
height := scale + 4
boxy := 1 + max(0, int(sz.HeightCells)-height)
lp.MoveCursorTo(1, boxy)
lp.ClearToEndOfScreen()
render_box := func(x int, text string, r *button_region) {
width := scale*wcswidth.Stringwidth(text) + 6
r.left = x - 1
r.top = boxy - 1
r.width = width
r.height = height
lp.MoveCursorTo(x, boxy)
for i := range height {
lp.SaveCursorPosition()
switch i {
case 0:
lp.QueueWriteString("╭")
lp.QueueWriteString(strings.Repeat("─", width-2))
lp.QueueWriteString("╮")
case height - 1:
lp.QueueWriteString("╰")
lp.QueueWriteString(strings.Repeat("─", width-2))
lp.QueueWriteString("╯")
default:
lp.QueueWriteString("│")
if i == 2 {
lp.MoveCursorHorizontally(2)
lp.DrawSizedText(text, loop.SizedText{Scale: scale})
}
lp.MoveCursorTo(x+width-1, boxy+i)
lp.QueueWriteString("│")
}
lp.RestoreCursorPosition()
lp.MoveCursorVertically(1)
}
}
render_box(1, "Copy", &copy_button_region)
box_width := 6 + len("move")*scale
render_box(1+int(sz.WidthCells)-box_width, "Move", &move_button_region)
_ = in_test_mode
return nil
}
lp.OnInitialize = func() (string, error) {
lp.AllowLineWrapping(false)
lp.SetCursorVisible(false)
if allow_drops {
lp.StartAcceptingDrops(opts.MachineId, slices.Collect(maps.Keys(drop_dests))...)
}
if allow_drags {
lp.StartOfferingDrags(opts.MachineId)
}
lp.SetWindowTitle("Drag and drop")
return "", render_screen()
}
lp.OnFinalize = func() string {
lp.AllowLineWrapping(true)
lp.SetCursorVisible(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. Use opts.DragAction to
// set what actions are allowed.
// 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..."
// Be very careful when writing dropped data from uri-list nothing
// should be written outside the destination directory (the current
// working directory by default). In particular, symlinks must be
// handled with care.
// When acting as a drag source, dont forget to implement support for
// remote dragging, which means providing data for the text/uri-list
// mime type file:// entries when the terminal requests it using the
// dnd protocol. If the action chosen is move, delete the files
// corresponding to the drag sources, including the files in the
// uri-list and exit.
switch cmd.Type {
case 'T':
switch string(cmd.Payload) {
case "PING":
send_test_response("PONG")
case "SETUP":
in_test_mode = true
lp.NoRoundtripToTerminalOnExit()
default:
send_test_response("UNKNOWN TEST COMMAND: " + string(cmd.Payload))
}
// Drops
case 'm':
payload := ""
if cmd.Payload != nil {
payload = utils.UnsafeBytesToString(cmd.Payload)
}
if on_drop_move(cmd.X, cmd.Y, payload) {
render_screen()
}
}
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
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, dnd_main)
}