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/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 21416ac75..77127583c 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -214,6 +214,9 @@ extension Ghostty { movedTo screenPoint: NSPoint ) { NSCursor.closedHand.set() + + // Handle hovering over a tab bar. + detectTabBarHover(at: screenPoint) } func draggingSession( @@ -226,6 +229,8 @@ extension Ghostty { self.escapeMonitor = nil } + hoveredTabState = nil + if operation == [] && !dragCancelledByEscape { let endsInWindow = NSApplication.shared.windows.contains { window in window.isVisible && window.frame.contains(screenPoint) @@ -242,6 +247,79 @@ extension Ghostty { isTracking = false onDragStateChanged?(false) } + + // MARK: Hovered Native Tabs + + /// The currently hovered tab, tracked to detect when hover changes. + private var hoveredTabState: HoveredTabState? + + /// This detects if the drag hover is over a native tab bar in our app. If it is then + /// we start a timer to focus that tab if the drag remains over it. + private func detectTabBarHover(at screenPoint: NSPoint) { + for window in NSApplication.shared.windows { + if let index = window.tabIndex(atScreenPoint: screenPoint) { + let state: HoveredTabState = .init(window: window, index: index) + guard hoveredTabState != state else { + // We already are tracking this. + return + } + + // Stop our prior state since we've changed tabs. + hoveredTabState = nil + + // Grab our window and ensure that it isn't the key window. If + // it is already key then the tab is already focused so we don't + // need to do it again. + guard let targetWindow = window.tabbedWindows?[safe: index] else { return } + guard !targetWindow.isKeyWindow else { return } + + // Start our timer to focus it and store our state. + state.startTimer() + hoveredTabState = state + return + } + } + + hoveredTabState = nil + } + + fileprivate class HoveredTabState: Equatable { + let window: NSWindow + let index: Int + var focusTimer: Timer? + + /// Duration to hover over a tab before it becomes focused. + private static let hoverDelay: TimeInterval = 0.5 + + init(window: NSWindow, index: Int) { + self.window = window + self.index = index + } + + deinit { + focusTimer?.invalidate() + } + + func startTimer() { + focusTimer?.invalidate() + focusTimer = Timer.scheduledTimer( + withTimeInterval: Self.hoverDelay, + repeats: false) { [weak self] _ in + self?.focus() + } + } + + private func focus() { + guard let targetWindow = window.tabbedWindows?[safe: index] else { return } + targetWindow.makeKeyAndOrderFront(nil) + } + + static func == (lhs: HoveredTabState, rhs: HoveredTabState) -> Bool { + return + lhs.window == rhs.window && + lhs.index == rhs.index + } + } } } 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 + } +}