From 4704bf9ead8f99393cfa84fc1d0b488c9993d3c7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 27 Jul 2026 08:38:00 -0700 Subject: [PATCH] update libghostty and bind missing APIs Update the pinned Ghostty revision and Zig overlay for the upstream Zig 0.16 requirement. Adapt terminal construction and configuration to the new scrollback limit ABI and temporary-file policy. Bind the remaining native OSC, SGR, color, Unicode, formatter, render, reporting, compression, and metadata APIs. Add typed accessors and tests, leaving only WebAssembly-only allocation helpers unbound. --- CMakeLists.txt | 2 +- TODO.md | 30 ----- color.go | 204 ++++++++++++++++++++++++++++++ color_scheme.go | 40 ++++++ color_scheme_test.go | 13 ++ color_test.go | 92 ++++++++++++++ doc.go | 2 +- examples/colors/main.go | 2 +- examples/render/main.go | 4 +- flake.lock | 6 +- flake.nix | 4 +- formatter.go | 20 +++ formatter_test.go | 34 +++++ grid_ref_test.go | 4 +- kitty_graphics.go | 55 +++++++++ kitty_graphics_test.go | 11 +- mode.go | 44 ++++++- mode_test.go | 15 +++ osc.go | 169 +++++++++++++++++++++++++ osc_test.go | 31 +++++ render_state.go | 13 ++ render_state_test.go | 29 +++++ selection_test.go | 2 +- sgr.go | 267 ++++++++++++++++++++++++++++++++++++++++ sgr_test.go | 69 +++++++++++ size_report.go | 41 ++++++ size_report_test.go | 16 +++ style.go | 36 ++++-- style_test.go | 13 ++ terminal.go | 180 ++++++++++++++++++++++++--- terminal_data.go | 116 ++++++++++++++++- terminal_data_test.go | 20 ++- terminal_effect.go | 17 +++ terminal_opt.go | 134 +++++++++++++++++--- terminal_opt_test.go | 124 +++++++++++++++++++ terminal_test.go | 2 +- types.go | 6 + types_test.go | 20 +++ unicode.go | 35 ++++++ unicode_test.go | 25 ++++ 40 files changed, 1849 insertions(+), 98 deletions(-) create mode 100644 color_scheme.go create mode 100644 color_scheme_test.go create mode 100644 color_test.go create mode 100644 osc.go create mode 100644 osc_test.go create mode 100644 sgr.go create mode 100644 sgr_test.go create mode 100644 size_report_test.go create mode 100644 style_test.go create mode 100644 types_test.go create mode 100644 unicode.go create mode 100644 unicode_test.go diff --git a/CMakeLists.txt b/CMakeLists.txt index a32d326..bb90aca 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 d4ac93a0395d321b043ee0116dc8a1a384f0fb83 + GIT_TAG 5a35415a5d59a117e654735ca5a01f876dec5841 ) FetchContent_MakeAvailable(ghostty) diff --git a/TODO.md b/TODO.md index e473e15..789a57f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,26 +1,5 @@ # Missing APIs -## Not Bound - -- [ ] OSC parser (`osc.h`) - - `ghostty_osc_new()` - - `ghostty_osc_free()` - - `ghostty_osc_reset()` - - `ghostty_osc_next()` - - `ghostty_osc_end()` - - `ghostty_osc_command_type()` - - `ghostty_osc_command_data()` -- [ ] SGR parser (`sgr.h`) - - `ghostty_sgr_new()` - - `ghostty_sgr_free()` - - `ghostty_sgr_reset()` - - `ghostty_sgr_set_params()` - - `ghostty_sgr_next()` - - `ghostty_sgr_unknown_full()` - - `ghostty_sgr_unknown_partial()` - - `ghostty_sgr_attribute_tag()` - - `ghostty_sgr_attribute_value()` - ## WASM-only Not Bound These APIs are guarded by `#ifdef __wasm__` upstream and are not available in the native cgo build. @@ -38,12 +17,3 @@ These APIs are guarded by `#ifdef __wasm__` upstream and are not available in th - `ghostty_wasm_free_usize()` - `ghostty_wasm_alloc_sgr_attribute()` (`sgr.h`) - `ghostty_wasm_free_sgr_attribute()` (`sgr.h`) - -## Partially Bound - -- [ ] `ghostty_mode_report_encode()` -- [ ] `ghostty_size_report_encode()` -- [ ] `ghostty_type_json()` -- [ ] `ghostty_style_default()` -- [ ] `ghostty_color_rgb_get()` -- [ ] `ghostty_formatter_format_buf()` diff --git a/color.go b/color.go index 9f3aa58..d11ce3e 100644 --- a/color.go +++ b/color.go @@ -5,11 +5,18 @@ package libghostty */ import "C" +import "unsafe" + // ColorRGB represents an RGB color value. // C: GhosttyColorRgb type ColorRGB struct { + // R is the red component. R uint8 + + // G is the green component. G uint8 + + // B is the blue component. B uint8 } @@ -19,6 +26,39 @@ const PaletteSize = 256 // Palette is a 256-color palette. type Palette [PaletteSize]ColorRGB +// ColorPaletteMask identifies palette entries that palette generation should +// preserve from its base palette. +// C: GhosttyColorPaletteMask +type ColorPaletteMask struct { + // Bits stores the 256-bit mask as four consecutive 64-bit words. + Bits [4]uint64 +} + +// Set marks index as preserved during palette generation. +func (m *ColorPaletteMask) Set(index uint8) { + m.Bits[index>>6] |= uint64(1) << (index & 63) +} + +// Unset clears index from the set preserved during palette generation. +func (m *ColorPaletteMask) Unset(index uint8) { + m.Bits[index>>6] &^= uint64(1) << (index & 63) +} + +// IsSet reports whether index is preserved during palette generation. +func (m *ColorPaletteMask) IsSet(index uint8) bool { + return m.Bits[index>>6]&(uint64(1)<<(index&63)) != 0 +} + +// X11Color is one entry in Ghostty's embedded X11 rgb.txt table. +// C: GhosttyColorX11Entry +type X11Color struct { + // Name is the exact spelling stored in the X11 color table. + Name string + + // Color is the RGB value associated with Name. + Color ColorRGB +} + // Named color palette indices. // C: GHOSTTY_COLOR_NAMED_* const ( @@ -39,3 +79,167 @@ const ( ColorNamedBrightCyan = C.GHOSTTY_COLOR_NAMED_BRIGHT_CYAN ColorNamedBrightWhite = C.GHOSTTY_COLOR_NAMED_BRIGHT_WHITE ) + +// Components returns the red, green, and blue components through +// ghostty_color_rgb_get. The fields on ColorRGB expose the same values +// directly; this method is useful for consumers mirroring the C API. +func (c ColorRGB) Components() (r, g, b uint8) { + cc := c.toC() + var cr, cg, cb C.uint8_t + C.ghostty_color_rgb_get(&cc, &cr, &cg, &cb) + return uint8(cr), uint8(cg), uint8(cb) +} + +// ParseColor parses a color using Ghostty's config and theme syntax. +func ParseColor(value string) (ColorRGB, error) { + var out C.GhosttyColorRgb + if err := resultError(C.ghostty_color_parse( + (*C.char)(unsafe.Pointer(unsafe.StringData(value))), + C.size_t(len(value)), + &out, + )); err != nil { + return ColorRGB{}, err + } + return colorRGBFromC(out), nil +} + +// ParseX11Color parses an X11 color name using Ghostty's embedded rgb.txt +// table. Matching is ASCII case-insensitive. +func ParseX11Color(name string) (ColorRGB, error) { + var out C.GhosttyColorRgb + if err := resultError(C.ghostty_color_parse_x11( + (*C.char)(unsafe.Pointer(unsafe.StringData(name))), + C.size_t(len(name)), + &out, + )); err != nil { + return ColorRGB{}, err + } + return colorRGBFromC(out), nil +} + +// ParsePaletteEntry parses a Ghostty palette override in INDEX=COLOR form. +func ParsePaletteEntry(value string) (uint8, ColorRGB, error) { + var index C.uint8_t + var color C.GhosttyColorRgb + if err := resultError(C.ghostty_color_parse_palette_entry( + (*C.char)(unsafe.Pointer(unsafe.StringData(value))), + C.size_t(len(value)), + &index, + &color, + )); err != nil { + return 0, ColorRGB{}, err + } + return uint8(index), colorRGBFromC(color), nil +} + +// DefaultPalette returns Ghostty's built-in 256-color palette. +func DefaultPalette() Palette { + var out [PaletteSize]C.GhosttyColorRgb + C.ghostty_color_palette_default(&out[0]) + return paletteFromC(&out) +} + +// GeneratePalette derives the 216-color cube and grayscale ramp from a base +// palette, background, and foreground. A nil base uses Ghostty's default +// palette. A nil skip mask preserves no additional indices. +func GeneratePalette( + base *Palette, + skip *ColorPaletteMask, + background ColorRGB, + foreground ColorRGB, + harmonious bool, +) Palette { + var cbase [PaletteSize]C.GhosttyColorRgb + var basePtr *C.GhosttyColorRgb + if base != nil { + paletteToC(base, &cbase) + basePtr = &cbase[0] + } + + var cskip C.GhosttyColorPaletteMask + var skipPtr *C.GhosttyColorPaletteMask + if skip != nil { + for i, bits := range skip.Bits { + cskip.bits[i] = C.uint64_t(bits) + } + skipPtr = &cskip + } + + bg := background.toC() + fg := foreground.toC() + var out [PaletteSize]C.GhosttyColorRgb + C.ghostty_color_palette_generate( + basePtr, + skipPtr, + &bg, + &fg, + C.bool(harmonious), + &out[0], + ) + return paletteFromC(&out) +} + +// Luminance returns the W3C relative luminance in the range 0 to 1. +func (c ColorRGB) Luminance() float64 { + cc := c.toC() + return float64(C.ghostty_color_luminance(&cc)) +} + +// PerceivedLuminance returns Ghostty's perceived luminance in the range 0 +// to 1. Ghostty treats backgrounds above 0.5 as light. +func (c ColorRGB) PerceivedLuminance() float64 { + cc := c.toC() + return float64(C.ghostty_color_perceived_luminance(&cc)) +} + +// Contrast returns the WCAG contrast ratio between c and other. +func (c ColorRGB) Contrast(other ColorRGB) float64 { + a := c.toC() + b := other.toC() + return float64(C.ghostty_color_contrast(&a, &b)) +} + +// X11Colors returns a Go-owned copy of Ghostty's X11 color name table in +// rgb.txt order. +func X11Colors() []X11Color { + count := int(C.ghostty_color_x11_name_count()) + entries := unsafe.Slice(C.ghostty_color_x11_names(), count) + result := make([]X11Color, count) + for i, entry := range entries { + result[i] = X11Color{ + Name: C.GoString(entry.name), + Color: colorRGBFromC(entry.color), + } + } + return result +} + +// toC converts a Go RGB value to GhosttyColorRgb. +func (c ColorRGB) toC() C.GhosttyColorRgb { + return C.GhosttyColorRgb{ + r: C.uint8_t(c.R), + g: C.uint8_t(c.G), + b: C.uint8_t(c.B), + } +} + +// colorRGBFromC converts GhosttyColorRgb to its Go representation. +func colorRGBFromC(c C.GhosttyColorRgb) ColorRGB { + return ColorRGB{R: uint8(c.r), G: uint8(c.g), B: uint8(c.b)} +} + +// paletteToC copies a Go palette into C-compatible storage. +func paletteToC(src *Palette, dst *[PaletteSize]C.GhosttyColorRgb) { + for i, color := range src { + dst[i] = color.toC() + } +} + +// paletteFromC copies C-compatible palette storage into a Go palette. +func paletteFromC(src *[PaletteSize]C.GhosttyColorRgb) Palette { + var result Palette + for i, color := range src { + result[i] = colorRGBFromC(color) + } + return result +} diff --git a/color_scheme.go b/color_scheme.go new file mode 100644 index 0000000..037082f --- /dev/null +++ b/color_scheme.go @@ -0,0 +1,40 @@ +package libghostty + +// Color scheme report encoding from color_scheme.h. + +/* +#include +*/ +import "C" + +import "unsafe" + +// ColorSchemeReportEncode encodes a color scheme report escape sequence. +func ColorSchemeReportEncode(scheme ColorScheme) ([]byte, error) { + var buf [32]byte + var written C.size_t + result := C.ghostty_color_scheme_report_encode( + C.GhosttyColorScheme(scheme), + (*C.char)(unsafe.Pointer(&buf[0])), + C.size_t(len(buf)), + &written, + ) + if result == C.GHOSTTY_SUCCESS { + return append([]byte(nil), buf[:int(written)]...), nil + } + if result != C.GHOSTTY_OUT_OF_SPACE { + return nil, &Error{Result: Result(result)} + } + + out := make([]byte, int(written)) + var outWritten C.size_t + if err := resultError(C.ghostty_color_scheme_report_encode( + C.GhosttyColorScheme(scheme), + (*C.char)(unsafe.Pointer(&out[0])), + C.size_t(len(out)), + &outWritten, + )); err != nil { + return nil, err + } + return out[:int(outWritten)], nil +} diff --git a/color_scheme_test.go b/color_scheme_test.go new file mode 100644 index 0000000..e37f99e --- /dev/null +++ b/color_scheme_test.go @@ -0,0 +1,13 @@ +package libghostty + +import "testing" + +func TestColorSchemeReportEncode(t *testing.T) { + got, err := ColorSchemeReportEncode(ColorSchemeDark) + if err != nil { + t.Fatal(err) + } + if want := "\x1b[?997;1n"; string(got) != want { + t.Fatalf("expected %q, got %q", want, got) + } +} diff --git a/color_test.go b/color_test.go new file mode 100644 index 0000000..a4cf4ff --- /dev/null +++ b/color_test.go @@ -0,0 +1,92 @@ +package libghostty + +import ( + "math" + "testing" +) + +func TestColorParseAndComponents(t *testing.T) { + color, err := ParseColor("#abc") + if err != nil { + t.Fatal(err) + } + if want := (ColorRGB{R: 0xaa, G: 0xbb, B: 0xcc}); color != want { + t.Fatalf("expected %#v, got %#v", want, color) + } + + r, g, b := color.Components() + if r != color.R || g != color.G || b != color.B { + t.Fatalf("components returned %d,%d,%d for %#v", r, g, b, color) + } + + x11, err := ParseX11Color("ForestGreen") + if err != nil { + t.Fatal(err) + } + if want := (ColorRGB{R: 34, G: 139, B: 34}); x11 != want { + t.Fatalf("expected %#v, got %#v", want, x11) + } + + if _, err := ParseColor("not a real color"); err == nil { + t.Fatal("expected invalid color to fail") + } +} + +func TestColorPaletteUtilities(t *testing.T) { + index, color, err := ParsePaletteEntry("0x10=#282c34") + if err != nil { + t.Fatal(err) + } + if index != 16 || color != (ColorRGB{R: 0x28, G: 0x2c, B: 0x34}) { + t.Fatalf("unexpected palette entry %d=%#v", index, color) + } + + base := DefaultPalette() + base[20] = ColorRGB{R: 1, G: 2, B: 3} + var skip ColorPaletteMask + skip.Set(20) + if !skip.IsSet(20) { + t.Fatal("expected palette mask bit to be set") + } + + generated := GeneratePalette( + &base, + &skip, + ColorRGB{R: 20, G: 20, B: 20}, + ColorRGB{R: 230, G: 230, B: 230}, + true, + ) + if generated[20] != base[20] { + t.Fatalf("expected skipped entry %#v, got %#v", base[20], generated[20]) + } + + skip.Unset(20) + if skip.IsSet(20) { + t.Fatal("expected palette mask bit to be unset") + } +} + +func TestColorMathAndX11Names(t *testing.T) { + black := ColorRGB{} + white := ColorRGB{R: 255, G: 255, B: 255} + if got := black.Luminance(); got != 0 { + t.Fatalf("expected black luminance 0, got %f", got) + } + if got := white.Luminance(); math.Abs(got-1) > 1e-12 { + t.Fatalf("expected white luminance 1, got %f", got) + } + if got := black.Contrast(white); math.Abs(got-21) > 1e-12 { + t.Fatalf("expected black/white contrast 21, got %f", got) + } + if black.PerceivedLuminance() >= white.PerceivedLuminance() { + t.Fatal("expected white to have greater perceived luminance") + } + + names := X11Colors() + if len(names) == 0 { + t.Fatal("expected X11 color names") + } + if names[0].Name == "" { + t.Fatal("expected first X11 color name to be non-empty") + } +} diff --git a/doc.go b/doc.go index d05b336..fbb6b83 100644 --- a/doc.go +++ b/doc.go @@ -11,7 +11,7 @@ // // term, err := libghostty.NewTerminal( // libghostty.WithSize(80, 24), -// libghostty.WithMaxScrollback(1000), +// libghostty.WithMaxScrollbackLines(1000), // ) // if err != nil { // log.Fatal(err) diff --git a/examples/colors/main.go b/examples/colors/main.go index 4673f3d..b268d39 100644 --- a/examples/colors/main.go +++ b/examples/colors/main.go @@ -15,7 +15,7 @@ func main() { // Step 1: Create an 80×24 terminal with no scrollback. t, err := ghostty.NewTerminal( ghostty.WithSize(80, 24), - ghostty.WithMaxScrollback(0), + ghostty.WithMaxScrollbackBytes(0), ) if err != nil { log.Fatal(err) diff --git a/examples/render/main.go b/examples/render/main.go index edae0fa..bbf9f4e 100644 --- a/examples/render/main.go +++ b/examples/render/main.go @@ -40,10 +40,10 @@ func cursorStyleName(s libghostty.CursorVisualStyle) string { } func main() { - // 1. Create terminal 40x5 with scrollback 10000. + // 1. Create terminal 40x5 with up to 10000 scrollback lines. term, err := libghostty.NewTerminal( libghostty.WithSize(40, 5), - libghostty.WithMaxScrollback(10000), + libghostty.WithMaxScrollbackLines(10000), ) if err != nil { log.Fatal(err) diff --git a/flake.lock b/flake.lock index 3951837..24214a3 100644 --- a/flake.lock +++ b/flake.lock @@ -94,11 +94,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1776789209, - "narHash": "sha256-G6B7Q4TXn7MZ1mB+f9rymjsYF5PLWoSvmbxijb/99bw=", + "lastModified": 1785069119, + "narHash": "sha256-waxzKvqWgk9gED1AmoE00cstQtIOUKnH/wdO3R94h8I=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "14fe971844e841297ddd2ce9783d6892b467af39", + "rev": "a5f92421b7364477a5246c6ca8444498d784b444", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 63f9a5f..9a0441e 100644 --- a/flake.nix +++ b/flake.nix @@ -26,9 +26,7 @@ pkgs.go pkgs.pinact pkgs.pkg-config - (if pkgs.stdenv.isDarwin - then zig.packages.${system}.brew."0.15.2" - else zig.packages.${system}."0.15.2") + zig.packages.${system}."0.16.0" ]; shellHook = '' diff --git a/formatter.go b/formatter.go index 3b864f0..1d9ec46 100644 --- a/formatter.go +++ b/formatter.go @@ -215,6 +215,26 @@ func (f *Formatter) Format() ([]byte, error) { return C.GoBytes(unsafe.Pointer(outPtr), C.int(outLen)), nil } +// FormatBuf formats into buf and returns the number of bytes written. If buf +// is too small, the returned count is the required size and the error has +// result [ResultOutOfSpace]. A nil buffer can be used to query the required +// size. +func (f *Formatter) FormatBuf(buf []byte) (int, error) { + var ptr *C.uint8_t + if len(buf) > 0 { + ptr = (*C.uint8_t)(unsafe.Pointer(&buf[0])) + } + + var written C.size_t + result := C.ghostty_formatter_format_buf( + f.ptr, + ptr, + C.size_t(len(buf)), + &written, + ) + return int(written), resultError(result) +} + // FormatString runs the formatter and returns the output as a string. // This is a convenience wrapper around Format. func (f *Formatter) FormatString() (string, error) { diff --git a/formatter_test.go b/formatter_test.go index b6366c3..203c97e 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -2,6 +2,7 @@ package libghostty import ( "bytes" + "errors" "io" "strings" "testing" @@ -174,3 +175,36 @@ func TestFormatterWriteTo(t *testing.T) { t.Fatalf("expected output to contain 'writeto test', got %q", buf.String()) } } + +func TestFormatterFormatBuf(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + term.VTWrite([]byte("buffer format")) + + f, err := NewFormatter(term) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + required, err := f.FormatBuf(nil) + var resultErr *Error + if !errors.As(err, &resultErr) || resultErr.Result != ResultOutOfSpace { + t.Fatalf("expected out-of-space size query, got required=%d err=%v", required, err) + } + + buf := make([]byte, required) + written, err := f.FormatBuf(buf) + if err != nil { + t.Fatal(err) + } + if written != required { + t.Fatalf("expected %d bytes written, got %d", required, written) + } + if !strings.Contains(string(buf[:written]), "buffer format") { + t.Fatalf("expected formatted contents, got %q", buf[:written]) + } +} diff --git a/grid_ref_test.go b/grid_ref_test.go index 1c3e629..fafc7fd 100644 --- a/grid_ref_test.go +++ b/grid_ref_test.go @@ -50,7 +50,7 @@ func TestGridRefHyperlinkURIAndPointFromGridRef(t *testing.T) { } func TestTerminalPointFromGridRefNoValue(t *testing.T) { - term, err := NewTerminal(WithSize(8, 3), WithMaxScrollback(100)) + term, err := NewTerminal(WithSize(8, 3), WithMaxScrollbackLines(100)) if err != nil { t.Fatal(err) } @@ -78,7 +78,7 @@ func TestTerminalTrackGridRefInvalidPoint(t *testing.T) { } func TestTrackedGridRef(t *testing.T) { - term, err := NewTerminal(WithSize(8, 3), WithMaxScrollback(100)) + term, err := NewTerminal(WithSize(8, 3), WithMaxScrollbackLines(100)) if err != nil { t.Fatal(err) } diff --git a/kitty_graphics.go b/kitty_graphics.go index 406205e..e49d27b 100644 --- a/kitty_graphics.go +++ b/kitty_graphics.go @@ -33,6 +33,21 @@ func initCSelection() C.GhosttySelection { return C.init_selection() } +// KittyGraphicsData identifies a storage-level Kitty graphics query. +// C: GhosttyKittyGraphicsData +type KittyGraphicsData int + +const ( + // KittyGraphicsDataInvalid is an invalid or sentinel query. + KittyGraphicsDataInvalid KittyGraphicsData = C.GHOSTTY_KITTY_GRAPHICS_DATA_INVALID + + // KittyGraphicsDataPlacementIterator populates a placement iterator. + KittyGraphicsDataPlacementIterator KittyGraphicsData = C.GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR + + // KittyGraphicsDataGeneration returns the storage-wide mutation stamp. + KittyGraphicsDataGeneration KittyGraphicsData = C.GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION +) + // KittyGraphicsImageData identifies a data field for Kitty graphics // image queries. // C: GhosttyKittyGraphicsImageData @@ -69,6 +84,10 @@ const ( // KittyGraphicsImageDataDataLen is the length of the raw pixel data // in bytes (size_t). KittyGraphicsImageDataDataLen KittyGraphicsImageData = C.GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN + + // KittyGraphicsImageDataGeneration is the image's content generation + // stamp (uint64_t). + KittyGraphicsImageDataGeneration KittyGraphicsImageData = C.GHOSTTY_KITTY_IMAGE_DATA_GENERATION ) // KittyGraphicsPlacementData identifies a data field for Kitty graphics @@ -268,6 +287,9 @@ type KittyGraphicsImageInfo struct { // Compression is the compression of the image. Compression KittyImageCompression + // Generation is the image's process-wide content generation stamp. + Generation uint64 + // Data is a borrowed slice of the raw pixel data. Only valid // until the next mutating terminal call. Data []byte @@ -369,6 +391,21 @@ func (kg *KittyGraphics) PlacementIterator(iter *KittyGraphicsPlacementIterator) )) } +// Generation returns the process-wide mutation stamp for this image storage. +// If unchanged, the placements and image contents are identical to the prior +// observation. Placement geometry may still have changed. +func (kg *KittyGraphics) Generation() (uint64, error) { + var generation C.uint64_t + if err := resultError(C.ghostty_kitty_graphics_get( + kg.ptr, + C.GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, + unsafe.Pointer(&generation), + )); err != nil { + return 0, err + } + return uint64(generation), nil +} + // Image looks up a Kitty graphics image by its image ID. Returns nil // if no image with the given ID exists. func (kg *KittyGraphics) Image(imageID uint32) *KittyGraphicsImage { @@ -497,6 +534,20 @@ func (img *KittyGraphicsImage) Compression() (KittyImageCompression, error) { return KittyImageCompression(v), nil } +// Generation returns the process-wide content generation stamp assigned when +// the image was added or replaced. +func (img *KittyGraphicsImage) Generation() (uint64, error) { + var generation C.uint64_t + if err := resultError(C.ghostty_kitty_graphics_image_get( + img.ptr, + C.GHOSTTY_KITTY_IMAGE_DATA_GENERATION, + unsafe.Pointer(&generation), + )); err != nil { + return 0, err + } + return uint64(generation), nil +} + // Info returns all image metadata in a single call. This is more // efficient than calling ID, Number, Width, Height, Format, // Compression, and Data individually. Uses the get_multi C API @@ -510,6 +561,7 @@ func (img *KittyGraphicsImage) Info() (*KittyGraphicsImageInfo, error) { height C.uint32_t format C.GhosttyKittyImageFormat compression C.GhosttyKittyImageCompression + generation C.uint64_t dataPtr *C.uint8_t dataLen C.size_t ) @@ -522,6 +574,7 @@ func (img *KittyGraphicsImage) Info() (*KittyGraphicsImageInfo, error) { C.GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, C.GHOSTTY_KITTY_IMAGE_DATA_FORMAT, C.GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION, + C.GHOSTTY_KITTY_IMAGE_DATA_GENERATION, C.GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR, C.GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, } @@ -537,6 +590,7 @@ func (img *KittyGraphicsImage) Info() (*KittyGraphicsImageInfo, error) { unsafe.Pointer(&height), unsafe.Pointer(&format), unsafe.Pointer(&compression), + unsafe.Pointer(&generation), unsafe.Pointer(&dataPtr), unsafe.Pointer(&dataLen), } @@ -560,6 +614,7 @@ func (img *KittyGraphicsImage) Info() (*KittyGraphicsImageInfo, error) { Height: uint32(height), Format: KittyImageFormat(format), Compression: KittyImageCompression(compression), + Generation: uint64(generation), } if dataPtr != nil && dataLen > 0 { diff --git a/kitty_graphics_test.go b/kitty_graphics_test.go index f143b2d..5bc1255 100644 --- a/kitty_graphics_test.go +++ b/kitty_graphics_test.go @@ -113,7 +113,16 @@ func TestKittyGraphicsMediumSetters(t *testing.T) { if err := term.SetKittyImageMediumFile(false); err != nil { t.Fatal(err) } - if err := term.SetKittyImageMediumTempFile(true); err != nil { + directory := t.TempDir() + if err := term.SetKittyImageMediumTempFile(&directory); err != nil { + t.Fatal(err) + } + if got, err := term.KittyImageMediumTempFile(); err != nil { + t.Fatal(err) + } else if got != directory { + t.Fatalf("expected temporary-file directory %q, got %q", directory, got) + } + if err := term.SetKittyImageMediumTempFile(nil); err != nil { t.Fatal(err) } if err := term.SetKittyImageMediumSharedMem(true); err != nil { diff --git a/mode.go b/mode.go index 0d093a0..47cc91c 100644 --- a/mode.go +++ b/mode.go @@ -5,21 +5,29 @@ package libghostty */ import "C" +import "unsafe" + // Mode is a packed 16-bit terminal mode identifier. It encodes a mode // value (bits 0–14) and an ANSI flag (bit 15). DEC private modes have // the ANSI bit clear; standard ANSI modes have it set. // C: GhosttyMode type Mode uint16 +// NewMode packs a numeric mode value and ANSI flag into a Mode. Values are +// limited to 15 bits, matching GhosttyMode. +func NewMode(value uint16, ansi bool) Mode { + return Mode(C.ghostty_mode_new(C.uint16_t(value), C.bool(ansi))) +} + // Value returns the numeric mode value (0–32767). func (m Mode) Value() uint16 { - return uint16(m) & 0x7FFF + return uint16(C.ghostty_mode_value(C.GhosttyMode(m))) } // ANSI reports whether this is a standard ANSI mode. If false, it is // a DEC private mode (?-prefixed). func (m Mode) ANSI() bool { - return (m >> 15) != 0 + return bool(C.ghostty_mode_ansi(C.GhosttyMode(m))) } // ANSI modes. @@ -171,3 +179,35 @@ const ( // ModeReportPermanentlyReset means the mode is permanently reset. ModeReportPermanentlyReset ModeReportState = C.GHOSTTY_MODE_REPORT_PERMANENTLY_RESET ) + +// ModeReportEncode encodes a DECRPM response for mode and state. +func ModeReportEncode(mode Mode, state ModeReportState) ([]byte, error) { + var buf [32]byte + var written C.size_t + result := C.ghostty_mode_report_encode( + C.GhosttyMode(mode), + C.GhosttyModeReportState(state), + (*C.char)(unsafe.Pointer(&buf[0])), + C.size_t(len(buf)), + &written, + ) + if result == C.GHOSTTY_SUCCESS { + return append([]byte(nil), buf[:int(written)]...), nil + } + if result != C.GHOSTTY_OUT_OF_SPACE { + return nil, &Error{Result: Result(result)} + } + + out := make([]byte, int(written)) + var outWritten C.size_t + if err := resultError(C.ghostty_mode_report_encode( + C.GhosttyMode(mode), + C.GhosttyModeReportState(state), + (*C.char)(unsafe.Pointer(&out[0])), + C.size_t(len(out)), + &outWritten, + )); err != nil { + return nil, err + } + return out[:int(outWritten)], nil +} diff --git a/mode_test.go b/mode_test.go index 93b3aae..f0dcd3e 100644 --- a/mode_test.go +++ b/mode_test.go @@ -21,3 +21,18 @@ func TestModeValueDEC(t *testing.T) { t.Fatal("expected DEC private mode") } } + +func TestNewModeAndReportEncode(t *testing.T) { + mode := NewMode(25, false) + if mode != ModeCursorVisible { + t.Fatalf("expected cursor-visible mode, got %d", mode) + } + + report, err := ModeReportEncode(mode, ModeReportSet) + if err != nil { + t.Fatal(err) + } + if want := "\x1b[?25;1$y"; string(report) != want { + t.Fatalf("expected %q, got %q", want, report) + } +} diff --git a/osc.go b/osc.go new file mode 100644 index 0000000..1943ae7 --- /dev/null +++ b/osc.go @@ -0,0 +1,169 @@ +package libghostty + +// OSC parser bindings from osc.h. + +/* +#include +*/ +import "C" + +import "unsafe" + +// OSCCommandType identifies a parsed Operating System Command. +// C: GhosttyOscCommandType +type OSCCommandType int + +const ( + // OSCCommandInvalid identifies an invalid or unsupported command. + OSCCommandInvalid OSCCommandType = C.GHOSTTY_OSC_COMMAND_INVALID + + // OSCCommandChangeWindowTitle changes the window title. + OSCCommandChangeWindowTitle OSCCommandType = C.GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE + + // OSCCommandChangeWindowIcon changes the window icon. + OSCCommandChangeWindowIcon OSCCommandType = C.GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON + + // OSCCommandSemanticPrompt carries semantic prompt metadata. + OSCCommandSemanticPrompt OSCCommandType = C.GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT + + // OSCCommandClipboardContents accesses clipboard contents. + OSCCommandClipboardContents OSCCommandType = C.GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS + + // OSCCommandReportPwd reports the present working directory. + OSCCommandReportPwd OSCCommandType = C.GHOSTTY_OSC_COMMAND_REPORT_PWD + + // OSCCommandMouseShape changes the pointer shape. + OSCCommandMouseShape OSCCommandType = C.GHOSTTY_OSC_COMMAND_MOUSE_SHAPE + + // OSCCommandColorOperation performs a color query or update. + OSCCommandColorOperation OSCCommandType = C.GHOSTTY_OSC_COMMAND_COLOR_OPERATION + + // OSCCommandKittyColorProtocol uses Kitty's color protocol. + OSCCommandKittyColorProtocol OSCCommandType = C.GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL + + // OSCCommandShowDesktopNotification requests a desktop notification. + OSCCommandShowDesktopNotification OSCCommandType = C.GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION + + // OSCCommandHyperlinkStart begins an OSC 8 hyperlink. + OSCCommandHyperlinkStart OSCCommandType = C.GHOSTTY_OSC_COMMAND_HYPERLINK_START + + // OSCCommandHyperlinkEnd ends an OSC 8 hyperlink. + OSCCommandHyperlinkEnd OSCCommandType = C.GHOSTTY_OSC_COMMAND_HYPERLINK_END + + // OSCCommandConEmuSleep is a ConEmu sleep command. + OSCCommandConEmuSleep OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_SLEEP + + // OSCCommandConEmuShowMessageBox is a ConEmu message-box command. + OSCCommandConEmuShowMessageBox OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX + + // OSCCommandConEmuChangeTabTitle is a ConEmu tab-title command. + OSCCommandConEmuChangeTabTitle OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE + + // OSCCommandConEmuProgressReport is a ConEmu progress command. + OSCCommandConEmuProgressReport OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT + + // OSCCommandConEmuWaitInput is a ConEmu wait-input command. + OSCCommandConEmuWaitInput OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT + + // OSCCommandConEmuGuiMacro is a ConEmu GUI macro command. + OSCCommandConEmuGuiMacro OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO + + // OSCCommandConEmuRunProcess is a ConEmu process command. + OSCCommandConEmuRunProcess OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS + + // OSCCommandConEmuOutputEnvironmentVariable is a ConEmu environment + // variable command. + OSCCommandConEmuOutputEnvironmentVariable OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE + + // OSCCommandConEmuXtermEmulation is a ConEmu xterm-emulation command. + OSCCommandConEmuXtermEmulation OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION + + // OSCCommandConEmuComment is a ConEmu comment command. + OSCCommandConEmuComment OSCCommandType = C.GHOSTTY_OSC_COMMAND_CONEMU_COMMENT + + // OSCCommandKittyTextSizing uses Kitty's text-sizing protocol. + OSCCommandKittyTextSizing OSCCommandType = C.GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING +) + +// OSCCommandData identifies typed data extractable from an OSC command. +// C: GhosttyOscCommandData +type OSCCommandData int + +const ( + // OSCDataInvalid is an invalid data query. + OSCDataInvalid OSCCommandData = C.GHOSTTY_OSC_DATA_INVALID + + // OSCDataChangeWindowTitleString extracts a null-terminated title string. + OSCDataChangeWindowTitleString OSCCommandData = C.GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR +) + +// OSCParser incrementally parses the bytes inside an OSC sequence. +// C: GhosttyOscParser +type OSCParser struct { + ptr C.GhosttyOscParser +} + +// OSCCommand is a borrowed command produced by [OSCParser.End]. It remains +// valid until the next parser operation other than command introspection. +// C: GhosttyOscCommand +type OSCCommand struct { + ptr C.GhosttyOscCommand +} + +// NewOSCParser creates a reusable OSC parser. +func NewOSCParser() (*OSCParser, error) { + var ptr C.GhosttyOscParser + if err := resultError(C.ghostty_osc_new(nil, &ptr)); err != nil { + return nil, err + } + return &OSCParser{ptr: ptr}, nil +} + +// Close frees the parser. Commands borrowed from it become invalid. +func (p *OSCParser) Close() { + C.ghostty_osc_free(p.ptr) +} + +// Reset clears partially parsed input and returns the parser to its initial +// state. +func (p *OSCParser) Reset() { + C.ghostty_osc_reset(p.ptr) +} + +// Next feeds one byte from the OSC sequence body into the parser. +func (p *OSCParser) Next(b byte) { + C.ghostty_osc_next(p.ptr, C.uint8_t(b)) +} + +// End finalizes the current sequence. terminator is normally BEL (0x07) or +// the final backslash byte of ST (0x5c). +func (p *OSCParser) End(terminator byte) OSCCommand { + return OSCCommand{ + ptr: C.ghostty_osc_end(p.ptr, C.uint8_t(terminator)), + } +} + +// Type returns the parsed command type. +func (c OSCCommand) Type() OSCCommandType { + return OSCCommandType(C.ghostty_osc_command_type(c.ptr)) +} + +// Data extracts a low-level typed value into out. The pointed-to Go value +// must match the output type documented for data in osc.h. +func (c OSCCommand) Data(data OSCCommandData, out unsafe.Pointer) bool { + return bool(C.ghostty_osc_command_data( + c.ptr, + C.GhosttyOscCommandData(data), + out, + )) +} + +// WindowTitle returns the title carried by a change-window-title command. +// The string is copied into Go-owned memory. +func (c OSCCommand) WindowTitle() (string, bool) { + var ptr *C.char + if !c.Data(OSCDataChangeWindowTitleString, unsafe.Pointer(&ptr)) { + return "", false + } + return C.GoString(ptr), true +} diff --git a/osc_test.go b/osc_test.go new file mode 100644 index 0000000..b1bd88d --- /dev/null +++ b/osc_test.go @@ -0,0 +1,31 @@ +package libghostty + +import "testing" + +func TestOSCParserWindowTitle(t *testing.T) { + parser, err := NewOSCParser() + if err != nil { + t.Fatal(err) + } + defer parser.Close() + + for _, b := range []byte("0;hello") { + parser.Next(b) + } + command := parser.End('\a') + if command.Type() != OSCCommandChangeWindowTitle { + t.Fatalf("expected title command, got %d", command.Type()) + } + title, ok := command.WindowTitle() + if !ok || title != "hello" { + t.Fatalf("expected title %q, got %q (ok=%v)", "hello", title, ok) + } + + parser.Reset() + for _, b := range []byte("999999;unsupported") { + parser.Next(b) + } + if got := parser.End('\a').Type(); got != OSCCommandInvalid { + t.Fatalf("expected invalid command, got %d", got) + } +} diff --git a/render_state.go b/render_state.go index 63304fe..bdafe09 100644 --- a/render_state.go +++ b/render_state.go @@ -113,3 +113,16 @@ func (rs *RenderState) Close() { func (rs *RenderState) Update(t *Terminal) error { return resultError(C.ghostty_render_state_update(rs.ptr, t.ptr)) } + +// BeginUpdate performs the terminal-dependent phase of a two-phase render +// state update. Every successful call must be paired with [RenderState.EndUpdate] +// before reading the render state. +func (rs *RenderState) BeginUpdate(t *Terminal) error { + return resultError(C.ghostty_render_state_begin_update(rs.ptr, t.ptr)) +} + +// EndUpdate completes deferred work from [RenderState.BeginUpdate]. It only +// accesses render-state-owned memory and does not require terminal access. +func (rs *RenderState) EndUpdate() error { + return resultError(C.ghostty_render_state_end_update(rs.ptr)) +} diff --git a/render_state_test.go b/render_state_test.go index bd21382..4c4b24f 100644 --- a/render_state_test.go +++ b/render_state_test.go @@ -180,3 +180,32 @@ func TestRenderStateCursor(t *testing.T) { t.Fatalf("expected cursor at 0,0, got %d,%d", x, y) } } + +func TestRenderStateTwoPhaseUpdate(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + term.VTWrite([]byte("two phase")) + + rs, err := NewRenderState() + if err != nil { + t.Fatal(err) + } + defer rs.Close() + + if err := rs.BeginUpdate(term); err != nil { + t.Fatal(err) + } + if err := rs.EndUpdate(); err != nil { + t.Fatal(err) + } + cols, err := rs.Cols() + if err != nil { + t.Fatal(err) + } + if cols != 80 { + t.Fatalf("expected 80 columns, got %d", cols) + } +} diff --git a/selection_test.go b/selection_test.go index f1dfbb6..f9a23ad 100644 --- a/selection_test.go +++ b/selection_test.go @@ -202,7 +202,7 @@ func TestTerminalActiveSelectionFormat(t *testing.T) { } func TestSelectionGestureAPIs(t *testing.T) { - term, err := NewTerminal(WithSize(20, 4), WithMaxScrollback(100)) + term, err := NewTerminal(WithSize(20, 4), WithMaxScrollbackLines(100)) if err != nil { t.Fatal(err) } diff --git a/sgr.go b/sgr.go new file mode 100644 index 0000000..5c618df --- /dev/null +++ b/sgr.go @@ -0,0 +1,267 @@ +package libghostty + +// SGR parser bindings from sgr.h. + +/* +#include +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +// SGRAttributeTag identifies the active value in an [SGRAttribute]. +// C: GhosttySgrAttributeTag +type SGRAttributeTag int + +const ( + // SGRAttrUnset is the zero-value attribute tag. + SGRAttrUnset SGRAttributeTag = C.GHOSTTY_SGR_ATTR_UNSET + + // SGRAttrUnknown identifies an unknown or malformed parameter sequence. + SGRAttrUnknown SGRAttributeTag = C.GHOSTTY_SGR_ATTR_UNKNOWN + + // SGRAttrBold enables bold text. + SGRAttrBold SGRAttributeTag = C.GHOSTTY_SGR_ATTR_BOLD + + // SGRAttrResetBold disables bold text. + SGRAttrResetBold SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_BOLD + + // SGRAttrItalic enables italic text. + SGRAttrItalic SGRAttributeTag = C.GHOSTTY_SGR_ATTR_ITALIC + + // SGRAttrResetItalic disables italic text. + SGRAttrResetItalic SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_ITALIC + + // SGRAttrFaint enables faint text. + SGRAttrFaint SGRAttributeTag = C.GHOSTTY_SGR_ATTR_FAINT + + // SGRAttrUnderline sets the underline style in Underline. + SGRAttrUnderline SGRAttributeTag = C.GHOSTTY_SGR_ATTR_UNDERLINE + + // SGRAttrUnderlineColor sets a direct RGB underline color in Color. + SGRAttrUnderlineColor SGRAttributeTag = C.GHOSTTY_SGR_ATTR_UNDERLINE_COLOR + + // SGRAttrUnderlineColor256 sets a palette underline color in PaletteIndex. + SGRAttrUnderlineColor256 SGRAttributeTag = C.GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 + + // SGRAttrResetUnderlineColor resets the underline color. + SGRAttrResetUnderlineColor SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR + + // SGRAttrOverline enables overline. + SGRAttrOverline SGRAttributeTag = C.GHOSTTY_SGR_ATTR_OVERLINE + + // SGRAttrResetOverline disables overline. + SGRAttrResetOverline SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_OVERLINE + + // SGRAttrBlink enables blinking text. + SGRAttrBlink SGRAttributeTag = C.GHOSTTY_SGR_ATTR_BLINK + + // SGRAttrResetBlink disables blinking text. + SGRAttrResetBlink SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_BLINK + + // SGRAttrInverse enables inverse video. + SGRAttrInverse SGRAttributeTag = C.GHOSTTY_SGR_ATTR_INVERSE + + // SGRAttrResetInverse disables inverse video. + SGRAttrResetInverse SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_INVERSE + + // SGRAttrInvisible enables invisible text. + SGRAttrInvisible SGRAttributeTag = C.GHOSTTY_SGR_ATTR_INVISIBLE + + // SGRAttrResetInvisible disables invisible text. + SGRAttrResetInvisible SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_INVISIBLE + + // SGRAttrStrikethrough enables strikethrough. + SGRAttrStrikethrough SGRAttributeTag = C.GHOSTTY_SGR_ATTR_STRIKETHROUGH + + // SGRAttrResetStrikethrough disables strikethrough. + SGRAttrResetStrikethrough SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH + + // SGRAttrDirectColorFG sets a direct RGB foreground color in Color. + SGRAttrDirectColorFG SGRAttributeTag = C.GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG + + // SGRAttrDirectColorBG sets a direct RGB background color in Color. + SGRAttrDirectColorBG SGRAttributeTag = C.GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG + + // SGRAttrBG8 sets an eight-color background in PaletteIndex. + SGRAttrBG8 SGRAttributeTag = C.GHOSTTY_SGR_ATTR_BG_8 + + // SGRAttrFG8 sets an eight-color foreground in PaletteIndex. + SGRAttrFG8 SGRAttributeTag = C.GHOSTTY_SGR_ATTR_FG_8 + + // SGRAttrResetFG resets the foreground color. + SGRAttrResetFG SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_FG + + // SGRAttrResetBG resets the background color. + SGRAttrResetBG SGRAttributeTag = C.GHOSTTY_SGR_ATTR_RESET_BG + + // SGRAttrBrightBG8 sets a bright eight-color background in PaletteIndex. + SGRAttrBrightBG8 SGRAttributeTag = C.GHOSTTY_SGR_ATTR_BRIGHT_BG_8 + + // SGRAttrBrightFG8 sets a bright eight-color foreground in PaletteIndex. + SGRAttrBrightFG8 SGRAttributeTag = C.GHOSTTY_SGR_ATTR_BRIGHT_FG_8 + + // SGRAttrBG256 sets a 256-color background in PaletteIndex. + SGRAttrBG256 SGRAttributeTag = C.GHOSTTY_SGR_ATTR_BG_256 + + // SGRAttrFG256 sets a 256-color foreground in PaletteIndex. + SGRAttrFG256 SGRAttributeTag = C.GHOSTTY_SGR_ATTR_FG_256 +) + +// SGRUnderline identifies an SGR underline style. +// C: GhosttySgrUnderline +type SGRUnderline int + +// SGRUnknown contains a copied unknown SGR parameter sequence. +// C: GhosttySgrUnknown +type SGRUnknown struct { + // Full is the complete parameter list supplied to the parser. + Full []uint16 + + // Partial is the portion at which parsing encountered the unknown value. + Partial []uint16 +} + +// SGRAttribute is one parsed SGR operation. Callers should inspect Tag and +// then use the corresponding field documented by that tag. +// C: GhosttySgrAttribute +type SGRAttribute struct { + // Tag identifies the attribute operation and active value. + Tag SGRAttributeTag + + // Unknown is populated when Tag is SGRAttrUnknown. + Unknown SGRUnknown + + // Underline is populated when Tag is SGRAttrUnderline. + Underline SGRUnderline + + // Color is populated for direct RGB foreground, background, or underline + // color attributes. + Color ColorRGB + + // PaletteIndex is populated for 8-, 16-, and 256-color attributes. + PaletteIndex uint8 +} + +// SGRParser parses CSI SGR parameter lists into semantic attributes. +// C: GhosttySgrParser +type SGRParser struct { + ptr C.GhosttySgrParser +} + +// NewSGRParser creates a reusable SGR parser. +func NewSGRParser() (*SGRParser, error) { + var ptr C.GhosttySgrParser + if err := resultError(C.ghostty_sgr_new(nil, &ptr)); err != nil { + return nil, err + } + return &SGRParser{ptr: ptr}, nil +} + +// Close frees the parser. +func (p *SGRParser) Close() { + C.ghostty_sgr_free(p.ptr) +} + +// Reset restarts iteration at the beginning of the current parameter list. +func (p *SGRParser) Reset() { + C.ghostty_sgr_reset(p.ptr) +} + +// SetParams copies a CSI SGR parameter list into the parser. separators may +// be nil to treat every separator as a semicolon; otherwise it must contain +// one ';' or ':' byte for every parameter. +func (p *SGRParser) SetParams(params []uint16, separators []byte) error { + if len(separators) != 0 && len(separators) != len(params) { + return fmt.Errorf( + "libghostty: SGR separators length %d does not match params length %d", + len(separators), + len(params), + ) + } + + var paramsPtr *C.uint16_t + if len(params) > 0 { + paramsPtr = (*C.uint16_t)(unsafe.Pointer(¶ms[0])) + } + var separatorsPtr *C.char + if len(separators) > 0 { + separatorsPtr = (*C.char)(unsafe.Pointer(&separators[0])) + } + + return resultError(C.ghostty_sgr_set_params( + p.ptr, + paramsPtr, + separatorsPtr, + C.size_t(len(params)), + )) +} + +// Next returns the next parsed attribute. The boolean is false when the +// current parameter list is exhausted. +func (p *SGRParser) Next() (SGRAttribute, bool) { + var raw C.GhosttySgrAttribute + if !bool(C.ghostty_sgr_next(p.ptr, &raw)) { + return SGRAttribute{}, false + } + + attr := SGRAttribute{ + Tag: SGRAttributeTag(C.ghostty_sgr_attribute_tag(raw)), + } + value := C.ghostty_sgr_attribute_value(&raw) + + switch attr.Tag { + case SGRAttrUnknown: + unknown := *(*C.GhosttySgrUnknown)(unsafe.Pointer(value)) + attr.Unknown = sgrUnknownFromC(unknown) + + case SGRAttrUnderline: + attr.Underline = SGRUnderline( + *(*C.GhosttySgrUnderline)(unsafe.Pointer(value)), + ) + + case SGRAttrUnderlineColor, SGRAttrDirectColorFG, SGRAttrDirectColorBG: + attr.Color = colorRGBFromC( + *(*C.GhosttyColorRgb)(unsafe.Pointer(value)), + ) + + case SGRAttrUnderlineColor256, + SGRAttrBG8, + SGRAttrFG8, + SGRAttrBrightBG8, + SGRAttrBrightFG8, + SGRAttrBG256, + SGRAttrFG256: + attr.PaletteIndex = uint8( + *(*C.GhosttyColorPaletteIndex)(unsafe.Pointer(value)), + ) + } + + return attr, true +} + +// sgrUnknownFromC copies both borrowed parameter slices from an unknown +// attribute into Go-owned memory. +func sgrUnknownFromC(unknown C.GhosttySgrUnknown) SGRUnknown { + var fullPtr *C.uint16_t + fullLen := C.ghostty_sgr_unknown_full(unknown, &fullPtr) + full := make([]uint16, int(fullLen)) + for i, value := range unsafe.Slice(fullPtr, int(fullLen)) { + full[i] = uint16(value) + } + + var partialPtr *C.uint16_t + partialLen := C.ghostty_sgr_unknown_partial(unknown, &partialPtr) + partial := make([]uint16, int(partialLen)) + for i, value := range unsafe.Slice(partialPtr, int(partialLen)) { + partial[i] = uint16(value) + } + + return SGRUnknown{ + Full: full, + Partial: partial, + } +} diff --git a/sgr_test.go b/sgr_test.go new file mode 100644 index 0000000..70125d9 --- /dev/null +++ b/sgr_test.go @@ -0,0 +1,69 @@ +package libghostty + +import ( + "slices" + "testing" +) + +func TestSGRParser(t *testing.T) { + parser, err := NewSGRParser() + if err != nil { + t.Fatal(err) + } + defer parser.Close() + + if err := parser.SetParams([]uint16{1, 31}, nil); err != nil { + t.Fatal(err) + } + attr, ok := parser.Next() + if !ok || attr.Tag != SGRAttrBold { + t.Fatalf("expected bold attribute, got %#v (ok=%v)", attr, ok) + } + attr, ok = parser.Next() + if !ok || attr.Tag != SGRAttrFG8 || attr.PaletteIndex != 1 { + t.Fatalf("expected red foreground attribute, got %#v (ok=%v)", attr, ok) + } + if _, ok := parser.Next(); ok { + t.Fatal("expected parser to be exhausted") + } + + parser.Reset() + attr, ok = parser.Next() + if !ok || attr.Tag != SGRAttrBold { + t.Fatalf("expected reset iteration to return bold, got %#v", attr) + } +} + +func TestSGRParserValuesAndUnknown(t *testing.T) { + parser, err := NewSGRParser() + if err != nil { + t.Fatal(err) + } + defer parser.Close() + + if err := parser.SetParams([]uint16{38, 2, 10, 20, 30}, nil); err != nil { + t.Fatal(err) + } + attr, ok := parser.Next() + if !ok || attr.Tag != SGRAttrDirectColorFG { + t.Fatalf("expected direct foreground, got %#v", attr) + } + if want := (ColorRGB{R: 10, G: 20, B: 30}); attr.Color != want { + t.Fatalf("expected %#v, got %#v", want, attr.Color) + } + + if err := parser.SetParams([]uint16{999}, nil); err != nil { + t.Fatal(err) + } + attr, ok = parser.Next() + if !ok || attr.Tag != SGRAttrUnknown { + t.Fatalf("expected unknown attribute, got %#v", attr) + } + if !slices.Equal(attr.Unknown.Full, []uint16{999}) { + t.Fatalf("unexpected full unknown params: %v", attr.Unknown.Full) + } + + if err := parser.SetParams([]uint16{1}, []byte{';', ':'}); err == nil { + t.Fatal("expected mismatched separators to fail") + } +} diff --git a/size_report.go b/size_report.go index 80e422f..c7360a8 100644 --- a/size_report.go +++ b/size_report.go @@ -5,6 +5,8 @@ package libghostty */ import "C" +import "unsafe" + // SizeReportStyle determines the output format for a terminal size report. // C: GhosttySizeReportStyle type SizeReportStyle int @@ -38,3 +40,42 @@ type SizeReportSize struct { // CellHeight is the height of a single terminal cell in pixels. CellHeight uint32 } + +// SizeReportEncode encodes a terminal size report in the requested style. +func SizeReportEncode(style SizeReportStyle, size SizeReportSize) ([]byte, error) { + csize := C.GhosttySizeReportSize{ + rows: C.uint16_t(size.Rows), + columns: C.uint16_t(size.Columns), + cell_width: C.uint32_t(size.CellWidth), + cell_height: C.uint32_t(size.CellHeight), + } + + var buf [64]byte + var written C.size_t + result := C.ghostty_size_report_encode( + C.GhosttySizeReportStyle(style), + csize, + (*C.char)(unsafe.Pointer(&buf[0])), + C.size_t(len(buf)), + &written, + ) + if result == C.GHOSTTY_SUCCESS { + return append([]byte(nil), buf[:int(written)]...), nil + } + if result != C.GHOSTTY_OUT_OF_SPACE { + return nil, &Error{Result: Result(result)} + } + + out := make([]byte, int(written)) + var outWritten C.size_t + if err := resultError(C.ghostty_size_report_encode( + C.GhosttySizeReportStyle(style), + csize, + (*C.char)(unsafe.Pointer(&out[0])), + C.size_t(len(out)), + &outWritten, + )); err != nil { + return nil, err + } + return out[:int(outWritten)], nil +} diff --git a/size_report_test.go b/size_report_test.go new file mode 100644 index 0000000..2bb036f --- /dev/null +++ b/size_report_test.go @@ -0,0 +1,16 @@ +package libghostty + +import "testing" + +func TestSizeReportEncode(t *testing.T) { + report, err := SizeReportEncode( + SizeReportCSI18T, + SizeReportSize{Rows: 24, Columns: 80, CellWidth: 8, CellHeight: 16}, + ) + if err != nil { + t.Fatal(err) + } + if want := "\x1b[8;24;80t"; string(report) != want { + t.Fatalf("expected %q, got %q", want, report) + } +} diff --git a/style.go b/style.go index 5f418ff..548e5db 100644 --- a/style.go +++ b/style.go @@ -49,15 +49,26 @@ type StyleColor struct { RGB ColorRGB } -// Underline style constants. +// SGR underline styles. // C: GhosttySgrUnderline const ( - UnderlineNone = C.GHOSTTY_SGR_UNDERLINE_NONE - UnderlineSingle = C.GHOSTTY_SGR_UNDERLINE_SINGLE - UnderlineDouble = C.GHOSTTY_SGR_UNDERLINE_DOUBLE - UnderlineCurly = C.GHOSTTY_SGR_UNDERLINE_CURLY - UnderlineDotted = C.GHOSTTY_SGR_UNDERLINE_DOTTED - UnderlineDashed = C.GHOSTTY_SGR_UNDERLINE_DASHED + // UnderlineNone disables underlining. + UnderlineNone SGRUnderline = C.GHOSTTY_SGR_UNDERLINE_NONE + + // UnderlineSingle draws a single underline. + UnderlineSingle SGRUnderline = C.GHOSTTY_SGR_UNDERLINE_SINGLE + + // UnderlineDouble draws a double underline. + UnderlineDouble SGRUnderline = C.GHOSTTY_SGR_UNDERLINE_DOUBLE + + // UnderlineCurly draws a curly underline. + UnderlineCurly SGRUnderline = C.GHOSTTY_SGR_UNDERLINE_CURLY + + // UnderlineDotted draws a dotted underline. + UnderlineDotted SGRUnderline = C.GHOSTTY_SGR_UNDERLINE_DOTTED + + // UnderlineDashed draws a dashed underline. + UnderlineDashed SGRUnderline = C.GHOSTTY_SGR_UNDERLINE_DASHED ) // Style is a thin wrapper around the copied C GhosttyStyle value. It @@ -70,6 +81,13 @@ type Style struct { c C.GhosttyStyle } +// DefaultStyle returns a new style initialized to Ghostty's default values. +func DefaultStyle() *Style { + var c C.GhosttyStyle + C.ghostty_style_default(&c) + return &Style{c: c} +} + // IsDefault reports whether the style is the default style // (no colors, no flags). func (s *Style) IsDefault() bool { @@ -132,8 +150,8 @@ func (s *Style) Overline() bool { } // Underline returns the underline style (one of the Underline* constants). -func (s *Style) Underline() int { - return int(s.c.underline) +func (s *Style) Underline() SGRUnderline { + return SGRUnderline(s.c.underline) } // styleColorFromC converts a C GhosttyStyleColor to a Go StyleColor. diff --git a/style_test.go b/style_test.go new file mode 100644 index 0000000..7d74998 --- /dev/null +++ b/style_test.go @@ -0,0 +1,13 @@ +package libghostty + +import "testing" + +func TestDefaultStyle(t *testing.T) { + style := DefaultStyle() + if !style.IsDefault() { + t.Fatal("expected default style") + } + if style.Underline() != UnderlineNone { + t.Fatalf("expected no underline, got %d", style.Underline()) + } +} diff --git a/terminal.go b/terminal.go index ce98a48..0f0eaa3 100644 --- a/terminal.go +++ b/terminal.go @@ -32,6 +32,7 @@ type Terminal struct { onBell BellFn onClipboardWrite ClipboardWriteFn onTitleChanged TitleChangedFn + onPwdChanged PwdChangedFn onEnquiry EnquiryFn onXtversion XtversionFn onSize SizeFn @@ -50,9 +51,8 @@ type Terminal struct { type TerminalOption func(*TerminalConfig) // TerminalConfig holds the configuration for creating a Terminal. -// It can be passed directly to NewTerminal or built up using -// functional options like WithSize and WithMaxScrollback. -// C: GhosttyTerminalOptions +// It is populated by functional options such as [WithSize], +// [WithMaxScrollbackBytes], and [WithMaxScrollbackLines]. type TerminalConfig struct { // Cols is the terminal width in cells. Must be greater than zero. Cols uint16 @@ -60,15 +60,20 @@ type TerminalConfig struct { // Rows is the terminal height in cells. Must be greater than zero. Rows uint16 - // MaxScrollback is the maximum number of lines to keep in scrollback - // history. Defaults to 0 (no scrollback). - MaxScrollback uint + // MaxScrollbackBytes is the optional approximate maximum scrollback + // allocation in bytes. Nil retains libghostty's default. + MaxScrollbackBytes *uint + + // MaxScrollbackLines is the optional maximum number of physical lines + // retained in scrollback. Nil retains libghostty's default. + MaxScrollbackLines *uint // Effect handlers applied after terminal creation. onWritePty WritePtyFn onBell BellFn onClipboardWrite ClipboardWriteFn onTitleChanged TitleChangedFn + onPwdChanged PwdChangedFn onEnquiry EnquiryFn onXtversion XtversionFn onSize SizeFn @@ -168,6 +173,12 @@ type ClipboardWriteFn func(t *Terminal, write ClipboardWrite) ClipboardWriteResu // C: GhosttyTerminalTitleChangedFn type TitleChangedFn func(t *Terminal) +// PwdChangedFn is called when the terminal working directory changes via +// OSC 7, OSC 9, or OSC 1337 CurrentDir. Query [Terminal.Pwd] in the callback +// to read the new value. +// C: GhosttyTerminalPwdChangedFn +type PwdChangedFn func(t *Terminal) + // EnquiryFn is called when the terminal receives ENQ (0x05). // The first parameter is the terminal that triggered the effect. // Return the response bytes; nil or empty means no response. @@ -208,11 +219,20 @@ func WithSize(cols, rows uint16) TerminalOption { } } -// WithMaxScrollback sets the maximum number of lines to keep in -// scrollback history. Defaults to 0 (no scrollback). -func WithMaxScrollback(lines uint) TerminalOption { +// WithMaxScrollbackBytes sets the maximum scrollback allocation in bytes. +// The limit is approximate because libghostty prunes at page granularity. +func WithMaxScrollbackBytes(bytes uint) TerminalOption { return func(c *TerminalConfig) { - c.MaxScrollback = lines + c.MaxScrollbackBytes = &bytes + } +} + +// WithMaxScrollbackLines sets the maximum number of physical scrollback +// lines. The limit is approximate because libghostty prunes at page +// granularity. +func WithMaxScrollbackLines(lines uint) TerminalOption { + return func(c *TerminalConfig) { + c.MaxScrollbackLines = &lines } } @@ -249,6 +269,14 @@ func WithTitleChanged(fn TitleChangedFn) TerminalOption { } } +// WithPwdChanged registers an effect handler invoked when the terminal +// working directory changes via an OSC sequence. +func WithPwdChanged(fn PwdChangedFn) TerminalOption { + return func(c *TerminalConfig) { + c.onPwdChanged = fn + } +} + // WithEnquiry registers an effect handler invoked when the terminal // receives an ENQ character (0x05). Return the response bytes; nil // or empty means no response. @@ -304,23 +332,48 @@ func NewTerminal(opts ...TerminalOption) (*Terminal, error) { opt(&cfg) } - options := C.GhosttyTerminalOptions{ - cols: C.uint16_t(cfg.Cols), - rows: C.uint16_t(cfg.Rows), - max_scrollback: C.size_t(cfg.MaxScrollback), - } - var cterm C.GhosttyTerminal - if err := resultError(C.ghostty_terminal_new(nil, &cterm, options)); err != nil { + if err := resultError(C.ghostty_terminal_new( + nil, + &cterm, + C.uint16_t(cfg.Cols), + C.uint16_t(cfg.Rows), + )); err != nil { return nil, err } + // Apply explicitly configured scrollback limits. Omitting either option + // retains libghostty's constructor default for that limit. + if cfg.MaxScrollbackBytes != nil { + maxBytes := C.size_t(*cfg.MaxScrollbackBytes) + if err := resultError(C.ghostty_terminal_set( + cterm, + C.GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES, + unsafe.Pointer(&maxBytes), + )); err != nil { + C.ghostty_terminal_free(cterm) + return nil, err + } + } + if cfg.MaxScrollbackLines != nil { + maxLines := C.size_t(*cfg.MaxScrollbackLines) + if err := resultError(C.ghostty_terminal_set( + cterm, + C.GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES, + unsafe.Pointer(&maxLines), + )); err != nil { + C.ghostty_terminal_free(cterm) + return nil, err + } + } + t := &Terminal{ ptr: cterm, onWritePty: cfg.onWritePty, onBell: cfg.onBell, onClipboardWrite: cfg.onClipboardWrite, onTitleChanged: cfg.onTitleChanged, + onPwdChanged: cfg.onPwdChanged, onEnquiry: cfg.onEnquiry, onXtversion: cfg.onXtversion, onSize: cfg.onSize, @@ -373,6 +426,85 @@ func (t *Terminal) Resize(cols, rows uint16, cellWidthPx, cellHeightPx uint32) e )) } +// TerminalCompressionMode controls how much scrollback compression work a +// call to [Terminal.Compress] performs. +// C: GhosttyTerminalCompressionMode +type TerminalCompressionMode int + +const ( + // TerminalCompressionIncremental performs one bounded step suitable for + // an idle callback. + TerminalCompressionIncremental TerminalCompressionMode = C.GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL + + // TerminalCompressionFull synchronously scans all eligible pages. + TerminalCompressionFull TerminalCompressionMode = C.GHOSTTY_TERMINAL_COMPRESSION_MODE_FULL +) + +// TerminalCompressionResult describes whether a compression pass needs more +// work. +// C: GhosttyTerminalCompressionResult +type TerminalCompressionResult int + +const ( + // TerminalCompressionUnsupported means retained-mapping reclamation is + // unavailable on this target. + TerminalCompressionUnsupported TerminalCompressionResult = C.GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED + + // TerminalCompressionPending means another incremental step should run + // while the terminal remains idle. + TerminalCompressionPending TerminalCompressionResult = C.GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING + + // TerminalCompressionComplete means no continuation is needed until the + // activity token changes. + TerminalCompressionComplete TerminalCompressionResult = C.GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE +) + +// TerminalCursorStyle identifies the visual style used when DECSCUSR resets +// the cursor. +// C: GhosttyTerminalCursorStyle +type TerminalCursorStyle int + +const ( + // TerminalCursorStyleBar is a vertical bar cursor. + TerminalCursorStyleBar TerminalCursorStyle = C.GHOSTTY_TERMINAL_CURSOR_STYLE_BAR + + // TerminalCursorStyleBlock is a filled block cursor. + TerminalCursorStyleBlock TerminalCursorStyle = C.GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK + + // TerminalCursorStyleUnderline is an underline cursor. + TerminalCursorStyleUnderline TerminalCursorStyle = C.GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE + + // TerminalCursorStyleBlockHollow is a hollow block cursor. + TerminalCursorStyleBlockHollow TerminalCursorStyle = C.GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW +) + +// CompressionActivity returns the opaque scrollback-compression activity +// token. Only equality comparisons between tokens are meaningful. +func (t *Terminal) CompressionActivity() (uint64, error) { + var activity C.uint64_t + if err := resultError(C.ghostty_terminal_compression_activity( + t.ptr, + &activity, + )); err != nil { + return 0, err + } + return uint64(activity), nil +} + +// Compress performs caller-driven scrollback compression. Calls must be +// serialized with all other access to the terminal. +func (t *Terminal) Compress(mode TerminalCompressionMode) (TerminalCompressionResult, error) { + var result C.GhosttyTerminalCompressionResult + if err := resultError(C.ghostty_terminal_compress( + t.ptr, + C.GhosttyTerminalCompressionMode(mode), + &result, + )); err != nil { + return 0, err + } + return TerminalCompressionResult(result), nil +} + // VTWrite feeds raw VT-encoded bytes through the terminal's parser, // updating terminal state. Malformed input is handled gracefully and // will not cause an error. Effect callbacks run synchronously before @@ -419,6 +551,10 @@ const ( // ScrollViewportDelta scrolls by a delta amount (up is negative). ScrollViewportDelta ScrollViewportTag = C.GHOSTTY_SCROLL_VIEWPORT_DELTA + + // ScrollViewportRow scrolls to an absolute row offset in the same + // coordinate space as [Scrollbar.Offset]. + ScrollViewportRow ScrollViewportTag = C.GHOSTTY_SCROLL_VIEWPORT_ROW ) // ScrollViewport scrolls the terminal viewport to the top of scrollback. @@ -446,6 +582,16 @@ func (t *Terminal) ScrollViewportDelta(delta int) { C.ghostty_terminal_scroll_viewport(t.ptr, sv) } +// ScrollViewportRow scrolls the viewport to an absolute row offset from the +// top of the scrollable area. The value is clamped to the available range. +func (t *Terminal) ScrollViewportRow(row uint) { + var sv C.GhosttyTerminalScrollViewport + sv.tag = C.GHOSTTY_SCROLL_VIEWPORT_ROW + // Set the row in the value union. The row field is at offset 0. + *(*C.size_t)(unsafe.Pointer(&sv.value[0])) = C.size_t(row) + C.ghostty_terminal_scroll_viewport(t.ptr, sv) +} + // TerminalScreen identifies which screen buffer is active. // C: GhosttyTerminalScreen type TerminalScreen int diff --git a/terminal_data.go b/terminal_data.go index a3259ae..39b6698 100644 --- a/terminal_data.go +++ b/terminal_data.go @@ -123,8 +123,9 @@ const ( // is enabled for Kitty image loading (bool). TerminalDataKittyImageMediumFile TerminalData = C.GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE - // TerminalDataKittyImageMediumTempFile indicates whether the temporary - // file medium is enabled for Kitty image loading (bool). + // TerminalDataKittyImageMediumTempFile is the directory allowed for + // temporary-file image loading, or an empty string when disabled + // (GhosttyString). TerminalDataKittyImageMediumTempFile TerminalData = C.GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE // TerminalDataKittyImageMediumSharedMem indicates whether the shared @@ -142,6 +143,18 @@ const ( // TerminalDataViewportActive indicates whether the viewport is pinned // to the active terminal area rather than scrolled into history (bool). TerminalDataViewportActive TerminalData = C.GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE + + // TerminalDataVTProcessingError indicates whether VT processing ever + // encountered a non-gracefully handled semantic update failure (bool). + TerminalDataVTProcessingError TerminalData = C.GHOSTTY_TERMINAL_DATA_VT_PROCESSING_ERROR + + // TerminalDataScrollbackMaxBytes is the configured approximate byte + // limit (size_t), or GHOSTTY_NO_VALUE when unlimited. + TerminalDataScrollbackMaxBytes TerminalData = C.GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_BYTES + + // TerminalDataScrollbackMaxLines is the configured approximate physical + // line limit (size_t), or GHOSTTY_NO_VALUE when unlimited. + TerminalDataScrollbackMaxLines TerminalData = C.GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_LINES ) // ActiveScreen returns which screen buffer is currently active. @@ -315,6 +328,62 @@ func (t *Terminal) KittyKeyboardFlags() (KittyKeyFlags, error) { return KittyKeyFlags(v), nil } +// KittyImageMediumFile reports whether Kitty image loading via arbitrary +// files is enabled for the active screen. +func (t *Terminal) KittyImageMediumFile() (bool, error) { + var v C.bool + if err := resultError(C.ghostty_terminal_get( + t.ptr, + C.GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE, + unsafe.Pointer(&v), + )); err != nil { + return false, err + } + return bool(v), nil +} + +// KittyImageMediumSharedMem reports whether Kitty image loading via shared +// memory is enabled for the active screen. +func (t *Terminal) KittyImageMediumSharedMem() (bool, error) { + var v C.bool + if err := resultError(C.ghostty_terminal_get( + t.ptr, + C.GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM, + unsafe.Pointer(&v), + )); err != nil { + return false, err + } + return bool(v), nil +} + +// KittyImageMediumTempFile returns the directory allowed for Kitty +// temporary-file image loading. An empty string means the medium is disabled. +func (t *Terminal) KittyImageMediumTempFile() (string, error) { + var s C.GhosttyString + if err := resultError(C.ghostty_terminal_get( + t.ptr, + C.GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE, + unsafe.Pointer(&s), + )); err != nil { + return "", err + } + return C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len)), nil +} + +// KittyImageStorageLimit returns the active screen's Kitty image storage +// limit in bytes. Zero means the protocol is disabled. +func (t *Terminal) KittyImageStorageLimit() (uint64, error) { + var v C.uint64_t + if err := resultError(C.ghostty_terminal_get( + t.ptr, + C.GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT, + unsafe.Pointer(&v), + )); err != nil { + return 0, err + } + return uint64(v), nil +} + // MouseTracking reports whether any mouse tracking mode is active. func (t *Terminal) MouseTracking() (bool, error) { var v C.bool @@ -397,6 +466,18 @@ func (t *Terminal) ScrollbackRows() (uint, error) { return uint(v), nil } +// ScrollbackMaxBytes returns the configured approximate scrollback byte +// limit. Nil means the byte limit is unlimited. +func (t *Terminal) ScrollbackMaxBytes() (*uint, error) { + return t.getOptionalSize(C.GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_BYTES) +} + +// ScrollbackMaxLines returns the configured approximate physical line +// limit. Nil means the line limit is unlimited. +func (t *Terminal) ScrollbackMaxLines() (*uint, error) { + return t.getOptionalSize(C.GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_LINES) +} + // Title returns the terminal title as set by escape sequences // (e.g. OSC 0/2). Returns an empty string if unset. The returned // string is copied; it remains valid after subsequent calls to @@ -419,6 +500,21 @@ func (t *Terminal) TotalRows() (uint, error) { return uint(v), nil } +// VTProcessingError reports whether VT processing has ever encountered a +// non-gracefully handled failure that may have prevented a semantic update. +// Reset does not clear this informational flag. +func (t *Terminal) VTProcessingError() (bool, error) { + var v C.bool + if err := resultError(C.ghostty_terminal_get( + t.ptr, + C.GHOSTTY_TERMINAL_DATA_VT_PROCESSING_ERROR, + unsafe.Pointer(&v), + )); err != nil { + return false, err + } + return bool(v), nil +} + // ViewportActive reports whether the viewport is pinned to the active // terminal area. It returns false when the user has scrolled into history. func (t *Terminal) ViewportActive() (bool, error) { @@ -467,3 +563,19 @@ func (t *Terminal) getPalette(data C.GhosttyTerminalData) (*Palette, error) { } return &p, nil } + +// getOptionalSize reads a size_t terminal value that uses NO_VALUE to mean +// unlimited. +func (t *Terminal) getOptionalSize(data C.GhosttyTerminalData) (*uint, error) { + var v C.size_t + err := resultError(C.ghostty_terminal_get(t.ptr, data, unsafe.Pointer(&v))) + if err != nil { + var ge *Error + if errors.As(err, &ge) && ge.Result == ResultNoValue { + return nil, nil + } + return nil, err + } + value := uint(v) + return &value, nil +} diff --git a/terminal_data_test.go b/terminal_data_test.go index 56e56b2..636a992 100644 --- a/terminal_data_test.go +++ b/terminal_data_test.go @@ -160,7 +160,7 @@ func TestTerminalSelection(t *testing.T) { } func TestTerminalTotalScrollbackRows(t *testing.T) { - term, err := NewTerminal(WithSize(80, 24), WithMaxScrollback(100)) + term, err := NewTerminal(WithSize(80, 24), WithMaxScrollbackLines(100)) if err != nil { t.Fatal(err) } @@ -235,7 +235,7 @@ func TestTerminalMouseTracking(t *testing.T) { } func TestTerminalViewportActive(t *testing.T) { - term, err := NewTerminal(WithSize(8, 3), WithMaxScrollback(100)) + term, err := NewTerminal(WithSize(8, 3), WithMaxScrollbackLines(100)) if err != nil { t.Fatal(err) } @@ -281,6 +281,22 @@ func TestTerminalViewportActive(t *testing.T) { } } +func TestTerminalVTProcessingError(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + failed, err := term.VTProcessingError() + if err != nil { + t.Fatal(err) + } + if failed { + t.Fatal("expected a fresh terminal to have no VT processing error") + } +} + func TestTerminalColorRoundTrip(t *testing.T) { term, err := NewTerminal(WithSize(80, 24)) if err != nil { diff --git a/terminal_effect.go b/terminal_effect.go index 3f60675..e59ccda 100644 --- a/terminal_effect.go +++ b/terminal_effect.go @@ -16,6 +16,7 @@ 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 void goPwdChangedTrampoline(GhosttyTerminal, void*); extern GhosttyString goEnquiryTrampoline(GhosttyTerminal, void*); extern GhosttyString goXtversionTrampoline(GhosttyTerminal, void*); extern bool goSizeTrampoline(GhosttyTerminal, void*, GhosttySizeReportSize*); @@ -37,6 +38,9 @@ static inline GhosttyResult set_clipboard_write(GhosttyTerminal t) { static inline GhosttyResult set_title_changed(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, (const void*)goTitleChangedTrampoline); } +static inline GhosttyResult set_pwd_changed(GhosttyTerminal t) { + return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_PWD_CHANGED, (const void*)goPwdChangedTrampoline); +} static inline GhosttyResult set_enquiry(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_ENQUIRY, (const void*)goEnquiryTrampoline); } @@ -88,6 +92,11 @@ func (t *Terminal) syncEffects() { } else { C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_TITLE_CHANGED) } + if t.onPwdChanged != nil { + C.set_pwd_changed(t.ptr) + } else { + C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_PWD_CHANGED) + } if t.onEnquiry != nil { C.set_enquiry(t.ptr) } else { @@ -218,6 +227,14 @@ func goTitleChangedTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) { } } +//export goPwdChangedTrampoline +func goPwdChangedTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) { + t := terminalFromUserdata(userdata) + if t.onPwdChanged != nil { + t.onPwdChanged(t) + } +} + //export goEnquiryTrampoline func goEnquiryTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) C.GhosttyString { t := terminalFromUserdata(userdata) diff --git a/terminal_opt.go b/terminal_opt.go index 3ed1a11..902cb9e 100644 --- a/terminal_opt.go +++ b/terminal_opt.go @@ -38,6 +38,13 @@ func (t *Terminal) SetEffectTitleChanged(fn TitleChangedFn) { t.syncEffects() } +// SetEffectPwdChanged registers (or clears) the pwd-changed effect on a live +// terminal. Pass nil to clear. +func (t *Terminal) SetEffectPwdChanged(fn PwdChangedFn) { + t.onPwdChanged = fn + t.syncEffects() +} + // SetEffectEnquiry registers (or clears) the enquiry effect on a live // terminal. Pass nil to clear. func (t *Terminal) SetEffectEnquiry(fn EnquiryFn) { @@ -136,6 +143,46 @@ func (t *Terminal) SetColorPalette(palette *Palette) error { )) } +// SetDefaultCursorBlink sets whether DECSCUSR reset selects a blinking +// cursor. Passing nil restores the built-in default of not blinking. +func (t *Terminal) SetDefaultCursorBlink(blink *bool) error { + var val unsafe.Pointer + if blink != nil { + v := C.bool(*blink) + val = unsafe.Pointer(&v) + } + return resultError(C.ghostty_terminal_set( + t.ptr, + C.GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK, + val, + )) +} + +// SetDefaultCursorStyle sets the cursor style selected by DECSCUSR reset. +// Passing nil restores the built-in block cursor. +func (t *Terminal) SetDefaultCursorStyle(style *TerminalCursorStyle) error { + var val unsafe.Pointer + if style != nil { + v := C.GhosttyTerminalCursorStyle(*style) + val = unsafe.Pointer(&v) + } + return resultError(C.ghostty_terminal_set( + t.ptr, + C.GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE, + val, + )) +} + +// SetGlyphProtocol enables or disables Glyph Protocol APC handling. +func (t *Terminal) SetGlyphProtocol(enabled bool) error { + v := C.bool(enabled) + return resultError(C.ghostty_terminal_set( + t.ptr, + C.GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL, + unsafe.Pointer(&v), + )) +} + // SetAPCMaxBytes sets the maximum bytes the APC handler will buffer for // all protocols. Passing nil removes all overrides and reverts to the // built-in defaults. @@ -171,15 +218,7 @@ func (t *Terminal) SetAPCMaxBytesKitty(limit *uint) error { // SetPwd sets the terminal working directory manually. An empty string // clears it. func (t *Terminal) SetPwd(pwd string) error { - s := C.GhosttyString{ - ptr: (*C.uint8_t)(unsafe.Pointer(unsafe.StringData(pwd))), - len: C.size_t(len(pwd)), - } - return resultError(C.ghostty_terminal_set( - t.ptr, - C.GHOSTTY_TERMINAL_OPT_PWD, - unsafe.Pointer(&s), - )) + return t.setStringOption(C.GHOSTTY_TERMINAL_OPT_PWD, &pwd) } // SetSelection sets the active screen selection. Passing nil clears the @@ -232,15 +271,13 @@ func (t *Terminal) SetKittyImageMediumFile(enabled bool) error { )) } -// SetKittyImageMediumTempFile enables or disables Kitty image loading via -// the temporary file medium. -func (t *Terminal) SetKittyImageMediumTempFile(enabled bool) error { - v := C.bool(enabled) - return resultError(C.ghostty_terminal_set( - t.ptr, +// SetKittyImageMediumTempFile enables Kitty image loading via the temporary +// file medium and restricts it to directory. Passing nil disables the medium. +func (t *Terminal) SetKittyImageMediumTempFile(directory *string) error { + return t.setStringOption( C.GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE, - unsafe.Pointer(&v), - )) + directory, + ) } // SetKittyImageMediumSharedMem enables or disables Kitty image loading via @@ -254,15 +291,72 @@ func (t *Terminal) SetKittyImageMediumSharedMem(enabled bool) error { )) } +// SetScrollbackMaxBytes sets the approximate maximum scrollback allocation +// in bytes. Passing nil removes the byte limit. Lowering the limit may +// immediately remove eligible historical pages. +func (t *Terminal) SetScrollbackMaxBytes(limit *uint) error { + var val unsafe.Pointer + if limit != nil { + v := C.size_t(*limit) + val = unsafe.Pointer(&v) + } + return resultError(C.ghostty_terminal_set( + t.ptr, + C.GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES, + val, + )) +} + +// SetScrollbackMaxLines sets the approximate maximum number of physical +// lines retained in scrollback. Passing nil removes the line limit. Lowering +// the limit may immediately remove eligible historical pages. +func (t *Terminal) SetScrollbackMaxLines(limit *uint) error { + var val unsafe.Pointer + if limit != nil { + v := C.size_t(*limit) + val = unsafe.Pointer(&v) + } + return resultError(C.ghostty_terminal_set( + t.ptr, + C.GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES, + val, + )) +} + // SetTitle sets the terminal title manually. An empty string clears it. func (t *Terminal) SetTitle(title string) error { + return t.setStringOption(C.GHOSTTY_TERMINAL_OPT_TITLE, &title) +} + +// setStringOption stages a Go string through C-owned memory before passing a +// GhosttyString descriptor to cgo. ghostty_terminal_set copies string options +// synchronously, so the temporary allocation can be released on return. +func (t *Terminal) setStringOption( + option C.GhosttyTerminalOption, + value *string, +) error { + if value == nil { + return resultError(C.ghostty_terminal_set(t.ptr, option, nil)) + } + + length := uintptr(len(*value)) + var ptr unsafe.Pointer + if length > 0 { + ptr = Alloc(length) + if ptr == nil { + return &Error{Result: ResultOutOfMemory} + } + copy(unsafe.Slice((*byte)(ptr), int(length)), *value) + defer Free(ptr, length) + } + s := C.GhosttyString{ - ptr: (*C.uint8_t)(unsafe.Pointer(unsafe.StringData(title))), - len: C.size_t(len(title)), + ptr: (*C.uint8_t)(ptr), + len: C.size_t(length), } return resultError(C.ghostty_terminal_set( t.ptr, - C.GHOSTTY_TERMINAL_OPT_TITLE, + option, unsafe.Pointer(&s), )) } diff --git a/terminal_opt_test.go b/terminal_opt_test.go index 4a2ac83..c2b8499 100644 --- a/terminal_opt_test.go +++ b/terminal_opt_test.go @@ -358,3 +358,127 @@ func TestTerminalSetColorPalette(t *testing.T) { t.Fatal(err) } } + +func TestTerminalSetScrollbackLimits(t *testing.T) { + term, err := NewTerminal( + WithSize(80, 24), + WithMaxScrollbackBytes(4096), + WithMaxScrollbackLines(100), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + bytes, err := term.ScrollbackMaxBytes() + if err != nil { + t.Fatal(err) + } + if bytes == nil || *bytes != 4096 { + t.Fatalf("expected 4096-byte limit, got %v", bytes) + } + + lines, err := term.ScrollbackMaxLines() + if err != nil { + t.Fatal(err) + } + if lines == nil || *lines != 100 { + t.Fatalf("expected 100-line limit, got %v", lines) + } + + if err := term.SetScrollbackMaxBytes(nil); err != nil { + t.Fatal(err) + } + if err := term.SetScrollbackMaxLines(nil); err != nil { + t.Fatal(err) + } + if bytes, err := term.ScrollbackMaxBytes(); err != nil { + t.Fatal(err) + } else if bytes != nil { + t.Fatalf("expected unlimited byte limit, got %v", *bytes) + } + if lines, err := term.ScrollbackMaxLines(); err != nil { + t.Fatal(err) + } else if lines != nil { + t.Fatalf("expected unlimited line limit, got %v", *lines) + } +} + +func TestTerminalAdditionalOptions(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + style := TerminalCursorStyleBar + blink := true + if err := term.SetDefaultCursorStyle(&style); err != nil { + t.Fatal(err) + } + if err := term.SetDefaultCursorBlink(&blink); err != nil { + t.Fatal(err) + } + if err := term.SetGlyphProtocol(true); err != nil { + t.Fatal(err) + } + if err := term.SetDefaultCursorStyle(nil); err != nil { + t.Fatal(err) + } + if err := term.SetDefaultCursorBlink(nil); err != nil { + t.Fatal(err) + } +} + +func TestTerminalPwdChangedEffect(t *testing.T) { + var calls int + var pwd string + term, err := NewTerminal( + WithSize(80, 24), + WithPwdChanged(func(term *Terminal) { + calls++ + pwd, _ = term.Pwd() + }), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + term.VTWrite([]byte("\x1b]7;file:///tmp\x07")) + if calls != 1 { + t.Fatalf("expected one pwd-changed callback, got %d", calls) + } + if pwd != "file:///tmp" { + t.Fatalf("expected pwd %q, got %q", "file:///tmp", pwd) + } + + term.SetEffectPwdChanged(nil) + term.VTWrite([]byte("\x1b]7;file:///var\x07")) + if calls != 1 { + t.Fatalf("expected callback to remain cleared, got %d calls", calls) + } +} + +func TestTerminalCompression(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24), WithMaxScrollbackLines(100)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + if _, err := term.CompressionActivity(); err != nil { + t.Fatal(err) + } + result, err := term.Compress(TerminalCompressionIncremental) + if err != nil { + t.Fatal(err) + } + switch result { + case TerminalCompressionUnsupported, + TerminalCompressionPending, + TerminalCompressionComplete: + default: + t.Fatalf("unexpected compression result %d", result) + } +} diff --git a/terminal_test.go b/terminal_test.go index da20d6b..730812b 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -34,7 +34,7 @@ func TestNewTerminalNoOptions(t *testing.T) { } func TestNewTerminalWithScrollback(t *testing.T) { - term, err := NewTerminal(WithSize(80, 24), WithMaxScrollback(1000)) + term, err := NewTerminal(WithSize(80, 24), WithMaxScrollbackLines(1000)) if err != nil { t.Fatal(err) } diff --git a/types.go b/types.go index f4559b0..aef932f 100644 --- a/types.go +++ b/types.go @@ -5,6 +5,12 @@ package libghostty */ import "C" +// TypeJSON returns a process-lifetime JSON description of every C API struct +// layout for the current target. +func TypeJSON() string { + return C.GoString(C.ghostty_type_json()) +} + // SurfacePosition is an x/y position in rendered surface pixel space. // // This is not a terminal grid coordinate. The origin is the top-left of the diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..70f79fa --- /dev/null +++ b/types_test.go @@ -0,0 +1,20 @@ +package libghostty + +import ( + "encoding/json" + "testing" +) + +func TestTypeJSON(t *testing.T) { + value := TypeJSON() + if value == "" { + t.Fatal("expected non-empty type metadata") + } + var decoded map[string]any + if err := json.Unmarshal([]byte(value), &decoded); err != nil { + t.Fatalf("invalid type metadata JSON: %v", err) + } + if len(decoded) == 0 { + t.Fatal("expected type metadata entries") + } +} diff --git a/unicode.go b/unicode.go new file mode 100644 index 0000000..6f87201 --- /dev/null +++ b/unicode.go @@ -0,0 +1,35 @@ +package libghostty + +// Unicode display-width utilities from unicode.h. + +/* +#include +*/ +import "C" + +import "unsafe" + +// UnicodeCodepointWidth returns the terminal display width of cp in cells. +// It uses the same width table as libghostty's terminal text layout. +func UnicodeCodepointWidth(cp uint32) uint8 { + return uint8(C.ghostty_unicode_codepoint_width(C.uint32_t(cp))) +} + +// UnicodeGraphemeWidth measures the first grapheme cluster in codepoints. +// It returns the number of codepoints consumed and that cluster's display +// width in cells. Empty input returns zero for both values. +func UnicodeGraphemeWidth(codepoints []uint32) (consumed int, width uint8) { + if len(codepoints) == 0 { + return 0, 0 + } + + // The C API is read-only for this call, so the Go-owned slice remains + // valid without a copy. + var out C.uint8_t + n := C.ghostty_unicode_grapheme_width( + (*C.uint32_t)(unsafe.Pointer(&codepoints[0])), + C.size_t(len(codepoints)), + &out, + ) + return int(n), uint8(out) +} diff --git a/unicode_test.go b/unicode_test.go new file mode 100644 index 0000000..71fb02a --- /dev/null +++ b/unicode_test.go @@ -0,0 +1,25 @@ +package libghostty + +import "testing" + +func TestUnicodeWidths(t *testing.T) { + if got := UnicodeCodepointWidth('a'); got != 1 { + t.Fatalf("expected narrow ASCII width 1, got %d", got) + } + if got := UnicodeCodepointWidth(0x0301); got != 0 { + t.Fatalf("expected combining mark width 0, got %d", got) + } + if got := UnicodeCodepointWidth(0x1f600); got != 2 { + t.Fatalf("expected emoji width 2, got %d", got) + } + + // Woman technologist: woman + ZWJ + laptop. + consumed, width := UnicodeGraphemeWidth([]uint32{0x1f469, 0x200d, 0x1f4bb}) + if consumed != 3 || width != 2 { + t.Fatalf("expected one 3-codepoint wide cluster, got consumed=%d width=%d", consumed, width) + } + + if consumed, width := UnicodeGraphemeWidth(nil); consumed != 0 || width != 0 { + t.Fatalf("expected empty input result 0,0, got %d,%d", consumed, width) + } +} -- 2.51.2