From ec47342cd48f60a9b18db83de52dadc0bed87166 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Aug 2026 09:24:15 -0700 Subject: [PATCH] formatter: expose selection option The Go formatter options previously omitted the native terminal formatter selection field, so callers could not restrict formatter output while emitting terminal extras. Add WithFormatterSelection and materialize the selection in temporary C-owned memory for the constructor. The native formatter copies the selection during creation; document the lifetime of its borrowed grid references. Add coverage for selection, terminal extras, and constructor copy semantics. --- formatter.go | 47 ++++++++++++++++++++++++++++++++++- formatter_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/formatter.go b/formatter.go index e3724e0..be4ed55 100644 --- a/formatter.go +++ b/formatter.go @@ -37,7 +37,8 @@ const ( // can mutate it directly. Only fields explicitly set by an option are // modified; everything else retains the GHOSTTY_INIT_SIZED defaults. type formatterOpts struct { - c C.GhosttyFormatterTerminalOptions + c C.GhosttyFormatterTerminalOptions + selection *Selection } // FormatterOption is a functional option for configuring a Formatter. @@ -66,6 +67,21 @@ func WithFormatterTrim(trim bool) FormatterOption { } } +// WithFormatterSelection restricts formatter output to sel. Passing nil +// formats the entire active screen. +// +// The selection must come from the same terminal passed to [NewFormatter] and +// must still be valid when NewFormatter is called. NewFormatter copies the +// selection immediately, so the Selection value itself does not need to +// outlive that call. The copied selection still contains borrowed grid +// references into the terminal; as with every [Selection], later terminal +// mutations may invalidate those references. +func WithFormatterSelection(sel *Selection) FormatterOption { + return func(o *formatterOpts) { + o.selection = sel + } +} + // WithFormatterExtraPalette emits the palette using OSC 4 sequences. func WithFormatterExtraPalette(v bool) FormatterOption { return func(o *formatterOpts) { @@ -159,6 +175,30 @@ func WithFormatterExtraCharsets(v bool) FormatterOption { } } +// prepare materializes the selection option in C-owned memory for the +// formatter constructor. libghostty copies the selection into the formatter +// during ghostty_formatter_terminal_new, so this allocation only needs to +// remain alive for that call. Using C-owned memory also avoids placing a Go +// pointer inside the C options struct passed through cgo. +func (o *formatterOpts) prepare() (func(), error) { + if o.selection == nil { + return func() {}, nil + } + + size := uintptr(C.sizeof_GhosttySelection) + ptr := Alloc(size) + if ptr == nil { + return nil, &Error{Result: ResultOutOfMemory} + } + csel := (*C.GhosttySelection)(ptr) + *csel = o.selection.toC() + o.c.selection = csel + + return func() { + Free(ptr, size) + }, nil +} + // Formatter wraps a Ghostty formatter handle that can produce // plain text, VT sequences, or HTML from a terminal's current state. // The formatter stores a borrowed reference to a terminal, so the @@ -184,6 +224,11 @@ func NewFormatter(t *Terminal, opts ...FormatterOption) (*Formatter, error) { for _, opt := range opts { opt(&fo) } + cleanup, err := fo.prepare() + if err != nil { + return nil, err + } + defer cleanup() var ptr C.GhosttyFormatter if err := resultError(C.ghostty_formatter_terminal_new(nil, &ptr, t.ptr, fo.c)); err != nil { diff --git a/formatter_test.go b/formatter_test.go index 0d7d8bb..ef0ad84 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -156,6 +156,69 @@ func TestFormatterReflectsCurrentState(t *testing.T) { } } +func TestFormatterSelection(t *testing.T) { + term, err := NewTerminal(WithSize(20, 3)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + term.VTWrite([]byte("first\r\nsecond\r\nthird")) + + secondRef, err := term.GridRef(Point{Tag: PointTagActive, X: 2, Y: 1}) + if err != nil { + t.Fatal(err) + } + second, err := term.SelectLine(SelectLineOptions{Ref: secondRef}) + if err != nil { + t.Fatal(err) + } + if second == nil { + t.Fatal("expected second-line selection") + } + + f, err := NewFormatter( + term, + WithFormatterFormat(FormatterFormatVT), + WithFormatterTrim(true), + WithFormatterSelection(second), + WithFormatterExtraCursor(true), + ) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + // The native formatter copies the selection during construction. Changing + // the caller's Go value afterward must not change the formatter's range. + firstRef, err := term.GridRef(Point{Tag: PointTagActive, X: 2, Y: 0}) + if err != nil { + t.Fatal(err) + } + first, err := term.SelectLine(SelectLineOptions{Ref: firstRef}) + if err != nil { + t.Fatal(err) + } + if first == nil { + t.Fatal("expected first-line selection") + } + *second = *first + + out, err := f.FormatString() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "second") { + t.Fatalf("expected selected line in formatter output, got %q", out) + } + if strings.Contains(out, "first") || strings.Contains(out, "third") { + t.Fatalf("expected output to be restricted to the selected line, got %q", out) + } + if !strings.Contains(out, "\x1b[") { + t.Fatalf("expected VT terminal extras alongside selected output, got %q", out) + } +} + func TestFormatterWriteTo(t *testing.T) { term, err := NewTerminal(WithSize(80, 24)) if err != nil { -- 2.51.2