From 4d33a73fc4640b19cb6160942c5bdcd2a8102fb9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Jun 2025 13:03:30 -0700 Subject: [PATCH 01/30] wip: redo terminal window styling --- macos/Ghostty.xcodeproj/project.pbxproj | 42 ++++- .../Terminal/TerminalController.swift | 170 +++++------------- .../HiddenTitlebarTerminalWindow.swift | 78 ++++++++ .../LegacyTerminalWindow.swift} | 44 +---- .../Terminal/{ => Window Styles}/Terminal.xib | 8 +- .../Window Styles/TerminalHiddenTitlebar.xib | 31 ++++ .../Terminal/Window Styles/TerminalLegacy.xib | 31 ++++ .../TerminalTransparentTitlebar.xib | 31 ++++ .../Window Styles/TerminalWindow.swift | 75 ++++++++ .../TransparentTitlebarTerminalWindow.swift | 72 ++++++++ .../Helpers/Extensions/NSView+Extension.swift | 27 +++ macos/Sources/Helpers/Fullscreen.swift | 11 +- 12 files changed, 448 insertions(+), 172 deletions(-) create mode 100644 macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift rename macos/Sources/Features/Terminal/{TerminalWindow.swift => Window Styles/LegacyTerminalWindow.swift} (95%) rename macos/Sources/Features/Terminal/{ => Window Styles}/Terminal.xib (86%) create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift create mode 100644 macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 9686dcbd1..594579744 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -15,7 +15,7 @@ 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 */; }; - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */; }; + A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; }; @@ -51,6 +51,12 @@ A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; + A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */; }; + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; }; + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; }; @@ -129,7 +135,7 @@ 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 = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.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 = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -159,6 +165,12 @@ A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; + A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalLegacy.xib; sourceTree = ""; }; + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; @@ -384,6 +396,21 @@ path = Sources; sourceTree = ""; }; + A5593FDD2DF8D56000B47B10 /* Window Styles */ = { + isa = PBXGroup; + children = ( + A59630992AEE1C6400D64628 /* Terminal.xib */, + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, + A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, + A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, + ); + path = "Window Styles"; + sourceTree = ""; + }; A55B7BB429B6F4410055DE60 /* Ghostty */ = { isa = PBXGroup; children = ( @@ -467,11 +494,10 @@ A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( - A59630992AEE1C6400D64628 /* Terminal.xib */, + A5593FDD2DF8D56000B47B10 /* Window Styles */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, @@ -647,9 +673,11 @@ buildActionMask = 2147483647; files = ( FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */, + A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, 9351BE8E3D22937F003B3499 /* nvim in Resources */, @@ -658,6 +686,7 @@ FC5218FA2D10FFCE004C93E0 /* zsh in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */, + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */, A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */, A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, @@ -702,6 +731,7 @@ A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, @@ -709,6 +739,7 @@ A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */, A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, @@ -734,9 +765,10 @@ A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5916c5921..7fb8f9a07 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -6,7 +6,22 @@ import GhosttyKit /// A classic, tabbed terminal experience. class TerminalController: BaseTerminalController { - override var windowNibName: NSNib.Name? { "Terminal" } + override var windowNibName: NSNib.Name? { + //NOTE(mitchellh): switch to this when we've transitioned all legacy logic out + //let defaultValue = "Terminal" + let defaultValue = "TerminalLegacy" + + guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } + let config = appDelegate.ghostty.config + let nib = switch config.macosTitlebarStyle { + case "tabs": defaultValue + case "hidden": "TerminalHiddenTitlebar" + case "transparent": "TerminalTransparentTitlebar" + default: defaultValue + } + + return nib + } /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail @@ -114,7 +129,7 @@ class TerminalController: BaseTerminalController { invalidateRestorableState() // Update our zoom state - if let window = window as? TerminalWindow { + if let window = window as? LegacyTerminalWindow { window.surfaceIsZoomed = to.zoomed != nil } @@ -129,11 +144,6 @@ class TerminalController: BaseTerminalController { // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } - if (!(fullscreenStyle?.isFullscreen ?? false) && - ghostty.config.macosTitlebarStyle == "hidden") - { - applyHiddenTitlebarStyle() - } syncAppearance(focusedSurface.derivedConfig) } @@ -278,9 +288,8 @@ class TerminalController: BaseTerminalController { tg.removeWindow(window) } - // If we have the "hidden" titlebar style we want to create new - // tabs as windows instead, so just skip adding it to the parent. - if (ghostty.config.macosTitlebarStyle != "hidden") { + // If we don't allow tabs then we create a new window instead. + if (window.tabbingMode != .disallowed) { // Add the window to the tab group and show it. switch ghostty.config.windowNewTabPosition { case "end": @@ -389,7 +398,7 @@ class TerminalController: BaseTerminalController { // Reset this to false. It'll be set back to true later. tabListenForFrame = false - guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return } + guard let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] else { return } // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. @@ -440,7 +449,11 @@ class TerminalController: BaseTerminalController { } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { - guard let window = self.window as? TerminalWindow else { return } + 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 @@ -523,31 +536,6 @@ class TerminalController: BaseTerminalController { } } - private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { - guard let window else { return } - - // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { - if (!LastWindowPosition.shared.restore(window)) { - window.center() - } - - return - } - - // Prefer the screen our window is being placed on otherwise our primary screen. - guard let screen = window.screen ?? NSScreen.screens.first else { - window.center() - return - } - - // Orient based on the top left of the primary monitor - let frame = screen.visibleFrame - window.setFrameOrigin(.init( - x: frame.minX + CGFloat(x), - y: frame.maxY - (CGFloat(y) + window.frame.height))) - } - /// Returns the default size of the window. This is contextual based on the focused surface because /// the focused surface may specify a different default size than others. private var defaultSize: NSRect? { @@ -889,52 +877,9 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } - fileprivate func hideWindowButtons() { - guard let window else { return } - - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - } - - fileprivate func applyHiddenTitlebarStyle() { - guard let window else { return } - - window.styleMask = [ - // We need `titled` in the mask to get the normal window frame - .titled, - - // Full size content view so we can extend - // content in to the hidden titlebar's area - .fullSizeContentView, - - .resizable, - .closable, - .miniaturizable, - ] - - // Hide the title - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - - // Hide the traffic lights (window control buttons) - hideWindowButtons() - - // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. - window.tabbingMode = .disallowed - - // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are - // some operations that appear to bring back the titlebar visibility so this ensures - // it is gone forever. - if let themeFrame = window.contentView?.superview, - let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { - titleBarContainer.isHidden = true - } - } - override func windowDidLoad() { super.windowDidLoad() - guard let window = window as? TerminalWindow else { return } + guard let window else { return } // Store our initial frame so we can know our default later. initialFrame = window.frame @@ -952,9 +897,6 @@ class TerminalController: BaseTerminalController { window.identifier = .init(String(describing: TerminalWindowRestoration.self)) } - // If window decorations are disabled, remove our title - if (!config.windowDecorations) { window.styleMask.remove(.titled) } - // If we have only a single surface (no splits) and there is a default size then // we should resize to that default size. if case let .leaf(view) = surfaceTree.root { @@ -967,42 +909,29 @@ class TerminalController: BaseTerminalController { } } - // Set our window positioning to coordinates if config value exists, otherwise - // fallback to original centering behavior - setInitialWindowPosition( - x: config.windowPositionX, - y: config.windowPositionY, - windowDecorations: config.windowDecorations) - - if config.macosWindowButtons == .hidden { - hideWindowButtons() - } - - // Make sure our theme is set on the window so styling is correct. - if let windowTheme = config.windowTheme { - window.windowTheme = .init(rawValue: windowTheme) - } - - // Handle titlebar tabs config option. Something about what we do while setting up the - // titlebar tabs interferes with the window restore process unless window.tabbingMode - // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - if (config.macosTitlebarStyle == "tabs") { - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic + // TODO: remove + if let window = window as? LegacyTerminalWindow { + // Handle titlebar tabs config option. Something about what we do while setting up the + // titlebar tabs interferes with the window restore process unless window.tabbingMode + // is set to .preferred, so we set it, and switch back to automatic as soon as we can. + if (config.macosTitlebarStyle == "tabs") { + window.tabbingMode = .preferred + window.titlebarTabs = true + DispatchQueue.main.async { + window.tabbingMode = .automatic + } + } else if (config.macosTitlebarStyle == "transparent") { + window.transparentTabs = true } - } else if (config.macosTitlebarStyle == "transparent") { - window.transparentTabs = true - } - if window.hasStyledTabs { - // Set the background color of the window - let backgroundColor = NSColor(config.backgroundColor) - window.backgroundColor = backgroundColor + if window.hasStyledTabs { + // Set the background color of the window + let backgroundColor = NSColor(config.backgroundColor) + window.backgroundColor = backgroundColor - // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) + // This makes sure our titlebar renders correctly when there is a transparent background + window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) + } } // Initialize our content view to the SwiftUI root @@ -1012,11 +941,6 @@ class TerminalController: BaseTerminalController { delegate: self )) - // If our titlebar style is "hidden" we adjust the style appropriately - if (config.macosTitlebarStyle == "hidden") { - applyHiddenTitlebarStyle() - } - // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -1218,7 +1142,7 @@ class TerminalController: BaseTerminalController { override func titleDidChange(to: String) { super.titleDidChange(to: to) - guard let window = window as? TerminalWindow else { return } + guard let window = window as? LegacyTerminalWindow else { return } // Custom toolbar-based title used when titlebar tabs are enabled. if let toolbar = window.toolbar as? TerminalToolbar { diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift new file mode 100644 index 000000000..f2d3b9b85 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -0,0 +1,78 @@ +import AppKit + +class HiddenTitlebarTerminalWindow: TerminalWindow { + override func awakeFromNib() { + super.awakeFromNib() + + // Setup our initial style + reapplyHiddenStyle() + + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(fullscreenDidExit(_:)), + name: .fullscreenDidExit, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + rect.origin.y = 0 + rect.size.height = self.frame.height + return rect + } + + /// Apply the hidden titlebar style. + private func reapplyHiddenStyle() { + styleMask = [ + // We need `titled` in the mask to get the normal window frame + .titled, + + // Full size content view so we can extend + // content in to the hidden titlebar's area + .fullSizeContentView, + + .resizable, + .closable, + .miniaturizable, + ] + + // Hide the title + titleVisibility = .hidden + titlebarAppearsTransparent = true + + // Hide the traffic lights (window control buttons) + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. + tabbingMode = .disallowed + + // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are + // some operations that appear to bring back the titlebar visibility so this ensures + // it is gone forever. + if let themeFrame = contentView?.superview, + let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + + // MARK: Notifications + + @objc private func fullscreenDidExit(_ notification: Notification) { + // Make sure they're talking about our window + guard let fullscreen = notification.object as? FullscreenBase else { return } + guard fullscreen.window == self else { return } + + // On exit we need to reapply the style because macOS breaks it usually. + // This is safe to call repeatedly so if its not broken its still safe. + reapplyHiddenStyle() + } +} diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift similarity index 95% rename from macos/Sources/Features/Terminal/TerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift index 0b43582f3..208e86343 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift @@ -1,9 +1,8 @@ import Cocoa -class TerminalWindow: NSWindow { - /// This is the key in UserDefaults to use for the default `level` value. - static let defaultLevelKey: String = "TerminalDefaultLevel" - +/// The terminal window that we originally had in Ghostty for a long time. Kind of a soupy mess +/// of styling. +class LegacyTerminalWindow: TerminalWindow { @objc dynamic var keyEquivalent: String = "" /// This is used to determine if certain elements should be drawn light or dark and should @@ -56,11 +55,6 @@ class TerminalWindow: NSWindow { } } - // Both of these must be true for windows without decorations to be able to - // still become key/main and receive events. - override var canBecomeKey: Bool { return true } - override var canBecomeMain: Bool { return true } - // MARK: - Lifecycle override func awakeFromNib() { @@ -77,8 +71,6 @@ class TerminalWindow: NSWindow { if titlebarTabs { generateToolbar() } - - level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } deinit { @@ -135,25 +127,6 @@ class TerminalWindow: NSWindow { } } - // We override this so that with the hidden titlebar style the titlebar - // area is not draggable. - override var contentLayoutRect: CGRect { - var rect = super.contentLayoutRect - - // If we are using a hidden titlebar style, the content layout is the - // full frame making it so that it is not draggable. - if let controller = windowController as? TerminalController, - controller.derivedConfig.macosTitlebarStyle == "hidden" { - rect.origin.y = 0 - rect.size.height = self.frame.height - } - return rect - } - - // The window theme configuration from Ghostty. This is used to control some - // behaviors that don't look quite right in certain situations. - var windowTheme: TerminalWindowTheme? - // 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 @@ -703,7 +676,7 @@ fileprivate class WindowDragView: NSView { fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. - private weak var terminalWindow: TerminalWindow? + private weak var terminalWindow: LegacyTerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() @@ -731,7 +704,7 @@ fileprivate class WindowButtonsBackdropView: NSView { fatalError("init(coder:) has not been implemented") } - init(window: TerminalWindow) { + init(window: LegacyTerminalWindow) { self.terminalWindow = window self.isLightTheme = window.isLightTheme @@ -746,10 +719,3 @@ fileprivate class WindowButtonsBackdropView: NSView { layer?.addSublayer(overlayLayer) } } - -enum TerminalWindowTheme: String { - case auto - case system - case light - case dark -} diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib similarity index 86% rename from macos/Sources/Features/Terminal/Terminal.xib rename to macos/Sources/Features/Terminal/Window Styles/Terminal.xib index 65b03b6eb..cfbb2221c 100644 --- a/macos/Sources/Features/Terminal/Terminal.xib +++ b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib @@ -1,8 +1,8 @@ - + - + @@ -17,10 +17,10 @@ - + - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib new file mode 100644 index 000000000..eb4675657 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib new file mode 100644 index 000000000..61ed6f782 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib new file mode 100644 index 000000000..ada6959b3 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift new file mode 100644 index 000000000..74744a962 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -0,0 +1,75 @@ +import AppKit + +/// The base class for all standalone, "normal" terminal windows. This sets the basic +/// style and configuration of the window based on the app configuration. +class TerminalWindow: NSWindow { + /// This is the key in UserDefaults to use for the default `level` value. This is + /// used by the manual float on top menu item feature. + static let defaultLevelKey: String = "TerminalDefaultLevel" + + // MARK: NSWindow Overrides + + override func awakeFromNib() { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return } + + // All new windows are based on the app config at the time of creation. + let config = appDelegate.ghostty.config + + // If window decorations are disabled, remove our title + if (!config.windowDecorations) { styleMask.remove(.titled) } + + // Set our window positioning to coordinates if config value exists, otherwise + // fallback to original centering behavior + setInitialWindowPosition( + x: config.windowPositionX, + y: config.windowPositionY, + windowDecorations: config.windowDecorations) + + // If our traffic buttons should be hidden, then hide them + if config.macosWindowButtons == .hidden { + hideWindowButtons() + } + + // Get our saved level + level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal + } + + // Both of these must be true for windows without decorations to be able to + // still become key/main and receive events. + override var canBecomeKey: Bool { return true } + override var canBecomeMain: Bool { return true } + + // 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) {} + + 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. + guard let x, let y else { + if (!LastWindowPosition.shared.restore(self)) { + center() + } + + return + } + + // Prefer the screen our window is being placed on otherwise our primary screen. + guard let screen = screen ?? NSScreen.screens.first else { + center() + return + } + + // Orient based on the top left of the primary monitor + let frame = screen.visibleFrame + setFrameOrigin(.init( + x: frame.minX + CGFloat(x), + y: frame.maxY - (CGFloat(y) + frame.height))) + } + + private func hideWindowButtons() { + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift new file mode 100644 index 000000000..4b3336874 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -0,0 +1,72 @@ +import AppKit + +class TransparentTitlebarTerminalWindow: TerminalWindow { + private var reapplyTimer: Timer? + + override func awakeFromNib() { + super.awakeFromNib() + } + + deinit { + reapplyTimer?.invalidate() + } + + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + if #available(macOS 26.0, *) { + syncAppearanceTahoe(surfaceConfig) + } else { + syncAppearanceVentura(surfaceConfig) + } + } + + @available(macOS 26.0, *) + private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + guard let titlebarBackgroundView else { return } + titlebarBackgroundView.isHidden = true + backgroundColor = NSColor(surfaceConfig.backgroundColor) + } + + @available(macOS 13.0, *) + private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + guard let titlebarContainer else { return } + + let configBgColor = NSColor(surfaceConfig.backgroundColor) + + // Set our window background color so it shows up + backgroundColor = configBgColor + + // Set the background color of our titlebar to match + titlebarContainer.wantsLayer = true + titlebarContainer.layer?.backgroundColor = configBgColor.withAlphaComponent(surfaceConfig.backgroundOpacity).cgColor + } + + private var titlebarBackgroundView: NSView? { + titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") + } + + private var titlebarContainer: NSView? { + // If we aren't fullscreen then the titlebar container is part of our window. + if !styleMask.contains(.fullScreen) { + return titlebarContainerView + } + + // If we are fullscreen, the titlebar container view is part of a separate + // "fullscreen window", we need to find the window and then get the view. + for window in NSApplication.shared.windows { + // This is the private window class that contains the toolbar + guard window.className == "NSToolbarFullScreenWindow" else { continue } + + // The parent will match our window. This is used to filter the correct + // fullscreen window if we have multiple. + guard window.parent == self else { continue } + + return titlebarContainerView + } + + return nil + } + + private var titlebarContainerView: NSView? { + contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } +} diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 48284df74..121d9a62a 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -13,6 +13,19 @@ extension NSView { return false } +} + +// MARK: View Traversal and Search + +extension NSView { + /// Returns the absolute root view by walking up the superview chain. + var rootView: NSView { + var root: NSView = self + while let superview = root.superview { + root = superview + } + return root + } /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { @@ -54,4 +67,18 @@ extension NSView { return nil } + + /// Finds and returns the first view with the given class name starting from the absolute root of the view hierarchy. + /// This includes private views like title bar views. + func firstViewFromRoot(withClassName name: String) -> NSView? { + let root = rootView + + // Check if the root view itself matches + if String(describing: type(of: root)) == name { + return root + } + + // Otherwise search descendants + return root.firstDescendant(withClassName: name) + } } diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6b10ceb40..3200608d0 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -78,10 +78,12 @@ class FullscreenBase { } @objc private func didEnterFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) delegate?.fullscreenDidChange() } @objc private func didExitFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) delegate?.fullscreenDidChange() } } @@ -238,6 +240,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.window.makeFirstResponder(firstResponder) } + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) self.delegate?.fullscreenDidChange() } } @@ -268,7 +271,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. - if let window = window as? TerminalWindow, + if let window = window as? LegacyTerminalWindow, window.titlebarTabs { window.titlebarTabs = true } @@ -303,6 +306,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.makeKeyAndOrderFront(nil) // Notify the delegate + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) self.delegate?.fullscreenDidChange() } @@ -422,3 +426,8 @@ class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { override var properties: Properties { Properties(paddedNotch: true) } } + +extension Notification.Name { + static let fullscreenDidEnter = Notification.Name("com.mitchellh.fullscreenDidEnter") + static let fullscreenDidExit = Notification.Name("com.mitchellh.fullscreenDidExit") +} From 7d02977482a3e8f6c1565c24ee26986038e0bf16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 07:00:40 -0700 Subject: [PATCH 02/30] macos: add NSView hierarchy debugging code --- .../TransparentTitlebarTerminalWindow.swift | 11 ++++- .../Helpers/Extensions/NSView+Extension.swift | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 4b3336874..d9d42365a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -1,14 +1,21 @@ import AppKit class TransparentTitlebarTerminalWindow: TerminalWindow { - private var reapplyTimer: Timer? + private var debugTimer: Timer? override func awakeFromNib() { super.awakeFromNib() + + // Debug timer to print view hierarchy every second + debugTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + print("=== TransparentTitlebarTerminalWindow Debug ===") + self?.contentView?.rootView.printViewHierarchy() + print("===============================================\n") + } } deinit { - reapplyTimer?.invalidate() + debugTimer?.invalidate() } override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 121d9a62a..14c07f6c9 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -82,3 +82,51 @@ extension NSView { return root.firstDescendant(withClassName: name) } } + +// MARK: Debug + +extension NSView { + /// Prints the view hierarchy from the root in a tree-like ASCII format. + /// + /// I need this because the "Capture View Hiearchy" was broken under some scenarios in + /// Xcode 26 (FB17912569). But, I kept it around because it might be useful to print out + /// the view hierarchy without halting the program. + func printViewHierarchy() { + let root = rootView + print("View Hierarchy from Root:") + print(root.viewHierarchyDescription()) + } + + /// Returns a string representation of the view hierarchy in a tree-like format. + private func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { + var result = "" + + // Add the tree branch characters + result += indent + if !indent.isEmpty { + result += isLast ? "└── " : "├── " + } + + // Add the class name and optional identifier + let className = String(describing: type(of: self)) + result += className + + // Add identifier if present + if let identifier = self.identifier { + result += " (id: \(identifier.rawValue))" + } + + // Add frame info + result += " [frame: \(frame)]" + result += "\n" + + // Process subviews + for (index, subview) in subviews.enumerated() { + let isLastSubview = index == subviews.count - 1 + let newIndent = indent + (isLast ? " " : "│ ") + result += subview.viewHierarchyDescription(indent: newIndent, isLast: isLastSubview) + } + + return result + } +} From 6ce7f612a66ea6e50884b426a4b0c3cfd6dd68be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 07:29:44 -0700 Subject: [PATCH 03/30] macos: transparent titlebar needs to be rehidden when tabs change --- .../TransparentTitlebarTerminalWindow.swift | 64 ++++++++++++++++--- .../Helpers/Extensions/NSView+Extension.swift | 30 +++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index d9d42365a..9f7b5e62f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -1,24 +1,39 @@ import AppKit class TransparentTitlebarTerminalWindow: TerminalWindow { - private var debugTimer: Timer? + // We need to restore our last synced appearance so that we can reapply + // the appearance in certain scenarios. + private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? + + // KVO observations + private var tabGroupWindowsObservation: NSKeyValueObservation? override func awakeFromNib() { super.awakeFromNib() - - // Debug timer to print view hierarchy every second - debugTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - print("=== TransparentTitlebarTerminalWindow Debug ===") - self?.contentView?.rootView.printViewHierarchy() - print("===============================================\n") - } + + // We need to observe the tab group because we need to redraw on + // tabbed window changes and there is no notification for that. + setupTabGroupObservation() } deinit { - debugTimer?.invalidate() + tabGroupWindowsObservation?.invalidate() } + override func becomeMain() { + // On macOS Tahoe, the tab bar redraws and restores non-transparency when + // switching tabs. To overcome this, we resync the appearance whenever this + // window becomes main (focused). + if #available(macOS 26.0, *), + let lastSurfaceConfig { + syncAppearance(lastSurfaceConfig) + } + } + + // MARK: Appearance + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + lastSurfaceConfig = surfaceConfig if #available(macOS 26.0, *) { syncAppearanceTahoe(surfaceConfig) } else { @@ -47,6 +62,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { titlebarContainer.layer?.backgroundColor = configBgColor.withAlphaComponent(surfaceConfig.backgroundOpacity).cgColor } + // MARK: View Finders + private var titlebarBackgroundView: NSView? { titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") } @@ -76,4 +93,33 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { private var titlebarContainerView: NSView? { contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") } + + // MARK: Tab Group Observation + + 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) + } + } } diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 14c07f6c9..0cf71138d 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -118,6 +118,36 @@ extension NSView { // Add frame info result += " [frame: \(frame)]" + + // Add visual properties + var properties: [String] = [] + + // Hidden status + if isHidden { + properties.append("hidden") + } + + // Opaque status + properties.append(isOpaque ? "opaque" : "transparent") + + // Layer backing + if wantsLayer { + properties.append("layer-backed") + if let bgColor = layer?.backgroundColor { + let color = NSColor(cgColor: bgColor) + if let rgb = color?.usingColorSpace(.deviceRGB) { + properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)", + rgb.redComponent * 255, + rgb.greenComponent * 255, + rgb.blueComponent * 255, + rgb.alphaComponent)) + } else { + properties.append("bg:\(bgColor)") + } + } + } + + result += " [\(properties.joined(separator: ", "))]" result += "\n" // Process subviews From 3595b2a8476ff16dae2ba266ef672d4fa295a777 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 12:37:15 -0700 Subject: [PATCH 04/30] macos: transparent titlebar handles transparent background --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../Terminal/TerminalController.swift | 53 +++-------- .../Window Styles/TerminalWindow.swift | 95 ++++++++++++++++++- .../TransparentTitlebarTerminalWindow.swift | 22 ++++- .../Helpers/Extensions/Double+Extension.swift | 5 + 5 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/Double+Extension.swift 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) + } +} From dfa7a114def0f4a9617ef3433dd67c3bb288b924 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 13:12:25 -0700 Subject: [PATCH 05/30] macos: make transparent titlebars robust against show/hide tabs --- .../TransparentTitlebarTerminalWindow.swift | 81 +++++++++++++------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index dfe2d35a1..98dd9f834 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -1,32 +1,41 @@ 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 { - // We need to restore our last synced appearance so that we can reapply - // the appearance in certain scenarios. + /// 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 observations + /// KVO observation for tab group window changes. private var tabGroupWindowsObservation: NSKeyValueObservation? + private var tabBarVisibleObservation: NSKeyValueObservation? override func awakeFromNib() { super.awakeFromNib() - // We need to observe the tab group because we need to redraw on - // tabbed window changes and there is no notification for that. - setupTabGroupObservation() + // Setup all the KVO we will use, see the docs for the respective functions + // to learn why we need KVO. + setupKVO() } deinit { tabGroupWindowsObservation?.invalidate() + tabBarVisibleObservation?.invalidate() } override func becomeMain() { - // On macOS Tahoe, the tab bar redraws and restores non-transparency when - // switching tabs. To overcome this, we resync the appearance whenever this - // window becomes main (focused). - if #available(macOS 26.0, *), - let lastSurfaceConfig { - syncAppearance(lastSurfaceConfig) + 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) + } } } @@ -34,8 +43,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { super.syncAppearance(surfaceConfig) - + + // Save our config in case we need to reapply lastSurfaceConfig = surfaceConfig + + // Everytime 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 { @@ -67,15 +82,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { @available(macOS 13.0, *) private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { guard let titlebarContainer else { return } - - let configBgColor = NSColor(surfaceConfig.backgroundColor) - - // Set our window background color so it shows up - backgroundColor = configBgColor - - // Set the background color of our titlebar to match titlebarContainer.wantsLayer = true - titlebarContainer.layer?.backgroundColor = configBgColor.withAlphaComponent(surfaceConfig.backgroundOpacity).cgColor + titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor } // MARK: View Finders @@ -111,7 +119,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } // MARK: Tab Group Observation - + + private func setupKVO() { + // See the docs for the respective setup functions for why. + setupTabGroupObservation() + 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() @@ -126,7 +143,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { tabGroupWindowsObservation = tabGroup.observe( \.windows, options: [.new] - ) { [weak self] _, _ in + ) { [weak self] _, change 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). @@ -138,4 +155,22 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { 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] _, change in + guard let self else { return } + guard let lastSurfaceConfig else { return } + self.syncAppearance(lastSurfaceConfig) + } + } } From a804dab28845675271afa13a80e23fc524386c78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 14:35:49 -0700 Subject: [PATCH 06/30] macos: native terminal style works with new subclasses --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +- .../Terminal/BaseTerminalController.swift | 6 +- .../Terminal/TerminalController.swift | 54 ++++++++---- .../TerminalTransparentTitlebar.xib | 2 +- .../Window Styles/TerminalWindow.swift | 88 +++++++++++++++++++ .../TransparentTitlebarTerminalWindow.swift | 2 + .../Helpers/Extensions/NSView+Extension.swift | 15 ++++ 7 files changed, 151 insertions(+), 22 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index c00d6119a..5f5b3013c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -402,12 +402,12 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, - A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, - A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, - A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, + A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, ); path = "Window Styles"; diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e91199358..849f13b34 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -568,7 +568,11 @@ class BaseTerminalController: NSWindowController, // Not zoomed or different node zoomed, zoom this node surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) } - + + // Move focus to our window. Importantly this ensures that if we click the + // reset zoom button in a tab bar of an unfocused tab that we become focused. + window?.makeKeyAndOrderFront(nil) + // Ensure focus stays on the target surface. We lose focus when we do // this so we need to grab it again. DispatchQueue.main.async { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5adef8ded..082a3c806 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -14,6 +14,7 @@ class TerminalController: BaseTerminalController { guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { + case "native": "Terminal" case "tabs": defaultValue case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" @@ -132,6 +133,9 @@ class TerminalController: BaseTerminalController { if let window = window as? LegacyTerminalWindow { window.surfaceIsZoomed = to.zoomed != nil } + if let window = window as? TerminalWindow { + window.surfaceIsZoomed2 = to.zoomed != nil + } // If our surface tree is now nil then we close our window. if (to.isEmpty) { @@ -395,28 +399,44 @@ class TerminalController: BaseTerminalController { /// changes, when a window is closed, and when tabs are reordered /// with the mouse. func relabelTabs() { - // Reset this to false. It'll be set back to true later. - tabListenForFrame = false - - guard let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] else { return } - // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. - tabListenForFrame = windows.count > 1 + tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1 - for (tab, window) in zip(1..., windows) { - // We need to clear any windows beyond this because they have had - // a keyEquivalent set previously. - guard tab <= 9 else { - window.keyEquivalent = "" - continue + if let windows = window?.tabbedWindows as? [TerminalWindow] { + for (tab, window) in zip(1..., windows) { + // We need to clear any windows beyond this because they have had + // a keyEquivalent set previously. + guard tab <= 9 else { + window.keyEquivalent2 = "" + continue + } + + let action = "goto_tab:\(tab)" + if let equiv = ghostty.config.keyboardShortcut(for: action) { + window.keyEquivalent2 = "\(equiv)" + } else { + window.keyEquivalent2 = "" + } } + } - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent = "\(equiv)" - } else { - window.keyEquivalent = "" + // Legacy + if let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] { + for (tab, window) in zip(1..., windows) { + // We need to clear any windows beyond this because they have had + // a keyEquivalent set previously. + guard tab <= 9 else { + window.keyEquivalent = "" + continue + } + + let action = "goto_tab:\(tab)" + if let equiv = ghostty.config.keyboardShortcut(for: action) { + window.keyEquivalent = "\(equiv)" + } else { + window.keyEquivalent = "" + } } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib index ada6959b3..25922e2f3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib @@ -17,7 +17,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index daf5b4554..9fac08c4b 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 SwiftUI import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic @@ -42,6 +43,14 @@ class TerminalWindow: NSWindow { hideWindowButtons() } + // Setup the accessory view for tabs that shows our keyboard shortcuts, + // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues + // where buttons were not clickable. + let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + stackView.setHuggingPriority(.defaultHigh, for: .horizontal) + stackView.spacing = 3 + tab.accessoryView = stackView + // Get our saved level level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } @@ -51,6 +60,85 @@ class TerminalWindow: NSWindow { override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + override func becomeKey() { + super.becomeKey() + resetZoomTabButton.contentTintColor = .controlAccentColor + } + + override func resignKey() { + super.resignKey() + resetZoomTabButton.contentTintColor = .secondaryLabelColor + } + + override func mergeAllWindows(_ sender: Any?) { + super.mergeAllWindows(sender) + + // It takes an event loop cycle to merge all the windows so we set a + // short timer to relabel the tabs (issue #1902) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.terminalController?.relabelTabs() + } + } + + // MARK: Tab Key Equivalents + + // TODO: rename once Legacy window removes + var keyEquivalent2: String? = nil { + didSet { + // When our key equivalent is set, we must update the tab label. + guard let keyEquivalent2 else { + keyEquivalentLabel.attributedStringValue = NSAttributedString() + return + } + + keyEquivalentLabel.attributedStringValue = NSAttributedString( + string: "\(keyEquivalent2) ", + attributes: [ + .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ]) + } + } + + /// The label that has the key equivalent for tab views. + private lazy var keyEquivalentLabel: NSTextField = { + let label = NSTextField(labelWithAttributedString: NSAttributedString()) + label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) + label.postsFrameChangedNotifications = true + return label + }() + + // MARK: Surface Zoom + + /// Set to true if a surface is currently zoomed to show the reset zoom button. + var surfaceIsZoomed2: Bool = false { + didSet { + // Show/hide our reset zoom button depending on if we're zoomed. + // We want to show it if we are zoomed. + resetZoomTabButton.isHidden = !surfaceIsZoomed2 + } + } + + private lazy var resetZoomTabButton: NSButton = generateResetZoomButton() + + private func generateResetZoomButton() -> NSButton { + let button = NSButton() + button.isHidden = true + button.target = terminalController + button.action = #selector(TerminalController.splitZoom(_:)) + button.isBordered = false + button.allowsExpansionToolTips = true + button.toolTip = "Reset Zoom" + button.contentTintColor = .controlAccentColor + button.state = .on + button.image = NSImage(named:"ResetZoom") + button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(equalToConstant: 20).isActive = true + button.heightAnchor.constraint(equalToConstant: 20).isActive = true + return button + } + // MARK: Positioning And Styling /// This is called by the controller when there is a need to reset the window apperance. diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 98dd9f834..ada84ff12 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -26,6 +26,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } override func becomeMain() { + super.becomeMain() + guard let lastSurfaceConfig else { return } syncAppearance(lastSurfaceConfig) diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 0cf71138d..aa56fe32e 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -27,6 +27,21 @@ extension NSView { return root } + /// Checks if a view contains another view in its hierarchy. + func contains(_ view: NSView) -> Bool { + if self == view { + return true + } + + for subview in subviews { + if subview.contains(view) { + return true + } + } + + return false + } + /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { for subview in subviews { From 63e56d0402a04696fbaff2eb7e5dbbf8f6257740 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 15:08:12 -0700 Subject: [PATCH 07/30] macos: titlebar fonts work with new terminal window --- .../Terminal/TerminalController.swift | 11 ++++ .../Window Styles/LegacyTerminalWindow.swift | 42 ------------- .../Window Styles/TerminalWindow.swift | 62 ++++++++++++++++++- .../TransparentTitlebarTerminalWindow.swift | 26 -------- 4 files changed, 72 insertions(+), 69 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 082a3c806..cf771d556 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -471,6 +471,17 @@ class TerminalController: BaseTerminalController { private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance if let window = window as? TerminalWindow { + // Sync our zoom state for splits + window.surfaceIsZoomed2 = surfaceTree.zoomed != nil + + // Set the font for the window and tab titles. + if let titleFontName = surfaceConfig.windowTitleFontFamily { + window.titlebarFont2 = NSFont(name: titleFontName, size: NSFont.systemFontSize) + } else { + window.titlebarFont2 = nil + } + + // Call this last in case it uses any of the properties above. window.syncAppearance(surfaceConfig) } diff --git a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift index 208e86343..e63681463 100644 --- a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift @@ -77,48 +77,6 @@ class LegacyTerminalWindow: TerminalWindow { bindings.forEach() { $0.invalidate() } } - // MARK: Titlebar Helpers - // These helpers are generic to what we're trying to achieve (i.e. titlebar - // style tabs, titlebar styling, etc.). They're just here to make it easier. - - private var titlebarContainer: NSView? { - // If we aren't fullscreen then the titlebar container is part of our window. - if !styleMask.contains(.fullScreen) { - guard let view = contentView?.superview ?? contentView else { return nil } - return titlebarContainerView(in: view) - } - - // If we are fullscreen, the titlebar container view is part of a separate - // "fullscreen window", we need to find the window and then get the view. - for window in NSApplication.shared.windows { - // This is the private window class that contains the toolbar - guard window.className == "NSToolbarFullScreenWindow" else { continue } - - // The parent will match our window. This is used to filter the correct - // fullscreen window if we have multiple. - guard window.parent == self else { continue } - - guard let view = window.contentView else { continue } - return titlebarContainerView(in: view) - } - - return nil - } - - private func titlebarContainerView(in view: NSView) -> NSView? { - if view.className == "NSTitlebarContainerView" { - return view - } - - for subview in view.subviews { - if let found = titlebarContainerView(in: subview) { - return found - } - } - - return nil - } - // MARK: - NSWindow override var title: String { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9fac08c4b..1e9ca1b01 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -72,7 +72,7 @@ class TerminalWindow: NSWindow { override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) - + // It takes an event loop cycle to merge all the windows so we set a // short timer to relabel the tabs (issue #1902) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in @@ -139,6 +139,66 @@ class TerminalWindow: NSWindow { return button } + // MARK: Title Text + + override var title: String { + didSet { + // Whenever we change the window title we must also update our + // tab title if we're using custom fonts. + tab.attributedTitle = attributedTitle + } + } + + // Used to set the titlebar font. + var titlebarFont2: NSFont? { + didSet { + let font = titlebarFont2 ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) + + titlebarTextField?.font = font + tab.attributedTitle = attributedTitle + } + } + + // Find the NSTextField responsible for displaying the titlebar's title. + private var titlebarTextField: NSTextField? { + titlebarContainer? + .firstDescendant(withClassName: "NSTitlebarView")? + .firstDescendant(withClassName: "NSTextField") as? NSTextField + } + + // Return a styled representation of our title property. + private var attributedTitle: NSAttributedString? { + guard let titlebarFont = titlebarFont2 else { return nil } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: titlebarFont, + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ] + return NSAttributedString(string: title, attributes: attributes) + } + + var titlebarContainer: NSView? { + // If we aren't fullscreen then the titlebar container is part of our window. + if !styleMask.contains(.fullScreen) { + return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + // If we are fullscreen, the titlebar container view is part of a separate + // "fullscreen window", we need to find the window and then get the view. + for window in NSApplication.shared.windows { + // This is the private window class that contains the toolbar + guard window.className == "NSToolbarFullScreenWindow" else { continue } + + // The parent will match our window. This is used to filter the correct + // fullscreen window if we have multiple. + guard window.parent == self else { continue } + + return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + return nil + } + // MARK: Positioning And Styling /// This is called by the controller when there is a need to reset the window apperance. diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index ada84ff12..f949b6094 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -94,32 +94,6 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") } - private var titlebarContainer: NSView? { - // If we aren't fullscreen then the titlebar container is part of our window. - if !styleMask.contains(.fullScreen) { - return titlebarContainerView - } - - // If we are fullscreen, the titlebar container view is part of a separate - // "fullscreen window", we need to find the window and then get the view. - for window in NSApplication.shared.windows { - // This is the private window class that contains the toolbar - guard window.className == "NSToolbarFullScreenWindow" else { continue } - - // The parent will match our window. This is used to filter the correct - // fullscreen window if we have multiple. - guard window.parent == self else { continue } - - return titlebarContainerView - } - - return nil - } - - private var titlebarContainerView: NSView? { - contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") - } - // MARK: Tab Group Observation private func setupKVO() { From e5cb33e9114a7b0818043c0f210bc2039b10cc00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 15:09:42 -0700 Subject: [PATCH 08/30] typos --- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- macos/Sources/Helpers/Extensions/NSView+Extension.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1e9ca1b01..eb4b4a6da 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -201,7 +201,7 @@ class TerminalWindow: NSWindow { // MARK: Positioning And Styling - /// This is called by the controller when there is a need to reset the window apperance. + /// This is called by the controller when there is a need to reset the window appearance. 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 diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index aa56fe32e..b958130f1 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -103,7 +103,7 @@ extension NSView { extension NSView { /// Prints the view hierarchy from the root in a tree-like ASCII format. /// - /// I need this because the "Capture View Hiearchy" was broken under some scenarios in + /// I need this because the "Capture View Hierarchy" was broken under some scenarios in /// Xcode 26 (FB17912569). But, I kept it around because it might be useful to print out /// the view hierarchy without halting the program. func printViewHierarchy() { From ccfd33022f2402b294aafdc9ce8224b15069a5f3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 15:15:06 -0700 Subject: [PATCH 09/30] macos: only titlebar tabs uses legacy styling now --- macos/Sources/Features/Terminal/TerminalController.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cf771d556..86b47b9bd 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -7,15 +7,13 @@ import GhosttyKit /// A classic, tabbed terminal experience. class TerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { - //NOTE(mitchellh): switch to this when we've transitioned all legacy logic out - //let defaultValue = "Terminal" - let defaultValue = "TerminalLegacy" + let defaultValue = "Terminal" guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { case "native": "Terminal" - case "tabs": defaultValue + case "tabs": "TerminalLegacy" case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" default: defaultValue From fd785f98bb5d794645079918df2828c4e0e09e1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 11:36:38 -0700 Subject: [PATCH 10/30] macos: titlebar tabs uses legacy window for now --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++ .../Terminal/TerminalController.swift | 56 +++-------- .../Window Styles/LegacyTerminalWindow.swift | 93 ++----------------- .../TabsTitlebarTerminalWindow.swift | 58 ++++++++++++ .../Window Styles/TerminalHiddenTitlebar.xib | 2 +- .../Window Styles/TerminalTabsTitlebar.xib | 31 +++++++ .../Window Styles/TerminalWindow.swift | 12 +-- 7 files changed, 124 insertions(+), 136 deletions(-) create mode 100644 macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5f5b3013c..3f1cddf44 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 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 */; }; + A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */; }; + A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */; }; A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; @@ -137,6 +139,8 @@ 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 = ""; }; + A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsTitlebarTerminalWindow.swift; sourceTree = ""; }; + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebar.xib; 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 = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; @@ -404,10 +408,12 @@ A59630992AEE1C6400D64628 /* Terminal.xib */, A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */, A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, + A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */, A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, ); path = "Window Styles"; @@ -694,6 +700,7 @@ A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, + A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -761,6 +768,7 @@ A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, + A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 86b47b9bd..d59f71619 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -13,6 +13,7 @@ class TerminalController: BaseTerminalController { let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { case "native": "Terminal" + //case "tabs": "TerminalTabsTitlebar" case "tabs": "TerminalLegacy" case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" @@ -128,11 +129,8 @@ class TerminalController: BaseTerminalController { invalidateRestorableState() // Update our zoom state - if let window = window as? LegacyTerminalWindow { - window.surfaceIsZoomed = to.zoomed != nil - } if let window = window as? TerminalWindow { - window.surfaceIsZoomed2 = to.zoomed != nil + window.surfaceIsZoomed = to.zoomed != nil } // If our surface tree is now nil then we close our window. @@ -418,25 +416,6 @@ class TerminalController: BaseTerminalController { } } } - - // Legacy - if let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] { - for (tab, window) in zip(1..., windows) { - // We need to clear any windows beyond this because they have had - // a keyEquivalent set previously. - guard tab <= 9 else { - window.keyEquivalent = "" - continue - } - - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent = "\(equiv)" - } else { - window.keyEquivalent = "" - } - } - } } private func fixTabBar() { @@ -470,13 +449,13 @@ class TerminalController: BaseTerminalController { // Let our window handle its own appearance if let window = window as? TerminalWindow { // Sync our zoom state for splits - window.surfaceIsZoomed2 = surfaceTree.zoomed != nil + window.surfaceIsZoomed = surfaceTree.zoomed != nil // Set the font for the window and tab titles. if let titleFontName = surfaceConfig.windowTitleFontFamily { - window.titlebarFont2 = NSFont(name: titleFontName, size: NSFont.systemFontSize) + window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) } else { - window.titlebarFont2 = nil + window.titlebarFont = nil } // Call this last in case it uses any of the properties above. @@ -488,16 +467,6 @@ class TerminalController: BaseTerminalController { 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 - - // 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 @@ -916,18 +885,15 @@ class TerminalController: BaseTerminalController { } // TODO: remove - if let window = window as? LegacyTerminalWindow { + if let window = window as? LegacyTerminalWindow, + config.macosTitlebarStyle == "tabs" { // Handle titlebar tabs config option. Something about what we do while setting up the // titlebar tabs interferes with the window restore process unless window.tabbingMode // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - if (config.macosTitlebarStyle == "tabs") { - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic - } - } else if (config.macosTitlebarStyle == "transparent") { - window.transparentTabs = true + window.tabbingMode = .preferred + window.titlebarTabs = true + DispatchQueue.main.async { + window.tabbingMode = .automatic } if window.hasStyledTabs { diff --git a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift index e63681463..89afbf72f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift @@ -3,12 +3,16 @@ import Cocoa /// The terminal window that we originally had in Ghostty for a long time. Kind of a soupy mess /// of styling. class LegacyTerminalWindow: TerminalWindow { - @objc dynamic var keyEquivalent: String = "" - /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. var isLightTheme: Bool = false + override var surfaceIsZoomed: Bool { + didSet { + updateResetZoomTitlebarButtonVisibility() + } + } + lazy var titlebarColor: NSColor = backgroundColor { didSet { guard let titlebarContainer else { return } @@ -17,33 +21,6 @@ class LegacyTerminalWindow: TerminalWindow { } } - private lazy var keyEquivalentLabel: NSTextField = { - let label = NSTextField(labelWithAttributedString: NSAttributedString()) - label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) - label.postsFrameChangedNotifications = true - - return label - }() - - private lazy var bindings = [ - observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in - guard let tabGroup = self?.tabGroup else { return } - - self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed - self?.updateResetZoomTitlebarButtonVisibility() - }, - - observe(\.keyEquivalent, options: [.initial, .new]) { [weak self] window, _ in - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), - .foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - let attributedString = NSAttributedString(string: " \(window.keyEquivalent) ", attributes: attributes) - - self?.keyEquivalentLabel.attributedStringValue = attributedString - }, - ] - // false if all three traffic lights are missing/hidden, otherwise true private var hasWindowButtons: Bool { get { @@ -60,31 +37,13 @@ class LegacyTerminalWindow: TerminalWindow { override func awakeFromNib() { super.awakeFromNib() - _ = bindings - - // Create the tab accessory view that houses the key-equivalent label and optional un-zoom button - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) - stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 - tab.accessoryView = stackView - if titlebarTabs { generateToolbar() } } - deinit { - bindings.forEach() { $0.invalidate() } - } - // MARK: - NSWindow - override var title: String { - didSet { - tab.attributedTitle = attributedTitle - } - } - // 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 @@ -101,7 +60,6 @@ class LegacyTerminalWindow: TerminalWindow { super.becomeKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .controlAccentColor resetZoomToolbarButton.contentTintColor = .controlAccentColor tab.attributedTitle = attributedTitle } @@ -110,7 +68,6 @@ class LegacyTerminalWindow: TerminalWindow { super.resignKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .secondaryLabelColor resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor tab.attributedTitle = attributedTitle } @@ -284,16 +241,8 @@ class LegacyTerminalWindow: TerminalWindow { // MARK: - Split Zoom Button - @objc dynamic var surfaceIsZoomed: Bool = false - private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton() - private lazy var resetZoomTabButton: NSButton = { - let button = generateResetZoomButton() - button.action = #selector(selectTabAndZoom(_:)) - return button - }() - private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { guard let titlebarContainer else { return nil } let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) @@ -356,37 +305,13 @@ class LegacyTerminalWindow: TerminalWindow { // MARK: - Titlebar Font // Used to set the titlebar font. - var titlebarFont: NSFont? { + override var titlebarFont: NSFont? { didSet { - let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) - - titlebarTextField?.font = font - tab.attributedTitle = attributedTitle - - if let toolbar = toolbar as? TerminalToolbar { - toolbar.titleFont = font - } + guard let toolbar = toolbar as? TerminalToolbar else { return } + toolbar.titleFont = titlebarFont ?? .titleBarFont(ofSize: NSFont.systemFontSize) } } - // Find the NSTextField responsible for displaying the titlebar's title. - private var titlebarTextField: NSTextField? { - guard let titlebarView = titlebarContainer?.subviews - .first(where: { $0.className == "NSTitlebarView" }) else { return nil } - return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField - } - - // Return a styled representation of our title property. - private var attributedTitle: NSAttributedString? { - guard let titlebarFont else { return nil } - - let attributes: [NSAttributedString.Key: Any] = [ - .font: titlebarFont, - .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - return NSAttributedString(string: title, attributes: attributes) - } - // MARK: - Titlebar Tabs private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil diff --git a/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift new file mode 100644 index 000000000..858b54829 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI + +class TabsTitlebarTerminalWindow: TerminalWindow, NSToolbarDelegate { + override func awakeFromNib() { + super.awakeFromNib() + + // We must hide the title since we're going to be moving tabs into + // the titlebar which have their own title. + titleVisibility = .hidden + + // Create a toolbar + let toolbar = NSToolbar(identifier: "TerminalToolbar") + toolbar.delegate = self + toolbar.centeredItemIdentifiers.insert(.title) + self.toolbar = toolbar + //toolbarStyle = .unifiedCompact + } + + // MARK: NSToolbarDelegate + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.title, .flexibleSpace, .space] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.flexibleSpace, .title, .flexibleSpace] + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + switch itemIdentifier { + case .title: + let item = NSToolbarItem(itemIdentifier: .title) + item.view = NSHostingView(rootView: TitleItem()) + item.visibilityPriority = .user + item.isEnabled = true + return item + default: + return NSToolbarItem(itemIdentifier: itemIdentifier) + } + } + +} + +extension NSToolbarItem.Identifier { + /// Displays the title of the window + static let title = NSToolbarItem.Identifier("Title") +} + +extension TabsTitlebarTerminalWindow { + struct TitleItem: View { + var body: some View { + Text("HELLO THIS IS A PRETTY LONG TITLE") + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib index eb4675657..1a2a6c192 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -17,7 +17,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib new file mode 100644 index 000000000..779b6e094 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index eb4b4a6da..a0e18d283 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -111,11 +111,11 @@ class TerminalWindow: NSWindow { // MARK: Surface Zoom /// Set to true if a surface is currently zoomed to show the reset zoom button. - var surfaceIsZoomed2: Bool = false { + var surfaceIsZoomed: Bool = false { didSet { // Show/hide our reset zoom button depending on if we're zoomed. // We want to show it if we are zoomed. - resetZoomTabButton.isHidden = !surfaceIsZoomed2 + resetZoomTabButton.isHidden = !surfaceIsZoomed } } @@ -150,9 +150,9 @@ class TerminalWindow: NSWindow { } // Used to set the titlebar font. - var titlebarFont2: NSFont? { + var titlebarFont: NSFont? { didSet { - let font = titlebarFont2 ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) + let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) titlebarTextField?.font = font tab.attributedTitle = attributedTitle @@ -167,8 +167,8 @@ class TerminalWindow: NSWindow { } // Return a styled representation of our title property. - private var attributedTitle: NSAttributedString? { - guard let titlebarFont = titlebarFont2 else { return nil } + var attributedTitle: NSAttributedString? { + guard let titlebarFont = titlebarFont else { return nil } let attributes: [NSAttributedString.Key: Any] = [ .font: titlebarFont, From 5877913ab8104d9887efb6dbacee8c2bc7b39200 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 12:02:31 -0700 Subject: [PATCH 11/30] macoS: Split out terminal tabs for ventura vs tahoe --- macos/Ghostty.xcodeproj/project.pbxproj | 32 ++--- .../Terminal/TerminalController.swift | 114 +++--------------- .../HiddenTitlebarTerminalWindow.swift | 29 +++-- .../Window Styles/TerminalHiddenTitlebar.xib | 2 +- ...gacy.xib => TerminalTabsTitlebarTahoe.xib} | 2 +- ...ar.xib => TerminalTabsTitlebarVentura.xib} | 4 +- .../Window Styles/TerminalWindow.swift | 2 +- ... => TitlebarTabsTahoeTerminalWindow.swift} | 5 +- ...> TitlebarTabsVenturaTerminalWindow.swift} | 72 +++++++++-- macos/Sources/Helpers/Fullscreen.swift | 3 +- 10 files changed, 120 insertions(+), 145 deletions(-) rename macos/Sources/Features/Terminal/Window Styles/{TerminalLegacy.xib => TerminalTabsTitlebarTahoe.xib} (94%) rename macos/Sources/Features/Terminal/Window Styles/{TerminalTabsTitlebar.xib => TerminalTabsTitlebarVentura.xib} (91%) rename macos/Sources/Features/Terminal/Window Styles/{TabsTitlebarTerminalWindow.swift => TitlebarTabsTahoeTerminalWindow.swift} (90%) rename macos/Sources/Features/Terminal/Window Styles/{LegacyTerminalWindow.swift => TitlebarTabsVenturaTerminalWindow.swift} (91%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3f1cddf44..cd0c17f9b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -16,9 +16,9 @@ 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 */; }; - A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */; }; - A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */; }; - A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */; }; + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */; }; + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */; }; + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; }; @@ -57,7 +57,7 @@ A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; - A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */; }; + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */; }; A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; }; A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; @@ -139,9 +139,9 @@ 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 = ""; }; - A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsTitlebarTerminalWindow.swift; sourceTree = ""; }; - A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebar.xib; sourceTree = ""; }; - A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTerminalWindow.swift; sourceTree = ""; }; + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsVenturaTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -174,7 +174,7 @@ A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; - A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalLegacy.xib; sourceTree = ""; }; + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarVentura.xib; sourceTree = ""; }; A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = ""; }; A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; @@ -407,13 +407,13 @@ children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, - A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, - A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */, + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */, + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */, A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, - A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, - A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */, + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */, + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */, A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, ); path = "Window Styles"; @@ -682,7 +682,7 @@ buildActionMask = 2147483647; files = ( FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */, - A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */, + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, @@ -700,7 +700,7 @@ A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, - A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */, + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -768,7 +768,7 @@ A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, - A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */, + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, @@ -777,7 +777,7 @@ A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index d59f71619..977a064e0 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -13,10 +13,15 @@ class TerminalController: BaseTerminalController { let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { case "native": "Terminal" - //case "tabs": "TerminalTabsTitlebar" - case "tabs": "TerminalLegacy" case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" + case "tabs": + if #available(macOS 26.0, *) { + // TODO: Switch to Tahoe when ready + "TerminalTabsTitlebarVentura" + } else { + "TerminalTabsTitlebarVentura" + } default: defaultValue } @@ -447,68 +452,20 @@ class TerminalController: BaseTerminalController { private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance - if let window = window as? TerminalWindow { - // Sync our zoom state for splits - window.surfaceIsZoomed = surfaceTree.zoomed != nil + guard let window = window as? TerminalWindow 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 - } + // Sync our zoom state for splits + window.surfaceIsZoomed = surfaceTree.zoomed != nil - // Call this last in case it uses any of the properties above. - window.syncAppearance(surfaceConfig) - } - - 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 - } - - // 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 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 - // configuration. - let backgroundColor: OSColor - if !surfaceTree.isEmpty { - if let focusedSurface = focusedSurface, - let treeRoot = surfaceTree.root, - let focusedNode = treeRoot.node(view: focusedSurface), - treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { - // Similar to above, an alpha component of "0" causes compositor issues, so - // we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308 - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001) - } else { - // We don't have a focused surface or our surface doesn't border the - // top. We choose to match the color of the top-left most surface. - let topLeftSurface = surfaceTree.root?.leftmostLeaf() - backgroundColor = OSColor(topLeftSurface?.backgroundColor ?? derivedConfig.backgroundColor) - } + // Set the font for the window and tab titles. + if let titleFontName = surfaceConfig.windowTitleFontFamily { + window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) } else { - backgroundColor = OSColor(self.derivedConfig.backgroundColor) + window.titlebarFont = nil } - window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity) - if (window.isOpaque) { - // Bg color is only synced if we have no transparency. This is because - // the transparency is handled at the surface level (window.backgroundColor - // ignores alpha components) - window.backgroundColor = backgroundColor - - // If there is transparency, calling this will make the titlebar opaque - // so we only call this if we are opaque. - window.updateTabBar() - } + // Call this last in case it uses any of the properties above. + window.syncAppearance(surfaceConfig) } /// Returns the default size of the window. This is contextual based on the focused surface because @@ -884,28 +841,6 @@ class TerminalController: BaseTerminalController { } } - // TODO: remove - if let window = window as? LegacyTerminalWindow, - config.macosTitlebarStyle == "tabs" { - // Handle titlebar tabs config option. Something about what we do while setting up the - // titlebar tabs interferes with the window restore process unless window.tabbingMode - // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic - } - - if window.hasStyledTabs { - // Set the background color of the window - let backgroundColor = NSColor(config.backgroundColor) - window.backgroundColor = backgroundColor - - // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) - } - } - // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, @@ -1110,23 +1045,6 @@ class TerminalController: BaseTerminalController { } //MARK: - TerminalViewDelegate - - override func titleDidChange(to: String) { - super.titleDidChange(to: to) - - guard let window = window as? LegacyTerminalWindow else { return } - - // Custom toolbar-based title used when titlebar tabs are enabled. - if let toolbar = window.toolbar as? TerminalToolbar { - if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") { - // Updating the title text as above automatically reveals the - // native title view in macOS 15.0 and above. Since we're using - // a custom view instead, we need to re-hide it. - window.titleVisibility = .hidden - } - toolbar.titleText = to - } - } override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index f2d3b9b85..5f4d6b177 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -19,15 +19,6 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { NotificationCenter.default.removeObserver(self) } - // We override this so that with the hidden titlebar style the titlebar - // area is not draggable. - override var contentLayoutRect: CGRect { - var rect = super.contentLayoutRect - rect.origin.y = 0 - rect.size.height = self.frame.height - return rect - } - /// Apply the hidden titlebar style. private func reapplyHiddenStyle() { styleMask = [ @@ -64,6 +55,26 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { } } + // MARK: NSWindow + + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + reapplyHiddenStyle() + } + } + + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + rect.origin.y = 0 + rect.size.height = self.frame.height + return rect + } + // MARK: Notifications @objc private func fullscreenDidExit(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib index 1a2a6c192..eb4675657 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -17,7 +17,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib similarity index 94% rename from macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib rename to macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib index 61ed6f782..deaeded9f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib @@ -13,7 +13,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib similarity index 91% rename from macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib rename to macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib index 779b6e094..bf53a4510 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib @@ -13,11 +13,11 @@ - + - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index a0e18d283..f6b53c289 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -10,7 +10,7 @@ class TerminalWindow: NSWindow { 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? + private(set) var derivedConfig: DerivedConfig? /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { diff --git a/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift similarity index 90% rename from macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 858b54829..c45e93d79 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -1,7 +1,8 @@ import AppKit import SwiftUI -class TabsTitlebarTerminalWindow: TerminalWindow, NSToolbarDelegate { +/// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. +class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { override func awakeFromNib() { super.awakeFromNib() @@ -49,7 +50,7 @@ extension NSToolbarItem.Identifier { static let title = NSToolbarItem.Identifier("Title") } -extension TabsTitlebarTerminalWindow { +extension TitlebarTabsTahoeTerminalWindow { struct TitleItem: View { var body: some View { Text("HELLO THIS IS A PRETTY LONG TITLE") diff --git a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift similarity index 91% rename from macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 89afbf72f..2f8eb5840 100644 --- a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -1,11 +1,10 @@ import Cocoa -/// The terminal window that we originally had in Ghostty for a long time. Kind of a soupy mess -/// of styling. -class LegacyTerminalWindow: TerminalWindow { +/// Titlebar tabs for macOS 13 to 15. +class TitlebarTabsVenturaTerminalWindow: TerminalWindow { /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. - var isLightTheme: Bool = false + fileprivate var isLightTheme: Bool = false override var surfaceIsZoomed: Bool { didSet { @@ -32,17 +31,30 @@ class LegacyTerminalWindow: TerminalWindow { } } - // MARK: - Lifecycle + // MARK: NSWindow override func awakeFromNib() { super.awakeFromNib() - if titlebarTabs { - generateToolbar() - } - } + // Handle titlebar tabs config option. Something about what we do while setting up the + // titlebar tabs interferes with the window restore process unless window.tabbingMode + // is set to .preferred, so we set it, and switch back to automatic as soon as we can. + tabbingMode = .preferred + DispatchQueue.main.async { + self.tabbingMode = .automatic + } - // MARK: - NSWindow + titlebarTabs = true + + // This should always be true since our super sets this up. + if let derivedConfig { + // Set the background color of the window + backgroundColor = derivedConfig.backgroundColor + + // This makes sure our titlebar renders correctly when there is a transparent background + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) + } + } // 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 @@ -145,7 +157,29 @@ class LegacyTerminalWindow: TerminalWindow { } } - // MARK: - Tab Bar Styling + // MARK: Appearance + + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + super.syncAppearance(surfaceConfig) + + // Update our window light/darkness based on our updated background color + isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor + + // Update our titlebar color + if let preferredBackgroundColor { + titlebarColor = preferredBackgroundColor + } else if let derivedConfig { + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) + } + + if (isOpaque) { + // If there is transparency, calling this will make the titlebar opaque + // so we only call this if we are opaque. + updateTabBar() + } + } + + // MARK: Tab Bar Styling // This is true if we should apply styles to the titlebar or tab bar. var hasStyledTabs: Bool { @@ -333,6 +367,18 @@ class LegacyTerminalWindow: TerminalWindow { } } + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + titleVisibility = .hidden + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleText = title + } + } + } + // We have to regenerate a toolbar when the titlebar tabs setting changes since our // custom toolbar conditionally generates the items based on this setting. I tried to // invalidate the toolbar items and force a refresh, but as far as I can tell that @@ -559,7 +605,7 @@ fileprivate class WindowDragView: NSView { fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. - private weak var terminalWindow: LegacyTerminalWindow? + private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() @@ -587,7 +633,7 @@ fileprivate class WindowButtonsBackdropView: NSView { fatalError("init(coder:) has not been implemented") } - init(window: LegacyTerminalWindow) { + init(window: TitlebarTabsVenturaTerminalWindow) { self.terminalWindow = window self.isLightTheme = window.isLightTheme diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 3200608d0..d1dac49a3 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -271,8 +271,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. - if let window = window as? LegacyTerminalWindow, - window.titlebarTabs { + if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { window.titlebarTabs = true } From 70029bf82ae13cc5697b7961db08fa2178740327 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 13:39:17 -0700 Subject: [PATCH 12/30] macos: tahoe terminal tabs shows title --- .../Terminal/TerminalController.swift | 8 ++-- .../Window Styles/TerminalWindow.swift | 7 ++-- .../TitlebarTabsTahoeTerminalWindow.swift | 42 +++++++++++++++++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 977a064e0..848617a53 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -18,7 +18,7 @@ class TerminalController: BaseTerminalController { case "tabs": if #available(macOS 26.0, *) { // TODO: Switch to Tahoe when ready - "TerminalTabsTitlebarVentura" + "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } @@ -409,15 +409,15 @@ class TerminalController: BaseTerminalController { // We need to clear any windows beyond this because they have had // a keyEquivalent set previously. guard tab <= 9 else { - window.keyEquivalent2 = "" + window.keyEquivalent = "" continue } let action = "goto_tab:\(tab)" if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent2 = "\(equiv)" + window.keyEquivalent = "\(equiv)" } else { - window.keyEquivalent2 = "" + window.keyEquivalent = "" } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index f6b53c289..907e0b250 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -82,17 +82,16 @@ class TerminalWindow: NSWindow { // MARK: Tab Key Equivalents - // TODO: rename once Legacy window removes - var keyEquivalent2: String? = nil { + var keyEquivalent: String? = nil { didSet { // When our key equivalent is set, we must update the tab label. - guard let keyEquivalent2 else { + guard let keyEquivalent else { keyEquivalentLabel.attributedStringValue = NSAttributedString() return } keyEquivalentLabel.attributedStringValue = NSAttributedString( - string: "\(keyEquivalent2) ", + string: "\(keyEquivalent) ", attributes: [ .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index c45e93d79..a139b1b62 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -3,6 +3,9 @@ import SwiftUI /// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { + /// The view model for SwiftUI views + private var viewModel = ViewModel() + override func awakeFromNib() { super.awakeFromNib() @@ -15,7 +18,23 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { toolbar.delegate = self toolbar.centeredItemIdentifiers.insert(.title) self.toolbar = toolbar - //toolbarStyle = .unifiedCompact + toolbarStyle = .unifiedCompact + } + + // MARK: NSWindow + + override var title: String { + didSet { + viewModel.title = title + } + } + + override func update() { + super.update() + + if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { + glass.isHidden = true + } } // MARK: NSToolbarDelegate @@ -34,7 +53,7 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { switch itemIdentifier { case .title: let item = NSToolbarItem(itemIdentifier: .title) - item.view = NSHostingView(rootView: TitleItem()) + item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) item.visibilityPriority = .user item.isEnabled = true return item @@ -43,6 +62,11 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { } } + // MARK: SwiftUI + + class ViewModel: ObservableObject { + @Published var title: String = "👻 Ghostty" + } } extension NSToolbarItem.Identifier { @@ -51,9 +75,21 @@ extension NSToolbarItem.Identifier { } extension TitlebarTabsTahoeTerminalWindow { + /// Displays the window title struct TitleItem: View { + @ObservedObject var viewModel: ViewModel + + var title: String { + // An empty title makes this view zero-sized and NSToolbar on macOS + // tahoe just deletes the item when that happens. So we use a space + // instead to ensure there's always some size. + viewModel.title.isEmpty ? " " : viewModel.title + } + var body: some View { - Text("HELLO THIS IS A PRETTY LONG TITLE") + Text(title) + .lineLimit(1) + .truncationMode(.tail) } } } From 658ec2eb6f13a7896720a3e95db87d2b808309d6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 14:33:18 -0700 Subject: [PATCH 13/30] macos: add reset zoom to all window titles --- .../Terminal/BaseTerminalController.swift | 2 + .../Terminal/TerminalController.swift | 4 +- .../Window Styles/TerminalWindow.swift | 62 +++++++++++++++++++ macos/Sources/Helpers/Fullscreen.swift | 12 ++-- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 849f13b34..bc91b920e 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -758,6 +758,8 @@ class BaseTerminalController: NSWindowController, } } + func fullscreenDidChange() {} + // MARK: Clipboard Confirmation @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 848617a53..cff230249 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -145,7 +145,9 @@ class TerminalController: BaseTerminalController { } - func fullscreenDidChange() { + override func fullscreenDidChange() { + super.fullscreenDidChange() + // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 907e0b250..4221d9ba4 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -9,6 +9,9 @@ class TerminalWindow: NSWindow { /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" + /// The view model for SwiftUI views + private var viewModel = ViewModel() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig? @@ -19,6 +22,15 @@ class TerminalWindow: NSWindow { // MARK: NSWindow Overrides + override var toolbar: NSToolbar? { + didSet { + DispatchQueue.main.async { + // When we have a toolbar, our SwiftUI view needs to know for layout + self.viewModel.hasToolbar = self.toolbar != nil + } + } + } + override func awakeFromNib() { guard let appDelegate = NSApp.delegate as? AppDelegate else { return } @@ -43,6 +55,18 @@ class TerminalWindow: NSWindow { hideWindowButtons() } + // Create our reset zoom titlebar accessory. + let resetZoomAccessory = NSTitlebarAccessoryViewController() + resetZoomAccessory.layoutAttribute = .right + resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView( + viewModel: viewModel, + action: { [weak self] in + guard let self else { return } + self.terminalController?.splitZoom(self) + })) + addTitlebarAccessoryViewController(resetZoomAccessory) + resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false + // Setup the accessory view for tabs that shows our keyboard shortcuts, // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues // where buttons were not clickable. @@ -115,6 +139,10 @@ class TerminalWindow: NSWindow { // Show/hide our reset zoom button depending on if we're zoomed. // We want to show it if we are zoomed. resetZoomTabButton.isHidden = !surfaceIsZoomed + + DispatchQueue.main.async { + self.viewModel.isSurfaceZoomed = self.surfaceIsZoomed + } } } @@ -313,3 +341,37 @@ class TerminalWindow: NSWindow { } } } + +// MARK: SwiftUI View + +extension TerminalWindow { + class ViewModel: ObservableObject { + @Published var isSurfaceZoomed: Bool = false + @Published var hasToolbar: Bool = false + } + + struct ResetZoomAccessoryView: View { + @ObservedObject var viewModel: ViewModel + let action: () -> Void + + var body: some View { + if viewModel.isSurfaceZoomed { + VStack { + Button(action: action) { + Image("ResetZoom") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .help("Reset Split Zoom") + .frame(width: 20, height: 20) + Spacer() + } + // With a toolbar, the window title is taller, so we need more padding + // to properly align. + .padding(.top, viewModel.hasToolbar ? 10 : 5) + // We always need space at the end of the titlebar + .padding(.trailing, 10) + } + } + } +} diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index d1dac49a3..49cab0756 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -45,10 +45,6 @@ protocol FullscreenDelegate: AnyObject { func fullscreenDidChange() } -extension FullscreenDelegate { - func fullscreenDidChange() {} -} - /// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own. class FullscreenBase { let window: NSWindow @@ -269,6 +265,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.styleMask = savedState.styleMask window.setFrame(window.frameRect(forContentRect: savedState.contentFrame), display: true) + // Removing the "titled" style also derefs all our accessory view controllers + // so we need to restore those. + for c in savedState.titlebarAccessoryViewControllers { + window.addTitlebarAccessoryViewController(c) + } + // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { @@ -383,6 +385,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let tabGroupIndex: Int? let contentFrame: NSRect let styleMask: NSWindow.StyleMask + let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController] let dock: Bool let menu: Bool @@ -394,6 +397,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask + self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.dock = window.screen?.hasDock ?? false if let cgWindowId = window.cgWindowId { From de40e7ce028b57dc993fafc3c18be863d52437c1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 14:36:33 -0700 Subject: [PATCH 14/30] macos: non-native fullscreen should restore toolbars --- macos/Sources/Helpers/Fullscreen.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 49cab0756..a2294a0af 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -271,6 +271,10 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.addTitlebarAccessoryViewController(c) } + // Removing "titled" also clears our toolbar + window.toolbar = savedState.toolbar + window.toolbarStyle = savedState.toolbarStyle + // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { @@ -385,6 +389,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let tabGroupIndex: Int? let contentFrame: NSRect let styleMask: NSWindow.StyleMask + let toolbar: NSToolbar? + let toolbarStyle: NSWindow.ToolbarStyle let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController] let dock: Bool let menu: Bool @@ -397,6 +403,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask + self.toolbar = window.toolbar + self.toolbarStyle = window.toolbarStyle self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.dock = window.screen?.hasDock ?? false From 5c8f1948cecf1c4dc63fb53fae0ef6203dd0d691 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 14:42:08 -0700 Subject: [PATCH 15/30] macos: remove the duplicated reset zoom accessory view from legacy --- .../Terminal/TerminalController.swift | 3 +- .../TitlebarTabsVenturaTerminalWindow.swift | 36 ------------------- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cff230249..9fbf154bc 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -18,7 +18,8 @@ class TerminalController: BaseTerminalController { case "tabs": if #available(macOS 26.0, *) { // TODO: Switch to Tahoe when ready - "TerminalTabsTitlebarTahoe" + "TerminalTabsTitlebarVentura" + //"TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 2f8eb5840..bc6a66a87 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -6,12 +6,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { /// be updated whenever the window background color or surrounding elements changes. fileprivate var isLightTheme: Bool = false - override var surfaceIsZoomed: Bool { - didSet { - updateResetZoomTitlebarButtonVisibility() - } - } - lazy var titlebarColor: NSColor = backgroundColor { didSet { guard let titlebarContainer else { return } @@ -108,8 +102,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } } - updateResetZoomTitlebarButtonVisibility() - // The remainder of this function only applies to styled tabs. guard hasStyledTabs else { return } @@ -277,33 +269,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton() - private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { - guard let titlebarContainer else { return nil } - let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) - let view = NSView(frame: NSRect(origin: .zero, size: size)) - - let button = generateResetZoomButton() - button.frame.origin.x = size.width/2 - button.bounds.width/2 - button.frame.origin.y = size.height/2 - button.bounds.height/2 - view.addSubview(button) - - let titlebarAccessoryViewController = NSTitlebarAccessoryViewController() - titlebarAccessoryViewController.view = view - titlebarAccessoryViewController.layoutAttribute = .right - - return titlebarAccessoryViewController - }() - - private func updateResetZoomTitlebarButtonVisibility() { - guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return } - - if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { - addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) - } - - resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed - } - private func generateResetZoomButton() -> NSButton { let button = NSButton() button.target = nil @@ -394,7 +359,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { resetZoomItem.view!.widthAnchor.constraint(equalToConstant: 22).isActive = true resetZoomItem.view!.heightAnchor.constraint(equalToConstant: 20).isActive = true } - updateResetZoomTitlebarButtonVisibility() } // For titlebar tabs, we want to hide the separator view so that we get rid From 6ae8bd737ae63bb15ba796512319632054248e21 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 15:11:35 -0700 Subject: [PATCH 16/30] macos: hide the reset zoom titlebar accessory when tab bar is shown --- .../Window Styles/TerminalWindow.swift | 23 ++++++++++++++++++- .../Helpers/Extensions/Array+Extension.swift | 4 ++++ .../Helpers/Extensions/NSView+Extension.swift | 17 +++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4221d9ba4..fe7293d5b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -104,6 +104,26 @@ class TerminalWindow: NSWindow { } } + override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { + super.addTitlebarAccessoryViewController(childViewController) + + // Tab bar is attached as a titlebar accessory view controller (layout bottom). We + // can detect when it is shown or hidden by overriding add/remove and searching for + // it. This has been verified to work on macOS 12 to 26 + if childViewController.view.contains(className: "NSTabBar") { + viewModel.hasTabBar = true + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + if let childViewController = titlebarAccessoryViewControllers[safe: index], + childViewController.view.contains(className: "NSTabBar") { + viewModel.hasTabBar = false + } + + super.removeTitlebarAccessoryViewController(at: index) + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -348,6 +368,7 @@ extension TerminalWindow { class ViewModel: ObservableObject { @Published var isSurfaceZoomed: Bool = false @Published var hasToolbar: Bool = false + @Published var hasTabBar: Bool = false } struct ResetZoomAccessoryView: View { @@ -355,7 +376,7 @@ extension TerminalWindow { let action: () -> Void var body: some View { - if viewModel.isSurfaceZoomed { + if viewModel.isSurfaceZoomed && !viewModel.hasTabBar { VStack { Button(action: action) { Image("ResetZoom") diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift index 6f005a349..12f2de43d 100644 --- a/macos/Sources/Helpers/Extensions/Array+Extension.swift +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -1,4 +1,8 @@ extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } + /// Returns the index before i, with wraparound. Assumes i is a valid index. func indexWrapping(before i: Int) -> Int { if i == 0 { diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index b958130f1..0da84abda 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -42,6 +42,21 @@ extension NSView { return false } + /// Checks if the view contains the given class in its hierarchy. + func contains(className name: String) -> Bool { + if String(describing: type(of: self)) == name { + return true + } + + for subview in subviews { + if subview.contains(className: name) { + return true + } + } + + return false + } + /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { for subview in subviews { @@ -113,7 +128,7 @@ extension NSView { } /// Returns a string representation of the view hierarchy in a tree-like format. - private func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { + func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { var result = "" // Add the tree branch characters From 5f9967024744827314535026221565763fd4791d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 16:37:26 -0700 Subject: [PATCH 17/30] macos: tahoe titlebar tabs taking shape --- .../Terminal/TerminalController.swift | 4 +- .../Window Styles/TerminalWindow.swift | 36 +++++- .../TitlebarTabsTahoeTerminalWindow.swift | 106 ++++++++++++++++-- .../Helpers/Extensions/NSView+Extension.swift | 10 ++ 4 files changed, 142 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 9fbf154bc..0b0b264d3 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -18,8 +18,8 @@ class TerminalController: BaseTerminalController { case "tabs": if #available(macOS 26.0, *) { // TODO: Switch to Tahoe when ready - "TerminalTabsTitlebarVentura" - //"TerminalTabsTitlebarTahoe" + //"TerminalTabsTitlebarVentura" + "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index fe7293d5b..032886802 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -110,20 +110,50 @@ class TerminalWindow: NSWindow { // Tab bar is attached as a titlebar accessory view controller (layout bottom). We // can detect when it is shown or hidden by overriding add/remove and searching for // it. This has been verified to work on macOS 12 to 26 - if childViewController.view.contains(className: "NSTabBar") { + if isTabBar(childViewController) { + childViewController.identifier = Self.tabBarIdentifier viewModel.hasTabBar = true } } override func removeTitlebarAccessoryViewController(at index: Int) { - if let childViewController = titlebarAccessoryViewControllers[safe: index], - childViewController.view.contains(className: "NSTabBar") { + if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) { viewModel.hasTabBar = false } super.removeTitlebarAccessoryViewController(at: index) } + // MARK: Tab Bar + + /// This identifier is attached to the tab bar view controller when we detect it being + /// added. + private static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + + func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { + if childViewController.identifier == nil { + // The good case + if childViewController.view.contains(className: "NSTabBar") { + return true + } + + // When a new window is attached to an existing tab group, AppKit adds + // an empty NSView as an accessory view and adds the tab bar later. If + // we're at the bottom and are a single NSView we assume its a tab bar. + if childViewController.layoutAttribute == .bottom && + childViewController.view.className == "NSView" && + childViewController.view.subviews.isEmpty { + return true + } + + return false + } + + // View controllers should be tagged with this as soon as possible to + // increase our accuracy. We do this manually. + return childViewController.identifier == Self.tabBarIdentifier + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index a139b1b62..42bcabee7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -20,7 +20,6 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { self.toolbar = toolbar toolbarStyle = .unifiedCompact } - // MARK: NSWindow override var title: String { @@ -29,11 +28,92 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { } } - override func update() { - super.update() + override var toolbar: NSToolbar? { + didSet{ + guard toolbar != nil else { return } - if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { - glass.isHidden = true + // When a toolbar is added, remove the Liquid Glass look because we're + // abusing the toolbar as a tab bar. + if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { + glass.isHidden = true + } + } + } + + override func becomeMain() { + super.becomeMain() + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + self.contentView?.printViewHierarchy() + } + } + + // This is called by macOS for native tabbing in order to add the tab bar. We hook into + // this, detect the tab bar being added, and override its behavior. + override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { + // If this is the tab bar then we need to set it up for the titlebar + guard isTabBar(childViewController) else { + super.addTitlebarAccessoryViewController(childViewController) + return + } + + // Some setup needs to happen BEFORE it is added, such as layout. If + // we don't do this before the call below, we'll trigger an AppKit + // assertion. + childViewController.layoutAttribute = .right + + super.addTitlebarAccessoryViewController(childViewController) + + // View model updates must happen on their own ticks + DispatchQueue.main.async { + self.viewModel.hasTabBar = true + } + + // Setup the tab bar to go into the titlebar. + DispatchQueue.main.async { + // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ + // If we don't do this then on launch windows with restored state with tabs will end + // up with messed up tab bars that don't show all tabs. + let accessoryView = childViewController.view + guard let clipView = accessoryView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } + guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // The container is the view that we'll constrain our tab bar within. + let container = toolbarView + + // Constrain the accessory clip view (the parent of the accessory view + // usually that clips the children) to the container view. + clipView.translatesAutoresizingMaskIntoConstraints = false + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 78).isActive = true + clipView.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true + clipView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true + clipView.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true + clipView.needsLayout = true + + // Constrain the actual accessory view (the tab bar) to the clip view + // so it takes up the full space. + accessoryView.translatesAutoresizingMaskIntoConstraints = false + accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor).isActive = true + accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor).isActive = true + accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor).isActive = true + accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor).isActive = true + accessoryView.needsLayout = true + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + guard let childViewController = titlebarAccessoryViewControllers[safe: index], + isTabBar(childViewController) else { + super.removeTitlebarAccessoryViewController(at: index) + return + } + + super.removeTitlebarAccessoryViewController(at: index) + + // View model needs to be updated on another tick because it + // triggers view updates. + DispatchQueue.main.async { + self.viewModel.hasTabBar = false } } @@ -66,6 +146,7 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { class ViewModel: ObservableObject { @Published var title: String = "👻 Ghostty" + @Published var hasTabBar: Bool = false } } @@ -83,13 +164,20 @@ extension TitlebarTabsTahoeTerminalWindow { // An empty title makes this view zero-sized and NSToolbar on macOS // tahoe just deletes the item when that happens. So we use a space // instead to ensure there's always some size. - viewModel.title.isEmpty ? " " : viewModel.title + return viewModel.title.isEmpty ? " " : viewModel.title } var body: some View { - Text(title) - .lineLimit(1) - .truncationMode(.tail) + if !viewModel.hasTabBar { + Text(title) + .lineLimit(1) + .truncationMode(.tail) + } else { + // 1x1.gif strikes again! For real: if we render a zero-sized + // view here then the toolbar just disappears our view. I don't + // know. + Color.clear.frame(width: 1, height: 1) + } } } } diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 0da84abda..b3628d406 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -57,6 +57,16 @@ extension NSView { return false } + /// Finds the superview with the given class name. + func firstSuperview(withClassName name: String) -> NSView? { + guard let superview else { return nil } + if String(describing: type(of: superview)) == name { + return superview + } + + return superview.firstSuperview(withClassName: name) + } + /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { for subview in subviews { From d84c30ce71546efc36e671beac1febf5b2a92875 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 18:10:25 -0700 Subject: [PATCH 18/30] macos: titlebar tabs should be transparent --- .../Window Styles/TitlebarTabsTahoeTerminalWindow.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 42bcabee7..ca88322ed 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -2,7 +2,10 @@ import AppKit import SwiftUI /// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. -class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { +/// +/// This inherits from transparent styling so that the titlebar matches the background color +/// of the window. +class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() From 9d9c451b0a893c35c29ba453722cfb337d73817a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 20:03:19 -0700 Subject: [PATCH 19/30] macos: titlebar tabs handle hidden traffic buttons --- .../Terminal/Window Styles/TerminalWindow.swift | 9 ++++++--- .../TitlebarTabsTahoeTerminalWindow.swift | 11 +++++++++-- .../TitlebarTabsVenturaTerminalWindow.swift | 13 +++++-------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 032886802..d9b98695e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -13,7 +13,7 @@ class TerminalWindow: NSWindow { private var viewModel = ViewModel() /// The configuration derived from the Ghostty config so we don't need to rely on references. - private(set) var derivedConfig: DerivedConfig? + private(set) var derivedConfig: DerivedConfig = .init() /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { @@ -341,8 +341,8 @@ class TerminalWindow: NSWindow { } } - let alpha = derivedConfig?.backgroundOpacity.clamped(to: 0.001...1) ?? 1 - return derivedConfig?.backgroundColor.withAlphaComponent(alpha) + let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return derivedConfig.backgroundColor.withAlphaComponent(alpha) } private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { @@ -379,15 +379,18 @@ class TerminalWindow: NSWindow { struct DerivedConfig { let backgroundColor: NSColor let backgroundOpacity: Double + let macosWindowButtons: Ghostty.MacOSWindowButtons init() { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 + self.macosWindowButtons = .visible } init(_ config: Ghostty.Config) { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity + self.macosWindowButtons = config.macosWindowButtons } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index ca88322ed..3dc505088 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -84,12 +84,19 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // The container is the view that we'll constrain our tab bar within. let container = toolbarView + // The padding for the tab bar. If we're showing window buttons then + // we need to offset the window buttons. + let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { + case .hidden: 0 + case .visible: 70 + } + // Constrain the accessory clip view (the parent of the accessory view // usually that clips the children) to the container view. clipView.translatesAutoresizingMaskIntoConstraints = false - clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 78).isActive = true + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding).isActive = true clipView.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true - clipView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true + clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2).isActive = true clipView.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true clipView.needsLayout = true diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index bc6a66a87..6e19d144d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -40,14 +40,11 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { titlebarTabs = true - // This should always be true since our super sets this up. - if let derivedConfig { - // Set the background color of the window - backgroundColor = derivedConfig.backgroundColor + // Set the background color of the window + backgroundColor = derivedConfig.backgroundColor - // This makes sure our titlebar renders correctly when there is a transparent background - titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) - } + // This makes sure our titlebar renders correctly when there is a transparent background + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } // We only need to set this once, but need to do it after the window has been created in order @@ -160,7 +157,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // Update our titlebar color if let preferredBackgroundColor { titlebarColor = preferredBackgroundColor - } else if let derivedConfig { + } else { titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } From 17ad77b5b0b7b7ead22bcfda11c9250d064d408d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 21:33:40 -0700 Subject: [PATCH 20/30] macos: fix background color of terminal window to match surface --- .../Window Styles/TerminalWindow.swift | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d9b98695e..a1bb1d86d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -322,22 +322,23 @@ class TerminalWindow: NSWindow { /// change the alpha channel again manually. var preferredBackgroundColor: NSColor? { if let terminalController, !terminalController.surfaceTree.isEmpty { + let surface: Ghostty.SurfaceView? + // 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) + treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { + surface = focusedSurface + } else { + // If it doesn't border the top, we use the top-left leaf + surface = terminalController.surfaceTree.root?.leftmostLeaf() } - // 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) + if let surface { + let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor + let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return NSColor(backgroundColor).withAlphaComponent(alpha) } } From 00d41239dad768d903790b86d56bd8a3bb1de82b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 11:11:00 -0700 Subject: [PATCH 21/30] macOS: prep the tab bar when system appearance changes --- .../TitlebarTabsTahoeTerminalWindow.swift | 182 +++++++++++++----- 1 file changed, 129 insertions(+), 53 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 3dc505088..ac4fae12a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -9,20 +9,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// The view model for SwiftUI views private var viewModel = ViewModel() - override func awakeFromNib() { - super.awakeFromNib() - - // We must hide the title since we're going to be moving tabs into - // the titlebar which have their own title. - titleVisibility = .hidden - - // Create a toolbar - let toolbar = NSToolbar(identifier: "TerminalToolbar") - toolbar.delegate = self - toolbar.centeredItemIdentifiers.insert(.title) - self.toolbar = toolbar - toolbarStyle = .unifiedCompact + deinit { + tabBarObserver = nil } + // MARK: NSWindow override var title: String { @@ -43,11 +33,27 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool } } + override func awakeFromNib() { + super.awakeFromNib() + + // We must hide the title since we're going to be moving tabs into + // the titlebar which have their own title. + titleVisibility = .hidden + + // Create a toolbar + let toolbar = NSToolbar(identifier: "TerminalToolbar") + toolbar.delegate = self + toolbar.centeredItemIdentifiers.insert(.title) + self.toolbar = toolbar + toolbarStyle = .unifiedCompact + } + override func becomeMain() { super.becomeMain() - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { - self.contentView?.printViewHierarchy() - } + + // Check if we have a tab bar and set it up if we have to. See the comment + // on this function to learn why we need to check this here. + setupTabBar() } // This is called by macOS for native tabbing in order to add the tab bar. We hook into @@ -66,48 +72,12 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool super.addTitlebarAccessoryViewController(childViewController) - // View model updates must happen on their own ticks - DispatchQueue.main.async { - self.viewModel.hasTabBar = true - } - // Setup the tab bar to go into the titlebar. DispatchQueue.main.async { // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ // If we don't do this then on launch windows with restored state with tabs will end // up with messed up tab bars that don't show all tabs. - let accessoryView = childViewController.view - guard let clipView = accessoryView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } - guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } - guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } - - // The container is the view that we'll constrain our tab bar within. - let container = toolbarView - - // The padding for the tab bar. If we're showing window buttons then - // we need to offset the window buttons. - let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { - case .hidden: 0 - case .visible: 70 - } - - // Constrain the accessory clip view (the parent of the accessory view - // usually that clips the children) to the container view. - clipView.translatesAutoresizingMaskIntoConstraints = false - clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding).isActive = true - clipView.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true - clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2).isActive = true - clipView.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true - clipView.needsLayout = true - - // Constrain the actual accessory view (the tab bar) to the clip view - // so it takes up the full space. - accessoryView.translatesAutoresizingMaskIntoConstraints = false - accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor).isActive = true - accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor).isActive = true - accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor).isActive = true - accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor).isActive = true - accessoryView.needsLayout = true + self.setupTabBar() } } @@ -120,11 +90,117 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool super.removeTitlebarAccessoryViewController(at: index) + removeTabBar() + } + + // MARK: Tab Bar Setup + + private var tabBarObserver: NSObjectProtocol? { + didSet { + // When we change this we want to clear our old observer + guard let oldValue else { return } + NotificationCenter.default.removeObserver(oldValue) + } + } + + /// Take the NSTabBar that is on the window and convert it into titlebar tabs. + /// + /// Let me explain more background on what is happening here. When a tab bar is created, only the + /// main window actually has an NSTabBar. When an NSWindow in the tab group gains main, AppKit + /// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar + /// is removed from the view hierarchy. + /// + /// We can't detect this via `addTitlebarAccessoryViewController` because AppKit + /// _always_ creates an accessory view controller for every window in the tab group, but puts a + /// zero-sized NSView into it (that the tab bar is then attached to later). + /// + /// The best way I've found to detect this is to search for and setup the tab bar anytime the + /// window gains focus. There are probably edge cases to check but to resolve all this I made + /// this function which is idempotent to call. + /// + /// There are more scenarios to look out for and they're documented within the method. + func setupTabBar() { + // We only want to setup the observer once + guard tabBarObserver == nil else { return } + + // Find our tab bar. If it doesn't exist we don't do anything. + guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return } + + // View model updates must happen on their own ticks. + DispatchQueue.main.async { + self.viewModel.hasTabBar = true + } + + // Find our clip view + guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let accessoryView = clipView.subviews[safe: 0] else { return } + guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } + guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // The container is the view that we'll constrain our tab bar within. + let container = toolbarView + + // The padding for the tab bar. If we're showing window buttons then + // we need to offset the window buttons. + let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { + case .hidden: 0 + case .visible: 70 + } + + // Constrain the accessory clip view (the parent of the accessory view + // usually that clips the children) to the container view. + clipView.translatesAutoresizingMaskIntoConstraints = false + accessoryView.translatesAutoresizingMaskIntoConstraints = false + + // Setup all our constraints + NSLayoutConstraint.activate([ + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding), + clipView.rightAnchor.constraint(equalTo: container.rightAnchor), + clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), + clipView.heightAnchor.constraint(equalTo: container.heightAnchor), + accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor), + accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor), + accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor), + accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor), + ]) + + clipView.needsLayout = true + accessoryView.needsLayout = true + + // We need to setup an observer for the NSTabBar frame. When we change system + // appearance, the tab bar temporarily becomes width/height 0 and breaks all our + // constraints and AppKit responds by nuking the whole tab bar cause it doesn't + // know what to do with it. We need to detect this before bad things happen. + tabBar.postsFrameChangedNotifications = true + tabBarObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: tabBar, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + // Check if either width or height is zero + guard tabBar.frame.size.width == 0 || tabBar.frame.size.height == 0 else { return } + + // Remove the observer so we can call setup again. + self.tabBarObserver = nil + + // Wait a tick to let the new tab bars appear and then set them up. + DispatchQueue.main.async { + self.setupTabBar() + } + } + } + + func removeTabBar() { // View model needs to be updated on another tick because it // triggers view updates. DispatchQueue.main.async { self.viewModel.hasTabBar = false } + + // Clear our observations + self.tabBarObserver = nil } // MARK: NSToolbarDelegate From b1b74d3421693fa9d7042fd9167603ade3619aa3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 12:25:21 -0700 Subject: [PATCH 22/30] comments --- .../TitlebarTabsTahoeTerminalWindow.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index ac4fae12a..145c37c59 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -25,8 +25,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool didSet{ guard toolbar != nil else { return } - // When a toolbar is added, remove the Liquid Glass look because we're - // abusing the toolbar as a tab bar. + // When a toolbar is added, remove the Liquid Glass look to have a cleaner + // appearance for our custom titlebar tabs. if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { glass.isHidden = true } @@ -110,9 +110,9 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar /// is removed from the view hierarchy. /// - /// We can't detect this via `addTitlebarAccessoryViewController` because AppKit - /// _always_ creates an accessory view controller for every window in the tab group, but puts a - /// zero-sized NSView into it (that the tab bar is then attached to later). + /// We can't reliably detect this via `addTitlebarAccessoryViewController` because AppKit + /// creates an accessory view controller for every window in the tab group, but only attaches + /// the actual NSTabBar to the main window's accessory view. /// /// The best way I've found to detect this is to search for and setup the tab bar anytime the /// window gains focus. There are probably edge cases to check but to resolve all this I made @@ -167,10 +167,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool clipView.needsLayout = true accessoryView.needsLayout = true - // We need to setup an observer for the NSTabBar frame. When we change system - // appearance, the tab bar temporarily becomes width/height 0 and breaks all our - // constraints and AppKit responds by nuking the whole tab bar cause it doesn't - // know what to do with it. We need to detect this before bad things happen. + // Setup an observer for the NSTabBar frame. When system appearance changes or + // other events occur, the tab bar can temporarily become zero-sized. When this + // happens, we need to remove our custom constraints and re-apply them once the + // tab bar has proper dimensions again to avoid constraint conflicts. tabBar.postsFrameChangedNotifications = true tabBarObserver = NotificationCenter.default.addObserver( forName: NSView.frameDidChangeNotification, From 59812c3b02ec6fffe0d9febf3b234faf27bd4dbc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 12:27:44 -0700 Subject: [PATCH 23/30] macos: remove TODO --- macos/Sources/Features/Terminal/TerminalController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 0b0b264d3..03a4e548e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -17,8 +17,6 @@ class TerminalController: BaseTerminalController { case "transparent": "TerminalTransparentTitlebar" case "tabs": if #available(macOS 26.0, *) { - // TODO: Switch to Tahoe when ready - //"TerminalTabsTitlebarVentura" "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" From f7f0514b9ff8065e7e0b75cf0e05e75d1ef00642 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 13:14:14 -0700 Subject: [PATCH 24/30] macos: move old toolbar into ventura file --- macos/Ghostty.xcodeproj/project.pbxproj | 4 - .../Features/Terminal/TerminalToolbar.swift | 130 ----------------- .../TitlebarTabsVenturaTerminalWindow.swift | 131 ++++++++++++++++++ 3 files changed, 131 insertions(+), 134 deletions(-) delete mode 100644 macos/Sources/Features/Terminal/TerminalToolbar.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index cd0c17f9b..e9c02ef41 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -120,7 +120,6 @@ A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; @@ -239,7 +238,6 @@ A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; C1F26EA62B738B9900404083 /* NSView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Extension.swift"; sourceTree = ""; }; C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = ""; }; @@ -507,7 +505,6 @@ A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); @@ -799,7 +796,6 @@ A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */, A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */, A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */, - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */, C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */, A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift deleted file mode 100644 index 9da14562c..000000000 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Cocoa - -// Custom NSToolbar subclass that displays a centered window title, -// in order to accommodate the titlebar tabs feature. -class TerminalToolbar: NSToolbar, NSToolbarDelegate { - private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") - - var titleText: String { - get { - titleTextField.stringValue - } - - set { - titleTextField.stringValue = newValue - } - } - - var titleFont: NSFont? { - get { - titleTextField.font - } - - set { - titleTextField.font = newValue - } - } - - var titleIsHidden: Bool { - get { - titleTextField.isHidden - } - - set { - titleTextField.isHidden = newValue - } - } - - override init(identifier: NSToolbar.Identifier) { - super.init(identifier: identifier) - - delegate = self - centeredItemIdentifiers.insert(.titleText) - } - - func toolbar(_ toolbar: NSToolbar, - itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, - willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - var item: NSToolbarItem - - switch itemIdentifier { - case .titleText: - item = NSToolbarItem(itemIdentifier: .titleText) - item.view = self.titleTextField - item.visibilityPriority = .user - - // This ensures the title text field doesn't disappear when shrinking the view - self.titleTextField.translatesAutoresizingMaskIntoConstraints = false - self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) - self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - - // Add constraints to the toolbar item's view - NSLayoutConstraint.activate([ - // Set the height constraint to match the toolbar's height - self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed - ]) - - item.isEnabled = true - case .resetZoom: - item = NSToolbarItem(itemIdentifier: .resetZoom) - default: - item = NSToolbarItem(itemIdentifier: itemIdentifier) - } - - return item - } - - func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [.titleText, .flexibleSpace, .space, .resetZoom] - } - - func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - // These space items are here to ensure that the title remains centered when it starts - // getting smaller than the max size so starts clipping. Lucky for us, two of the - // built-in spacers plus the un-zoom button item seems to exactly match the space - // on the left that's reserved for the window buttons. - return [.flexibleSpace, .titleText, .flexibleSpace] - } -} - -/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. -fileprivate class CenteredDynamicLabel: NSTextField { - override func viewDidMoveToSuperview() { - // Configure the text field - isEditable = false - isBordered = false - drawsBackground = false - alignment = .center - lineBreakMode = .byTruncatingTail - cell?.truncatesLastVisibleLine = true - - // Use Auto Layout - translatesAutoresizingMaskIntoConstraints = false - - // Set content hugging and compression resistance priorities - setContentHuggingPriority(.defaultLow, for: .horizontal) - setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - } - - // Vertically center the text - override func draw(_ dirtyRect: NSRect) { - guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { - super.draw(dirtyRect) - return - } - - let textSize = attributedString.size() - - let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better - - let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, - width: self.bounds.width, height: textSize.height) - - attributedString.draw(in: centeredRect) - } -} - -extension NSToolbarItem.Identifier { - static let resetZoom = NSToolbarItem.Identifier("ResetZoom") - static let titleText = NSToolbarItem.Identifier("TitleText") -} diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 6e19d144d..20280b982 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -609,3 +609,134 @@ fileprivate class WindowButtonsBackdropView: NSView { layer?.addSublayer(overlayLayer) } } + +// MARK: Toolbar + +// Custom NSToolbar subclass that displays a centered window title, +// in order to accommodate the titlebar tabs feature. +fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate { + private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") + + var titleText: String { + get { + titleTextField.stringValue + } + + set { + titleTextField.stringValue = newValue + } + } + + var titleFont: NSFont? { + get { + titleTextField.font + } + + set { + titleTextField.font = newValue + } + } + + var titleIsHidden: Bool { + get { + titleTextField.isHidden + } + + set { + titleTextField.isHidden = newValue + } + } + + override init(identifier: NSToolbar.Identifier) { + super.init(identifier: identifier) + + delegate = self + centeredItemIdentifiers.insert(.titleText) + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + var item: NSToolbarItem + + switch itemIdentifier { + case .titleText: + item = NSToolbarItem(itemIdentifier: .titleText) + item.view = self.titleTextField + item.visibilityPriority = .user + + // This ensures the title text field doesn't disappear when shrinking the view + self.titleTextField.translatesAutoresizingMaskIntoConstraints = false + self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) + self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + // Add constraints to the toolbar item's view + NSLayoutConstraint.activate([ + // Set the height constraint to match the toolbar's height + self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed + ]) + + item.isEnabled = true + case .resetZoom: + item = NSToolbarItem(itemIdentifier: .resetZoom) + default: + item = NSToolbarItem(itemIdentifier: itemIdentifier) + } + + return item + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.titleText, .flexibleSpace, .space, .resetZoom] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + // These space items are here to ensure that the title remains centered when it starts + // getting smaller than the max size so starts clipping. Lucky for us, two of the + // built-in spacers plus the un-zoom button item seems to exactly match the space + // on the left that's reserved for the window buttons. + return [.flexibleSpace, .titleText, .flexibleSpace] + } +} + +/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. +fileprivate class CenteredDynamicLabel: NSTextField { + override func viewDidMoveToSuperview() { + // Configure the text field + isEditable = false + isBordered = false + drawsBackground = false + alignment = .center + lineBreakMode = .byTruncatingTail + cell?.truncatesLastVisibleLine = true + + // Use Auto Layout + translatesAutoresizingMaskIntoConstraints = false + + // Set content hugging and compression resistance priorities + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + } + + // Vertically center the text + override func draw(_ dirtyRect: NSRect) { + guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { + super.draw(dirtyRect) + return + } + + let textSize = attributedString.size() + + let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better + + let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, + width: self.bounds.width, height: textSize.height) + + attributedString.draw(in: centeredRect) + } +} + +extension NSToolbarItem.Identifier { + static let resetZoom = NSToolbarItem.Identifier("ResetZoom") + static let titleText = NSToolbarItem.Identifier("TitleText") +} From a7df90ee5529b2cccac5651c57661dac1f350b99 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 13:36:03 -0700 Subject: [PATCH 25/30] macos: remove split zoom accessory when tabs appear --- .../Window Styles/TerminalWindow.swift | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index a1bb1d86d..d588a5944 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -12,6 +12,9 @@ class TerminalWindow: NSWindow { /// The view model for SwiftUI views private var viewModel = ViewModel() + /// Reset split zoom button in titlebar + private let resetZoomAccessory = NSTitlebarAccessoryViewController() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() @@ -56,7 +59,6 @@ class TerminalWindow: NSWindow { } // Create our reset zoom titlebar accessory. - let resetZoomAccessory = NSTitlebarAccessoryViewController() resetZoomAccessory.layoutAttribute = .right resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView( viewModel: viewModel, @@ -94,6 +96,18 @@ class TerminalWindow: NSWindow { resetZoomTabButton.contentTintColor = .secondaryLabelColor } + override func becomeMain() { + super.becomeMain() + + // Its possible we miss the accessory titlebar call so we check again + // whenever the window becomes main. Both of these are idempotent. + if hasTabBar { + tabBarDidAppear() + } else { + tabBarDidDisappear() + } + } + override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) @@ -112,13 +126,13 @@ class TerminalWindow: NSWindow { // it. This has been verified to work on macOS 12 to 26 if isTabBar(childViewController) { childViewController.identifier = Self.tabBarIdentifier - viewModel.hasTabBar = true + tabBarDidAppear() } } override func removeTitlebarAccessoryViewController(at index: Int) { if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) { - viewModel.hasTabBar = false + tabBarDidDisappear() } super.removeTitlebarAccessoryViewController(at: index) @@ -130,6 +144,11 @@ class TerminalWindow: NSWindow { /// added. private static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + /// Returns true if there is a tab bar visible on this window. + var hasTabBar: Bool { + contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil + } + func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { if childViewController.identifier == nil { // The good case @@ -154,6 +173,28 @@ class TerminalWindow: NSWindow { return childViewController.identifier == Self.tabBarIdentifier } + /// Ensures we only run didAppear/didDisappear once per state. + private var tabBarDidAppearRan = false + + private func tabBarDidAppear() { + guard !tabBarDidAppearRan else { return } + tabBarDidAppearRan = true + + // Remove our reset zoom accessory. For some reason having a SwiftUI + // titlebar accessory causes our content view scaling to be wrong. + // Removing it fixes it, we just need to remember to add it again later. + if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { + removeTitlebarAccessoryViewController(at: idx) + } + } + + private func tabBarDidDisappear() { + guard tabBarDidAppearRan else { return } + tabBarDidAppearRan = false + + addTitlebarAccessoryViewController(resetZoomAccessory) + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -402,7 +443,6 @@ extension TerminalWindow { class ViewModel: ObservableObject { @Published var isSurfaceZoomed: Bool = false @Published var hasToolbar: Bool = false - @Published var hasTabBar: Bool = false } struct ResetZoomAccessoryView: View { @@ -410,7 +450,7 @@ extension TerminalWindow { let action: () -> Void var body: some View { - if viewModel.isSurfaceZoomed && !viewModel.hasTabBar { + if viewModel.isSurfaceZoomed { VStack { Button(action: action) { Image("ResetZoom") From 8cfc904c0c86a8e87cb6f5234aa6fe55e4bd2d53 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 14:38:07 -0700 Subject: [PATCH 26/30] macos: fix up some sequoia regressions --- .../Window Styles/TerminalWindow.swift | 27 +++++++++++-------- macos/Sources/Helpers/Fullscreen.swift | 12 +++------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d588a5944..74181089d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -173,13 +173,7 @@ class TerminalWindow: NSWindow { return childViewController.identifier == Self.tabBarIdentifier } - /// Ensures we only run didAppear/didDisappear once per state. - private var tabBarDidAppearRan = false - private func tabBarDidAppear() { - guard !tabBarDidAppearRan else { return } - tabBarDidAppearRan = true - // Remove our reset zoom accessory. For some reason having a SwiftUI // titlebar accessory causes our content view scaling to be wrong. // Removing it fixes it, we just need to remember to add it again later. @@ -189,10 +183,11 @@ class TerminalWindow: NSWindow { } private func tabBarDidDisappear() { - guard tabBarDidAppearRan else { return } - tabBarDidAppearRan = false - - addTitlebarAccessoryViewController(resetZoomAccessory) + if styleMask.contains(.titled) { + if titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) == nil { + addTitlebarAccessoryViewController(resetZoomAccessory) + } + } } // MARK: Tab Key Equivalents @@ -448,6 +443,16 @@ extension TerminalWindow { struct ResetZoomAccessoryView: View { @ObservedObject var viewModel: ViewModel let action: () -> Void + + // The padding from the top that the view appears. This was all just manually + // measured based on the OS. + var topPadding: CGFloat { + if #available(macOS 26.0, *) { + return viewModel.hasToolbar ? 10 : 5 + } else { + return viewModel.hasToolbar ? 9 : 4 + } + } var body: some View { if viewModel.isSurfaceZoomed { @@ -463,7 +468,7 @@ extension TerminalWindow { } // With a toolbar, the window title is taller, so we need more padding // to properly align. - .padding(.top, viewModel.hasToolbar ? 10 : 5) + .padding(.top, topPadding) // We always need space at the end of the titlebar .padding(.trailing, 10) } diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index a2294a0af..d78775a1d 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -268,19 +268,15 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Removing the "titled" style also derefs all our accessory view controllers // so we need to restore those. for c in savedState.titlebarAccessoryViewControllers { - window.addTitlebarAccessoryViewController(c) + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { + window.addTitlebarAccessoryViewController(c) + } } // Removing "titled" also clears our toolbar window.toolbar = savedState.toolbar window.toolbarStyle = savedState.toolbarStyle - - // This is a hack that I want to remove from this but for now, we need to - // fix up the titlebar tabs here before we do everything below. - if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { - window.titlebarTabs = true - } - + // If the window was previously in a tab group that isn't empty now, // we re-add it. We have to do this because our process of doing non-native // fullscreen removes the window from the tab group. From 1388c277d5978094994b3b928b9d79a950974989 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 14:43:01 -0700 Subject: [PATCH 27/30] macos: sequoia should use same tab bar identifier as TerminalWindow --- .../Terminal/Window Styles/TerminalWindow.swift | 2 +- .../TitlebarTabsVenturaTerminalWindow.swift | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 74181089d..f9dfb9591 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -142,7 +142,7 @@ class TerminalWindow: NSWindow { /// This identifier is attached to the tab bar view controller when we detect it being /// added. - private static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") /// Returns true if there is a tab bar visible on this window. var hasTabBar: Bool { diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 20280b982..a236df107 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -196,8 +196,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // We can only update titlebar tabs if there is a titlebar. Without the // styleMask check the app will crash (issue #1876) if titlebarTabs && styleMask.contains(.titled) { - guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.TabBarController}) else { return } - + guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.tabBarIdentifier}) else { return } tabBarAccessoryViewController.layoutAttribute = .right pushTabsToTitlebar(tabBarAccessoryViewController) } @@ -314,9 +313,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { private var windowDragHandle: WindowDragView? = nil - // The tab bar controller ID from macOS - static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController") - // Used by the window controller to enable/disable titlebar tabs. var titlebarTabs = false { didSet { @@ -384,10 +380,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // This is called by macOS for native tabbing in order to add the tab bar. We hook into // this, detect the tab bar being added, and override its behavior. override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { - let isTabBar = self.titlebarTabs && ( - childViewController.layoutAttribute == .bottom || - childViewController.identifier == Self.TabBarController - ) + let isTabBar = self.titlebarTabs && isTabBar(childViewController) if (isTabBar) { // Ensure it has the right layoutAttribute to force it next to our titlebar @@ -399,7 +392,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // Mark the controller for future reference so we can easily find it. Otherwise // the tab bar has no ID by default. - childViewController.identifier = Self.TabBarController + childViewController.identifier = Self.tabBarIdentifier } super.addTitlebarAccessoryViewController(childViewController) @@ -410,7 +403,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } override func removeTitlebarAccessoryViewController(at index: Int) { - let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController + let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.tabBarIdentifier super.removeTitlebarAccessoryViewController(at: index) if (isTabBar) { resetCustomTabBarViews() From ac4b0dcac0b5b864a212ddf2c08280b72c5a8016 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 14:57:49 -0700 Subject: [PATCH 28/30] macos: fix transparent tabs on sequoia --- .../TitlebarTabsVenturaTerminalWindow.swift | 16 ------- .../TransparentTitlebarTerminalWindow.swift | 47 +++++++++++++++++-- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index a236df107..99111b55b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -99,9 +99,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } } - // The remainder of this function only applies to styled tabs. - guard hasStyledTabs else { return } - titlebarSeparatorStyle = tabbedWindows != nil && !titlebarTabs ? .line : .none if titlebarTabs { hideToolbarOverflowButton() @@ -170,19 +167,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // MARK: Tab Bar Styling - // This is true if we should apply styles to the titlebar or tab bar. - var hasStyledTabs: Bool { - // If we have titlebar tabs then we always style. - guard !titlebarTabs else { return true } - - // We style the tabs if they're transparent - return transparentTabs - } - - // Set to true if the background color should bleed through the titlebar/tab bar. - // This only applies to non-titlebar tabs. - var transparentTabs: Bool = false - var hasVeryDarkBackground: Bool { backgroundColor.luminance < 0.05 } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index f949b6094..94e938326 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -11,6 +11,13 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { /// KVO observation for tab group window changes. private var tabGroupWindowsObservation: NSKeyValueObservation? private var tabBarVisibleObservation: NSKeyValueObservation? + + deinit { + tabGroupWindowsObservation?.invalidate() + tabBarVisibleObservation?.invalidate() + } + + // MARK: NSWindow override func awakeFromNib() { super.awakeFromNib() @@ -19,11 +26,6 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // to learn why we need KVO. setupKVO() } - - deinit { - tabGroupWindowsObservation?.invalidate() - tabBarVisibleObservation?.invalidate() - } override func becomeMain() { super.becomeMain() @@ -40,6 +42,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } } } + + 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.0) { + hideEffectView() + } + } // MARK: Appearance @@ -86,6 +98,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { guard let titlebarContainer else { return } titlebarContainer.wantsLayer = true titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor + effectViewIsHidden = false } // MARK: View Finders @@ -149,4 +162,28 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { 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 + } } From 1b6142b271b72048f82648920f6de0b2b48960f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 15:02:05 -0700 Subject: [PATCH 29/30] macos: don't restore tab bar with non-native fs --- macos/Sources/Helpers/Fullscreen.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index d78775a1d..f3940a9aa 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -268,6 +268,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Removing the "titled" style also derefs all our accessory view controllers // so we need to restore those. for c in savedState.titlebarAccessoryViewControllers { + // Restoring the tab bar causes all sorts of problems. Its best to just ignore it, + // even though this is kind of a hack. + if let window = window as? TerminalWindow, window.isTabBar(c) { + continue + } + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { window.addTitlebarAccessoryViewController(c) } From 928603c23e1e59717e1c042e3d39487066d9f12f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 20:20:49 -0700 Subject: [PATCH 30/30] macos: use a runtime liquid glass check for our Tahoe styling --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++-- .../Terminal/TerminalController.swift | 2 +- .../Window Styles/TerminalWindow.swift | 2 +- .../TransparentTitlebarTerminalWindow.swift | 4 +- macos/Sources/Helpers/AppInfo.swift | 44 +++++++++++++++++++ macos/Sources/Helpers/Xcode.swift | 10 ----- 6 files changed, 52 insertions(+), 18 deletions(-) create mode 100644 macos/Sources/Helpers/AppInfo.swift delete mode 100644 macos/Sources/Helpers/Xcode.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e9c02ef41..4943f2f4d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -90,7 +90,7 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; }; A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; }; - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* AppInfo.swift */; }; A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; @@ -205,7 +205,7 @@ A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; }; A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; }; - A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = ""; }; A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastWindowPosition.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -312,8 +312,8 @@ children = ( A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, - A5A6F7292CC41B8700B232A5 /* Xcode.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, @@ -756,7 +756,7 @@ A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 03a4e548e..49b3fea34 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -16,7 +16,7 @@ class TerminalController: BaseTerminalController { case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" case "tabs": - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *), hasLiquidGlass() { "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index f9dfb9591..e24323113 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -447,7 +447,7 @@ extension TerminalWindow { // The padding from the top that the view appears. This was all just manually // measured based on the OS. var topPadding: CGFloat { - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *), hasLiquidGlass() { return viewModel.hasToolbar ? 10 : 5 } else { return viewModel.hasToolbar ? 9 : 4 diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 94e938326..1a92fa024 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -48,7 +48,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // 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.0) { + if !effectViewIsHidden && !hasLiquidGlass() { hideEffectView() } } @@ -65,7 +65,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // references changed (e.g. tabGroup is new). setupKVO() - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *), hasLiquidGlass() { syncAppearanceTahoe(surfaceConfig) } else { syncAppearanceVentura(surfaceConfig) diff --git a/macos/Sources/Helpers/AppInfo.swift b/macos/Sources/Helpers/AppInfo.swift new file mode 100644 index 000000000..cf66e332d --- /dev/null +++ b/macos/Sources/Helpers/AppInfo.swift @@ -0,0 +1,44 @@ +import Foundation + +/// True if we appear to be running in Xcode. +func isRunningInXcode() -> Bool { + if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { + return true + } + + return false +} + +/// True if we have liquid glass available. +func hasLiquidGlass() -> Bool { + // Can't have liquid glass unless we're in macOS 26+ + if #unavailable(macOS 26.0) { + return false + } + + // If we aren't running SDK 26.0 or later then we definitely + // do not have liquid glass. + guard let sdkName = Bundle.main.infoDictionary?["DTSDKName"] as? String else { + // If we don't have this, we assume we're built against the latest + // since we're on macOS 26+ + return true + } + + // If the SDK doesn't start with macosx then we just assume we + // have it because we already verified we're on macOS above. + guard sdkName.hasPrefix("macosx") else { + return true + } + + // The SDK version must be at least 26 + let versionString = String(sdkName.dropFirst("macosx".count)) + guard let major = if let dotIndex = versionString.firstIndex(of: ".") { + Int(String(versionString[..= 26 +} diff --git a/macos/Sources/Helpers/Xcode.swift b/macos/Sources/Helpers/Xcode.swift deleted file mode 100644 index 281bad18b..000000000 --- a/macos/Sources/Helpers/Xcode.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// True if we appear to be running in Xcode. -func isRunningInXcode() -> Bool { - if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { - return true - } - - return false -}