Files
ghostty/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
Mitchell Hashimoto cfa0ca7106 macos: defer transparent titlebar KVO rebinding
Fixes #13386

Defer transparent-titlebar KVO rebinding to the next main-queue turn.
Track the observed tab group so unchanged bindings are preserved.

Previously, a tab-group callback could invalidate and recreate its own
observation before returning, leaving closed terminal windows registered
with AppKit after the undo timeout. These windows accumulated titlebar and
layer state, increasing memory use and WindowServer CPU with tab churn.

Validated with an AppDelegate change that sat and created/closed tabs
in a loop, then counted weak controllers/windows/nsapp window.

Co-authored-by: Mustafa J <mustafa.0x@gmail.com>
2026-08-04 06:40:41 -07:00

228 lines
9.3 KiB
Swift

import AppKit
/// A terminal window style that provides a transparent titlebar effect. With this effect, the titlebar
/// matches the background color of the window.
class TransparentTitlebarTerminalWindow: TerminalWindow {
/// Stores the last surface configuration to reapply appearance when needed.
/// This is necessary because various macOS operations (tab switching, tab bar
/// visibility changes) can reset the titlebar appearance.
private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig?
/// KVO observation for tab group window changes.
private weak var observedTabGroup: NSWindowTabGroup?
private var tabGroupWindowsObservation: NSKeyValueObservation?
private var tabBarVisibleObservation: NSKeyValueObservation?
deinit {
tabGroupWindowsObservation?.invalidate()
tabBarVisibleObservation?.invalidate()
}
// MARK: NSWindow
override func awakeFromNib() {
super.awakeFromNib()
// Setup all the KVO we will use, see the docs for the respective functions
// to learn why we need KVO.
setupKVO()
}
override func becomeMain() {
super.becomeMain()
guard let lastSurfaceConfig else { return }
syncAppearance(lastSurfaceConfig)
// This is a nasty edge case. If we're going from 2 to 1 tab and the tab bar
// automatically disappears, then we need to resync our appearance because
// at some point macOS replaces the tab views.
if tabGroup?.windows.count ?? 0 == 2 {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
self?.syncAppearance(self?.lastSurfaceConfig ?? lastSurfaceConfig)
}
}
}
override func update() {
super.update()
// On macOS 13 to 15, we need to hide the NSVisualEffectView in order to allow our
// titlebar to be truly transparent.
if #unavailable(macOS 26) {
if !effectViewIsHidden {
hideEffectView()
}
}
}
// MARK: Appearance
override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
super.syncAppearance(surfaceConfig)
// override appearance based on the terminal's background color
if let preferredBackgroundColor {
appearance = (preferredBackgroundColor.isLightColor ? NSAppearance(named: .aqua) : NSAppearance(named: .darkAqua))
}
// Save our config in case we need to reapply
lastSurfaceConfig = surfaceConfig
// Every time we change appearance, set KVO up again in case any of our
// references changed (e.g. tabGroup is new).
setupKVO()
if #available(macOS 26.0, *) {
syncAppearanceTahoe(surfaceConfig)
} else {
syncAppearanceVentura(surfaceConfig)
}
}
@available(macOS 26.0, *)
private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// When we have transparency, we need to set the titlebar background to match the
// window background but with opacity. The window background is set using the
// "preferred background color" property.
//
// Even if we aren't transparent, we still set this because this becomes the
// color of the titlebar in native fullscreen view.
if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") {
titlebarView.wantsLayer = true
// For glass background styles, use a transparent titlebar to let the glass effect show through
// Only apply this for transparent and tabs titlebar styles
let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle
let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == .transparent ||
derivedConfig.macosTitlebarStyle == .tabs
titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar)
? NSColor.clear.cgColor
: preferredBackgroundColor?.cgColor
}
// In all cases, we have to hide the background view since this has multiple subviews
// that force a background color.
titlebarBackgroundView?.isHidden = true
}
@available(macOS 13.0, *)
private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
guard let titlebarContainer else { return }
// Setup the titlebar background color to match ours
titlebarContainer.wantsLayer = true
titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor
// See the docs for the function that sets this to true on why
effectViewIsHidden = false
// Necessary to not draw the border around the title
titlebarAppearsTransparent = true
}
// MARK: View Finders
private var titlebarBackgroundView: NSView? {
titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView")
}
// MARK: Tab Group Observation
private func setupKVO() {
// This can run from one of the observation callbacks below. Replacing
// an observation before its callback returns leaves the window retained
// by AppKit, so always rebind on the next main-queue turn.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Recheck because the tab group and observation state may have changed
// while this work was waiting on the main queue.
let currentTabGroup = self.tabGroup
let observationsValid = currentTabGroup == nil || (
self.tabGroupWindowsObservation != nil &&
self.tabBarVisibleObservation != nil
)
// Keep the existing observations when they already match.
guard self.observedTabGroup !== currentTabGroup || !observationsValid else { return }
self.observedTabGroup = currentTabGroup
self.setupTabGroupObservation()
self.setupTabBarVisibleObservation()
}
}
/// Monitors the tabGroup windows value for any changes and resyncs the appearance on change.
/// This is necessary because when the windows change, the tab bar and titlebar are recreated
/// which breaks our changes.
private func setupTabGroupObservation() {
// Remove existing observation if any
tabGroupWindowsObservation?.invalidate()
tabGroupWindowsObservation = nil
// Check if tabGroup is available
guard let tabGroup else { return }
// Set up KVO observation for the windows array. Whenever it changes
// we resync the appearance because it can cause macOS to redraw the
// tab bar.
tabGroupWindowsObservation = tabGroup.observe(
\.windows,
options: [.new]
) { [weak self] _, _ in
// NOTE: At one point, I guarded this on only if we went from 0 to N
// or N to 0 under the assumption that the tab bar would only get
// replaced on those cases. This turned out to be false (Tahoe).
// It's cheap enough to always redraw this so we should just do it
// unconditionally.
guard let self else { return }
guard let lastSurfaceConfig else { return }
self.syncAppearance(lastSurfaceConfig)
}
}
/// Monitors the tab bar for visibility. This lets the "Show/Hide Tab Bar" manual menu item
/// to not break our appearance.
private func setupTabBarVisibleObservation() {
// Remove existing observation if any
tabBarVisibleObservation?.invalidate()
tabBarVisibleObservation = nil
// Set up KVO observation for isTabBarVisible
tabBarVisibleObservation = tabGroup?.observe(
\.isTabBarVisible,
options: [.new]
) { [weak self] _, _ in
guard let self else { return }
guard let lastSurfaceConfig else { return }
self.syncAppearance(lastSurfaceConfig)
}
}
// MARK: macOS 13 to 15
// We only need to set this once, but need to do it after the window has been created in order
// to determine if the theme is using a very dark background, in which case we don't want to
// remove the effect view if the default tab bar is being used since the effect created in
// `updateTabsForVeryDarkBackgrounds` creates a confusing visual design.
private var effectViewIsHidden = false
private func hideEffectView() {
guard !effectViewIsHidden else { return }
// By hiding the visual effect view, we allow the window's (or titlebar's in this case)
// background color to show through. If we were to set `titlebarAppearsTransparent` to true
// the selected tab would look fine, but the unselected ones and new tab button backgrounds
// would be an opaque color. When the titlebar isn't transparent, however, the system applies
// a compositing effect to the unselected tab backgrounds, which makes them blend with the
// titlebar's/window's background.
if let effectView = titlebarContainer?.descendants(withClassName: "NSVisualEffectView").first {
effectView.isHidden = true
}
effectViewIsHidden = true
}
}