macos: transparent titlebar handles transparent background

pull/7588/head
Mitchell Hashimoto 2025-06-11 12:37:15 -07:00
parent 6ce7f612a6
commit 3595b2a847
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
5 changed files with 137 additions and 42 deletions

View File

@ -12,6 +12,7 @@
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
@ -134,6 +135,7 @@
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTerminalWindow.swift; sourceTree = "<group>"; };
A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = "<group>"; };
@ -464,6 +466,7 @@
isa = PBXGroup;
children = (
A586366A2DF0A98900E04A10 /* Array+Extension.swift */,
A50297342DFA0F3300B4E924 /* Double+Extension.swift */,
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
@ -737,6 +740,7 @@
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */,

View File

@ -449,57 +449,34 @@ class TerminalController: BaseTerminalController {
}
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// Let our window handle its own appearance
if let window = window as? TerminalWindow {
window.syncAppearance(surfaceConfig)
}
guard let window = self.window as? LegacyTerminalWindow else { return }
// Set our explicit appearance if we need to based on the configuration.
window.appearance = surfaceConfig.windowAppearance
guard let window else { return }
if let window = window as? LegacyTerminalWindow {
// Update our window light/darkness based on our updated background color
window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
// Sync our zoom state for splits
window.surfaceIsZoomed = surfaceTree.zoomed != nil
// If our window is not visible, then we do nothing. Some things such as blurring
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard window.isVisible else { return }
// Set the font for the window and tab titles.
if let titleFontName = surfaceConfig.windowTitleFontFamily {
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
} else {
window.titlebarFont = nil
}
// If we have window transparency then set it transparent. Otherwise set it opaque.
// Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through.
if (!window.styleMask.contains(.fullScreen) &&
surfaceConfig.backgroundOpacity < 1
) {
window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
// matches Terminal.app much more closer. This lets users transition from
// Terminal.app more easily.
window.backgroundColor = .white.withAlphaComponent(0.001)
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
} else {
window.isOpaque = true
window.backgroundColor = .windowBackgroundColor
}
window.hasShadow = surfaceConfig.macosWindowShadow
// If our window is not visible, then we do nothing. Some things such as blurring
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard window.isVisible else { return }
guard window.hasStyledTabs else { return }
guard let window = window as? LegacyTerminalWindow, window.hasStyledTabs else { return }
// Our background color depends on if our focused surface borders the top or not.
// If it does, we match the focused surface. If it doesn't, we use the app

View File

@ -1,4 +1,5 @@
import AppKit
import GhosttyKit
/// The base class for all standalone, "normal" terminal windows. This sets the basic
/// style and configuration of the window based on the app configuration.
@ -7,6 +8,14 @@ class TerminalWindow: NSWindow {
/// used by the manual float on top menu item feature.
static let defaultLevelKey: String = "TerminalDefaultLevel"
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig?
/// Gets the terminal controller from the window controller.
var terminalController: TerminalController? {
windowController as? TerminalController
}
// MARK: NSWindow Overrides
override func awakeFromNib() {
@ -15,6 +24,9 @@ class TerminalWindow: NSWindow {
// All new windows are based on the app config at the time of creation.
let config = appDelegate.ghostty.config
// Setup our initial config
derivedConfig = .init(config)
// If window decorations are disabled, remove our title
if (!config.windowDecorations) { styleMask.remove(.titled) }
@ -42,7 +54,71 @@ class TerminalWindow: NSWindow {
// MARK: Positioning And Styling
/// This is called by the controller when there is a need to reset the window apperance.
func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {}
func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// If our window is not visible, then we do nothing. Some things such as blurring
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard isVisible else { return }
// Basic properties
appearance = surfaceConfig.windowAppearance
hasShadow = surfaceConfig.macosWindowShadow
// Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through.
if !styleMask.contains(.fullScreen) &&
surfaceConfig.backgroundOpacity < 1
{
isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
// matches Terminal.app much more closer. This lets users transition from
// Terminal.app more easily.
backgroundColor = .white.withAlphaComponent(0.001)
if let appDelegate = NSApp.delegate as? AppDelegate {
ghostty_set_window_background_blur(
appDelegate.ghostty.app,
Unmanaged.passUnretained(self).toOpaque())
}
} else {
isOpaque = true
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
self.backgroundColor = backgroundColor.withAlphaComponent(1)
}
}
/// The preferred window background color. The current window background color may not be set
/// to this, since this is dynamic based on the state of the surface tree.
///
/// This background color will include alpha transparency if set. If the caller doesn't want that,
/// change the alpha channel again manually.
var preferredBackgroundColor: NSColor? {
if let terminalController, !terminalController.surfaceTree.isEmpty {
// If our focused surface borders the top then we prefer its background color
if let focusedSurface = terminalController.focusedSurface,
let treeRoot = terminalController.surfaceTree.root,
let focusedNode = treeRoot.node(view: focusedSurface),
treeRoot.spatial().doesBorder(side: .up, from: focusedNode),
let backgroundcolor = focusedSurface.backgroundColor {
let alpha = focusedSurface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
return NSColor(backgroundcolor).withAlphaComponent(alpha)
}
// Doesn't border the top or we don't have a focused surface, so
// we try to match the top-left surface.
let topLeftSurface = terminalController.surfaceTree.root?.leftmostLeaf()
if let topLeftBgColor = topLeftSurface?.backgroundColor {
let alpha = topLeftSurface?.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) ?? 1
return NSColor(topLeftBgColor).withAlphaComponent(alpha)
}
}
let alpha = derivedConfig?.backgroundOpacity.clamped(to: 0.001...1) ?? 1
return derivedConfig?.backgroundColor.withAlphaComponent(alpha)
}
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
// If we don't have an X/Y then we try to use the previously saved window pos.
@ -72,4 +148,21 @@ class TerminalWindow: NSWindow {
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
}
// MARK: Config
struct DerivedConfig {
let backgroundColor: NSColor
let backgroundOpacity: Double
init() {
self.backgroundColor = NSColor.windowBackgroundColor
self.backgroundOpacity = 1
}
init(_ config: Ghostty.Config) {
self.backgroundColor = NSColor(config.backgroundColor)
self.backgroundOpacity = config.backgroundOpacity
}
}
}

View File

@ -33,6 +33,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// MARK: Appearance
override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
super.syncAppearance(surfaceConfig)
lastSurfaceConfig = surfaceConfig
if #available(macOS 26.0, *) {
syncAppearanceTahoe(surfaceConfig)
@ -43,9 +45,23 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
@available(macOS 26.0, *)
private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
guard let titlebarBackgroundView else { return }
titlebarBackgroundView.isHidden = true
backgroundColor = NSColor(surfaceConfig.backgroundColor)
// 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.
//
// As an inverse, if we don't have transparency, we don't bother with this because
// the window background will be set to the correct color so we can just hide the
// titlebar completely and we're good to go.
if !isOpaque {
if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") {
titlebarView.wantsLayer = true
titlebarView.layer?.backgroundColor = 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, *)

View File

@ -0,0 +1,5 @@
extension Double {
func clamped(to range: ClosedRange<Double>) -> Double {
return Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}