macOS: Customize Ghostty Icon (#3063)

This is some last minute flare to get into the 1.0 release. 😄 

This introduces some new configurations to let you customize the macOS
app icon at runtime. The runtime icon only applies to certain areas such
as the dock, cmd-tab, etc. It does not change the icon in Finder or on
disk since Apple requires those icons are bundled as files with the
signed app bundle (i.e. you can't change it later without resigning).

I still think this introduces a lot of fun into the macOS app. 😄 

I'm still finalizing the exact customization options that will exist for
the icon, so I documented this option as experimental. I'm feeling
pretty good about what's there but I may add other options in the
future.

## Demo

### Beige Yellow

```
macos-icon = custom-style
macos-icon-frame = beige
macos-icon-ghost-color = yellow
macos-icon-screen-color = dark goldenrod,dark khaki
```

![CleanShot 2024-12-21 at 19 55
49@2x](https://github.com/user-attachments/assets/50d782ae-f30c-4a14-afc9-150b98b1659c)

### Laker Nation

```
macos-icon = custom-style
macos-icon-ghost-color = yellow
macos-icon-screen-color = purple,maroon
```

![CleanShot 2024-12-21 at 19 56
26@2x](https://github.com/user-attachments/assets/f82c901c-27a2-4fdb-8c46-9d1a5d8e5f39)
This commit is contained in:
Mitchell Hashimoto
2024-12-21 20:07:42 -08:00
committed by GitHub
32 changed files with 745 additions and 46 deletions

View File

@ -333,6 +333,21 @@ typedef struct {
uint32_t cell_height_px; uint32_t cell_height_px;
} ghostty_surface_size_s; } ghostty_surface_size_s;
// Config types
// config.Color
typedef struct {
uint8_t r;
uint8_t g;
uint8_t b;
} ghostty_config_color_s;
// config.ColorList
typedef struct {
const ghostty_config_color_s* colors;
size_t len;
} ghostty_config_color_list_s;
// apprt.Target.Key // apprt.Target.Key
typedef enum { typedef enum {
GHOSTTY_TARGET_APP, GHOSTTY_TARGET_APP,

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "base.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "beige.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "chrome.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "plastic.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "crt-effect.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "ghosty.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "gloss.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "screen-dark.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "screen-mask.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -39,6 +39,10 @@
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */; };
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */; };
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; };
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; };
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; };
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; };
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
@ -124,6 +128,10 @@
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; }; A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = "<group>"; };
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = "<group>"; };
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; };
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
@ -237,6 +245,7 @@
A57D79252C9C8782001D522E /* Secure Input */, A57D79252C9C8782001D522E /* Secure Input */,
A534263E2A7DCC5800EBB7A2 /* Settings */, A534263E2A7DCC5800EBB7A2 /* Settings */,
A51BFC1C2B2FB5AB00E92F16 /* About */, A51BFC1C2B2FB5AB00E92F16 /* About */,
A54B0CE72D0CEC9800CBEFF8 /* Colorized Ghostty Icon */,
A51BFC292B30F69F00E92F16 /* Update */, A51BFC292B30F69F00E92F16 /* Update */,
); );
path = Features; path = Features;
@ -256,6 +265,7 @@
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
@ -307,6 +317,16 @@
path = macOS; path = macOS;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A54B0CE72D0CEC9800CBEFF8 /* Colorized Ghostty Icon */ = {
isa = PBXGroup;
children = (
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */,
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */,
A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */,
);
path = "Colorized Ghostty Icon";
sourceTree = "<group>";
};
A54CD6ED299BEB14008C95BB /* Sources */ = { A54CD6ED299BEB14008C95BB /* Sources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -580,8 +600,10 @@
files = ( files = (
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
@ -620,6 +642,8 @@
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */, A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */,

View File

@ -98,6 +98,13 @@ class AppDelegate: NSObject,
/// The observer for the app appearance. /// The observer for the app appearance.
private var appearanceObserver: NSKeyValueObservation? = nil private var appearanceObserver: NSKeyValueObservation? = nil
/// The custom app icon image that is currently in use.
@Published private(set) var appIcon: NSImage? = nil {
didSet {
NSApplication.shared.applicationIconImage = appIcon
}
}
override init() { override init() {
terminalManager = TerminalManager(ghostty) terminalManager = TerminalManager(ghostty)
updaterController = SPUStandardUpdaterController( updaterController = SPUStandardUpdaterController(
@ -519,6 +526,22 @@ class AppDelegate: NSObject,
} else { } else {
GlobalEventTap.shared.disable() GlobalEventTap.shared.disable()
} }
switch (config.macosIcon) {
case .official:
self.appIcon = nil
break
case .customStyle:
guard let ghostColor = config.macosIconGhostColor else { break }
guard let screenColors = config.macosIconScreenColor else { break }
guard let icon = ColorizedGhosttyIcon(
screenColors: screenColors,
ghostColor: ghostColor,
frame: config.macosIconFrame
).makeImage() else { break }
self.appIcon = icon
}
} }
/// Sync the appearance of our app with the theme specified in the config. /// Sync the appearance of our app with the theme specified in the config.

View File

@ -44,7 +44,7 @@ struct AboutView: View {
var body: some View { var body: some View {
VStack(alignment: .center) { VStack(alignment: .center) {
Image("AppIconImage") ghosttyIconImage()
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(height: 128) .frame(height: 128)

View File

@ -0,0 +1,55 @@
import Cocoa
struct ColorizedGhosttyIcon {
/// The colors that make up the gradient of the screen.
let screenColors: [NSColor]
/// The color of the ghost.
let ghostColor: NSColor
/// The frame type to use
let frame: Ghostty.MacOSIconFrame
/// Make a custom colorized ghostty icon.
func makeImage() -> NSImage? {
// All of our layers (not in order)
guard let screen = NSImage(named: "CustomIconScreen") else { return nil }
guard let screenMask = NSImage(named: "CustomIconScreenMask") else { return nil }
guard let ghost = NSImage(named: "CustomIconGhost") else { return nil }
guard let crt = NSImage(named: "CustomIconCRT") else { return nil }
guard let gloss = NSImage(named: "CustomIconGloss") else { return nil }
let baseName = switch (frame) {
case .aluminum: "CustomIconBaseAluminum"
case .beige: "CustomIconBaseBeige"
case .chrome: "CustomIconBaseChrome"
case .plastic: "CustomIconBasePlastic"
}
guard let base = NSImage(named: baseName) else { return nil }
// Apply our color in various ways to our layers.
// NOTE: These functions are not built-in, they're implemented as an extension
// to NSImage in NSImage+Extension.swift.
guard let screenGradient = screenMask.gradient(colors: screenColors) else { return nil }
guard let tintedGhost = ghost.tint(color: ghostColor) else { return nil }
// Combine our layers using the proper blending modes
return.combine(images: [
base,
screen,
screenGradient,
ghost,
tintedGhost,
crt,
gloss,
], blendingModes: [
.normal,
.normal,
.color,
.normal,
.color,
.overlay,
.normal,
])
}
}

View File

@ -0,0 +1,15 @@
import SwiftUI
extension View {
/// Returns the ghostty icon to use for views.
func ghosttyIconImage() -> Image {
#if os(macOS)
if let delegate = NSApplication.shared.delegate as? AppDelegate,
let nsImage = delegate.appIcon {
return Image(nsImage: nsImage)
}
#endif
return Image("AppIconImage")
}
}

View File

@ -0,0 +1,13 @@
import SwiftUI
import Cocoa
// For testing.
struct ColorizedGhosttyIconView: View {
var body: some View {
Image(nsImage: ColorizedGhosttyIcon(
screenColors: [.purple, .blue],
ghostColor: .yellow,
frame: .aluminum
).makeImage()!)
}
}

View File

@ -252,6 +252,46 @@ extension Ghostty {
return v return v
} }
var macosIcon: MacOSIcon {
let defaultValue = MacOSIcon.official
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil
let key = "macos-icon"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
guard let ptr = v else { return defaultValue }
let str = String(cString: ptr)
return MacOSIcon(rawValue: str) ?? defaultValue
}
var macosIconFrame: MacOSIconFrame {
let defaultValue = MacOSIconFrame.aluminum
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil
let key = "macos-icon-frame"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
guard let ptr = v else { return defaultValue }
let str = String(cString: ptr)
return MacOSIconFrame(rawValue: str) ?? defaultValue
}
var macosIconGhostColor: OSColor? {
guard let config = self.config else { return nil }
var v: ghostty_config_color_s = .init()
let key = "macos-icon-ghost-color"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
return .init(ghostty: v)
}
var macosIconScreenColor: [OSColor]? {
guard let config = self.config else { return nil }
var v: ghostty_config_color_list_s = .init()
let key = "macos-icon-screen-color"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
guard v.len > 0 else { return nil }
let buffer = UnsafeBufferPointer(start: v.colors, count: v.len)
return buffer.map { .init(ghostty: $0) }
}
var focusFollowsMouse : Bool { var focusFollowsMouse : Bool {
guard let config = self.config else { return false } guard let config = self.config else { return false }
var v = false; var v = false;
@ -261,9 +301,9 @@ extension Ghostty {
} }
var backgroundColor: Color { var backgroundColor: Color {
var rgb: UInt32 = 0 var color: ghostty_config_color_s = .init();
let bg_key = "background" let bg_key = "background"
if (!ghostty_config_get(config, &rgb, bg_key, UInt(bg_key.count))) { if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.count))) {
#if os(macOS) #if os(macOS)
return Color(NSColor.windowBackgroundColor) return Color(NSColor.windowBackgroundColor)
#elseif os(iOS) #elseif os(iOS)
@ -273,14 +313,10 @@ extension Ghostty {
#endif #endif
} }
let red = Double(rgb & 0xff) return .init(
let green = Double((rgb >> 8) & 0xff) red: Double(color.r) / 255,
let blue = Double((rgb >> 16) & 0xff) green: Double(color.g) / 255,
blue: Double(color.b) / 255
return Color(
red: red / 255,
green: green / 255,
blue: blue / 255
) )
} }
@ -311,21 +347,17 @@ extension Ghostty {
var unfocusedSplitFill: Color { var unfocusedSplitFill: Color {
guard let config = self.config else { return .white } guard let config = self.config else { return .white }
var rgb: UInt32 = 16777215 // white default var color: ghostty_config_color_s = .init();
let key = "unfocused-split-fill" let key = "unfocused-split-fill"
if (!ghostty_config_get(config, &rgb, key, UInt(key.count))) { if (!ghostty_config_get(config, &color, key, UInt(key.count))) {
let bg_key = "background" let bg_key = "background"
_ = ghostty_config_get(config, &rgb, bg_key, UInt(bg_key.count)); _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.count));
} }
let red = Double(rgb & 0xff) return .init(
let green = Double((rgb >> 8) & 0xff) red: Double(color.r),
let blue = Double((rgb >> 16) & 0xff) green: Double(color.g) / 255,
blue: Double(color.b) / 255
return Color(
red: red / 255,
green: green / 255,
blue: blue / 255
) )
} }

View File

@ -194,7 +194,21 @@ extension Ghostty {
} }
} }
} }
/// macos-icon
enum MacOSIcon: String {
case official
case customStyle = "custom-style"
}
/// macos-icon-frame
enum MacOSIconFrame: String {
case aluminum
case beige
case plastic
case chrome
}
/// Enum for the macos-titlebar-proxy-icon config option /// Enum for the macos-titlebar-proxy-icon config option
enum MacOSTitlebarProxyIcon: String { enum MacOSTitlebarProxyIcon: String {
case visible case visible

View File

@ -0,0 +1,90 @@
import Cocoa
extension NSImage {
/// Combine multiple images with the given blend modes. This is useful given a set
/// of layers to create a final rasterized image.
static func combine(images: [NSImage], blendingModes: [CGBlendMode]) -> NSImage? {
guard images.count == blendingModes.count else { return nil }
guard images.count > 0 else { return nil }
// The final size will be the same size as our first image.
let size = images.first!.size
// Create a bitmap context manually
guard let bitmapContext = CGContext(
data: nil,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
// Clear the context
bitmapContext.setFillColor(.clear)
bitmapContext.fill(.init(origin: .zero, size: size))
// Draw each image with its corresponding blend mode
for (index, image) in images.enumerated() {
guard let cgImage = image.cgImage(
forProposedRect: nil,
context: nil,
hints: nil
) else { return nil }
let blendMode = blendingModes[index]
bitmapContext.setBlendMode(blendMode)
bitmapContext.draw(cgImage, in: CGRect(origin: .zero, size: size))
}
// Create a CGImage from the context
guard let combinedCGImage = bitmapContext.makeImage() else { return nil }
// Wrap the CGImage in an NSImage
return NSImage(cgImage: combinedCGImage, size: size)
}
/// Apply a gradient onto this image, using this image as a mask.
func gradient(colors: [NSColor]) -> NSImage? {
let resultImage = NSImage(size: size)
resultImage.lockFocus()
defer { resultImage.unlockFocus() }
// Draw the gradient
guard let gradient = NSGradient(colors: colors) else { return nil }
gradient.draw(in: .init(origin: .zero, size: size), angle: 90)
// Apply the mask
draw(at: .zero, from: .zero, operation: .destinationIn, fraction: 1.0)
return resultImage
}
// Tint an NSImage with the given color by applying a basic fill on top of it.
func tint(color: NSColor) -> NSImage? {
// Create a new image with the same size as the base image
let newImage = NSImage(size: size)
// Draw into the new image
newImage.lockFocus()
defer { newImage.unlockFocus() }
// Set up the drawing context
guard let context = NSGraphicsContext.current?.cgContext else { return nil }
defer { context.restoreGState() }
// Draw the base image
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
context.draw(cgImage, in: .init(origin: .zero, size: size))
// Set the tint color and blend mode
context.setFillColor(color.cgColor)
context.setBlendMode(.sourceAtop)
// Apply the tint color over the entire image
context.fill(.init(origin: .zero, size: size))
return newImage
}
}

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import GhosttyKit
extension OSColor { extension OSColor {
var isLightColor: Bool { var isLightColor: Bool {
@ -47,6 +48,37 @@ extension OSColor {
#endif #endif
} }
/// Create an OSColor from a hex string.
convenience init?(hex: String) {
var cleanedHex = hex.trimmingCharacters(in: .whitespacesAndNewlines)
// Remove `#` if present
if cleanedHex.hasPrefix("#") {
cleanedHex.removeFirst()
}
guard cleanedHex.count == 6 || cleanedHex.count == 8 else { return nil }
let scanner = Scanner(string: cleanedHex)
var hexNumber: UInt64 = 0
guard scanner.scanHexInt64(&hexNumber) else { return nil }
let red, green, blue, alpha: CGFloat
if cleanedHex.count == 8 {
alpha = CGFloat((hexNumber & 0xFF000000) >> 24) / 255
red = CGFloat((hexNumber & 0x00FF0000) >> 16) / 255
green = CGFloat((hexNumber & 0x0000FF00) >> 8) / 255
blue = CGFloat(hexNumber & 0x000000FF) / 255
} else { // 6 characters
alpha = 1.0
red = CGFloat((hexNumber & 0xFF0000) >> 16) / 255
green = CGFloat((hexNumber & 0x00FF00) >> 8) / 255
blue = CGFloat(hexNumber & 0x0000FF) / 255
}
self.init(red: red, green: green, blue: blue, alpha: alpha)
}
func darken(by amount: CGFloat) -> OSColor { func darken(by amount: CGFloat) -> OSColor {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
self.getHue(&h, saturation: &s, brightness: &b, alpha: &a) self.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
@ -58,3 +90,15 @@ extension OSColor {
) )
} }
} }
// MARK: Ghostty Types
extension OSColor {
/// Create a color from a Ghostty color.
convenience init(ghostty: ghostty_config_color_s) {
let red = Double(ghostty.r) / 255
let green = Double(ghostty.g) / 255
let blue = Double(ghostty.b) / 255
self.init(red: red, green: green, blue: blue, alpha: 1)
}
}

View File

@ -1675,6 +1675,73 @@ keybind: Keybinds = .{},
/// you may want to disable it. /// you may want to disable it.
@"macos-secure-input-indication": bool = true, @"macos-secure-input-indication": bool = true,
/// Customize the macOS app icon.
///
/// This only affects the icon that appears in the dock, application
/// switcher, etc. This does not affect the icon in Finder because
/// that is controlled by a hardcoded value in the signed application
/// bundle and can't be changed at runtime. For more details on what
/// exactly is affected, see the `NSApplication.icon` Apple documentation;
/// that is the API that is being used to set the icon.
///
/// Valid values:
///
/// * `official` - Use the official Ghostty icon.
/// * `custom-style` - Use the official Ghostty icon but with custom
/// styles applied to various layers. The custom styles must be
/// specified using the additional `macos-icon`-prefixed configurations.
/// The `macos-icon-ghost-color` and `macos-icon-screen-color`
/// configurations are required for this style.
///
/// WARNING: The `custom-style` option is _experimental_. We may change
/// the format of the custom styles in the future. We're still finalizing
/// the exact layers and customization options that will be available.
///
/// Other caveats:
///
/// * The icon in the update dialog will always be the official icon.
/// This is because the update dialog is managed through a
/// separate framework and cannot be customized without significant
/// effort.
///
@"macos-icon": MacAppIcon = .official,
/// The material to use for the frame of the macOS app icon.
///
/// Valid values:
///
/// * `aluminum` - A brushed aluminum frame. This is the default.
/// * `beige` - A classic 90's computer beige frame.
/// * `plastic` - A glossy, dark plastic frame.
/// * `chrome` - A shiny chrome frame.
///
/// This only has an effect when `macos-icon` is set to `custom-style`.
@"macos-icon-frame": MacAppIconFrame = .aluminum,
/// The color of the ghost in the macOS app icon.
///
/// The format of the color is the same as the `background` configuration;
/// see that for more information.
///
/// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`.
///
/// This only has an effect when `macos-icon` is set to `custom-style`.
@"macos-icon-ghost-color": ?Color = null,
/// The color of the screen in the macOS app icon.
///
/// The screen is a gradient so you can specify multiple colors that
/// make up the gradient. Colors should be separated by commas. The
/// format of the color is the same as the `background` configuration;
/// see that for more information.
///
/// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`.
///
/// This only has an effect when `macos-icon` is set to `custom-style`.
@"macos-icon-screen-color": ?ColorList = null,
/// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// Put every surface (tab, split, window) into a dedicated Linux cgroup.
/// ///
/// This makes it so that resource management can be done on a per-surface /// This makes it so that resource management can be done on a per-surface
@ -3529,11 +3596,22 @@ pub const WindowPaddingColor = enum {
/// ///
/// This is a packed struct so that the C API to read color values just /// This is a packed struct so that the C API to read color values just
/// works by setting it to a C integer. /// works by setting it to a C integer.
pub const Color = packed struct(u24) { pub const Color = struct {
r: u8, r: u8,
g: u8, g: u8,
b: u8, b: u8,
/// ghostty_config_color_s
pub const C = extern struct {
r: u8,
g: u8,
b: u8,
};
pub fn cval(self: Color) Color.C {
return .{ .r = self.r, .g = self.g, .b = self.b };
}
/// Convert this to the terminal RGB struct /// Convert this to the terminal RGB struct
pub fn toTerminalRGB(self: Color) terminal.color.RGB { pub fn toTerminalRGB(self: Color) terminal.color.RGB {
return .{ .r = self.r, .g = self.g, .b = self.b }; return .{ .r = self.r, .g = self.g, .b = self.b };
@ -3566,14 +3644,19 @@ pub const Color = packed struct(u24) {
var buf: [128]u8 = undefined; var buf: [128]u8 = undefined;
try formatter.formatEntry( try formatter.formatEntry(
[]const u8, []const u8,
std.fmt.bufPrint( try self.formatBuf(&buf),
&buf,
"#{x:0>2}{x:0>2}{x:0>2}",
.{ self.r, self.g, self.b },
) catch return error.OutOfMemory,
); );
} }
/// Format the color as a string.
pub fn formatBuf(self: Color, buf: []u8) Allocator.Error![]const u8 {
return std.fmt.bufPrint(
buf,
"#{x:0>2}{x:0>2}{x:0>2}",
.{ self.r, self.g, self.b },
) catch error.OutOfMemory;
}
/// fromHex parses a color from a hex value such as #RRGGBB. The "#" /// fromHex parses a color from a hex value such as #RRGGBB. The "#"
/// is optional. /// is optional.
pub fn fromHex(input: []const u8) !Color { pub fn fromHex(input: []const u8) !Color {
@ -3626,6 +3709,133 @@ pub const Color = packed struct(u24) {
} }
}; };
pub const ColorList = struct {
const Self = @This();
colors: std.ArrayListUnmanaged(Color) = .{},
colors_c: std.ArrayListUnmanaged(Color.C) = .{},
/// ghostty_config_color_list_s
pub const C = extern struct {
colors: [*]Color.C,
len: usize,
};
pub fn cval(self: *const Self) C {
return .{
.colors = self.colors_c.items.ptr,
.len = self.colors_c.items.len,
};
}
pub fn parseCLI(
self: *Self,
alloc: Allocator,
input_: ?[]const u8,
) !void {
const input = input_ orelse return error.ValueRequired;
if (input.len == 0) return error.ValueRequired;
// Always reset on parse
self.* = .{};
// Split the input by commas and parse each color
var it = std.mem.tokenizeScalar(u8, input, ',');
var count: usize = 0;
while (it.next()) |raw| {
count += 1;
if (count > 64) return error.InvalidValue;
const color = try Color.parseCLI(raw);
try self.colors.append(alloc, color);
try self.colors_c.append(alloc, color.cval());
}
// If no colors were parsed, we need to return an error
if (self.colors.items.len == 0) return error.InvalidValue;
assert(self.colors.items.len == self.colors_c.items.len);
}
pub fn clone(
self: *const Self,
alloc: Allocator,
) Allocator.Error!Self {
return .{
.colors = try self.colors.clone(alloc),
};
}
/// Compare if two of our value are requal. Required by Config.
pub fn equal(self: Self, other: Self) bool {
const itemsA = self.colors.items;
const itemsB = other.colors.items;
if (itemsA.len != itemsB.len) return false;
for (itemsA, itemsB) |a, b| {
if (!a.equal(b)) return false;
} else return true;
}
/// Used by Formatter
pub fn formatEntry(
self: Self,
formatter: anytype,
) !void {
// If no items, we want to render an empty field.
if (self.colors.items.len == 0) {
try formatter.formatEntry(void, {});
return;
}
// Build up the value of our config. Our buffer size should be
// sized to contain all possible maximum values.
var buf: [1024]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
var writer = fbs.writer();
for (self.colors.items, 0..) |color, i| {
var color_buf: [128]u8 = undefined;
const color_str = try color.formatBuf(&color_buf);
if (i != 0) writer.writeByte(',') catch return error.OutOfMemory;
writer.writeAll(color_str) catch return error.OutOfMemory;
}
try formatter.formatEntry(
[]const u8,
fbs.getWritten(),
);
}
test "parseCLI" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var p: Self = .{};
try p.parseCLI(alloc, "black,white");
try testing.expectEqual(2, p.colors.items.len);
// Error cases
try testing.expectError(error.ValueRequired, p.parseCLI(alloc, null));
try testing.expectError(error.InvalidValue, p.parseCLI(alloc, " "));
}
test "format" {
const testing = std.testing;
var buf = std.ArrayList(u8).init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var p: Self = .{};
try p.parseCLI(alloc, "black,white");
try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = #000000,#ffffff\n", buf.items);
}
};
/// Palette is the 256 color palette for 256-color mode. This is still /// Palette is the 256 color palette for 256-color mode. This is still
/// used by many terminal applications. /// used by many terminal applications.
pub const Palette = struct { pub const Palette = struct {
@ -3742,7 +3952,7 @@ pub const RepeatableString = struct {
return .{ .list = list }; return .{ .list = list };
} }
/// The number of itemsin the list /// The number of items in the list
pub fn count(self: Self) usize { pub fn count(self: Self) usize {
return self.list.items.len; return self.list.items.len;
} }
@ -4906,11 +5116,29 @@ pub const MacTitlebarStyle = enum {
}; };
/// See macos-titlebar-proxy-icon /// See macos-titlebar-proxy-icon
pub const MacTitlebarProxyIcon: type = enum { pub const MacTitlebarProxyIcon = enum {
visible, visible,
hidden, hidden,
}; };
/// See macos-icon
///
/// Note: future versions of Ghostty can support a custom icon with
/// path by changing this to a tagged union, which doesn't change our
/// format at all.
pub const MacAppIcon = enum {
official,
@"custom-style",
};
/// See macos-icon-frame
pub const MacAppIconFrame = enum {
aluminum,
beige,
plastic,
chrome,
};
/// See gtk-single-instance /// See gtk-single-instance
pub const GtkSingleInstance = enum { pub const GtkSingleInstance = enum {
desktop, desktop,
@ -5246,9 +5474,8 @@ pub const Duration = struct {
} }
} }
pub fn c_get(self: Duration, ptr_raw: *anyopaque) void { pub fn cval(self: Duration) usize {
const ptr: *usize = @ptrCast(@alignCast(ptr_raw)); return @intCast(self.asMilliseconds());
ptr.* = @intCast(self.asMilliseconds());
} }
/// Convenience function to convert to milliseconds since many OS and /// Convenience function to convert to milliseconds since many OS and

View File

@ -60,9 +60,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool {
}, },
.Struct => |info| { .Struct => |info| {
// If the struct implements c_get then we call that // If the struct implements cval then we call then.
if (@hasDecl(@TypeOf(value), "c_get")) { if (@hasDecl(T, "cval")) {
value.c_get(ptr_raw); const PtrT = @typeInfo(@TypeOf(T.cval)).Fn.return_type.?;
const ptr: *PtrT = @ptrCast(@alignCast(ptr_raw));
ptr.* = value.cval();
return true; return true;
} }
@ -100,7 +102,7 @@ fn fieldByKey(self: *const Config, comptime k: Key) Value(k) {
return @field(self, field.name); return @field(self, field.name);
} }
test "u8" { test "c_get: u8" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -113,7 +115,7 @@ test "u8" {
try testing.expectEqual(@as(f32, 24), cval); try testing.expectEqual(@as(f32, 24), cval);
} }
test "enum" { test "c_get: enum" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -128,7 +130,7 @@ test "enum" {
try testing.expectEqualStrings("dark", str); try testing.expectEqualStrings("dark", str);
} }
test "color" { test "c_get: color" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -136,12 +138,14 @@ test "color" {
defer c.deinit(); defer c.deinit();
c.background = .{ .r = 255, .g = 0, .b = 0 }; c.background = .{ .r = 255, .g = 0, .b = 0 };
var cval: c_uint = undefined; var cval: Color.C = undefined;
try testing.expect(get(&c, .background, @ptrCast(&cval))); try testing.expect(get(&c, .background, @ptrCast(&cval)));
try testing.expectEqual(@as(c_uint, 255), cval); try testing.expectEqual(255, cval.r);
try testing.expectEqual(0, cval.g);
try testing.expectEqual(0, cval.b);
} }
test "optional" { test "c_get: optional" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -150,14 +154,16 @@ test "optional" {
{ {
c.@"unfocused-split-fill" = null; c.@"unfocused-split-fill" = null;
var cval: c_uint = undefined; var cval: Color.C = undefined;
try testing.expect(!get(&c, .@"unfocused-split-fill", @ptrCast(&cval))); try testing.expect(!get(&c, .@"unfocused-split-fill", @ptrCast(&cval)));
} }
{ {
c.@"unfocused-split-fill" = .{ .r = 255, .g = 0, .b = 0 }; c.@"unfocused-split-fill" = .{ .r = 255, .g = 0, .b = 0 };
var cval: c_uint = undefined; var cval: Color.C = undefined;
try testing.expect(get(&c, .@"unfocused-split-fill", @ptrCast(&cval))); try testing.expect(get(&c, .@"unfocused-split-fill", @ptrCast(&cval)));
try testing.expectEqual(@as(c_uint, 255), cval); try testing.expectEqual(255, cval.r);
try testing.expectEqual(0, cval.g);
try testing.expectEqual(0, cval.b);
} }
} }