diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 594579744..c00d6119a 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; + A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; @@ -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 */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7fb8f9a07..5adef8ded 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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 } + guard let window else { return } - // Set our explicit appearance if we need to based on the configuration. - window.appearance = surfaceConfig.windowAppearance + if let window = window as? LegacyTerminalWindow { + // Update our window light/darkness based on our updated background color + window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor - // 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 - // Sync our zoom state for splits - window.surfaceIsZoomed = surfaceTree.zoomed != nil + // 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 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 - - 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 diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 74744a962..daf5b4554 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -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 + } + } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 9f7b5e62f..dfe2d35a1 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -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, *) diff --git a/macos/Sources/Helpers/Extensions/Double+Extension.swift b/macos/Sources/Helpers/Extensions/Double+Extension.swift new file mode 100644 index 000000000..8d1151bac --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Double+Extension.swift @@ -0,0 +1,5 @@ +extension Double { + func clamped(to range: ClosedRange) -> Double { + return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +}