mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-17 01:06:08 +03:00
macOS: Added titlebar tabs
This commit is contained in:
@ -159,7 +159,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
}
|
||||
|
||||
override func windowDidLoad() {
|
||||
guard let window = window else { return }
|
||||
guard let window = window as? TerminalWindow else { return }
|
||||
|
||||
// Setting all three of these is required for restoration to work.
|
||||
window.isRestorable = restorable
|
||||
@ -204,7 +204,33 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
// Center the window to start, we'll move the window frame automatically
|
||||
// when cascading.
|
||||
window.center()
|
||||
|
||||
|
||||
// Set the background color of the window
|
||||
window.backgroundColor = NSColor(self.ghostty.config.backgroundColor)
|
||||
|
||||
// Handle titlebar tabs config option
|
||||
if (self.ghostty.config.macosTitlebarTabs) {
|
||||
window.titlebarTabs = true
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
||||
// We use the toolbar to anchor our tab bar positions in the titlebar,
|
||||
// so we make sure it's the right size/position, and exists.
|
||||
window.toolbarStyle = .unifiedCompact
|
||||
if (window.toolbar == nil) {
|
||||
window.toolbar = NSToolbar(identifier: "Toolbar")
|
||||
}
|
||||
} else {
|
||||
window.titlebarTabs = false
|
||||
window.titlebarAppearsTransparent = false
|
||||
|
||||
// "expanded" places the toolbar below the titlebar, so setting this style and
|
||||
// removing the toolbar ensures that the titlebar will be the default height.
|
||||
window.toolbarStyle = .expanded
|
||||
if (window.toolbar != nil) {
|
||||
window.toolbar = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize our content view to the SwiftUI root
|
||||
window.contentView = NSHostingView(rootView: TerminalView(
|
||||
ghostty: self.ghostty,
|
||||
@ -293,6 +319,10 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
self.relabelTabs()
|
||||
|
||||
// Fix for titlebar tabs, see comment on implementation of fixUntabbedWindow for details.
|
||||
guard let window = window as? TerminalWindow else { return }
|
||||
window.fixUntabbedWindow()
|
||||
}
|
||||
|
||||
// Called when the window will be encoded. We handle the data encoding here in the
|
||||
|
@ -1,8 +1,189 @@
|
||||
import Cocoa
|
||||
|
||||
// Passes mouseDown events from this view to window.performDrag so that you can drag the window by it.
|
||||
class WindowDragView: NSView {
|
||||
override public func mouseDown(with event: NSEvent) {
|
||||
// Drag the window for single left clicks, double clicks should bypass the drag handle.
|
||||
if (event.type == .leftMouseDown && event.clickCount == 1) {
|
||||
window?.performDrag(with: event)
|
||||
NSCursor.closedHand.set()
|
||||
} else {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
override public func mouseEntered(with event: NSEvent) {
|
||||
super.mouseEntered(with: event)
|
||||
window?.disableCursorRects()
|
||||
NSCursor.openHand.set()
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
super.mouseExited(with: event)
|
||||
window?.enableCursorRects()
|
||||
NSCursor.arrow.set()
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
addCursorRect(bounds, cursor: .openHand)
|
||||
}
|
||||
}
|
||||
|
||||
let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController")
|
||||
|
||||
class TerminalWindow: NSWindow {
|
||||
// Both of these must be true for windows without decorations to be able to
|
||||
// still become key/main and receive events.
|
||||
override var canBecomeKey: Bool { return true }
|
||||
override var canBecomeMain: Bool { return true }
|
||||
|
||||
// Used by the window controller to enable/disable titlebar tabs.
|
||||
public var titlebarTabs = false
|
||||
|
||||
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
|
||||
var isTabBar = false
|
||||
if (self.titlebarTabs && (
|
||||
childViewController.layoutAttribute == .bottom ||
|
||||
childViewController.identifier == TabBarController)
|
||||
) {
|
||||
// Ensure it has the right layoutAttribute
|
||||
childViewController.layoutAttribute = .right
|
||||
// Hide the title text if the tab bar is showing.
|
||||
titleVisibility = .hidden
|
||||
// Mark the controller for future reference (it gets re-used sometimes)
|
||||
childViewController.identifier = TabBarController
|
||||
isTabBar = true
|
||||
}
|
||||
super.addTitlebarAccessoryViewController(childViewController)
|
||||
if (isTabBar) {
|
||||
pushTabsToTitlebar(childViewController)
|
||||
}
|
||||
}
|
||||
|
||||
override func removeTitlebarAccessoryViewController(at index: Int) {
|
||||
let childViewController = titlebarAccessoryViewControllers[index]
|
||||
super.removeTitlebarAccessoryViewController(at: index)
|
||||
if (childViewController.layoutAttribute == .right) {
|
||||
hideCustomTabBarViews()
|
||||
}
|
||||
}
|
||||
|
||||
// This is a hack - provide a function for the window controller to call in windowDidBecomeKey
|
||||
// to check if it's no longer tabbed and fix its appearing if so. This is required because the
|
||||
// removeTitlebarAccessoryViewControlle hook does not catch the creation of a new window by
|
||||
// "tearing off" a tab from a tabbed window.
|
||||
public func fixUntabbedWindow() {
|
||||
if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 {
|
||||
hideCustomTabBarViews()
|
||||
}
|
||||
}
|
||||
|
||||
private var windowButtonsBackdrop: NSView? = nil
|
||||
|
||||
private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) {
|
||||
guard windowButtonsBackdrop == nil else { return }
|
||||
|
||||
windowButtonsBackdrop = NSView()
|
||||
|
||||
guard let windowButtonsBackdrop = windowButtonsBackdrop else { return }
|
||||
|
||||
windowButtonsBackdrop.identifier = NSUserInterfaceItemIdentifier("_windowButtonsBackdrop")
|
||||
titlebarView.addSubview(windowButtonsBackdrop)
|
||||
windowButtonsBackdrop.translatesAutoresizingMaskIntoConstraints = false
|
||||
windowButtonsBackdrop.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
|
||||
windowButtonsBackdrop.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 80).isActive = true
|
||||
windowButtonsBackdrop.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
|
||||
windowButtonsBackdrop.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true
|
||||
windowButtonsBackdrop.wantsLayer = true
|
||||
windowButtonsBackdrop.layer?.backgroundColor = CGColor(genericGrayGamma2_2Gray: 0.0, alpha: 0.45)
|
||||
|
||||
let topBorder = NSView()
|
||||
windowButtonsBackdrop.addSubview(topBorder)
|
||||
topBorder.translatesAutoresizingMaskIntoConstraints = false
|
||||
topBorder.leftAnchor.constraint(equalTo: windowButtonsBackdrop.leftAnchor).isActive = true
|
||||
topBorder.rightAnchor.constraint(equalTo: windowButtonsBackdrop.rightAnchor).isActive = true
|
||||
topBorder.topAnchor.constraint(equalTo: windowButtonsBackdrop.topAnchor).isActive = true
|
||||
topBorder.bottomAnchor.constraint(equalTo: windowButtonsBackdrop.topAnchor, constant: 1).isActive = true
|
||||
topBorder.wantsLayer = true
|
||||
topBorder.layer?.backgroundColor = CGColor(genericGrayGamma2_2Gray: 0.0, alpha: 0.85)
|
||||
}
|
||||
|
||||
var windowDragHandle: WindowDragView? = nil
|
||||
|
||||
private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) {
|
||||
guard windowDragHandle == nil else { return }
|
||||
|
||||
windowDragHandle = WindowDragView()
|
||||
|
||||
guard let windowDragHandle = windowDragHandle else { return }
|
||||
|
||||
windowDragHandle.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle")
|
||||
titlebarView.superview?.addSubview(windowDragHandle)
|
||||
windowDragHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||
windowDragHandle.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
|
||||
windowDragHandle.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true
|
||||
windowDragHandle.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
|
||||
windowDragHandle.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true
|
||||
}
|
||||
|
||||
// To be called immediately after the tab bar is disabled.
|
||||
public func hideCustomTabBarViews() {
|
||||
// Hide the window buttons backdrop.
|
||||
windowButtonsBackdrop?.isHidden = true
|
||||
// Hide the window drag handle.
|
||||
windowDragHandle?.isHidden = true
|
||||
// Enable the window title text.
|
||||
titleVisibility = .visible
|
||||
}
|
||||
|
||||
private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) {
|
||||
let accessoryView = tabBarController.view
|
||||
guard let accessoryClipView = accessoryView.superview else { return }
|
||||
guard let titlebarView = accessoryClipView.superview else { return }
|
||||
|
||||
guard titlebarView.className == "NSTitlebarView" else { return }
|
||||
|
||||
guard let toolbarView = titlebarView.subviews.first(where: {
|
||||
$0.className == "NSToolbarView"
|
||||
}) else { return }
|
||||
|
||||
addWindowButtonsBackdrop(titlebarView: titlebarView, toolbarView: toolbarView)
|
||||
windowButtonsBackdrop?.isHidden = false
|
||||
guard let windowButtonsBackdrop = windowButtonsBackdrop else { return }
|
||||
|
||||
addWindowDragHandle(titlebarView: titlebarView, toolbarView: toolbarView)
|
||||
windowDragHandle?.isHidden = false
|
||||
|
||||
accessoryClipView.translatesAutoresizingMaskIntoConstraints = false
|
||||
accessoryClipView.leftAnchor.constraint(equalTo: windowButtonsBackdrop.rightAnchor).isActive = true
|
||||
accessoryClipView.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true
|
||||
accessoryClipView.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
|
||||
accessoryClipView.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true
|
||||
accessoryClipView.needsLayout = true
|
||||
|
||||
accessoryView.translatesAutoresizingMaskIntoConstraints = false
|
||||
accessoryView.leftAnchor.constraint(equalTo: accessoryClipView.leftAnchor).isActive = true
|
||||
accessoryView.rightAnchor.constraint(equalTo: accessoryClipView.rightAnchor).isActive = true
|
||||
accessoryView.topAnchor.constraint(equalTo: accessoryClipView.topAnchor).isActive = true
|
||||
accessoryView.heightAnchor.constraint(equalTo: accessoryClipView.heightAnchor).isActive = true
|
||||
accessoryView.needsLayout = true
|
||||
|
||||
// This is a horrible hack. During the transition while things are resizing to make room for
|
||||
// new tabs or expand existing tabs to fill the empty space after one is closed, the centering
|
||||
// of the tab titles can't be properly calculated, so we wait for 0.2 seconds and then mark
|
||||
// the entire view hierarchy for the tab bar as dirty to fix the positioning...
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
self.markHierarchyForLayout(accessoryView)
|
||||
}
|
||||
}
|
||||
|
||||
private func markHierarchyForLayout(_ view: NSView) {
|
||||
view.needsUpdateConstraints = true
|
||||
view.needsLayout = true
|
||||
view.needsDisplay = true
|
||||
view.setNeedsDisplay(view.bounds)
|
||||
for subview in view.subviews {
|
||||
markHierarchyForLayout(subview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -197,6 +197,14 @@ extension Ghostty {
|
||||
_ = ghostty_config_get(config, &v, key, UInt(key.count))
|
||||
return v
|
||||
}
|
||||
|
||||
var macosTitlebarTabs: Bool {
|
||||
guard let config = self.config else { return false }
|
||||
var v = false;
|
||||
let key = "macos-titlebar-tabs"
|
||||
_ = ghostty_config_get(config, &v, key, UInt(key.count))
|
||||
return v
|
||||
}
|
||||
|
||||
var backgroundColor: Color {
|
||||
var rgb: UInt32 = 0
|
||||
|
@ -891,6 +891,13 @@ keybind: Keybinds = .{},
|
||||
/// * `false` - Use native macOS fullscreeen
|
||||
@"macos-non-native-fullscreen": NonNativeFullscreen = .false,
|
||||
|
||||
/// If `true`, places the tab bar in the titlebar for tabbed windows.
|
||||
///
|
||||
/// This option intercepts the native tab bar view from macOS and forces it to use
|
||||
/// different positioning. Because of this, it might be buggy or break entirely if
|
||||
/// macOS changes the way its native tab bar view is constructed or managed.
|
||||
@"macos-titlebar-tabs": bool = false,
|
||||
|
||||
/// If `true`, the *Option* key will be treated as *Alt*. This makes terminal
|
||||
/// sequences expecting *Alt* to work properly, but will break Unicode input
|
||||
/// sequences on macOS if you use them via the *Alt* key. You may set this to
|
||||
|
Reference in New Issue
Block a user