mirror-ghostty/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift

845 lines
33 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import AppKit
import SwiftUI
import GhosttyKit
/// The base class for all standalone, "normal" terminal windows. This sets the basic
/// style and configuration of the window based on the app configuration.
class TerminalWindow: NSWindow {
/// Posted when a terminal window awakes from nib.
static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
/// Posted when a terminal window will close
static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose")
/// This is the key in UserDefaults to use for the default `level` value. This is
/// used by the manual float on top menu item feature.
static let defaultLevelKey: String = "TerminalDefaultLevel"
/// The view model for SwiftUI views
private var viewModel = ViewModel()
/// Reset split zoom button in titlebar
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
/// Update notification UI in titlebar
private let updateAccessory = NSTitlebarAccessoryViewController()
/// Visual indicator that mirrors the selected tab color.
private lazy var tabColorIndicator: NSHostingView<TabColorIndicatorView> = {
let view = NSHostingView(rootView: TabColorIndicatorView(tabColor: tabColor))
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig = .init()
/// Sets up our tab context menu
private var tabMenuObserver: NSObjectProtocol? = nil
/// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool {
// Native window supports it.
true
}
/// Glass effect view for liquid glass background when transparency is enabled
private var glassEffectView: NSView?
/// Gets the terminal controller from the window controller.
var terminalController: TerminalController? {
windowController as? TerminalController
}
/// The color assigned to this window's tab. Setting this updates the tab color indicator
/// and marks the window's restorable state as dirty.
var tabColor: TerminalTabColor = .none {
didSet {
guard tabColor != oldValue else { return }
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
invalidateRestorableState()
}
}
// MARK: NSWindow Overrides
override var toolbar: NSToolbar? {
didSet {
DispatchQueue.main.async {
// When we have a toolbar, our SwiftUI view needs to know for layout
self.viewModel.hasToolbar = self.toolbar != nil
}
}
}
override func awakeFromNib() {
// Notify that this terminal window has loaded
NotificationCenter.default.post(name: Self.terminalDidAwake, object: self)
// This is fragile, but there doesn't seem to be an official API for customizing
// native tab bar menus.
tabMenuObserver = NotificationCenter.default.addObserver(
forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"),
object: nil,
queue: .main
) { [weak self] n in
guard let self, let menu = n.object as? NSMenu else { return }
self.configureTabContextMenuIfNeeded(menu)
}
// This is required so that window restoration properly creates our tabs
// again. I'm not sure why this is required. If you don't do this, then
// tabs restore as separate windows.
tabbingMode = .preferred
DispatchQueue.main.async {
self.tabbingMode = .automatic
}
// All new windows are based on the app config at the time of creation.
guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
let config = appDelegate.ghostty.config
// Setup our initial config
derivedConfig = .init(config)
// If there is a hardcoded title in the configuration, we set that
// immediately. Future `set_title` apprt actions will override this
// if necessary but this ensures our window loads with the proper
// title immediately rather than on another event loop tick (see #5934)
if let title = derivedConfig.title {
self.title = title
}
// If window decorations are disabled, remove our title
if (!config.windowDecorations) { styleMask.remove(.titled) }
// Set our window positioning to coordinates if config value exists, otherwise
// fallback to original centering behavior
setInitialWindowPosition(
x: config.windowPositionX,
y: config.windowPositionY)
// If our traffic buttons should be hidden, then hide them
if config.macosWindowButtons == .hidden {
hideWindowButtons()
}
// Create our reset zoom titlebar accessory. We have to have a title
// to do this or AppKit triggers an assertion.
if styleMask.contains(.titled) {
resetZoomAccessory.layoutAttribute = .right
resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView(
viewModel: viewModel,
action: { [weak self] in
guard let self else { return }
self.terminalController?.splitZoom(self)
}))
addTitlebarAccessoryViewController(resetZoomAccessory)
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
// Create update notification accessory
if supportsUpdateAccessory {
updateAccessory.layoutAttribute = .right
updateAccessory.view = NonDraggableHostingView(rootView: UpdateAccessoryView(
viewModel: viewModel,
model: appDelegate.updateViewModel
))
addTitlebarAccessoryViewController(updateAccessory)
updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
}
}
// Setup the accessory view for tabs that shows our keyboard shortcuts,
// zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
// where buttons were not clickable.
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
let stackView = NSStackView()
stackView.orientation = .horizontal
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
stackView.spacing = 4
stackView.alignment = .centerY
stackView.addArrangedSubview(tabColorIndicator)
stackView.addArrangedSubview(keyEquivalentLabel)
stackView.addArrangedSubview(resetZoomTabButton)
tab.accessoryView = stackView
// Get our saved level
level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal
}
// 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 }
override func close() {
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
super.close()
}
override func becomeKey() {
super.becomeKey()
resetZoomTabButton.contentTintColor = .controlAccentColor
}
override func resignKey() {
super.resignKey()
resetZoomTabButton.contentTintColor = .secondaryLabelColor
}
override func becomeMain() {
super.becomeMain()
// Its possible we miss the accessory titlebar call so we check again
// whenever the window becomes main. Both of these are idempotent.
if hasTabBar {
tabBarDidAppear()
} else {
tabBarDidDisappear()
}
viewModel.isMainWindow = true
}
override func resignMain() {
super.resignMain()
viewModel.isMainWindow = false
}
override func mergeAllWindows(_ sender: Any?) {
super.mergeAllWindows(sender)
// It takes an event loop cycle to merge all the windows so we set a
// short timer to relabel the tabs (issue #1902)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.terminalController?.relabelTabs()
}
}
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
super.addTitlebarAccessoryViewController(childViewController)
// Tab bar is attached as a titlebar accessory view controller (layout bottom). We
// can detect when it is shown or hidden by overriding add/remove and searching for
// it. This has been verified to work on macOS 12 to 26
if isTabBar(childViewController) {
childViewController.identifier = Self.tabBarIdentifier
tabBarDidAppear()
}
}
override func removeTitlebarAccessoryViewController(at index: Int) {
if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) {
tabBarDidDisappear()
}
super.removeTitlebarAccessoryViewController(at: index)
}
// MARK: Tab Bar
/// This identifier is attached to the tab bar view controller when we detect it being
/// added.
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
func findTitlebarView() -> NSView? {
// Find our tab bar. If it doesn't exist we don't do anything.
//
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
guard let themeFrameView = contentView?.rootView else { return nil }
let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) {
themeFrameView.value(forKey: "titlebarView") as? NSView
} else {
NSView?.none
}
return titlebarView
}
func findTabBar() -> NSView? {
findTitlebarView()?.firstDescendant(withClassName: "NSTabBar")
}
/// Returns true if there is a tab bar visible on this window.
var hasTabBar: Bool {
findTabBar() != nil
}
var hasMoreThanOneTabs: Bool {
/// accessing ``tabGroup?.windows`` here
/// will cause other edge cases, be careful
(tabbedWindows?.count ?? 0) > 1
}
func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
if childViewController.identifier == nil {
// The good case
if childViewController.view.contains(className: "NSTabBar") {
return true
}
// When a new window is attached to an existing tab group, AppKit adds
// an empty NSView as an accessory view and adds the tab bar later. If
// we're at the bottom and are a single NSView we assume its a tab bar.
if childViewController.layoutAttribute == .bottom &&
childViewController.view.className == "NSView" &&
childViewController.view.subviews.isEmpty {
return true
}
return false
}
// View controllers should be tagged with this as soon as possible to
// increase our accuracy. We do this manually.
return childViewController.identifier == Self.tabBarIdentifier
}
private func tabBarDidAppear() {
// Remove our reset zoom accessory. For some reason having a SwiftUI
// titlebar accessory causes our content view scaling to be wrong.
// Removing it fixes it, we just need to remember to add it again later.
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
removeTitlebarAccessoryViewController(at: idx)
}
// We don't need to do this with the update accessory. I don't know why but
// everything works fine.
}
private func tabBarDidDisappear() {
if styleMask.contains(.titled) {
if titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) == nil {
addTitlebarAccessoryViewController(resetZoomAccessory)
}
}
}
// MARK: Tab Key Equivalents
var keyEquivalent: String? = nil {
didSet {
// When our key equivalent is set, we must update the tab label.
guard let keyEquivalent else {
keyEquivalentLabel.attributedStringValue = NSAttributedString()
return
}
keyEquivalentLabel.attributedStringValue = NSAttributedString(
string: "\(keyEquivalent) ",
attributes: [
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
])
}
}
/// The label that has the key equivalent for tab views.
private lazy var keyEquivalentLabel: NSTextField = {
let label = NSTextField(labelWithAttributedString: NSAttributedString())
label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
label.postsFrameChangedNotifications = true
return label
}()
// MARK: Surface Zoom
/// Set to true if a surface is currently zoomed to show the reset zoom button.
var surfaceIsZoomed: Bool = false {
didSet {
// Show/hide our reset zoom button depending on if we're zoomed.
// We want to show it if we are zoomed.
resetZoomTabButton.isHidden = !surfaceIsZoomed
DispatchQueue.main.async {
self.viewModel.isSurfaceZoomed = self.surfaceIsZoomed
}
}
}
private lazy var resetZoomTabButton: NSButton = generateResetZoomButton()
private func generateResetZoomButton() -> NSButton {
let button = NSButton()
button.isHidden = true
button.target = terminalController
button.action = #selector(TerminalController.splitZoom(_:))
button.isBordered = false
button.allowsExpansionToolTips = true
button.toolTip = "Reset Zoom"
button.contentTintColor = isMainWindow ? .controlAccentColor : .secondaryLabelColor
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
}
// MARK: Title Text
override var title: String {
didSet {
// Whenever we change the window title we must also update our
// tab title if we're using custom fonts.
tab.attributedTitle = attributedTitle
/// We also needs to update this here, just in case
/// the value is not what we want
///
/// Check ``titlebarFont`` down below
/// to see why we need to check `hasMoreThanOneTabs` here
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
}
}
// Used to set the titlebar font.
var titlebarFont: NSFont? {
didSet {
let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
titlebarTextField?.font = font
/// We check `hasMoreThanOneTabs` here because the system
/// may copy this setting to the tabs text field at some point(e.g. entering/exiting fullscreen),
/// which can cause the title to be vertically misaligned (shifted downward).
///
/// This behaviour is the opposite of what happens in the title bars text field, which is quite odd...
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
tab.attributedTitle = attributedTitle
}
}
// Find the NSTextField responsible for displaying the titlebar's title.
private var titlebarTextField: NSTextField? {
titlebarContainer?
.firstDescendant(withClassName: "NSTitlebarView")?
.firstDescendant(withClassName: "NSTextField") as? NSTextField
}
// Return a styled representation of our title property.
var attributedTitle: NSAttributedString? {
guard let titlebarFont = titlebarFont else { return nil }
let attributes: [NSAttributedString.Key: Any] = [
.font: titlebarFont,
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
]
return NSAttributedString(string: title, attributes: attributes)
}
var titlebarContainer: NSView? {
// If we aren't fullscreen then the titlebar container is part of our window.
if !styleMask.contains(.fullScreen) {
return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
}
// If we are fullscreen, the titlebar container view is part of a separate
// "fullscreen window", we need to find the window and then get the view.
for window in NSApplication.shared.windows {
// This is the private window class that contains the toolbar
guard window.className == "NSToolbarFullScreenWindow" else { continue }
// The parent will match our window. This is used to filter the correct
// fullscreen window if we have multiple.
guard window.parent == self else { continue }
return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
}
return nil
}
// MARK: Positioning And Styling
/// This is called by the controller when there is a need to reset the window appearance.
func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// If our window is not visible, then we do nothing. Some things such as blurring
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard isVisible else { return }
defer { updateColorSchemeForSurfaceTree() }
// Basic properties
appearance = surfaceConfig.windowAppearance
hasShadow = surfaceConfig.macosWindowShadow
// Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through.
//
// Also check if the user has overridden transparency to be fully opaque.
let forceOpaque = terminalController?.isBackgroundOpaque ?? false
if !styleMask.contains(.fullScreen) &&
!forceOpaque &&
surfaceConfig.backgroundOpacity < 1
{
isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
// matches Terminal.app much more closer. This lets users transition from
// Terminal.app more easily.
backgroundColor = .white.withAlphaComponent(0.001)
// Add liquid glass behind terminal content
if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle {
setupGlassLayer()
} else if let appDelegate = NSApp.delegate as? AppDelegate {
// If we had a prior glass layer we should remove it
if #available(macOS 26.0, *) {
removeGlassLayer()
}
ghostty_set_window_background_blur(
appDelegate.ghostty.app,
Unmanaged.passUnretained(self).toOpaque())
}
} else {
isOpaque = true
// Remove liquid glass when not transparent
if #available(macOS 26.0, *) {
removeGlassLayer()
}
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
self.backgroundColor = backgroundColor.withAlphaComponent(1)
}
}
/// The preferred window background color. The current window background color may not be set
/// to this, since this is dynamic based on the state of the surface tree.
///
/// This background color will include alpha transparency if set. If the caller doesn't want that,
/// change the alpha channel again manually.
var preferredBackgroundColor: NSColor? {
if let terminalController, !terminalController.surfaceTree.isEmpty {
let surface: Ghostty.SurfaceView?
// If our focused surface borders the top then we prefer its background color
if let focusedSurface = terminalController.focusedSurface,
let treeRoot = terminalController.surfaceTree.root,
let focusedNode = treeRoot.node(view: focusedSurface),
treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
surface = focusedSurface
} else {
// If it doesn't border the top, we use the top-left leaf
surface = terminalController.surfaceTree.root?.leftmostLeaf()
}
if let surface {
let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor
let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
return NSColor(backgroundColor).withAlphaComponent(alpha)
}
}
let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
}
func updateColorSchemeForSurfaceTree() {
terminalController?.updateColorSchemeForSurfaceTree()
}
private func setInitialWindowPosition(x: Int16?, y: Int16?) {
// If we don't have an X/Y then we try to use the previously saved window pos.
guard x != nil, y != nil else {
if (!LastWindowPosition.shared.restore(self)) {
center()
}
return
}
// Prefer the screen our window is being placed on otherwise our primary screen.
guard let screen = screen ?? NSScreen.screens.first else {
center()
return
}
// We have an X/Y, use our controller function to set it up.
guard let terminalController else {
center()
return
}
let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen)
setFrameOrigin(frame.origin)
}
private func hideWindowButtons() {
standardWindowButton(.closeButton)?.isHidden = true
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
}
deinit {
if let observer = tabMenuObserver {
NotificationCenter.default.removeObserver(observer)
}
}
#if compiler(>=6.2)
// MARK: Glass
@available(macOS 26.0, *)
private func setupGlassLayer() {
// Remove existing glass effect view
removeGlassLayer()
// Get the window content view (parent of the NSHostingView)
guard let contentView else { return }
guard let windowContentView = contentView.superview else { return }
// Create NSGlassEffectView for native glass effect
let effectView = NSGlassEffectView()
// Map Ghostty config to NSGlassEffectView style
switch derivedConfig.backgroundBlur {
case .macosGlassRegular:
effectView.style = NSGlassEffectView.Style.regular
case .macosGlassClear:
effectView.style = NSGlassEffectView.Style.clear
default:
// Should not reach here since we check for glass style before calling
// setupGlassLayer()
assertionFailure()
}
effectView.cornerRadius = derivedConfig.windowCornerRadius
effectView.tintColor = preferredBackgroundColor
effectView.frame = windowContentView.bounds
effectView.autoresizingMask = [.width, .height]
// Position BELOW the terminal content to act as background
windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView)
glassEffectView = effectView
}
@available(macOS 26.0, *)
private func removeGlassLayer() {
glassEffectView?.removeFromSuperview()
glassEffectView = nil
}
#endif // compiler(>=6.2)
// MARK: Config
struct DerivedConfig {
let title: String?
let backgroundBlur: Ghostty.Config.BackgroundBlur
let backgroundColor: NSColor
let backgroundOpacity: Double
let macosWindowButtons: Ghostty.MacOSWindowButtons
let macosTitlebarStyle: String
let windowCornerRadius: CGFloat
init() {
self.title = nil
self.backgroundColor = NSColor.windowBackgroundColor
self.backgroundOpacity = 1
self.macosWindowButtons = .visible
self.backgroundBlur = .disabled
self.macosTitlebarStyle = "transparent"
self.windowCornerRadius = 16
}
init(_ config: Ghostty.Config) {
self.title = config.title
self.backgroundColor = NSColor(config.backgroundColor)
self.backgroundOpacity = config.backgroundOpacity
self.macosWindowButtons = config.macosWindowButtons
self.backgroundBlur = config.backgroundBlur
self.macosTitlebarStyle = config.macosTitlebarStyle
// Set corner radius based on macos-titlebar-style
// Native, transparent, and hidden styles use 16pt radius
// Tabs style uses 20pt radius
switch config.macosTitlebarStyle {
case "tabs":
self.windowCornerRadius = 20
default:
self.windowCornerRadius = 16
}
}
}
}
// MARK: SwiftUI View
extension TerminalWindow {
class ViewModel: ObservableObject {
@Published var isSurfaceZoomed: Bool = false
@Published var hasToolbar: Bool = false
@Published var isMainWindow: Bool = true
/// Calculates the top padding based on toolbar visibility and macOS version
fileprivate var accessoryTopPadding: CGFloat {
if #available(macOS 26.0, *) {
return hasToolbar ? 10 : 5
} else {
return hasToolbar ? 9 : 4
}
}
}
struct ResetZoomAccessoryView: View {
@ObservedObject var viewModel: ViewModel
let action: () -> Void
var body: some View {
if viewModel.isSurfaceZoomed {
VStack {
Button(action: action) {
Image("ResetZoom")
.foregroundColor(viewModel.isMainWindow ? .accentColor : .secondary)
}
.buttonStyle(.plain)
.help("Reset Split Zoom")
.frame(width: 20, height: 20)
Spacer()
}
// With a toolbar, the window title is taller, so we need more padding
// to properly align.
.padding(.top, viewModel.accessoryTopPadding)
// We always need space at the end of the titlebar
.padding(.trailing, 10)
}
}
}
/// A pill-shaped button that displays update status and provides access to update actions.
struct UpdateAccessoryView: View {
@ObservedObject var viewModel: ViewModel
@ObservedObject var model: UpdateViewModel
var body: some View {
// We use the same top/trailing padding so that it hugs the same.
UpdatePill(model: model)
.padding(.top, viewModel.accessoryTopPadding)
.padding(.trailing, viewModel.accessoryTopPadding)
}
}
}
/// A small circle indicator displayed in the tab accessory view that shows
/// the user-assigned tab color. When no color is set, the view is hidden.
private struct TabColorIndicatorView: View {
/// The tab color to display.
let tabColor: TerminalTabColor
var body: some View {
if let color = tabColor.displayColor {
Circle()
.fill(Color(color))
.frame(width: 6, height: 6)
} else {
Circle()
.fill(Color.clear)
.frame(width: 6, height: 6)
.hidden()
}
}
}
// MARK: - Tab Context Menu
extension TerminalWindow {
private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem")
private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem")
private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator")
private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette")
func configureTabContextMenuIfNeeded(_ menu: NSMenu) {
guard isTabContextMenu(menu) else { return }
// Get the target from an existing menu item. The native tab context menu items
// target the specific window/controller that was right-clicked, not the focused one.
// We need to use that same target so validation and action use the correct tab.
let targetController = menu.items
.first { $0.action == NSSelectorFromString("performClose:") }
.flatMap { $0.target as? NSWindow }
.flatMap { $0.windowController as? TerminalController }
// Close tabs to the right
let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "")
item.identifier = Self.closeTabsOnRightMenuItemIdentifier
item.target = targetController
item.setImageIfDesired(systemSymbolName: "xmark")
if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil,
menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil {
menu.addItem(item)
}
// Other close items should have the xmark to match Safari on macOS 26
for menuItem in menu.items {
if menuItem.action == NSSelectorFromString("performClose:") ||
menuItem.action == NSSelectorFromString("performCloseOtherTabs:") {
menuItem.setImageIfDesired(systemSymbolName: "xmark")
}
}
appendTabModifierSection(to: menu, target: targetController)
}
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
guard NSApp.keyWindow === self else { return false }
// These selectors must all exist for it to be a tab context menu.
let requiredSelectors: Set<String> = [
"performClose:",
"performCloseOtherTabs:",
"moveTabToNewWindow:",
"toggleTabOverview:"
]
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
return requiredSelectors.isSubset(of: selectorNames)
}
private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) {
menu.removeItems(withIdentifiers: [
Self.tabColorSeparatorIdentifier,
Self.changeTitleMenuItemIdentifier,
Self.tabColorPaletteIdentifier
])
let separator = NSMenuItem.separator()
separator.identifier = Self.tabColorSeparatorIdentifier
menu.addItem(separator)
// Change Title...
let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier
changeTitleItem.target = target
changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line")
menu.addItem(changeTitleItem)
let paletteItem = NSMenuItem()
paletteItem.identifier = Self.tabColorPaletteIdentifier
paletteItem.view = makeTabColorPaletteView(
selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none
) { [weak target] color in
(target?.window as? TerminalWindow)?.tabColor = color
}
menu.addItem(paletteItem)
}
}
private func makeTabColorPaletteView(
selectedColor: TerminalTabColor,
selectionHandler: @escaping (TerminalTabColor) -> Void
) -> NSView {
let hostingView = NSHostingView(rootView: TabColorMenuView(
selectedColor: selectedColor,
onSelect: selectionHandler
))
hostingView.frame.size = hostingView.intrinsicContentSize
return hostingView
}