diff --git a/formatter.go b/formatter.go --- a/formatter.go +++ b/formatter.go @@ -37,7 +37,8 @@ // formatterOpts wraps the C options struct so that functional options // 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. @@ -63,6 +64,21 @@ // non-blank lines. func WithFormatterTrim(trim bool) FormatterOption { return func(o *formatterOpts) { o.c.trim = C.bool(trim) + } +} + +// 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 } } @@ -159,6 +175,30 @@ o.c.extra.screen.charsets = C.bool(v) } } +// 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 @@ fo := formatterOpts{c: C.init_formatter_terminal_options()} 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 --- a/formatter_test.go +++ b/formatter_test.go @@ -156,6 +156,69 @@ t.Fatal("first format should not contain text written afterward") } } +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 {