diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..a51859dc5 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -787,6 +787,7 @@ typedef enum { GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2, GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3, + GHOSTTY_ACTION_COLOR_KIND_TAB = -4, } ghostty_action_color_kind_e; // apprt.action.ColorChange diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 09e369d4a..97cab5825 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -140,7 +140,6 @@ struct TerminalCommandPaletteView: View { guard let window = controller.window else { return [] } let color = (window as? TerminalWindow)?.tabColor - let displayColor = color != TerminalTabColor.none ? color : nil return controller.surfaceTree.map { surface in let terminalTitle = surface.title.isEmpty ? window.title : surface.title @@ -163,7 +162,7 @@ struct TerminalCommandPaletteView: View { title: "Focus: \(displayTitle)", subtitle: subtitle, leadingIcon: "rectangle.on.rectangle", - leadingColor: displayColor?.displayColor.map { Color($0) }, + leadingColor: color?.color.map { Color($0) }, sortKey: AnySortKey(ObjectIdentifier(surface)) ) { NotificationCenter.default.post( diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 2879822b3..8f493992f 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -1,7 +1,7 @@ import AppKit import SwiftUI -enum TerminalTabColor: Int, CaseIterable, Codable { +enum TerminalTabColorPreset: Int, CaseIterable, Codable { case none case blue case purple @@ -67,6 +67,8 @@ enum TerminalTabColor: Int, CaseIterable, Codable { } } + var tabColor: TerminalTabColor { TerminalTabColor(color: displayColor) } + func swatchImage(selected: Bool) -> NSImage { let size = NSSize(width: 18, height: 18) return NSImage(size: size, flipped: false) { rect in @@ -105,15 +107,57 @@ enum TerminalTabColor: Int, CaseIterable, Codable { } } +struct TerminalTabColor: Equatable, Codable { + let color: NSColor? + + static let none = TerminalTabColor(color: nil) + + init(color: NSColor?) { + self.color = color + } + + init(color: Color) { + self.color = NSColor(color) + } + + // MARK: Codable + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + // Backward compatibility: attempt to decode the previously stored preset index. + if let preset = try? container.decode(TerminalTabColorPreset.self) { + self.color = preset.displayColor + return + } + let hex = try container.decode(String.self) + self.color = NSColor(hex: hex) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(color?.hexString ?? "") + } + + // MARK: Equatable + + static func == (lhs: TerminalTabColor, rhs: TerminalTabColor) -> Bool { + lhs.color == rhs.color + } + + var matchingPreset: TerminalTabColorPreset { + TerminalTabColorPreset.allCases.first { $0.displayColor == color } ?? .none + } +} + // 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 + @State private var currentSelection: TerminalTabColorPreset + let onSelect: (TerminalTabColorPreset) -> Void - init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) { + init(selectedColor: TerminalTabColorPreset, onSelect: @escaping (TerminalTabColorPreset) -> Void) { self._currentSelection = State(initialValue: selectedColor) self.onSelect = onSelect } @@ -143,7 +187,7 @@ struct TabColorMenuView: View { .padding(.bottom, 4) } - static let paletteRows: [[TerminalTabColor]] = [ + static let paletteRows: [[TerminalTabColorPreset]] = [ [.none, .blue, .purple, .pink, .red], [.orange, .yellow, .green, .teal, .graphite], ] @@ -161,7 +205,7 @@ struct TabColorMenuView: View { /// A single color swatch button in the tab color palette. private struct TabColorSwatch: View { - let color: TerminalTabColor + let color: TerminalTabColorPreset let isSelected: Bool let action: () -> Void diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index ac1d2b881..4af8ceb3a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -686,7 +686,7 @@ private struct TabColorIndicatorView: View { let tabColor: TerminalTabColor var body: some View { - if let color = tabColor.displayColor { + if let color = tabColor.color { Circle() .fill(Color(color)) .frame(width: 6, height: 6) @@ -777,17 +777,17 @@ extension TerminalWindow { let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( - selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none + selectedColor: (target?.window as? TerminalWindow)?.tabColor.matchingPreset ?? .none ) { [weak target] color in - (target?.window as? TerminalWindow)?.tabColor = color + (target?.window as? TerminalWindow)?.tabColor = color.tabColor } menu.addItem(paletteItem) } } private func makeTabColorPaletteView( - selectedColor: TerminalTabColor, - selectionHandler: @escaping (TerminalTabColor) -> Void + selectedColor: TerminalTabColorPreset, + selectionHandler: @escaping (TerminalTabColorPreset) -> Void ) -> NSView { let hostingView = NSHostingView(rootView: TabColorMenuView( selectedColor: selectedColor, diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index f3842fc56..2565a8def 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -14,6 +14,7 @@ extension Ghostty.Action { case foreground case background case cursor + case tab case palette(index: UInt8) } @@ -25,6 +26,8 @@ extension Ghostty.Action { self.kind = .background case GHOSTTY_ACTION_COLOR_KIND_CURSOR: self.kind = .cursor + case GHOSTTY_ACTION_COLOR_KIND_TAB: + self.kind = .tab default: self.kind = .palette(index: UInt8(c.kind.rawValue)) } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 8c22c0cdf..e5b77c7a5 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -751,6 +751,11 @@ extension Ghostty { self?.backgroundColor = change.color } + case .tab: + DispatchQueue.main.async { [weak self] in + (self?.window as? TerminalWindow)?.tabColor = TerminalTabColor(color: change.color) + } + default: // We don't do anything for the other colors yet. break diff --git a/src/Surface.zig b/src/Surface.zig index 410f717b0..cd09847ed 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5547,6 +5547,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .io => self.queueIo(.{ .crash = {} }, .unlocked), }, + .colorize_tab => |hex| { + const color = try terminal.color.RGB.parse(hex); + return try self.rt_app.performAction(.{ .surface = self }, .color_change, .{ + .kind = .tab, + .r = color.r, + .g = color.g, + .b = color.b, + }); + }, + .adjust_selection => |direction| { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f6865af83..09a657f40 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -845,6 +845,7 @@ pub const ColorKind = enum(c_int) { foreground = -1, background = -2, cursor = -3, + tab = -4, // 0+ values indicate a palette index _, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d60f2933b..b9b96ac57 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -970,6 +970,29 @@ pub const Action = union(enum) { /// crash: CrashThread, + /// Mark the current tab with the color defined. + /// This is equivalent to right-click a tab and selecting one of the predefine color. + /// + /// For example, `colorize_tab:#632CA6` + /// + /// Any of the following forms are accepted: + /// + /// - rgb:// + /// , , := h | hh | hhh | hhhh + /// where `h` is a single hexadecimal digit. + /// + /// - rgbi:// + /// where , , and are floating point values between + /// 0.0 and 1.0 (inclusive). + /// + /// - #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb + /// where `r`, `g`, and `b` are a single hexadecimal digit. + /// These specify a color with 4, 8, 12, and 16 bits of precision + /// per color channel. + /// + /// Only implemented on macOS. + colorize_tab: []const u8, + pub const Key = @typeInfo(Action).@"union".tag_type.?; /// Make this a valid gobject if we're in a GTK environment. @@ -1427,6 +1450,7 @@ pub const Action = union(enum) { .resize_split, .equalize_splits, .inspector, + .colorize_tab, => .surface, }; } diff --git a/src/input/command.zig b/src/input/command.zig index ac048eec0..7a247383f 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -706,6 +706,7 @@ fn actionCommands(action: Action.Key) []const Command { .deactivate_all_key_tables, .end_key_sequence, .crash, + .colorize_tab, => comptime &.{}, // No commands because I'm not sure they make sense in a command