Merge pull request #1418 from qwerasd205/macos-titlebar-tabs

macOS: Added titlebar tabs
This commit is contained in:
Mitchell Hashimoto
2024-01-31 15:45:05 -08:00
committed by GitHub
6 changed files with 354 additions and 17 deletions

View File

@ -65,6 +65,7 @@
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; };
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
/* End PBXBuildFile section */
@ -125,6 +126,7 @@
A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = "<group>"; };
A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -281,6 +283,7 @@
A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */,
A596309D2AEE1D6C00D64628 /* TerminalView.swift */,
A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */,
AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */,
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
);
path = Terminal;
@ -493,6 +496,7 @@
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */,
8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */,
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */,
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */,
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */,

View File

@ -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
@ -205,6 +205,18 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// when cascading.
window.center()
// Set the background color of the window
window.backgroundColor = NSColor(ghostty.config.backgroundColor)
// Handle titlebar tabs config option
window.titlebarTabs = ghostty.config.macosTitlebarTabs
window.setTitlebarBackground(
window
.backgroundColor
.withAlphaComponent(ghostty.config.backgroundOpacity)
.cgColor
)
// Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView(
ghostty: self.ghostty,
@ -470,7 +482,15 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
func titleDidChange(to: String) {
self.window?.title = to
guard let window = window as? TerminalWindow else { return }
// Set the main window title
window.title = to
// Custom toolbar-based title used when titlebar tabs are enabled.
if let toolbar = window.toolbar as? TerminalToolbar {
toolbar.titleText = to
}
}
func cellSizeDidChange(to: NSSize) {

View File

@ -0,0 +1,49 @@
import Cocoa
// Custom NSToolbar subclass that displays a centered window title,
// in order to accommodate the titlebar tabs feature.
class TerminalToolbar: NSToolbar, NSToolbarDelegate {
static private let identifier = NSToolbarItem.Identifier("TitleText")
private let titleTextField = NSTextField(labelWithString: "👻 Ghostty")
var titleText: String {
get {
titleTextField.stringValue
}
set {
titleTextField.stringValue = newValue
}
}
override init(identifier: NSToolbar.Identifier) {
super.init(identifier: identifier)
delegate = self
if #available(macOS 13.0, *) {
centeredItemIdentifiers.insert(Self.identifier)
} else {
centeredItemIdentifier = Self.identifier
}
}
func toolbar(_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
guard itemIdentifier == Self.identifier else { return nil }
let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier)
toolbarItem.isEnabled = true
toolbarItem.view = self.titleTextField
return toolbarItem
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [Self.identifier]
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [Self.identifier]
}
}

View File

@ -5,4 +5,243 @@ class TerminalWindow: NSWindow {
// still become key/main and receive events.
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
// MARK: - NSWindow
override func becomeKey() {
// 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.
if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 {
hideCustomTabBarViews()
}
super.becomeKey()
}
// MARK: - Titlebar Tabs
// Used by the window controller to enable/disable titlebar tabs.
var titlebarTabs = false {
didSet {
changedTitlebarTabs(to: titlebarTabs)
}
}
private var windowButtonsBackdrop: NSView? = nil
private var windowDragHandle: WindowDragView? = nil
// The tab bar controller ID from macOS
static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController")
/// This is called by titlebarTabs changing so that we can setup the rest of our window
private func changedTitlebarTabs(to newValue: Bool) {
self.titlebarAppearsTransparent = newValue
if (newValue) {
// 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.
self.toolbarStyle = .unifiedCompact
if (self.toolbar == nil) {
self.toolbar = TerminalToolbar(identifier: "Toolbar")
}
// We directly hide the view containing the title text because if we use the
// `titleVisibility` property for this it prevents the window from hiding the
// tab bar when we get down to a single tab.
if let toolbarTitleView = contentView?.superview?.subviews.first(where: {
$0.className == "NSTitlebarContainerView"
})?.subviews.first(where: {
$0.className == "NSTitlebarView"
})?.subviews.first(where: {
$0.className == "NSToolbarView"
})?.subviews.first(where: {
$0.className == "NSToolbarTitleView"
}) {
toolbarTitleView.isHidden = true
}
} else {
// "expanded" places the toolbar below the titlebar, so setting this style and
// removing the toolbar ensures that the titlebar will be the default height.
self.toolbarStyle = .expanded
self.toolbar = nil
}
}
// Assign a background color to the titlebar area.
func setTitlebarBackground(_ color: CGColor) {
guard let titlebarContainer = contentView?.superview?.subviews.first(where: {
$0.className == "NSTitlebarContainerView"
}) else { return }
titlebarContainer.wantsLayer = true
titlebarContainer.layer?.backgroundColor = color
}
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
// this, detect the tab bar being added, and override its behavior.
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
let isTabBar = self.titlebarTabs && (
childViewController.layoutAttribute == .bottom ||
childViewController.identifier == Self.TabBarController
)
if (isTabBar) {
// Ensure it has the right layoutAttribute to force it next to our titlebar
childViewController.layoutAttribute = .right
// If we don't set titleVisibility to hidden here, the toolbar will display a
// "collapsed items" indicator which interferes with the tab bar.
titleVisibility = .hidden
// Mark the controller for future reference so we can easily find it. Otherwise
// the tab bar has no ID by default.
childViewController.identifier = Self.TabBarController
}
super.addTitlebarAccessoryViewController(childViewController)
if (isTabBar) {
pushTabsToTitlebar(childViewController)
}
}
override func removeTitlebarAccessoryViewController(at index: Int) {
let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController
super.removeTitlebarAccessoryViewController(at: index)
if (isTabBar) {
hideCustomTabBarViews()
}
}
// To be called immediately after the tab bar is disabled.
private func hideCustomTabBarViews() {
// Hide the window buttons backdrop.
windowButtonsBackdrop?.isHidden = true
// Hide the window drag handle.
windowDragHandle?.isHidden = true
}
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)
guard let windowButtonsBackdrop = windowButtonsBackdrop else { return }
windowButtonsBackdrop.isHidden = false
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 addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) {
guard windowButtonsBackdrop == nil else { return }
let view = NSView()
view.identifier = NSUserInterfaceItemIdentifier("_windowButtonsBackdrop")
titlebarView.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 80).isActive = true
view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
view.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true
view.wantsLayer = true
view.layer?.backgroundColor = CGColor(genericGrayGamma2_2Gray: 0.0, alpha: 0.45)
let topBorder = NSView()
view.addSubview(topBorder)
topBorder.translatesAutoresizingMaskIntoConstraints = false
topBorder.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
topBorder.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
topBorder.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
topBorder.bottomAnchor.constraint(equalTo: view.topAnchor, constant: 1).isActive = true
topBorder.wantsLayer = true
topBorder.layer?.backgroundColor = CGColor(genericGrayGamma2_2Gray: 0.0, alpha: 0.85)
windowButtonsBackdrop = view
}
private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) {
guard windowDragHandle == nil else { return }
let view = WindowDragView()
view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle")
titlebarView.superview?.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
view.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true
view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
view.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true
windowDragHandle = view
}
// This forces this view and all subviews to update layout and redraw. This is
// a hack (see the caller).
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)
}
}
}
// Passes mouseDown events from this view to window.performDrag so that you can drag the window by it.
fileprivate 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)
}
}

View File

@ -21,7 +21,7 @@ extension Ghostty {
/// Return the errors found while loading the configuration.
var errors: [String] {
guard let cfg = self.config else { return [] }
var errors: [String] = [];
let errCount = ghostty_config_errors_count(cfg)
for i in 0..<errCount {
@ -29,7 +29,7 @@ extension Ghostty {
let message = String(cString: err.message)
errors.append(message)
}
return errors
}
@ -155,7 +155,7 @@ extension Ghostty {
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowNewTabPosition: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
@ -164,7 +164,7 @@ extension Ghostty {
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowDecorations: Bool {
guard let config = self.config else { return true }
var v = false;
@ -198,30 +198,38 @@ extension Ghostty {
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
let bg_key = "background"
if (!ghostty_config_get(config, &rgb, bg_key, UInt(bg_key.count))) {
#if os(macOS)
#if os(macOS)
return Color(NSColor.windowBackgroundColor)
#elseif os(iOS)
#elseif os(iOS)
return Color(UIColor.systemBackground)
#else
#error("unsupported")
#endif
#else
#error("unsupported")
#endif
}
let red = Double(rgb & 0xff)
let green = Double((rgb >> 8) & 0xff)
let blue = Double((rgb >> 16) & 0xff)
return Color(
red: red / 255,
green: green / 255,
blue: blue / 255
)
}
var backgroundOpacity: Double {
guard let config = self.config else { return 1 }
var v: Double = 1
@ -247,11 +255,11 @@ extension Ghostty {
let bg_key = "background"
_ = ghostty_config_get(config, &rgb, bg_key, UInt(bg_key.count));
}
let red = Double(rgb & 0xff)
let green = Double((rgb >> 8) & 0xff)
let blue = Double((rgb >> 16) & 0xff)
return Color(
red: red / 255,
green: green / 255,

View File

@ -882,15 +882,32 @@ keybind: Keybinds = .{},
/// using a new space. It's faster than the native fullscreen mode since it
/// doesn't use animations.
///
/// Warning: tabs do not work with a non-native fullscreen window. This
/// can be fixed but is looking for contributors to help. See issue #392.
///
/// Allowable values are:
///
/// * `visible-menu` - Use non-native macOS fullscreen, keep the menu bar visible
///
/// * `true` - Use non-native macOS fullscreen, hide the menu bar
///
/// * `false` - Use native macOS fullscreeen
///
@"macos-non-native-fullscreen": NonNativeFullscreen = .false,
/// If `true`, places the tab bar in the titlebar for tabbed windows.
///
/// When this is true, the titlebar will also always appear even when
/// fullscreen (native fullscreen) with only one tab. This is not considered
/// a bug but if you'd like to improve this behavior then I'm open to it and
/// please contribute to the project.
///
/// 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.
/// This has been tested on macOS 14.
///
/// This option only applies to new windows when changed.
@"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