diff --git a/macos/Assets.xcassets/ResetZoom.imageset/Contents.json b/macos/Assets.xcassets/ResetZoom.imageset/Contents.json deleted file mode 100644 index b5bca19ac..000000000 --- a/macos/Assets.xcassets/ResetZoom.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "ResetZoom.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" - } -} diff --git a/macos/Assets.xcassets/ResetZoom.imageset/ResetZoom.pdf b/macos/Assets.xcassets/ResetZoom.imageset/ResetZoom.pdf deleted file mode 100644 index fa36790fc..000000000 Binary files a/macos/Assets.xcassets/ResetZoom.imageset/ResetZoom.pdf and /dev/null differ diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 07e679771..5bb44f341 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,8 +54,8 @@ class TerminalController: NSWindowController, NSWindowDelegate, /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true - - init(_ ghostty: Ghostty.App, + + init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: Ghostty.SplitNode? = nil ) { @@ -120,22 +120,31 @@ class TerminalController: NSWindowController, NSWindowDelegate, func relabelTabs() { // Reset this to false. It'll be set back to true later. tabListenForFrame = false - - guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return } - + + guard let windows = self.window?.tabbedWindows else { return } + // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. tabListenForFrame = windows.count > 1 - + for (index, window) in windows.enumerated().prefix(9) { let action = "goto_tab:\(index + 1)" - - if let equiv = ghostty.config.keyEquivalent(for: action) { - window.keyEquivalent = "\(equiv)" + guard let equiv = ghostty.config.keyEquivalent(for: action) else { + continue } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.labelFont(ofSize: 0), + .foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ] + let attributedString = NSAttributedString(string: " \(equiv) ", attributes: attributes) + let text = NSTextField(labelWithAttributedString: attributedString) + text.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) + text.postsFrameChangedNotifications = true + window.tab.accessoryView = text } } - + private func fixTabBar() { // We do this to make sure that the tab bar will always re-composite. If we don't, // then the it will "drag" pieces of the background with it when a transparent @@ -191,7 +200,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, leaf.surface.focusDidChange(focused) } } - + //MARK: - NSWindowController override func windowWillLoad() { @@ -246,16 +255,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, // when cascading. window.center() - // Set the background color of the window. We only do this if the lum is - // over 0.1 to prevent: https://github.com/mitchellh/ghostty/issues/1549 - let bgColor = NSColor(ghostty.config.backgroundColor) - if (bgColor.luminance > 0.1) { - window.backgroundColor = bgColor - } - - // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarOpacity = ghostty.config.backgroundOpacity - // Handle titlebar tabs config option. Something about what we do while setting up the // titlebar tabs interferes with the window restore process unless window.tabbingMode // is set to .preferred, so we set it, and switch back to automatic as soon as we can. @@ -266,8 +265,20 @@ class TerminalController: NSWindowController, NSWindowDelegate, DispatchQueue.main.async { window.tabbingMode = .automatic } + + // Set the background color of the window + window.backgroundColor = NSColor(ghostty.config.backgroundColor) + + // Set a custom background on the titlebar - this is required for when + // titlebar tabs are used in conjunction with a transparent background. + 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, @@ -302,7 +313,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, guard let surface = self.focusedSurface?.surface else { return } ghostty.newTab(surface: surface) } - + //MARK: - NSWindowDelegate // This is called when performClose is called on a window (NOT when close() @@ -382,7 +393,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, } } } - + // Called when the window will be encoded. We handle the data encoding here in the // window controller. func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { @@ -583,12 +594,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, // we want to invalidate our state. invalidateRestorableState() } - - func zoomStateDidChange(to: Bool) { - guard let window = window as? TerminalWindow else { return } - window.surfaceIsZoomed = to - } - + //MARK: - Clipboard Confirmation func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift index 88a093d87..b0857cb24 100644 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -3,6 +3,7 @@ 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 = CenteredDynamicLabel(labelWithString: "👻 Ghostty") var titleText: String { @@ -14,61 +15,56 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { titleTextField.stringValue = newValue } } - + override init(identifier: NSToolbar.Identifier) { super.init(identifier: identifier) delegate = self if #available(macOS 13.0, *) { - centeredItemIdentifiers.insert(.titleText) + centeredItemIdentifiers.insert(Self.identifier) } else { - centeredItemIdentifier = .titleText + centeredItemIdentifier = Self.identifier } } func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - var item: NSToolbarItem - - switch itemIdentifier { - case .titleText: - item = NSToolbarItem(itemIdentifier: .titleText) - item.view = self.titleTextField - item.visibilityPriority = .user - - // NSToolbarItem.minSize and NSToolbarItem.maxSize are deprecated, and make big ugly - // warnings in Xcode when you use them, but I cannot for the life of me figure out - // how to get this to work with constraints. The behavior isn't the same, instead of - // shrinking the item and clipping the subview, it hides the item as soon as the - // intrinsic size of the subview gets too big for the toolbar width, regardless of - // whether I have constraints set on its width, height, or both :/ - // - // If someone can fix this so we don't have to use deprecated properties: Please do. - item.minSize = NSSize(width: 32, height: 1) - item.maxSize = NSSize(width: 1024, height: self.titleTextField.intrinsicContentSize.height) - - item.isEnabled = true - case .resetZoom: - item = NSToolbarItem(itemIdentifier: .resetZoom) - default: - item = NSToolbarItem(itemIdentifier: itemIdentifier) + guard itemIdentifier == Self.identifier else { + return NSToolbarItem(itemIdentifier: itemIdentifier) } - - return item + + let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier) + toolbarItem.view = self.titleTextField + toolbarItem.visibilityPriority = .user + + // NSToolbarItem.minSize and NSToolbarItem.maxSize are deprecated, and make big ugly + // warnings in Xcode when you use them, but I cannot for the life of me figure out + // how to get this to work with constraints. The behavior isn't the same, instead of + // shrinking the item and clipping the subview, it hides the item as soon as the + // intrinsic size of the subview gets too big for the toolbar width, regardless of + // whether I have constraints set on its width, height, or both :/ + // + // If someone can fix this so we don't have to use deprecated properties: Please do. + toolbarItem.minSize = NSSize(width: 32, height: 1) + toolbarItem.maxSize = NSSize(width: 1024, height: self.titleTextField.intrinsicContentSize.height) + + toolbarItem.isEnabled = true + + return toolbarItem } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [.titleText, .flexibleSpace, .space, .resetZoom] + return [Self.identifier, .space] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { // These space items are here to ensure that the title remains centered when it starts - // getting smaller than the max size so starts clipping. Lucky for us, two of the - // built-in spacers plus the un-zoom button item seems to exactly match the space - // on the left that's reserved for the window buttons. - return [.titleText, .flexibleSpace, .space, .space, .resetZoom] + // getting smaller than the max size so starts clipping. Lucky for us, three of the + // built-in spacers seems to exactly match the space on the left that's reserved for + // the window buttons. + return [Self.identifier, .space, .space, .space] } } @@ -87,8 +83,3 @@ fileprivate class CenteredDynamicLabel: NSTextField { needsLayout = true } } - -extension NSToolbarItem.Identifier { - static let resetZoom = NSToolbarItem.Identifier("ResetZoom") - static let titleText = NSToolbarItem.Identifier("TitleText") -} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 8e1f0dbdd..d0766c7ab 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -17,8 +17,6 @@ protocol TerminalViewDelegate: AnyObject { /// The surface tree did change in some way, i.e. a split was added, removed, etc. This is /// not called initially. func surfaceTreeDidChange() - - func zoomStateDidChange(to: Bool) } // Default all the functions so they're optional @@ -26,7 +24,6 @@ extension TerminalViewDelegate { func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} func titleDidChange(to: String) {} func cellSizeDidChange(to: NSSize) {} - func zoomStateDidChange(to: Bool) {} } /// The view model is a required implementation for TerminalView callers. This contains @@ -67,6 +64,12 @@ struct TerminalView: View { } } + if let zoomedSplit = zoomedSplit { + if zoomedSplit { + title = "🔍 " + title + } + } + return title } @@ -104,9 +107,6 @@ struct TerminalView: View { // in the hash value. self.delegate?.surfaceTreeDidChange() } - .onChange(of: zoomedSplit) { newValue in - self.delegate?.zoomStateDidChange(to: newValue ?? false) - } } } } diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index c8e194644..5fa06cfb0 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -1,116 +1,15 @@ import Cocoa class TerminalWindow: NSWindow { - @objc dynamic var surfaceIsZoomed: Bool = false - @objc dynamic var keyEquivalent: String = "" - - var titlebarOpacity: CGFloat = 1 { - didSet { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } - - titlebarContainer.wantsLayer = true - titlebarContainer.layer?.backgroundColor = backgroundColor.withAlphaComponent(titlebarOpacity).cgColor - } - } - - private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton() - - private lazy var resetZoomTabButton: NSButton = { - let button = generateResetZoomButton() - button.action = #selector(selectTabAndZoom(_:)) - return button - }() - - private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil } - - let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) - let view = NSView(frame: NSRect(origin: .zero, size: size)) - - let button = generateResetZoomButton() - button.frame.origin.x = size.width/2 - button.bounds.width/2 - button.frame.origin.y = size.height/2 - button.bounds.height/2 - view.addSubview(button) - - let titlebarAccessoryViewController = NSTitlebarAccessoryViewController() - titlebarAccessoryViewController.view = view - titlebarAccessoryViewController.layoutAttribute = .right - - return titlebarAccessoryViewController - }() - - private lazy var keyEquivalentLabel: NSTextField = { - let label = NSTextField(labelWithAttributedString: NSAttributedString()) - label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) - label.postsFrameChangedNotifications = true - - return label - }() - - private lazy var bindings = [ - observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in - guard let tabGroup = self?.tabGroup else { return } - - self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed - self?.updateResetZoomTitlebarButtonVisibility() - }, - - observe(\.keyEquivalent, options: [.initial, .new]) { [weak self] window, _ in - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), - .foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - let attributedString = NSAttributedString(string: " \(window.keyEquivalent) ", attributes: attributes) - - self?.keyEquivalentLabel.attributedStringValue = attributedString - }, - ] - // 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 } - - // MARK: - Lifecycle - - override func awakeFromNib() { - super.awakeFromNib() - - _ = bindings - - // By hiding the visual effect view, we allow the window's (or titlebar's in this case) - // background color to show through. If we were to set `titlebarAppearsTransparent` to true - // the selected tab would look fine, but the unselected ones and new tab button backgrounds - // would be an opaque color. When the titlebar isn't transparent, however, the system applies - // a compositing effect to the unselected tab backgrounds, which makes them blend with the - // titlebar's/window's background. - if let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }), let effectView = titlebarContainer.descendants(withClassName: "NSVisualEffectView").first { - effectView.isHidden = true - } - - // Create the tab accessory view that houses the key-equivalent label and optional un-zoom button - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) - stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 - tab.accessoryView = stackView - - if titlebarTabs { - generateToolbar() - } - } - - deinit { - bindings.forEach() { $0.invalidate() } - } - + // MARK: - NSWindow override func becomeKey() { - // This is required because the removeTitlebarAccessoryViewController hook does not + // 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() @@ -118,177 +17,32 @@ class TerminalWindow: NSWindow { super.becomeKey() - updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .controlAccentColor - resetZoomToolbarButton.contentTintColor = .controlAccentColor + if titlebarTabs { + updateNewTabButtonOpacity() + } } override func resignKey() { super.resignKey() - updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .secondaryLabelColor - resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor - } - - override func update() { - super.update() - - updateResetZoomTitlebarButtonVisibility() - - titlebarSeparatorStyle = tabbedWindows != nil && !titlebarTabs ? .line : .none - - // This is called when we open, close, switch, and reorder tabs, at which point we determine if the - // first tab in the tab bar is selected. If it is, we make the `windowButtonsBackdrop` color the same - // as that of the active tab (i.e. the titlebar's background color), otherwise we make it the same - // color as the background of unselected tabs. - if let index = windowController?.window?.tabbedWindows?.firstIndex(of: self), titlebarTabs { - windowButtonsBackdrop?.isHighlighted = index == 0 + if titlebarTabs { + updateNewTabButtonOpacity() } - - // Color the new tab button's image to match the color of the tab title/keyboard shortcut labels, - // just as it does in the stock tab bar. - updateNewTabButtonOpacity() - - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } - guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } - guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { - $0 as? NSImageView != nil - }) as? NSImageView else { return } - guard let newTabButtonImage = newTabButtonImageView.image else { return } - - let isLightTheme = backgroundColor.isLightColor - - if newTabButtonImageLayer == nil { - let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85) - let newImage = NSImage(size: newTabButtonImage.size, flipped: false) { rect in - newTabButtonImage.draw(in: rect) - fillColor.setFill() - rect.fill(using: .sourceAtop) - return true - } - let imageLayer = VibrantLayer(forAppearance: isLightTheme ? .light : .dark)! - imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size) - imageLayer.contentsGravity = .resizeAspect - imageLayer.contents = newImage - imageLayer.opacity = 0.5 - - newTabButtonImageLayer = imageLayer - } - - newTabButtonImageView.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer() - newTabButtonImageView.layer?.addSublayer(newTabButtonImageLayer!) - newTabButtonImageView.image = nil - // When we nil out the original image, the image view's frame resizes and repositions - // slightly, so we need to reset it to make sure our new image doesn't shift quickly. - newTabButtonImageView.frame = newTabButton.bounds } - // MARK: - - - private var newTabButtonImageLayer: VibrantLayer? = nil - - // Since we are coloring the new tab button's image, it doesn't respond to the - // window's key status changes in terms of becoming less prominent visually, - // so we need to do it manually. - private func updateNewTabButtonOpacity() { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } - guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } - guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { - $0 as? NSImageView != nil - }) as? NSImageView else { return } - - newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5 - } - - private func updateResetZoomTitlebarButtonVisibility() { - guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return } - - let isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed - - if titlebarTabs { - resetZoomToolbarButton.isHidden = isHidden - - for (index, vc) in titlebarAccessoryViewControllers.enumerated() { - guard vc == resetZoomTitlebarAccessoryViewController else { return } - removeTitlebarAccessoryViewController(at: index) - } - } else { - if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { - addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) - } - resetZoomTitlebarAccessoryViewController.view.isHidden = isHidden - } - } - - // We have to regenerate a toolbar when the titlebar tabs setting changes since our - // custom toolbar conditionally generates the items based on this setting. I tried to - // invalidate the toolbar items and force a refresh, but as far as I can tell that - // isn't possible. - private func generateToolbar() { - let terminalToolbar = TerminalToolbar(identifier: "Toolbar") - - toolbar = terminalToolbar - toolbarStyle = .unifiedCompact - if let resetZoomItem = terminalToolbar.items.first(where: { $0.itemIdentifier == .resetZoom }) { - resetZoomItem.view = resetZoomToolbarButton - resetZoomItem.view?.translatesAutoresizingMaskIntoConstraints = false - resetZoomItem.view?.widthAnchor.constraint(equalToConstant: 22).isActive = true - resetZoomItem.view?.heightAnchor.constraint(equalToConstant: 20).isActive = true - } - updateResetZoomTitlebarButtonVisibility() - } - - private func generateResetZoomButton() -> NSButton { - let button = NSButton() - button.target = nil - button.action = #selector(TerminalController.splitZoom(_:)) - button.isBordered = false - button.allowsExpansionToolTips = true - button.toolTip = "Reset Zoom" - button.contentTintColor = .controlAccentColor - button.state = .on - button.image = NSImage(named:"ResetZoom") - button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) - button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: 20).isActive = true - button.heightAnchor.constraint(equalToConstant: 20).isActive = true - - return button - } - - @objc private func selectTabAndZoom(_ sender: NSButton) { - guard let tabGroup else { return } - - guard let associatedWindow = tabGroup.windows.first(where: { - guard let accessoryView = $0.tab.accessoryView else { return false } - return accessoryView.subviews.contains(sender) - }), - let windowController = associatedWindow.windowController as? TerminalController - else { return } - - tabGroup.selectedWindow = associatedWindow - windowController.splitZoom(self) - } - // MARK: - Titlebar Tabs // Used by the window controller to enable/disable titlebar tabs. var titlebarTabs = false { didSet { - self.titleVisibility = titlebarTabs ? .hidden : .visible - if titlebarTabs { - generateToolbar() - } + changedTitlebarTabs(to: titlebarTabs) } } private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil private var windowDragHandle: WindowDragView? = nil + private var storedTitlebarBackgroundColor: CGColor? = nil + private var newTabButtonImageLayer: VibrantLayer? = nil // The tab bar controller ID from macOS static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController") @@ -312,6 +66,72 @@ class TerminalWindow: NSWindow { } } + /// This is called by titlebarTabs changing so that we can setup the rest of our window + private func changedTitlebarTabs(to newValue: Bool) { + if (newValue) { + // By hiding the visual effect view, we allow the window's (or titlebar's in this case) + // background color to show through. If we were to set `titlebarAppearsTransparent` to true + // the selected tab would look fine, but the unselected ones and new tab button backgrounds + // would be an opaque color. When the titlebar isn't transparent, however, the system applies + // a compositing effect to the unselected tab backgrounds, which makes them blend with the + // titlebar's/window's background. + if let titlebarContainer = contentView?.superview?.subviews.first(where: { + $0.className == "NSTitlebarContainerView" + }), let effectView = titlebarContainer.descendants(withClassName: "NSVisualEffectView").first { + effectView.isHidden = true + } + + self.titlebarSeparatorStyle = .none + + // 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") + } + + // Set a custom background on the titlebar - this is required for when + // titlebar tabs is used in conjunction with a transparent background. + self.restoreTitlebarBackground() + + // Reset the new tab button image so that we are sure to generate a fresh + // one, tinted appropriately for the given theme. + self.newTabButtonImageLayer = nil + + // We have to wait before setting the titleVisibility or else it prevents + // the window from hiding the tab bar when we get down to a single tab. + DispatchQueue.main.async { + self.titleVisibility = .hidden + } + } 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 + + // Reset the appearance to whatever our app global value is + self.appearance = nil + } + } + + // Assign a background color to the titlebar area. + func setTitlebarBackground(_ color: CGColor) { + storedTitlebarBackgroundColor = color + + guard let titlebarContainer = contentView?.superview?.subviews.first(where: { + $0.className == "NSTitlebarContainerView" + }) else { return } + + titlebarContainer.wantsLayer = true + titlebarContainer.layer?.backgroundColor = color + } + + // Make sure the titlebar has the assigned background color. + private func restoreTitlebarBackground() { + guard let color = storedTitlebarBackgroundColor else { return } + setTitlebarBackground(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) { @@ -394,6 +214,73 @@ class TerminalWindow: NSWindow { } } + override func update() { + super.update() + + guard titlebarTabs else { return } + + // This is called when we open, close, switch, and reorder tabs, at which point we determine if the + // first tab in the tab bar is selected. If it is, we make the `windowButtonsBackdrop` color the same + // as that of the active tab (i.e. the titlebar's background color), otherwise we make it the same + // color as the background of unselected tabs. + if let index = windowController?.window?.tabbedWindows?.firstIndex(of: self) { + windowButtonsBackdrop?.isHighlighted = index == 0 + } + + // Color the new tab button's image to match the color of the tab title/keyboard shortcut labels, + // just as it does in the stock tab bar. + updateNewTabButtonOpacity() + + guard let titlebarContainer = contentView?.superview?.subviews.first(where: { + $0.className == "NSTitlebarContainerView" + }) else { return } + guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } + guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { + $0 as? NSImageView != nil + }) as? NSImageView else { return } + guard let newTabButtonImage = newTabButtonImageView.image else { return } + guard let storedTitlebarBackgroundColor, let isLightTheme = NSColor(cgColor: storedTitlebarBackgroundColor)?.isLightColor else { return } + + if newTabButtonImageLayer == nil { + let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85) + let newImage = NSImage(size: newTabButtonImage.size, flipped: false) { rect in + newTabButtonImage.draw(in: rect) + fillColor.setFill() + rect.fill(using: .sourceAtop) + return true + } + let imageLayer = VibrantLayer(forAppearance: isLightTheme ? .light : .dark)! + imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size) + imageLayer.contentsGravity = .resizeAspect + imageLayer.contents = newImage + imageLayer.opacity = 0.5 + + newTabButtonImageLayer = imageLayer + } + + newTabButtonImageView.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer() + newTabButtonImageView.layer?.addSublayer(newTabButtonImageLayer!) + newTabButtonImageView.image = nil + // When we nil out the original image, the image view's frame resizes and repositions + // slightly, so we need to reset it to make sure our new image doesn't shift quickly. + newTabButtonImageView.frame = newTabButton.bounds + } + + // Since we are coloring the new tab button's image, it doesn't respond to the + // window's key status changes in terms of becoming less prominent visually, + // so we need to do it manually. + private func updateNewTabButtonOpacity() { + guard let titlebarContainer = contentView?.superview?.subviews.first(where: { + $0.className == "NSTitlebarContainerView" + }) else { return } + guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } + guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { + $0 as? NSImageView != nil + }) as? NSImageView else { return } + + newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5 + } + private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) { // If we already made the view, just make sure it's unhidden and correctly placed as a subview. if let view = windowButtonsBackdrop { @@ -407,9 +294,7 @@ class TerminalWindow: NSWindow { return } - let backdropColor = backgroundColor.withAlphaComponent(titlebarOpacity).usingColorSpace(colorSpace!)!.cgColor - - let view = WindowButtonsBackdropView(backgroundColor: backdropColor) + let view = WindowButtonsBackdropView(backgroundColor: storedTitlebarBackgroundColor ?? NSColor.windowBackgroundColor.cgColor) view.identifier = NSUserInterfaceItemIdentifier("_windowButtonsBackdrop") titlebarView.addSubview(view)