From 37da8742e264ab1e1028d23bfc2e895c21e4ba3f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 1 Sep 2026 14:52:15 -0700 Subject: [PATCH] lib: update libghostty and add search bindings Update the pinned libghostty revision to current main. Expose terminal search with incremental feed and tick APIs, typed result access, match selection, and viewport scroll policy. Bind the Kitty clipboard write-size option and query added upstream, and document targets-only clipboard reads. --- CMakeLists.txt | 2 +- doc.go | 13 +- search.go | 425 +++++++++++++++++++++++++++++++++++++++++++ search_test.go | 214 ++++++++++++++++++++++ terminal.go | 26 ++- terminal_data.go | 19 ++ terminal_opt.go | 20 ++ terminal_opt_test.go | 31 ++++ 8 files changed, 743 insertions(+), 7 deletions(-) create mode 100644 search.go create mode 100644 search_test.go diff --git a/CMakeLists.txt b/CMakeLists.txt index e5d1e3d..b90f8a9 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 75d657788a63d1593e5fff22c766bd7e162f255c + GIT_TAG 3c1ef5b32fc5ea6b93d28493fabf193f595139cf ) FetchContent_MakeAvailable(ghostty) diff --git a/doc.go b/doc.go index a947b9d..af687d9 100644 --- a/doc.go +++ b/doc.go @@ -28,11 +28,14 @@ // view confined to one goroutine at a time or protect it with your own // synchronization. // -// [RenderState] is the main exception. Hold exclusive access to the -// terminal while calling [RenderState.Update]. After Update returns, the -// render state can be read without touching the terminal until the next -// Update. Do not call Update concurrently with reads from the same -// render state. +// [RenderState] and [Search] support limited concurrent use. Their type +// documentation identifies which calls may run while another goroutine uses +// the terminal. +// +// Hold exclusive access to the terminal while calling [RenderState.Update]. +// After Update returns, the render state can be read without touching the +// terminal until the next Update. Do not call Update concurrently with reads +// from the same render state. // // Borrowed views such as [GridRef], [KittyGraphics], [KittyGraphicsImage], // and [Selection], plus raw pixel slices returned by Kitty graphics diff --git a/search.go b/search.go new file mode 100644 index 0000000..65455d8 --- /dev/null +++ b/search.go @@ -0,0 +1,425 @@ +package libghostty + +/* +#include +*/ +import "C" + +import ( + "errors" + "runtime" + "unsafe" +) + +// Search finds a byte sequence in a terminal's active screen and scrollback +// history. It preserves results for both the primary and alternate screens. +// Calls to [Search.Feed] update those results as the terminal changes. Search +// work can be split across calls so that large scrollback histories do not +// block the caller. +// +// A Search uses, but does not own, the terminal passed to [NewSearch]. The +// search and terminal may be closed in either order. +// +// A Search is not safe for concurrent use. [Search.Tick], [Search.GetMulti], +// the data-reading methods, and [Search.SetScrollPolicy] do not access the +// terminal. These methods may run while another goroutine uses the terminal, +// provided no other goroutine is using the same Search. All other Search +// operations must be serialized with every other operation on the terminal. +// Before passing a returned [Selection] to a [Terminal] method, ensure the +// terminal has not changed since the selection was read and hold exclusive +// access to the terminal for that call. +// +// C: GhosttySearch +type Search struct { + ptr C.GhosttySearch +} + +// SearchStatus describes whether a search can make progress without reading +// from its terminal. +// +// C: GhosttySearchStatus +type SearchStatus int + +const ( + // SearchStatusRunning means [Search.Tick] can make progress using data that + // the search has already copied from the terminal. + SearchStatusRunning SearchStatus = C.GHOSTTY_SEARCH_STATUS_RUNNING + + // SearchStatusFeedRequired means [Search.Feed] must copy more terminal data + // before [Search.Tick] can continue. + SearchStatusFeedRequired SearchStatus = C.GHOSTTY_SEARCH_STATUS_FEED_REQUIRED + + // SearchStatusComplete means the search is caught up as of the most recent + // [Search.Feed]. An idle Search is also complete. Call [Search.Feed] again + // after the terminal changes. + SearchStatusComplete SearchStatus = C.GHOSTTY_SEARCH_STATUS_COMPLETE +) + +// SearchScrollPolicy controls whether selecting a match moves the terminal +// viewport. +// +// C: GhosttySearchScroll +type SearchScrollPolicy int + +const ( + // SearchScrollPolicyIfNeeded moves the viewport only when the selected + // match is not visible. This is the default policy. + SearchScrollPolicyIfNeeded SearchScrollPolicy = C.GHOSTTY_SEARCH_SCROLL_IF_NEEDED + + // SearchScrollPolicyNever leaves the viewport unchanged when a match is + // selected. + SearchScrollPolicyNever SearchScrollPolicy = C.GHOSTTY_SEARCH_SCROLL_NONE +) + +// SearchData identifies a low-level field that can be read with +// [Search.GetMulti]. Most callers should use methods such as [Search.Status] +// and [Search.Matches] instead. +// +// C: GhosttySearchData +type SearchData int + +const ( + // SearchDataStatus is the current search status (an int32 value compatible + // with GhosttySearchStatus). + SearchDataStatus SearchData = C.GHOSTTY_SEARCH_DATA_STATUS + + // SearchDataNeedle is the current search needle (GhosttyString). Reading this + // field returns an error with result [ResultNoValue] when the Search is idle. + // Prefer [Search.Needle]. + SearchDataNeedle SearchData = C.GHOSTTY_SEARCH_DATA_NEEDLE + + // SearchDataMatchCount is the number of matches found so far on the active + // screen, including scrollback (uint). + SearchDataMatchCount SearchData = C.GHOSTTY_SEARCH_DATA_TOTAL_MATCHES + + // SearchDataSelectedIndex is the selected match index (uint). Reading this + // field returns an error with result [ResultNoValue] when no match is + // selected. + SearchDataSelectedIndex SearchData = C.GHOSTTY_SEARCH_DATA_SELECTED_INDEX + + // SearchDataSelectedMatch is the selected match (GhosttySelection). Reading + // this field returns an error with result [ResultNoValue] when no match is + // selected. Prefer [Search.SelectedMatch]. + SearchDataSelectedMatch SearchData = C.GHOSTTY_SEARCH_DATA_SELECTED_MATCH + + // SearchDataMatches contains the matches found so far on the active screen, + // ordered from newest to oldest (GhosttySelectionBuffer). Prefer + // [Search.Matches]. + SearchDataMatches SearchData = C.GHOSTTY_SEARCH_DATA_MATCHES + + // SearchDataViewportMatches contains matches near the viewport + // (GhosttySelectionBuffer). Prefer [Search.ViewportMatches]. + SearchDataViewportMatches SearchData = C.GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES + + // SearchDataScrollPolicy is the current scroll policy (an int32 value + // compatible with GhosttySearchScroll). + SearchDataScrollPolicy SearchData = C.GHOSTTY_SEARCH_DATA_SELECT_SCROLL +) + +// NewSearch creates an idle search for the given terminal. Call +// [Search.SetNeedle] to start searching, and call [Search.Close] when the +// search is no longer needed. +// +// NewSearch registers with the terminal so that either value can be closed +// first. Callers must not use the terminal concurrently while NewSearch runs. +func NewSearch(t *Terminal) (*Search, error) { + var ptr C.GhosttySearch + if err := resultError(C.ghostty_search_new(nil, &ptr, t.ptr)); err != nil { + return nil, err + } + return &Search{ptr: ptr}, nil +} + +// Close releases resources owned by the search. If the terminal is still +// open, Close also releases state tracked by the terminal. Callers must not +// access the terminal concurrently while Close runs. The Search must not be +// used after Close returns. +func (s *Search) Close() { + C.ghostty_search_free(s.ptr) +} + +// Tick performs a bounded amount of search work and returns the new status. +// It uses only data already copied into the Search, so it may run while +// another goroutine accesses the terminal. +func (s *Search) Tick() (SearchStatus, error) { + var status C.GhosttySearchStatus + if err := resultError(C.ghostty_search_tick(s.ptr, &status)); err != nil { + return 0, err + } + return SearchStatus(status), nil +} + +// Feed copies recent terminal state into the Search. It updates matches in the +// active screen and viewport, then copies another portion of scrollback when +// needed. Each call performs a bounded amount of work. Call Feed periodically +// while the Search is in use so that later terminal changes are included. +// +// Callers must not access the terminal concurrently while Feed runs. +func (s *Search) Feed() error { + return resultError(C.ghostty_search_feed(s.ptr)) +} + +// Run blocks until the Search has processed the terminal's current contents. +// It is convenient for one-time searches. Interactive applications should use +// [Search.Feed] and [Search.Tick] to control how much work happens at once. +// +// Callers must not access the terminal concurrently while Run executes. +func (s *Search) Run() error { + return resultError(C.ghostty_search_run(s.ptr)) +} + +// GetMulti reads several low-level search fields in one library call. Each +// entry in values must point to storage of the type documented by the matching +// key. Keys that use a GhosttyString, GhosttySelection, or +// GhosttySelectionBuffer require storage matching the corresponding C type. +// Status and scroll policy fields require int32 storage because the underlying +// C enums are 32 bits. +// Prefer methods such as [Search.Status], [Search.MatchCount], and +// [Search.SelectedIndex] unless reducing calls into libghostty is important. +// +// On success, written equals len(keys). If a field cannot be read, written is +// the index of that field. Fields before that index have already been written. +// +// C: ghostty_search_get_multi +func (s *Search) GetMulti(keys []SearchData, values []unsafe.Pointer) (written int, err error) { + if len(keys) != len(values) { + return 0, errors.New("libghostty: keys and values must have the same length") + } + if len(keys) == 0 { + return 0, nil + } + + cKeys := make([]C.GhosttySearchData, len(keys)) + for i, key := range keys { + cKeys[i] = C.GhosttySearchData(key) + } + // The Go output pointers are nested inside a C-allocated array, so pin them + // explicitly for the duration of the call. + var pinner runtime.Pinner + for _, value := range values { + if value != nil { + pinner.Pin(value) + } + } + defer pinner.Unpin() + + cValues, cValuesSize := cValuesArray(values) + defer Free(unsafe.Pointer(cValues), cValuesSize) + + var cWritten C.size_t + err = resultError(C.ghostty_search_get_multi( + s.ptr, + C.size_t(len(cKeys)), + (*C.GhosttySearchData)(unsafe.Pointer(&cKeys[0])), + cValues, + &cWritten, + )) + return int(cWritten), err +} + +// SetNeedle sets the byte sequence to find. Matching is byte-for-byte except +// that ASCII letters are compared without regard to case. Search copies needle +// before SetNeedle returns. +// +// An empty needle clears the search and discards its results. Changing a +// non-empty needle restarts the search from the beginning. Setting an +// equivalent needle again preserves the current results. Equivalence uses the +// same ASCII case-insensitive comparison as matching. +func (s *Search) SetNeedle(needle []byte) error { + if len(needle) == 0 { + return s.set(C.GHOSTTY_SEARCH_OPT_NEEDLE, nil) + } + + allocationSize := uintptr(len(needle)) + ptr := Alloc(allocationSize) + if ptr == nil { + return &Error{Result: ResultOutOfMemory} + } + defer Free(ptr, allocationSize) + copy(unsafe.Slice((*byte)(ptr), len(needle)), needle) + + value := C.GhosttyString{ + ptr: (*C.uint8_t)(ptr), + len: C.size_t(len(needle)), + } + return s.set(C.GHOSTTY_SEARCH_OPT_NEEDLE, unsafe.Pointer(&value)) +} + +// SelectNext first updates the Search from the terminal, then selects the next +// match toward older content. Selection wraps from the oldest match back to +// the newest match. It returns an error with result [ResultNoValue] when the +// search has no matches. +func (s *Search) SelectNext() error { + return s.set(C.GHOSTTY_SEARCH_OPT_SELECT_NEXT, nil) +} + +// SelectPrevious first updates the Search from the terminal, then selects the +// previous match toward newer content. Selection wraps from the newest match +// back to the oldest match. It returns an error with result [ResultNoValue] +// when the search has no matches. +func (s *Search) SelectPrevious() error { + return s.set(C.GHOSTTY_SEARCH_OPT_SELECT_PREV, nil) +} + +// SetScrollPolicy sets how selecting a match affects the terminal viewport. +// It only changes Search state and may run while another goroutine accesses +// the terminal. +func (s *Search) SetScrollPolicy(policy SearchScrollPolicy) error { + value := C.GhosttySearchScroll(policy) + return s.set(C.GHOSTTY_SEARCH_OPT_SELECT_SCROLL, unsafe.Pointer(&value)) +} + +// Status reports whether the Search can make progress without another call to +// [Search.Feed]. A complete Search may become stale after the terminal changes. +func (s *Search) Status() (SearchStatus, error) { + var status C.GhosttySearchStatus + if err := s.get(SearchDataStatus, unsafe.Pointer(&status)); err != nil { + return 0, err + } + return SearchStatus(status), nil +} + +// Needle returns a copy of the current search needle. It returns nil when the +// Search is idle. The returned bytes remain valid after later Search calls. +func (s *Search) Needle() ([]byte, error) { + var value C.GhosttyString + err := s.get(SearchDataNeedle, unsafe.Pointer(&value)) + if err != nil { + if isResult(err, ResultNoValue) { + return nil, nil + } + return nil, err + } + needle, ok := copyGhosttyString(value) + if !ok { + return nil, &Error{Result: ResultInvalidValue} + } + return needle, nil +} + +// MatchCount returns the number of matches found so far in the active screen +// buffer, including its scrollback. The count is based on terminal contents +// copied by the most recent [Search.Feed] and can increase until +// [Search.Status] returns [SearchStatusComplete]. +func (s *Search) MatchCount() (uint, error) { + var count C.size_t + if err := s.get(SearchDataMatchCount, unsafe.Pointer(&count)); err != nil { + return 0, err + } + return uint(count), nil +} + +// SelectedIndex returns the selected match's index in [Search.Matches]. It +// returns nil when no match is selected. Index zero is the newest match. +func (s *Search) SelectedIndex() (*uint, error) { + var index C.size_t + err := s.get(SearchDataSelectedIndex, unsafe.Pointer(&index)) + if err != nil { + if isResult(err, ResultNoValue) { + return nil, nil + } + return nil, err + } + value := uint(index) + return &value, nil +} + +// SelectedMatch returns the selected match as a Selection. It returns nil when +// no match is selected. The next operation that mutates the terminal may +// invalidate the Selection. +func (s *Search) SelectedMatch() (*Selection, error) { + value := initCSelection() + err := s.get(SearchDataSelectedMatch, unsafe.Pointer(&value)) + if err != nil { + if isResult(err, ResultNoValue) { + return nil, nil + } + return nil, err + } + match := selectionFromC(value) + return &match, nil +} + +// Matches returns the matches found so far on the active screen, including +// scrollback, from newest to oldest. The next operation that mutates the +// terminal may invalidate the returned selections. +func (s *Search) Matches() ([]Selection, error) { + return s.selections(SearchDataMatches) +} + +// ViewportMatches returns matches near the viewport as of the most recent +// [Search.Feed]. Applications can use these selections to draw search +// highlights. The result may include nearby off-screen matches. Use +// [Terminal.PointFromGridRef] with [PointTagViewport] to convert each endpoint, +// then ignore matches that fall outside the visible rows. +// +// The next operation that mutates the terminal may invalidate the returned +// selections. +func (s *Search) ViewportMatches() ([]Selection, error) { + return s.selections(SearchDataViewportMatches) +} + +// ScrollPolicy returns the policy used when a match is selected. +func (s *Search) ScrollPolicy() (SearchScrollPolicy, error) { + var policy C.GhosttySearchScroll + if err := s.get(SearchDataScrollPolicy, unsafe.Pointer(&policy)); err != nil { + return 0, err + } + return SearchScrollPolicy(policy), nil +} + +// selections reads either match list. The output buffer uses C memory because +// GhosttySelection contains C pointers. +func (s *Search) selections(data SearchData) ([]Selection, error) { + var buffer C.GhosttySelectionBuffer + err := s.get(data, unsafe.Pointer(&buffer)) + if err != nil && !isResult(err, ResultOutOfSpace) { + return nil, err + } + + capacity := int(buffer.len) + if capacity == 0 { + return nil, nil + } + + allocationSize := uintptr(capacity) * C.sizeof_GhosttySelection + ptr := Alloc(allocationSize) + if ptr == nil { + return nil, &Error{Result: ResultOutOfMemory} + } + defer Free(ptr, allocationSize) + + buffer.ptr = (*C.GhosttySelection)(ptr) + buffer.cap = C.size_t(capacity) + buffer.len = 0 + if err := s.get(data, unsafe.Pointer(&buffer)); err != nil { + return nil, err + } + length := int(buffer.len) + matches := make([]Selection, length) + for i, value := range unsafe.Slice(buffer.ptr, length) { + matches[i] = selectionFromC(value) + } + return matches, nil +} + +// set writes one search option. +func (s *Search) set(option C.GhosttySearchOption, value unsafe.Pointer) error { + return resultError(C.ghostty_search_set(s.ptr, option, value)) +} + +// get reads one search data field. +func (s *Search) get(data SearchData, value unsafe.Pointer) error { + return resultError(C.ghostty_search_get( + s.ptr, + C.GhosttySearchData(data), + value, + )) +} + +// isResult reports whether err is a libghostty Error with the requested +// result code. +func isResult(err error, result Result) bool { + var ghosttyErr *Error + return errors.As(err, &ghosttyErr) && ghosttyErr.Result == result +} diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..fb596db --- /dev/null +++ b/search_test.go @@ -0,0 +1,214 @@ +package libghostty + +import ( + "testing" + "unsafe" +) + +func TestSearchLifecycleAndResults(t *testing.T) { + term, err := NewTerminal( + WithSize(10, 4), + WithMaxScrollbackLines(100), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + term.VTWrite([]byte("Fizz\r\nBuzz\r\nFIZZ")) + + search, err := NewSearch(term) + if err != nil { + t.Fatal(err) + } + defer search.Close() + + status, err := search.Status() + if err != nil { + t.Fatal(err) + } + if status != SearchStatusComplete { + t.Fatalf("expected idle search to be complete, got %d", status) + } + needle, err := search.Needle() + if err != nil { + t.Fatal(err) + } + if needle != nil { + t.Fatalf("expected no idle needle, got %q", needle) + } + + if err := search.SetNeedle([]byte("fizz")); err != nil { + t.Fatal(err) + } + + status, err = search.Tick() + if err != nil { + t.Fatal(err) + } + if status != SearchStatusFeedRequired { + t.Fatalf("expected a feed-required search, got %d", status) + } + if err := search.Run(); err != nil { + t.Fatal(err) + } + + matchCount, err := search.MatchCount() + if err != nil { + t.Fatal(err) + } + if matchCount != 2 { + t.Fatalf("expected 2 matches, got %d", matchCount) + } + if index, err := search.SelectedIndex(); err != nil { + t.Fatal(err) + } else if index != nil { + t.Fatalf("expected no initial selection, got index %d", *index) + } + matches, err := search.Matches() + if err != nil { + t.Fatal(err) + } + if len(matches) != 2 { + t.Fatalf("expected 2 match snapshots, got %d", len(matches)) + } + newestPoint, err := term.PointFromGridRef(&matches[0].Start, PointTagActive) + if err != nil { + t.Fatal(err) + } + oldestPoint, err := term.PointFromGridRef(&matches[1].Start, PointTagActive) + if err != nil { + t.Fatal(err) + } + if newestPoint.Y != 2 || oldestPoint.Y != 0 { + t.Fatalf("expected newest-to-oldest rows 2,0; got %d,%d", newestPoint.Y, oldestPoint.Y) + } + + viewportMatches, err := search.ViewportMatches() + if err != nil { + t.Fatal(err) + } + if len(viewportMatches) != 2 { + t.Fatalf("expected 2 viewport matches, got %d", len(viewportMatches)) + } + + if err := search.SelectNext(); err != nil { + t.Fatal(err) + } + index, err := search.SelectedIndex() + if err != nil { + t.Fatal(err) + } + if index == nil || *index != 0 { + t.Fatalf("expected selected index 0, got %v", index) + } + selectedMatch, err := search.SelectedMatch() + if err != nil { + t.Fatal(err) + } + if selectedMatch == nil { + t.Fatal("expected a selected match") + } + + var rawMatchCount, rawSelectedIndex uint + written, err := search.GetMulti( + []SearchData{SearchDataMatchCount, SearchDataSelectedIndex}, + []unsafe.Pointer{unsafe.Pointer(&rawMatchCount), unsafe.Pointer(&rawSelectedIndex)}, + ) + if err != nil { + t.Fatal(err) + } + if written != 2 || rawMatchCount != 2 || rawSelectedIndex != 0 { + t.Fatalf( + "unexpected batched values: written=%d matches=%d index=%d", + written, + rawMatchCount, + rawSelectedIndex, + ) + } + + if err := search.SelectNext(); err != nil { + t.Fatal(err) + } + index, err = search.SelectedIndex() + if err != nil { + t.Fatal(err) + } + if index == nil || *index != 1 { + t.Fatalf("expected selected index 1, got %v", index) + } + if err := search.SelectPrevious(); err != nil { + t.Fatal(err) + } + index, err = search.SelectedIndex() + if err != nil { + t.Fatal(err) + } + if index == nil || *index != 0 { + t.Fatalf("expected selected index 0 after previous, got %v", index) + } + + if policy, err := search.ScrollPolicy(); err != nil { + t.Fatal(err) + } else if policy != SearchScrollPolicyIfNeeded { + t.Fatalf("expected default scroll policy, got %d", policy) + } + if err := search.SetScrollPolicy(SearchScrollPolicyNever); err != nil { + t.Fatal(err) + } + if policy, err := search.ScrollPolicy(); err != nil { + t.Fatal(err) + } else if policy != SearchScrollPolicyNever { + t.Fatalf("expected no-scroll policy, got %d", policy) + } + if err := search.SetScrollPolicy(SearchScrollPolicyIfNeeded); err != nil { + t.Fatal(err) + } + if policy, err := search.ScrollPolicy(); err != nil { + t.Fatal(err) + } else if policy != SearchScrollPolicyIfNeeded { + t.Fatalf("expected restored scroll policy, got %d", policy) + } + + if err := search.SetNeedle(nil); err != nil { + t.Fatal(err) + } + if needle, err := search.Needle(); err != nil { + t.Fatal(err) + } else if needle != nil { + t.Fatalf("expected cleared needle, got %q", needle) + } + if err := search.SelectNext(); err == nil { + t.Fatal("expected selecting without matches to fail") + } else { + assertResultError(t, err, ResultNoValue) + } +} + +func TestSearchTerminalMayCloseFirst(t *testing.T) { + term, err := NewTerminal(WithSize(10, 4)) + if err != nil { + t.Fatal(err) + } + term.VTWrite([]byte("searchable")) + + search, err := NewSearch(term) + if err != nil { + t.Fatal(err) + } + if err := search.SetNeedle([]byte("search")); err != nil { + t.Fatal(err) + } + if err := search.Run(); err != nil { + t.Fatal(err) + } + + term.Close() + assertResultError(t, search.Feed(), ResultInvalidValue) + if needle, err := search.Needle(); err != nil { + t.Fatal(err) + } else if string(needle) != "search" { + t.Fatalf("expected retained needle after terminal close, got %q", needle) + } + search.Close() +} diff --git a/terminal.go b/terminal.go index f11b8cd..7e5a95d 100644 --- a/terminal.go +++ b/terminal.go @@ -76,6 +76,11 @@ type TerminalConfig struct { // disabled; zero also explicitly disables tracking. ContinuationMaxBytes *uint + // KittyClipboardWriteMaxBytes is the maximum number of decoded bytes in a + // single Kitty clipboard protocol write. Nil retains libghostty's default + // limit of 64 MiB. The maximum uint value removes the limit. + KittyClipboardWriteMaxBytes *uint + // TitleReport optionally enables replies to CSI 21 t title queries. Nil // retains the secure disabled default. TitleReport *bool @@ -239,7 +244,10 @@ type ClipboardRead struct { Name string // Granted reports that a prior session grant already authorizes this - // request, so an embedder should skip its permission prompt. + // request, so an application should skip its permission prompt. Granted is + // always false when MIMEs is empty. A request with List set and no MIMEs + // only asks which MIME types are available and should be served without + // prompting. Granted bool // CanRemember reports that a successful reply may set Remember to record a @@ -488,6 +496,16 @@ func WithContinuationMaxBytes(limit uint) TerminalOption { } } +// WithKittyClipboardWriteMaxBytes sets the maximum number of decoded bytes in +// a single Kitty clipboard protocol write. This option applies to OSC 5522 and +// does not affect OSC 52. The default limit is 64 MiB. The maximum uint value +// removes the limit. +func WithKittyClipboardWriteMaxBytes(limit uint) TerminalOption { + return func(c *TerminalConfig) { + c.KittyClipboardWriteMaxBytes = &limit + } +} + // WithTitleReport enables or disables replies to CSI 21 t window-title // queries. Reporting is disabled by default because a program can otherwise // query a title it previously set and inject that text into the pty stream. @@ -707,6 +725,12 @@ func NewTerminal(opts ...TerminalOption) (*Terminal, error) { // exposed for live terminals. The terminal is already wrapped so failures // can use the normal Close path to release both the C terminal and userdata // handle. + if cfg.KittyClipboardWriteMaxBytes != nil { + if err := t.SetKittyClipboardWriteMaxBytes(cfg.KittyClipboardWriteMaxBytes); err != nil { + t.Close() + return nil, err + } + } if cfg.TitleReport != nil { if err := t.SetTitleReport(*cfg.TitleReport); err != nil { t.Close() diff --git a/terminal_data.go b/terminal_data.go index 56c0094..0ca7e79 100644 --- a/terminal_data.go +++ b/terminal_data.go @@ -172,6 +172,10 @@ const ( // TerminalDataCursorAtPrompt indicates whether the cursor is currently at // a semantic shell prompt or input area (bool). TerminalDataCursorAtPrompt TerminalData = C.GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT + + // TerminalDataKittyClipboardWriteMaxBytes is the configured maximum number + // of decoded bytes in a single Kitty clipboard protocol write (size_t). + TerminalDataKittyClipboardWriteMaxBytes TerminalData = C.GHOSTTY_TERMINAL_DATA_CLIPBOARD_WRITE_MAX_BYTES ) // ActiveScreen returns which screen buffer is currently active. @@ -365,6 +369,21 @@ func (t *Terminal) HeightPx() (uint32, error) { return uint32(v), nil } +// KittyClipboardWriteMaxBytes returns the maximum number of decoded bytes in a +// single Kitty clipboard protocol write. The limit applies to OSC 5522 and not +// OSC 52. +func (t *Terminal) KittyClipboardWriteMaxBytes() (uint, error) { + var limit C.size_t + if err := resultError(C.ghostty_terminal_get( + t.ptr, + C.GHOSTTY_TERMINAL_DATA_CLIPBOARD_WRITE_MAX_BYTES, + unsafe.Pointer(&limit), + )); err != nil { + return 0, err + } + return uint(limit), nil +} + // KittyKeyboardFlags returns the current Kitty keyboard protocol flags. func (t *Terminal) KittyKeyboardFlags() (KittyKeyFlags, error) { var v C.uint8_t diff --git a/terminal_opt.go b/terminal_opt.go index bd26382..094576e 100644 --- a/terminal_opt.go +++ b/terminal_opt.go @@ -287,6 +287,26 @@ func (t *Terminal) SetSelection(sel *Selection) error { )) } +// SetKittyClipboardWriteMaxBytes sets the maximum number of decoded bytes in +// new Kitty clipboard protocol writes. A write that is already in progress +// keeps the limit that was active when it started. This setting applies to OSC +// 5522 and does not affect OSC 52. +// +// Pass nil to restore the default limit of 64 MiB. Pass a pointer to the +// maximum uint value to remove the limit. +func (t *Terminal) SetKittyClipboardWriteMaxBytes(limit *uint) error { + var cValue unsafe.Pointer + if limit != nil { + cLimit := C.size_t(*limit) + cValue = unsafe.Pointer(&cLimit) + } + return resultError(C.ghostty_terminal_set( + t.ptr, + C.GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES, + cValue, + )) +} + // SetKittyImageStorageLimit sets the Kitty image storage limit in bytes. // Applied to all initialized screens (primary and alternate). A value of // zero disables the Kitty graphics protocol entirely, deleting all stored diff --git a/terminal_opt_test.go b/terminal_opt_test.go index cd936a7..a924e46 100644 --- a/terminal_opt_test.go +++ b/terminal_opt_test.go @@ -94,6 +94,37 @@ func TestTerminalSetAPCMaxBytes(t *testing.T) { } } +func TestTerminalKittyClipboardWriteMaxBytes(t *testing.T) { + term, err := NewTerminal( + WithSize(80, 24), + WithKittyClipboardWriteMaxBytes(4), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + limit, err := term.KittyClipboardWriteMaxBytes() + if err != nil { + t.Fatal(err) + } + if limit != 4 { + t.Fatalf("expected constructor limit 4, got %d", limit) + } + + if err := term.SetKittyClipboardWriteMaxBytes(nil); err != nil { + t.Fatal(err) + } + limit, err = term.KittyClipboardWriteMaxBytes() + if err != nil { + t.Fatal(err) + } + const defaultLimit = 64 * 1024 * 1024 + if limit != defaultLimit { + t.Fatalf("expected default limit %d, got %d", defaultLimit, limit) + } +} + func TestTerminalWithBell(t *testing.T) { var bellCount int term, err := NewTerminal(WithSize(80, 24), WithBell(func(_ *Terminal) { -- 2.51.2