From 5f9967024744827314535026221565763fd4791d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 16:37:26 -0700 Subject: [PATCH] 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 {