kitty/tools/cmd/mouse_demo/main.go
2026-04-21 11:56:55 +05:30

580 lines
16 KiB
Go

// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package mouse_demo
import (
"fmt"
"net/url"
"path/filepath"
"strconv"
"strings"
"github.com/emmansun/base64"
"github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui/loop"
)
var _ = fmt.Print
var debugprintln = tty.DebugPrintln
var _ = debugprintln
// 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
// 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) {
if width < 4 {
width = 4
}
inner_width := width - 2
// Top border
lp.QueueWriteString("╭" + strings.Repeat("─", inner_width) + "╮\r\n")
for _, line := range lines {
// 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(runes)
lp.QueueWriteString("│" + string(runes) + strings.Repeat(" ", padding) + "│\r\n")
}
// Bottom border
lp.QueueWriteString("╰" + strings.Repeat("─", inner_width) + "╯\r\n")
}
func Run(args []string) (rc int, err error) {
all_pointer_shapes := []loop.PointerShape{
// start all pointer shapes (auto generated by gen-key-constants.py do not edit)
loop.DEFAULT_POINTER,
loop.TEXT_POINTER,
loop.POINTER_POINTER,
loop.HELP_POINTER,
loop.WAIT_POINTER,
loop.PROGRESS_POINTER,
loop.CROSSHAIR_POINTER,
loop.CELL_POINTER,
loop.VERTICAL_TEXT_POINTER,
loop.MOVE_POINTER,
loop.E_RESIZE_POINTER,
loop.NE_RESIZE_POINTER,
loop.NW_RESIZE_POINTER,
loop.N_RESIZE_POINTER,
loop.SE_RESIZE_POINTER,
loop.SW_RESIZE_POINTER,
loop.S_RESIZE_POINTER,
loop.W_RESIZE_POINTER,
loop.EW_RESIZE_POINTER,
loop.NS_RESIZE_POINTER,
loop.NESW_RESIZE_POINTER,
loop.NWSE_RESIZE_POINTER,
loop.ZOOM_IN_POINTER,
loop.ZOOM_OUT_POINTER,
loop.ALIAS_POINTER,
loop.COPY_POINTER,
loop.NOT_ALLOWED_POINTER,
loop.NO_DROP_POINTER,
loop.GRAB_POINTER,
loop.GRABBING_POINTER,
// end all pointer shapes
}
all_pointer_shape_names := make([]string, len(all_pointer_shapes))
col_width := 0
for i, p := range all_pointer_shapes {
all_pointer_shape_names[i] = p.String()
col_width = max(col_width, len(all_pointer_shape_names[i]))
}
col_width += 1
lp, err := loop.New()
if err != nil {
return 1, err
}
lp.MouseTrackingMode(loop.FULL_MOUSE_TRACKING)
var current_mouse_event *loop.MouseEvent
var dnd dnd_state
// 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()
lp.AllowLineWrapping(false)
defer lp.AllowLineWrapping(true)
lp.ClearScreen()
sw := 80
sh := 24
if s, err := lp.ScreenSize(); err == nil {
sw = int(s.WidthCells)
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); {
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++
if current_mouse_event.Buttons != loop.NO_MOUSE_BUTTON {
lp.Println(current_mouse_event.Buttons.String())
y++
}
if mods := current_mouse_event.Mods.String(); mods != "" {
lp.Printf("Modifiers: %s\r\n", mods)
y++
}
lp.Println("Hover the mouse over the names below to see the shapes")
y++
num_cols := max(1, sw/col_width)
pos := 0
colfmt := "%-" + strconv.Itoa(col_width) + "s"
is_on_name := false
var ps loop.PointerShape
for y < sh && pos < len(all_pointer_shapes) {
is_row := y == current_mouse_event.Cell.Y
for c := 0; c < num_cols && pos < len(all_pointer_shapes); c++ {
name := all_pointer_shape_names[pos]
is_hovered := false
if is_row {
start_x := c * col_width
x := current_mouse_event.Cell.X
if x < start_x+len(name) && x >= start_x {
is_on_name = true
is_hovered = true
ps = all_pointer_shapes[pos]
}
}
if is_hovered {
lp.QueueWriteString("\x1b[31m")
}
lp.Printf(colfmt, name)
lp.QueueWriteString("\x1b[m")
pos++
}
y++
lp.Println()
}
lp.PopPointerShape()
if is_on_name {
lp.PushPointerShape(ps)
}
}
// 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)
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, 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.QueueDnDData(loop.DndCommand{Type: 'r', X: dnd.uri_list_mime_idx, Y: 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.QueueDnDData(loop.DndCommand{Type: 'r'})
dnd.has_drop_data = true
draw_screen()
}
handle_dnd_cmd := func(cmd loop.DndCommand) error {
switch cmd.Type {
case 'm':
// Drag move event from terminal.
// Check if drag has left the window (x=-1, y=-1).
if cmd.X == -1 || cmd.Y == -1 {
dnd.reset_drag()
draw_screen()
return nil
}
mimes := strings.Fields(string(cmd.Payload))
if len(mimes) > 0 {
dnd.drag_mimes = mimes
}
dnd.dragging = true
dnd.drag_cell_x = cmd.X
dnd.drag_cell_y = cmd.Y
over_box := dnd.is_over_drop_box(cmd.X, cmd.Y)
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.QueueDnDData(loop.DndCommand{Type: 'm', Operation: 1, Payload: []byte(strings.Join(accepted_mimes, " "))})
}
} else {
// Not over drop region; reject the drag.
lp.QueueDnDData(loop.DndCommand{Type: 'm'})
}
draw_screen()
case 'M':
// Drop event from terminal.
dnd.reset_drag()
dnd.reset_drop_data()
mimes := strings.Fields(string(cmd.Payload))
dnd.drop_mimes = mimes
// 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.QueueDnDData(loop.DndCommand{Type: 'r', X: idx + 1})
return nil
}
}
if dnd.uri_list_mime_idx > 0 {
dnd.collecting = "text/uri-list"
lp.QueueDnDData(loop.DndCommand{Type: 'r', X: dnd.uri_list_mime_idx})
return nil
}
// Nothing to collect; signal done.
lp.QueueDnDData(loop.DndCommand{Type: 'r'})
dnd.has_drop_data = true
draw_screen()
case 'r':
// Data response from terminal.
is_file_response := cmd.Y != 0
if is_file_response {
// Response for an individual file request (t=r:x=idx:y=subidx).
if !cmd.Has_more && len(cmd.Payload) == 0 {
// End of file data.
fi := file_info{}
if cmd.Xp > 1 {
// Directory: close the handle.
fi.is_dir = true
lp.QueueDnDData(loop.DndCommand{Type: 'r', Yp: cmd.Xp})
} else if cmd.Xp == 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(string(cmd.Payload))
if err == nil {
dnd.file_read_size += int64(len(decoded))
}
}
return nil
}
// Response for a MIME type data request.
if !cmd.Has_more && len(cmd.Payload) == 0 {
// End of MIME type data.
switch dnd.collecting {
case "text/plain":
text := dnd.collect_buf.String()
text = strings.TrimRight(text, "\r\n")
if before, _, ok := strings.Cut(text, "\n"); ok {
dnd.plain_text = strings.TrimRight(before, "\r")
} else {
dnd.plain_text = text
}
dnd.collect_buf.Reset()
// Now request text/uri-list if available.
if dnd.uri_list_mime_idx > 0 {
dnd.collecting = "text/uri-list"
lp.QueueDnDData(
loop.DndCommand{Type: 'r', X: dnd.uri_list_mime_idx})
return nil
}
case "text/uri-list":
text := dnd.collect_buf.String()
dnd.collect_buf.Reset()
// Check if terminal indicated remote files (X=1 in URI list response).
if cmd.Xp == 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 = ""
lp.QueueDnDData(loop.DndCommand{Type: 'r'})
dnd.has_drop_data = true
draw_screen()
} else {
decoded, err := base64.RawStdEncoding.DecodeString(string(cmd.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" && cmd.Xp != 0 {
dnd.is_remote = cmd.Xp == 1
}
}
}
case 'R':
// Error response from terminal.
is_file_response := cmd.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: string(cmd.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.QueueDnDData(loop.DndCommand{Type: 'r'})
dnd.has_drop_data = true
draw_screen()
}
}
return nil
}
lp.OnInitialize = func() (string, error) {
lp.SetWindowTitle("kitty mouse features demo")
lp.SetCursorVisible(false)
lp.StartAcceptingDrops("", "text/plain", "text/uri-list")
draw_screen()
return "", nil
}
lp.OnFinalize = func() string {
lp.SetCursorVisible(true)
lp.StopAcceptingDrops()
return ""
}
lp.OnMouseEvent = func(ev *loop.MouseEvent) error {
current_mouse_event = ev
draw_screen()
return nil
}
lp.OnKeyEvent = func(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("ctrl+c") {
lp.Quit(0)
}
return nil
}
lp.OnResize = func(old_size loop.ScreenSize, new_size loop.ScreenSize) error {
draw_screen()
return nil
}
lp.OnDnDData = handle_dnd_cmd
err = lp.Run()
if err != nil {
rc = 1
}
return
}