diff --git a/TODO.md b/TODO.md index efcb9d7..4140d7e 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,6 @@ - [x] Kitty graphics (`kitty_graphics.h`) - [x] Allocator (`allocator.h` — `ghostty_alloc`, `ghostty_free`) - [ ] Selection APIs (`selection.h`) -- [ ] Tracked grid references (`grid_ref_tracked.h`, `ghostty_terminal_grid_ref_track()`) - [ ] Render-state row selection (`GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION`) ## Partially Bound @@ -21,6 +20,4 @@ - [ ] `ghostty_type_json()` - [ ] `ghostty_style_default()` - [ ] `ghostty_color_rgb_get()` -- [ ] `ghostty_grid_ref_hyperlink_uri()` -- [ ] `ghostty_terminal_point_from_grid_ref()` - [ ] `ghostty_formatter_format_buf()` diff --git a/grid_ref.go b/grid_ref.go index 0c1993a..0fb9cc2 100644 --- a/grid_ref.go +++ b/grid_ref.go @@ -85,6 +85,38 @@ func (g *GridRef) Graphemes() ([]uint32, error) { return buf[:uint(outLen)], nil } +// HyperlinkURI returns the URI for the hyperlink at the grid reference's +// position. It returns an empty string when the cell has no hyperlink. +func (g *GridRef) HyperlinkURI() (string, error) { + // First call to get the required length. A cell with no hyperlink returns + // success with a length of zero; a hyperlinked cell returns OUT_OF_SPACE + // and reports the buffer size needed for the URI bytes. + var outLen C.size_t + err := resultError(C.ghostty_grid_ref_hyperlink_uri(&g.ref, nil, 0, &outLen)) + if err != nil { + ge, ok := err.(*Error) + if !ok || ge.Result != ResultOutOfSpace { + return "", err + } + } + + if outLen == 0 { + return "", nil + } + + buf := make([]byte, uint(outLen)) + if err := resultError(C.ghostty_grid_ref_hyperlink_uri( + &g.ref, + (*C.uint8_t)(unsafe.Pointer(&buf[0])), + C.size_t(len(buf)), + &outLen, + )); err != nil { + return "", err + } + + return string(buf[:uint(outLen)]), nil +} + // Style returns the style of the cell at the grid reference's position. func (g *GridRef) Style() (*Style, error) { cs := initCStyle() diff --git a/grid_ref_test.go b/grid_ref_test.go new file mode 100644 index 0000000..1c3e629 --- /dev/null +++ b/grid_ref_test.go @@ -0,0 +1,218 @@ +package libghostty + +import ( + "errors" + "testing" +) + +func TestGridRefHyperlinkURIAndPointFromGridRef(t *testing.T) { + term, err := NewTerminal(WithSize(10, 3)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + // OSC 8 starts a hyperlink, prints one cell, then ends the hyperlink. + term.VTWrite([]byte("\x1b]8;;https://example.com\x1b\\A\x1b]8;;\x1b\\")) + + ref, err := term.GridRef(Point{Tag: PointTagActive, X: 0, Y: 0}) + if err != nil { + t.Fatal(err) + } + + uri, err := ref.HyperlinkURI() + if err != nil { + t.Fatal(err) + } + if uri != "https://example.com" { + t.Fatalf("expected hyperlink URI %q, got %q", "https://example.com", uri) + } + + point, err := term.PointFromGridRef(ref, PointTagActive) + if err != nil { + t.Fatal(err) + } + if point != (Point{Tag: PointTagActive, X: 0, Y: 0}) { + t.Fatalf("expected active point 0,0, got %+v", point) + } + + emptyRef, err := term.GridRef(Point{Tag: PointTagActive, X: 1, Y: 0}) + if err != nil { + t.Fatal(err) + } + uri, err = emptyRef.HyperlinkURI() + if err != nil { + t.Fatal(err) + } + if uri != "" { + t.Fatalf("expected empty hyperlink URI for non-hyperlinked cell, got %q", uri) + } +} + +func TestTerminalPointFromGridRefNoValue(t *testing.T) { + term, err := NewTerminal(WithSize(8, 3), WithMaxScrollback(100)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + term.VTWrite([]byte("alpha\r\nbravo\r\ncharlie\r\ndelta")) + + ref, err := term.GridRef(Point{Tag: PointTagHistory, X: 0, Y: 0}) + if err != nil { + t.Fatal(err) + } + _, err = term.PointFromGridRef(ref, PointTagActive) + assertResultError(t, err, ResultNoValue) +} + +func TestTerminalTrackGridRefInvalidPoint(t *testing.T) { + term, err := NewTerminal(WithSize(8, 3)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + _, err = term.TrackGridRef(Point{Tag: PointTagActive, X: 0, Y: 99}) + assertResultError(t, err, ResultInvalidValue) +} + +func TestTrackedGridRef(t *testing.T) { + term, err := NewTerminal(WithSize(8, 3), WithMaxScrollback(100)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + term.VTWrite([]byte("alpha\r\nbravo\r\ncharlie")) + + tracked, err := term.TrackGridRef(Point{Tag: PointTagActive, X: 0, Y: 0}) + if err != nil { + t.Fatal(err) + } + defer tracked.Close() + + // Force the original first row into scrollback. The tracked reference + // should continue to point at the same cell. + term.VTWrite([]byte("\r\ndelta")) + + if !tracked.HasValue() { + t.Fatal("expected tracked ref to retain value after scroll") + } + + snapshot, err := tracked.Snapshot() + if err != nil { + t.Fatal(err) + } + cell, err := snapshot.Cell() + if err != nil { + t.Fatal(err) + } + cp, err := cell.Codepoint() + if err != nil { + t.Fatal(err) + } + if cp != 'a' { + t.Fatalf("expected tracked codepoint %q after scroll, got %q", 'a', rune(cp)) + } + + point, err := tracked.Point(PointTagScreen) + if err != nil { + t.Fatal(err) + } + if point != (Point{Tag: PointTagScreen, X: 0, Y: 0}) { + t.Fatalf("expected tracked screen point 0,0, got %+v", point) + } + + term.Reset() + if tracked.HasValue() { + t.Fatal("expected tracked ref to lose value after reset") + } + _, err = tracked.Snapshot() + assertResultError(t, err, ResultNoValue) + _, err = tracked.Point(PointTagScreen) + assertResultError(t, err, ResultNoValue) + + term.VTWrite([]byte("echo")) + if err := tracked.Set(term, Point{Tag: PointTagActive, X: 0, Y: 0}); err != nil { + t.Fatal(err) + } + if !tracked.HasValue() { + t.Fatal("expected tracked ref to have value after set") + } + + snapshot, err = tracked.Snapshot() + if err != nil { + t.Fatal(err) + } + cell, err = snapshot.Cell() + if err != nil { + t.Fatal(err) + } + cp, err = cell.Codepoint() + if err != nil { + t.Fatal(err) + } + if cp != 'e' { + t.Fatalf("expected tracked codepoint %q after set, got %q", 'e', rune(cp)) + } +} + +func TestTrackedGridRefClose(t *testing.T) { + term, err := NewTerminal(WithSize(8, 3)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + tracked, err := term.TrackGridRef(Point{Tag: PointTagActive, X: 0, Y: 0}) + if err != nil { + t.Fatal(err) + } + if tracked.ptr == nil { + t.Fatal("expected tracked ref pointer before close") + } + + tracked.Close() + if tracked.ptr != nil { + t.Fatal("expected tracked ref pointer to be nil after close") + } + + // Close sets the handle to nil, and libghostty explicitly permits freeing a + // nil tracked reference. This verifies the Go wrapper is safe to defer after + // an explicit close. + tracked.Close() +} + +func TestTrackedGridRefAfterTerminalClose(t *testing.T) { + term, err := NewTerminal(WithSize(8, 3)) + if err != nil { + t.Fatal(err) + } + + tracked, err := term.TrackGridRef(Point{Tag: PointTagActive, X: 0, Y: 0}) + if err != nil { + term.Close() + t.Fatal(err) + } + defer tracked.Close() + + term.Close() + + if tracked.HasValue() { + t.Fatal("expected tracked ref to lose value after terminal close") + } + _, err = tracked.Snapshot() + assertResultError(t, err, ResultNoValue) + _, err = tracked.Point(PointTagActive) + assertResultError(t, err, ResultNoValue) +} + +func assertResultError(t *testing.T, err error, result Result) { + t.Helper() + + var ge *Error + if !errors.As(err, &ge) || ge.Result != result { + t.Fatalf("expected %v error, got %v", result, err) + } +} diff --git a/grid_ref_tracked.go b/grid_ref_tracked.go new file mode 100644 index 0000000..db96390 --- /dev/null +++ b/grid_ref_tracked.go @@ -0,0 +1,73 @@ +package libghostty + +/* +#include +*/ +import "C" + +// TrackedGridRef is an owned grid reference that follows its cell as the +// terminal changes. Obtain one from [Terminal.TrackGridRef] and release it +// with [TrackedGridRef.Close]. +// +// A tracked reference may lose its value if the referenced grid contents are +// discarded, such as after terminal reset or scrollback pruning. In that state +// [TrackedGridRef.HasValue] returns false and Snapshot or Point return an error +// with ResultNoValue. The same handle can be moved to a new terminal point with +// Set. +// C: GhosttyTrackedGridRef +type TrackedGridRef struct { + ptr C.GhosttyTrackedGridRef +} + +// Close frees the tracked grid reference. Passing an already-closed tracked +// reference is safe; after Close, the reference must not be used again. +func (g *TrackedGridRef) Close() { + C.ghostty_tracked_grid_ref_free(g.ptr) + g.ptr = nil +} + +// HasValue reports whether the tracked grid reference currently has a +// meaningful location. It returns false after the owning terminal is closed or +// after the tracked cell is discarded. +func (g *TrackedGridRef) HasValue() bool { + return bool(C.ghostty_tracked_grid_ref_has_value(g.ptr)) +} + +// Point converts the tracked grid reference to a point in the requested +// coordinate system. If the reference no longer has a value, or cannot be +// represented in that coordinate system, this returns an error with +// ResultNoValue. +func (g *TrackedGridRef) Point(tag PointTag) (Point, error) { + var coord C.GhosttyPointCoordinate + if err := resultError(C.ghostty_tracked_grid_ref_point( + g.ptr, + C.GhosttyPointTag(tag), + &coord, + )); err != nil { + return Point{}, err + } + return pointFromC(tag, coord), nil +} + +// Set moves the tracked grid reference to a new point in its owning terminal. +// The terminal must be the same terminal that originally created the tracked +// reference. On success, any prior no-value state is cleared. +func (g *TrackedGridRef) Set(t *Terminal, point Point) error { + return resultError(C.ghostty_tracked_grid_ref_set( + g.ptr, + t.ptr, + point.toC(), + )) +} + +// Snapshot returns an untracked snapshot of the tracked grid reference's +// current location. The returned GridRef has the same borrowed lifetime rules +// as [Terminal.GridRef]: use it immediately and do not retain it across later +// terminal mutations. +func (g *TrackedGridRef) Snapshot() (*GridRef, error) { + ref := initCGridRef() + if err := resultError(C.ghostty_tracked_grid_ref_snapshot(g.ptr, &ref)); err != nil { + return nil, err + } + return &GridRef{ref: ref}, nil +} diff --git a/point.go b/point.go index 37b80da..45c11e1 100644 --- a/point.go +++ b/point.go @@ -49,3 +49,14 @@ func (p Point) toC() C.GhosttyPoint { coord.y = C.uint32_t(p.Y) return cp } + +// pointFromC converts a C coordinate plus its requested tag into a Go +// Point. The C APIs that resolve refs back to points return only the +// coordinate because the caller already selected the coordinate system. +func pointFromC(tag PointTag, coord C.GhosttyPointCoordinate) Point { + return Point{ + Tag: tag, + X: uint16(coord.x), + Y: uint32(coord.y), + } +} diff --git a/terminal.go b/terminal.go index a21d99d..d5bde94 100644 --- a/terminal.go +++ b/terminal.go @@ -420,6 +420,45 @@ func (t *Terminal) GridRef(point Point) (*GridRef, error) { return &GridRef{ref: ref}, nil } +// TrackGridRef creates an owned tracked grid reference for a terminal +// point. The returned reference follows the referenced cell as normal +// screen operations update the terminal page list. Close the returned +// reference when finished. +// +// The tracked reference is attached to the terminal screen/page-list that +// is active when this method is called. If the terminal is closed first, +// the tracked handle remains valid only for tracked-grid-ref APIs: it +// reports no value and can still be closed. +func (t *Terminal) TrackGridRef(point Point) (*TrackedGridRef, error) { + var ptr C.GhosttyTrackedGridRef + if err := resultError(C.ghostty_terminal_grid_ref_track( + t.ptr, + point.toC(), + &ptr, + )); err != nil { + return nil, err + } + return &TrackedGridRef{ptr: ptr}, nil +} + +// PointFromGridRef converts a grid reference back to a point in the +// requested coordinate system. The grid reference must come from the same +// terminal and must still be valid. If the reference cannot be represented +// in the requested coordinate system, this returns an error with +// ResultNoValue. +func (t *Terminal) PointFromGridRef(ref *GridRef, tag PointTag) (Point, error) { + var coord C.GhosttyPointCoordinate + if err := resultError(C.ghostty_terminal_point_from_grid_ref( + t.ptr, + &ref.ref, + C.GhosttyPointTag(tag), + &coord, + )); err != nil { + return Point{}, err + } + return pointFromC(tag, coord), nil +} + // handleToPointer converts a cgo.Handle (uintptr) to unsafe.Pointer // for passing as C userdata. The handle is an opaque integer, not a // real Go pointer, so we suppress checkptr which would otherwise