diff --git a/include/ghostty.h b/include/ghostty.h index d2e59b09f..61c3aad32 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -333,6 +333,21 @@ typedef struct { uint32_t cell_height_px; } 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 typedef enum { GHOSTTY_TARGET_APP, diff --git a/macos/Assets.xcassets/Custom Icon/Contents.json b/macos/Assets.xcassets/Custom Icon/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBaseAluminum.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconBaseAluminum.imageset/Contents.json new file mode 100644 index 000000000..cc28dc42e --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconBaseAluminum.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "base.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBaseAluminum.imageset/base.png b/macos/Assets.xcassets/Custom Icon/CustomIconBaseAluminum.imageset/base.png new file mode 100644 index 000000000..2c6f3a34b Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconBaseAluminum.imageset/base.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBaseBeige.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconBaseBeige.imageset/Contents.json new file mode 100644 index 000000000..db7850446 --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconBaseBeige.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "beige.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBaseBeige.imageset/beige.png b/macos/Assets.xcassets/Custom Icon/CustomIconBaseBeige.imageset/beige.png new file mode 100644 index 000000000..20c081611 Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconBaseBeige.imageset/beige.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBaseChrome.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconBaseChrome.imageset/Contents.json new file mode 100644 index 000000000..3889bd273 --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconBaseChrome.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chrome.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBaseChrome.imageset/chrome.png b/macos/Assets.xcassets/Custom Icon/CustomIconBaseChrome.imageset/chrome.png new file mode 100644 index 000000000..66f2f86dd Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconBaseChrome.imageset/chrome.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBasePlastic.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconBasePlastic.imageset/Contents.json new file mode 100644 index 000000000..37ca4585c --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconBasePlastic.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "plastic.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconBasePlastic.imageset/plastic.png b/macos/Assets.xcassets/Custom Icon/CustomIconBasePlastic.imageset/plastic.png new file mode 100644 index 000000000..a73470481 Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconBasePlastic.imageset/plastic.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconCRT.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconCRT.imageset/Contents.json new file mode 100644 index 000000000..ec32cf191 --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconCRT.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "crt-effect.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconCRT.imageset/crt-effect.png b/macos/Assets.xcassets/Custom Icon/CustomIconCRT.imageset/crt-effect.png new file mode 100644 index 000000000..c032c8400 Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconCRT.imageset/crt-effect.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconGhost.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconGhost.imageset/Contents.json new file mode 100644 index 000000000..286506fd9 --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconGhost.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ghosty.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconGhost.imageset/ghosty.png b/macos/Assets.xcassets/Custom Icon/CustomIconGhost.imageset/ghosty.png new file mode 100644 index 000000000..5df106fe6 Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconGhost.imageset/ghosty.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconGloss.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconGloss.imageset/Contents.json new file mode 100644 index 000000000..ed8e4328f --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconGloss.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "gloss.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconGloss.imageset/gloss.png b/macos/Assets.xcassets/Custom Icon/CustomIconGloss.imageset/gloss.png new file mode 100644 index 000000000..f57bc72a0 Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconGloss.imageset/gloss.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconScreen.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconScreen.imageset/Contents.json new file mode 100644 index 000000000..6d6a03eaf --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconScreen.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "screen-dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconScreen.imageset/screen-dark.png b/macos/Assets.xcassets/Custom Icon/CustomIconScreen.imageset/screen-dark.png new file mode 100644 index 000000000..2995fb9da Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconScreen.imageset/screen-dark.png differ diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconScreenMask.imageset/Contents.json b/macos/Assets.xcassets/Custom Icon/CustomIconScreenMask.imageset/Contents.json new file mode 100644 index 000000000..083891019 --- /dev/null +++ b/macos/Assets.xcassets/Custom Icon/CustomIconScreenMask.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "screen-mask.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/macos/Assets.xcassets/Custom Icon/CustomIconScreenMask.imageset/screen-mask.png b/macos/Assets.xcassets/Custom Icon/CustomIconScreenMask.imageset/screen-mask.png new file mode 100644 index 000000000..acc431862 Binary files /dev/null and b/macos/Assets.xcassets/Custom Icon/CustomIconScreenMask.imageset/screen-mask.png differ diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d07ebc12f..68322756b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -39,6 +39,10 @@ A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.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 */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.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 = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = ""; }; + A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = ""; }; + A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = ""; }; + A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = ""; }; + A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; @@ -237,6 +245,7 @@ A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, + A54B0CE72D0CEC9800CBEFF8 /* Colorized Ghostty Icon */, A51BFC292B30F69F00E92F16 /* Update */, ); path = Features; @@ -256,6 +265,7 @@ A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, + A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, @@ -307,6 +317,16 @@ path = macOS; sourceTree = ""; }; + A54B0CE72D0CEC9800CBEFF8 /* Colorized Ghostty Icon */ = { + isa = PBXGroup; + children = ( + A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */, + A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */, + A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */, + ); + path = "Colorized Ghostty Icon"; + sourceTree = ""; + }; A54CD6ED299BEB14008C95BB /* Sources */ = { isa = PBXGroup; children = ( @@ -580,8 +600,10 @@ files = ( A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, + A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, + A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, @@ -620,6 +642,8 @@ A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, + A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, + A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index ed257d9ec..b38a019f0 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -98,6 +98,13 @@ class AppDelegate: NSObject, /// The observer for the app appearance. 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() { terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( @@ -519,6 +526,22 @@ class AppDelegate: NSObject, } else { 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. diff --git a/macos/Sources/Features/About/AboutView.swift b/macos/Sources/Features/About/AboutView.swift index a011984d4..6ed3285ed 100644 --- a/macos/Sources/Features/About/AboutView.swift +++ b/macos/Sources/Features/About/AboutView.swift @@ -44,7 +44,7 @@ struct AboutView: View { var body: some View { VStack(alignment: .center) { - Image("AppIconImage") + ghosttyIconImage() .resizable() .aspectRatio(contentMode: .fit) .frame(height: 128) diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift new file mode 100644 index 000000000..58de8f771 --- /dev/null +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift @@ -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, + ]) + } +} diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift new file mode 100644 index 000000000..4d522067e --- /dev/null +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift @@ -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") + } +} diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift new file mode 100644 index 000000000..8fbebfdc8 --- /dev/null +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift @@ -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()!) + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index ee37c8cc5..af76ca2c3 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -252,6 +252,46 @@ extension Ghostty { return v } + var macosIcon: MacOSIcon { + let defaultValue = MacOSIcon.official + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = 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? = 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 { guard let config = self.config else { return false } var v = false; @@ -261,9 +301,9 @@ extension Ghostty { } var backgroundColor: Color { - var rgb: UInt32 = 0 + var color: ghostty_config_color_s = .init(); 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) return Color(NSColor.windowBackgroundColor) #elseif os(iOS) @@ -273,14 +313,10 @@ extension Ghostty { #endif } - let red = Double(rgb & 0xff) - let green = Double((rgb >> 8) & 0xff) - let blue = Double((rgb >> 16) & 0xff) - - return Color( - red: red / 255, - green: green / 255, - blue: blue / 255 + return .init( + red: Double(color.r) / 255, + green: Double(color.g) / 255, + blue: Double(color.b) / 255 ) } @@ -311,21 +347,17 @@ extension Ghostty { var unfocusedSplitFill: Color { 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" - if (!ghostty_config_get(config, &rgb, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.count))) { 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) - let green = Double((rgb >> 8) & 0xff) - let blue = Double((rgb >> 16) & 0xff) - - return Color( - red: red / 255, - green: green / 255, - blue: blue / 255 + return .init( + red: Double(color.r), + green: Double(color.g) / 255, + blue: Double(color.b) / 255 ) } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index e7d9d98fd..65f928443 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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 MacOSTitlebarProxyIcon: String { case visible diff --git a/macos/Sources/Helpers/NSImage+Extension.swift b/macos/Sources/Helpers/NSImage+Extension.swift new file mode 100644 index 000000000..670148e27 --- /dev/null +++ b/macos/Sources/Helpers/NSImage+Extension.swift @@ -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 + } +} diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/OSColor+Extension.swift index 2d08e1cd2..54b3e1fab 100644 --- a/macos/Sources/Helpers/OSColor+Extension.swift +++ b/macos/Sources/Helpers/OSColor+Extension.swift @@ -1,4 +1,5 @@ import Foundation +import GhosttyKit extension OSColor { var isLightColor: Bool { @@ -47,6 +48,37 @@ extension OSColor { #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 { var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 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) + } +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 720c1f305..dbbf1be99 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1675,6 +1675,73 @@ keybind: Keybinds = .{}, /// you may want to disable it. @"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. /// /// 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 /// works by setting it to a C integer. -pub const Color = packed struct(u24) { +pub const Color = struct { r: u8, g: 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 pub fn toTerminalRGB(self: Color) terminal.color.RGB { 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; try formatter.formatEntry( []const u8, - std.fmt.bufPrint( - &buf, - "#{x:0>2}{x:0>2}{x:0>2}", - .{ self.r, self.g, self.b }, - ) catch return error.OutOfMemory, + try self.formatBuf(&buf), ); } + /// 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 "#" /// is optional. 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 /// used by many terminal applications. pub const Palette = struct { @@ -3742,7 +3952,7 @@ pub const RepeatableString = struct { return .{ .list = list }; } - /// The number of itemsin the list + /// The number of items in the list pub fn count(self: Self) usize { return self.list.items.len; } @@ -4906,11 +5116,29 @@ pub const MacTitlebarStyle = enum { }; /// See macos-titlebar-proxy-icon -pub const MacTitlebarProxyIcon: type = enum { +pub const MacTitlebarProxyIcon = enum { visible, 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 pub const GtkSingleInstance = enum { desktop, @@ -5246,9 +5474,8 @@ pub const Duration = struct { } } - pub fn c_get(self: Duration, ptr_raw: *anyopaque) void { - const ptr: *usize = @ptrCast(@alignCast(ptr_raw)); - ptr.* = @intCast(self.asMilliseconds()); + pub fn cval(self: Duration) usize { + return @intCast(self.asMilliseconds()); } /// Convenience function to convert to milliseconds since many OS and diff --git a/src/config/c_get.zig b/src/config/c_get.zig index 32a19df1c..dd7c7cce8 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -60,9 +60,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { }, .Struct => |info| { - // If the struct implements c_get then we call that - if (@hasDecl(@TypeOf(value), "c_get")) { - value.c_get(ptr_raw); + // If the struct implements cval then we call then. + if (@hasDecl(T, "cval")) { + const PtrT = @typeInfo(@TypeOf(T.cval)).Fn.return_type.?; + const ptr: *PtrT = @ptrCast(@alignCast(ptr_raw)); + ptr.* = value.cval(); return true; } @@ -100,7 +102,7 @@ fn fieldByKey(self: *const Config, comptime k: Key) Value(k) { return @field(self, field.name); } -test "u8" { +test "c_get: u8" { const testing = std.testing; const alloc = testing.allocator; @@ -113,7 +115,7 @@ test "u8" { try testing.expectEqual(@as(f32, 24), cval); } -test "enum" { +test "c_get: enum" { const testing = std.testing; const alloc = testing.allocator; @@ -128,7 +130,7 @@ test "enum" { try testing.expectEqualStrings("dark", str); } -test "color" { +test "c_get: color" { const testing = std.testing; const alloc = testing.allocator; @@ -136,12 +138,14 @@ test "color" { defer c.deinit(); 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.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 alloc = testing.allocator; @@ -150,14 +154,16 @@ test "optional" { { 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))); } { 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.expectEqual(@as(c_uint, 255), cval); + try testing.expectEqual(255, cval.r); + try testing.expectEqual(0, cval.g); + try testing.expectEqual(0, cval.b); } }