mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-03 02:18:12 +00:00
macos: detect surface tab bar hovers and focus them
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user