diff --git a/CMakeLists.txt b/CMakeLists.txt index e908cab..a32d326 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ project(go-libghostty LANGUAGES C) include(FetchContent) FetchContent_Declare(ghostty GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git - GIT_TAG cb36966a752982014827a9cabcf630ec3788b3d9 + GIT_TAG d4ac93a0395d321b043ee0116dc8a1a384f0fb83 ) FetchContent_MakeAvailable(ghostty) diff --git a/examples/effects/main.go b/examples/effects/main.go index e8378e1..982330f 100644 --- a/examples/effects/main.go +++ b/examples/effects/main.go @@ -1,8 +1,8 @@ // Example program demonstrating terminal effect callbacks. // -// It registers write_pty, bell, and title_changed effect handlers, then -// feeds VT sequences that trigger each one. Output shows how the -// callbacks fire and how terminal state can be queried from within them. +// It registers write_pty, bell, title_changed, and clipboard_write effect +// handlers, then feeds VT sequences that trigger each one. Output shows how +// the callbacks fire and how terminal state can be queried from within them. package main import ( @@ -30,6 +30,15 @@ func main() { fmt.Printf("bell: count=%d\n", bellCount) }), + // clipboard_write: called with normalized, decoded clipboard contents. + ghostty.WithClipboardWrite(func(_ *ghostty.Terminal, write ghostty.ClipboardWrite) ghostty.ClipboardWriteResult { + fmt.Printf("clipboard_write: location=%d contents=%d\n", write.Location, len(write.Contents)) + for _, content := range write.Contents { + fmt.Printf(" %s: %q\n", content.MIME, content.Data) + } + return ghostty.ClipboardWriteSuccess + }), + // title_changed: called when the terminal title changes via OSC 0/2. // The terminal is passed directly as a parameter. ghostty.WithTitleChanged(func(t *ghostty.Terminal) { @@ -54,6 +63,9 @@ func main() { // DECRQM query → triggers write_pty with the response. term.VTWrite([]byte("\x1b[?7$p")) + // OSC 52 (set clipboard) → triggers clipboard_write with decoded data. + term.VTWrite([]byte("\x1b]52;c;SGVsbG8gY2xpcGJvYXJk\x1b\\")) + // Another BEL → triggers bell handler again. term.VTWrite([]byte{0x07}) diff --git a/terminal.go b/terminal.go index d5bde94..ce98a48 100644 --- a/terminal.go +++ b/terminal.go @@ -30,6 +30,7 @@ type Terminal struct { onWritePty WritePtyFn onBell BellFn + onClipboardWrite ClipboardWriteFn onTitleChanged TitleChangedFn onEnquiry EnquiryFn onXtversion XtversionFn @@ -66,6 +67,7 @@ type TerminalConfig struct { // Effect handlers applied after terminal creation. onWritePty WritePtyFn onBell BellFn + onClipboardWrite ClipboardWriteFn onTitleChanged TitleChangedFn onEnquiry EnquiryFn onXtversion XtversionFn @@ -85,6 +87,82 @@ type WritePtyFn func(t *Terminal, data []byte) // C: GhosttyTerminalBellFn type BellFn func(t *Terminal) +// ClipboardLocation identifies the normalized destination for a clipboard +// write. Protocol-specific selectors are converted to one of these values +// before the callback runs. +// C: GhosttyClipboardLocation +type ClipboardLocation int + +const ( + // ClipboardLocationStandard identifies the standard system clipboard. + ClipboardLocationStandard ClipboardLocation = C.GHOSTTY_CLIPBOARD_LOCATION_STANDARD + + // ClipboardLocationSelection identifies the selection clipboard. + ClipboardLocationSelection ClipboardLocation = C.GHOSTTY_CLIPBOARD_LOCATION_SELECTION + + // ClipboardLocationPrimary identifies the primary selection clipboard. + ClipboardLocationPrimary ClipboardLocation = C.GHOSTTY_CLIPBOARD_LOCATION_PRIMARY +) + +// ClipboardContent is one MIME representation in a clipboard write. Data is +// decoded from its protocol-level encoding and is binary-safe. Empty Data is +// an explicit empty representation; only an empty [ClipboardWrite.Contents] +// requests that the destination be cleared. +// C: GhosttyClipboardContent +type ClipboardContent struct { + // MIME is the MIME type of this representation. + MIME string + + // Data is the decoded, binary-safe representation data. + Data []byte +} + +// ClipboardWrite is a semantic, atomic clipboard write. Every entry in +// Contents represents the same logical value and should be committed +// atomically. An empty Contents slice requests that Location be cleared. +// C: GhosttyClipboardWrite +type ClipboardWrite struct { + // Location is the normalized clipboard destination. + Location ClipboardLocation + + // Contents contains all MIME representations of the logical value. + Contents []ClipboardContent +} + +// ClipboardWriteResult reports the outcome of a clipboard write callback. +// Protocols without write acknowledgements, including OSC 52 and iTerm2 +// OSC 1337 Copy, ignore this result. +// C: GhosttyClipboardWriteResult +type ClipboardWriteResult int + +const ( + // ClipboardWriteSuccess means the clipboard write completed successfully. + ClipboardWriteSuccess ClipboardWriteResult = C.GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS + + // ClipboardWriteDenied means policy or the user denied the write. + ClipboardWriteDenied ClipboardWriteResult = C.GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED + + // ClipboardWriteUnsupported means the destination or a representation is unsupported. + ClipboardWriteUnsupported ClipboardWriteResult = C.GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED + + // ClipboardWriteBusy means the clipboard is temporarily unavailable. + ClipboardWriteBusy ClipboardWriteResult = C.GHOSTTY_CLIPBOARD_WRITE_RESULT_BUSY + + // ClipboardWriteInvalidData means one or more representations contain invalid data. + ClipboardWriteInvalidData ClipboardWriteResult = C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA + + // ClipboardWriteIOError means the clipboard write failed due to an I/O error. + ClipboardWriteIOError ClipboardWriteResult = C.GHOSTTY_CLIPBOARD_WRITE_RESULT_IO_ERROR +) + +// ClipboardWriteFn is called synchronously for a complete logical clipboard +// write. Protocol details such as selectors, encodings, chunks, and aliases +// have already been normalized. The write and all of its content are copied +// into Go-owned memory before the callback runs and may be retained. Return +// the result of attempting the write. +// C: GhosttyTerminalClipboardWriteFn +type ClipboardWriteFn func(t *Terminal, write ClipboardWrite) ClipboardWriteResult + // TitleChangedFn is called when the terminal title changes via OSC 0/2. // The parameter is the terminal that triggered the effect. // C: GhosttyTerminalTitleChangedFn @@ -155,6 +233,14 @@ func WithBell(fn BellFn) TerminalOption { } } +// WithClipboardWrite registers an effect handler invoked for normalized, +// decoded clipboard writes. Clipboard read requests are never forwarded. +func WithClipboardWrite(fn ClipboardWriteFn) TerminalOption { + return func(c *TerminalConfig) { + c.onClipboardWrite = fn + } +} + // WithTitleChanged registers an effect handler invoked when the // terminal title changes via OSC 0 or OSC 2. func WithTitleChanged(fn TitleChangedFn) TerminalOption { @@ -233,6 +319,7 @@ func NewTerminal(opts ...TerminalOption) (*Terminal, error) { ptr: cterm, onWritePty: cfg.onWritePty, onBell: cfg.onBell, + onClipboardWrite: cfg.onClipboardWrite, onTitleChanged: cfg.onTitleChanged, onEnquiry: cfg.onEnquiry, onXtversion: cfg.onXtversion, diff --git a/terminal_effect.go b/terminal_effect.go index c0be230..3f60675 100644 --- a/terminal_effect.go +++ b/terminal_effect.go @@ -14,6 +14,7 @@ package libghostty // addresses on the C side. extern void goWritePtyTrampoline(GhosttyTerminal, void*, uint8_t*, size_t); extern void goBellTrampoline(GhosttyTerminal, void*); +extern GhosttyClipboardWriteResult goClipboardWriteTrampoline(GhosttyTerminal, void*, GhosttyClipboardWrite*); extern void goTitleChangedTrampoline(GhosttyTerminal, void*); extern GhosttyString goEnquiryTrampoline(GhosttyTerminal, void*); extern GhosttyString goXtversionTrampoline(GhosttyTerminal, void*); @@ -30,6 +31,9 @@ static inline GhosttyResult set_write_pty(GhosttyTerminal t) { static inline GhosttyResult set_bell(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_BELL, (const void*)goBellTrampoline); } +static inline GhosttyResult set_clipboard_write(GhosttyTerminal t) { + return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, (const void*)goClipboardWriteTrampoline); +} static inline GhosttyResult set_title_changed(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, (const void*)goTitleChangedTrampoline); } @@ -74,6 +78,11 @@ func (t *Terminal) syncEffects() { } else { C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_BELL) } + if t.onClipboardWrite != nil { + C.set_clipboard_write(t.ptr) + } else { + C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE) + } if t.onTitleChanged != nil { C.set_title_changed(t.ptr) } else { @@ -127,6 +136,80 @@ func goBellTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) { } } +//export goClipboardWriteTrampoline +func goClipboardWriteTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer, write *C.GhosttyClipboardWrite) C.GhosttyClipboardWriteResult { + t := terminalFromUserdata(userdata) + if t.onClipboardWrite == nil { + return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED + } + + // GhosttyClipboardWrite is a sized struct so newer libghostty versions + // can extend it without breaking existing callbacks. Only read the fields + // this binding knows about when the descriptor contains the full current + // layout. + if write == nil || write.size < C.size_t(C.sizeof_GhosttyClipboardWrite) { + return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA + } + + count, ok := clipboardSizeToInt(write.contents_len) + if !ok || (count > 0 && write.contents == nil) { + return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA + } + + contents := make([]ClipboardContent, count) + if count > 0 { + cContents := unsafe.Slice(write.contents, count) + for i, content := range cContents { + mime, valid := copyClipboardString(content.mime) + if !valid { + return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA + } + data, valid := copyClipboardString(content.data) + if !valid { + return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA + } + + contents[i] = ClipboardContent{ + MIME: string(mime), + Data: data, + } + } + } + + result := t.onClipboardWrite(t, ClipboardWrite{ + Location: ClipboardLocation(write.location), + Contents: contents, + }) + return C.GhosttyClipboardWriteResult(result) +} + +// clipboardSizeToInt converts a C size to a Go slice length without allowing +// an overflowing conversion to produce an invalid unsafe.Slice length. +func clipboardSizeToInt(size C.size_t) (int, bool) { + if uint64(size) > uint64(^uint(0)>>1) { + return 0, false + } + return int(size), true +} + +// copyClipboardString copies a borrowed, binary-safe GhosttyString into Go +// memory. For zero-length strings the pointer is intentionally ignored because +// libghostty does not require it to be valid. +func copyClipboardString(value C.GhosttyString) ([]byte, bool) { + length, ok := clipboardSizeToInt(value.len) + if !ok { + return nil, false + } + if length == 0 { + return []byte{}, true + } + if value.ptr == nil { + return nil, false + } + + return append([]byte(nil), unsafe.Slice((*byte)(unsafe.Pointer(value.ptr)), length)...), true +} + //export goTitleChangedTrampoline func goTitleChangedTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) { t := terminalFromUserdata(userdata) diff --git a/terminal_opt.go b/terminal_opt.go index f275dad..3ed1a11 100644 --- a/terminal_opt.go +++ b/terminal_opt.go @@ -24,6 +24,13 @@ func (t *Terminal) SetEffectBell(fn BellFn) { t.syncEffects() } +// SetEffectClipboardWrite registers (or clears) the clipboard-write effect +// on a live terminal. Pass nil to clear. +func (t *Terminal) SetEffectClipboardWrite(fn ClipboardWriteFn) { + t.onClipboardWrite = fn + t.syncEffects() +} + // SetEffectTitleChanged registers (or clears) the title-changed effect // on a live terminal. Pass nil to clear. func (t *Terminal) SetEffectTitleChanged(fn TitleChangedFn) { diff --git a/terminal_opt_test.go b/terminal_opt_test.go index 6a88a28..4a2ac83 100644 --- a/terminal_opt_test.go +++ b/terminal_opt_test.go @@ -1,6 +1,9 @@ package libghostty -import "testing" +import ( + "bytes" + "testing" +) func TestTerminalSetTitle(t *testing.T) { term, err := NewTerminal(WithSize(80, 24)) @@ -139,6 +142,106 @@ func TestTerminalSetEffectBell(t *testing.T) { } } +func TestTerminalWithClipboardWrite(t *testing.T) { + var writes []ClipboardWrite + term, err := NewTerminal( + WithSize(80, 24), + WithClipboardWrite(func(_ *Terminal, write ClipboardWrite) ClipboardWriteResult { + writes = append(writes, write) + return ClipboardWriteDenied + }), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + // OSC 52 is decoded before the effect runs, including binary data and + // sequences split across multiple writes. + term.VTWrite([]byte("\x1b]52;c;aGVs")) + term.VTWrite([]byte("bG8Ad29ybGQ=\x1b\\")) + if len(writes) != 1 { + t.Fatalf("expected 1 clipboard write, got %d", len(writes)) + } + if got := writes[0].Location; got != ClipboardLocationStandard { + t.Fatalf("expected standard clipboard, got %d", got) + } + if got := len(writes[0].Contents); got != 1 { + t.Fatalf("expected 1 clipboard representation, got %d", got) + } + content := writes[0].Contents[0] + if got := content.MIME; got != "text/plain" { + t.Fatalf("expected text/plain MIME type, got %q", got) + } + if want := []byte("hello\x00world"); !bytes.Equal(content.Data, want) { + t.Fatalf("expected clipboard data %q, got %q", want, content.Data) + } + + // An empty content list is a clear, while the normalized destination is + // still preserved. + term.VTWrite([]byte("\x1b]52;s;\x1b\\")) + if len(writes) != 2 { + t.Fatalf("expected 2 clipboard writes, got %d", len(writes)) + } + if got := writes[1].Location; got != ClipboardLocationSelection { + t.Fatalf("expected selection clipboard, got %d", got) + } + if got := len(writes[1].Contents); got != 0 { + t.Fatalf("expected a clipboard clear, got %d representations", got) + } + + // iTerm2 Copy uses the same protocol-neutral callback shape. + term.VTWrite([]byte("\x1b]1337;Copy=:aVRlcm0=\x1b\\")) + if len(writes) != 3 { + t.Fatalf("expected 3 clipboard writes, got %d", len(writes)) + } + if got := writes[2].Contents[0].Data; !bytes.Equal(got, []byte("iTerm")) { + t.Fatalf("expected decoded iTerm2 data, got %q", got) + } + + // Clipboard reads and malformed payloads are intentionally ignored. + term.VTWrite([]byte("\x1b]52;c;?\x1b\\")) + term.VTWrite([]byte("\x1b]52;c;%%%\x1b\\")) + if len(writes) != 3 { + t.Fatalf("expected ignored reads and malformed data, got %d writes", len(writes)) + } + + // Callback data is copied into Go memory and remains valid after later + // terminal writes have reused libghostty's borrowed descriptors. + if want := []byte("hello\x00world"); !bytes.Equal(writes[0].Contents[0].Data, want) { + t.Fatalf("expected retained clipboard data %q, got %q", want, writes[0].Contents[0].Data) + } +} + +func TestTerminalSetEffectClipboardWrite(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + var writes int + term.SetEffectClipboardWrite(func(_ *Terminal, write ClipboardWrite) ClipboardWriteResult { + writes++ + if write.Location != ClipboardLocationPrimary { + t.Errorf("expected primary clipboard, got %d", write.Location) + } + return ClipboardWriteSuccess + }) + + term.VTWrite([]byte("\x1b]52;p;eA==\x1b\\")) + if writes != 1 { + t.Fatalf("expected 1 clipboard write, got %d", writes) + } + + // Clearing the callback takes effect immediately. + term.SetEffectClipboardWrite(nil) + term.VTWrite([]byte("\x1b]52;p;eA==\x1b\\")) + if writes != 1 { + t.Fatalf("expected still 1 clipboard write after clearing, got %d", writes) + } +} + func TestTerminalWithWritePty(t *testing.T) { var received []byte term, err := NewTerminal(WithSize(80, 24), WithWritePty(func(_ *Terminal, data []byte) {