From ba16d65d021d220b87fff39bed3b9209c9ea46f0 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 30 Jan 2024 18:48:36 -0500 Subject: [PATCH] macOS: Added titlebar tabs --- .../Terminal/TerminalController.swift | 34 +++- .../Features/Terminal/TerminalWindow.swift | 181 ++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 8 + src/config/Config.zig | 7 + 4 files changed, 228 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 2a3e76fb7..84911e13b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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 diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 9a74a7e2b..af7956d4d 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -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) + } + } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 8b1146e14..77c08722c 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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 diff --git a/src/config/Config.zig b/src/config/Config.zig index b0f618ba3..6ee1bffa8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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