diff options
| author | adamdottv <[email protected]> | 2025-07-09 04:55:19 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-07-09 04:55:24 -0500 |
| commit | 3f25e5bf869d70a06afab64ca7812930f06e4ed5 (patch) | |
| tree | 0a2aaf39ec4bfb945d62ab3becd1918f1f4a9dca /packages/tui/internal | |
| parent | 67765fa47c54c0d0b8146fb124c0d412e09bf5e8 (diff) | |
| download | opencode-3f25e5bf869d70a06afab64ca7812930f06e4ed5.tar.gz opencode-3f25e5bf869d70a06afab64ca7812930f06e4ed5.zip | |
chore: internal clipboard package
Diffstat (limited to 'packages/tui/internal')
| -rw-r--r-- | packages/tui/internal/app/app.go | 2 | ||||
| -rw-r--r-- | packages/tui/internal/clipboard/clipboard.go | 155 | ||||
| -rw-r--r-- | packages/tui/internal/clipboard/clipboard_darwin.go | 266 | ||||
| -rw-r--r-- | packages/tui/internal/clipboard/clipboard_linux.go | 276 | ||||
| -rw-r--r-- | packages/tui/internal/clipboard/clipboard_nocgo.go | 25 | ||||
| -rw-r--r-- | packages/tui/internal/clipboard/clipboard_windows.go | 551 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 2 |
7 files changed, 1275 insertions, 2 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 83934343e..a70b3ca64 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -12,13 +12,13 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode/internal/clipboard" "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/toast" "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" - "golang.design/x/clipboard" ) type App struct { diff --git a/packages/tui/internal/clipboard/clipboard.go b/packages/tui/internal/clipboard/clipboard.go new file mode 100644 index 000000000..70e05bd29 --- /dev/null +++ b/packages/tui/internal/clipboard/clipboard.go @@ -0,0 +1,155 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou <changkun.de> + +/* +Package clipboard provides cross platform clipboard access and supports +macOS/Linux/Windows/Android/iOS platform. Before interacting with the +clipboard, one must call Init to assert if it is possible to use this +package: + + err := clipboard.Init() + if err != nil { + panic(err) + } + +The most common operations are `Read` and `Write`. To use them: + + // write/read text format data of the clipboard, and + // the byte buffer regarding the text are UTF8 encoded. + clipboard.Write(clipboard.FmtText, []byte("text data")) + clipboard.Read(clipboard.FmtText) + + // write/read image format data of the clipboard, and + // the byte buffer regarding the image are PNG encoded. + clipboard.Write(clipboard.FmtImage, []byte("image data")) + clipboard.Read(clipboard.FmtImage) + +Note that read/write regarding image format assumes that the bytes are +PNG encoded since it serves the alpha blending purpose that might be +used in other graphical software. + +In addition, `clipboard.Write` returns a channel that can receive an +empty struct as a signal, which indicates the corresponding write call +to the clipboard is outdated, meaning the clipboard has been overwritten +by others and the previously written data is lost. For instance: + + changed := clipboard.Write(clipboard.FmtText, []byte("text data")) + + select { + case <-changed: + println(`"text data" is no longer available from clipboard.`) + } + +You can ignore the returning channel if you don't need this type of +notification. Furthermore, when you need more than just knowing whether +clipboard data is changed, use the watcher API: + + ch := clipboard.Watch(context.TODO(), clipboard.FmtText) + for data := range ch { + // print out clipboard data whenever it is changed + println(string(data)) + } +*/ +package clipboard + +import ( + "context" + "errors" + "fmt" + "os" + "sync" +) + +var ( + // activate only for running tests. + debug = false + errUnavailable = errors.New("clipboard unavailable") + errUnsupported = errors.New("unsupported format") + errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0") +) + +// Format represents the format of clipboard data. +type Format int + +// All sorts of supported clipboard data +const ( + // FmtText indicates plain text clipboard format + FmtText Format = iota + // FmtImage indicates image/png clipboard format + FmtImage +) + +var ( + // Due to the limitation on operating systems (such as darwin), + // concurrent read can even cause panic, use a global lock to + // guarantee one read at a time. + lock = sync.Mutex{} + initOnce sync.Once + initError error +) + +// Init initializes the clipboard package. It returns an error +// if the clipboard is not available to use. This may happen if the +// target system lacks required dependency, such as libx11-dev in X11 +// environment. For example, +// +// err := clipboard.Init() +// if err != nil { +// panic(err) +// } +// +// If Init returns an error, any subsequent Read/Write/Watch call +// may result in an unrecoverable panic. +func Init() error { + initOnce.Do(func() { + initError = initialize() + }) + return initError +} + +// Read returns a chunk of bytes of the clipboard data if it presents +// in the desired format t presents. Otherwise, it returns nil. +func Read(t Format) []byte { + lock.Lock() + defer lock.Unlock() + + buf, err := read(t) + if err != nil { + if debug { + fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err) + } + return nil + } + return buf +} + +// Write writes a given buffer to the clipboard in a specified format. +// Write returned a receive-only channel can receive an empty struct +// as a signal, which indicates the clipboard has been overwritten from +// this write. +// If format t indicates an image, then the given buf assumes +// the image data is PNG encoded. +func Write(t Format, buf []byte) <-chan struct{} { + lock.Lock() + defer lock.Unlock() + + changed, err := write(t, buf) + if err != nil { + if debug { + fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err) + } + return nil + } + return changed +} + +// Watch returns a receive-only channel that received the clipboard data +// whenever any change of clipboard data in the desired format happens. +// +// The returned channel will be closed if the given context is canceled. +func Watch(ctx context.Context, t Format) <-chan []byte { + return watch(ctx, t) +} diff --git a/packages/tui/internal/clipboard/clipboard_darwin.go b/packages/tui/internal/clipboard/clipboard_darwin.go new file mode 100644 index 000000000..ead6811f1 --- /dev/null +++ b/packages/tui/internal/clipboard/clipboard_darwin.go @@ -0,0 +1,266 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou <changkun.de> + +//go:build darwin + +package clipboard + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +var ( + lastChangeCount int64 + changeCountMu sync.Mutex +) + +func initialize() error { return nil } + +func read(t Format) (buf []byte, err error) { + switch t { + case FmtText: + return readText() + case FmtImage: + return readImage() + default: + return nil, errUnsupported + } +} + +func readText() ([]byte, error) { + // Check if clipboard contains string data + checkScript := ` + try + set clipboardTypes to (clipboard info) + repeat with aType in clipboardTypes + if (first item of aType) is string then + return "hastext" + end if + end repeat + return "notext" + on error + return "error" + end try + ` + + cmd := exec.Command("osascript", "-e", checkScript) + checkOut, err := cmd.Output() + if err != nil { + return nil, errUnavailable + } + + checkOut = bytes.TrimSpace(checkOut) + if !bytes.Equal(checkOut, []byte("hastext")) { + return nil, errUnavailable + } + + // Now get the actual text + cmd = exec.Command("osascript", "-e", "get the clipboard") + out, err := cmd.Output() + if err != nil { + return nil, errUnavailable + } + // Remove trailing newline that osascript adds + out = bytes.TrimSuffix(out, []byte("\n")) + + // If clipboard was set to empty string, return nil + if len(out) == 0 { + return nil, nil + } + return out, nil +} +func readImage() ([]byte, error) { + // AppleScript to read image data from clipboard as base64 + script := ` + try + set theData to the clipboard as «class PNGf» + return theData + on error + return "" + end try + ` + + cmd := exec.Command("osascript", "-e", script) + out, err := cmd.Output() + if err != nil { + return nil, errUnavailable + } + + // Check if we got any data + out = bytes.TrimSpace(out) + if len(out) == 0 { + return nil, errUnavailable + } + + // The output is in hex format (e.g., «data PNGf89504E...») + // We need to extract and convert it + outStr := string(out) + if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") { + return nil, errUnavailable + } + + // Extract hex data + hexData := strings.TrimPrefix(outStr, "«data PNGf") + hexData = strings.TrimSuffix(hexData, "»") + + // Convert hex to bytes + buf := make([]byte, len(hexData)/2) + for i := 0; i < len(hexData); i += 2 { + b, err := strconv.ParseUint(hexData[i:i+2], 16, 8) + if err != nil { + return nil, errUnavailable + } + buf[i/2] = byte(b) + } + + return buf, nil +} + +// write writes the given data to clipboard and +// returns true if success or false if failed. +func write(t Format, buf []byte) (<-chan struct{}, error) { + var err error + switch t { + case FmtText: + err = writeText(buf) + case FmtImage: + err = writeImage(buf) + default: + return nil, errUnsupported + } + + if err != nil { + return nil, err + } + + // Update change count + changeCountMu.Lock() + lastChangeCount++ + currentCount := lastChangeCount + changeCountMu.Unlock() + + // use unbuffered channel to prevent goroutine leak + changed := make(chan struct{}, 1) + go func() { + for { + time.Sleep(time.Second) + changeCountMu.Lock() + if lastChangeCount != currentCount { + changeCountMu.Unlock() + changed <- struct{}{} + close(changed) + return + } + changeCountMu.Unlock() + } + }() + return changed, nil +} + +func writeText(buf []byte) error { + if len(buf) == 0 { + // Clear clipboard + script := `set the clipboard to ""` + cmd := exec.Command("osascript", "-e", script) + if err := cmd.Run(); err != nil { + return errUnavailable + } + return nil + } + + // Escape the text for AppleScript + text := string(buf) + text = strings.ReplaceAll(text, "\\", "\\\\") + text = strings.ReplaceAll(text, "\"", "\\\"") + + script := fmt.Sprintf(`set the clipboard to "%s"`, text) + cmd := exec.Command("osascript", "-e", script) + if err := cmd.Run(); err != nil { + return errUnavailable + } + return nil +} +func writeImage(buf []byte) error { + if len(buf) == 0 { + // Clear clipboard + script := `set the clipboard to ""` + cmd := exec.Command("osascript", "-e", script) + if err := cmd.Run(); err != nil { + return errUnavailable + } + return nil + } + + // Create a temporary file to store the PNG data + tmpFile, err := os.CreateTemp("", "clipboard*.png") + if err != nil { + return errUnavailable + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(buf); err != nil { + tmpFile.Close() + return errUnavailable + } + tmpFile.Close() + + // Use osascript to set clipboard to the image file + script := fmt.Sprintf(` + set theFile to POSIX file "%s" + set theImage to read theFile as «class PNGf» + set the clipboard to theImage + `, tmpFile.Name()) + + cmd := exec.Command("osascript", "-e", script) + if err := cmd.Run(); err != nil { + return errUnavailable + } + return nil +} +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + ti := time.NewTicker(time.Second) + + // Get initial clipboard content + var lastContent []byte + if b := Read(t); b != nil { + lastContent = make([]byte, len(b)) + copy(lastContent, b) + } + + go func() { + defer close(recv) + defer ti.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ti.C: + b := Read(t) + if b == nil { + continue + } + + // Check if content changed + if !bytes.Equal(lastContent, b) { + recv <- b + lastContent = make([]byte, len(b)) + copy(lastContent, b) + } + } + } + }() + return recv +} diff --git a/packages/tui/internal/clipboard/clipboard_linux.go b/packages/tui/internal/clipboard/clipboard_linux.go new file mode 100644 index 000000000..ca8a3bc6a --- /dev/null +++ b/packages/tui/internal/clipboard/clipboard_linux.go @@ -0,0 +1,276 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou <changkun.de> + +//go:build linux + +package clipboard + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "sync" + "time" +) + +var ( + // Clipboard tools in order of preference + clipboardTools = []struct { + name string + readCmd []string + writeCmd []string + readImg []string + writeImg []string + available bool + }{ + { + name: "xclip", + readCmd: []string{"xclip", "-selection", "clipboard", "-o"}, + writeCmd: []string{"xclip", "-selection", "clipboard"}, + readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"}, + writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"}, + }, + { + name: "xsel", + readCmd: []string{"xsel", "--clipboard", "--output"}, + writeCmd: []string{"xsel", "--clipboard", "--input"}, + readImg: []string{"xsel", "--clipboard", "--output"}, + writeImg: []string{"xsel", "--clipboard", "--input"}, + }, + { + name: "wl-clipboard", + readCmd: []string{"wl-paste", "-n"}, + writeCmd: []string{"wl-copy"}, + readImg: []string{"wl-paste", "-t", "image/png", "-n"}, + writeImg: []string{"wl-copy", "-t", "image/png"}, + }, + } + + selectedTool int = -1 + toolMutex sync.Mutex + lastChangeTime time.Time + changeTimeMu sync.Mutex +) + +func initialize() error { + toolMutex.Lock() + defer toolMutex.Unlock() + + if selectedTool >= 0 { + return nil // Already initialized + } + + // Check which clipboard tool is available + for i, tool := range clipboardTools { + cmd := exec.Command("which", tool.name) + if err := cmd.Run(); err == nil { + clipboardTools[i].available = true + if selectedTool < 0 { + selectedTool = i + } + } + } + + if selectedTool < 0 { + return fmt.Errorf(`%w: No clipboard utility found. Install one of the following: + +For X11 systems: + apt install -y xclip + # or + apt install -y xsel + +For Wayland systems: + apt install -y wl-clipboard + +If running in a headless environment, you may also need: + apt install -y xvfb + # and run: + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + export DISPLAY=:99.0`, errUnavailable) + } + + return nil +} + +func read(t Format) (buf []byte, err error) { + toolMutex.Lock() + tool := clipboardTools[selectedTool] + toolMutex.Unlock() + + switch t { + case FmtText: + return readText(tool) + case FmtImage: + return readImage(tool) + default: + return nil, errUnsupported + } +} + +func readText(tool struct { + name string + readCmd []string + writeCmd []string + readImg []string + writeImg []string + available bool +}) ([]byte, error) { + // First check if clipboard contains text + cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...) + out, err := cmd.Output() + if err != nil { + // Check if it's because clipboard contains non-text data + if tool.name == "xclip" { + // xclip returns error when clipboard doesn't contain requested type + checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o") + targets, _ := checkCmd.Output() + if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) { + return nil, errUnavailable + } + } + return nil, errUnavailable + } + + return out, nil +} + +func readImage(tool struct { + name string + readCmd []string + writeCmd []string + readImg []string + writeImg []string + available bool +}) ([]byte, error) { + if tool.name == "xsel" { + // xsel doesn't support image types well, return error + return nil, errUnavailable + } + + cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...) + out, err := cmd.Output() + if err != nil { + return nil, errUnavailable + } + + // Verify it's PNG data + if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) { + return nil, errUnavailable + } + + return out, nil +} + +func write(t Format, buf []byte) (<-chan struct{}, error) { + toolMutex.Lock() + tool := clipboardTools[selectedTool] + toolMutex.Unlock() + + var cmd *exec.Cmd + switch t { + case FmtText: + if len(buf) == 0 { + // Write empty string + cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...) + cmd.Stdin = bytes.NewReader([]byte{}) + } else { + cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...) + cmd.Stdin = bytes.NewReader(buf) + } + case FmtImage: + if tool.name == "xsel" { + // xsel doesn't support image types well + return nil, errUnavailable + } + if len(buf) == 0 { + // Clear clipboard + cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...) + cmd.Stdin = bytes.NewReader([]byte{}) + } else { + cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...) + cmd.Stdin = bytes.NewReader(buf) + } + default: + return nil, errUnsupported + } + + if err := cmd.Run(); err != nil { + return nil, errUnavailable + } + + // Update change time + changeTimeMu.Lock() + lastChangeTime = time.Now() + currentTime := lastChangeTime + changeTimeMu.Unlock() + + // Create change notification channel + changed := make(chan struct{}, 1) + go func() { + for { + time.Sleep(time.Second) + changeTimeMu.Lock() + if !lastChangeTime.Equal(currentTime) { + changeTimeMu.Unlock() + changed <- struct{}{} + close(changed) + return + } + changeTimeMu.Unlock() + } + }() + + return changed, nil +} + +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + ti := time.NewTicker(time.Second) + + // Get initial clipboard content + var lastContent []byte + if b := Read(t); b != nil { + lastContent = make([]byte, len(b)) + copy(lastContent, b) + } + + go func() { + defer close(recv) + defer ti.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ti.C: + b := Read(t) + if b == nil { + continue + } + + // Check if content changed + if !bytes.Equal(lastContent, b) { + recv <- b + lastContent = make([]byte, len(b)) + copy(lastContent, b) + } + } + } + }() + return recv +} + +// Helper function to check clipboard content type for xclip +func getClipboardTargets() []string { + cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o") + out, err := cmd.Output() + if err != nil { + return nil + } + return strings.Split(string(out), "\n") +} diff --git a/packages/tui/internal/clipboard/clipboard_nocgo.go b/packages/tui/internal/clipboard/clipboard_nocgo.go new file mode 100644 index 000000000..7b3e05f6c --- /dev/null +++ b/packages/tui/internal/clipboard/clipboard_nocgo.go @@ -0,0 +1,25 @@ +//go:build !windows && !darwin && !linux && !cgo + +package clipboard + +import "context" + +func initialize() error { + return errNoCgo +} + +func read(t Format) (buf []byte, err error) { + panic("clipboard: cannot use when CGO_ENABLED=0") +} + +func readc(t string) ([]byte, error) { + panic("clipboard: cannot use when CGO_ENABLED=0") +} + +func write(t Format, buf []byte) (<-chan struct{}, error) { + panic("clipboard: cannot use when CGO_ENABLED=0") +} + +func watch(ctx context.Context, t Format) <-chan []byte { + panic("clipboard: cannot use when CGO_ENABLED=0") +} diff --git a/packages/tui/internal/clipboard/clipboard_windows.go b/packages/tui/internal/clipboard/clipboard_windows.go new file mode 100644 index 000000000..bd042cda8 --- /dev/null +++ b/packages/tui/internal/clipboard/clipboard_windows.go @@ -0,0 +1,551 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou <changkun.de> + +//go:build windows + +package clipboard + +// Interacting with Clipboard on Windows: +// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "image" + "image/color" + "image/png" + "reflect" + "runtime" + "syscall" + "time" + "unicode/utf16" + "unsafe" + + "golang.org/x/image/bmp" +) + +func initialize() error { return nil } + +// readText reads the clipboard and returns the text data if presents. +// The caller is responsible for opening/closing the clipboard before +// calling this function. +func readText() (buf []byte, err error) { + hMem, _, err := getClipboardData.Call(cFmtUnicodeText) + if hMem == 0 { + return nil, err + } + p, _, err := gLock.Call(hMem) + if p == 0 { + return nil, err + } + defer gUnlock.Call(hMem) + + // Find NUL terminator + n := 0 + for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ { + ptr = unsafe.Pointer(uintptr(ptr) + + unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p))))) + } + + var s []uint16 + h := (*reflect.SliceHeader)(unsafe.Pointer(&s)) + h.Data = p + h.Len = n + h.Cap = n + return []byte(string(utf16.Decode(s))), nil +} + +// writeText writes given data to the clipboard. It is the caller's +// responsibility for opening/closing the clipboard before calling +// this function. +func writeText(buf []byte) error { + r, _, err := emptyClipboard.Call() + if r == 0 { + return fmt.Errorf("failed to clear clipboard: %w", err) + } + + // empty text, we are done here. + if len(buf) == 0 { + return nil + } + + s, err := syscall.UTF16FromString(string(buf)) + if err != nil { + return fmt.Errorf("failed to convert given string: %w", err) + } + + hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0])))) + if hMem == 0 { + return fmt.Errorf("failed to alloc global memory: %w", err) + } + + p, _, err := gLock.Call(hMem) + if p == 0 { + return fmt.Errorf("failed to lock global memory: %w", err) + } + defer gUnlock.Call(hMem) + + // no return value + memMove.Call(p, uintptr(unsafe.Pointer(&s[0])), + uintptr(len(s)*int(unsafe.Sizeof(s[0])))) + + v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem) + if v == 0 { + gFree.Call(hMem) + return fmt.Errorf("failed to set text to clipboard: %w", err) + } + + return nil +} + +// readImage reads the clipboard and returns PNG encoded image data +// if presents. The caller is responsible for opening/closing the +// clipboard before calling this function. +func readImage() ([]byte, error) { + hMem, _, err := getClipboardData.Call(cFmtDIBV5) + if hMem == 0 { + // second chance to try FmtDIB + return readImageDib() + } + p, _, err := gLock.Call(hMem) + if p == 0 { + return nil, err + } + defer gUnlock.Call(hMem) + + // inspect header information + info := (*bitmapV5Header)(unsafe.Pointer(p)) + + // maybe deal with other formats? + if info.BitCount != 32 { + return nil, errUnsupported + } + + var data []byte + sh := (*reflect.SliceHeader)(unsafe.Pointer(&data)) + sh.Data = uintptr(p) + sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height)) + sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height)) + img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height))) + offset := int(info.Size) + stride := int(info.Width) + for y := 0; y < int(info.Height); y++ { + for x := 0; x < int(info.Width); x++ { + idx := offset + 4*(y*stride+x) + xhat := (x + int(info.Width)) % int(info.Width) + yhat := int(info.Height) - 1 - y + r := data[idx+2] + g := data[idx+1] + b := data[idx+0] + a := data[idx+3] + img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a}) + } + } + // always use PNG encoding. + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes(), nil +} + +func readImageDib() ([]byte, error) { + const ( + fileHeaderLen = 14 + infoHeaderLen = 40 + cFmtDIB = 8 + ) + + hClipDat, _, err := getClipboardData.Call(cFmtDIB) + if err != nil { + return nil, errors.New("not dib format data: " + err.Error()) + } + pMemBlk, _, err := gLock.Call(hClipDat) + if pMemBlk == 0 { + return nil, errors.New("failed to call global lock: " + err.Error()) + } + defer gUnlock.Call(hClipDat) + + bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk)) + dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen + + if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 { + iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3) + dataSize += iSizeImage + } + buf := new(bytes.Buffer) + binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8)) + binary.Write(buf, binary.LittleEndian, uint32(dataSize)) + binary.Write(buf, binary.LittleEndian, uint32(0)) + const sizeof_colorbar = 0 + binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar)) + j := 0 + for i := fileHeaderLen; i < int(dataSize); i++ { + binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j)))) + j++ + } + return bmpToPng(buf) +} + +func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) { + var f bytes.Buffer + original_image, err := bmp.Decode(bmpBuf) + if err != nil { + return nil, err + } + err = png.Encode(&f, original_image) + if err != nil { + return nil, err + } + return f.Bytes(), nil +} + +func writeImage(buf []byte) error { + r, _, err := emptyClipboard.Call() + if r == 0 { + return fmt.Errorf("failed to clear clipboard: %w", err) + } + + // empty text, we are done here. + if len(buf) == 0 { + return nil + } + + img, err := png.Decode(bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("input bytes is not PNG encoded: %w", err) + } + + offset := unsafe.Sizeof(bitmapV5Header{}) + width := img.Bounds().Dx() + height := img.Bounds().Dy() + imageSize := 4 * width * height + + data := make([]byte, int(offset)+imageSize) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + idx := int(offset) + 4*(y*width+x) + r, g, b, a := img.At(x, height-1-y).RGBA() + data[idx+2] = uint8(r) + data[idx+1] = uint8(g) + data[idx+0] = uint8(b) + data[idx+3] = uint8(a) + } + } + + info := bitmapV5Header{} + info.Size = uint32(offset) + info.Width = int32(width) + info.Height = int32(height) + info.Planes = 1 + info.Compression = 0 // BI_RGB + info.SizeImage = uint32(4 * info.Width * info.Height) + info.RedMask = 0xff0000 // default mask + info.GreenMask = 0xff00 + info.BlueMask = 0xff + info.AlphaMask = 0xff000000 + info.BitCount = 32 // we only deal with 32 bpp at the moment. + // Use calibrated RGB values as Go's image/png assumes linear color space. + // Other options: + // - LCS_CALIBRATED_RGB = 0x00000000 + // - LCS_sRGB = 0x73524742 + // - LCS_WINDOWS_COLOR_SPACE = 0x57696E20 + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f + info.CSType = 0x73524742 + // Use GL_IMAGES for GamutMappingIntent + // Other options: + // - LCS_GM_ABS_COLORIMETRIC = 0x00000008 + // - LCS_GM_BUSINESS = 0x00000001 + // - LCS_GM_GRAPHICS = 0x00000002 + // - LCS_GM_IMAGES = 0x00000004 + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38 + info.Intent = 4 // LCS_GM_IMAGES + + infob := make([]byte, int(unsafe.Sizeof(info))) + for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) { + infob[i] = v + } + copy(data[:], infob[:]) + + hMem, _, err := gAlloc.Call(gmemMoveable, + uintptr(len(data)*int(unsafe.Sizeof(data[0])))) + if hMem == 0 { + return fmt.Errorf("failed to alloc global memory: %w", err) + } + + p, _, err := gLock.Call(hMem) + if p == 0 { + return fmt.Errorf("failed to lock global memory: %w", err) + } + defer gUnlock.Call(hMem) + + memMove.Call(p, uintptr(unsafe.Pointer(&data[0])), + uintptr(len(data)*int(unsafe.Sizeof(data[0])))) + + v, _, err := setClipboardData.Call(cFmtDIBV5, hMem) + if v == 0 { + gFree.Call(hMem) + return fmt.Errorf("failed to set text to clipboard: %w", err) + } + + return nil +} + +func read(t Format) (buf []byte, err error) { + // On Windows, OpenClipboard and CloseClipboard must be executed on + // the same thread. Thus, lock the OS thread for further execution. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var format uintptr + switch t { + case FmtImage: + format = cFmtDIBV5 + case FmtText: + fallthrough + default: + format = cFmtUnicodeText + } + + // check if clipboard is avaliable for the requested format + r, _, err := isClipboardFormatAvailable.Call(format) + if r == 0 { + return nil, errUnavailable + } + + // try again until open clipboard successed + for { + r, _, _ = openClipboard.Call() + if r == 0 { + continue + } + break + } + defer closeClipboard.Call() + + switch format { + case cFmtDIBV5: + return readImage() + case cFmtUnicodeText: + fallthrough + default: + return readText() + } +} + +// write writes the given data to clipboard and +// returns true if success or false if failed. +func write(t Format, buf []byte) (<-chan struct{}, error) { + errch := make(chan error) + changed := make(chan struct{}, 1) + go func() { + // make sure GetClipboardSequenceNumber happens with + // OpenClipboard on the same thread. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + for { + r, _, _ := openClipboard.Call(0) + if r == 0 { + continue + } + break + } + + // var param uintptr + switch t { + case FmtImage: + err := writeImage(buf) + if err != nil { + errch <- err + closeClipboard.Call() + return + } + case FmtText: + fallthrough + default: + // param = cFmtUnicodeText + err := writeText(buf) + if err != nil { + errch <- err + closeClipboard.Call() + return + } + } + // Close the clipboard otherwise other applications cannot + // paste the data. + closeClipboard.Call() + + cnt, _, _ := getClipboardSequenceNumber.Call() + errch <- nil + for { + time.Sleep(time.Second) + cur, _, _ := getClipboardSequenceNumber.Call() + if cur != cnt { + changed <- struct{}{} + close(changed) + return + } + } + }() + err := <-errch + if err != nil { + return nil, err + } + return changed, nil +} + +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + ready := make(chan struct{}) + go func() { + // not sure if we are too slow or the user too fast :) + ti := time.NewTicker(time.Second) + cnt, _, _ := getClipboardSequenceNumber.Call() + ready <- struct{}{} + for { + select { + case <-ctx.Done(): + close(recv) + return + case <-ti.C: + cur, _, _ := getClipboardSequenceNumber.Call() + if cnt != cur { + b := Read(t) + if b == nil { + continue + } + recv <- b + cnt = cur + } + } + } + }() + <-ready + return recv +} + +const ( + cFmtBitmap = 2 // Win+PrintScreen + cFmtUnicodeText = 13 + cFmtDIBV5 = 17 + // Screenshot taken from special shortcut is in different format (why??), see: + // https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/ + cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats + gmemMoveable = 0x0002 +) + +// BITMAPV5Header structure, see: +// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header +type bitmapV5Header struct { + Size uint32 + Width int32 + Height int32 + Planes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter int32 + YPelsPerMeter int32 + ClrUsed uint32 + ClrImportant uint32 + RedMask uint32 + GreenMask uint32 + BlueMask uint32 + AlphaMask uint32 + CSType uint32 + Endpoints struct { + CiexyzRed, CiexyzGreen, CiexyzBlue struct { + CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30 + } + } + GammaRed uint32 + GammaGreen uint32 + GammaBlue uint32 + Intent uint32 + ProfileData uint32 + ProfileSize uint32 + Reserved uint32 +} + +type bitmapHeader struct { + Size uint32 + Width uint32 + Height uint32 + PLanes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter uint32 + YPelsPerMeter uint32 + ClrUsed uint32 + ClrImportant uint32 +} + +// Calling a Windows DLL, see: +// https://github.com/golang/go/wiki/WindowsDLLs +var ( + user32 = syscall.MustLoadDLL("user32") + // Opens the clipboard for examination and prevents other + // applications from modifying the clipboard content. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard + openClipboard = user32.MustFindProc("OpenClipboard") + // Closes the clipboard. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard + closeClipboard = user32.MustFindProc("CloseClipboard") + // Empties the clipboard and frees handles to data in the clipboard. + // The function then assigns ownership of the clipboard to the + // window that currently has the clipboard open. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard + emptyClipboard = user32.MustFindProc("EmptyClipboard") + // Retrieves data from the clipboard in a specified format. + // The clipboard must have been opened previously. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata + getClipboardData = user32.MustFindProc("GetClipboardData") + // Places data on the clipboard in a specified clipboard format. + // The window must be the current clipboard owner, and the + // application must have called the OpenClipboard function. (When + // responding to the WM_RENDERFORMAT message, the clipboard owner + // must not call OpenClipboard before calling SetClipboardData.) + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata + setClipboardData = user32.MustFindProc("SetClipboardData") + // Determines whether the clipboard contains data in the specified format. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable + isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable") + // Clipboard data formats are stored in an ordered list. To perform + // an enumeration of clipboard data formats, you make a series of + // calls to the EnumClipboardFormats function. For each call, the + // format parameter specifies an available clipboard format, and the + // function returns the next available clipboard format. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable + enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats") + // Retrieves the clipboard sequence number for the current window station. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber + getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber") + // Registers a new clipboard format. This format can then be used as + // a valid clipboard format. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata + registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA") + + kernel32 = syscall.NewLazyDLL("kernel32") + + // Locks a global memory object and returns a pointer to the first + // byte of the object's memory block. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock + gLock = kernel32.NewProc("GlobalLock") + // Decrements the lock count associated with a memory object that was + // allocated with GMEM_MOVEABLE. This function has no effect on memory + // objects allocated with GMEM_FIXED. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock + gUnlock = kernel32.NewProc("GlobalUnlock") + // Allocates the specified number of bytes from the heap. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc + gAlloc = kernel32.NewProc("GlobalAlloc") + // Frees the specified global memory object and invalidates its handle. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree + gFree = kernel32.NewProc("GlobalFree") + memMove = kernel32.NewProc("RtlMoveMemory") +) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 071f22d0e..6053c9c01 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -15,13 +15,13 @@ import ( "github.com/google/uuid" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/clipboard" "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/textarea" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" - "golang.design/x/clipboard" ) type EditorComponent interface { |
