diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a6af38..09648f4 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 b32f20f3e8d25bb925ec545c54498e93518e7ced + GIT_TAG 27e8b3fa85d9cf8c7cd5ae2ced348bcb0a4fba9c ) FetchContent_MakeAvailable(ghostty) diff --git a/doc.go b/doc.go index aa34528..90e0428 100644 --- a/doc.go +++ b/doc.go @@ -48,18 +48,23 @@ // // # Effects // -// The terminal communicates side-effects back to the host through +// The terminal communicates side effects back to the host through // effect callbacks. Register them at creation time with functional // options like [WithWritePty], [WithDesktopNotification], and -// [WithProgressReport], or on a live terminal with +// [WithProgressReport], or on an existing terminal with // [Terminal.SetEffectWritePty] and friends. // // Effect callbacks run synchronously during [Terminal.VTWrite] and // [Terminal.VTWriteUntilGround]. They must not call either VT write method // on the same terminal and should avoid blocking for long periods. // -// [WithWritePty] is the most common effect — it delivers data that -// the terminal wants to send back to the pty (e.g. query responses): +// Use [WithRenderHold] when creating a terminal or +// [Terminal.SetEffectRenderHold] later. A [RenderHoldFunc] may call +// [RenderState.Update] to preserve the last complete frame when a render hold +// begins. +// +// [WithWritePty] is the most common effect. It delivers data that the terminal +// wants to send back to the pty, such as query responses: // // term, _ := libghostty.NewTerminal( // libghostty.WithSize(80, 24), diff --git a/render_state.go b/render_state.go index de23d67..60cf652 100644 --- a/render_state.go +++ b/render_state.go @@ -144,11 +144,13 @@ func (rs *RenderState) Close() { rs.ptr = nil } -// Update updates the render state from a terminal instance. This -// consumes terminal/screen dirty state and is the only render-state -// operation that touches the terminal. Hold exclusive access to the -// terminal while this call is running, and do not read from the same -// render state concurrently with Update. +// Update copies the terminal's current display state into rs. It consumes +// dirty state from the terminal and its screen. Update is the only render state +// operation that accesses the terminal. +// +// The caller must hold exclusive access to the terminal while Update runs. Do +// not read from rs concurrently with Update. A [RenderHoldFunc] may call Update +// when a hold begins to capture the last complete frame. func (rs *RenderState) Update(t *Terminal) error { return resultError(C.ghostty_render_state_update(rs.ptr, t.ptr)) } diff --git a/terminal.go b/terminal.go index c643de0..18e3f9b 100644 --- a/terminal.go +++ b/terminal.go @@ -12,12 +12,14 @@ import ( // Terminal wraps a Ghostty VT terminal handle. // It is stateful, not safe for concurrent use, and not reentrant. -// Serialize all calls that touch a terminal, including getters, -// setters, [Terminal.VTWrite], [Terminal.VTWriteUntilGround], -// [Terminal.Resize], [Terminal.Close], -// and any borrowed handles derived from it. Effect callbacks run -// synchronously during terminal operations; they must not reenter the same -// terminal. Clipboard callbacks may block to mediate user permission because +// Serialize all calls that access a terminal. This includes getters, setters, +// [Terminal.VTWrite], [Terminal.VTWriteUntilGround], [Terminal.Resize], +// [Terminal.Close], and operations on borrowed handles derived from the +// terminal. Effect callbacks run synchronously during terminal operations. They +// must not call [Terminal.VTWrite] or [Terminal.VTWriteUntilGround] on the same +// terminal. +// Individual callback types document other operations that are safe during a +// callback. Clipboard callbacks may block to mediate user permission because // the VT stream waits for their replies. // C: GhosttyTerminal type Terminal struct { @@ -41,6 +43,7 @@ type Terminal struct { onColorScheme ColorSchemeFn onDeviceAttributes DeviceAttributesFn onUnknownSequence UnknownSequenceFn + onRenderHold RenderHoldFunc // effectBuf holds C-allocated memory for the most recent response // returned by an effect trampoline (e.g. enquiry, xtversion). @@ -117,6 +120,7 @@ type TerminalConfig struct { onColorScheme ColorSchemeFn onDeviceAttributes DeviceAttributesFn onUnknownSequence UnknownSequenceFn + onRenderHold RenderHoldFunc } // WritePtyFn is called when the terminal writes data back to the pty, such as @@ -424,6 +428,30 @@ type TerminalUnknownSequence struct { // C: GhosttyTerminalUnknownSequenceFn type UnknownSequenceFn func(t *Terminal, sequence TerminalUnknownSequence) +// RenderHoldFunc is called when a terminal starts or ends a render hold. A +// render hold asks the application to keep displaying the last complete frame +// while the running program prepares the next one. held is true when the hold +// starts and false when it ends. +// +// Synchronized output (DEC private mode 2026) is currently the only feature +// that uses render holds. A hold ends when VT input disables synchronized +// output, the terminal is reset, or the terminal is resized. Repeating the +// current synchronized output setting does not call the function again. +// Changing [ModeSyncOutput] with [Terminal.SetMode] does not call the function. +// +// The function runs synchronously while the terminal processes VT input. When +// held is true, the terminal contains the last complete frame, and later bytes +// from the same write have not been processed. A renderer can call +// [RenderState.Update] from the function to preserve that frame, then pause +// further updates until held is false. +// +// libghostty does not time out render holds. Applications should stop honoring +// a hold after a reasonable duration so that a program cannot freeze the +// display indefinitely. +// +// C: GhosttyTerminalRenderHoldFn +type RenderHoldFunc func(t *Terminal, held bool) + // 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. @@ -632,6 +660,15 @@ func WithUnknownSequence(fn UnknownSequenceFn) TerminalOption { } } +// WithRenderHold returns a terminal option that sets fn as the render hold +// callback. If fn is nil, render hold notifications are disabled. See +// [RenderHoldFunc] for callback behavior and timeout guidance. +func WithRenderHold(fn RenderHoldFunc) TerminalOption { + return func(c *TerminalConfig) { + c.onRenderHold = 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. @@ -802,6 +839,7 @@ func terminalFromC(cterm C.GhosttyTerminal, cfg TerminalConfig) *Terminal { onColorScheme: cfg.onColorScheme, onDeviceAttributes: cfg.onDeviceAttributes, onUnknownSequence: cfg.onUnknownSequence, + onRenderHold: cfg.onRenderHold, } } diff --git a/terminal_effect.go b/terminal_effect.go index be7fab4..01b3744 100644 --- a/terminal_effect.go +++ b/terminal_effect.go @@ -27,6 +27,7 @@ extern bool goSizeTrampoline(GhosttyTerminal, void*, GhosttySizeReportSize*); extern bool goColorSchemeTrampoline(GhosttyTerminal, void*, GhosttyColorScheme*); extern bool goDeviceAttributesTrampoline(GhosttyTerminal, void*, GhosttyDeviceAttributes*); extern void goUnknownSequenceTrampoline(GhosttyTerminal, void*, GhosttyTerminalUnknownSequence*); +extern void goRenderHoldTrampoline(GhosttyTerminal, void*, bool); // Helpers to set each effect via ghostty_terminal_set. // We need these because cgo cannot take the address of a Go-exported @@ -73,6 +74,9 @@ static inline GhosttyResult set_device_attributes(GhosttyTerminal t) { static inline GhosttyResult set_unknown_sequence(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE, (const void*)goUnknownSequenceTrampoline); } +static inline GhosttyResult set_render_hold(GhosttyTerminal t) { + return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_RENDER_HOLD, (const void*)goRenderHoldTrampoline); +} // Convert the integer cgo.Handle to native userdata only after control enters // C. A handle is not a valid pointer and must never occupy an unsafe.Pointer @@ -196,6 +200,11 @@ func (t *Terminal) syncEffects() { } else { C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE) } + if t.onRenderHold != nil { + C.set_render_hold(t.ptr) + } else { + C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_RENDER_HOLD) + } } // hasEffects reports whether any native effect trampoline needs to recover @@ -214,7 +223,8 @@ func (t *Terminal) hasEffects() bool { t.onSize != nil || t.onColorScheme != nil || t.onDeviceAttributes != nil || - t.onUnknownSequence != nil + t.onUnknownSequence != nil || + t.onRenderHold != nil } // terminalFromUserdata recovers a *Terminal from the C userdata pointer. @@ -569,6 +579,14 @@ func goUnknownSequenceTrampoline( t.onUnknownSequence(t, value) } +//export goRenderHoldTrampoline +func goRenderHoldTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer, held C.bool) { + t := terminalFromUserdata(userdata) + if t.onRenderHold != nil { + t.onRenderHold(t, bool(held)) + } +} + //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 88c9cc1..ab140f8 100644 --- a/terminal_opt.go +++ b/terminal_opt.go @@ -111,6 +111,14 @@ func (t *Terminal) SetEffectUnknownSequence(fn UnknownSequenceFn) { t.syncEffects() } +// SetEffectRenderHold sets fn as the render hold callback. Passing nil removes +// the current callback. See [RenderHoldFunc] for callback behavior and timeout +// guidance. +func (t *Terminal) SetEffectRenderHold(fn RenderHoldFunc) { + t.onRenderHold = fn + t.syncEffects() +} + // SetColorBackground sets the default background color. Pass nil to // clear (unset). func (t *Terminal) SetColorBackground(c *ColorRGB) error { diff --git a/terminal_opt_test.go b/terminal_opt_test.go index 1cbad26..18d098c 100644 --- a/terminal_opt_test.go +++ b/terminal_opt_test.go @@ -2,6 +2,7 @@ package libghostty import ( "bytes" + "slices" "testing" ) @@ -173,6 +174,85 @@ func TestTerminalSetEffectBell(t *testing.T) { } } +func TestTerminalWithRenderHold(t *testing.T) { + renderState, err := NewRenderState() + if err != nil { + t.Fatal(err) + } + defer renderState.Close() + + var transitions []bool + var captureErr error + term, err := NewTerminal( + WithSize(80, 24), + WithRenderHold(func(term *Terminal, held bool) { + transitions = append(transitions, held) + if held { + // Capture the completed frame before the terminal processes the + // program's next update. + captureErr = renderState.Update(term) + } + }), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + // Synchronized output begins and ends one render hold. Repeating either + // mode transition must not emit duplicate notifications. + term.VTWrite([]byte("\x1b[?2026h\x1b[?2026h")) + term.VTWrite([]byte("\x1b[?2026l\x1b[?2026l")) + + // A full reset and a resize both force an active hold to end so an + // abandoned mode cannot freeze rendering indefinitely. + term.VTWrite([]byte("\x1b[?2026h")) + term.Reset() + term.VTWrite([]byte("\x1b[?2026h")) + if err := term.Resize(81, 25, 8, 16); err != nil { + t.Fatal(err) + } + + // Direct mode changes update terminal state without reporting render hold + // transitions. Only changes caused by VT input, reset, or resize report it. + if err := term.SetMode(ModeSyncOutput, true); err != nil { + t.Fatal(err) + } + if err := term.SetMode(ModeSyncOutput, false); err != nil { + t.Fatal(err) + } + + want := []bool{true, false, true, false, true, false} + if !slices.Equal(transitions, want) { + t.Fatalf("expected render hold transitions %v, got %v", want, transitions) + } + if captureErr != nil { + t.Fatalf("capture render state from hold callback: %v", captureErr) + } +} + +func TestTerminalSetEffectRenderHold(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + var transitions []bool + term.SetEffectRenderHold(func(_ *Terminal, held bool) { + transitions = append(transitions, held) + }) + term.VTWrite([]byte("\x1b[?2026h")) + + // Clearing the callback takes effect immediately. The terminal still + // updates its mode, but no further Go notification is delivered. + term.SetEffectRenderHold(nil) + term.VTWrite([]byte("\x1b[?2026l")) + if want := []bool{true}; !slices.Equal(transitions, want) { + t.Fatalf("expected render hold transitions %v, got %v", want, transitions) + } +} + func TestTerminalWithClipboardWrite(t *testing.T) { var writes []ClipboardWrite term, err := NewTerminal(