From 1a65c1aae2f35e4accd9c2b4761d92dbd7d742ae Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 22:24:10 +0200 Subject: [PATCH 01/10] feat(macos): add tab color picker to tab context menu --- .../Terminal/TerminalController.swift | 96 +++-- .../Terminal/TerminalRestorable.swift | 7 +- .../Window Styles/TerminalWindow.swift | 357 ++++++++++++++++-- .../Helpers/Extensions/NSMenu+Extension.swift | 13 +- 4 files changed, 404 insertions(+), 69 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a275c3f395..1b2a31928b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,6 +54,17 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig + /// The accent color that should be rendered for this tab. + var tabColor: TerminalWindow.TabColor = .none { + didSet { + guard tabColor != oldValue else { return } + if let terminalWindow = window as? TerminalWindow { + terminalWindow.display(tabColor: tabColor) + } + window?.invalidateRestorableState() + } + } + /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -148,7 +159,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - + // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() @@ -195,7 +206,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr $0.window?.isMainWindow ?? false } ?? lastMain ?? all.last } - + // 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 // always prefer the main window but if there isn't any (because we're triggered @@ -517,13 +528,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr fromTopLeftOffsetX: CGFloat(x), offsetY: CGFloat(y), windowSize: frame.size) - + // Clamp the origin to ensure the window stays fully visible on screen var safeOrigin = origin let vf = screen.visibleFrame safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) - + // Return our new origin var result = frame result.origin = safeOrigin @@ -558,7 +569,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeWindowImmediately() return } - + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -579,15 +590,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + window.close() } - + private func closeOtherTabsImmediately() { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } guard tabGroup.windows.count > 1 else { return } - + // Start an undo grouping if let undoManager { undoManager.beginUndoGrouping() @@ -595,7 +606,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr defer { undoManager?.endUndoGrouping() } - + // Iterate through all tabs except the current one. for window in tabGroup.windows where window != self.window { // We ignore any non-terminal tabs. They don't currently exist and we can't @@ -607,10 +618,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr controller.closeTabImmediately(registerRedo: false) } } - + if let undoManager { undoManager.setActionName("Close Other Tabs") - + // We need to register an undo that refocuses this window. Otherwise, the // undo operation above for each tab will steal focus. undoManager.registerUndo( @@ -620,7 +631,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { target.window?.makeKeyAndOrderFront(nil) } - + // Register redo action undoManager.registerUndo( withTarget: target, @@ -746,7 +757,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr case (nil, nil): return true } } - + // 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 // UndoState. @@ -772,12 +783,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let controllers = undoStates.map { undoState in TerminalController(ghostty, with: undoState) } - + // The first controller becomes the parent window for all tabs. // If we don't have a first controller (shouldn't be possible?) // then we can't restore tabs. guard let firstController = controllers.first else { return } - + // Add all subsequent controllers as tabs to the first window for controller in controllers.dropFirst() { controller.showWindow(nil) @@ -786,7 +797,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr firstWindow.addTabbedWindow(newWindow, ordered: .above) } } - + // Make the appropriate window key. If we had a key window, restore it. // Otherwise, make the last window key. if let keyWindowIndex, keyWindowIndex < controllers.count { @@ -852,12 +863,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? + let tabColor: TerminalWindow.TabColor } convenience init(_ ghostty: Ghostty.App, with undoState: UndoState ) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) + self.tabColor = undoState.tabColor // Show the window and restore its frame showWindow(nil) @@ -898,7 +911,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr surfaceTree: surfaceTree, focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), - tabGroup: window.tabGroup) + tabGroup: window.tabGroup, + tabColor: tabColor) } //MARK: - NSWindowController @@ -939,14 +953,17 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr viewModel: self, delegate: self, )) - + + if let terminalWindow = window as? TerminalWindow { + terminalWindow.display(tabColor: tabColor) + } // If we have a default size, we want to apply it. if let defaultSize { switch (defaultSize) { case .frame: // Frames can be applied immediately defaultSize.apply(to: window) - + case .contentIntrinsicSize: // Content intrinsic size requires a short delay so that AppKit // can layout our SwiftUI views. @@ -956,13 +973,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + // 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. // Note: we probably want to set this on the first frame change or something // so it respects cascade. initialFrame = window.frame - + // 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 // it. @@ -1073,7 +1090,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if let window { LastWindowPosition.shared.save(window) } - + // Remember our last main Self.lastMain = self } @@ -1120,7 +1137,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr @IBAction func closeOtherTabs(_ sender: Any?) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } - + // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } @@ -1178,6 +1195,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + + func setTabColor(_ color: TerminalWindow.TabColor) { + tabColor = color + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1219,7 +1241,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } //MARK: - TerminalViewDelegate - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -1283,7 +1305,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Get our target window let targetWindow = tabbedWindows[finalIndex] - + // 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 // find a better workaround. For now, this improves things dramatically. @@ -1296,7 +1318,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { selectedWindow.makeKey() } - + return } } @@ -1451,24 +1473,24 @@ extension TerminalController { guard let window, let tabGroup = window.tabGroup else { return false } guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } - + case #selector(returnToDefaultSize): guard let window else { return false } - + // Native fullscreen windows can't revert to default size. if window.styleMask.contains(.fullScreen) { return false } - + // If we're fullscreen at all then we can't change size if fullscreenStyle?.isFullscreen ?? false { return false } - + // If our window is already the default size or we don't have a // default size, then disable. return defaultSize?.isChanged(for: window) ?? false - + default: return super.validateMenuItem(item) } @@ -1484,10 +1506,10 @@ extension TerminalController { enum DefaultSize { /// A frame, set with `window.setFrame` case frame(NSRect) - + /// A content size, set with `window.setContentSize` case contentIntrinsicSize - + func isChanged(for window: NSWindow) -> Bool { switch self { case .frame(let rect): @@ -1496,11 +1518,11 @@ extension TerminalController { guard let view = window.contentView else { return false } - + return view.frame.size != view.intrinsicContentSize } } - + func apply(to window: NSWindow) { switch self { case .frame(let rect): @@ -1509,13 +1531,13 @@ extension TerminalController { guard let size = window.contentView?.intrinsicContentSize else { return } - + window.setContentSize(size) window.constrainToScreen() } } } - + private var defaultSize: DefaultSize? { if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main { // Maximize takes priority, we take up the full screen we're on. diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 71e54b612a..852cad5813 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,16 +4,18 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 5 + static let version: Int = 6 let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? + let tabColorRawValue: Int init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode + self.tabColorRawValue = controller.tabColor.rawValue } init?(coder aDecoder: NSCoder) { @@ -31,6 +33,7 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode + self.tabColorRawValue = v.value.tabColorRawValue } func encode(with coder: NSCoder) { @@ -94,6 +97,8 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } + c.tabColor = TerminalWindow.TabColor(rawValue: state.tabColorRawValue) ?? .none + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 77ee98cb40..1361bbd1f7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -7,10 +7,10 @@ import GhosttyKit 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" @@ -20,15 +20,20 @@ class TerminalWindow: NSWindow { /// 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 let tabColorIndicator = TabColorIndicator() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() - private var tabMenuObserver: NSObjectProtocol? = nil - + private var tabColorSelection: TabColor = .none { + didSet { tabColorIndicator.tabColor = tabColorSelection } + } + /// 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 { @@ -40,7 +45,11 @@ class TerminalWindow: NSWindow { var terminalController: TerminalController? { windowController as? TerminalController } - + + func display(tabColor: TabColor) { + tabColorSelection = tabColor + } + // MARK: NSWindow Overrides override var toolbar: NSToolbar? { @@ -66,7 +75,7 @@ class TerminalWindow: NSWindow { 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. @@ -74,14 +83,14 @@ class TerminalWindow: NSWindow { 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 @@ -116,7 +125,7 @@ class TerminalWindow: NSWindow { })) addTitlebarAccessoryViewController(resetZoomAccessory) resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false - + // Create update notification accessory if supportsUpdateAccessory { updateAccessory.layoutAttribute = .right @@ -132,9 +141,19 @@ class TerminalWindow: NSWindow { // 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. - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + tabColorIndicator.translatesAutoresizingMaskIntoConstraints = false + tabColorIndicator.widthAnchor.constraint(equalToConstant: 12).isActive = true + tabColorIndicator.heightAnchor.constraint(equalToConstant: 4).isActive = true + tabColorIndicator.tabColor = tabColorSelection + + let stackView = NSStackView() + stackView.orientation = .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 // Get our saved level @@ -145,7 +164,7 @@ class TerminalWindow: NSWindow { // 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() @@ -216,6 +235,9 @@ class TerminalWindow: NSWindow { static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") 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 findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. @@ -279,7 +301,7 @@ class TerminalWindow: NSWindow { 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. } @@ -302,29 +324,37 @@ class TerminalWindow: NSWindow { .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:")) { + let insertionIndex: UInt + if let idx = menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) { + insertionIndex = idx + } else if let idx = menu.insertItem(item, after: NSSelectorFromString("performClose:")) { + insertionIndex = idx + } else { menu.addItem(item) + insertionIndex = UInt(menu.items.count - 1) } // 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.action == NSSelectorFromString("performCloseOtherTabs:") { menuItem.setImageIfDesired(systemSymbolName: "xmark") } } + + removeTabColorSection(from: menu) + insertTabColorSection(into: menu, startingAt: Int(insertionIndex) + 1) } 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 = [ "performClose:", @@ -332,12 +362,56 @@ class TerminalWindow: NSWindow { "moveTabToNewWindow:", "toggleTabOverview:" ] - + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) return !selectorNames.isDisjoint(with: tabContextSelectors) } + private func removeTabColorSection(from menu: NSMenu) { + let identifiers: Set = [ + Self.tabColorSeparatorIdentifier, + Self.tabColorHeaderIdentifier, + Self.tabColorPaletteIdentifier + ] + + for (index, item) in menu.items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + menu.removeItem(at: index) + } + } + } + + private func insertTabColorSection(into menu: NSMenu, startingAt index: Int) { + guard let terminalController else { return } + + var insertionIndex = index + + let separator = NSMenuItem.separator() + separator.identifier = Self.tabColorSeparatorIdentifier + menu.insertItem(separator, at: insertionIndex) + insertionIndex += 1 + + let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") + let headerItem = NSMenuItem() + headerItem.identifier = Self.tabColorHeaderIdentifier + headerItem.title = headerTitle + headerItem.isEnabled = false + menu.insertItem(headerItem, at: insertionIndex) + insertionIndex += 1 + + let paletteItem = NSMenuItem() + paletteItem.identifier = Self.tabColorPaletteIdentifier + let paletteView = TabColorPaletteView( + selectedColor: tabColorSelection + ) { [weak terminalController] color in + terminalController?.setTabColor(color) + } + paletteItem.view = paletteView + menu.insertItem(paletteItem, at: insertionIndex) + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -549,7 +623,7 @@ class TerminalWindow: NSWindow { 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 let x, let y else { + guard x != nil, y != nil else { if (!LastWindowPosition.shared.restore(self)) { center() } @@ -568,7 +642,7 @@ class TerminalWindow: NSWindow { center() return } - + let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen) setFrameOrigin(frame.origin) } @@ -584,7 +658,7 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - + // MARK: Config struct DerivedConfig { @@ -651,12 +725,12 @@ extension TerminalWindow { } } } - + /// 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) @@ -666,3 +740,236 @@ extension TerminalWindow { } } + +extension TerminalWindow { + enum TabColor: Int, CaseIterable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + static let paletteRows: [[TabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] + + var localizedName: String { + switch self { + case .none: + return NSLocalizedString("None", comment: "Tab color option label") + case .blue: + return NSLocalizedString("Blue", comment: "Tab color option label") + case .purple: + return NSLocalizedString("Purple", comment: "Tab color option label") + case .pink: + return NSLocalizedString("Pink", comment: "Tab color option label") + case .red: + return NSLocalizedString("Red", comment: "Tab color option label") + case .orange: + return NSLocalizedString("Orange", comment: "Tab color option label") + case .yellow: + return NSLocalizedString("Yellow", comment: "Tab color option label") + case .green: + return NSLocalizedString("Green", comment: "Tab color option label") + case .teal: + return NSLocalizedString("Teal", comment: "Tab color option label") + case .graphite: + return NSLocalizedString("Graphite", comment: "Tab color option label") + } + } + + 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 + } + } + } +} + +private final class TabColorIndicator: NSView { + var tabColor: TerminalWindow.TabColor = .none { + didSet { updateAppearance() } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + updateAppearance() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + updateAppearance() + } + + private func updateAppearance() { + guard let layer else { return } + layer.cornerRadius = bounds.height / 2 + + if let color = tabColor.displayColor { + alphaValue = 1 + layer.backgroundColor = color.cgColor + layer.borderWidth = 0 + layer.borderColor = nil + } else { + alphaValue = 0 + layer.backgroundColor = NSColor.clear.cgColor + layer.borderWidth = 0 + layer.borderColor = nil + } + } +} + +private final class TabColorPaletteView: NSView { + private let stackView = NSStackView() + private var selectedColor: TerminalWindow.TabColor + private let selectionHandler: (TerminalWindow.TabColor) -> Void + private var buttons: [NSButton] = [] + + init(selectedColor: TerminalWindow.TabColor, + selectionHandler: @escaping (TerminalWindow.TabColor) -> Void) { + self.selectedColor = selectedColor + self.selectionHandler = selectionHandler + super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) + + stackView.orientation = .vertical + stackView.spacing = 6 + addSubview(stackView) + + for row in TerminalWindow.TabColor.paletteRows { + let rowStack = NSStackView() + rowStack.orientation = .horizontal + rowStack.spacing = 6 + + for color in row { + let button = makeButton(for: color) + rowStack.addArrangedSubview(button) + buttons.append(button) + } + + stackView.addArrangedSubview(rowStack) + } + + translatesAutoresizingMaskIntoConstraints = true + setFrameSize(intrinsicContentSize) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 190, height: 70) + } + + override func layout() { + super.layout() + stackView.frame = bounds.insetBy(dx: 10, dy: 6) + } + + private func makeButton(for color: TerminalWindow.TabColor) -> NSButton { + let button = NSButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyUpOrDown + button.image = color.swatchImage(selected: color == selectedColor) + button.setButtonType(.momentaryChange) + button.isBordered = false + button.focusRingType = .none + button.target = self + button.action = #selector(onSelectColor(_:)) + button.tag = color.rawValue + button.toolTip = color.localizedName + + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 24), + button.heightAnchor.constraint(equalToConstant: 24) + ]) + + return button + } + + @objc private func onSelectColor(_ sender: NSButton) { + guard let color = TerminalWindow.TabColor(rawValue: sender.tag) else { return } + selectedColor = color + updateButtonImages() + selectionHandler(color) + } + + private func updateButtonImages() { + for button in buttons { + guard let color = TerminalWindow.TabColor(rawValue: button.tag) else { continue } + button.image = color.swatchImage(selected: color == selectedColor) + } + } +} diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift index 7ddfa419f9..0166047c0f 100644 --- a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -10,20 +10,21 @@ extension NSMenu { /// - item: The menu item to insert. /// - action: The action selector to search for. The new item will be inserted after the first /// item with this action. - /// - Returns: `true` if the item was inserted after the specified action, `false` if the action - /// was not found and the item was not inserted. + /// - Returns: The index where the item was inserted, or `nil` if the action was not found + /// and the item was not inserted. @discardableResult - func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + func insertItem(_ item: NSMenuItem, after action: Selector) -> UInt? { if let identifier = item.identifier, let existing = items.first(where: { $0.identifier == identifier }) { removeItem(existing) } guard let idx = items.firstIndex(where: { $0.action == action }) else { - return false + return nil } - insertItem(item, at: idx + 1) - return true + let insertionIndex = idx + 1 + insertItem(item, at: insertionIndex) + return UInt(insertionIndex) } } From 51589a4e0248256ff38a4ca3169f8db849195a4e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 07:23:50 -0800 Subject: [PATCH 02/10] macos: move TerminalTabColor to its own file --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Terminal/TerminalController.swift | 6 +- .../Terminal/TerminalRestorable.swift | 2 +- .../Features/Terminal/TerminalTabColor.swift | 110 +++++++++++++++ .../Window Styles/TerminalWindow.swift | 131 ++---------------- 5 files changed, 126 insertions(+), 124 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalTabColor.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 31e812f0c9..eb5d706c33 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ Features/Terminal/ErrorView.swift, Features/Terminal/TerminalController.swift, Features/Terminal/TerminalRestorable.swift, + Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 1b2a31928b..7941ae22e6 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -55,7 +55,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr private(set) var derivedConfig: DerivedConfig /// The accent color that should be rendered for this tab. - var tabColor: TerminalWindow.TabColor = .none { + var tabColor: TerminalTabColor = .none { didSet { guard tabColor != oldValue else { return } if let terminalWindow = window as? TerminalWindow { @@ -863,7 +863,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? - let tabColor: TerminalWindow.TabColor + let tabColor: TerminalTabColor } convenience init(_ ghostty: Ghostty.App, @@ -1196,7 +1196,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } - func setTabColor(_ color: TerminalWindow.TabColor) { + func setTabColor(_ color: TerminalTabColor) { tabColor = color } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 852cad5813..c527a01c11 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -97,7 +97,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = TerminalWindow.TabColor(rawValue: state.tabColorRawValue) ?? .none + c.tabColor = TerminalTabColor(rawValue: state.tabColorRawValue) ?? .none // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift new file mode 100644 index 0000000000..3d2b9c4475 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -0,0 +1,110 @@ +import AppKit + +enum TerminalTabColor: Int, CaseIterable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + static let paletteRows: [[TerminalTabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .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 + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1361bbd1f7..9e329b76ea 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -30,7 +30,7 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() private var tabMenuObserver: NSObjectProtocol? = nil - private var tabColorSelection: TabColor = .none { + private var tabColorSelection: TerminalTabColor = .none { didSet { tabColorIndicator.tabColor = tabColorSelection } } @@ -46,7 +46,7 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - func display(tabColor: TabColor) { + func display(tabColor: TerminalTabColor) { tabColorSelection = tabColor } @@ -741,119 +741,10 @@ extension TerminalWindow { } -extension TerminalWindow { - enum TabColor: Int, CaseIterable { - case none - case blue - case purple - case pink - case red - case orange - case yellow - case green - case teal - case graphite - - static let paletteRows: [[TabColor]] = [ - [.none, .blue, .purple, .pink, .red], - [.orange, .yellow, .green, .teal, .graphite], - ] - - var localizedName: String { - switch self { - case .none: - return NSLocalizedString("None", comment: "Tab color option label") - case .blue: - return NSLocalizedString("Blue", comment: "Tab color option label") - case .purple: - return NSLocalizedString("Purple", comment: "Tab color option label") - case .pink: - return NSLocalizedString("Pink", comment: "Tab color option label") - case .red: - return NSLocalizedString("Red", comment: "Tab color option label") - case .orange: - return NSLocalizedString("Orange", comment: "Tab color option label") - case .yellow: - return NSLocalizedString("Yellow", comment: "Tab color option label") - case .green: - return NSLocalizedString("Green", comment: "Tab color option label") - case .teal: - return NSLocalizedString("Teal", comment: "Tab color option label") - case .graphite: - return NSLocalizedString("Graphite", comment: "Tab color option label") - } - } - - 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 - } - } - } -} private final class TabColorIndicator: NSView { - var tabColor: TerminalWindow.TabColor = .none { + var tabColor: TerminalTabColor = .none { didSet { updateAppearance() } } @@ -892,12 +783,12 @@ private final class TabColorIndicator: NSView { private final class TabColorPaletteView: NSView { private let stackView = NSStackView() - private var selectedColor: TerminalWindow.TabColor - private let selectionHandler: (TerminalWindow.TabColor) -> Void + private var selectedColor: TerminalTabColor + private let selectionHandler: (TerminalTabColor) -> Void private var buttons: [NSButton] = [] - init(selectedColor: TerminalWindow.TabColor, - selectionHandler: @escaping (TerminalWindow.TabColor) -> Void) { + init(selectedColor: TerminalTabColor, + selectionHandler: @escaping (TerminalTabColor) -> Void) { self.selectedColor = selectedColor self.selectionHandler = selectionHandler super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) @@ -906,7 +797,7 @@ private final class TabColorPaletteView: NSView { stackView.spacing = 6 addSubview(stackView) - for row in TerminalWindow.TabColor.paletteRows { + for row in TerminalTabColor.paletteRows { let rowStack = NSStackView() rowStack.orientation = .horizontal rowStack.spacing = 6 @@ -937,7 +828,7 @@ private final class TabColorPaletteView: NSView { stackView.frame = bounds.insetBy(dx: 10, dy: 6) } - private func makeButton(for color: TerminalWindow.TabColor) -> NSButton { + private func makeButton(for color: TerminalTabColor) -> NSButton { let button = NSButton() button.translatesAutoresizingMaskIntoConstraints = false button.imagePosition = .imageOnly @@ -960,7 +851,7 @@ private final class TabColorPaletteView: NSView { } @objc private func onSelectColor(_ sender: NSButton) { - guard let color = TerminalWindow.TabColor(rawValue: sender.tag) else { return } + guard let color = TerminalTabColor(rawValue: sender.tag) else { return } selectedColor = color updateButtonImages() selectionHandler(color) @@ -968,7 +859,7 @@ private final class TabColorPaletteView: NSView { private func updateButtonImages() { for button in buttons { - guard let color = TerminalWindow.TabColor(rawValue: button.tag) else { continue } + guard let color = TerminalTabColor(rawValue: button.tag) else { continue } button.image = color.swatchImage(selected: color == selectedColor) } } From 04913905a385e6c836783d7b70aa1cffed908293 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 07:24:46 -0800 Subject: [PATCH 03/10] macos: tab color is codable for restoration --- macos/Sources/Features/Terminal/TerminalRestorable.swift | 8 ++++---- macos/Sources/Features/Terminal/TerminalTabColor.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index c527a01c11..931739987f 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -9,13 +9,13 @@ class TerminalRestorableState: Codable { let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? - let tabColorRawValue: Int + let tabColor: TerminalTabColor init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode - self.tabColorRawValue = controller.tabColor.rawValue + self.tabColor = controller.tabColor } init?(coder aDecoder: NSCoder) { @@ -33,7 +33,7 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode - self.tabColorRawValue = v.value.tabColorRawValue + self.tabColor = v.value.tabColor } func encode(with coder: NSCoder) { @@ -97,7 +97,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = TerminalTabColor(rawValue: state.tabColorRawValue) ?? .none + c.tabColor = state.tabColor // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 3d2b9c4475..41e85eb7ad 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -1,6 +1,6 @@ import AppKit -enum TerminalTabColor: Int, CaseIterable { +enum TerminalTabColor: Int, CaseIterable, Codable { case none case blue case purple From a0089702f18cb2be1ce0c9ab7f358b1a07a4b61e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:18:25 -0800 Subject: [PATCH 04/10] macos: convert tab color view to SwiftUI --- .../Features/Terminal/TerminalTabColor.swift | 72 ++++++++++++ .../Window Styles/TerminalWindow.swift | 110 +++--------------- 2 files changed, 89 insertions(+), 93 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 41e85eb7ad..1af6aa10b5 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI enum TerminalTabColor: Int, CaseIterable, Codable { case none @@ -108,3 +109,74 @@ enum TerminalTabColor: Int, CaseIterable, Codable { } } } + +// 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(TerminalTabColor.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) + } + + /// 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) + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9e329b76ea..ff3814b036 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -349,7 +349,7 @@ class TerminalWindow: NSWindow { } removeTabColorSection(from: menu) - insertTabColorSection(into: menu, startingAt: Int(insertionIndex) + 1) + appendTabColorSection(to: menu) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -383,33 +383,29 @@ class TerminalWindow: NSWindow { } } - private func insertTabColorSection(into menu: NSMenu, startingAt index: Int) { + private func appendTabColorSection(to menu: NSMenu) { guard let terminalController else { return } - var insertionIndex = index - let separator = NSMenuItem.separator() separator.identifier = Self.tabColorSeparatorIdentifier - menu.insertItem(separator, at: insertionIndex) - insertionIndex += 1 + menu.addItem(separator) let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") let headerItem = NSMenuItem() headerItem.identifier = Self.tabColorHeaderIdentifier headerItem.title = headerTitle headerItem.isEnabled = false - menu.insertItem(headerItem, at: insertionIndex) - insertionIndex += 1 + headerItem.setImageIfDesired(systemSymbolName: "eyedropper") + menu.addItem(headerItem) let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier - let paletteView = TabColorPaletteView( + paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection ) { [weak terminalController] color in terminalController?.setTabColor(color) } - paletteItem.view = paletteView - menu.insertItem(paletteItem, at: insertionIndex) + menu.addItem(paletteItem) } // MARK: Tab Key Equivalents @@ -781,86 +777,14 @@ private final class TabColorIndicator: NSView { } } -private final class TabColorPaletteView: NSView { - private let stackView = NSStackView() - private var selectedColor: TerminalTabColor - private let selectionHandler: (TerminalTabColor) -> Void - private var buttons: [NSButton] = [] - - init(selectedColor: TerminalTabColor, - selectionHandler: @escaping (TerminalTabColor) -> Void) { - self.selectedColor = selectedColor - self.selectionHandler = selectionHandler - super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) - - stackView.orientation = .vertical - stackView.spacing = 6 - addSubview(stackView) - - for row in TerminalTabColor.paletteRows { - let rowStack = NSStackView() - rowStack.orientation = .horizontal - rowStack.spacing = 6 - - for color in row { - let button = makeButton(for: color) - rowStack.addArrangedSubview(button) - buttons.append(button) - } - - stackView.addArrangedSubview(rowStack) - } - - translatesAutoresizingMaskIntoConstraints = true - setFrameSize(intrinsicContentSize) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 190, height: 70) - } - - override func layout() { - super.layout() - stackView.frame = bounds.insetBy(dx: 10, dy: 6) - } - - private func makeButton(for color: TerminalTabColor) -> NSButton { - let button = NSButton() - button.translatesAutoresizingMaskIntoConstraints = false - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyUpOrDown - button.image = color.swatchImage(selected: color == selectedColor) - button.setButtonType(.momentaryChange) - button.isBordered = false - button.focusRingType = .none - button.target = self - button.action = #selector(onSelectColor(_:)) - button.tag = color.rawValue - button.toolTip = color.localizedName - - NSLayoutConstraint.activate([ - button.widthAnchor.constraint(equalToConstant: 24), - button.heightAnchor.constraint(equalToConstant: 24) - ]) - - return button - } - - @objc private func onSelectColor(_ sender: NSButton) { - guard let color = TerminalTabColor(rawValue: sender.tag) else { return } - selectedColor = color - updateButtonImages() - selectionHandler(color) - } - - private func updateButtonImages() { - for button in buttons { - guard let color = TerminalTabColor(rawValue: button.tag) else { continue } - button.image = color.swatchImage(selected: color == selectedColor) - } - } +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 } From f559bccc385acceb803a3edc07aa04146e3378ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:36:46 -0800 Subject: [PATCH 05/10] macos: clean up setting up the tab menu by using an NSMenu extension --- .../Window Styles/TerminalWindow.swift | 37 +++++-------------- .../Helpers/Extensions/NSMenu+Extension.swift | 12 ++++++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index ff3814b036..d0c0f750e8 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -330,14 +330,9 @@ class TerminalWindow: NSWindow { item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController item.setImageIfDesired(systemSymbolName: "xmark") - let insertionIndex: UInt - if let idx = menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) { - insertionIndex = idx - } else if let idx = menu.insertItem(item, after: NSSelectorFromString("performClose:")) { - insertionIndex = idx - } else { + if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, + menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { menu.addItem(item) - insertionIndex = UInt(menu.items.count - 1) } // Other close items should have the xmark to match Safari on macOS 26 @@ -348,8 +343,7 @@ class TerminalWindow: NSWindow { } } - removeTabColorSection(from: menu) - appendTabColorSection(to: menu) + appendTabColorSection(to: menu, target: targetController) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -367,33 +361,20 @@ class TerminalWindow: NSWindow { return !selectorNames.isDisjoint(with: tabContextSelectors) } - - private func removeTabColorSection(from menu: NSMenu) { - let identifiers: Set = [ + private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, Self.tabColorHeaderIdentifier, Self.tabColorPaletteIdentifier - ] - - for (index, item) in menu.items.enumerated().reversed() { - guard let identifier = item.identifier else { continue } - if identifiers.contains(identifier) { - menu.removeItem(at: index) - } - } - } - - private func appendTabColorSection(to menu: NSMenu) { - guard let terminalController else { return } + ]) let separator = NSMenuItem.separator() separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") let headerItem = NSMenuItem() headerItem.identifier = Self.tabColorHeaderIdentifier - headerItem.title = headerTitle + headerItem.title = "Tab Color" headerItem.isEnabled = false headerItem.setImageIfDesired(systemSymbolName: "eyedropper") menu.addItem(headerItem) @@ -402,8 +383,8 @@ class TerminalWindow: NSWindow { paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection - ) { [weak terminalController] color in - terminalController?.setTabColor(color) + ) { [weak target] color in + target?.setTabColor(color) } menu.addItem(paletteItem) } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift index 0166047c0f..82c0a3a411 100644 --- a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -27,4 +27,16 @@ extension NSMenu { 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) { + for (index, item) in items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + removeItem(at: index) + } + } + } } From 1073e89a0dfcf4fe042f6160e6cce93aaed7ad24 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:40:01 -0800 Subject: [PATCH 06/10] macos: move context menu stuff in terminal window down to an ext --- .../Window Styles/TerminalWindow.swift | 164 +++++++++--------- 1 file changed, 84 insertions(+), 80 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d0c0f750e8..7061655736 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -234,11 +234,6 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") - 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 findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -314,81 +309,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:")) == 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 = [ - "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: tabColorSelection - ) { [weak target] color in - target?.setTabColor(color) - } - menu.addItem(paletteItem) - } - // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -758,6 +678,90 @@ private final class TabColorIndicator: NSView { } } +// 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 = [ + "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: tabColorSelection + ) { [weak target] color in + target?.setTabColor(color) + } + menu.addItem(paletteItem) + } +} + private func makeTabColorPaletteView( selectedColor: TerminalTabColor, selectionHandler: @escaping (TerminalTabColor) -> Void From c83bf1de75a401b0eacc6417018c1f316dcf2b94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:50:12 -0800 Subject: [PATCH 07/10] macos: simplify terminal controller a bunch --- .../Terminal/TerminalController.swift | 24 ++++--------------- .../Terminal/TerminalRestorable.swift | 5 ++-- .../Window Styles/TerminalWindow.swift | 11 ++++++--- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7941ae22e6..a980723baf 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,16 +54,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig - /// The accent color that should be rendered for this tab. - var tabColor: TerminalTabColor = .none { - didSet { - guard tabColor != oldValue else { return } - if let terminalWindow = window as? TerminalWindow { - terminalWindow.display(tabColor: tabColor) - } - window?.invalidateRestorableState() - } - } /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -870,12 +860,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr with undoState: UndoState ) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) - self.tabColor = undoState.tabColor // Show the window and restore its frame showWindow(nil) if let window { 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 let tabGroup = undoState.tabGroup, @@ -912,7 +904,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), tabGroup: window.tabGroup, - tabColor: tabColor) + tabColor: (window as? TerminalWindow)?.tabColor ?? .none) } //MARK: - NSWindowController @@ -954,9 +946,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr delegate: self, )) - if let terminalWindow = window as? TerminalWindow { - terminalWindow.display(tabColor: tabColor) - } // If we have a default size, we want to apply it. if let defaultSize { switch (defaultSize) { @@ -1195,11 +1184,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - - func setTabColor(_ color: TerminalTabColor) { - tabColor = color - } - @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 931739987f..ce13f26200 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -15,7 +15,7 @@ class TerminalRestorableState: Codable { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode - self.tabColor = controller.tabColor + self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none } init?(coder aDecoder: NSCoder) { @@ -97,7 +97,8 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = state.tabColor + // Restore our tab color + (window as? TerminalWindow)?.tabColor = state.tabColor // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 7061655736..2828a9c567 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -46,8 +46,13 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - func display(tabColor: TerminalTabColor) { - tabColorSelection = tabColor + var tabColor: TerminalTabColor { + get { tabColorSelection } + set { + guard tabColorSelection != newValue else { return } + tabColorSelection = newValue + invalidateRestorableState() + } } // MARK: NSWindow Overrides @@ -756,7 +761,7 @@ extension TerminalWindow { paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection ) { [weak target] color in - target?.setTabColor(color) + (target?.window as? TerminalWindow)?.tabColor = color } menu.addItem(paletteItem) } From f71a25a62113b6646ae8ce68dae8382205ef829a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:53:56 -0800 Subject: [PATCH 08/10] macos: make the tab color indicator SwiftUI --- .../Window Styles/TerminalWindow.swift | 75 +++++++------------ 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2828a9c567..5874f354ec 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -25,14 +25,17 @@ class TerminalWindow: NSWindow { private let updateAccessory = NSTitlebarAccessoryViewController() /// Visual indicator that mirrors the selected tab color. - private let tabColorIndicator = TabColorIndicator() + private lazy var tabColorIndicator: NSHostingView = { + 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 - private var tabColorSelection: TerminalTabColor = .none { - didSet { tabColorIndicator.tabColor = tabColorSelection } - } /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -46,11 +49,12 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - var tabColor: TerminalTabColor { - get { tabColorSelection } - set { - guard tabColorSelection != newValue else { return } - tabColorSelection = newValue + /// 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() } } @@ -146,10 +150,7 @@ class TerminalWindow: NSWindow { // 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.translatesAutoresizingMaskIntoConstraints = false - tabColorIndicator.widthAnchor.constraint(equalToConstant: 12).isActive = true - tabColorIndicator.heightAnchor.constraint(equalToConstant: 4).isActive = true - tabColorIndicator.tabColor = tabColorSelection + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) let stackView = NSStackView() stackView.orientation = .horizontal @@ -643,42 +644,22 @@ extension TerminalWindow { } +/// A pill-shaped visual 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 - -private final class TabColorIndicator: NSView { - var tabColor: TerminalTabColor = .none { - didSet { updateAppearance() } - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - updateAppearance() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layout() { - super.layout() - updateAppearance() - } - - private func updateAppearance() { - guard let layer else { return } - layer.cornerRadius = bounds.height / 2 - + var body: some View { if let color = tabColor.displayColor { - alphaValue = 1 - layer.backgroundColor = color.cgColor - layer.borderWidth = 0 - layer.borderColor = nil + Capsule() + .fill(Color(color)) + .frame(width: 12, height: 4) } else { - alphaValue = 0 - layer.backgroundColor = NSColor.clear.cgColor - layer.borderWidth = 0 - layer.borderColor = nil + Capsule() + .fill(Color.clear) + .frame(width: 12, height: 4) + .hidden() } } } @@ -759,7 +740,7 @@ extension TerminalWindow { let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( - selectedColor: tabColorSelection + selectedColor: tabColor ) { [weak target] color in (target?.window as? TerminalWindow)?.tabColor = color } From 6332fb5c0197daf45c585b4752f85c8e55d86095 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:59:06 -0800 Subject: [PATCH 09/10] macos: some cleanup --- .../Sources/Features/Terminal/TerminalTabColor.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 1af6aa10b5..9059d3202a 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -13,11 +13,6 @@ enum TerminalTabColor: Int, CaseIterable, Codable { case teal case graphite - static let paletteRows: [[TerminalTabColor]] = [ - [.none, .blue, .purple, .pink, .red], - [.orange, .yellow, .green, .teal, .graphite], - ] - var localizedName: String { switch self { case .none: @@ -125,7 +120,7 @@ struct TabColorMenuView: View { var body: some View { VStack(alignment: .leading, spacing: 3) { - ForEach(TerminalTabColor.paletteRows, id: \.self) { row in + ForEach(Self.paletteRows, id: \.self) { row in HStack(spacing: 2) { ForEach(row, id: \.self) { color in TabColorSwatch( @@ -144,6 +139,11 @@ struct TabColorMenuView: View { .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. From 2331e178351c92363dcb7b100533ffe8aa18ea3d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 14:12:24 -0800 Subject: [PATCH 10/10] macos: change tab color label to circle --- .../Terminal/Window Styles/TerminalWindow.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 5874f354ec..3db1b275b9 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -644,7 +644,7 @@ extension TerminalWindow { } -/// A pill-shaped visual indicator displayed in the tab accessory view that shows +/// 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. @@ -652,13 +652,13 @@ private struct TabColorIndicatorView: View { var body: some View { if let color = tabColor.displayColor { - Capsule() + Circle() .fill(Color(color)) - .frame(width: 12, height: 4) + .frame(width: 6, height: 6) } else { - Capsule() + Circle() .fill(Color.clear) - .frame(width: 12, height: 4) + .frame(width: 6, height: 6) .hidden() } }