diff --git a/README.md b/README.md index 5c9526736..6fb07a2c0 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,39 @@ if something isn't working. Eventually, we'll have a better mechanism for showing errors to the user. +### Themes + +Ghostty ships with 300+ built-in themes (from +[iTerm2 Color Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes)). +You can configure Ghostty to use any of these themes using the `theme` +configuration. Example: + +``` +theme = Solarized Dark - Patched +``` + +You can find a list of built-in themes using the `+list-themes` action: + +``` +$ ghostty +list-themes +... +``` + +On macOS, the themes are built-in to the `Ghostty.app` bundle. On Linux, +theme support requires a valid Ghostty resources dir ("share" directory). +More details about how to validate the resources directory on Linux +is covered in the [shell integration section](#shell-integration-installation-and-verification). + +Any custom color configuration (`palette`, `background`, `foreground`, etc.) +in your configuration files will override the theme settings. This can be +used to load a theme and fine-tune specific colors to your liking. + +**Interested in contributing a new theme or updating an existing theme?** +Please send theme changes upstream to the +[iTerm2 Color Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes)) +repository. Ghostty periodically updates the themes from this source. +_Do not send theme changes to the Ghostty project directly_. + ### Shell Integration Ghostty supports some features that require shell integration. I am aiming diff --git a/build.zig b/build.zig index cb60aabb7..0389d81d1 100644 --- a/build.zig +++ b/build.zig @@ -305,6 +305,29 @@ pub fn build(b: *std.Build) !void { } } + // Themes + { + const upstream = b.dependency("iterm2_themes", .{}); + const install = b.addInstallDirectory(.{ + .source_dir = upstream.path("ghostty"), + .install_dir = .{ .custom = "share" }, + .install_subdir = "themes", + .exclude_extensions = &.{".md"}, + }); + b.getInstallStep().dependOn(&install.step); + + if (target.isDarwin() and exe_ != null) { + const mac_install = b.addInstallDirectory(options: { + var copy = install.options; + copy.install_dir = .{ + .custom = "Ghostty.app/Contents/Resources", + }; + break :options copy; + }); + b.getInstallStep().dependOn(&mac_install.step); + } + } + // Terminfo { // Encode our terminfo diff --git a/build.zig.zon b/build.zig.zon index cc50c887c..f6a675cb2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -41,7 +41,11 @@ .glslang = .{ .path = "./pkg/glslang" }, .spirv_cross = .{ .path = "./pkg/spirv-cross" }, - // System headers + // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .iterm2_themes = .{ + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/53acae071801e0de6ed160315869abb9bdaf20fa.tar.gz", + .hash = "12201575c5a2b21c2e110593773040cddcd357544038092d18bd98fc5a2141354bbd", + }, }, } diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 0099a59b0..1e2e3f612 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CB04382B0F1C1C009ED217 /* themes in Resources */ = {isa = PBXBuildFile; fileRef = A5CB04372B0F1C1C009ED217 /* themes */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */; }; A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */; }; @@ -76,6 +77,7 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CB04372B0F1C1C009ED217 /* themes */ = {isa = PBXFileReference; lastKnownFileType = folder; name = themes; path = "../zig-out/share/themes"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = ""; }; A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = ""; }; @@ -198,6 +200,7 @@ A5B30528299BEAAA0047F10C = { isa = PBXGroup; children = ( + A5CB04372B0F1C1C009ED217 /* themes */, A571AB1C2A206FC600248498 /* Ghostty-Info.plist */, A5B30538299BEAAB0047F10C /* Assets.xcassets */, A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */, @@ -302,6 +305,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A5CB04382B0F1C1C009ED217 /* themes in Resources */, A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */, A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, diff --git a/src/App.zig b/src/App.zig index a09860a86..785617721 100644 --- a/src/App.zig +++ b/src/App.zig @@ -41,10 +41,6 @@ mailbox: Mailbox.Queue, /// Set to true once we're quitting. This never goes false again. quit: bool, -/// The app resources directory, equivalent to zig-out/share when we build -/// from source. This is null if we can't detect it. -resources_dir: ?[]const u8 = null, - /// Font discovery mechanism. This is only safe to use from the main thread. /// This is lazily initialized on the first call to fontDiscover so do /// not access this directly. @@ -59,17 +55,11 @@ pub fn create( var app = try alloc.create(App); errdefer alloc.destroy(app); - // Find our resources directory once for the app so every launch - // hereafter can use this cached value. - const resources_dir = try internal_os.resourcesDir(alloc); - errdefer if (resources_dir) |dir| alloc.free(dir); - app.* = .{ .alloc = alloc, .surfaces = .{}, .mailbox = .{}, .quit = false, - .resources_dir = resources_dir, }; errdefer app.surfaces.deinit(alloc); @@ -81,7 +71,6 @@ pub fn destroy(self: *App) void { for (self.surfaces.items) |surface| surface.deinit(); self.surfaces.deinit(self.alloc); - if (self.resources_dir) |dir| self.alloc.free(dir); if (comptime font.Discover != void) { if (self.font_discover) |*v| v.deinit(); } diff --git a/src/Surface.zig b/src/Surface.zig index fd70ec2d5..698f218f5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -21,6 +21,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ziglyph = @import("ziglyph"); +const main = @import("main.zig"); const renderer = @import("renderer.zig"); const termio = @import("termio.zig"); const objc = @import("objc"); @@ -451,7 +452,7 @@ pub fn init( .padding = padding, .full_config = config, .config = try termio.Impl.DerivedConfig.init(alloc, config), - .resources_dir = app.resources_dir, + .resources_dir = main.state.resources_dir, .renderer_state = &self.renderer_state, .renderer_wakeup = render_thread.wakeup, .renderer_mailbox = render_thread.mailbox, diff --git a/src/apprt/gtk/icon.zig b/src/apprt/gtk/icon.zig index 30416d07b..2c446c620 100644 --- a/src/apprt/gtk/icon.zig +++ b/src/apprt/gtk/icon.zig @@ -2,6 +2,7 @@ const std = @import("std"); const App = @import("App.zig"); const c = @import("c.zig"); +const global_state = &@import("../../main.zig").state; const log = std.log.scoped(.gtk_icon); @@ -28,7 +29,7 @@ pub fn appIcon(app: *App, widget: *c.GtkWidget) !Icon { // to the search path and see if we can find it there. const icon_theme = c.gtk_icon_theme_get_for_display(c.gtk_widget_get_display(widget)); if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) icon: { - const base = app.core_app.resources_dir orelse { + const base = global_state.resources_dir orelse { log.info("gtk app missing Ghostty icon and no resources dir detected", .{}); log.info("gtk app will not have Ghostty icon", .{}); break :icon; diff --git a/src/cli/action.zig b/src/cli/action.zig index 38cc1fbd7..1297d640b 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const list_fonts = @import("list_fonts.zig"); const version = @import("version.zig"); const list_keybinds = @import("list_keybinds.zig"); +const list_themes = @import("list_themes.zig"); /// Special commands that can be invoked via CLI flags. These are all /// invoked by using `+` as a CLI flag. The only exception is @@ -18,6 +19,9 @@ pub const Action = enum { /// List available keybinds @"list-keybinds", + /// List available themes + @"list-themes", + pub const Error = error{ /// Multiple actions were detected. You can specify at most one /// action on the CLI otherwise the behavior desired is ambiguous. @@ -57,6 +61,7 @@ pub const Action = enum { .version => try version.run(), .@"list-fonts" => try list_fonts.run(alloc), .@"list-keybinds" => try list_keybinds.run(alloc), + .@"list-themes" => try list_themes.run(alloc), }; } }; diff --git a/src/cli/args.zig b/src/cli/args.zig index 678c44b1d..d8b670852 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -67,6 +67,11 @@ pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void { }; while (iter.next()) |arg| { + // If an _inputs fields exist we keep track of the inputs. + if (@hasField(T, "_inputs")) { + try dst._inputs.append(arena_alloc, try arena_alloc.dupe(u8, arg)); + } + // Do manual parsing if we have a hook for it. if (@hasDecl(T, "parseManuallyHook")) { if (!try dst.parseManuallyHook(arena_alloc, arg, iter)) return; @@ -381,6 +386,30 @@ test "parse: error tracking" { try testing.expect(!data._errors.empty()); } +test "parse: input tracking" { + const testing = std.testing; + + var data: struct { + a: []const u8 = "", + b: enum { one } = .one, + + _arena: ?ArenaAllocator = null, + _errors: ErrorList = .{}, + _inputs: std.ArrayListUnmanaged([]const u8) = .{}, + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--what --a=42", + ); + defer iter.deinit(); + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expect(data._inputs.items.len == 2); + try testing.expectEqualStrings("--what", data._inputs.items[0]); + try testing.expectEqualStrings("--a=42", data._inputs.items[1]); +} test "parseIntoField: ignore underscore-prefixed fields" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); @@ -732,6 +761,25 @@ pub fn lineIterator(reader: anytype) LineIterator(@TypeOf(reader)) { return .{ .r = reader }; } +/// An iterator valid for arg parsing from a slice. +pub const SliceIterator = struct { + const Self = @This(); + + slice: []const []const u8, + idx: usize = 0, + + pub fn next(self: *Self) ?[]const u8 { + if (self.idx >= self.slice.len) return null; + defer self.idx += 1; + return self.slice[self.idx]; + } +}; + +/// Construct a SliceIterator from a slice. +pub fn sliceIterator(slice: []const []const u8) SliceIterator { + return .{ .slice = slice }; +} + test "LineIterator" { const testing = std.testing; var fbs = std.io.fixedBufferStream( diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig new file mode 100644 index 000000000..bf763c5bf --- /dev/null +++ b/src/cli/list_themes.zig @@ -0,0 +1,74 @@ +const std = @import("std"); +const inputpkg = @import("../input.zig"); +const args = @import("args.zig"); +const Arena = std.heap.ArenaAllocator; +const Allocator = std.mem.Allocator; +const Config = @import("../config/Config.zig"); +const global_state = &@import("../main.zig").state; + +pub const Options = struct { + pub fn deinit(self: Options) void { + _ = self; + } +}; + +/// The "list-themes" command is used to list all the available themes +/// for Ghostty. +/// +/// Themes require that Ghostty have access to the resources directory. +/// On macOS this is embedded in the app bundle. On Linux, this is usually +/// in `/usr/share`. If you're compiling from source, this is the +/// `zig-out/share` directory. You can also set the `GHOSTTY_RESOURCES_DIR` +/// environment variable to point to the resources directory. Themes +/// live in the `themes` subdirectory of the resources directory. +pub fn run(alloc: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + const stderr = std.io.getStdErr().writer(); + const stdout = std.io.getStdOut().writer(); + + const resources_dir = global_state.resources_dir orelse { + try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++ + "that Ghostty is installed correctly.\n", .{}); + return 1; + }; + + const path = try std.fs.path.join(alloc, &.{ resources_dir, "themes" }); + defer alloc.free(path); + + var dir = try std.fs.cwd().openIterableDir(path, .{}); + defer dir.close(); + + var walker = try dir.walk(alloc); + defer walker.deinit(); + + var themes = std.ArrayList([]const u8).init(alloc); + defer { + for (themes.items) |v| alloc.free(v); + themes.deinit(); + } + + while (try walker.next()) |entry| { + if (entry.kind != .file) continue; + try themes.append(try alloc.dupe(u8, entry.basename)); + } + + std.mem.sortUnstable([]const u8, themes.items, {}, struct { + fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.ascii.orderIgnoreCase(lhs, rhs) == .lt; + } + }.lessThan); + + for (themes.items) |theme| { + try stdout.print("{s}\n", .{theme}); + } + + return 0; +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 189033b8f..a28bb2d2d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7,6 +7,7 @@ const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const global_state = &@import("../main.zig").state; const fontpkg = @import("../font/main.zig"); const inputpkg = @import("../input.zig"); const terminal = @import("../terminal/main.zig"); @@ -153,6 +154,26 @@ const c = @cImport({ @"adjust-strikethrough-position": ?MetricModifier = null, @"adjust-strikethrough-thickness": ?MetricModifier = null, +/// A named theme to use. The available themes are currently hardcoded to +/// the themes that ship with Ghostty. On macOS, this list is in the +/// `Ghostty.app/Contents/Resources/themes` directory. On Linux, this +/// list is in the `share/ghostty/themes` directory (wherever you installed +/// the Ghostty "share" directory. +/// +/// To see a list of available themes, run `ghostty +list-themes`. +/// +/// Any additional colors specified via background, foreground, palette, +/// etc. will override the colors specified in the theme. +/// +/// This configuration can be changed at runtime, but the new theme will +/// only affect new cells. Existing colored cells will not be updated. +/// Therefore, after changing the theme, you should restart any running +/// programs to ensure they get the new colors. +/// +/// A future update will allow custom themes to be installed in +/// certain directories. +theme: ?[]const u8 = null, + /// Background color for the window. background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, @@ -726,6 +747,10 @@ _arena: ?ArenaAllocator = null, /// configuration file. _errors: ErrorList = .{}, +/// The inputs that built up this configuration. This is used to reload +/// the configuration if we have to. +_inputs: std.ArrayListUnmanaged([]const u8) = .{}, + pub fn deinit(self: *Config) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; @@ -1182,6 +1207,16 @@ fn ctrlOrSuper(mods: inputpkg.Mods) inputpkg.Mods { return copy; } +/// Load configuration from an iterator that yields values that look like +/// command-line arguments, i.e. `--key=value`. +pub fn loadIter( + self: *Config, + alloc: Allocator, + iter: anytype, +) !void { + try cli.args.parse(Config, alloc, self, iter); +} + /// Load the configuration from the default configuration file. The default /// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { @@ -1195,7 +1230,7 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { var buf_reader = std.io.bufferedReader(file.reader()); var iter = cli.args.lineIterator(buf_reader.reader()); - try cli.args.parse(Config, alloc, self, &iter); + try self.loadIter(alloc, &iter); try self.expandPaths(std.fs.path.dirname(config_path).?); } else |err| switch (err) { error.FileNotFound => std.log.info( @@ -1222,7 +1257,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // Parse the config from the CLI args var iter = try std.process.argsWithAllocator(alloc_gpa); defer iter.deinit(); - try cli.args.parse(Config, alloc_gpa, self, &iter); + try self.loadIter(alloc_gpa, &iter); // Config files loaded from the CLI args are relative to pwd if (self.@"config-file".value.list.items.len > 0) { @@ -1279,7 +1314,7 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { log.info("loading config-file path={s}", .{path}); var buf_reader = std.io.bufferedReader(file.reader()); var iter = cli.args.lineIterator(buf_reader.reader()); - try cli.args.parse(Config, alloc_gpa, self, &iter); + try self.loadIter(alloc_gpa, &iter); try self.expandPaths(std.fs.path.dirname(path).?); } } @@ -1299,7 +1334,85 @@ fn expandPaths(self: *Config, base: []const u8) !void { } } +fn loadTheme(self: *Config, theme: []const u8) !void { + const alloc = self._arena.?.allocator(); + const resources_dir = global_state.resources_dir orelse { + try self._errors.add(alloc, .{ + .message = "no resources directory found, themes will not work", + }); + return; + }; + + const path = try std.fs.path.join(alloc, &.{ + resources_dir, + "themes", + theme, + }); + + const cwd = std.fs.cwd(); + var file = cwd.openFile(path, .{}) catch |err| { + switch (err) { + error.FileNotFound => try self._errors.add(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "theme \"{s}\" not found, path={s}", + .{ theme, path }, + ), + }), + + else => try self._errors.add(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "failed to load theme \"{s}\": {}", + .{ theme, err }, + ), + }), + } + return; + }; + defer file.close(); + + // From this point onwards, we load the theme and do a bit of a dance + // to achive two separate goals: + // + // (1) We want the theme to be loaded and our existing config to + // override the theme. So we need to load the theme and apply + // our config on top of it. + // + // (2) We want to free existing memory that we aren't using anymore + // as a result of reloading the configuration. + // + // Point 2 is strictly a result of aur approach to point 1. + + // Keep track of our input length prior ot loading the theme + // so that we can replay the previous config to override values. + const input_len = self._inputs.items.len; + + // Load into a new configuration so that we can free the existing memory. + const alloc_gpa = self._arena.?.child_allocator; + var new_config = try default(alloc_gpa); + errdefer new_config.deinit(); + + // Load our theme + var buf_reader = std.io.bufferedReader(file.reader()); + var iter = cli.args.lineIterator(buf_reader.reader()); + try new_config.loadIter(alloc_gpa, &iter); + + // Replay our previous inputs so that we can override values + // from the theme. + var slice_it = cli.args.sliceIterator(self._inputs.items[0..input_len]); + try new_config.loadIter(alloc_gpa, &slice_it); + + // Success, swap our new config in and free the old. + self.deinit(); + self.* = new_config; +} + pub fn finalize(self: *Config) !void { + // We always load the theme first because it may set other fields + // in our config. + if (self.theme) |theme| try self.loadTheme(theme); + // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does // --font-family=foo, then we try to get the stylized versions of diff --git a/src/main.zig b/src/main.zig index 3ef42d136..6d8ac9ad0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -175,6 +175,10 @@ pub const GlobalState = struct { action: ?cli.Action, logging: Logging, + /// The app resources directory, equivalent to zig-out/share when we build + /// from source. This is null if we can't detect it. + resources_dir: ?[]const u8, + /// Where logging should go pub const Logging = union(enum) { disabled: void, @@ -192,6 +196,7 @@ pub const GlobalState = struct { .tracy = undefined, .action = null, .logging = .{ .stderr = {} }, + .resources_dir = null, }; errdefer self.deinit(); @@ -271,11 +276,18 @@ pub const GlobalState = struct { // Initialize glslang for shader compilation try glslang.init(); + + // Find our resources directory once for the app so every launch + // hereafter can use this cached value. + self.resources_dir = try internal_os.resourcesDir(self.alloc); + errdefer if (self.resources_dir) |dir| self.alloc.free(dir); } /// Cleans up the global state. This doesn't _need_ to be called but /// doing so in dev modes will check for memory leaks. pub fn deinit(self: *GlobalState) void { + if (self.resources_dir) |dir| self.alloc.free(dir); + if (self.gpa) |*value| { // We want to ensure that we deinit the GPA because this is // the point at which it will output if there were safety violations.