From 31b65cdc24cfec370d313522ee3cb3dcb0ec48d3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 20 Sep 2026 22:01:45 +0000 Subject: [PATCH] lib: update libghostty and bind render holds Update the libghostty pin to the latest upstream main, which adds render hold notifications for synchronized output along with internal mode lookup and Unicode data changes. Expose render hold notifications through constructor and live-terminal callbacks. Document callback timing, reset and resize behavior, timeout ownership, and safe render-state capture. Add tests for transitions, callback removal, render-state capture, and direct mode changes. --- CMakeLists.txt | 2 +- doc.go | 13 +++++++++---- render_state.go | 12 +++++++----- terminal.go | 50 ++++++++++++++++++++++++++++++++++++++++++++------ terminal_effect.go | 20 +++++++++++++++++++- terminal_opt.go | 8 ++++++++ terminal_opt_test.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 file(s) changed, 168 insertion(s)(+), 17 deletion(s)(-) diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ 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 --- a/doc.go +++ b/doc.go @@ -48,18 +48,23 @@ // that produced them. // // # 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 --- a/render_state.go +++ b/render_state.go @@ -144,11 +144,13 @@ C.ghostty_render_state_free(rs.ptr) 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 --- a/terminal.go +++ b/terminal.go @@ -12,12 +12,14 @@ ) // 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 @@ onSize SizeFn 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 @@ onSize SizeFn 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 @@ // enabled with [WithUnknownMaxBytes] or [Terminal.SetUnknownMaxBytes]. // 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 @@ c.onUnknownSequence = fn } } +// 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 @@ onSize: cfg.onSize, onColorScheme: cfg.onColorScheme, onDeviceAttributes: cfg.onDeviceAttributes, onUnknownSequence: cfg.onUnknownSequence, + onRenderHold: cfg.onRenderHold, } } diff --git a/terminal_effect.go b/terminal_effect.go --- 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 @@ -72,6 +73,9 @@ return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES, (const void*)goDeviceAttributesTrampoline); } 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 @@ -196,6 +200,11 @@ C.set_unknown_sequence(t.ptr) } 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 @@ t.onXtversion != nil || 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. @@ -567,6 +577,14 @@ } } 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 diff --git a/terminal_opt.go b/terminal_opt.go --- a/terminal_opt.go +++ b/terminal_opt.go @@ -111,6 +111,14 @@ t.onUnknownSequence = fn 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 --- a/terminal_opt_test.go +++ b/terminal_opt_test.go @@ -2,6 +2,7 @@ package libghostty import ( "bytes" + "slices" "testing" ) @@ -170,6 +171,85 @@ term.SetEffectBell(nil) term.VTWrite([]byte("\x07")) if bellCount != 1 { t.Fatalf("expected still 1 bell after clearing, got %d", bellCount) + } +} + +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) } } -- tangled.sh