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) {