Get unzoom button working with standard title/tab bar

To do this I forced a toolbar, so that we would have a place to put the
button when no tabs were opened. I also took the opportunity to make the
standard title/tab bar meld better with the terminal's background color,
just as we do with titlebar tabs.
This commit is contained in:
Pete Schaffner
2024-02-23 15:08:35 +01:00
parent 021daeedb2
commit dc60afc261
3 changed files with 99 additions and 149 deletions

View File

@ -3,31 +3,6 @@ import Cocoa
import SwiftUI import SwiftUI
import GhosttyKit 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. /// The terminal controller is an NSWindowController that maps 1:1 to a terminal window.
class TerminalController: NSWindowController, NSWindowDelegate, class TerminalController: NSWindowController, NSWindowDelegate,
TerminalViewDelegate, TerminalViewModel, TerminalViewDelegate, TerminalViewModel,
@ -169,21 +144,15 @@ class TerminalController: NSWindowController, NSWindowDelegate,
text.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) text.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
text.postsFrameChangedNotifications = true text.postsFrameChangedNotifications = true
let stackView = NSStackView(views: [text]) window.tab.accessoryView = NSStackView(views: [text])
// stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
window.tab.accessoryView = stackView
} }
if surfaceIsZoomed { if surfaceIsZoomed {
guard let stackView = window?.tabGroup?.selectedWindow?.tab.accessoryView as? NSStackView else { return } guard let stackView = window?.tabGroup?.selectedWindow?.tab.accessoryView as? NSStackView,
let buttonView = window?.toolbar?.items.first(where: { $0.itemIdentifier == .unZoom })?.view
let zoomButton: ZoomButtonView = ZoomButtonView(frame: NSRect(x: 0, y: 0, width: 20, height: 20), target: self, selector: #selector(splitZoom(_:))) else { return }
zoomButton.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(buttonView)
zoomButton.widthAnchor.constraint(equalToConstant: 20).isActive = true
zoomButton.heightAnchor.constraint(equalToConstant: 20).isActive = true
stackView.addArrangedSubview(zoomButton)
} }
} }
@ -243,14 +212,10 @@ class TerminalController: NSWindowController, NSWindowDelegate,
} }
} }
func windowDidUpdate(_ notification: Notification) { private func updateToolbarUnZoomButton() {
updateToolbarZoomButton() guard let buttonView = window?.toolbar?.items.first(where: { $0.itemIdentifier == .unZoom })?.view else { return }
}
private func updateToolbarZoomButton() { buttonView.isHidden = !surfaceIsZoomed
guard let itemView = window?.toolbar?.items.last?.view as? ZoomButtonView else { return }
itemView.isHidden = !surfaceIsZoomed
} }
//MARK: - NSWindowController //MARK: - NSWindowController
@ -307,6 +272,12 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// when cascading. // when cascading.
window.center() 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 // 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 // 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. // 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 { DispatchQueue.main.async {
window.tabbingMode = .automatic 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 // Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView( window.contentView = NSHostingView(rootView: TerminalView(
@ -357,11 +323,6 @@ class TerminalController: NSWindowController, NSWindowDelegate,
window.tabGroup?.removeWindow(window) 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. // 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 } guard let surface = self.focusedSurface?.surface else { return }
ghostty.newTab(surface: surface) ghostty.newTab(surface: surface)
} }
//MARK: - NSWindowDelegate //MARK: - NSWindowDelegate
// This is called when performClose is called on a window (NOT when close() // 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 // Called when the window will be encoded. We handle the data encoding here in the
// window controller. // window controller.
func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
@ -654,7 +619,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
func zoomStateDidChange(to: Bool) { func zoomStateDidChange(to: Bool) {
self.surfaceIsZoomed = to self.surfaceIsZoomed = to
updateToolbarZoomButton() updateToolbarUnZoomButton()
relabelTabs() relabelTabs()
} }

View File

