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
Mitchell Hashimoto 2025-12-11 14:27:25 -08:00 committed by GitHub
commit d77b7c32f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 412 additions and 111 deletions

View File

@ -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",

View File

@ -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> = []
@ -148,7 +149,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) { override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to) super.surfaceTreeDidChange(from: from, to: to)
// Whenever our surface tree changes in any way (new split, close split, etc.) // Whenever our surface tree changes in any way (new split, close split, etc.)
// we want to invalidate our state. // we want to invalidate our state.
invalidateRestorableState() invalidateRestorableState()
@ -195,7 +196,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
$0.window?.isMainWindow ?? false $0.window?.isMainWindow ?? false
} ?? lastMain ?? all.last } ?? lastMain ?? all.last
} }
// The last controller to be main. We use this when paired with "preferredParent" // The last controller to be main. We use this when paired with "preferredParent"
// to find the preferred window to attach new tabs, perform actions, etc. We // to find the preferred window to attach new tabs, perform actions, etc. We
// always prefer the main window but if there isn't any (because we're triggered // always prefer the main window but if there isn't any (because we're triggered
@ -517,13 +518,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
fromTopLeftOffsetX: CGFloat(x), fromTopLeftOffsetX: CGFloat(x),
offsetY: CGFloat(y), offsetY: CGFloat(y),
windowSize: frame.size) windowSize: frame.size)
// Clamp the origin to ensure the window stays fully visible on screen // Clamp the origin to ensure the window stays fully visible on screen
var safeOrigin = origin var safeOrigin = origin
let vf = screen.visibleFrame let vf = screen.visibleFrame
safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width)
safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height)
// Return our new origin // Return our new origin
var result = frame var result = frame
result.origin = safeOrigin result.origin = safeOrigin
@ -558,7 +559,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
closeWindowImmediately() closeWindowImmediately()
return return
} }
// Undo // Undo
if let undoManager, let undoState { if let undoManager, let undoState {
// Register undo action to restore the tab // Register undo action to restore the tab
@ -579,15 +580,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
} }
} }
} }
window.close() window.close()
} }
private func closeOtherTabsImmediately() { private func closeOtherTabsImmediately() {
guard let window = window else { return } guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return } guard let tabGroup = window.tabGroup else { return }
guard tabGroup.windows.count > 1 else { return } guard tabGroup.windows.count > 1 else { return }
// Start an undo grouping // Start an undo grouping
if let undoManager { if let undoManager {
undoManager.beginUndoGrouping() undoManager.beginUndoGrouping()
@ -595,7 +596,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
defer { defer {
undoManager?.endUndoGrouping() undoManager?.endUndoGrouping()
} }
// Iterate through all tabs except the current one. // Iterate through all tabs except the current one.
for window in tabGroup.windows where window != self.window { for window in tabGroup.windows where window != self.window {
// We ignore any non-terminal tabs. They don't currently exist and we can't // We ignore any non-terminal tabs. They don't currently exist and we can't
@ -607,10 +608,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
controller.closeTabImmediately(registerRedo: false) controller.closeTabImmediately(registerRedo: false)
} }
} }
if let undoManager { if let undoManager {
undoManager.setActionName("Close Other Tabs") undoManager.setActionName("Close Other Tabs")
// We need to register an undo that refocuses this window. Otherwise, the // We need to register an undo that refocuses this window. Otherwise, the
// undo operation above for each tab will steal focus. // undo operation above for each tab will steal focus.
undoManager.registerUndo( undoManager.registerUndo(
@ -620,7 +621,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
DispatchQueue.main.async { DispatchQueue.main.async {
target.window?.makeKeyAndOrderFront(nil) target.window?.makeKeyAndOrderFront(nil)
} }
// Register redo action // Register redo action
undoManager.registerUndo( undoManager.registerUndo(
withTarget: target, withTarget: target,
@ -746,7 +747,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
case (nil, nil): return true case (nil, nil): return true
} }
} }
// Find the index of the key window in our sorted states. This is a bit verbose // Find the index of the key window in our sorted states. This is a bit verbose
// but we only need this for this style of undo so we don't want to add it to // but we only need this for this style of undo so we don't want to add it to
// UndoState. // UndoState.
@ -772,12 +773,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
let controllers = undoStates.map { undoState in let controllers = undoStates.map { undoState in
TerminalController(ghostty, with: undoState) TerminalController(ghostty, with: undoState)
} }
// The first controller becomes the parent window for all tabs. // The first controller becomes the parent window for all tabs.
// If we don't have a first controller (shouldn't be possible?) // If we don't have a first controller (shouldn't be possible?)
// then we can't restore tabs. // then we can't restore tabs.
guard let firstController = controllers.first else { return } guard let firstController = controllers.first else { return }
// Add all subsequent controllers as tabs to the first window // Add all subsequent controllers as tabs to the first window
for controller in controllers.dropFirst() { for controller in controllers.dropFirst() {
controller.showWindow(nil) controller.showWindow(nil)
@ -786,7 +787,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
firstWindow.addTabbedWindow(newWindow, ordered: .above) firstWindow.addTabbedWindow(newWindow, ordered: .above)
} }
} }
// Make the appropriate window key. If we had a key window, restore it. // Make the appropriate window key. If we had a key window, restore it.
// Otherwise, make the last window key. // Otherwise, make the last window key.
if let keyWindowIndex, keyWindowIndex < controllers.count { if let keyWindowIndex, keyWindowIndex < controllers.count {
@ -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
@ -939,14 +945,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
viewModel: self, viewModel: self,
delegate: self, delegate: self,
)) ))
// If we have a default size, we want to apply it. // If we have a default size, we want to apply it.
if let defaultSize { if let defaultSize {
switch (defaultSize) { switch (defaultSize) {
case .frame: case .frame:
// Frames can be applied immediately // Frames can be applied immediately
defaultSize.apply(to: window) defaultSize.apply(to: window)
case .contentIntrinsicSize: case .contentIntrinsicSize:
// Content intrinsic size requires a short delay so that AppKit // Content intrinsic size requires a short delay so that AppKit
// can layout our SwiftUI views. // can layout our SwiftUI views.
@ -956,13 +962,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
} }
} }
} }
// Store our initial frame so we can know our default later. This MUST // Store our initial frame so we can know our default later. This MUST
// be after the defaultSize call above so that we don't re-apply our frame. // be after the defaultSize call above so that we don't re-apply our frame.
// Note: we probably want to set this on the first frame change or something // Note: we probably want to set this on the first frame change or something
// so it respects cascade. // so it respects cascade.
initialFrame = window.frame initialFrame = window.frame
// In various situations, macOS automatically tabs new windows. Ghostty handles // In various situations, macOS automatically tabs new windows. Ghostty handles
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes // its own tabbing so we DONT want this behavior. This detects this scenario and undoes
// it. // it.
@ -1073,7 +1079,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
if let window { if let window {
LastWindowPosition.shared.save(window) LastWindowPosition.shared.save(window)
} }
// Remember our last main // Remember our last main
Self.lastMain = self Self.lastMain = self
} }
@ -1120,7 +1126,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
@IBAction func closeOtherTabs(_ sender: Any?) { @IBAction func closeOtherTabs(_ sender: Any?) {
guard let window = window else { return } guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return } guard let tabGroup = window.tabGroup else { return }
// If we only have one window then we have no other tabs to close // If we only have one window then we have no other tabs to close
guard tabGroup.windows.count > 1 else { return } guard tabGroup.windows.count > 1 else { return }
@ -1219,7 +1225,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
} }
//MARK: - TerminalViewDelegate //MARK: - TerminalViewDelegate
override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
super.focusedSurfaceDidChange(to: to) super.focusedSurfaceDidChange(to: to)
@ -1283,7 +1289,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Get our target window // Get our target window
let targetWindow = tabbedWindows[finalIndex] let targetWindow = tabbedWindows[finalIndex]
// Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs. // Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs.
// I believe this is due to messed up constraints for our hacky tab bar. I'd like to // I believe this is due to messed up constraints for our hacky tab bar. I'd like to
// find a better workaround. For now, this improves things dramatically. // find a better workaround. For now, this improves things dramatically.
@ -1296,7 +1302,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
DispatchQueue.main.async { DispatchQueue.main.async {
selectedWindow.makeKey() selectedWindow.makeKey()
} }
return return
} }
} }
@ -1451,24 +1457,24 @@ extension TerminalController {
guard let window, let tabGroup = window.tabGroup else { return false } guard let window, let tabGroup = window.tabGroup else { return false }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false }
return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } return tabGroup.windows.enumerated().contains { $0.offset > currentIndex }
case #selector(returnToDefaultSize): case #selector(returnToDefaultSize):
guard let window else { return false } guard let window else { return false }
// Native fullscreen windows can't revert to default size. // Native fullscreen windows can't revert to default size.
if window.styleMask.contains(.fullScreen) { if window.styleMask.contains(.fullScreen) {
return false return false
} }
// If we're fullscreen at all then we can't change size // If we're fullscreen at all then we can't change size
if fullscreenStyle?.isFullscreen ?? false { if fullscreenStyle?.isFullscreen ?? false {
return false return false
} }
// If our window is already the default size or we don't have a // If our window is already the default size or we don't have a
// default size, then disable. // default size, then disable.
return defaultSize?.isChanged(for: window) ?? false return defaultSize?.isChanged(for: window) ?? false
default: default:
return super.validateMenuItem(item) return super.validateMenuItem(item)
} }
@ -1484,10 +1490,10 @@ extension TerminalController {
enum DefaultSize { enum DefaultSize {
/// A frame, set with `window.setFrame` /// A frame, set with `window.setFrame`
case frame(NSRect) case frame(NSRect)
/// A content size, set with `window.setContentSize` /// A content size, set with `window.setContentSize`
case contentIntrinsicSize case contentIntrinsicSize
func isChanged(for window: NSWindow) -> Bool { func isChanged(for window: NSWindow) -> Bool {
switch self { switch self {
case .frame(let rect): case .frame(let rect):
@ -1496,11 +1502,11 @@ extension TerminalController {
guard let view = window.contentView else { guard let view = window.contentView else {
return false return false
} }
return view.frame.size != view.intrinsicContentSize return view.frame.size != view.intrinsicContentSize
} }
} }
func apply(to window: NSWindow) { func apply(to window: NSWindow) {
switch self { switch self {
case .frame(let rect): case .frame(let rect):
@ -1509,13 +1515,13 @@ extension TerminalController {
guard let size = window.contentView?.intrinsicContentSize else { guard let size = window.contentView?.intrinsicContentSize else {
return return
} }
window.setContentSize(size) window.setContentSize(size)
window.constrainToScreen() window.constrainToScreen()
} }
} }
} }
private var defaultSize: DefaultSize? { private var defaultSize: DefaultSize? {
if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main { if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main {
// Maximize takes priority, we take up the full screen we're on. // Maximize takes priority, we take up the full screen we're on.

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -7,10 +7,10 @@ import GhosttyKit
class TerminalWindow: NSWindow { class TerminalWindow: NSWindow {
/// Posted when a terminal window awakes from nib. /// Posted when a terminal window awakes from nib.
static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
/// Posted when a terminal window will close /// Posted when a terminal window will close
static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose") static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose")
/// This is the key in UserDefaults to use for the default `level` value. This is /// 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. /// used by the manual float on top menu item feature.
static let defaultLevelKey: String = "TerminalDefaultLevel" static let defaultLevelKey: String = "TerminalDefaultLevel"
@ -20,15 +20,23 @@ class TerminalWindow: NSWindow {
/// Reset split zoom button in titlebar /// Reset split zoom button in titlebar
private let resetZoomAccessory = NSTitlebarAccessoryViewController() private let resetZoomAccessory = NSTitlebarAccessoryViewController()
/// 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()
private var tabMenuObserver: NSObjectProtocol? = nil
/// 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 /// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications. /// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool { var supportsUpdateAccessory: Bool {
@ -40,7 +48,17 @@ class TerminalWindow: NSWindow {
var terminalController: TerminalController? { var terminalController: TerminalController? {
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? {
@ -66,7 +84,7 @@ class TerminalWindow: NSWindow {
guard let self, let menu = n.object as? NSMenu else { return } guard let self, let menu = n.object as? NSMenu else { return }
self.configureTabContextMenuIfNeeded(menu) self.configureTabContextMenuIfNeeded(menu)
} }
// This is required so that window restoration properly creates our tabs // 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 // again. I'm not sure why this is required. If you don't do this, then
// tabs restore as separate windows. // tabs restore as separate windows.
@ -74,14 +92,14 @@ class TerminalWindow: NSWindow {
DispatchQueue.main.async { DispatchQueue.main.async {
self.tabbingMode = .automatic self.tabbingMode = .automatic
} }
// All new windows are based on the app config at the time of creation. // All new windows are based on the app config at the time of creation.
guard let appDelegate = NSApp.delegate as? AppDelegate else { return } guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
let config = appDelegate.ghostty.config let config = appDelegate.ghostty.config
// Setup our initial config // Setup our initial config
derivedConfig = .init(config) derivedConfig = .init(config)
// If there is a hardcoded title in the configuration, we set that // If there is a hardcoded title in the configuration, we set that
// immediately. Future `set_title` apprt actions will override this // immediately. Future `set_title` apprt actions will override this
// if necessary but this ensures our window loads with the proper // if necessary but this ensures our window loads with the proper
@ -116,7 +134,7 @@ class TerminalWindow: NSWindow {
})) }))
addTitlebarAccessoryViewController(resetZoomAccessory) addTitlebarAccessoryViewController(resetZoomAccessory)
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
// Create update notification accessory // Create update notification accessory
if supportsUpdateAccessory { if supportsUpdateAccessory {
updateAccessory.layoutAttribute = .right updateAccessory.layoutAttribute = .right
@ -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
@ -145,7 +170,7 @@ class TerminalWindow: NSWindow {
// 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 }
override var canBecomeMain: Bool { return true } override var canBecomeMain: Bool { return true }
override func close() { override func close() {
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
super.close() super.close()
@ -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.
// //
@ -279,7 +302,7 @@ class TerminalWindow: NSWindow {
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
removeTitlebarAccessoryViewController(at: idx) removeTitlebarAccessoryViewController(at: idx)
} }
// We don't need to do this with the update accessory. I don't know why but // We don't need to do this with the update accessory. I don't know why but
// everything works fine. // everything works fine.
} }
@ -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()
} }
@ -568,7 +545,7 @@ class TerminalWindow: NSWindow {
center() center()
return return
} }
let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen) let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen)
setFrameOrigin(frame.origin) setFrameOrigin(frame.origin)
} }
@ -584,7 +561,7 @@ class TerminalWindow: NSWindow {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
} }
} }
// MARK: Config // MARK: Config
struct DerivedConfig { struct DerivedConfig {
@ -651,12 +628,12 @@ extension TerminalWindow {
} }
} }
} }
/// A pill-shaped button that displays update status and provides access to update actions. /// A pill-shaped button that displays update status and provides access to update actions.
struct UpdateAccessoryView: View { struct UpdateAccessoryView: View {
@ObservedObject var viewModel: ViewModel @ObservedObject var viewModel: ViewModel
@ObservedObject var model: UpdateViewModel @ObservedObject var model: UpdateViewModel
var body: some View { var body: some View {
// We use the same top/trailing padding so that it hugs the same. // We use the same top/trailing padding so that it hugs the same.
UpdatePill(model: model) UpdatePill(model: model)
@ -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
}

View File

@ -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)
}
}
} }
} }