diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e4f700170..fb86ce8f7 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -952,7 +952,7 @@ class BaseTerminalController: NSWindowController, // controller is a TerminalController this is easy because it has a way // to do this. if let c = sourceController as? TerminalController { - c.closeWindowImmediately() + c.closeTabImmediately() } else { // Not a TerminalController so we always undo into a new window. _ = TerminalController.newWindow( diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 26ca8f70e..abaedbe41 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -614,7 +614,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeWindow(nil) } - private func closeTabImmediately(registerRedo: Bool = true) { + func closeTabImmediately(registerRedo: Bool = true) { guard let window = window else { return } guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9debd2cb3..501ac0e67 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -194,7 +194,7 @@ class TerminalWindow: NSWindow { // Its possible we miss the accessory titlebar call so we check again // whenever the window becomes main. Both of these are idempotent. - if hasTabBar { + if tabBarView != nil { tabBarDidAppear() } else { tabBarDidDisappear() @@ -243,31 +243,6 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") - func findTitlebarView() -> NSView? { - // Find our tab bar. If it doesn't exist we don't do anything. - // - // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. - // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; - // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. - // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 - guard let themeFrameView = contentView?.rootView else { return nil } - let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) { - themeFrameView.value(forKey: "titlebarView") as? NSView - } else { - NSView?.none - } - return titlebarView - } - - func findTabBar() -> NSView? { - findTitlebarView()?.firstDescendant(withClassName: "NSTabBar") - } - - /// Returns true if there is a tab bar visible on this window. - var hasTabBar: Bool { - findTabBar() != nil - } - var hasMoreThanOneTabs: Bool { /// accessing ``tabGroup?.windows`` here /// will cause other edge cases, be careful diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index b18fff291..918191522 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -85,7 +85,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool return } - guard let tabBarView = findTabBar() else { + guard let tabBarView else { super.sendEvent(event) return } @@ -176,8 +176,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool guard tabBarObserver == nil else { return } guard - let titlebarView = findTitlebarView(), - let tabBar = findTabBar() + let titlebarView, + let tabBarView = self.tabBarView else { return } // View model updates must happen on their own ticks. @@ -186,13 +186,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool } // Find our clip view - guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } guard let accessoryView = clipView.subviews[safe: 0] else { return } guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } // Make sure tabBar's height won't be stretched guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return } - tabBar.frame.size.height = newTabButton.frame.width + tabBarView.frame.size.height = newTabButton.frame.width // The container is the view that we'll constrain our tab bar within. let container = toolbarView @@ -228,10 +228,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // other events occur, the tab bar can resize and clear our constraints. 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 + tabBarView.postsFrameChangedNotifications = true tabBarObserver = NotificationCenter.default.addObserver( forName: NSView.frameDidChangeNotification, - object: tabBar, + object: tabBarView, queue: .main ) { [weak self] _ in guard let self else { return } diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index f8df803db..5d1831f26 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -10,12 +10,6 @@ extension NSWindow { return CGWindowID(windowNumber) } - /// True if this is the first window in the tab group. - var isFirstWindowInTabGroup: Bool { - guard let firstWindow = tabGroup?.windows.first else { return true } - return firstWindow === self - } - /// Adjusts the window frame if necessary to ensure the window remains visible on screen. /// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen). func constrainToScreen() { @@ -36,3 +30,53 @@ extension NSWindow { } } } + +// MARK: Native Tabbing + +extension NSWindow { + /// True if this is the first window in the tab group. + var isFirstWindowInTabGroup: Bool { + guard let firstWindow = tabGroup?.windows.first else { return true } + return firstWindow === self + } +} + +/// Native tabbing private API usage. :( +extension NSWindow { + var titlebarView: NSView? { + // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. + // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; + // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. + // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 + guard let themeFrameView = contentView?.rootView else { return nil } + guard themeFrameView.responds(to: Selector(("titlebarView"))) else { return nil } + return themeFrameView.value(forKey: "titlebarView") as? NSView + } + + /// Returns the [private] NSTabBar view, if it exists. + var tabBarView: NSView? { + titlebarView?.firstDescendant(withClassName: "NSTabBar") + } + + /// Returns the index of the tab button at the given screen point, if any. + func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + guard let tabBarView else { return nil } + let locationInWindow = convertPoint(fromScreen: screenPoint) + let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) + guard tabBarView.bounds.contains(locationInTabBar) else { return nil } + + // Find all tab buttons and sort by x position to get visual order. + // The view hierarchy order doesn't match the visual tab order. + let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton") + .sorted { $0.frame.origin.x < $1.frame.origin.x } + + for (index, tabItemView) in tabItemViews.enumerated() { + let locationInTab = tabItemView.convert(locationInWindow, from: nil) + if tabItemView.bounds.contains(locationInTab) { + return index + } + } + + return nil + } +}