@ -1,13 +1,8 @@
import Cocoa import Cocoa
fileprivate extension NSToolbarItem.Identifier {
static let zoom = NSToolbarItem.Identifier("zoom")
}
// Custom NSToolbar subclass that displays a centered window title, // Custom NSToolbar subclass that displays a centered window title,
// in order to accommodate the titlebar tabs feature. // in order to accommodate the titlebar tabs feature.
class TerminalToolbar: NSToolbar, NSToolbarDelegate { class TerminalToolbar: NSToolbar, NSToolbarDelegate {
static private let identifier = NSToolbarItem.Identifier("TitleText")
private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty")
var titleText: String { var titleText: String {
@ -19,16 +14,18 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
titleTextField.stringValue = newValue titleTextField.stringValue = newValue
} }
} }
var hasTitle: Bool = false
override init(identifier: NSToolbar.Identifier) { override init(identifier: NSToolbar.Identifier) {
super.init(identifier: identifier) super.init(identifier: identifier)
delegate = self delegate = self
if #available(macOS 13.0, *) { if #available(macOS 13.0, *) {
centeredItemIdentifiers.insert(Self.identifier) centeredItemIdentifiers.insert(.titleText)
} else { } else {
centeredItemIdentifier = Self.identifier centeredItemIdentifier = .titleText
} }
} }
@ -38,8 +35,8 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
var item: NSToolbarItem var item: NSToolbarItem
switch itemIdentifier { switch itemIdentifier {
case Self.identifier: case .titleText:
item = NSToolbarItem(itemIdentifier: itemIdentifier) item = NSToolbarItem(itemIdentifier: .titleText)
item.view = self.titleTextField item.view = self.titleTextField
item.visibilityPriority = .user item.visibilityPriority = .user
@ -55,8 +52,24 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
item.maxSize = NSSize(width: 1024, height: self.titleTextField.intrinsicContentSize.height) item.maxSize = NSSize(width: 1024, height: self.titleTextField.intrinsicContentSize.height)
item.isEnabled = true item.isEnabled = true
case .zoom: case .unZoom:
item = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier("zoom")) 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: default:
item = NSToolbarItem(itemIdentifier: itemIdentifier) item = NSToolbarItem(itemIdentifier: itemIdentifier)
} }
@ -65,15 +78,19 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
} }
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [Self.identifier, .space, .zoom] return [.titleText, .flexibleSpace, .space, .unZoom]
} }
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
// These space items are here to ensure that the title remains centered when it starts // 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 // getting smaller than the max size so starts clipping. Lucky for us, two of the
// built-in spacers seems to exactly match the space on the left that's reserved for // built-in spacers plus the un-zoom button item seems to exactly match the space
// the window buttons. // on the left that's reserved for the window buttons.
return [Self.identifier, .space, .space, .zoom] if hasTitle {
return [.titleText, .flexibleSpace, .space, .space, .unZoom]
} else {
return [.flexibleSpace, .unZoom]
}
} }
} }
@ -92,3 +109,8 @@ fileprivate class CenteredDynamicLabel: NSTextField {
needsLayout = true needsLayout = true
} }
} }
extension NSToolbarItem.Identifier {
static let unZoom = NSToolbarItem.Identifier("UnZoom")
static let titleText = NSToolbarItem.Identifier("TitleText")
}

View File

@ -1,6 +1,17 @@
import Cocoa import Cocoa
class TerminalWindow: NSWindow { 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 // Both of these must be true for windows without decorations to be able to
// still become key/main and receive events. // still become key/main and receive events.
override var canBecomeKey: Bool { return true } override var canBecomeKey: Bool { return true }
@ -35,13 +46,12 @@ class TerminalWindow: NSWindow {
// Used by the window controller to enable/disable titlebar tabs. // Used by the window controller to enable/disable titlebar tabs.
var titlebarTabs = false { var titlebarTabs = false {
didSet { didSet {
changedTitlebarTabs(to: titlebarTabs) self.titleVisibility = titlebarTabs ? .hidden : .visible
} }
} }
private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil
private var windowDragHandle: WindowDragView? = nil private var windowDragHandle: WindowDragView? = nil
private var storedTitlebarBackgroundColor: CGColor? = nil
private var newTabButtonImageLayer: VibrantLayer? = nil private var newTabButtonImageLayer: VibrantLayer? = nil
// The tab bar controller ID from macOS // 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 override func awakeFromNib() {
private func changedTitlebarTabs(to newValue: Bool) { super.awakeFromNib()
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 // 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
// We use the toolbar to anchor our tab bar positions in the titlebar, // the selected tab would look fine, but the unselected ones and new tab button backgrounds
// so we make sure it's the right size/position, and exists. // would be an opaque color. When the titlebar isn't transparent, however, the system applies
self.toolbarStyle = .unifiedCompact // a compositing effect to the unselected tab backgrounds, which makes them blend with the
if (self.toolbar == nil) { // titlebar's/window's background.
self.toolbar = TerminalToolbar(identifier: "Toolbar") if let titlebarContainer = contentView?.superview?.subviews.first(where: {
} $0.className == "NSTitlebarContainerView"
}), let effectView = titlebarContainer.descendants(withClassName: "NSVisualEffectView").first {
// Set a custom background on the titlebar - this is required for when effectView.isHidden = true
// 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 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. // this, detect the tab bar being added, and override its behavior.
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
@ -217,13 +177,13 @@ class TerminalWindow: NSWindow {
override func update() { override func update() {
super.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 // 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 // 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 // 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. // 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 windowButtonsBackdrop?.isHighlighted = index == 0
} }
@ -239,7 +199,8 @@ class TerminalWindow: NSWindow {
$0 as? NSImageView != nil $0 as? NSImageView != nil
}) as? NSImageView else { return } }) as? NSImageView else { return }
guard let newTabButtonImage = newTabButtonImageView.image 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 { if newTabButtonImageLayer == nil {
let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85) let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85)
@ -294,7 +255,9 @@ class TerminalWindow: NSWindow {
return 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") view.identifier = NSUserInterfaceItemIdentifier("_windowButtonsBackdrop")
titlebarView.addSubview(view) titlebarView.addSubview(view)