Merge branch 'ghostty-org:main' into dolphin-action

This commit is contained in:
Andrej Daskalov
2024-11-29 00:26:42 +01:00
committed by GitHub
28 changed files with 885 additions and 219 deletions

View File

@ -107,25 +107,40 @@ palette = 7=#a89984
palette = 15=#fbf1c7 palette = 15=#fbf1c7
``` ```
You can view all available configuration options and their documentation #### Configuration Documentation
by executing the command `ghostty +show-config --default --docs`. Note that
this will output the full default configuration with docs to stdout, so There are multiple places to find documentation on the configuration options.
you may want to pipe that through a pager, an editor, etc. All locations are identical (they're all generated from the same source):
1. There are HTML and Markdown formatted docs in the
`$prefix/share/ghostty/docs` directory. This directory is created
when you build or install Ghostty. The `$prefix` is `zig-out` if you're
building from source (or the specified `--prefix` flag). On macOS,
`$prefix` is the `Contents/Resources` subdirectory of the `.app` bundle.
2. There are man pages in the `$prefix/share/man` directory. This directory
is created when you build or install Ghostty.
3. In the CLI, you can run `ghostty +show-config --default --docs`.
Note that this will output the full default configuration with docs to
stdout, so you may want to pipe that through a pager, an editor, etc.
4. In the source code, you can find the configuration structure in the
[Config structure](https://github.com/ghostty-org/ghostty/blob/main/src/config/Config.zig).
The available keys are the keys verbatim, and their possible values are typically
documented in the comments.
5. Not documentation per se, but you can search for the
[public config files](https://github.com/search?q=path%3Aghostty%2Fconfig&type=code)
of many Ghostty users for examples and inspiration.
> [!NOTE] > [!NOTE]
> >
> You'll see a lot of weird blank configurations like `font-family =`. This > You may see strange looking blank configurations like `font-family =`. This
> is a valid syntax to specify the default behavior (no value). The > is a valid syntax to specify the default behavior (no value). The
> `+show-config` outputs it so it's clear that key is defaulting and also > `+show-config` outputs it so it's clear that key is defaulting and also
> to have something to attach the doc comment to. > to have something to attach the doc comment to.
You can also see and read all available configuration options in the source
[Config structure](https://github.com/ghostty-org/ghostty/blob/main/src/config/Config.zig).
The available keys are the keys verbatim, and their possible values are typically
documented in the comments. You also can search for the
[public config files](https://github.com/search?q=path%3Aghostty%2Fconfig&type=code)
of many Ghostty users for examples and inspiration.
> [!NOTE] > [!NOTE]
> >
> Configuration can be reloaded on the fly with the `reload_config` > Configuration can be reloaded on the fly with the `reload_config`

View File

@ -94,6 +94,16 @@ class TerminalController: BaseTerminalController {
} }
} }
override func fullscreenDidChange() {
super.fullscreenDidChange()
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
}
//MARK: - Methods //MARK: - Methods
@objc private func ghosttyConfigDidChange(_ notification: Notification) { @objc private func ghosttyConfigDidChange(_ notification: Notification) {
@ -204,7 +214,13 @@ class TerminalController: BaseTerminalController {
} }
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (surfaceConfig.backgroundOpacity < 1) {
// Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through.
if (!window.styleMask.contains(.fullScreen) &&
surfaceConfig.backgroundOpacity < 1
) {
window.isOpaque = false window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // This is weird, but we don't use ".clear" because this creates a look that

View File

@ -376,7 +376,13 @@ pub fn init(
// We want a config pointer for everything so we get that either // We want a config pointer for everything so we get that either
// based on our conditional state or the original config. // based on our conditional state or the original config.
const config: *const configpkg.Config = if (config_) |*c| c else config_original; const config: *const configpkg.Config = if (config_) |*c| config: {
// We want to preserve our original working directory. We
// don't need to dupe memory here because termio will derive
// it. We preserve this so directory inheritance works.
c.@"working-directory" = config_original.@"working-directory";
break :config c;
} else config_original;
// Get our configuration // Get our configuration
var derived_config = try DerivedConfig.init(alloc, config); var derived_config = try DerivedConfig.init(alloc, config);
@ -837,21 +843,28 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
}, .unlocked); }, .unlocked);
}, },
.color_change => |change| try self.rt_app.performAction( .color_change => |change| {
.{ .surface = self }, // On any color change, we have to report for mode 2031
.color_change, // if it is enabled.
.{ self.reportColorScheme(false);
.kind = switch (change.kind) {
.background => .background, // Notify our apprt
.foreground => .foreground, try self.rt_app.performAction(
.cursor => .cursor, .{ .surface = self },
.palette => |v| @enumFromInt(v), .color_change,
.{
.kind = switch (change.kind) {
.background => .background,
.foreground => .foreground,
.cursor => .cursor,
.palette => |v| @enumFromInt(v),
},
.r = change.color.r,
.g = change.color.g,
.b = change.color.b,
}, },
.r = change.color.r, );
.g = change.color.g, },
.b = change.color.b,
},
),
.set_mouse_shape => |shape| { .set_mouse_shape => |shape| {
log.debug("changing mouse shape: {}", .{shape}); log.debug("changing mouse shape: {}", .{shape});
@ -915,7 +928,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.renderer_health => |health| self.updateRendererHealth(health), .renderer_health => |health| self.updateRendererHealth(health),
.report_color_scheme => try self.reportColorScheme(), .report_color_scheme => |force| self.reportColorScheme(force),
.present_surface => try self.presentSurface(), .present_surface => try self.presentSurface(),
@ -952,8 +965,18 @@ fn passwordInput(self: *Surface, v: bool) !void {
try self.queueRender(); try self.queueRender();
} }
/// Sends a DSR response for the current color scheme to the pty. /// Sends a DSR response for the current color scheme to the pty. If
fn reportColorScheme(self: *Surface) !void { /// force is false then we only send the response if the terminal mode
/// 2031 is enabled.
fn reportColorScheme(self: *Surface, force: bool) void {
if (!force) {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
if (!self.renderer_state.terminal.modes.get(.report_color_scheme)) {
return;
}
}
const output = switch (self.config_conditional_state.theme) { const output = switch (self.config_conditional_state.theme) {
.light => "\x1B[?997;2n", .light => "\x1B[?997;2n",
.dark => "\x1B[?997;1n", .dark => "\x1B[?997;1n",
@ -3660,12 +3683,7 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void {
self.notifyConfigConditionalState(); self.notifyConfigConditionalState();
// If mode 2031 is on, then we report the change live. // If mode 2031 is on, then we report the change live.
const report = report: { self.reportColorScheme(false);
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
break :report self.renderer_state.terminal.modes.get(.report_color_scheme);
};
if (report) try self.reportColorScheme();
} }
pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate { pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate {

View File

@ -85,26 +85,38 @@ pub const App = struct {
}; };
core_app: *CoreApp, core_app: *CoreApp,
config: *const Config,
opts: Options, opts: Options,
keymap: input.Keymap, keymap: input.Keymap,
/// The configuration for the app. This is owned by this structure.
config: Config,
/// The keymap state is used for global keybinds only. Each surface /// The keymap state is used for global keybinds only. Each surface
/// also has its own keymap state for focused keybinds. /// also has its own keymap state for focused keybinds.
keymap_state: input.Keymap.State, keymap_state: input.Keymap.State,
pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App { pub fn init(
core_app: *CoreApp,
config: *const Config,
opts: Options,
) !App {
// We have to clone the config.
const alloc = core_app.alloc;
var config_clone = try config.clone(alloc);
errdefer config_clone.deinit();
return .{ return .{
.core_app = core_app, .core_app = core_app,
.config = config, .config = config_clone,
.opts = opts, .opts = opts,
.keymap = try input.Keymap.init(), .keymap = try input.Keymap.init(),
.keymap_state = .{}, .keymap_state = .{},
}; };
} }
pub fn terminate(self: App) void { pub fn terminate(self: *App) void {
self.keymap.deinit(); self.keymap.deinit();
self.config.deinit();
} }
/// Returns true if there are any global keybinds in the configuration. /// Returns true if there are any global keybinds in the configuration.
@ -370,11 +382,11 @@ pub const App = struct {
} }
} }
pub fn wakeup(self: App) void { pub fn wakeup(self: *const App) void {
self.opts.wakeup(self.opts.userdata); self.opts.wakeup(self.opts.userdata);
} }
pub fn wait(self: App) !void { pub fn wait(self: *const App) !void {
_ = self; _ = self;
} }
@ -450,6 +462,19 @@ pub const App = struct {
}, },
}, },
.config_change => switch (target) {
.surface => {},
// For app updates, we update our core config. We need to
// clone it because the caller owns the param.
.app => if (value.config.clone(self.core_app.alloc)) |config| {
self.config.deinit();
self.config = config;
} else |err| {
log.err("error updating app config err={}", .{err});
},
},
else => {}, else => {},
} }
} }
@ -573,7 +598,7 @@ pub const Surface = struct {
errdefer app.core_app.deleteSurface(self); errdefer app.core_app.deleteSurface(self);
// Shallow copy the config so that we can modify it. // Shallow copy the config so that we can modify it.
var config = try apprt.surface.newConfig(app.core_app, app.config); var config = try apprt.surface.newConfig(app.core_app, &app.config);
defer config.deinit(); defer config.deinit();
// If we have a working directory from the options then we set it. // If we have a working directory from the options then we set it.
@ -1831,7 +1856,7 @@ pub const CAPI = struct {
// This is only supported on macOS // This is only supported on macOS
if (comptime builtin.target.os.tag != .macos) return; if (comptime builtin.target.os.tag != .macos) return;
const config = app.config; const config = &app.config;
// Do nothing if we don't have background transparency enabled // Do nothing if we don't have background transparency enabled
if (config.@"background-opacity" >= 1.0) return; if (config.@"background-opacity" >= 1.0) return;

View File

@ -724,7 +724,7 @@ pub const Surface = struct {
/// Set the shape of the cursor. /// Set the shape of the cursor.
fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
if ((comptime builtin.target.isDarwin()) and if ((comptime builtin.target.isDarwin()) and
!internal_os.macosVersionAtLeast(13, 0, 0)) !internal_os.macos.isAtLeastVersion(13, 0, 0))
{ {
// We only set our cursor if we're NOT on Mac, or if we are then the // We only set our cursor if we're NOT on Mac, or if we are then the
// macOS version is >= 13 (Ventura). On prior versions, glfw crashes // macOS version is >= 13 (Ventura). On prior versions, glfw crashes

View File

@ -14,6 +14,7 @@ const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const builtin = @import("builtin"); const builtin = @import("builtin");
const build_config = @import("../../build_config.zig");
const apprt = @import("../../apprt.zig"); const apprt = @import("../../apprt.zig");
const configpkg = @import("../../config.zig"); const configpkg = @import("../../config.zig");
const input = @import("../../input.zig"); const input = @import("../../input.zig");
@ -99,9 +100,13 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
c.gtk_get_micro_version(), c.gtk_get_micro_version(),
}); });
// Disabling Vulkan can improve startup times by hundreds of
// milliseconds on some systems. We don't use Vulkan so we can just
// disable it.
if (version.atLeast(4, 16, 0)) { if (version.atLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
_ = internal_os.setenv("GDK_DISABLE", "gles-api"); // For the remainder of "why" see the 4.14 comment below.
_ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan");
_ = internal_os.setenv("GDK_DEBUG", "opengl"); _ = internal_os.setenv("GDK_DEBUG", "opengl");
} else if (version.atLeast(4, 14, 0)) { } else if (version.atLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14. // We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
@ -110,11 +115,14 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// reassess... // reassess...
// //
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles"); _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable");
} else {
_ = internal_os.setenv("GDK_DEBUG", "vulkan-disable");
} }
if (version.atLeast(4, 14, 0)) { if (version.atLeast(4, 14, 0)) {
// We need to export GSK_RENDERER to opengl because GTK uses ngl by default after 4.14 // We need to export GSK_RENDERER to opengl because GTK uses ngl by
// default after 4.14
_ = internal_os.setenv("GSK_RENDERER", "opengl"); _ = internal_os.setenv("GSK_RENDERER", "opengl");
} }
@ -181,7 +189,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
} }
} }
const default_id = "com.mitchellh.ghostty"; const default_id = comptime build_config.bundle_id;
break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id; break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id;
}; };
@ -377,22 +385,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
if (config.@"initial-window") if (config.@"initial-window")
c.g_application_activate(gapp); c.g_application_activate(gapp);
// Register for dbus events
if (c.g_application_get_dbus_connection(gapp)) |dbus_connection| {
_ = c.g_dbus_connection_signal_subscribe(
dbus_connection,
null,
"org.freedesktop.portal.Settings",
"SettingChanged",
"/org/freedesktop/portal/desktop",
"org.freedesktop.appearance",
c.G_DBUS_SIGNAL_FLAGS_MATCH_ARG0_NAMESPACE,
&gtkNotifyColorScheme,
core_app,
null,
);
}
// Internally, GTK ensures that only one instance of this provider exists in the provider list // Internally, GTK ensures that only one instance of this provider exists in the provider list
// for the display. // for the display.
const css_provider = c.gtk_css_provider_new(); const css_provider = c.gtk_css_provider_new();
@ -401,12 +393,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
@ptrCast(css_provider), @ptrCast(css_provider),
c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 3, c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 3,
); );
loadRuntimeCss(core_app.alloc, &config, css_provider) catch |err| switch (err) {
error.OutOfMemory => log.warn(
"out of memory loading runtime CSS, no runtime CSS applied",
.{},
),
};
return .{ return .{
.core_app = core_app, .core_app = core_app,
@ -462,7 +448,7 @@ pub fn performAction(
.equalize_splits => self.equalizeSplits(target), .equalize_splits => self.equalizeSplits(target),
.goto_split => self.gotoSplit(target, value), .goto_split => self.gotoSplit(target, value),
.open_config => try configpkg.edit.open(self.core_app.alloc), .open_config => try configpkg.edit.open(self.core_app.alloc),
.config_change => self.configChange(value.config), .config_change => self.configChange(target, value.config),
.reload_config => try self.reloadConfig(target, value), .reload_config => try self.reloadConfig(target, value),
.inspector => self.controlInspector(target, value), .inspector => self.controlInspector(target, value),
.desktop_notification => self.showDesktopNotification(target, value), .desktop_notification => self.showDesktopNotification(target, value),
@ -818,18 +804,38 @@ fn showDesktopNotification(
c.g_application_send_notification(g_app, n.body.ptr, notification); c.g_application_send_notification(g_app, n.body.ptr, notification);
} }
fn configChange(self: *App, new_config: *const Config) void { fn configChange(
_ = new_config; self: *App,
target: apprt.Target,
new_config: *const Config,
) void {
switch (target) {
// We don't do anything for surface config change events. There
// is nothing to sync with regards to a surface today.
.surface => {},
self.syncConfigChanges() catch |err| { .app => {
log.warn("error handling configuration changes err={}", .{err}); // We clone (to take ownership) and update our configuration.
}; if (new_config.clone(self.core_app.alloc)) |config_clone| {
self.config.deinit();
self.config = config_clone;
} else |err| {
log.warn("error cloning configuration err={}", .{err});
}
if (adwaita.enabled(&self.config)) { self.syncConfigChanges() catch |err| {
if (self.core_app.focusedSurface()) |core_surface| { log.warn("error handling configuration changes err={}", .{err});
const surface = core_surface.rt_surface; };
if (surface.container.window()) |window| window.onConfigReloaded();
} // App changes needs to show a toast that our configuration
// has reloaded.
if (adwaita.enabled(&self.config)) {
if (self.core_app.focusedSurface()) |core_surface| {
const surface = core_surface.rt_surface;
if (surface.container.window()) |window| window.onConfigReloaded();
}
}
},
} }
} }
@ -870,7 +876,7 @@ fn syncConfigChanges(self: *App) !void {
// Load our runtime CSS. If this fails then our window is just stuck // Load our runtime CSS. If this fails then our window is just stuck
// with the old CSS but we don't want to fail the entire sync operation. // with the old CSS but we don't want to fail the entire sync operation.
loadRuntimeCss(self.core_app.alloc, &self.config, self.css_provider) catch |err| switch (err) { self.loadRuntimeCss() catch |err| switch (err) {
error.OutOfMemory => log.warn( error.OutOfMemory => log.warn(
"out of memory loading runtime CSS, no runtime CSS applied", "out of memory loading runtime CSS, no runtime CSS applied",
.{}, .{},
@ -934,15 +940,14 @@ fn syncActionAccelerator(
} }
fn loadRuntimeCss( fn loadRuntimeCss(
alloc: Allocator, self: *const App,
config: *const Config,
provider: *c.GtkCssProvider,
) Allocator.Error!void { ) Allocator.Error!void {
var stack_alloc = std.heap.stackFallback(4096, alloc); var stack_alloc = std.heap.stackFallback(4096, self.core_app.alloc);
var buf = std.ArrayList(u8).init(stack_alloc.get()); var buf = std.ArrayList(u8).init(stack_alloc.get());
defer buf.deinit(); defer buf.deinit();
const writer = buf.writer(); const writer = buf.writer();
const config: *const Config = &self.config;
const window_theme = config.@"window-theme"; const window_theme = config.@"window-theme";
const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background; const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background;
const headerbar_background = config.background; const headerbar_background = config.background;
@ -1005,7 +1010,7 @@ fn loadRuntimeCss(
// Clears any previously loaded CSS from this provider // Clears any previously loaded CSS from this provider
c.gtk_css_provider_load_from_data( c.gtk_css_provider_load_from_data(
provider, self.css_provider,
buf.items.ptr, buf.items.ptr,
@intCast(buf.items.len), @intCast(buf.items.len),
); );
@ -1054,11 +1059,17 @@ pub fn run(self: *App) !void {
self.transient_cgroup_base = path; self.transient_cgroup_base = path;
} else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"}); } else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"});
// Setup our D-Bus connection for listening to settings changes.
self.initDbus();
// Setup our menu items // Setup our menu items
self.initActions(); self.initActions();
self.initMenu(); self.initMenu();
self.initContextMenu(); self.initContextMenu();
// Setup our initial color scheme
self.colorSchemeEvent(self.getColorScheme());
// On startup, we want to check for configuration errors right away // On startup, we want to check for configuration errors right away
// so we can show our error window. We also need to setup other initial // so we can show our error window. We also need to setup other initial
// state. // state.
@ -1092,6 +1103,26 @@ pub fn run(self: *App) !void {
} }
} }
fn initDbus(self: *App) void {
const dbus = c.g_application_get_dbus_connection(@ptrCast(self.app)) orelse {
log.warn("unable to get dbus connection, not setting up events", .{});
return;
};
_ = c.g_dbus_connection_signal_subscribe(
dbus,
null,
"org.freedesktop.portal.Settings",
"SettingChanged",
"/org/freedesktop/portal/desktop",
"org.freedesktop.appearance",
c.G_DBUS_SIGNAL_FLAGS_MATCH_ARG0_NAMESPACE,
&gtkNotifyColorScheme,
self,
null,
);
}
// This timeout function is started when no surfaces are open. It can be // This timeout function is started when no surfaces are open. It can be
// cancelled if a new surface is opened before the timer expires. // cancelled if a new surface is opened before the timer expires.
pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean { pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean {
@ -1372,7 +1403,7 @@ fn gtkNotifyColorScheme(
parameters: ?*c.GVariant, parameters: ?*c.GVariant,
user_data: ?*anyopaque, user_data: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const core_app: *CoreApp = @ptrCast(@alignCast(user_data orelse { const self: *App = @ptrCast(@alignCast(user_data orelse {
log.err("style change notification: userdata is null", .{}); log.err("style change notification: userdata is null", .{});
return; return;
})); }));
@ -1404,9 +1435,20 @@ fn gtkNotifyColorScheme(
else else
.light; .light;
for (core_app.surfaces.items) |surface| { self.colorSchemeEvent(color_scheme);
surface.core_surface.colorSchemeCallback(color_scheme) catch |err| { }
log.err("unable to tell surface about color scheme change: {}", .{err});
fn colorSchemeEvent(
self: *App,
scheme: apprt.ColorScheme,
) void {
self.core_app.colorSchemeEvent(self, scheme) catch |err| {
log.err("error updating app color scheme err={}", .{err});
};
for (self.core_app.surfaces.items) |surface| {
surface.core_surface.colorSchemeCallback(scheme) catch |err| {
log.err("unable to tell surface about color scheme change err={}", .{err});
}; };
} }
} }

View File

@ -3,6 +3,7 @@ const ConfigErrors = @This();
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const build_config = @import("../../build_config.zig");
const configpkg = @import("../../config.zig"); const configpkg = @import("../../config.zig");
const Config = configpkg.Config; const Config = configpkg.Config;
@ -53,7 +54,7 @@ fn init(self: *ConfigErrors, app: *App) !void {
c.gtk_window_set_title(gtk_window, "Configuration Errors"); c.gtk_window_set_title(gtk_window, "Configuration Errors");
c.gtk_window_set_default_size(gtk_window, 600, 275); c.gtk_window_set_default_size(gtk_window, 600, 275);
c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_resizable(gtk_window, 0);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
// Set some state // Set some state

View File

@ -5,6 +5,7 @@ const Surface = @This();
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const build_config = @import("../../build_config.zig");
const configpkg = @import("../../config.zig"); const configpkg = @import("../../config.zig");
const apprt = @import("../../apprt.zig"); const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig"); const font = @import("../../font/main.zig");
@ -1149,7 +1150,7 @@ pub fn showDesktopNotification(
defer c.g_object_unref(notification); defer c.g_object_unref(notification);
c.g_notification_set_body(notification, body.ptr); c.g_notification_set_body(notification, body.ptr);
const icon = c.g_themed_icon_new("com.mitchellh.ghostty"); const icon = c.g_themed_icon_new(build_config.bundle_id);
defer c.g_object_unref(icon); defer c.g_object_unref(icon);
c.g_notification_set_icon(notification, icon); c.g_notification_set_icon(notification, icon);

View File

@ -103,7 +103,7 @@ pub fn init(self: *Window, app: *App) !void {
// to disable this so that terminal programs can capture F10 (such as htop) // to disable this so that terminal programs can capture F10 (such as htop)
c.gtk_window_set_handle_menubar_accel(gtk_window, 0); c.gtk_window_set_handle_menubar_accel(gtk_window, 0);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
// Apply class to color headerbar if window-theme is set to `ghostty` and // Apply class to color headerbar if window-theme is set to `ghostty` and
// GTK version is before 4.16. The conditional is because above 4.16 // GTK version is before 4.16. The conditional is because above 4.16

View File

@ -2,6 +2,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const assert = std.debug.assert; const assert = std.debug.assert;
const build_config = @import("../../build_config.zig");
const App = @import("App.zig"); const App = @import("App.zig");
const Surface = @import("Surface.zig"); const Surface = @import("Surface.zig");
const TerminalWindow = @import("Window.zig"); const TerminalWindow = @import("Window.zig");
@ -141,7 +142,7 @@ const Window = struct {
self.window = gtk_window; self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_default_size(gtk_window, 1000, 600);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
// Initialize our imgui widget // Initialize our imgui widget
try self.imgui_widget.init(); try self.imgui_widget.init();

View File

@ -58,8 +58,10 @@ pub const Message = union(enum) {
/// Health status change for the renderer. /// Health status change for the renderer.
renderer_health: renderer.Health, renderer_health: renderer.Health,
/// Report the color scheme /// Report the color scheme. The bool parameter is whether to force or not.
report_color_scheme: void, /// If force is true, the color scheme should be reported even if mode
/// 2031 is not set.
report_color_scheme: bool,
/// Tell the surface to present itself to the user. This may require raising /// Tell the surface to present itself to the user. This may require raising
/// a window and switching tabs. /// a window and switching tabs.

View File

@ -53,7 +53,7 @@ fn writeFishCompletions(writer: anytype) !void {
if (std.mem.startsWith(u8, field.name, "font-family")) if (std.mem.startsWith(u8, field.name, "font-family"))
try writer.writeAll(" -f -a \"(ghostty +list-fonts | grep '^[A-Z]')\"") try writer.writeAll(" -f -a \"(ghostty +list-fonts | grep '^[A-Z]')\"")
else if (std.mem.eql(u8, "theme", field.name)) else if (std.mem.eql(u8, "theme", field.name))
try writer.writeAll(" -f -a \"(ghostty +list-themes)\"") try writer.writeAll(" -f -a \"(ghostty +list-themes | sed -E 's/^(.*) \\(.*\\$/\\1/')\"")
else if (std.mem.eql(u8, "working-directory", field.name)) else if (std.mem.eql(u8, "working-directory", field.name))
try writer.writeAll(" -f -k -a \"(__fish_complete_directories)\"") try writer.writeAll(" -f -k -a \"(__fish_complete_directories)\"")
else { else {

View File

@ -103,6 +103,20 @@ pub const app_runtime: apprt.Runtime = config.app_runtime;
pub const font_backend: font.Backend = config.font_backend; pub const font_backend: font.Backend = config.font_backend;
pub const renderer: rendererpkg.Impl = config.renderer; pub const renderer: rendererpkg.Impl = config.renderer;
/// The bundle ID for the app. This is used in many places and is currently
/// hardcoded here. We could make this configurable in the future if there
/// is a reason to do so.
///
/// On macOS, this must match the App bundle ID. We can get that dynamically
/// via an API but I don't want to pay the cost of that at runtime.
///
/// On GTK, this should match the various folders with resources.
///
/// There are many places that don't use this variable so simply swapping
/// this variable is NOT ENOUGH to change the bundle ID. I just wanted to
/// avoid it in Zig coe as much as possible.
pub const bundle_id = "com.mitchellh.ghostty";
/// True if we should have "slow" runtime safety checks. The initial motivation /// True if we should have "slow" runtime safety checks. The initial motivation
/// for this was terminal page/pagelist integrity checks. These were VERY /// for this was terminal page/pagelist integrity checks. These were VERY
/// slow but very thorough. But they made it so slow that the terminal couldn't /// slow but very thorough. But they made it so slow that the terminal couldn't

View File

@ -104,7 +104,7 @@ pub fn parse(
try dst._diagnostics.append(arena_alloc, .{ try dst._diagnostics.append(arena_alloc, .{
.key = try arena_alloc.dupeZ(u8, arg), .key = try arena_alloc.dupeZ(u8, arg),
.message = "invalid field", .message = "invalid field",
.location = diags.Location.fromIter(iter), .location = try diags.Location.fromIter(iter, arena_alloc),
}); });
continue; continue;
@ -145,7 +145,7 @@ pub fn parse(
try dst._diagnostics.append(arena_alloc, .{ try dst._diagnostics.append(arena_alloc, .{
.key = try arena_alloc.dupeZ(u8, key), .key = try arena_alloc.dupeZ(u8, key),
.message = message, .message = message,
.location = diags.Location.fromIter(iter), .location = try diags.Location.fromIter(iter, arena_alloc),
}); });
}; };
} }
@ -1140,7 +1140,7 @@ pub fn ArgsIterator(comptime Iterator: type) type {
} }
/// Returns a location for a diagnostic message. /// Returns a location for a diagnostic message.
pub fn location(self: *const Self) ?diags.Location { pub fn location(self: *const Self, _: Allocator) error{}!?diags.Location {
return .{ .cli = self.index }; return .{ .cli = self.index };
} }
}; };
@ -1262,12 +1262,15 @@ pub fn LineIterator(comptime ReaderType: type) type {
} }
/// Returns a location for a diagnostic message. /// Returns a location for a diagnostic message.
pub fn location(self: *const Self) ?diags.Location { pub fn location(
self: *const Self,
alloc: Allocator,
) Allocator.Error!?diags.Location {
// If we have no filepath then we have no location. // If we have no filepath then we have no location.
if (self.filepath.len == 0) return null; if (self.filepath.len == 0) return null;
return .{ .file = .{ return .{ .file = .{
.path = self.filepath, .path = try alloc.dupe(u8, self.filepath),
.line = self.line, .line = self.line,
} }; } };
} }

View File

@ -34,6 +34,14 @@ pub const Diagnostic = struct {
try writer.print("{s}", .{self.message}); try writer.print("{s}", .{self.message});
} }
pub fn clone(self: *const Diagnostic, alloc: Allocator) Allocator.Error!Diagnostic {
return .{
.location = try self.location.clone(alloc),
.key = try alloc.dupeZ(u8, self.key),
.message = try alloc.dupeZ(u8, self.message),
};
}
}; };
/// The possible locations for a diagnostic message. This is used /// The possible locations for a diagnostic message. This is used
@ -48,7 +56,7 @@ pub const Location = union(enum) {
pub const Key = @typeInfo(Location).Union.tag_type.?; pub const Key = @typeInfo(Location).Union.tag_type.?;
pub fn fromIter(iter: anytype) Location { pub fn fromIter(iter: anytype, alloc: Allocator) Allocator.Error!Location {
const Iter = t: { const Iter = t: {
const T = @TypeOf(iter); const T = @TypeOf(iter);
break :t switch (@typeInfo(T)) { break :t switch (@typeInfo(T)) {
@ -59,7 +67,20 @@ pub const Location = union(enum) {
}; };
if (!@hasDecl(Iter, "location")) return .none; if (!@hasDecl(Iter, "location")) return .none;
return iter.location() orelse .none; return (try iter.location(alloc)) orelse .none;
}
pub fn clone(self: *const Location, alloc: Allocator) Allocator.Error!Location {
return switch (self.*) {
.none,
.cli,
=> self.*,
.file => |v| .{ .file = .{
.path = try alloc.dupe(u8, v.path),
.line = v.line,
} },
};
} }
}; };
@ -88,11 +109,45 @@ pub const DiagnosticList = struct {
// We specifically want precompute for libghostty. // We specifically want precompute for libghostty.
.lib => true, .lib => true,
}; };
const Precompute = if (precompute_enabled) struct { const Precompute = if (precompute_enabled) struct {
messages: std.ArrayListUnmanaged([:0]const u8) = .{}, messages: std.ArrayListUnmanaged([:0]const u8) = .{},
pub fn clone(
self: *const Precompute,
alloc: Allocator,
) Allocator.Error!Precompute {
var result: Precompute = .{};
try result.messages.ensureTotalCapacity(alloc, self.messages.items.len);
for (self.messages.items) |msg| {
result.messages.appendAssumeCapacity(
try alloc.dupeZ(u8, msg),
);
}
return result;
}
} else void; } else void;
const precompute_init: Precompute = if (precompute_enabled) .{} else {}; const precompute_init: Precompute = if (precompute_enabled) .{} else {};
pub fn clone(
self: *const DiagnosticList,
alloc: Allocator,
) Allocator.Error!DiagnosticList {
var result: DiagnosticList = .{};
try result.list.ensureTotalCapacity(alloc, self.list.items.len);
for (self.list.items) |*diag| result.list.appendAssumeCapacity(
try diag.clone(alloc),
);
if (comptime precompute_enabled) {
result.precompute = try self.precompute.clone(alloc);
}
return result;
}
pub fn append( pub fn append(
self: *DiagnosticList, self: *DiagnosticList,
alloc: Allocator, alloc: Allocator,

View File

@ -527,6 +527,10 @@ palette: Palette = .{},
/// The opacity level (opposite of transparency) of the background. A value of /// The opacity level (opposite of transparency) of the background. A value of
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0
/// or greater than 1 will be clamped to the nearest valid value. /// or greater than 1 will be clamped to the nearest valid value.
///
/// On macOS, background opacity is disabled when the terminal enters native
/// fullscreen. This is because the background becomes gray and it can cause
/// widgets to show through which isn't generally desirable.
@"background-opacity": f64 = 1.0, @"background-opacity": f64 = 1.0,
/// A positive value enables blurring of the background when background-opacity /// A positive value enables blurring of the background when background-opacity
@ -1793,6 +1797,10 @@ _diagnostics: cli.DiagnosticList = .{},
/// determine if a conditional configuration matches or not. /// determine if a conditional configuration matches or not.
_conditional_state: conditional.State = .{}, _conditional_state: conditional.State = .{},
/// The conditional keys that are used at any point during the configuration
/// loading. This is used to speed up the conditional evaluation process.
_conditional_set: std.EnumSet(conditional.Key) = .{},
/// The steps we can use to reload the configuration after it has been loaded /// The steps we can use to reload the configuration after it has been loaded
/// without reopening the files. This is used in very specific cases such /// without reopening the files. This is used in very specific cases such
/// as loadTheme which has more details on why. /// as loadTheme which has more details on why.
@ -1809,9 +1817,10 @@ pub fn deinit(self: *Config) void {
/// Load the configuration according to the default rules: /// Load the configuration according to the default rules:
/// ///
/// 1. Defaults /// 1. Defaults
/// 2. XDG Config File /// 2. XDG config dir
/// 3. CLI flags /// 3. "Application Support" directory (macOS only)
/// 4. Recursively defined configuration files /// 4. CLI flags
/// 5. Recursively defined configuration files
/// ///
pub fn load(alloc_gpa: Allocator) !Config { pub fn load(alloc_gpa: Allocator) !Config {
var result = try default(alloc_gpa); var result = try default(alloc_gpa);
@ -2394,25 +2403,37 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void {
try self.expandPaths(std.fs.path.dirname(path).?); try self.expandPaths(std.fs.path.dirname(path).?);
} }
/// Load the configuration from the default configuration file. The default /// Load optional configuration file from `path`. All errors are ignored.
/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`. pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void {
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { self.loadFile(alloc, path) catch |err| switch (err) {
const config_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
defer alloc.free(config_path);
self.loadFile(alloc, config_path) catch |err| switch (err) {
error.FileNotFound => std.log.info( error.FileNotFound => std.log.info(
"homedir config not found, not loading path={s}", "optional config file not found, not loading path={s}",
.{config_path}, .{path},
), ),
else => std.log.warn( else => std.log.warn(
"error reading config file, not loading err={} path={s}", "error reading optional config file, not loading err={} path={s}",
.{ err, config_path }, .{ err, path },
), ),
}; };
} }
/// Load configurations from the default configuration files. The default
/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`.
///
/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config`
/// is also loaded.
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
defer alloc.free(xdg_path);
self.loadOptionalFile(alloc, xdg_path);
if (comptime builtin.os.tag == .macos) {
const app_support_path = try internal_os.macos.appSupportDir(alloc, "config");
defer alloc.free(app_support_path);
self.loadOptionalFile(alloc, app_support_path);
}
}
/// Load and parse the CLI args. /// Load and parse the CLI args.
pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
switch (builtin.os.tag) { switch (builtin.os.tag) {
@ -2610,6 +2631,10 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
/// on the new state. The caller must free the old configuration if they /// on the new state. The caller must free the old configuration if they
/// wish. /// wish.
/// ///
/// This returns null if the conditional state would result in no changes
/// to the configuration. In this case, the caller can continue to use
/// the existing configuration or clone if they want a copy.
///
/// This doesn't re-read any files, it just re-applies the same /// This doesn't re-read any files, it just re-applies the same
/// configuration with the new conditional state. Importantly, this means /// configuration with the new conditional state. Importantly, this means
/// that if you change the conditional state and the user in the interim /// that if you change the conditional state and the user in the interim
@ -2618,7 +2643,30 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
pub fn changeConditionalState( pub fn changeConditionalState(
self: *const Config, self: *const Config,
new: conditional.State, new: conditional.State,
) !Config { ) !?Config {
// If the conditional state between the old and new is the same,
// then we don't need to do anything.
relevant: {
inline for (@typeInfo(conditional.Key).Enum.fields) |field| {
const key: conditional.Key = @field(conditional.Key, field.name);
// Conditional set contains the keys that this config uses. So we
// only continue if we use this key.
if (self._conditional_set.contains(key) and !equalField(
@TypeOf(@field(self._conditional_state, field.name)),
@field(self._conditional_state, field.name),
@field(new, field.name),
)) {
break :relevant;
}
}
// If we got here, then we didn't find any differences between
// the old and new conditional state that would affect the
// configuration.
return null;
}
// Create our new configuration // Create our new configuration
const alloc_gpa = self._arena.?.child_allocator; const alloc_gpa = self._arena.?.child_allocator;
var new_config = try self.cloneEmpty(alloc_gpa); var new_config = try self.cloneEmpty(alloc_gpa);
@ -2765,6 +2813,9 @@ pub fn finalize(self: *Config) !void {
// This setting doesn't make sense with different light/dark themes // This setting doesn't make sense with different light/dark themes
// because it'll force the theme based on the Ghostty theme. // because it'll force the theme based on the Ghostty theme.
if (self.@"window-theme" == .auto) self.@"window-theme" = .system; if (self.@"window-theme" == .auto) self.@"window-theme" = .system;
// Mark that we use a conditional theme
self._conditional_set.insert(.theme);
} }
} }
@ -2941,7 +2992,7 @@ pub fn parseManuallyHook(
if (command.items.len == 0) { if (command.items.len == 0) {
try self._diagnostics.append(alloc, .{ try self._diagnostics.append(alloc, .{
.location = cli.Location.fromIter(iter), .location = try cli.Location.fromIter(iter, alloc),
.message = try std.fmt.allocPrintZ( .message = try std.fmt.allocPrintZ(
alloc, alloc,
"missing command after {s}", "missing command after {s}",
@ -2995,22 +3046,47 @@ pub fn cloneEmpty(
/// Create a copy of this configuration. /// Create a copy of this configuration.
/// ///
/// This will not re-read referenced configuration files except for the /// This will not re-read referenced configuration files and operates
/// theme, but the config-file values will be preserved. /// purely in-memory.
pub fn clone( pub fn clone(
self: *const Config, self: *const Config,
alloc_gpa: Allocator, alloc_gpa: Allocator,
) !Config { ) Allocator.Error!Config {
// Create a new config with a new arena // Start with an empty config
var new_config = try self.cloneEmpty(alloc_gpa); var result = try self.cloneEmpty(alloc_gpa);
errdefer new_config.deinit(); errdefer result.deinit();
const alloc_arena = result._arena.?.allocator();
// Replay all of our steps to rebuild the configuration // Copy our values
var it = Replay.iterator(self._replay_steps.items, &new_config); inline for (@typeInfo(Config).Struct.fields) |field| {
try new_config.loadIter(alloc_gpa, &it); if (!@hasField(Key, field.name)) continue;
try new_config.finalize(); @field(result, field.name) = try cloneValue(
alloc_arena,
field.type,
@field(self, field.name),
);
}
return new_config; // Copy our diagnostics
result._diagnostics = try self._diagnostics.clone(alloc_arena);
// Preserve our replay steps. We copy them exactly to also preserve
// the exact conditionals required for some steps.
try result._replay_steps.ensureTotalCapacity(
alloc_arena,
self._replay_steps.items.len,
);
for (self._replay_steps.items) |item| {
result._replay_steps.appendAssumeCapacity(
try item.clone(alloc_arena),
);
}
assert(result._replay_steps.items.len == self._replay_steps.items.len);
// Copy the conditional set
result._conditional_set = self._conditional_set;
return result;
} }
fn cloneValue( fn cloneValue(
@ -3204,6 +3280,24 @@ const Replay = struct {
conditions: []const Conditional, conditions: []const Conditional,
arg: []const u8, arg: []const u8,
}, },
fn clone(
self: Step,
alloc: Allocator,
) Allocator.Error!Step {
return switch (self) {
.arg => |v| .{ .arg = try alloc.dupe(u8, v) },
.expand => |v| .{ .expand = try alloc.dupe(u8, v) },
.conditional_arg => |v| conditional: {
var conds = try alloc.alloc(Conditional, v.conditions.len);
for (v.conditions, 0..) |cond, i| conds[i] = try cond.clone(alloc);
break :conditional .{ .conditional_arg = .{
.conditions = conds,
.arg = try alloc.dupe(u8, v.arg),
} };
},
};
}
}; };
const Iterator = struct { const Iterator = struct {
@ -4523,17 +4617,33 @@ pub const RepeatableLink = struct {
} }
/// Deep copy of the struct. Required by Config. /// Deep copy of the struct. Required by Config.
pub fn clone(self: *const Self, alloc: Allocator) error{}!Self { pub fn clone(
_ = self; self: *const Self,
_ = alloc; alloc: Allocator,
return .{}; ) Allocator.Error!Self {
// Note: we don't do any errdefers below since the allocation
// is expected to be arena allocated.
var list = try std.ArrayListUnmanaged(inputpkg.Link).initCapacity(
alloc,
self.links.items.len,
);
for (self.links.items) |item| {
const copy = try item.clone(alloc);
list.appendAssumeCapacity(copy);
}
return .{ .links = list };
} }
/// Compare if two of our value are requal. Required by Config. /// Compare if two of our value are requal. Required by Config.
pub fn equal(self: Self, other: Self) bool { pub fn equal(self: Self, other: Self) bool {
_ = self; const itemsA = self.links.items;
_ = other; const itemsB = other.links.items;
return true; if (itemsA.len != itemsB.len) return false;
for (itemsA, itemsB) |*a, *b| {
if (!a.equal(b)) return false;
} else return true;
} }
/// Used by Formatter /// Used by Formatter
@ -5221,20 +5331,107 @@ test "clone preserves conditional state" {
var a = try Config.default(alloc); var a = try Config.default(alloc);
defer a.deinit(); defer a.deinit();
var b = try a.changeConditionalState(.{ .theme = .dark }); a._conditional_state.theme = .dark;
defer b.deinit(); try testing.expectEqual(.dark, a._conditional_state.theme);
try testing.expectEqual(.dark, b._conditional_state.theme); var dest = try a.clone(alloc);
var dest = try b.clone(alloc);
defer dest.deinit(); defer dest.deinit();
// Should have no changes // Should have no changes
var it = b.changeIterator(&dest); var it = a.changeIterator(&dest);
try testing.expectEqual(@as(?Key, null), it.next()); try testing.expectEqual(@as(?Key, null), it.next());
// Should have the same conditional state // Should have the same conditional state
try testing.expectEqual(.dark, dest._conditional_state.theme); try testing.expectEqual(.dark, dest._conditional_state.theme);
} }
test "clone can then change conditional state" {
// This tests a particular bug sequence where:
// 1. Load light
// 2. Convert to dark
// 3. Clone dark
// 4. Convert to light
// 5. Config is still dark (bug)
const testing = std.testing;
const alloc = testing.allocator;
var arena = ArenaAllocator.init(alloc);
defer arena.deinit();
const alloc_arena = arena.allocator();
// Setup our test theme
var td = try internal_os.TempDir.init();
defer td.deinit();
{
var file = try td.dir.createFile("theme_light", .{});
defer file.close();
try file.writer().writeAll(@embedFile("testdata/theme_light"));
}
{
var file = try td.dir.createFile("theme_dark", .{});
defer file.close();
try file.writer().writeAll(@embedFile("testdata/theme_dark"));
}
var light_buf: [std.fs.max_path_bytes]u8 = undefined;
const light = try td.dir.realpath("theme_light", &light_buf);
var dark_buf: [std.fs.max_path_bytes]u8 = undefined;
const dark = try td.dir.realpath("theme_dark", &dark_buf);
var cfg_light = try Config.default(alloc);
defer cfg_light.deinit();
var it: TestIterator = .{ .data = &.{
try std.fmt.allocPrint(
alloc_arena,
"--theme=light:{s},dark:{s}",
.{ light, dark },
),
} };
try cfg_light.loadIter(alloc, &it);
try cfg_light.finalize();
var cfg_dark = (try cfg_light.changeConditionalState(.{ .theme = .dark })).?;
defer cfg_dark.deinit();
try testing.expectEqual(Color{
.r = 0xEE,
.g = 0xEE,
.b = 0xEE,
}, cfg_dark.background);
var cfg_clone = try cfg_dark.clone(alloc);
defer cfg_clone.deinit();
try testing.expectEqual(Color{
.r = 0xEE,
.g = 0xEE,
.b = 0xEE,
}, cfg_clone.background);
var cfg_light2 = (try cfg_clone.changeConditionalState(.{ .theme = .light })).?;
defer cfg_light2.deinit();
try testing.expectEqual(Color{
.r = 0xFF,
.g = 0xFF,
.b = 0xFF,
}, cfg_light2.background);
}
test "clone preserves conditional set" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=light:foo,dark:bar",
"--window-theme=auto",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
var clone1 = try cfg.clone(alloc);
defer clone1.deinit();
try testing.expect(clone1._conditional_set.contains(.theme));
}
test "changed" { test "changed" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -5249,6 +5446,44 @@ test "changed" {
try testing.expect(!source.changed(&dest, .@"font-size")); try testing.expect(!source.changed(&dest, .@"font-size"));
} }
test "changeConditionalState ignores irrelevant changes" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=foo",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
try testing.expect(try cfg.changeConditionalState(
.{ .theme = .dark },
) == null);
}
}
test "changeConditionalState applies relevant changes" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=light:foo,dark:bar",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
var cfg2 = (try cfg.changeConditionalState(.{ .theme = .dark })).?;
defer cfg2.deinit();
try testing.expect(cfg2._conditional_set.contains(.theme));
}
}
test "theme loading" { test "theme loading" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -5280,6 +5515,9 @@ test "theme loading" {
.g = 0x3A, .g = 0x3A,
.b = 0xBC, .b = 0xBC,
}, cfg.background); }, cfg.background);
// Not a conditional theme
try testing.expect(!cfg._conditional_set.contains(.theme));
} }
test "theme loading preserves conditional state" { test "theme loading preserves conditional state" {
@ -5428,7 +5666,7 @@ test "theme loading correct light/dark" {
try cfg.loadIter(alloc, &it); try cfg.loadIter(alloc, &it);
try cfg.finalize(); try cfg.finalize();
var new = try cfg.changeConditionalState(.{ .theme = .dark }); var new = (try cfg.changeConditionalState(.{ .theme = .dark })).?;
defer new.deinit(); defer new.deinit();
try testing.expectEqual(Color{ try testing.expectEqual(Color{
.r = 0xEE, .r = 0xEE,
@ -5455,3 +5693,22 @@ test "theme specifying light/dark changes window-theme from auto" {
try testing.expect(cfg.@"window-theme" == .system); try testing.expect(cfg.@"window-theme" == .system);
} }
} }
test "theme specifying light/dark sets theme usage in conditional state" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=light:foo,dark:bar",
"--window-theme=auto",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
try testing.expect(cfg.@"window-theme" == .system);
try testing.expect(cfg._conditional_set.contains(.theme));
}
}

View File

@ -61,6 +61,17 @@ pub const Conditional = struct {
value: []const u8, value: []const u8,
pub const Op = enum { eq, ne }; pub const Op = enum { eq, ne };
pub fn clone(
self: Conditional,
alloc: Allocator,
) Allocator.Error!Conditional {
return .{
.key = self.key,
.op = self.op,
.value = try alloc.dupe(u8, self.value),
};
}
}; };
test "conditional enum match" { test "conditional enum match" {

View File

@ -4,6 +4,8 @@
//! action types. //! action types.
const Link = @This(); const Link = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const oni = @import("oniguruma"); const oni = @import("oniguruma");
const Mods = @import("key.zig").Mods; const Mods = @import("key.zig").Mods;
@ -59,3 +61,19 @@ pub fn oniRegex(self: *const Link) !oni.Regex {
null, null,
); );
} }
/// Deep clone the link.
pub fn clone(self: *const Link, alloc: Allocator) Allocator.Error!Link {
return .{
.regex = try alloc.dupe(u8, self.regex),
.action = self.action,
.highlight = self.highlight,
};
}
/// Check if two links are equal.
pub fn equal(self: *const Link, other: *const Link) bool {
return std.meta.eql(self.action, other.action) and
std.meta.eql(self.highlight, other.highlight) and
std.mem.eql(u8, self.regex, other.regex);
}

View File

@ -141,7 +141,7 @@ fn logFn(
// Initialize a logger. This is slow to do on every operation // Initialize a logger. This is slow to do on every operation
// but we shouldn't be logging too much. // but we shouldn't be logging too much.
const logger = macos.os.Log.create("com.mitchellh.ghostty", @tagName(scope)); const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope));
defer logger.release(); defer logger.release();
logger.log(std.heap.c_allocator, mac_level, format, args); logger.log(std.heap.c_allocator, mac_level, format, args);
} }

118
src/os/macos.zig Normal file
View File

@ -0,0 +1,118 @@
const std = @import("std");
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const assert = std.debug.assert;
const objc = @import("objc");
const Allocator = std.mem.Allocator;
/// Verifies that the running macOS system version is at least the given version.
pub fn isAtLeastVersion(major: i64, minor: i64, patch: i64) bool {
comptime assert(builtin.target.isDarwin());
const NSProcessInfo = objc.getClass("NSProcessInfo").?;
const info = NSProcessInfo.msgSend(objc.Object, objc.sel("processInfo"), .{});
return info.msgSend(bool, objc.sel("isOperatingSystemAtLeastVersion:"), .{
NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch },
});
}
pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed};
/// Return the path to the application support directory for Ghostty
/// with the given sub path joined. This allocates the result using the
/// given allocator.
pub fn appSupportDir(
alloc: Allocator,
sub_path: []const u8,
) AppSupportDirError![]u8 {
comptime assert(builtin.target.isDarwin());
const NSFileManager = objc.getClass("NSFileManager").?;
const manager = NSFileManager.msgSend(
objc.Object,
objc.sel("defaultManager"),
.{},
);
const url = manager.msgSend(
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
.{
NSSearchPathDirectory.NSApplicationSupportDirectory,
NSSearchPathDomainMask.NSUserDomainMask,
@as(?*anyopaque, null),
true,
@as(?*anyopaque, null),
},
);
// I don't think this is possible but just in case.
if (url.value == null) return error.AppleAPIFailed;
// Get the UTF-8 string from the URL.
const path = url.getProperty(objc.Object, "path");
const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse
return error.AppleAPIFailed;
const app_support_dir = std.mem.sliceTo(c_str, 0);
return try std.fs.path.join(alloc, &.{
app_support_dir,
build_config.bundle_id,
sub_path,
});
}
pub const SetQosClassError = error{
// The thread can't have its QoS class changed usually because
// a different pthread API was called that makes it an invalid
// target.
ThreadIncompatible,
};
/// Set the QoS class of the running thread.
///
/// https://developer.apple.com/documentation/apple-silicon/tuning-your-code-s-performance-for-apple-silicon?preferredLanguage=occ
pub fn setQosClass(class: QosClass) !void {
return switch (std.posix.errno(pthread_set_qos_class_self_np(
class,
0,
))) {
.SUCCESS => {},
.PERM => error.ThreadIncompatible,
// EPERM is the only known error that can happen based on
// the man pages for pthread_set_qos_class_self_np. I haven't
// checked the XNU source code to see if there are other
// possible errors.
else => @panic("unexpected pthread_set_qos_class_self_np error"),
};
}
/// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheTaskLevel.html#//apple_ref/doc/uid/TP40013929-CH35-SW1
pub const QosClass = enum(c_uint) {
user_interactive = 0x21,
user_initiated = 0x19,
default = 0x15,
utility = 0x11,
background = 0x09,
unspecified = 0x00,
};
extern "c" fn pthread_set_qos_class_self_np(
qos_class: QosClass,
relative_priority: c_int,
) c_int;
pub const NSOperatingSystemVersion = extern struct {
major: i64,
minor: i64,
patch: i64,
};
pub const NSSearchPathDirectory = enum(c_ulong) {
NSApplicationSupportDirectory = 14,
};
pub const NSSearchPathDomainMask = enum(c_ulong) {
NSUserDomainMask = 1,
};

View File

@ -1,21 +0,0 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const objc = @import("objc");
/// Verifies that the running macOS system version is at least the given version.
pub fn macosVersionAtLeast(major: i64, minor: i64, patch: i64) bool {
assert(builtin.target.isDarwin());
const NSProcessInfo = objc.getClass("NSProcessInfo").?;
const info = NSProcessInfo.msgSend(objc.Object, objc.sel("processInfo"), .{});
return info.msgSend(bool, objc.sel("isOperatingSystemAtLeastVersion:"), .{
NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch },
});
}
pub const NSOperatingSystemVersion = extern struct {
major: i64,
minor: i64,
patch: i64,
};

View File

@ -8,7 +8,6 @@ const file = @import("file.zig");
const flatpak = @import("flatpak.zig"); const flatpak = @import("flatpak.zig");
const homedir = @import("homedir.zig"); const homedir = @import("homedir.zig");
const locale = @import("locale.zig"); const locale = @import("locale.zig");
const macos_version = @import("macos_version.zig");
const mouse = @import("mouse.zig"); const mouse = @import("mouse.zig");
const openpkg = @import("open.zig"); const openpkg = @import("open.zig");
const pipepkg = @import("pipe.zig"); const pipepkg = @import("pipe.zig");
@ -21,6 +20,7 @@ pub const hostname = @import("hostname.zig");
pub const passwd = @import("passwd.zig"); pub const passwd = @import("passwd.zig");
pub const xdg = @import("xdg.zig"); pub const xdg = @import("xdg.zig");
pub const windows = @import("windows.zig"); pub const windows = @import("windows.zig");
pub const macos = @import("macos.zig");
// Functions and types // Functions and types
pub const CFReleaseThread = @import("cf_release_thread.zig"); pub const CFReleaseThread = @import("cf_release_thread.zig");
@ -37,7 +37,6 @@ pub const freeTmpDir = file.freeTmpDir;
pub const isFlatpak = flatpak.isFlatpak; pub const isFlatpak = flatpak.isFlatpak;
pub const home = homedir.home; pub const home = homedir.home;
pub const ensureLocale = locale.ensureLocale; pub const ensureLocale = locale.ensureLocale;
pub const macosVersionAtLeast = macos_version.macosVersionAtLeast;
pub const clickInterval = mouse.clickInterval; pub const clickInterval = mouse.clickInterval;
pub const open = openpkg.open; pub const open = openpkg.open;
pub const pipe = pipepkg.pipe; pub const pipe = pipepkg.pipe;

View File

@ -4,8 +4,10 @@ pub const Thread = @This();
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const assert = std.debug.assert;
const xev = @import("xev"); const xev = @import("xev");
const crash = @import("../crash/main.zig"); const crash = @import("../crash/main.zig");
const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
@ -92,6 +94,10 @@ flags: packed struct {
/// This is true when the view is visible. This is used to determine /// This is true when the view is visible. This is used to determine
/// if we should be rendering or not. /// if we should be rendering or not.
visible: bool = true, visible: bool = true,
/// This is true when the view is focused. This defaults to true
/// and it is up to the apprt to set the correct value.
focused: bool = true,
} = .{}, } = .{},
pub const DerivedConfig = struct { pub const DerivedConfig = struct {
@ -199,6 +205,9 @@ fn threadMain_(self: *Thread) !void {
}; };
defer crash.sentry.thread_state = null; defer crash.sentry.thread_state = null;
// Setup our thread QoS
self.setQosClass();
// Run our loop start/end callbacks if the renderer cares. // Run our loop start/end callbacks if the renderer cares.
const has_loop = @hasDecl(renderer.Renderer, "loopEnter"); const has_loop = @hasDecl(renderer.Renderer, "loopEnter");
if (has_loop) try self.renderer.loopEnter(self); if (has_loop) try self.renderer.loopEnter(self);
@ -237,6 +246,36 @@ fn threadMain_(self: *Thread) !void {
_ = try self.loop.run(.until_done); _ = try self.loop.run(.until_done);
} }
fn setQosClass(self: *const Thread) void {
// Thread QoS classes are only relevant on macOS.
if (comptime !builtin.target.isDarwin()) return;
const class: internal_os.macos.QosClass = class: {
// If we aren't visible (our view is fully occluded) then we
// always drop our rendering priority down because it's just
// mostly wasted work.
//
// The renderer itself should be doing this as well (for example
// Metal will stop our DisplayLink) but this also helps with
// general forced updates and CPU usage i.e. a rebuild cells call.
if (!self.flags.visible) break :class .utility;
// If we're not focused, but we're visible, then we set a higher
// than default priority because framerates still matter but it isn't
// as important as when we're focused.
if (!self.flags.focused) break :class .user_initiated;
// We are focused and visible, we are the definition of user interactive.
break :class .user_interactive;
};
if (internal_os.macos.setQosClass(class)) {
log.debug("thread QoS class set class={}", .{class});
} else |err| {
log.warn("error setting QoS class err={}", .{err});
}
}
fn startDrawTimer(self: *Thread) void { fn startDrawTimer(self: *Thread) void {
// If our renderer doesn't support animations then we never run this. // If our renderer doesn't support animations then we never run this.
if (!@hasDecl(renderer.Renderer, "hasAnimations")) return; if (!@hasDecl(renderer.Renderer, "hasAnimations")) return;
@ -273,10 +312,16 @@ fn drainMailbox(self: *Thread) !void {
switch (message) { switch (message) {
.crash => @panic("crash request, crashing intentionally"), .crash => @panic("crash request, crashing intentionally"),
.visible => |v| { .visible => |v| visible: {
// If our state didn't change we do nothing.
if (self.flags.visible == v) break :visible;
// Set our visible state // Set our visible state
self.flags.visible = v; self.flags.visible = v;
// Visibility affects our QoS class
self.setQosClass();
// If we became visible then we immediately trigger a draw. // If we became visible then we immediately trigger a draw.
// We don't need to update frame data because that should // We don't need to update frame data because that should
// still be happening. // still be happening.
@ -293,7 +338,16 @@ fn drainMailbox(self: *Thread) !void {
// check the visible state themselves to control their behavior. // check the visible state themselves to control their behavior.
}, },
.focus => |v| { .focus => |v| focus: {
// If our state didn't change we do nothing.
if (self.flags.focused == v) break :focus;
// Set our state
self.flags.focused = v;
// Focus affects our QoS class
self.setQosClass();
// Set it on the renderer // Set it on the renderer
try self.renderer.setFocus(v); try self.renderer.setFocus(v);

View File

@ -1955,13 +1955,9 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void {
} }
pub fn eraseChars(self: *Terminal, count_req: usize) void { pub fn eraseChars(self: *Terminal, count_req: usize) void {
const count = @max(count_req, 1); const count = end: {
// Our last index is at most the end of the number of chars we have
// in the current line.
const end = end: {
const remaining = self.cols - self.screen.cursor.x; const remaining = self.cols - self.screen.cursor.x;
var end = @min(remaining, count); var end = @min(remaining, @max(count_req, 1));
// If our last cell is a wide char then we need to also clear the // If our last cell is a wide char then we need to also clear the
// cell beyond it since we can't just split a wide char. // cell beyond it since we can't just split a wide char.
@ -1979,7 +1975,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
// protected modes. We need to figure out how to make `clearCells` or at // protected modes. We need to figure out how to make `clearCells` or at
// least `clearUnprotectedCells` handle boundary conditions... // least `clearUnprotectedCells` handle boundary conditions...
self.screen.splitCellBoundary(self.screen.cursor.x); self.screen.splitCellBoundary(self.screen.cursor.x);
self.screen.splitCellBoundary(end); self.screen.splitCellBoundary(self.screen.cursor.x + count);
// Reset our row's soft-wrap. // Reset our row's soft-wrap.
self.screen.cursorResetWrap(); self.screen.cursorResetWrap();
@ -1997,7 +1993,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
self.screen.clearCells( self.screen.clearCells(
&self.screen.cursor.page_pin.node.data, &self.screen.cursor.page_pin.node.data,
self.screen.cursor.page_row, self.screen.cursor.page_row,
cells[0..end], cells[0..count],
); );
return; return;
} }
@ -2005,7 +2001,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
self.screen.clearUnprotectedCells( self.screen.clearUnprotectedCells(
&self.screen.cursor.page_pin.node.data, &self.screen.cursor.page_pin.node.data,
self.screen.cursor.page_row, self.screen.cursor.page_row,
cells[0..end], cells[0..count],
); );
} }
@ -6104,6 +6100,36 @@ test "Terminal: eraseChars wide char boundary conditions" {
} }
} }
test "Terminal: eraseChars wide char splits proper cell boundaries" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 1, .cols = 30 });
defer t.deinit(alloc);
// This is a test for a bug: https://github.com/ghostty-org/ghostty/issues/2817
// To explain the setup:
// (1) We need our wide characters starting on an even (1-based) column.
// (2) We need our cursor to be in the middle somewhere.
// (3) We need our count to be less than our cursor X and on a split cell.
// The bug was that we split the wrong cell boundaries.
try t.printString("x食べて下さい");
{
const str = try t.plainString(alloc);
defer testing.allocator.free(str);
try testing.expectEqualStrings("x食べて下さい", str);
}
t.setCursorPos(1, 6); // At:
t.eraseChars(4); // Delete:
t.screen.cursor.page_pin.node.data.assertIntegrity();
{
const str = try t.plainString(alloc);
defer testing.allocator.free(str);
try testing.expectEqualStrings("x食べ さい", str);
}
}
test "Terminal: eraseChars wide char wrap boundary conditions" { test "Terminal: eraseChars wide char wrap boundary conditions" {
const alloc = testing.allocator; const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 3, .cols = 8 }); var t = try init(alloc, .{ .rows = 3, .cols = 8 });

View File

@ -843,6 +843,7 @@ const Subprocess = struct {
// Don't leak these environment variables to child processes. // Don't leak these environment variables to child processes.
if (comptime build_config.app_runtime == .gtk) { if (comptime build_config.app_runtime == .gtk) {
env.remove("GDK_DEBUG"); env.remove("GDK_DEBUG");
env.remove("GDK_DISABLE");
env.remove("GSK_RENDERER"); env.remove("GSK_RENDERER");
} }

View File

@ -200,9 +200,9 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
.default_cursor_style = opts.config.cursor_style, .default_cursor_style = opts.config.cursor_style,
.default_cursor_blink = opts.config.cursor_blink, .default_cursor_blink = opts.config.cursor_blink,
.default_cursor_color = default_cursor_color, .default_cursor_color = default_cursor_color,
.cursor_color = default_cursor_color, .cursor_color = null,
.foreground_color = opts.config.foreground.toTerminalRGB(), .foreground_color = null,
.background_color = opts.config.background.toTerminalRGB(), .background_color = null,
}; };
}; };

View File

@ -78,7 +78,7 @@ pub fn setup(
try setupXdgDataDirs(alloc_arena, resource_dir, env); try setupXdgDataDirs(alloc_arena, resource_dir, env);
break :shell .{ break :shell .{
.shell = .elvish, .shell = .elvish,
.command = command, .command = try alloc_arena.dupe(u8, command),
}; };
} }
@ -86,7 +86,7 @@ pub fn setup(
try setupXdgDataDirs(alloc_arena, resource_dir, env); try setupXdgDataDirs(alloc_arena, resource_dir, env);
break :shell .{ break :shell .{
.shell = .fish, .shell = .fish,
.command = command, .command = try alloc_arena.dupe(u8, command),
}; };
} }
@ -94,7 +94,7 @@ pub fn setup(
try setupZsh(resource_dir, env); try setupZsh(resource_dir, env);
break :shell .{ break :shell .{
.shell = .zsh, .shell = .zsh,
.command = command, .command = try alloc_arena.dupe(u8, command),
}; };
} }

View File

@ -52,20 +52,20 @@ pub const StreamHandler = struct {
default_cursor_blink: ?bool, default_cursor_blink: ?bool,
default_cursor_color: ?terminal.color.RGB, default_cursor_color: ?terminal.color.RGB,
/// Actual cursor color. This can be changed with OSC 12. /// Actual cursor color. This can be changed with OSC 12. If unset, falls
/// back to the default cursor color.
cursor_color: ?terminal.color.RGB, cursor_color: ?terminal.color.RGB,
/// The default foreground and background color are those set by the user's /// The default foreground and background color are those set by the user's
/// config file. These can be overridden by terminal applications using OSC /// config file.
/// 10 and OSC 11, respectively.
default_foreground_color: terminal.color.RGB, default_foreground_color: terminal.color.RGB,
default_background_color: terminal.color.RGB, default_background_color: terminal.color.RGB,
/// The actual foreground and background color. Normally this will be the /// The foreground and background color as set by an OSC 10 or OSC 11
/// same as the default foreground and background color, unless changed by a /// sequence. If unset then the respective color falls back to the default
/// terminal application. /// value.
foreground_color: terminal.color.RGB, foreground_color: ?terminal.color.RGB,
background_color: terminal.color.RGB, background_color: ?terminal.color.RGB,
/// The response to use for ENQ requests. The memory is owned by /// The response to use for ENQ requests. The memory is owned by
/// whoever owns StreamHandler. /// whoever owns StreamHandler.
@ -126,6 +126,9 @@ pub const StreamHandler = struct {
if (self.default_cursor) self.setCursorStyle(.default) catch |err| { if (self.default_cursor) self.setCursorStyle(.default) catch |err| {
log.warn("failed to set default cursor style: {}", .{err}); log.warn("failed to set default cursor style: {}", .{err});
}; };
// The config could have changed any of our colors so update mode 2031
self.surfaceMessageWriter(.{ .report_color_scheme = false });
} }
inline fn surfaceMessageWriter( inline fn surfaceMessageWriter(
@ -767,7 +770,7 @@ pub const StreamHandler = struct {
self.messageWriter(msg); self.messageWriter(msg);
}, },
.color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = true }),
} }
} }
@ -892,6 +895,9 @@ pub const StreamHandler = struct {
) !void { ) !void {
self.terminal.fullReset(); self.terminal.fullReset();
try self.setMouseShape(.text); try self.setMouseShape(.text);
// Reset resets our palette so we report it for mode 2031.
self.surfaceMessageWriter(.{ .report_color_scheme = false });
} }
pub fn queryKittyKeyboard(self: *StreamHandler) !void { pub fn queryKittyKeyboard(self: *StreamHandler) !void {
@ -1191,9 +1197,12 @@ pub const StreamHandler = struct {
const color = switch (kind) { const color = switch (kind) {
.palette => |i| self.terminal.color_palette.colors[i], .palette => |i| self.terminal.color_palette.colors[i],
.foreground => self.foreground_color, .foreground => self.foreground_color orelse self.default_foreground_color,
.background => self.background_color, .background => self.background_color orelse self.default_background_color,
.cursor => self.cursor_color orelse self.foreground_color, .cursor => self.cursor_color orelse
self.default_cursor_color orelse
self.foreground_color orelse
self.default_foreground_color,
}; };
var msg: termio.Message = .{ .write_small = .{} }; var msg: termio.Message = .{ .write_small = .{} };
@ -1336,34 +1345,35 @@ pub const StreamHandler = struct {
} }
}, },
.foreground => { .foreground => {
self.foreground_color = self.default_foreground_color; self.foreground_color = null;
_ = self.renderer_mailbox.push(.{ _ = self.renderer_mailbox.push(.{
.foreground_color = self.foreground_color, .foreground_color = self.default_foreground_color,
}, .{ .forever = {} }); }, .{ .forever = {} });
self.surfaceMessageWriter(.{ .color_change = .{ self.surfaceMessageWriter(.{ .color_change = .{
.kind = .foreground, .kind = .foreground,
.color = self.foreground_color, .color = self.default_foreground_color,
} }); } });
}, },
.background => { .background => {
self.background_color = self.default_background_color; self.background_color = null;
_ = self.renderer_mailbox.push(.{ _ = self.renderer_mailbox.push(.{
.background_color = self.background_color, .background_color = self.default_background_color,
}, .{ .forever = {} }); }, .{ .forever = {} });
self.surfaceMessageWriter(.{ .color_change = .{ self.surfaceMessageWriter(.{ .color_change = .{
.kind = .background, .kind = .background,
.color = self.background_color, .color = self.default_background_color,
} }); } });
}, },
.cursor => { .cursor => {
self.cursor_color = self.default_cursor_color; self.cursor_color = null;
_ = self.renderer_mailbox.push(.{ _ = self.renderer_mailbox.push(.{
.cursor_color = self.cursor_color, .cursor_color = self.default_cursor_color,
}, .{ .forever = {} }); }, .{ .forever = {} });
if (self.cursor_color) |color| { if (self.default_cursor_color) |color| {
self.surfaceMessageWriter(.{ .color_change = .{ self.surfaceMessageWriter(.{ .color_change = .{
.kind = .cursor, .kind = .cursor,
.color = color, .color = color,
@ -1408,7 +1418,7 @@ pub const StreamHandler = struct {
var buf = std.ArrayList(u8).init(self.alloc); var buf = std.ArrayList(u8).init(self.alloc);
defer buf.deinit(); defer buf.deinit();
const writer = buf.writer(); const writer = buf.writer();
try writer.writeAll("\x1b[21"); try writer.writeAll("\x1b]21");
for (request.list.items) |item| { for (request.list.items) |item| {
switch (item) { switch (item) {
@ -1416,16 +1426,16 @@ pub const StreamHandler = struct {
const color: terminal.color.RGB = switch (key) { const color: terminal.color.RGB = switch (key) {
.palette => |palette| self.terminal.color_palette.colors[palette], .palette => |palette| self.terminal.color_palette.colors[palette],
.special => |special| switch (special) { .special => |special| switch (special) {
.foreground => self.foreground_color, .foreground => self.foreground_color orelse self.default_foreground_color,
.background => self.background_color, .background => self.background_color orelse self.default_background_color,
.cursor => self.cursor_color, .cursor => self.cursor_color orelse self.default_cursor_color,
else => { else => {
log.warn("ignoring unsupported kitty color protocol key: {}", .{key}); log.warn("ignoring unsupported kitty color protocol key: {}", .{key});
continue; continue;
}, },
}, },
} orelse { } orelse {
log.warn("no color configured for {}", .{key}); try writer.print(";{}=", .{key});
continue; continue;
}; };
@ -1479,15 +1489,15 @@ pub const StreamHandler = struct {
.special => |special| { .special => |special| {
const msg: renderer.Message = switch (special) { const msg: renderer.Message = switch (special) {
.foreground => msg: { .foreground => msg: {
self.foreground_color = self.default_foreground_color; self.foreground_color = null;
break :msg .{ .foreground_color = self.default_foreground_color }; break :msg .{ .foreground_color = self.default_foreground_color };
}, },
.background => msg: { .background => msg: {
self.background_color = self.default_background_color; self.background_color = null;
break :msg .{ .background_color = self.default_background_color }; break :msg .{ .background_color = self.default_background_color };
}, },
.cursor => msg: { .cursor => msg: {
self.cursor_color = self.default_cursor_color; self.cursor_color = null;
break :msg .{ .cursor_color = self.default_cursor_color }; break :msg .{ .cursor_color = self.default_cursor_color };
}, },
else => { else => {