diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 3ddac967a..bee5ec691 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,31 +3,6 @@ import Cocoa import SwiftUI import GhosttyKit -fileprivate class ZoomButtonView: NSView { - let target: Any - let action: Selector - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - init(frame frameRect: NSRect, target: Any, selector: Selector) { - self.target = target - self.action = selector - - super.init(frame: frameRect) - - let zoomButton = NSButton(image: NSImage(systemSymbolName: "arrow.down.right.and.arrow.up.left.square.fill", accessibilityDescription: nil)!, target: target, action: selector) - - zoomButton.frame = bounds - zoomButton.isBordered = false - zoomButton.contentTintColor = .systemBlue - zoomButton.state = .on - zoomButton.imageScaling = .scaleProportionallyUpOrDown - addSubview(zoomButton) - } -} - /// The terminal controller is an NSWindowController that maps 1:1 to a terminal window. class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel, @@ -169,21 +144,15 @@ class TerminalController: NSWindowController, NSWindowDelegate, text.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) text.postsFrameChangedNotifications = true - let stackView = NSStackView(views: [text]) -// stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - - window.tab.accessoryView = stackView + window.tab.accessoryView = NSStackView(views: [text]) } if surfaceIsZoomed { - guard let stackView = window?.tabGroup?.selectedWindow?.tab.accessoryView as? NSStackView else { return } - - let zoomButton: ZoomButtonView = ZoomButtonView(frame: NSRect(x: 0, y: 0, width: 20, height: 20), target: self, selector: #selector(splitZoom(_:))) - - zoomButton.translatesAutoresizingMaskIntoConstraints = false - zoomButton.widthAnchor.constraint(equalToConstant: 20).isActive = true - zoomButton.heightAnchor.constraint(equalToConstant: 20).isActive = true - stackView.addArrangedSubview(zoomButton) + guard let stackView = window?.tabGroup?.selectedWindow?.tab.accessoryView as? NSStackView, + let buttonView = window?.toolbar?.items.first(where: { $0.itemIdentifier == .unZoom })?.view + else { return } + + stackView.addArrangedSubview(buttonView) } } @@ -243,14 +212,10 @@ class TerminalController: NSWindowController, NSWindowDelegate, } } - func windowDidUpdate(_ notification: Notification) { - updateToolbarZoomButton() - } + private func updateToolbarUnZoomButton() { + guard let buttonView = window?.toolbar?.items.first(where: { $0.itemIdentifier == .unZoom })?.view else { return } - private func updateToolbarZoomButton() { - guard let itemView = window?.toolbar?.items.last?.view as? ZoomButtonView else { return } - - itemView.isHidden = !surfaceIsZoomed + buttonView.isHidden = !surfaceIsZoomed } //MARK: - NSWindowController @@ -307,6 +272,12 @@ class TerminalController: NSWindowController, NSWindowDelegate, // when cascading. window.center() + // Set the background color of the window + window.backgroundColor = NSColor(ghostty.config.backgroundColor) + + // 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. @@ -317,19 +288,14 @@ 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 - ) } + + // Set a toolbar that is used with toolbar tabs + let toolbar = TerminalToolbar(identifier: "Toolbar") + toolbar.hasTitle = ghostty.config.macosTitlebarTabs + + window.toolbar = toolbar + window.toolbarStyle = .unifiedCompact // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( @@ -357,11 +323,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, window.tabGroup?.removeWindow(window) } } - - guard let toolbarZoomItem = window.toolbar?.items.last else { return } - var zoomButton: ZoomButtonView = ZoomButtonView(frame: NSRect(x: 0, y: 0, width: 20, height: 20), target: self, selector: #selector(splitZoom(_:))) - - toolbarZoomItem.view = zoomButton } // Shows the "+" button in the tab bar, responds to that click. @@ -370,7 +331,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() @@ -450,7 +411,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, } } } - + + func windowDidUpdate(_ notification: Notification) { + updateToolbarUnZoomButton() + } + // Called when the window will be encoded. We handle the data encoding here in the // window controller. func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { @@ -654,7 +619,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, func zoomStateDidChange(to: Bool) { self.surfaceIsZoomed = to - updateToolbarZoomButton() + updateToolbarUnZoomButton() relabelTabs() } diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift index 0bc389385..811026526 100644 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -1,13 +1,8 @@ import Cocoa -fileprivate extension NSToolbarItem.Identifier { - static let zoom = NSToolbarItem.Identifier("zoom") -} - // 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 { @@ -19,16 +14,18 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { titleTextField.stringValue = newValue } } - + + var hasTitle: Bool = false + override init(identifier: NSToolbar.Identifier) { super.init(identifier: identifier) delegate = self if #available(macOS 13.0, *) { - centeredItemIdentifiers.insert(Self.identifier) + centeredItemIdentifiers.insert(.titleText) } else { - centeredItemIdentifier = Self.identifier + centeredItemIdentifier = .titleText } } @@ -38,8 +35,8 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { var item: NSToolbarItem switch itemIdentifier { - case Self.identifier: - item = NSToolbarItem(itemIdentifier: itemIdentifier) + case .titleText: + item = NSToolbarItem(itemIdentifier: .titleText) item.view = self.titleTextField item.visibilityPriority = .user @@ -55,8 +52,24 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { item.maxSize = NSSize(width: 1024, height: self.titleTextField.intrinsicContentSize.height) item.isEnabled = true - case .zoom: - item = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier("zoom")) + case .unZoom: + item = NSToolbarItem(itemIdentifier: .unZoom) + + let view = NSView(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) + view.translatesAutoresizingMaskIntoConstraints = false + view.widthAnchor.constraint(equalToConstant: 20).isActive = true + view.heightAnchor.constraint(equalToConstant: 20).isActive = true + + let button = NSButton(image: NSImage(systemSymbolName: "arrow.down.right.and.arrow.up.left.square.fill", accessibilityDescription: nil)!, target: nil, action: #selector(TerminalController.splitZoom(_:))) + + button.frame = view.bounds + button.isBordered = false + button.contentTintColor = .systemBlue + button.state = .on + button.imageScaling = .scaleProportionallyUpOrDown + view.addSubview(button) + + item.view = view default: item = NSToolbarItem(itemIdentifier: itemIdentifier) } @@ -65,15 +78,19 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [Self.identifier, .space, .zoom] + return [.titleText, .flexibleSpace, .space, .unZoom] } 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, 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, .zoom] + // 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. + if hasTitle { + return [.titleText, .flexibleSpace, .space, .space, .unZoom] + } else { + return [.flexibleSpace, .unZoom] + } } } @@ -92,3 +109,8 @@ fileprivate class CenteredDynamicLabel: NSTextField { needsLayout = true } } + +extension NSToolbarItem.Identifier { + static let unZoom = NSToolbarItem.Identifier("UnZoom") + static let titleText = NSToolbarItem.Identifier("TitleText") +} diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 5fa06cfb0..db2a49b23 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -1,6 +1,17 @@ import Cocoa class TerminalWindow: NSWindow { + 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 + } + } + // 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 } @@ -35,13 +46,12 @@ class TerminalWindow: NSWindow { // Used by the window controller to enable/disable titlebar tabs. var titlebarTabs = false { didSet { - changedTitlebarTabs(to: titlebarTabs) + self.titleVisibility = titlebarTabs ? .hidden : .visible } } 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 @@ -66,72 +76,22 @@ 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 - } + override func awakeFromNib() { + super.awakeFromNib() - 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 + // 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 } } - - // 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) { @@ -217,13 +177,13 @@ class TerminalWindow: NSWindow { override func update() { super.update() - guard titlebarTabs else { return } + 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) { + if let index = windowController?.window?.tabbedWindows?.firstIndex(of: self), titlebarTabs { windowButtonsBackdrop?.isHighlighted = index == 0 } @@ -239,7 +199,8 @@ class TerminalWindow: NSWindow { $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 } + + let isLightTheme = backgroundColor.isLightColor if newTabButtonImageLayer == nil { let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85) @@ -294,7 +255,9 @@ class TerminalWindow: NSWindow { return } - let view = WindowButtonsBackdropView(backgroundColor: storedTitlebarBackgroundColor ?? NSColor.windowBackgroundColor.cgColor) + let backdropColor = backgroundColor.withAlphaComponent(titlebarOpacity).usingColorSpace(colorSpace!)!.cgColor + + let view = WindowButtonsBackdropView(backgroundColor: backdropColor) view.identifier = NSUserInterfaceItemIdentifier("_windowButtonsBackdrop") titlebarView.addSubview(view)