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) + } +}