macos: detect surface tab bar hovers and focus them

This commit is contained in:
Mitchell Hashimoto
2025-12-30 14:12:15 -08:00
parent f32d54bedb
commit 3e399a3d35
4 changed files with 136 additions and 39 deletions

View File

@@ -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

View File

@@ -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 }

View File

@@ -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
}
}
}
}

View File

@@ -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
}
}