From a0089702f18cb2be1ce0c9ab7f358b1a07a4b61e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:18:25 -0800 Subject: [PATCH] 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 41e85eb7a..1af6aa10b 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 9e329b76e..ff3814b03 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 }