diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 8c8e327d4..eb8c236e7 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; /* 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 */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 2a3e76fb7..37d6a516b 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 @@ -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) { diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift new file mode 100644 index 000000000..5b4760642 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -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] + } +} diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 9a74a7e2b..13ffa8be9 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -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) + } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 8b1146e14..c26b525eb 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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..? = 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, diff --git a/src/config/Config.zig b/src/config/Config.zig index b0f618ba3..fc9fee414 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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