From 6ec5d15d1051b6c3f0119bc237d521b35c6df659 Mon Sep 17 00:00:00 2001 From: Aryeh Date: Tue, 30 Jun 2026 00:31:18 -0400 Subject: [PATCH] Turn off continuous rendering for panes that are not visible in the app. --- Macterm/App/MactermApp.swift | 20 ++++++++++ Macterm/Ghostty/GhosttyApp.swift | 10 +++++ .../Terminal/GhosttyTerminalNSView.swift | 39 ++++++++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/Macterm/App/MactermApp.swift b/Macterm/App/MactermApp.swift index 9a9d3e7..ff64dd0 100644 --- a/Macterm/App/MactermApp.swift +++ b/Macterm/App/MactermApp.swift @@ -176,6 +176,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var windowObserver: Any? private var activateObserver: Any? + private var appFocusObservers: [Any] = [] private var mainAppResponder: MainAppResponder? private var hasInstalledResponders = false @@ -214,6 +215,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + // Tell libghostty when we stop/start being the active app so idle + // surfaces stop blinking the cursor and animating while backgrounded. + // Visible terminals keep rendering real output (that's gated by + // per-surface occlusion, not app focus), so watching a running command + // from another app still updates. + let focusCenter = NotificationCenter.default + appFocusObservers = [ + focusCenter.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { _ in MainActor.assumeIsolated { GhosttyApp.shared.setAppFocus(true) } }, + focusCenter.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { _ in MainActor.assumeIsolated { GhosttyApp.shared.setAppFocus(false) } }, + ] + windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeMainNotification, object: nil, diff --git a/Macterm/Ghostty/GhosttyApp.swift b/Macterm/Ghostty/GhosttyApp.swift index 58d8570..6e9f367 100644 --- a/Macterm/Ghostty/GhosttyApp.swift +++ b/Macterm/Ghostty/GhosttyApp.swift @@ -100,6 +100,16 @@ final class GhosttyApp { ghostty_app_tick(app) } + /// Propagate application-level focus to libghostty. When the app is not the + /// active app, surfaces stop blinking the cursor and running idle + /// animations — redraws that otherwise keep every surface's renderer busy. + /// Visible surfaces still draw real terminal output; this only throttles + /// app-focus-driven repaints (see also per-surface occlusion). + func setAppFocus(_ focused: Bool) { + guard let app else { return } + ghostty_app_set_focus(app, focused) + } + // MARK: - Config /// Result of a config (re)load. `missingUserConfigPath` is populated when diff --git a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift index e0bb262..9dd0c97 100644 --- a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift +++ b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift @@ -218,6 +218,24 @@ final class GhosttyTerminalNSView: NSView { ghostty_surface_set_display_id(surface, displayID) } ghostty_surface_set_focus(surface, isFocused) + syncOcclusion() + } + + /// Tell libghostty whether this surface's pixels are actually on screen. + /// An occluded surface — an off-screen tab parked in the `SurfaceIncubator` + /// window, or a covered/minimized/hidden main window — parks its renderer's + /// display link instead of drawing every frame. With many panes that idle + /// redraw is the dominant background CPU cost. The pty io thread keeps + /// running while occluded, so off-screen output stays current and the tab + /// is up to date the moment it's viewed. + /// + /// The bool is "visible" (matches Ghostty's own `updateOcclusionState`): + /// true when the surface's window reports `.visible`, false otherwise — + /// including when the view has no window at all. + private func syncOcclusion() { + guard let surface else { return } + let visible = window?.occlusionState.contains(.visible) ?? false + ghostty_surface_set_occlusion(surface, visible) } func destroySurface() { @@ -269,7 +287,13 @@ final class GhosttyTerminalNSView: NSView { } windowObservers.removeAll() - guard let window else { return } + guard let window else { + // Detached from its window (e.g. pulled out of the incubator before + // re-attaching). Mark occluded so the renderer doesn't draw to an + // off-screen layer. + syncOcclusion() + return + } if surface == nil { createSurface() } else { @@ -303,7 +327,18 @@ final class GhosttyTerminalNSView: NSView { queue: .main, using: handler ) - windowObservers = [backing, screen] + // Park the renderer when this view's window is covered, minimized, or + // hidden; resume when it's revealed. The incubator window never reports + // `.visible`, so off-screen tabs stay occluded for free. + let occlusion = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.syncOcclusion() } + } + windowObservers = [backing, screen, occlusion] + syncOcclusion() } override func setFrameSize(_ newSize: NSSize) {