Merge pull request #942 from mitchellh/themes

Built-in Themes (based on iTerm2-Color-Schemes)
This commit is contained in:
Mitchell Hashimoto
2023-11-28 08:13:19 -08:00
committed by GitHub
12 changed files with 324 additions and 17 deletions

View File

@ -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

View File

@ -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

View File

@ -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",
},
},
}

View File

@ -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 = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CB04372B0F1C1C009ED217 /* themes */ = {isa = PBXFileReference; lastKnownFileType = folder; name = themes; path = "../zig-out/share/themes"; sourceTree = "<group>"; };
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = "<group>"; };
A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = "<group>"; };
A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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();
}

View File

@ -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,

View File

@ -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;

View File

@ -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 `+<action>` 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),
};
}
};

View File

@ -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(

74
src/cli/list_themes.zig Normal file
View File

@ -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;
}

View File

@ -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

View File

@ -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.