Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Macterm/App/MactermApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions Macterm/Ghostty/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions Macterm/Views/Terminal/GhosttyTerminalNSView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down