From 53e90502ef807a4a4598b6467e1252078626f6d5 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sat, 23 May 2026 01:21:22 +0900 Subject: [PATCH] fix: support screenshot paste from the clipboard Use platform-aware clipboard readers so screenshot/image paste works reliably in slk compose on supported platforms. --- internal/ui/app.go | 100 ++++++++++++++-------------- internal/ui/clipboard_darwin.go | 9 +++ internal/ui/clipboard_darwin_cgo.go | 91 +++++++++++++++++++++++++ internal/ui/clipboard_default.go | 9 +++ 4 files changed, 159 insertions(+), 50 deletions(-) create mode 100644 internal/ui/clipboard_darwin.go create mode 100644 internal/ui/clipboard_darwin_cgo.go create mode 100644 internal/ui/clipboard_default.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 27dfeb3..05de43b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -19,7 +19,6 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "golang.design/x/clipboard" "github.com/gammons/slk/internal/cache" "github.com/gammons/slk/internal/config" "github.com/gammons/slk/internal/debuglog" @@ -44,6 +43,7 @@ import ( "github.com/gammons/slk/internal/ui/threadsview" "github.com/gammons/slk/internal/ui/workspace" "github.com/gammons/slk/internal/ui/workspacefinder" + "golang.design/x/clipboard" ) type Panel int @@ -349,7 +349,7 @@ type ( UserID string WorkspaceID string } - TypingExpiredMsg struct{} + TypingExpiredMsg struct{} PresenceChangeMsg struct { UserID string Presence string @@ -694,9 +694,9 @@ type ChannelJoinFailedMsg struct { // clipboard contents. Production code uses the real clipboard.Read. type clipboardReader func(format clipboard.Format) []byte -// defaultClipboardReader is the real clipboard read function. It's +// defaultClipboardReader is the platform clipboard reader. It's // overridable per-App via SetClipboardReader for tests. -var defaultClipboardReader clipboardReader = clipboard.Read +var defaultClipboardReader clipboardReader = platformClipboardReader() type App struct { // Sub-models @@ -725,10 +725,10 @@ type App struct { keys KeyMap // Cached layout widths for mouse hit-testing - layoutRailWidth int - layoutSidebarEnd int // railWidth + sidebarWidth + sidebarBorder - layoutMsgEnd int // layoutSidebarEnd + msgWidth + msgBorder - layoutThreadEnd int // layoutMsgEnd + threadWidth + threadBorder + layoutRailWidth int + layoutSidebarEnd int // railWidth + sidebarWidth + sidebarBorder + layoutMsgEnd int // layoutSidebarEnd + msgWidth + msgBorder + layoutThreadEnd int // layoutMsgEnd + threadWidth + threadBorder // Cached pane content heights, used for page-up/down distance calculations. layoutMsgHeight int layoutSidebarHeight int @@ -774,11 +774,11 @@ type App struct { // always). channelSyncedAtReader func(channelID string) int64 olderMessagesFetcher OlderMessagesFetchFunc - messageSender MessageSendFunc - messageEditor MessageEditFunc - messageDeleter MessageDeleteFunc - messageMarkUnreader MarkUnreadFunc - uploader UploadFunc + messageSender MessageSendFunc + messageEditor MessageEditFunc + messageDeleter MessageDeleteFunc + messageMarkUnreader MarkUnreadFunc + uploader UploadFunc // clipboardAvailable is set at startup based on the result of // clipboard.Init(). When false, Ctrl+V smart-paste is a no-op. @@ -788,18 +788,18 @@ type App struct { // clipboard contents. Tests inject fakes via SetClipboardReader. clipboardRead clipboardReader - threadFetcher ThreadFetchFunc - threadCacheReader ThreadCacheReadFunc - threadMarker ThreadMarkFunc - threadReplySender ThreadReplySendFunc - channelJoiner JoinChannelFunc - threadsListFetcher ThreadsListFetchFunc + threadFetcher ThreadFetchFunc + threadCacheReader ThreadCacheReadFunc + threadMarker ThreadMarkFunc + threadReplySender ThreadReplySendFunc + channelJoiner JoinChannelFunc + threadsListFetcher ThreadsListFetchFunc // channelLastReadFetcher returns the parent channel's last_read_ts // so the thread panel can render a "── new ──" boundary. Optional — // when nil, the thread panel renders without an unread boundary. channelLastReadFetcher func(channelID string) string - threadsDirtyDebounce time.Duration - fetchingOlder bool + threadsDirtyDebounce time.Duration + fetchingOlder bool // Cached user-id -> display-name map (mirror of what SetUserNames // last received). Used by openSelectedThreadCmd to populate the @@ -1007,36 +1007,36 @@ func previewSpinnerTickCmd() tea.Cmd { func NewApp() *App { app := &App{ - workspaceRail: workspace.New(nil, 0), - sidebar: sidebar.New(nil), - messagepane: messages.New(nil, ""), - compose: compose.New(""), - statusbar: statusbar.New(), - channelFinder: channelfinder.New(), - workspaceFinder: workspacefinder.New(), - themeSwitcher: themeswitcher.New(), - presenceMenu: presencemenu.New(), - help: help.New(), - threadPanel: thread.New(), - threadCompose: compose.New("thread"), - threadsView: threadsview.New(nil, ""), - reactionPicker: reactionpicker.New(), - confirmPrompt: confirmprompt.New(), - mode: ModeNormal, - focusedPanel: PanelSidebar, - sidebarVisible: true, - view: ViewChannels, - keys: DefaultKeyMap(), - typingUsers: make(map[string]map[string]time.Time), - selfSentTSes: make(map[string]time.Time), + workspaceRail: workspace.New(nil, 0), + sidebar: sidebar.New(nil), + messagepane: messages.New(nil, ""), + compose: compose.New(""), + statusbar: statusbar.New(), + channelFinder: channelfinder.New(), + workspaceFinder: workspacefinder.New(), + themeSwitcher: themeswitcher.New(), + presenceMenu: presencemenu.New(), + help: help.New(), + threadPanel: thread.New(), + threadCompose: compose.New("thread"), + threadsView: threadsview.New(nil, ""), + reactionPicker: reactionpicker.New(), + confirmPrompt: confirmprompt.New(), + mode: ModeNormal, + focusedPanel: PanelSidebar, + sidebarVisible: true, + view: ViewChannels, + keys: DefaultKeyMap(), + typingUsers: make(map[string]map[string]time.Time), + selfSentTSes: make(map[string]time.Time), lastSelfSendByChannel: make(map[string]time.Time), - threadsDirtyDebounce: 150 * time.Millisecond, - userNames: map[string]string{}, - externalUsers: map[string]bool{}, - statusByTeam: map[string]workspaceStatus{}, - lastChannelByTeam: map[string]string{}, - navHistory: make(map[string]*navStack), - clipboardRead: defaultClipboardReader, + threadsDirtyDebounce: 150 * time.Millisecond, + userNames: map[string]string{}, + externalUsers: map[string]bool{}, + statusByTeam: map[string]workspaceStatus{}, + lastChannelByTeam: map[string]string{}, + navHistory: make(map[string]*navStack), + clipboardRead: defaultClipboardReader, } // Seed the picker with built-in emojis so the autocomplete works even // before the first workspace finishes loading customs. diff --git a/internal/ui/clipboard_darwin.go b/internal/ui/clipboard_darwin.go new file mode 100644 index 0000000..0b6575d --- /dev/null +++ b/internal/ui/clipboard_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin && !cgo + +package ui + +import "golang.design/x/clipboard" + +func platformClipboardReader() clipboardReader { + return clipboard.Read +} diff --git a/internal/ui/clipboard_darwin_cgo.go b/internal/ui/clipboard_darwin_cgo.go new file mode 100644 index 0000000..a7a8327 --- /dev/null +++ b/internal/ui/clipboard_darwin_cgo.go @@ -0,0 +1,91 @@ +//go:build darwin && cgo + +package ui + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa +#import +#import +#include + +static unsigned int slk_copy_nsdata(NSData *data, void **out) { + if (data == nil || [data length] == 0) { + *out = NULL; + return 0; + } + NSUInteger size = [data length]; + void *buf = malloc(size); + if (buf == NULL) { + *out = NULL; + return 0; + } + [data getBytes:buf length:size]; + *out = buf; + return (unsigned int)size; +} + +unsigned int slk_clipboard_read_image_png(void **out) { + @autoreleasepool { + *out = NULL; + NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; + + NSData *png = [pasteboard dataForType:NSPasteboardTypePNG]; + if (png != nil) { + return slk_copy_nsdata(png, out); + } + + NSData *tiff = [pasteboard dataForType:NSPasteboardTypeTIFF]; + if (tiff != nil) { + NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff]; + NSData *converted = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + if (converted != nil) { + return slk_copy_nsdata(converted, out); + } + } + + NSImage *image = [[NSImage alloc] initWithPasteboard:pasteboard]; + if (image == nil) { + return 0; + } + CGImageRef cgImage = [image CGImageForProposedRect:NULL context:nil hints:nil]; + if (cgImage == NULL) { + [image release]; + return 0; + } + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; + NSData *converted = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + unsigned int n = slk_copy_nsdata(converted, out); + [rep release]; + [image release]; + return n; + } +} +*/ +import "C" + +import ( + "unsafe" + + "golang.design/x/clipboard" +) + +func platformClipboardReader() clipboardReader { + return func(format clipboard.Format) []byte { + b := clipboard.Read(format) + if format != clipboard.FmtImage || len(b) > 0 { + return b + } + return readDarwinClipboardImagePNG() + } +} + +func readDarwinClipboardImagePNG() []byte { + var data unsafe.Pointer + n := C.slk_clipboard_read_image_png(&data) + if data == nil || n == 0 { + return nil + } + defer C.free(data) + return C.GoBytes(data, C.int(n)) +} diff --git a/internal/ui/clipboard_default.go b/internal/ui/clipboard_default.go new file mode 100644 index 0000000..4cb80f2 --- /dev/null +++ b/internal/ui/clipboard_default.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package ui + +import "golang.design/x/clipboard" + +func platformClipboardReader() clipboardReader { + return clipboard.Read +}