Rewrite mouse-demo drop to use OnDnDData API; fix QueueDnDData bugs

Agent-Logs-Url: https://github.com/kovidgoyal/kitty/sessions/3359d812-5cb2-44a6-ae6e-ba1d708c490a

Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-16 15:32:49 +00:00
committed by GitHub
parent f8dd59ebb0
commit 2e314ecba9
2 changed files with 47 additions and 148 deletions

View File

@@ -3,93 +3,21 @@
package mouse_demo package mouse_demo
import ( import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"fmt" "fmt"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"github.com/kovidgoyal/kitty"
"github.com/kovidgoyal/kitty/tools/tty" "github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/tui/loop"
"github.com/kovidgoyal/kitty/tools/utils"
"github.com/kovidgoyal/kitty/tools/utils/machine_id"
) )
var _ = fmt.Print var _ = fmt.Print
var debugprintln = tty.DebugPrintln var debugprintln = tty.DebugPrintln
var _ = debugprintln var _ = debugprintln
const dnd_accepted_mimes = "text/plain text/uri-list"
func dnd_escape(metadata, payload string) string {
b := strings.Builder{}
fmt.Fprintf(&b, "\x1b]%d;", kitty.DndCode)
b.WriteString(metadata)
if payload != "" {
b.WriteByte(';')
b.WriteString(payload)
}
b.WriteString("\x1b\\")
return b.String()
}
// get_machine_id returns the machine id in the format expected by the DnD
// protocol ("1:" followed by HMAC-SHA256 of /etc/machine-id).
func get_machine_id() string {
ans, err := machine_id.MachineId()
if err != nil {
return ""
}
mac := hmac.New(sha256.New, []byte("tty-dnd-protocol-machine-id"))
mac.Write(utils.UnsafeStringToBytes(ans))
return "1:" + hex.EncodeToString(mac.Sum(nil))
}
func dnd_start_accepting(machine_id string) string {
result := dnd_escape("t=a", dnd_accepted_mimes)
if machine_id != "" {
result += dnd_escape("t=a:x=1", machine_id)
}
return result
}
func dnd_stop_accepting() string {
return dnd_escape("t=A", "")
}
func dnd_accept_drag(mimes string) string {
return dnd_escape("t=m:o=1", mimes)
}
func dnd_reject_drag() string {
return dnd_escape("t=m:o=0", "")
}
// dnd_request_mime_data requests MIME type data by 1-based index.
func dnd_request_mime_data(idx int) string {
return dnd_escape(fmt.Sprintf("t=r:x=%d", idx), "")
}
// dnd_request_file requests individual file data by MIME index and file subindex.
func dnd_request_file(mime_idx, file_idx int) string {
return dnd_escape(fmt.Sprintf("t=r:x=%d:y=%d", mime_idx, file_idx), "")
}
// dnd_close_dir closes a directory handle by sending t=r:Y=handle.
func dnd_close_dir(handle int) string {
return dnd_escape(fmt.Sprintf("t=r:Y=%d", handle), "")
}
func dnd_finish() string {
return dnd_escape("t=r", "")
}
// file_info holds metadata about a dropped file. // file_info holds metadata about a dropped file.
type file_info struct { type file_info struct {
name string name string
@@ -258,8 +186,6 @@ func Run(args []string) (rc int, err error) {
var current_mouse_event *loop.MouseEvent var current_mouse_event *loop.MouseEvent
var dnd dnd_state var dnd dnd_state
machine_id := get_machine_id()
// build_box_lines computes the drop box content lines based on current state. // build_box_lines computes the drop box content lines based on current state.
build_box_lines := func() []string { build_box_lines := func() []string {
if dnd.drag_over_box { if dnd.drag_over_box {
@@ -428,7 +354,11 @@ func Run(args []string) (rc int, err error) {
// Request this file via the protocol. // Request this file via the protocol.
dnd.file_read_size = 0 dnd.file_read_size = 0
dnd.collecting = "file" dnd.collecting = "file"
lp.QueueWriteString(dnd_request_file(dnd.uri_list_mime_idx, dnd.file_read_idx+1)) lp.QueueDnDData(map[string]string{
"t": "r",
"x": strconv.Itoa(dnd.uri_list_mime_idx),
"y": strconv.Itoa(dnd.file_read_idx + 1),
}, "", false)
return return
} }
// Non-file URI: record as-is with no size info. // Non-file URI: record as-is with no size info.
@@ -437,50 +367,30 @@ func Run(args []string) (rc int, err error) {
} }
// All files processed; finish the drop. // All files processed; finish the drop.
dnd.collecting = "" dnd.collecting = ""
lp.QueueWriteString(dnd_finish()) lp.QueueDnDData(map[string]string{"t": "r"}, "", false)
dnd.has_drop_data = true dnd.has_drop_data = true
draw_screen() draw_screen()
} }
handle_dnd_osc := func(raw []byte) error { handle_dnd_cmd := func(cmd loop.DndCommand) error {
// raw is the OSC payload after ESC ] and before ST. switch cmd.Type {
// Format: DND_CODE;metadata[;payload] case 'm':
prefix := fmt.Sprintf("%d;", kitty.DndCode)
if !bytes.HasPrefix(raw, []byte(prefix)) {
return nil
}
rest := string(raw[len(prefix):])
// Split into metadata and optional payload.
meta, payload, _ := strings.Cut(rest, ";")
// Parse metadata key=value pairs separated by ':'.
meta_map := make(map[string]string)
for kv := range strings.SplitSeq(meta, ":") {
k, v, _ := strings.Cut(kv, "=")
if k != "" {
meta_map[k] = v
}
}
t := meta_map["t"]
switch t {
case "m":
// Drag move event from terminal. // Drag move event from terminal.
// Check if drag has left the window (x=-1, y=-1). // Check if drag has left the window (x=-1, y=-1).
if meta_map["x"] == "-1" || meta_map["y"] == "-1" { if cmd.X == -1 || cmd.Y == -1 {
dnd.reset_drag() dnd.reset_drag()
draw_screen() draw_screen()
return nil return nil
} }
mimes := strings.Fields(payload) mimes := strings.Fields(string(cmd.Payload))
if len(mimes) > 0 { if len(mimes) > 0 {
dnd.drag_mimes = mimes dnd.drag_mimes = mimes
} }
dnd.dragging = true dnd.dragging = true
cx, _ := strconv.Atoi(meta_map["x"]) dnd.drag_cell_x = cmd.X
cy, _ := strconv.Atoi(meta_map["y"]) dnd.drag_cell_y = cmd.Y
dnd.drag_cell_x = cx
dnd.drag_cell_y = cy
over_box := dnd.is_over_drop_box(cx, cy) over_box := dnd.is_over_drop_box(cmd.X, cmd.Y)
dnd.drag_over_box = over_box dnd.drag_over_box = over_box
if over_box { if over_box {
@@ -492,18 +402,18 @@ func Run(args []string) (rc int, err error) {
} }
} }
if len(accepted_mimes) > 0 { if len(accepted_mimes) > 0 {
lp.QueueWriteString(dnd_accept_drag(strings.Join(accepted_mimes, " "))) lp.QueueDnDData(map[string]string{"t": "m", "o": "1"}, strings.Join(accepted_mimes, " "), false)
} }
} else { } else {
// Not over drop region; reject the drag. // Not over drop region; reject the drag.
lp.QueueWriteString(dnd_reject_drag()) lp.QueueDnDData(map[string]string{"t": "m", "o": "0"}, "", false)
} }
draw_screen() draw_screen()
case "M": case 'M':
// Drop event from terminal. // Drop event from terminal.
dnd.reset_drag() dnd.reset_drag()
dnd.reset_drop_data() dnd.reset_drop_data()
mimes := strings.Fields(payload) mimes := strings.Fields(string(cmd.Payload))
dnd.drop_mimes = mimes dnd.drop_mimes = mimes
// Find the MIME indices we need. // Find the MIME indices we need.
@@ -517,35 +427,32 @@ func Run(args []string) (rc int, err error) {
for idx, m := range mimes { for idx, m := range mimes {
if m == "text/plain" { if m == "text/plain" {
dnd.collecting = "text/plain" dnd.collecting = "text/plain"
lp.QueueWriteString(dnd_request_mime_data(idx + 1)) lp.QueueDnDData(map[string]string{"t": "r", "x": strconv.Itoa(idx + 1)}, "", false)
return nil return nil
} }
} }
if dnd.uri_list_mime_idx > 0 { if dnd.uri_list_mime_idx > 0 {
dnd.collecting = "text/uri-list" dnd.collecting = "text/uri-list"
lp.QueueWriteString(dnd_request_mime_data(dnd.uri_list_mime_idx)) lp.QueueDnDData(map[string]string{"t": "r", "x": strconv.Itoa(dnd.uri_list_mime_idx)}, "", false)
return nil return nil
} }
// Nothing to collect; signal done. // Nothing to collect; signal done.
lp.QueueWriteString(dnd_finish()) lp.QueueDnDData(map[string]string{"t": "r"}, "", false)
dnd.has_drop_data = true dnd.has_drop_data = true
draw_screen() draw_screen()
case "r": case 'r':
// Data response from terminal. // Data response from terminal.
resp_y, _ := strconv.Atoi(meta_map["y"]) is_file_response := cmd.Y != 0
resp_X, _ := strconv.Atoi(meta_map["X"])
is_file_response := resp_y != 0
if is_file_response { if is_file_response {
// Response for an individual file request (t=r:x=idx:y=subidx). // Response for an individual file request (t=r:x=idx:y=subidx).
if payload == "" { if !cmd.Has_more && len(cmd.Payload) == 0 {
// End of file data. // End of file data.
fi := file_info{} fi := file_info{}
if resp_X > 1 { if cmd.Xp > 1 {
// Directory: close the handle. // Directory: close the handle.
fi.is_dir = true fi.is_dir = true
lp.QueueWriteString(dnd_close_dir(resp_X)) lp.QueueDnDData(map[string]string{"t": "r", "Y": strconv.Itoa(cmd.Xp)}, "", false)
} else if resp_X == 1 { } else if cmd.Xp == 1 {
fi.is_link = true fi.is_link = true
fi.size = dnd.file_read_size fi.size = dnd.file_read_size
} else { } else {
@@ -556,7 +463,7 @@ func Run(args []string) (rc int, err error) {
draw_screen() draw_screen()
start_next_file_request() start_next_file_request()
} else { } else {
decoded, err := base64.RawStdEncoding.DecodeString(payload) decoded, err := base64.RawStdEncoding.DecodeString(string(cmd.Payload))
if err == nil { if err == nil {
dnd.file_read_size += int64(len(decoded)) dnd.file_read_size += int64(len(decoded))
} }
@@ -565,7 +472,7 @@ func Run(args []string) (rc int, err error) {
} }
// Response for a MIME type data request. // Response for a MIME type data request.
if payload == "" { if !cmd.Has_more && len(cmd.Payload) == 0 {
// End of MIME type data. // End of MIME type data.
switch dnd.collecting { switch dnd.collecting {
case "text/plain": case "text/plain":
@@ -580,14 +487,14 @@ func Run(args []string) (rc int, err error) {
// Now request text/uri-list if available. // Now request text/uri-list if available.
if dnd.uri_list_mime_idx > 0 { if dnd.uri_list_mime_idx > 0 {
dnd.collecting = "text/uri-list" dnd.collecting = "text/uri-list"
lp.QueueWriteString(dnd_request_mime_data(dnd.uri_list_mime_idx)) lp.QueueDnDData(map[string]string{"t": "r", "x": strconv.Itoa(dnd.uri_list_mime_idx)}, "", false)
return nil return nil
} }
case "text/uri-list": case "text/uri-list":
text := dnd.collect_buf.String() text := dnd.collect_buf.String()
dnd.collect_buf.Reset() dnd.collect_buf.Reset()
// Check if terminal indicated remote files (X=1 in URI list response). // Check if terminal indicated remote files (X=1 in URI list response).
if resp_X == 1 { if cmd.Xp == 1 {
dnd.is_remote = true dnd.is_remote = true
} }
// Parse URI list: lines starting with # are comments. // Parse URI list: lines starting with # are comments.
@@ -605,33 +512,32 @@ func Run(args []string) (rc int, err error) {
} }
} }
dnd.collecting = "" dnd.collecting = ""
lp.QueueWriteString(dnd_finish()) lp.QueueDnDData(map[string]string{"t": "r"}, "", false)
dnd.has_drop_data = true dnd.has_drop_data = true
draw_screen() draw_screen()
} else { } else {
decoded, err := base64.RawStdEncoding.DecodeString(payload) decoded, err := base64.RawStdEncoding.DecodeString(string(cmd.Payload))
if err == nil { if err == nil {
dnd.collect_buf.Write(decoded) dnd.collect_buf.Write(decoded)
// Capture X from URI list response (may be in first chunk). // Capture X from URI list response (may be in first chunk).
if dnd.collecting == "text/uri-list" && resp_X != 0 { if dnd.collecting == "text/uri-list" && cmd.Xp != 0 {
dnd.is_remote = resp_X == 1 dnd.is_remote = cmd.Xp == 1
} }
} }
} }
case "R": case 'R':
// Error response from terminal. // Error response from terminal.
resp_y, _ := strconv.Atoi(meta_map["y"]) is_file_response := cmd.Y != 0
is_file_response := resp_y != 0
if is_file_response && dnd.collecting == "file" { if is_file_response && dnd.collecting == "file" {
// Record the error for this file. // Record the error for this file.
dnd.file_infos = append(dnd.file_infos, file_info{err_msg: payload}) dnd.file_infos = append(dnd.file_infos, file_info{err_msg: string(cmd.Payload)})
dnd.file_read_idx++ dnd.file_read_idx++
draw_screen() draw_screen()
start_next_file_request() start_next_file_request()
} else if !is_file_response { } else if !is_file_response {
// Error getting MIME data; finish the drop with what we have. // Error getting MIME data; finish the drop with what we have.
dnd.collecting = "" dnd.collecting = ""
lp.QueueWriteString(dnd_finish()) lp.QueueDnDData(map[string]string{"t": "r"}, "", false)
dnd.has_drop_data = true dnd.has_drop_data = true
draw_screen() draw_screen()
} }
@@ -642,13 +548,14 @@ func Run(args []string) (rc int, err error) {
lp.OnInitialize = func() (string, error) { lp.OnInitialize = func() (string, error) {
lp.SetWindowTitle("kitty mouse features demo") lp.SetWindowTitle("kitty mouse features demo")
lp.SetCursorVisible(false) lp.SetCursorVisible(false)
lp.QueueWriteString(dnd_start_accepting(machine_id)) lp.StartAcceptingDrops("text/plain", "text/uri-list")
draw_screen() draw_screen()
return "", nil return "", nil
} }
lp.OnFinalize = func() string { lp.OnFinalize = func() string {
lp.SetCursorVisible(true) lp.SetCursorVisible(true)
return dnd_stop_accepting() lp.StopAcceptingDrops()
return ""
} }
lp.OnMouseEvent = func(ev *loop.MouseEvent) error { lp.OnMouseEvent = func(ev *loop.MouseEvent) error {
@@ -666,12 +573,7 @@ func Run(args []string) (rc int, err error) {
draw_screen() draw_screen()
return nil return nil
} }
lp.OnEscapeCode = func(etype loop.EscapeCodeType, raw []byte) error { lp.OnDnDData = handle_dnd_cmd
if etype == loop.OSC {
return handle_dnd_osc(raw)
}
return nil
}
err = lp.Run() err = lp.Run()
if err != nil { if err != nil {
rc = 1 rc = 1

View File

@@ -673,13 +673,10 @@ func (self *Loop) QueueDnDData(metadata map[string]string, payload string, as_ba
b := strings.Builder{} b := strings.Builder{}
b.Grow(64) b.Grow(64)
fmt.Fprintf(&b, "\x1b]%d;", kitty.DndCode) fmt.Fprintf(&b, "\x1b]%d;", kitty.DndCode)
is_first := false sep := ""
for key, val := range metadata { for key, val := range metadata {
if !is_first { fmt.Fprintf(&b, "%s%s=%s", sep, key, val)
b.WriteString(":") sep = ":"
}
is_first = false
fmt.Fprintf(&b, "%s=%s", key, val)
} }
payload_sz := len(payload) payload_sz := len(payload)
if payload_sz == 0 { if payload_sz == 0 {
@@ -699,7 +696,7 @@ func (self *Loop) QueueDnDData(metadata map[string]string, payload string, as_ba
is_last := end >= len(payload) is_last := end >= len(payload)
end = min(end, len(payload)) end = min(end, len(payload))
if i == 0 { if i == 0 {
fmt.Fprintf(&b, "m=%d;", utils.IfElse(is_last, 0, 1)) fmt.Fprintf(&b, "%sm=%d;", sep, utils.IfElse(is_last, 0, 1))
self.QueueWriteString(b.String()) self.QueueWriteString(b.String())
} else { } else {
self.QueueWriteString(fmt.Sprintf("\x1b]%d;m=%d;", kitty.DndCode, utils.IfElse(is_last, 0, 1))) self.QueueWriteString(fmt.Sprintf("\x1b]%d;m=%d;", kitty.DndCode, utils.IfElse(is_last, 0, 1)))