From d7fcf30250ad32a0320adc07e22d69bcb6eb0cf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 05:58:10 +0000 Subject: [PATCH] Improve mouse-demo DnD: region-only drops, file sizes, remote file support Agent-Logs-Url: https://github.com/kovidgoyal/kitty/sessions/df38243b-dc36-470c-bb8f-e1e8162bb1ab Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com> --- tools/cmd/mouse_demo/main.go | 429 ++++++++++++++++++++++++++++------- 1 file changed, 353 insertions(+), 76 deletions(-) diff --git a/tools/cmd/mouse_demo/main.go b/tools/cmd/mouse_demo/main.go index d48f34a8d..55b61fa03 100644 --- a/tools/cmd/mouse_demo/main.go +++ b/tools/cmd/mouse_demo/main.go @@ -4,9 +4,14 @@ package mouse_demo import ( "bytes" + "crypto/hmac" + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" - "slices" + "net/url" + "os" + "path/filepath" "strconv" "strings" @@ -33,8 +38,25 @@ func dnd_escape(metadata, payload string) string { return b.String() } -func dnd_start_accepting() string { - return dnd_escape("t=a", dnd_accepted_mimes) +// 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 { + data, err := os.ReadFile("/etc/machine-id") + if err != nil { + return "" + } + data = bytes.TrimRight(data, "\n") + mac := hmac.New(sha256.New, []byte("tty-dnd-protocol-machine-id")) + mac.Write(data) + 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 { @@ -45,37 +67,124 @@ func dnd_accept_drag(mimes string) string { return dnd_escape("t=m:o=1", mimes) } -func dnd_request_data(mime string) string { - return dnd_escape("t=r", mime) +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. +type file_info struct { + name string + size int64 + is_dir bool + is_link bool + err_msg string +} + type dnd_state struct { - dragging bool - drag_mimes []string - drop_mimes []string - collecting string // MIME type currently being collected - collect_buf strings.Builder - plain_text string - uri_list []string + dragging bool + drag_mimes []string + // Current drag cell position (set from t=m events). + drag_cell_x int + drag_cell_y int + drag_over_box bool // true when the drag is currently over the drop region + + // Drop handling. + drop_mimes []string + uri_list_mime_idx int // 1-based index of text/uri-list in drop_mimes (0 = not present) + is_remote bool // X=1 received in URI list response (client is on different machine) + + // Collection state: what we're currently collecting. + // Values: "", "text/plain", "text/uri-list", "file" + collecting string + collect_buf strings.Builder + + // Results of drop. + plain_text string + uri_list []string // parsed file:// URIs from text/uri-list + file_infos []file_info // one entry per uri_list entry has_drop_data bool + + // File reading state. + file_read_idx int // 1-based index of file currently being read (0 = not reading files) + file_read_size int64 // bytes accumulated so far for current file + + // Layout: drop box position on screen (set during draw_screen). + drop_box_start_row int + drop_box_end_row int + drop_box_width int } func (d *dnd_state) reset_drag() { d.dragging = false + d.drag_over_box = false d.drag_mimes = nil } func (d *dnd_state) reset_drop_data() { d.drop_mimes = nil + d.uri_list_mime_idx = 0 + d.is_remote = false d.collecting = "" d.collect_buf.Reset() d.plain_text = "" d.uri_list = nil + d.file_infos = nil d.has_drop_data = false + d.file_read_idx = 0 + d.file_read_size = 0 +} + +// is_over_drop_box returns true if the given cell coordinates are within the +// drop box region as tracked from the last draw_screen call. +func (d *dnd_state) is_over_drop_box(cell_x, cell_y int) bool { + // Before the first draw, drop_box_end_row equals drop_box_start_row (both zero). + if d.drop_box_end_row <= d.drop_box_start_row { + return false + } + return cell_y >= d.drop_box_start_row && cell_y <= d.drop_box_end_row && + cell_x >= 0 && cell_x < d.drop_box_width +} + +// filename_from_uri extracts the base filename from a file:// URI. +func filename_from_uri(uri string) string { + u, err := url.Parse(uri) + if err != nil || u.Scheme != "file" { + return uri + } + return filepath.Base(u.Path) +} + +// format_size formats a byte count as a human-readable size string. +func format_size(n int64) string { + switch { + case n < 1024: + return fmt.Sprintf("%d B", n) + case n < 1024*1024: + return fmt.Sprintf("%.1f KiB", float64(n)/1024) + case n < 1024*1024*1024: + return fmt.Sprintf("%.1f MiB", float64(n)/(1024*1024)) + default: + return fmt.Sprintf("%.1f GiB", float64(n)/(1024*1024*1024)) + } } func draw_rounded_box(lp *loop.Loop, width int, lines []string) { @@ -86,11 +195,13 @@ func draw_rounded_box(lp *loop.Loop, width int, lines []string) { // Top border lp.QueueWriteString("╭" + strings.Repeat("─", inner_width) + "╮\r\n") for _, line := range lines { - if len(line) > inner_width { - line = line[:inner_width] + // Truncate to inner_width runes (not bytes) to handle multi-byte chars safely. + runes := []rune(line) + if len(runes) > inner_width { + runes = runes[:inner_width] } - padding := inner_width - len(line) - lp.QueueWriteString("│" + line + strings.Repeat(" ", padding) + "│\r\n") + padding := inner_width - len(runes) + lp.QueueWriteString("│" + string(runes) + strings.Repeat(" ", padding) + "│\r\n") } // Bottom border lp.QueueWriteString("╰" + strings.Repeat("─", inner_width) + "╯\r\n") @@ -147,6 +258,54 @@ func Run(args []string) (rc int, err error) { var current_mouse_event *loop.MouseEvent 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 := func() []string { + if dnd.drag_over_box { + lines := []string{"Drop here! MIME types:"} + for _, m := range dnd.drag_mimes { + lines = append(lines, " "+m) + } + return lines + } + if dnd.dragging { + return []string{"Drag in window — move over this box to drop"} + } + if dnd.has_drop_data { + lines := []string{} + if dnd.plain_text != "" { + lines = append(lines, "text/plain: "+dnd.plain_text) + } + if len(dnd.file_infos) > 0 { + for i, fi := range dnd.file_infos { + if i >= len(dnd.uri_list) { + break + } + name := filename_from_uri(dnd.uri_list[i]) + if fi.err_msg != "" { + lines = append(lines, name+": error: "+fi.err_msg) + } else if fi.is_dir { + lines = append(lines, name+"/ [directory]") + } else if fi.is_link { + lines = append(lines, name+" [symlink]") + } else { + lines = append(lines, name+" "+format_size(fi.size)) + } + } + } else if len(dnd.uri_list) > 0 { + for _, u := range dnd.uri_list { + lines = append(lines, " "+u) + } + } + if len(lines) == 0 { + lines = []string{"Drop received (no recognized content)"} + } + return lines + } + return []string{"Drop files here"} + } + draw_screen := func() { lp.StartAtomicUpdate() defer lp.EndAtomicUpdate() @@ -161,36 +320,47 @@ func Run(args []string) (rc int, err error) { sh = int(s.HeightCells) } + // y tracks the next row to be printed; used for both content drawing + // and computing the drop box position. + y := 0 + if current_mouse_event == nil { lp.Println(`Move the mouse or click to see mouse events`) + y++ lp.Println("Hover the mouse over the names below to see the shapes") + y++ lp.Println() + y++ + num_cols := max(1, sw/col_width) + colfmt := "%-" + strconv.Itoa(col_width) + "s" for pos := 0; pos < len(all_pointer_shapes); { - num_cols := max(1, sw/col_width) - colfmt := "%-" + strconv.Itoa(col_width) + "s" for c := 0; c < num_cols && pos < len(all_pointer_shapes); c++ { lp.Printf(colfmt, all_pointer_shape_names[pos]) pos++ } lp.Println() + y++ } } else if current_mouse_event.Event_type == loop.MOUSE_LEAVE { lp.Println("Mouse has left the window") + y++ } else { lp.Printf("Position: %d, %d (pixels)\r\n", current_mouse_event.Pixel.X, current_mouse_event.Pixel.Y) + y++ lp.Printf("Cell : %d, %d\r\n", current_mouse_event.Cell.X, current_mouse_event.Cell.Y) + y++ lp.Printf("Type : %s\r\n", current_mouse_event.Event_type) - y := 3 + y++ if current_mouse_event.Buttons != loop.NO_MOUSE_BUTTON { lp.Println(current_mouse_event.Buttons.String()) - y += 1 + y++ } if mods := current_mouse_event.Mods.String(); mods != "" { lp.Printf("Modifiers: %s\r\n", mods) - y += 1 + y++ } lp.Println("Hover the mouse over the names below to see the shapes") - y += 1 + y++ num_cols := max(1, sw/col_width) pos := 0 @@ -218,7 +388,7 @@ func Run(args []string) (rc int, err error) { lp.QueueWriteString("\x1b[m") pos++ } - y += 1 + y++ lp.Println() } lp.PopPointerShape() @@ -227,43 +397,62 @@ func Run(args []string) (rc int, err error) { } } - // Draw the drop area below the pointer shapes list + // Draw the drop area below the pointer shapes list. + // y is now the row where the blank separator will be printed. lp.Println() + y++ // blank separator line + box_width := min(sw, 60) - if dnd.dragging { - lines := []string{"Dragging over window. MIME types:"} - for _, m := range dnd.drag_mimes { - lines = append(lines, " "+m) - } - draw_rounded_box(lp, box_width, lines) - } else if dnd.has_drop_data { - lines := []string{} - if dnd.plain_text != "" { - lines = append(lines, "text/plain: "+dnd.plain_text) - } - for _, u := range dnd.uri_list { - lines = append(lines, "URL: "+u) - } - if len(lines) == 0 { - lines = []string{"Drop received (no recognized content)"} - } - draw_rounded_box(lp, box_width, lines) + dnd.drop_box_width = box_width + box_lines := build_box_lines() + dnd.drop_box_start_row = y + dnd.drop_box_end_row = y + len(box_lines) + 1 // top border + lines + bottom border + + if dnd.drag_over_box { + // Highlight the box in green when drag is over it. + lp.QueueWriteString("\x1b[32m") + draw_rounded_box(lp, box_width, box_lines) + lp.QueueWriteString("\x1b[m") } else { - draw_rounded_box(lp, box_width, []string{"Drop something onto this window"}) + draw_rounded_box(lp, box_width, box_lines) } } + // start_next_file_request sends a request for the next unread file URI, + // or finishes the drop if all files have been read. + var start_next_file_request func() + start_next_file_request = func() { + for dnd.file_read_idx < len(dnd.uri_list) { + uri := dnd.uri_list[dnd.file_read_idx] + if strings.HasPrefix(uri, "file://") { + // Request this file via the protocol. + dnd.file_read_size = 0 + dnd.collecting = "file" + lp.QueueWriteString(dnd_request_file(dnd.uri_list_mime_idx, dnd.file_read_idx+1)) + return + } + // Non-file URI: record as-is with no size info. + dnd.file_infos = append(dnd.file_infos, file_info{name: uri}) + dnd.file_read_idx++ + } + // All files processed; finish the drop. + dnd.collecting = "" + lp.QueueWriteString(dnd_finish()) + dnd.has_drop_data = true + draw_screen() + } + handle_dnd_osc := func(raw []byte) error { - // raw is the OSC payload after ESC ] and before ST - // Format: 72;metadata;payload or 72;metadata + // raw is the OSC payload after ESC ] and before ST. + // Format: DND_CODE;metadata[;payload] 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 + // Split into metadata and optional payload. meta, payload, _ := strings.Cut(rest, ";") - // Parse metadata key=value pairs separated by ':' + // Parse metadata key=value pairs separated by ':'. meta_map := make(map[string]string) for kv := range strings.SplitSeq(meta, ":") { k, v, _ := strings.Cut(kv, "=") @@ -274,8 +463,8 @@ func Run(args []string) (rc int, err error) { t := meta_map["t"] switch t { case "m": - // Drag move event from terminal - // Check if drag has left the window (x=-1, y=-1) + // Drag move event from terminal. + // Check if drag has left the window (x=-1, y=-1). if meta_map["x"] == "-1" || meta_map["y"] == "-1" { dnd.reset_drag() draw_screen() @@ -286,78 +475,166 @@ func Run(args []string) (rc int, err error) { dnd.drag_mimes = mimes } dnd.dragging = true - // Accept the drag with copy operation - accepted_mimes := []string{} - for _, m := range dnd.drag_mimes { - if m == "text/plain" || m == "text/uri-list" { - accepted_mimes = append(accepted_mimes, m) + cx, _ := strconv.Atoi(meta_map["x"]) + cy, _ := strconv.Atoi(meta_map["y"]) + dnd.drag_cell_x = cx + dnd.drag_cell_y = cy + + over_box := dnd.is_over_drop_box(cx, cy) + dnd.drag_over_box = over_box + + if over_box { + // Accept the drag with copy operation for supported MIME types. + accepted_mimes := []string{} + for _, m := range dnd.drag_mimes { + if m == "text/plain" || m == "text/uri-list" { + accepted_mimes = append(accepted_mimes, m) + } } - } - if len(accepted_mimes) > 0 { - lp.QueueWriteString(dnd_accept_drag(strings.Join(accepted_mimes, " "))) + if len(accepted_mimes) > 0 { + lp.QueueWriteString(dnd_accept_drag(strings.Join(accepted_mimes, " "))) + } + } else { + // Not over drop region; reject the drag. + lp.QueueWriteString(dnd_reject_drag()) } draw_screen() case "M": - // Drop event from terminal - dnd.dragging = false + // Drop event from terminal. + dnd.reset_drag() dnd.reset_drop_data() mimes := strings.Fields(payload) dnd.drop_mimes = mimes - // Request data for text/plain first, then text/uri-list - for _, x := range []string{"text/plain", "text/uri-list"} { - if slices.Contains(mimes, x) { - dnd.collecting = x - lp.QueueWriteString(dnd_request_data(x)) + + // Find the MIME indices we need. + for i, m := range mimes { + if m == "text/uri-list" { + dnd.uri_list_mime_idx = i + 1 + } + } + + // Request data: text/plain first, then text/uri-list. + for idx, m := range mimes { + if m == "text/plain" { + dnd.collecting = "text/plain" + lp.QueueWriteString(dnd_request_mime_data(idx + 1)) return nil } } - // Nothing to collect, signal done + if dnd.uri_list_mime_idx > 0 { + dnd.collecting = "text/uri-list" + lp.QueueWriteString(dnd_request_mime_data(dnd.uri_list_mime_idx)) + return nil + } + // Nothing to collect; signal done. lp.QueueWriteString(dnd_finish()) dnd.has_drop_data = true draw_screen() case "r": - // Data response from terminal + // Data response from terminal. + resp_y, _ := strconv.Atoi(meta_map["y"]) + resp_X, _ := strconv.Atoi(meta_map["X"]) + + is_file_response := resp_y != 0 + if is_file_response { + // Response for an individual file request (t=r:x=idx:y=subidx). + if payload == "" { + // End of file data. + fi := file_info{} + if resp_X > 1 { + // Directory: close the handle. + fi.is_dir = true + lp.QueueWriteString(dnd_close_dir(resp_X)) + } else if resp_X == 1 { + fi.is_link = true + fi.size = dnd.file_read_size + } else { + fi.size = dnd.file_read_size + } + dnd.file_infos = append(dnd.file_infos, fi) + dnd.file_read_idx++ + draw_screen() + start_next_file_request() + } else { + decoded, err := base64.RawStdEncoding.DecodeString(payload) + if err == nil { + dnd.file_read_size += int64(len(decoded)) + } + } + return nil + } + + // Response for a MIME type data request. if payload == "" { - // End of data for current MIME type + // End of MIME type data. switch dnd.collecting { case "text/plain": text := dnd.collect_buf.String() - // Get first line + text = strings.TrimRight(text, "\r\n") if before, _, ok := strings.Cut(text, "\n"); ok { - dnd.plain_text = before + dnd.plain_text = strings.TrimRight(before, "\r") } else { dnd.plain_text = text } dnd.collect_buf.Reset() - // Now request text/uri-list if available - if slices.Contains(dnd.drop_mimes, "text/uri-list") { + // Now request text/uri-list if available. + if dnd.uri_list_mime_idx > 0 { dnd.collecting = "text/uri-list" - lp.QueueWriteString(dnd_request_data("text/uri-list")) + lp.QueueWriteString(dnd_request_mime_data(dnd.uri_list_mime_idx)) return nil } case "text/uri-list": text := dnd.collect_buf.String() dnd.collect_buf.Reset() - // Parse URI list: lines starting with # are comments + // Check if terminal indicated remote files (X=1 in URI list response). + if resp_X == 1 { + dnd.is_remote = true + } + // Parse URI list: lines starting with # are comments. for line := range strings.SplitSeq(text, "\n") { line = strings.TrimRight(line, "\r") if line != "" && !strings.HasPrefix(line, "#") { dnd.uri_list = append(dnd.uri_list, line) } } + // Start reading individual files. + if len(dnd.uri_list) > 0 && dnd.uri_list_mime_idx > 0 { + dnd.file_read_idx = 0 + start_next_file_request() + return nil + } } dnd.collecting = "" - // Signal done lp.QueueWriteString(dnd_finish()) dnd.has_drop_data = true draw_screen() } else { - // Decode base64 payload and append to buffer decoded, err := base64.RawStdEncoding.DecodeString(payload) if err == nil { dnd.collect_buf.Write(decoded) + // Capture X from URI list response (may be in first chunk). + if dnd.collecting == "text/uri-list" && resp_X != 0 { + dnd.is_remote = resp_X == 1 + } } } + case "R": + // Error response from terminal. + resp_y, _ := strconv.Atoi(meta_map["y"]) + is_file_response := resp_y != 0 + if is_file_response && dnd.collecting == "file" { + // Record the error for this file. + dnd.file_infos = append(dnd.file_infos, file_info{err_msg: payload}) + dnd.file_read_idx++ + draw_screen() + start_next_file_request() + } else if !is_file_response { + // Error getting MIME data; finish the drop with what we have. + dnd.collecting = "" + lp.QueueWriteString(dnd_finish()) + dnd.has_drop_data = true + draw_screen() + } } return nil } @@ -365,7 +642,7 @@ func Run(args []string) (rc int, err error) { lp.OnInitialize = func() (string, error) { lp.SetWindowTitle("kitty mouse features demo") lp.SetCursorVisible(false) - lp.QueueWriteString(dnd_start_accepting()) + lp.QueueWriteString(dnd_start_accepting(machine_id)) draw_screen() return "", nil }