Skip to content

Surface: visibility-gated rendering, draw throttling, live-resize winsize coalescing#7

Open
robgough wants to merge 1 commit into
arach:mainfrom
robgough:perf/surface-render-efficiency
Open

Surface: visibility-gated rendering, draw throttling, live-resize winsize coalescing#7
robgough wants to merge 1 commit into
arach:mainfrom
robgough:perf/surface-render-efficiency

Conversation

@robgough

@robgough robgough commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Render-path efficiency work from Belfry, which keeps several warm surfaces mounted at once (instant session switching) and cares a lot about battery. Three related changes to the macOS surface:

Visibility gating. New isRenderVisible parameter on TerminiTerminalView/TerminiSurfaceView (default true — fully additive), combined with the hosting window's occlusion state into a canRender gate. A gated surface runs no render timers, skips per-output draws, and gets ghostty_surface_set_occlusion(false) so libghostty's renderer idles too; output is still processed and the PTY winsize still tracks layout, with one catch-up draw on reveal. Before: a hidden surface receiving output rendered invisibly at up to 30 Hz forever, and the focused surface's 2 Hz idle loop kept drawing while the window was occluded or minimized.

Draw throttling. Every output chunk previously did a synchronous ghostty_surface_draw — unbounded frame rate under output floods. Immediate draws are now capped at ~60 fps with the existing 30 Hz burst timer coalescing the rest (echo latency for interactive typing is unaffected). Render timers gained tolerances so the OS can coalesce wakeups.

Live-resize winsize coalescing. AppKit emits ~60 layout passes/sec during a window drag, and each TIOCSWINSZ is a SIGWINCH + full redraw in the child — very janky under tmux with aggressive-resize. The visual surface still tracks the window every frame; the PTY push is throttled to ~12 Hz with one authoritative final push in viewDidEndLiveResize.

Combined with the tick-backstop PR, this took an idle host app from ~22% CPU to ~0.0% measured over 30 s windows, with a busy hidden surface costing nothing until revealed.

Builds clean for macOS (swift build) and iOS (xcodebuild -scheme Termini -destination 'generic/platform=iOS Simulator'); the iOS init accepts isRenderVisible for API parity and ignores it (its CADisplayLink rendering is untouched).

🤖 Generated with Claude Code

https://claude.ai/code/session_01RcSW6KsDkBruL1dPD63rhX

Render-path efficiency work for hosts that keep several surfaces alive
(warm caches) and for battery life generally:

- New isRenderVisible parameter on TerminiTerminalView /
  TerminiSurfaceView (default true — additive). Combined with the
  window's occlusion state into a canRender gate: a gated surface runs
  no render timers, skips per-output draws, and gets
  ghostty_surface_set_occlusion(false) so libghostty's renderer idles
  too. Output is still processed and PTY winsize still tracks layout;
  one catch-up draw runs on reveal. Previously a hidden surface
  receiving output rendered invisibly at up to 30 Hz forever, and the
  focused surface's idle loop kept drawing with the window occluded or
  minimized.

- Immediate per-output-chunk draws are capped at ~60 fps; under an
  output flood the 30 Hz burst timer coalesces frames instead of
  drawing synchronously per chunk. Render timers gained tolerance so
  the OS can coalesce their wakeups.

- PTY winsize pushes are coalesced during live resize: AppKit emits
  ~60 layout passes/sec while dragging, and each TIOCSWINSZ triggers a
  SIGWINCH + full redraw in the child (very janky under tmux with
  aggressive-resize). The visual surface still tracks the window every
  frame; the winsize push is throttled to ~12 Hz with one authoritative
  final push in viewDidEndLiveResize.

The iOS init accepts isRenderVisible for API parity (its rendering is
CADisplayLink-driven and untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcSW6KsDkBruL1dPD63rhX
NotificationCenter.default.removeObserver(occlusionObserver)
self.occlusionObserver = nil
}
guard let window else {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the view detaches, canRender becomes false, but this branch returns without calling renderGateChanged(). The surface remains alive, so Ghostty retains its previous visible state and may keep its renderer/display link active.

Could this call renderGateChanged() instead? It already stops the local render loop when canRender is false and will also propagate ghostty_surface_set_occlusion(surface, false).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants