diff --git a/include/ghostty.h b/include/ghostty.h index 43981cdc5..61c3aad32 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -342,6 +342,7 @@ typedef struct { uint8_t b; } ghostty_config_color_s; +// config.ColorList typedef struct { const ghostty_config_color_s* colors; size_t len; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 87e56e8f0..9536b3867 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -528,7 +528,7 @@ class AppDelegate: NSObject, } if let colorizedIcon = ColorizedGhosttyIcon( - screenColors: [.purple, .blue], + screenColors: [], ghostColor: .yellow ).makeImage() { self.appIcon = colorizedIcon diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/OSColor+Extension.swift index 2d08e1cd2..5a02af5ed 100644 --- a/macos/Sources/Helpers/OSColor+Extension.swift +++ b/macos/Sources/Helpers/OSColor+Extension.swift @@ -47,6 +47,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) diff --git a/src/config/Config.zig b/src/config/Config.zig index bb17fece9..3081ac363 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -16,6 +16,7 @@ const build_config = @import("../build_config.zig"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const build_config = @import("../build_config.zig"); const global_state = &@import("../global.zig").state; const fontpkg = @import("../font/main.zig"); const inputpkg = @import("../input.zig"); @@ -1675,6 +1676,40 @@ 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, Activity Monitor, 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. +/// +/// Valid values: +/// +/// * `official` - Use the official Ghostty icon. +/// * `custom-color` - Use the official Ghostty icon but with custom +/// colors applied to various layers. The custom colors must be +/// specified using `macos-icon-layer-color`. +/// +@"macos-icon": MacAppIcon = .official, + +/// 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. +/// +/// This only has an effect when `macos-icon` is set to `custom-color`. +@"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. +/// +/// This only has an effect when `macos-icon` is set to `custom-color`. +@"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 @@ -3577,14 +3612,19 @@ pub const Color = struct { 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) ![]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 { @@ -3637,6 +3677,130 @@ pub const Color = struct { } }; +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; + + // Whenever a color list is set, we reset the list + self.colors.clearRetainingCapacity(); + + // 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) try writer.writeByte(','); + try writer.writeAll(color_str); + } + + 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 { @@ -3753,7 +3917,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; } @@ -4922,6 +5086,16 @@ pub const MacTitlebarProxyIcon = enum { 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-color", +}; + /// See gtk-single-instance pub const GtkSingleInstance = enum { desktop,