feat(macos): add tab color picker to tab context menu (#9784)
<img width="1824" height="1488" alt="image" src="https://github.com/user-attachments/assets/4a77f743-9eae-40bf-8cb4-d45d884a85a5" />pull/9876/head
commit
d77b7c32f9
|
|
@ -115,6 +115,7 @@
|
||||||
Features/Terminal/ErrorView.swift,
|
Features/Terminal/ErrorView.swift,
|
||||||
Features/Terminal/TerminalController.swift,
|
Features/Terminal/TerminalController.swift,
|
||||||
Features/Terminal/TerminalRestorable.swift,
|
Features/Terminal/TerminalRestorable.swift,
|
||||||
|
Features/Terminal/TerminalTabColor.swift,
|
||||||
Features/Terminal/TerminalView.swift,
|
Features/Terminal/TerminalView.swift,
|
||||||
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
|
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
|
||||||
"Features/Terminal/Window Styles/Terminal.xib",
|
"Features/Terminal/Window Styles/Terminal.xib",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||||
private(set) var derivedConfig: DerivedConfig
|
private(set) var derivedConfig: DerivedConfig
|
||||||
|
|
||||||
|
|
||||||
/// The notification cancellable for focused surface property changes.
|
/// The notification cancellable for focused surface property changes.
|
||||||
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
|
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
|
|
@ -852,6 +853,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
let focusedSurface: UUID?
|
let focusedSurface: UUID?
|
||||||
let tabIndex: Int?
|
let tabIndex: Int?
|
||||||
weak var tabGroup: NSWindowTabGroup?
|
weak var tabGroup: NSWindowTabGroup?
|
||||||
|
let tabColor: TerminalTabColor
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(_ ghostty: Ghostty.App,
|
convenience init(_ ghostty: Ghostty.App,
|
||||||
|
|
@ -863,6 +865,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
showWindow(nil)
|
showWindow(nil)
|
||||||
if let window {
|
if let window {
|
||||||
window.setFrame(undoState.frame, display: true)
|
window.setFrame(undoState.frame, display: true)
|
||||||
|
if let terminalWindow = window as? TerminalWindow {
|
||||||
|
terminalWindow.tabColor = undoState.tabColor
|
||||||
|
}
|
||||||
|
|
||||||
// If we have a tab group and index, restore the tab to its original position
|
// If we have a tab group and index, restore the tab to its original position
|
||||||
if let tabGroup = undoState.tabGroup,
|
if let tabGroup = undoState.tabGroup,
|
||||||
|
|
@ -898,7 +903,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
surfaceTree: surfaceTree,
|
surfaceTree: surfaceTree,
|
||||||
focusedSurface: focusedSurface?.id,
|
focusedSurface: focusedSurface?.id,
|
||||||
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
|
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
|
||||||
tabGroup: window.tabGroup)
|
tabGroup: window.tabGroup,
|
||||||
|
tabColor: (window as? TerminalWindow)?.tabColor ?? .none)
|
||||||
}
|
}
|
||||||
|
|
||||||
//MARK: - NSWindowController
|
//MARK: - NSWindowController
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,18 @@ import Cocoa
|
||||||
class TerminalRestorableState: Codable {
|
class TerminalRestorableState: Codable {
|
||||||
static let selfKey = "state"
|
static let selfKey = "state"
|
||||||
static let versionKey = "version"
|
static let versionKey = "version"
|
||||||
static let version: Int = 5
|
static let version: Int = 6
|
||||||
|
|
||||||
let focusedSurface: String?
|
let focusedSurface: String?
|
||||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||||
let effectiveFullscreenMode: FullscreenMode?
|
let effectiveFullscreenMode: FullscreenMode?
|
||||||
|
let tabColor: TerminalTabColor
|
||||||
|
|
||||||
init(from controller: TerminalController) {
|
init(from controller: TerminalController) {
|
||||||
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||||
self.surfaceTree = controller.surfaceTree
|
self.surfaceTree = controller.surfaceTree
|
||||||
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
|
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
|
||||||
|
self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(coder aDecoder: NSCoder) {
|
init?(coder aDecoder: NSCoder) {
|
||||||
|
|
@ -31,6 +33,7 @@ class TerminalRestorableState: Codable {
|
||||||
self.surfaceTree = v.value.surfaceTree
|
self.surfaceTree = v.value.surfaceTree
|
||||||
self.focusedSurface = v.value.focusedSurface
|
self.focusedSurface = v.value.focusedSurface
|
||||||
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
|
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
|
||||||
|
self.tabColor = v.value.tabColor
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(with coder: NSCoder) {
|
func encode(with coder: NSCoder) {
|
||||||
|
|
@ -94,6 +97,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore our tab color
|
||||||
|
(window as? TerminalWindow)?.tabColor = state.tabColor
|
||||||
|
|
||||||
// Setup our restored state on the controller
|
// Setup our restored state on the controller
|
||||||
// Find the focused surface in surfaceTree
|
// Find the focused surface in surfaceTree
|
||||||
if let focusedStr = state.focusedSurface {
|
if let focusedStr = state.focusedSurface {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum TerminalTabColor: Int, CaseIterable, Codable {
|
||||||
|
case none
|
||||||
|
case blue
|
||||||
|
case purple
|
||||||
|
case pink
|
||||||
|
case red
|
||||||
|
case orange
|
||||||
|
case yellow
|
||||||
|
case green
|
||||||
|
case teal
|
||||||
|
case graphite
|
||||||
|
|
||||||
|
var localizedName: String {
|
||||||
|
switch self {
|
||||||
|
case .none:
|
||||||
|
return "None"
|
||||||
|
case .blue:
|
||||||
|
return "Blue"
|
||||||
|
case .purple:
|
||||||
|
return "Purple"
|
||||||
|
case .pink:
|
||||||
|
return "Pink"
|
||||||
|
case .red:
|
||||||
|
return "Red"
|
||||||
|
case .orange:
|
||||||
|
return "Orange"
|
||||||
|
case .yellow:
|
||||||
|
return "Yellow"
|
||||||
|
case .green:
|
||||||
|
return "Green"
|
||||||
|
case .teal:
|
||||||
|
return "Teal"
|
||||||
|
case .graphite:
|
||||||
|
return "Graphite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayColor: NSColor? {
|
||||||
|
switch self {
|
||||||
|
case .none:
|
||||||
|
return nil
|
||||||
|
case .blue:
|
||||||
|
return .systemBlue
|
||||||
|
case .purple:
|
||||||
|
return .systemPurple
|
||||||
|
case .pink:
|
||||||
|
return .systemPink
|
||||||
|
case .red:
|
||||||
|
return .systemRed
|
||||||
|
case .orange:
|
||||||
|
return .systemOrange
|
||||||
|
case .yellow:
|
||||||
|
return .systemYellow
|
||||||
|
case .green:
|
||||||
|
return .systemGreen
|
||||||
|
case .teal:
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
return .systemMint
|
||||||
|
} else {
|
||||||
|
return .systemTeal
|
||||||
|
}
|
||||||
|
case .graphite:
|
||||||
|
return .systemGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func swatchImage(selected: Bool) -> NSImage {
|
||||||
|
let size = NSSize(width: 18, height: 18)
|
||||||
|
return NSImage(size: size, flipped: false) { rect in
|
||||||
|
let circleRect = rect.insetBy(dx: 1, dy: 1)
|
||||||
|
let circlePath = NSBezierPath(ovalIn: circleRect)
|
||||||
|
|
||||||
|
if let fillColor = self.displayColor {
|
||||||
|
fillColor.setFill()
|
||||||
|
circlePath.fill()
|
||||||
|
} else {
|
||||||
|
NSColor.clear.setFill()
|
||||||
|
circlePath.fill()
|
||||||
|
NSColor.quaternaryLabelColor.setStroke()
|
||||||
|
circlePath.lineWidth = 1
|
||||||
|
circlePath.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
if self == .none {
|
||||||
|
let slash = NSBezierPath()
|
||||||
|
slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2))
|
||||||
|
slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2))
|
||||||
|
slash.lineWidth = 1.5
|
||||||
|
NSColor.secondaryLabelColor.setStroke()
|
||||||
|
slash.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected {
|
||||||
|
let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5))
|
||||||
|
highlight.lineWidth = 2
|
||||||
|
NSColor.controlAccentColor.setStroke()
|
||||||
|
highlight.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Menu View
|
||||||
|
|
||||||
|
/// A SwiftUI view displaying a color palette for tab color selection.
|
||||||
|
/// Used as a custom view inside an NSMenuItem in the tab context menu.
|
||||||
|
struct TabColorMenuView: View {
|
||||||
|
@State private var currentSelection: TerminalTabColor
|
||||||
|
let onSelect: (TerminalTabColor) -> Void
|
||||||
|
|
||||||
|
init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) {
|
||||||
|
self._currentSelection = State(initialValue: selectedColor)
|
||||||
|
self.onSelect = onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(Self.paletteRows, id: \.self) { row in
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ForEach(row, id: \.self) { color in
|
||||||
|
TabColorSwatch(
|
||||||
|
color: color,
|
||||||
|
isSelected: color == currentSelection
|
||||||
|
) {
|
||||||
|
currentSelection = color
|
||||||
|
onSelect(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, Self.leadingPadding)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let paletteRows: [[TerminalTabColor]] = [
|
||||||
|
[.none, .blue, .purple, .pink, .red],
|
||||||
|
[.orange, .yellow, .green, .teal, .graphite],
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Leading padding to align with the menu's icon gutter.
|
||||||
|
/// macOS 26 introduced icons in menus, requiring additional padding.
|
||||||
|
private static var leadingPadding: CGFloat {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
return 40
|
||||||
|
} else {
|
||||||
|
return 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single color swatch button in the tab color palette.
|
||||||
|
private struct TabColorSwatch: View {
|
||||||
|
let color: TerminalTabColor
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Group {
|
||||||
|
if color == .none {
|
||||||
|
Image(systemName: isSelected ? "circle.slash" : "circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let displayColor = color.displayColor {
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle.fill")
|
||||||
|
.foregroundStyle(Color(nsColor: displayColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(color.localizedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,9 +24,17 @@ class TerminalWindow: NSWindow {
|
||||||
/// Update notification UI in titlebar
|
/// Update notification UI in titlebar
|
||||||
private let updateAccessory = NSTitlebarAccessoryViewController()
|
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.
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||||
private(set) var derivedConfig: DerivedConfig = .init()
|
private(set) var derivedConfig: DerivedConfig = .init()
|
||||||
|
|
||||||
|
/// Sets up our tab context menu
|
||||||
private var tabMenuObserver: NSObjectProtocol? = nil
|
private var tabMenuObserver: NSObjectProtocol? = nil
|
||||||
|
|
||||||
/// Whether this window supports the update accessory. If this is false, then views within this
|
/// Whether this window supports the update accessory. If this is false, then views within this
|
||||||
|
|
@ -41,6 +49,16 @@ class TerminalWindow: NSWindow {
|
||||||
windowController as? 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
|
// MARK: NSWindow Overrides
|
||||||
|
|
||||||
override var toolbar: NSToolbar? {
|
override var toolbar: NSToolbar? {
|
||||||
|
|
@ -132,9 +150,16 @@ class TerminalWindow: NSWindow {
|
||||||
// Setup the accessory view for tabs that shows our keyboard shortcuts,
|
// 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
|
// zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
|
||||||
// where buttons were not clickable.
|
// where buttons were not clickable.
|
||||||
let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
|
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
|
||||||
|
|
||||||
|
let stackView = NSStackView()
|
||||||
|
stackView.orientation = .horizontal
|
||||||
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
|
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
|
||||||
stackView.spacing = 3
|
stackView.spacing = 4
|
||||||
|
stackView.alignment = .centerY
|
||||||
|
stackView.addArrangedSubview(tabColorIndicator)
|
||||||
|
stackView.addArrangedSubview(keyEquivalentLabel)
|
||||||
|
stackView.addArrangedSubview(resetZoomTabButton)
|
||||||
tab.accessoryView = stackView
|
tab.accessoryView = stackView
|
||||||
|
|
||||||
// Get our saved level
|
// Get our saved level
|
||||||
|
|
@ -215,8 +240,6 @@ class TerminalWindow: NSWindow {
|
||||||
/// added.
|
/// added.
|
||||||
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
|
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
|
||||||
|
|
||||||
private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem")
|
|
||||||
|
|
||||||
func findTitlebarView() -> NSView? {
|
func findTitlebarView() -> NSView? {
|
||||||
// Find our tab bar. If it doesn't exist we don't do anything.
|
// Find our tab bar. If it doesn't exist we don't do anything.
|
||||||
//
|
//
|
||||||
|
|
@ -292,52 +315,6 @@ class TerminalWindow: NSWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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:")) &&
|
|
||||||
!menu.insertItem(item, after: NSSelectorFromString("performClose:")) {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
|
|
||||||
guard NSApp.keyWindow === self else { return false }
|
|
||||||
|
|
||||||
// These are the target selectors, at least for macOS 26.
|
|
||||||
let tabContextSelectors: Set<String> = [
|
|
||||||
"performClose:",
|
|
||||||
"performCloseOtherTabs:",
|
|
||||||
"moveTabToNewWindow:",
|
|
||||||
"toggleTabOverview:"
|
|
||||||
]
|
|
||||||
|
|
||||||
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
|
|
||||||
return !selectorNames.isDisjoint(with: tabContextSelectors)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: Tab Key Equivalents
|
// MARK: Tab Key Equivalents
|
||||||
|
|
||||||
var keyEquivalent: String? = nil {
|
var keyEquivalent: String? = nil {
|
||||||
|
|
@ -549,7 +526,7 @@ class TerminalWindow: NSWindow {
|
||||||
|
|
||||||
private func setInitialWindowPosition(x: Int16?, y: Int16?) {
|
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.
|
// If we don't have an X/Y then we try to use the previously saved window pos.
|
||||||
guard let x, let y else {
|
guard x != nil, y != nil else {
|
||||||
if (!LastWindowPosition.shared.restore(self)) {
|
if (!LastWindowPosition.shared.restore(self)) {
|
||||||
center()
|
center()
|
||||||
}
|
}
|
||||||
|
|
@ -666,3 +643,119 @@ extension TerminalWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator")
|
||||||
|
private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTabColorSection(to: menu, target: targetController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
|
||||||
|
guard NSApp.keyWindow === self else { return false }
|
||||||
|
|
||||||
|
// These are the target selectors, at least for macOS 26.
|
||||||
|
let tabContextSelectors: Set<String> = [
|
||||||
|
"performClose:",
|
||||||
|
"performCloseOtherTabs:",
|
||||||
|
"moveTabToNewWindow:",
|
||||||
|
"toggleTabOverview:"
|
||||||
|
]
|
||||||
|
|
||||||
|
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
|
||||||
|
return !selectorNames.isDisjoint(with: tabContextSelectors)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) {
|
||||||
|
menu.removeItems(withIdentifiers: [
|
||||||
|
Self.tabColorSeparatorIdentifier,
|
||||||
|
Self.tabColorHeaderIdentifier,
|
||||||
|
Self.tabColorPaletteIdentifier
|
||||||
|
])
|
||||||
|
|
||||||
|
let separator = NSMenuItem.separator()
|
||||||
|
separator.identifier = Self.tabColorSeparatorIdentifier
|
||||||
|
menu.addItem(separator)
|
||||||
|
|
||||||
|
let headerItem = NSMenuItem()
|
||||||
|
headerItem.identifier = Self.tabColorHeaderIdentifier
|
||||||
|
headerItem.title = "Tab Color"
|
||||||
|
headerItem.isEnabled = false
|
||||||
|
headerItem.setImageIfDesired(systemSymbolName: "eyedropper")
|
||||||
|
menu.addItem(headerItem)
|
||||||
|
|
||||||
|
let paletteItem = NSMenuItem()
|
||||||
|
paletteItem.identifier = Self.tabColorPaletteIdentifier
|
||||||
|
paletteItem.view = makeTabColorPaletteView(
|
||||||
|
selectedColor: tabColor
|
||||||
|
) { [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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,33 @@ extension NSMenu {
|
||||||
/// - item: The menu item to insert.
|
/// - item: The menu item to insert.
|
||||||
/// - action: The action selector to search for. The new item will be inserted after the first
|
/// - action: The action selector to search for. The new item will be inserted after the first
|
||||||
/// item with this action.
|
/// item with this action.
|
||||||
/// - Returns: `true` if the item was inserted after the specified action, `false` if the action
|
/// - Returns: The index where the item was inserted, or `nil` if the action was not found
|
||||||
/// was not found and the item was not inserted.
|
/// and the item was not inserted.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool {
|
func insertItem(_ item: NSMenuItem, after action: Selector) -> UInt? {
|
||||||
if let identifier = item.identifier,
|
if let identifier = item.identifier,
|
||||||
let existing = items.first(where: { $0.identifier == identifier }) {
|
let existing = items.first(where: { $0.identifier == identifier }) {
|
||||||
removeItem(existing)
|
removeItem(existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let idx = items.firstIndex(where: { $0.action == action }) else {
|
guard let idx = items.firstIndex(where: { $0.action == action }) else {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
insertItem(item, at: idx + 1)
|
let insertionIndex = idx + 1
|
||||||
return true
|
insertItem(item, at: insertionIndex)
|
||||||
|
return UInt(insertionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes all menu items whose identifier is in the given set.
|
||||||
|
///
|
||||||
|
/// - Parameter identifiers: The set of identifiers to match for removal.
|
||||||
|
func removeItems(withIdentifiers identifiers: Set<NSUserInterfaceItemIdentifier>) {
|
||||||
|
for (index, item) in items.enumerated().reversed() {
|
||||||
|
guard let identifier = item.identifier else { continue }
|
||||||
|
if identifiers.contains(identifier) {
|
||||||
|
removeItem(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue