From 116c7af24f35cfb05a2d29d876016dc1719a895d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:23:29 -0700 Subject: [PATCH 1/8] Add cmux theme picker helper hooks --- build.zig | 2 + src/cli/list_themes.zig | 475 ++++++++++++++++++++++++++++++++++++++-- src/main_ghostty.zig | 5 +- 3 files changed, 466 insertions(+), 16 deletions(-) diff --git a/build.zig b/build.zig index f9d861b1946..c162d51f524 100644 --- a/build.zig +++ b/build.zig @@ -36,6 +36,7 @@ pub fn build(b: *std.Build) !void { // All our steps which we'll hook up later. The steps are shown // up here just so that they are more self-documenting. const libvt_step = b.step("lib-vt", "Build libghostty-vt"); + const cli_helper_step = b.step("cli-helper", "Build the Ghostty CLI helper"); const run_step = b.step("run", "Run the app"); const run_valgrind_step = b.step( "run-valgrind", @@ -61,6 +62,7 @@ pub fn build(b: *std.Build) !void { // Ghostty executable, the actual runnable Ghostty program. const exe = try buildpkg.GhosttyExe.init(b, &config, &deps); + cli_helper_step.dependOn(&exe.install_step.step); // Ghostty docs const docs = try buildpkg.GhosttyDocs.init(b, &deps); diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 42aff9d566a..7ebd974a0c6 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -1,3 +1,4 @@ +const builtin = @import("builtin"); const std = @import("std"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; @@ -9,6 +10,7 @@ const global_state = &@import("../global.zig").state; const vaxis = @import("vaxis"); const zf = @import("zf"); +const objc = if (builtin.target.os.tag.isDarwin()) @import("objc") else struct {}; // When the number of filtered themes is less than or equal to this threshold, // the window position will be reset to 0 to show all results from the top. @@ -17,6 +19,71 @@ const zf = @import("zf"); const SMALL_LIST_THRESHOLD = 10; const ColorScheme = enum { all, dark, light }; +const ThemeTargetMode = enum { both, light, dark }; +const cmux_block_start = "# cmux themes start"; +const cmux_block_end = "# cmux themes end"; + +const CmuxThemePicker = struct { + config_path: []u8, + bundle_id: []u8, + initial_light: ?[]u8, + initial_dark: ?[]u8, + target_mode: ThemeTargetMode, + original_contents: ?[]u8, + + fn load(alloc: std.mem.Allocator) !?CmuxThemePicker { + const config_path = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_CONFIG"); + if (config_path == null) return null; + errdefer alloc.free(config_path.?); + + const bundle_id = (try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_BUNDLE_ID")) orelse + try alloc.dupe(u8, "com.cmuxterm.app"); + errdefer alloc.free(bundle_id); + + const initial_light = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_INITIAL_LIGHT"); + errdefer if (initial_light) |value| alloc.free(value); + + const initial_dark = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_INITIAL_DARK"); + errdefer if (initial_dark) |value| alloc.free(value); + + const target_mode = target: { + const raw = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_TARGET") orelse break :target .both; + defer alloc.free(raw); + break :target std.meta.stringToEnum(ThemeTargetMode, raw) orelse .both; + }; + + const original_contents = try readOptionalFile(alloc, config_path.?); + errdefer if (original_contents) |value| alloc.free(value); + + return .{ + .config_path = config_path.?, + .bundle_id = bundle_id, + .initial_light = initial_light, + .initial_dark = initial_dark, + .target_mode = target_mode, + .original_contents = original_contents, + }; + } + + fn deinit(self: *CmuxThemePicker, alloc: std.mem.Allocator) void { + alloc.free(self.config_path); + alloc.free(self.bundle_id); + if (self.initial_light) |value| alloc.free(value); + if (self.initial_dark) |value| alloc.free(value); + if (self.original_contents) |value| alloc.free(value); + } + + fn initialTheme(self: *const CmuxThemePicker) ?[]const u8 { + return switch (self.target_mode) { + .both => if (eqlOptionalTheme(self.initial_light, self.initial_dark)) + self.initial_light orelse self.initial_dark + else + self.initial_dark orelse self.initial_light, + .light => self.initial_light orelse self.initial_dark, + .dark => self.initial_dark orelse self.initial_light, + }; + } +}; pub const Options = struct { /// If true, print the full path to the theme. @@ -222,6 +289,198 @@ fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void { try w.interface.flush(); } +fn trimmedEnvValue(alloc: std.mem.Allocator, key: []const u8) !?[]u8 { + const raw = std.process.getEnvVarOwned(alloc, key) catch |err| switch (err) { + error.EnvironmentVariableNotFound => return null, + else => return err, + }; + + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0) { + alloc.free(raw); + return null; + } + if (trimmed.ptr == raw.ptr and trimmed.len == raw.len) { + return raw; + } + + const duped = try alloc.dupe(u8, trimmed); + alloc.free(raw); + return duped; +} + +fn readOptionalFile(alloc: std.mem.Allocator, path: []const u8) !?[]u8 { + const file = std.fs.openFileAbsolute(path, .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(); + + return try file.readToEndAlloc(alloc, 1024 * 1024); +} + +fn writeAbsoluteFile(path: []const u8, contents: []const u8) !void { + if (std.fs.path.dirname(path)) |dir| { + try std.fs.makeDirAbsolute(dir); + } + + var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(contents); +} + +fn removeManagedThemeOverride( + alloc: std.mem.Allocator, + contents: []const u8, +) ![]u8 { + const start = std.mem.indexOf(u8, contents, cmux_block_start) orelse + return try alloc.dupe(u8, contents); + const end_marker = std.mem.indexOfPos(u8, contents, start, cmux_block_end) orelse + return try alloc.dupe(u8, contents); + + var remove_start = start; + if (remove_start > 0 and contents[remove_start - 1] == '\n') { + remove_start -= 1; + } + + var remove_end = end_marker + cmux_block_end.len; + if (remove_end < contents.len and contents[remove_end] == '\n') { + remove_end += 1; + } + + var result: std.ArrayList(u8) = .empty; + errdefer result.deinit(alloc); + try result.appendSlice(alloc, contents[0..remove_start]); + try result.appendSlice(alloc, contents[remove_end..]); + return try result.toOwnedSlice(alloc); +} + +fn encodeCmuxThemeValue( + alloc: std.mem.Allocator, + light: ?[]const u8, + dark: ?[]const u8, +) !?[]u8 { + if (light) |light_theme| { + if (dark) |dark_theme| { + return try std.fmt.allocPrint( + alloc, + "light:{s},dark:{s}", + .{ light_theme, dark_theme }, + ); + } + + return try std.fmt.allocPrint( + alloc, + "light:{s}", + .{light_theme}, + ); + } + + if (dark) |dark_theme| { + return try std.fmt.allocPrint( + alloc, + "dark:{s}", + .{dark_theme}, + ); + } + + return null; +} + +fn writeCmuxThemeOverride( + alloc: std.mem.Allocator, + cmux: *const CmuxThemePicker, + raw_theme_value: []const u8, +) !void { + const existing = (try readOptionalFile(alloc, cmux.config_path)) orelse + try alloc.dupe(u8, ""); + defer alloc.free(existing); + + const stripped = try removeManagedThemeOverride(alloc, existing); + defer alloc.free(stripped); + + const trimmed = std.mem.trim(u8, stripped, " \t\r\n"); + const block = try std.fmt.allocPrint( + alloc, + "{s}\ntheme = {s}\n{s}\n", + .{ cmux_block_start, raw_theme_value, cmux_block_end }, + ); + defer alloc.free(block); + + var next_contents: std.ArrayList(u8) = .empty; + defer next_contents.deinit(alloc); + if (trimmed.len > 0) { + try next_contents.appendSlice(alloc, trimmed); + try next_contents.appendSlice(alloc, "\n\n"); + } + try next_contents.appendSlice(alloc, block); + try writeAbsoluteFile(cmux.config_path, next_contents.items); +} + +fn restoreCmuxThemeOverride(cmux: *const CmuxThemePicker) !void { + if (cmux.original_contents) |contents| { + try writeAbsoluteFile(cmux.config_path, contents); + return; + } + + std.fs.deleteFileAbsolute(cmux.config_path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +fn postCmuxReloadNotification( + alloc: std.mem.Allocator, + bundle_id: []const u8, +) !void { + if (!builtin.target.os.tag.isDarwin()) return; + + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + const NSString = objc.getClass("NSString") orelse return error.ObjCFailed; + const center_class = objc.getClass("NSDistributedNotificationCenter") orelse + return error.ObjCFailed; + const center = center_class.msgSend(objc.Object, objc.sel("defaultCenter"), .{}); + + const name_c = try alloc.dupeZ(u8, "com.cmuxterm.themes.reload-config"); + defer alloc.free(name_c); + const object_c = try alloc.dupeZ(u8, bundle_id); + defer alloc.free(object_c); + + const name = NSString.msgSend( + objc.Object, + objc.sel("stringWithUTF8String:"), + .{name_c.ptr}, + ); + const object = NSString.msgSend( + objc.Object, + objc.sel("stringWithUTF8String:"), + .{object_c.ptr}, + ); + + center.msgSend( + void, + objc.sel("postNotificationName:object:userInfo:deliverImmediately:"), + .{ + name, + object, + @as(?*anyopaque, null), + true, + }, + ); +} + +fn eqlOptionalTheme(lhs: ?[]const u8, rhs: ?[]const u8) bool { + if (lhs) |left| { + if (rhs) |right| { + return std.ascii.eqlIgnoreCase(left, right); + } + return false; + } + return rhs == null; +} + const Event = union(enum) { key_press: vaxis.Key, mouse: vaxis.Mouse, @@ -232,6 +491,10 @@ const Event = union(enum) { const Preview = struct { allocator: std.mem.Allocator, should_quit: bool, + outcome: enum { + cancel, + apply, + }, tty: vaxis.Tty, vx: vaxis.Vaxis, mouse: ?vaxis.Mouse, @@ -249,6 +512,12 @@ const Preview = struct { color_scheme: vaxis.Color.Scheme, text_input: vaxis.widgets.TextInput, theme_filter: ColorScheme, + cmux: ?CmuxThemePicker, + cmux_target_mode: ThemeTargetMode, + cmux_preview_light: ?[]const u8, + cmux_preview_dark: ?[]const u8, + cmux_applied_light: ?[]const u8, + cmux_applied_dark: ?[]const u8, pub fn init( allocator: std.mem.Allocator, @@ -257,10 +526,12 @@ const Preview = struct { buf: []u8, ) !*Preview { const self = try allocator.create(Preview); + const cmux = try CmuxThemePicker.load(allocator); self.* = .{ .allocator = allocator, .should_quit = false, + .outcome = .cancel, .tty = try .init(buf), .vx = try vaxis.init(allocator, .{}), .mouse = null, @@ -273,15 +544,25 @@ const Preview = struct { .color_scheme = .light, .text_input = .init(allocator), .theme_filter = theme_filter, + .cmux = cmux, + .cmux_target_mode = if (cmux) |value| value.target_mode else .both, + .cmux_preview_light = if (cmux) |value| value.initial_light else null, + .cmux_preview_dark = if (cmux) |value| value.initial_dark else null, + .cmux_applied_light = if (cmux) |value| value.initial_light else null, + .cmux_applied_dark = if (cmux) |value| value.initial_dark else null, }; try self.updateFiltered(); + if (self.cmuxInitialTheme()) |theme_name| { + self.selectTheme(theme_name); + } return self; } pub fn deinit(self: *Preview) void { const allocator = self.allocator; + if (self.cmux) |*value| value.deinit(allocator); self.filtered.deinit(allocator); self.text_input.deinit(); self.vx.deinit(allocator, self.tty.writer()); @@ -290,6 +571,11 @@ const Preview = struct { } pub fn run(self: *Preview) !void { + errdefer self.restoreCmuxOriginal() catch {}; + defer if (self.outcome == .cancel) { + self.restoreCmuxOriginal() catch {}; + }; + var loop: vaxis.Loop(Event) = .{ .tty = &self.tty, .vaxis = &self.vx, @@ -300,7 +586,10 @@ const Preview = struct { const writer = self.tty.writer(); try self.vx.enterAltScreen(writer); - try self.vx.setTitle(writer, "👻 Ghostty Theme Preview 👻"); + try self.vx.setTitle( + writer, + if (self.cmux != null) "cmux Theme Preview" else "👻 Ghostty Theme Preview 👻", + ); try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); try self.vx.setMouseMode(writer, true); if (self.vx.caps.color_scheme_updates) @@ -411,6 +700,70 @@ const Preview = struct { }; } + fn selectTheme(self: *Preview, theme_name: []const u8) void { + for (self.filtered.items, 0..) |index, i| { + if (std.ascii.eqlIgnoreCase(self.themes[index].theme, theme_name)) { + self.current = i; + return; + } + } + } + + fn cmuxInitialTheme(self: *const Preview) ?[]const u8 { + const cmux = self.cmux orelse return null; + return cmux.initialTheme(); + } + + fn applyCmuxSelectionForCurrentTheme(self: *Preview) !void { + const cmux = self.cmux orelse return; + if (self.filtered.items.len == 0) return; + + const theme = self.themes[self.filtered.items[self.current]].theme; + switch (self.cmux_target_mode) { + .both => { + self.cmux_preview_light = theme; + self.cmux_preview_dark = theme; + }, + .light => self.cmux_preview_light = theme, + .dark => self.cmux_preview_dark = theme, + } + + try self.syncCmuxPreview(cmux); + } + + fn restoreCmuxOriginal(self: *Preview) !void { + const cmux = self.cmux orelse return; + self.cmux_preview_light = cmux.initial_light; + self.cmux_preview_dark = cmux.initial_dark; + try self.syncCmuxPreview(cmux); + } + + fn syncCmuxPreview(self: *Preview, cmux: CmuxThemePicker) !void { + if (eqlOptionalTheme(self.cmux_preview_light, self.cmux_applied_light) and + eqlOptionalTheme(self.cmux_preview_dark, self.cmux_applied_dark)) + { + return; + } + + if (eqlOptionalTheme(self.cmux_preview_light, cmux.initial_light) and + eqlOptionalTheme(self.cmux_preview_dark, cmux.initial_dark)) + { + try restoreCmuxThemeOverride(&cmux); + } else { + const raw_theme_value = (try encodeCmuxThemeValue( + self.allocator, + self.cmux_preview_light, + self.cmux_preview_dark, + )) orelse return; + defer self.allocator.free(raw_theme_value); + try writeCmuxThemeOverride(self.allocator, &cmux, raw_theme_value); + } + + try postCmuxReloadNotification(self.allocator, cmux.bundle_id); + self.cmux_applied_light = self.cmux_preview_light; + self.cmux_applied_dark = self.cmux_preview_dark; + } + fn up(self: *Preview, count: usize) void { if (self.filtered.items.len == 0) { self.current = 0; @@ -432,40 +785,71 @@ const Preview = struct { pub fn update(self: *Preview, event: Event, alloc: std.mem.Allocator) !void { switch (event) { .key_press => |key| { - if (key.matches('c', .{ .ctrl = true })) + if (key.matches('c', .{ .ctrl = true })) { + self.outcome = .cancel; self.should_quit = true; + } switch (self.mode) { .normal => { - if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{})) + if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{})) { + self.outcome = .cancel; self.should_quit = true; + } if (key.matchesAny(&.{ '?', vaxis.Key.f1 }, .{})) self.mode = .help; if (key.matches('h', .{ .ctrl = true })) self.mode = .help; if (key.matches('/', .{})) self.mode = .search; - if (key.matchesAny(&.{ vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) - self.mode = .save; + if (key.matchesAny(&.{ vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) { + if (self.cmux != null) { + self.outcome = .apply; + self.should_quit = true; + } else { + self.mode = .save; + } + } if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { self.text_input.buf.clearRetainingCapacity(); try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); } - if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{})) + if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{})) { self.current = 0; - if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{})) { self.current = self.filtered.items.len - 1; - if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{})) { self.down(1); - if (key.matchesAny(&.{ vaxis.Key.page_down, vaxis.Key.kp_down }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ vaxis.Key.page_down, vaxis.Key.kp_down }, .{})) { self.down(20); - if (key.matchesAny(&.{ 'k', '-', vaxis.Key.up, vaxis.Key.kp_up, vaxis.Key.kp_subtract }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ 'k', '-', vaxis.Key.up, vaxis.Key.kp_up, vaxis.Key.kp_subtract }, .{})) { self.up(1); - if (key.matchesAny(&.{ vaxis.Key.page_up, vaxis.Key.kp_page_up }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ vaxis.Key.page_up, vaxis.Key.kp_page_up }, .{})) { self.up(20); + try self.applyCmuxSelectionForCurrentTheme(); + } if (key.matchesAny(&.{ 'h', 'x' }, .{})) self.hex = true; if (key.matches('d', .{})) self.hex = false; + if (self.cmux != null and key.matches('t', .{})) { + self.cmux_target_mode = switch (self.cmux_target_mode) { + .both => .light, + .light => .dark, + .dark => .both, + }; + try self.applyCmuxSelectionForCurrentTheme(); + } if (key.matches('c', .{})) try self.vx.copyToSystemClipboard( self.tty.writer(), @@ -485,11 +869,14 @@ const Preview = struct { .light => self.theme_filter = .all, } try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); } }, .help => { - if (key.matches('q', .{})) + if (key.matches('q', .{})) { + self.outcome = .cancel; self.should_quit = true; + } if (key.matchesAny(&.{ '?', vaxis.Key.escape, vaxis.Key.f1 }, .{})) self.mode = .normal; if (key.matches('h', .{ .ctrl = true })) @@ -503,14 +890,18 @@ const Preview = struct { if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { self.text_input.clearRetainingCapacity(); try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); break :search; } try self.text_input.update(.{ .key_press = key }); try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); }, .save => { - if (key.matches('q', .{})) + if (key.matches('q', .{})) { + self.outcome = .cancel; self.should_quit = true; + } if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) self.mode = .normal; if (key.matches('w', .{})) { @@ -615,15 +1006,18 @@ const Preview = struct { if (self.mode == .normal) { if (mouse.button == .wheel_up) { self.up(1); + try self.applyCmuxSelectionForCurrentTheme(); } if (mouse.button == .wheel_down) { self.down(1); + try self.applyCmuxSelectionForCurrentTheme(); } if (theme_list.hasMouse(mouse)) |_| { if (mouse.button == .left and mouse.type == .release) { const selection = self.window + mouse.row; if (selection < self.filtered.items.len) { self.current = selection; + try self.applyCmuxSelectionForCurrentTheme(); } } highlight = mouse.row; @@ -720,6 +1114,37 @@ const Preview = struct { try self.drawPreview(alloc, win, theme_list.x_off + theme_list.width); + if (self.cmux != null) { + const footer = win.child(.{ + .x_off = 0, + .y_off = win.height - 1, + .width = win.width, + .height = 1, + }); + footer.fill(.{ .style = self.ui_selected() }); + + const text = try std.fmt.allocPrint( + alloc, + " cmux live preview target={s} light={s} dark={s} t cycle target Enter apply q cancel ", + .{ + @tagName(self.cmux_target_mode), + self.cmux_preview_light orelse "inherit", + self.cmux_preview_dark orelse "inherit", + }, + ); + const max_len = @min(text.len, footer.width); + _ = footer.printSegment( + .{ + .text = text[0..max_len], + .style = self.ui_selected(), + }, + .{ + .row_offset = 0, + .col_offset = 0, + }, + ); + } + switch (self.mode) { .normal => { win.hideCursor(); @@ -761,11 +1186,31 @@ const Preview = struct { .{ .keys = "End", .help = "Go to the end of the list." }, .{ .keys = "/", .help = "Start search." }, .{ .keys = "^X, ^/", .help = "Clear search." }, - .{ .keys = "⏎", .help = "Save theme or close search window." }, - .{ .keys = "w", .help = "Write theme to auto config file." }, + .{ + .keys = "⏎", + .help = if (self.cmux != null) + "Apply current preview and close." + else + "Save theme or close search window.", + }, + .{ + .keys = "w", + .help = if (self.cmux != null) + "Unused in cmux mode." + else + "Write theme to auto config file.", + }, + .{ + .keys = "t", + .help = if (self.cmux != null) + "Cycle cmux target (both, light, dark)." + else + "", + }, }; for (key_help, 0..) |help, captured_i| { + if (help.help.len == 0) continue; const i: u16 = @intCast(captured_i); _ = child.printSegment( .{ diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 531a0646134..b81297fce4d 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -72,7 +72,9 @@ pub fn main() !MainReturn { } if (comptime build_config.app_runtime == .none) { - const stdout = std.io.getStdOut().writer(); + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); + const stdout = &stdout_writer.interface; try stdout.print("Usage: ghostty + [flags]\n\n", .{}); try stdout.print( \\This is the Ghostty helper CLI that accompanies the graphical Ghostty app. @@ -89,6 +91,7 @@ pub fn main() !MainReturn { , .{}, ); + try stdout.flush(); posix.exit(0); } From efff67770a0158e8ba9dd15695b6c2d298005bca Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:37:27 -0700 Subject: [PATCH 2/8] Fix cmux theme picker preview writes --- src/cli/list_themes.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 7ebd974a0c6..abab7a2052a 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -321,7 +321,7 @@ fn readOptionalFile(alloc: std.mem.Allocator, path: []const u8) !?[]u8 { fn writeAbsoluteFile(path: []const u8, contents: []const u8) !void { if (std.fs.path.dirname(path)) |dir| { - try std.fs.makeDirAbsolute(dir); + try std.fs.cwd().makePath(dir); } var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); From 872e8b9de36365f5caa61a6745585ed678727e5d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:44:09 -0700 Subject: [PATCH 3/8] Improve cmux theme picker footer contrast --- src/cli/list_themes.zig | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index abab7a2052a..51590d0df06 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -986,6 +986,13 @@ const Preview = struct { }; } + pub fn ui_footer(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_fg(), + .bg = self.ui_hover_bg(), + }; + } + pub fn draw(self: *Preview, alloc: std.mem.Allocator) !void { const win = self.vx.window(); win.clear(); @@ -1121,7 +1128,7 @@ const Preview = struct { .width = win.width, .height = 1, }); - footer.fill(.{ .style = self.ui_selected() }); + footer.fill(.{ .style = self.ui_footer() }); const text = try std.fmt.allocPrint( alloc, @@ -1136,7 +1143,7 @@ const Preview = struct { _ = footer.printSegment( .{ .text = text[0..max_len], - .style = self.ui_selected(), + .style = self.ui_footer(), }, .{ .row_offset = 0, From eb97801517d40da686ea562a3c792f18ad86319e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:51:59 -0700 Subject: [PATCH 4/8] Respect system theme in cmux picker --- src/cli/list_themes.zig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 51590d0df06..986659c6bdb 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -29,6 +29,7 @@ const CmuxThemePicker = struct { initial_light: ?[]u8, initial_dark: ?[]u8, target_mode: ThemeTargetMode, + ui_color_scheme: vaxis.Color.Scheme, original_contents: ?[]u8, fn load(alloc: std.mem.Allocator) !?CmuxThemePicker { @@ -52,6 +53,12 @@ const CmuxThemePicker = struct { break :target std.meta.stringToEnum(ThemeTargetMode, raw) orelse .both; }; + const ui_color_scheme = color_scheme: { + const raw = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_COLOR_SCHEME") orelse break :color_scheme .light; + defer alloc.free(raw); + break :color_scheme std.meta.stringToEnum(vaxis.Color.Scheme, raw) orelse .light; + }; + const original_contents = try readOptionalFile(alloc, config_path.?); errdefer if (original_contents) |value| alloc.free(value); @@ -61,6 +68,7 @@ const CmuxThemePicker = struct { .initial_light = initial_light, .initial_dark = initial_dark, .target_mode = target_mode, + .ui_color_scheme = ui_color_scheme, .original_contents = original_contents, }; } @@ -541,7 +549,7 @@ const Preview = struct { .window = 0, .hex = false, .mode = .normal, - .color_scheme = .light, + .color_scheme = if (cmux) |value| value.ui_color_scheme else .light, .text_input = .init(allocator), .theme_filter = theme_filter, .cmux = cmux, From 6f63ee97f88e7d7cab69b41b3de11d88d9b7b63c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:55:56 -0700 Subject: [PATCH 5/8] Skip theme detection in cmux picker --- src/cli/list_themes.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 986659c6bdb..2bed74aa878 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -598,9 +598,11 @@ const Preview = struct { writer, if (self.cmux != null) "cmux Theme Preview" else "👻 Ghostty Theme Preview 👻", ); - try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); + if (self.cmux == null) { + try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); + } try self.vx.setMouseMode(writer, true); - if (self.vx.caps.color_scheme_updates) + if (self.cmux == null and self.vx.caps.color_scheme_updates) try self.vx.subscribeToColorSchemeUpdates(writer); while (!self.should_quit) { From 4c3fb24533ddb2f68d7cac2524f04ddcddc5ab16 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 05:12:20 -0700 Subject: [PATCH 6/8] Match Ghostty theme picker startup --- src/cli/list_themes.zig | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 2bed74aa878..7eedda36012 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -561,9 +561,6 @@ const Preview = struct { }; try self.updateFiltered(); - if (self.cmuxInitialTheme()) |theme_name| { - self.selectTheme(theme_name); - } return self; } @@ -710,20 +707,6 @@ const Preview = struct { }; } - fn selectTheme(self: *Preview, theme_name: []const u8) void { - for (self.filtered.items, 0..) |index, i| { - if (std.ascii.eqlIgnoreCase(self.themes[index].theme, theme_name)) { - self.current = i; - return; - } - } - } - - fn cmuxInitialTheme(self: *const Preview) ?[]const u8 { - const cmux = self.cmux orelse return null; - return cmux.initialTheme(); - } - fn applyCmuxSelectionForCurrentTheme(self: *Preview) !void { const cmux = self.cmux orelse return; if (self.filtered.items.len == 0) return; From bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 06:59:47 -0700 Subject: [PATCH 7/8] Harden cmux theme override writes --- src/cli/list_themes.zig | 59 +++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 7eedda36012..63bf6f8f500 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -330,9 +330,25 @@ fn readOptionalFile(alloc: std.mem.Allocator, path: []const u8) !?[]u8 { fn writeAbsoluteFile(path: []const u8, contents: []const u8) !void { if (std.fs.path.dirname(path)) |dir| { try std.fs.cwd().makePath(dir); + var dir_handle = try std.fs.openDirAbsolute(dir, .{}); + defer dir_handle.close(); + + var buf: [1024]u8 = undefined; + var atomic_file = try dir_handle.atomicFile(std.fs.path.basename(path), .{ + .mode = 0o600, + .write_buffer = &buf, + }); + defer atomic_file.deinit(); + + try atomic_file.file_writer.interface.writeAll(contents); + try atomic_file.finish(); + return; } - var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); + var file = try std.fs.createFileAbsolute(path, .{ + .truncate = true, + .mode = 0o600, + }); defer file.close(); try file.writeAll(contents); } @@ -341,25 +357,34 @@ fn removeManagedThemeOverride( alloc: std.mem.Allocator, contents: []const u8, ) ![]u8 { - const start = std.mem.indexOf(u8, contents, cmux_block_start) orelse - return try alloc.dupe(u8, contents); - const end_marker = std.mem.indexOfPos(u8, contents, start, cmux_block_end) orelse - return try alloc.dupe(u8, contents); - - var remove_start = start; - if (remove_start > 0 and contents[remove_start - 1] == '\n') { - remove_start -= 1; - } + var result: std.ArrayList(u8) = .empty; + errdefer result.deinit(alloc); + + var cursor: usize = 0; + while (true) { + const start = std.mem.indexOfPos(u8, contents, cursor, cmux_block_start) orelse { + try result.appendSlice(alloc, contents[cursor..]); + break; + }; + const end_marker = std.mem.indexOfPos(u8, contents, start, cmux_block_end) orelse { + try result.appendSlice(alloc, contents[cursor..]); + break; + }; + + var remove_start = start; + if (remove_start > cursor and contents[remove_start - 1] == '\n') { + remove_start -= 1; + } - var remove_end = end_marker + cmux_block_end.len; - if (remove_end < contents.len and contents[remove_end] == '\n') { - remove_end += 1; + var remove_end = end_marker + cmux_block_end.len; + if (remove_end < contents.len and contents[remove_end] == '\n') { + remove_end += 1; + } + + try result.appendSlice(alloc, contents[cursor..remove_start]); + cursor = remove_end; } - var result: std.ArrayList(u8) = .empty; - errdefer result.deinit(alloc); - try result.appendSlice(alloc, contents[0..remove_start]); - try result.appendSlice(alloc, contents[remove_end..]); return try result.toOwnedSlice(alloc); } From bb60bc964be4a616e61ed4e0229f72c222df039e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 26 Mar 2026 23:56:18 -0700 Subject: [PATCH 8/8] coretext: clamp fallback glyphs to primary cell height --- src/font/Collection.zig | 8 ++++ src/font/face/coretext.zig | 83 ++++++++++++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 5d7bfa519fb..2d8d6e0e2a5 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -123,6 +123,7 @@ pub fn add( return error.CollectionFull; var owned_face = face; + setFaceFallback(&owned_face, opts.fallback); // Scale factor to adjust the size of the added face. const scale_factor = self.scaleFactor( @@ -229,6 +230,7 @@ fn getFaceFromEntry( // Load the face. var face = try d.load(opts.library, opts.faceOptions()); errdefer face.deinit(); + setFaceFallback(&face, entry.fallback); // Calculate the scale factor for this // entry now that we have a loaded face. @@ -263,6 +265,12 @@ fn getFaceFromEntry( }; } +inline fn setFaceFallback(face: *Face, fallback: bool) void { + if (comptime @hasField(Face, "fallback")) { + face.fallback = fallback; + } +} + /// Return the index of the font in this collection that contains /// the given codepoint, style, and presentation. If no font is found, /// null is returned. diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 1d1333882ea..abfc9700f01 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -34,6 +34,12 @@ pub const Face = struct { /// The current size this font is set to. size: font.face.DesiredSize, + /// Cached metrics for this face at the current size. + metrics: font.Metrics.FaceMetrics, + + /// True when this face was loaded as a fallback face in a collection. + fallback: bool = false, + /// True if our build is using Harfbuzz. If we're not, we can avoid /// some Harfbuzz-specific code paths. const harfbuzz_shaper = font.options.backend.hasHarfbuzz(); @@ -111,6 +117,7 @@ pub const Face = struct { .hb_font = hb_font, .color = color, .size = opts.size, + .metrics = calcMetrics(ct_font), }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); @@ -174,7 +181,9 @@ pub const Face = struct { pub fn syntheticItalic(self: *const Face, opts: font.face.Options) !Face { const ct_font = try self.font.copyWithAttributes(0.0, &italic_skew, null); errdefer ct_font.release(); - return try initFont(ct_font, opts); + var face = try initFont(ct_font, opts); + face.fallback = self.fallback; + return face; } /// Return a new face that is the same as this but applies a synthetic @@ -184,6 +193,7 @@ pub const Face = struct { const ct_font = try self.font.copyWithAttributes(0.0, null, null); errdefer ct_font.release(); var face = try initFont(ct_font, opts); + face.fallback = self.fallback; // To determine our synthetic bold line width we get a multiplier // from the font size in points. This is a heuristic that is based @@ -202,7 +212,11 @@ pub const Face = struct { /// but sometimes allocation isn't required and a static string is /// returned. pub fn name(self: *const Face, buf: []u8) Allocator.Error![]const u8 { - const family_name = self.font.copyFamilyName(); + return try nameFont(self.font, buf); + } + + fn nameFont(ct_font: *macos.text.Font, buf: []u8) Allocator.Error![]const u8 { + const family_name = ct_font.copyFamilyName(); if (family_name.cstringPtr(.utf8)) |str| return str; // "NULL if the internal storage of theString does not allow @@ -215,7 +229,8 @@ pub const Face = struct { /// for clearing any glyph caches, font atlas data, etc. pub fn setSize(self: *Face, opts: font.face.Options) !void { // We just create a copy and replace ourself - const face = try initFontCopy(self.font, opts); + var face = try initFontCopy(self.font, opts); + face.fallback = self.fallback; self.deinit(); self.* = face; } @@ -248,7 +263,8 @@ pub const Face = struct { // Initialize a font based on these attributes. const ct_font = try self.font.copyWithAttributes(0, null, desc); errdefer ct_font.release(); - const face = try initFont(ct_font, new_opts); + var face = try initFont(ct_font, new_opts); + face.fallback = self.fallback; self.deinit(); self.* = face; } @@ -269,6 +285,10 @@ pub const Face = struct { /// Returns the glyph index for the given Unicode code point. If this /// face doesn't support this glyph, null is returned. pub fn glyphIndex(self: Face, cp: u32) ?u32 { + return glyphIndexFont(self.font, cp); + } + + fn glyphIndexFont(ct_font: *macos.text.Font, cp: u32) ?u32 { // Turn UTF-32 into UTF-16 for CT API var unichars: [2]u16 = undefined; const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(cp, &unichars); @@ -276,7 +296,7 @@ pub const Face = struct { // Get our glyphs var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; - if (!self.font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len])) + if (!ct_font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len])) return null; // We can have pairs due to chars like emoji but we expect all of them @@ -339,6 +359,16 @@ pub const Face = struct { // Next we apply any constraints to get the final size of the glyph. const constraint = opts.constraint; + var layout_rect = rect; + if (self.fallback and !is_color and !constraint.doesAnything()) { + const fallback_scale = self.fallbackHeightScale(metrics); + if (fallback_scale < 1.0) { + layout_rect.origin.x *= fallback_scale; + layout_rect.origin.y *= fallback_scale; + layout_rect.size.width *= fallback_scale; + layout_rect.size.height *= fallback_scale; + } + } // We need to add the baseline position before passing to the constrain // function since it operates on cell-relative positions, not baseline. @@ -346,10 +376,10 @@ pub const Face = struct { const glyph_size = constraint.constrain( .{ - .width = rect.size.width, - .height = rect.size.height, - .x = rect.origin.x, - .y = rect.origin.y + cell_baseline, + .width = layout_rect.size.width, + .height = layout_rect.size.height, + .x = layout_rect.origin.x, + .y = layout_rect.origin.y + cell_baseline, }, metrics, opts.constraint_width, @@ -568,7 +598,28 @@ pub const Face = struct { /// Get the `FaceMetrics` for this face. pub fn getMetrics(self: *Face) font.Metrics.FaceMetrics { - const ct_font = self.font; + return self.metrics; + } + + fn fallbackHeightScale(self: *const Face, grid_metrics: font.Metrics) f64 { + const fallback_metrics = font.Metrics.calc(self.metrics); + + const primary_top = @as(f64, @floatFromInt(grid_metrics.cell_height)) - + @as(f64, @floatFromInt(grid_metrics.cell_baseline)); + const primary_bottom = @as(f64, @floatFromInt(grid_metrics.cell_baseline)); + const fallback_top = @as(f64, @floatFromInt(fallback_metrics.cell_height)) - + @as(f64, @floatFromInt(fallback_metrics.cell_baseline)); + const fallback_bottom = @as(f64, @floatFromInt(fallback_metrics.cell_baseline)); + + var scale: f64 = 1.0; + if (fallback_top > 0) scale = @min(scale, primary_top / fallback_top); + if (fallback_bottom > 0) scale = @min(scale, primary_bottom / fallback_bottom); + + if (!std.math.isFinite(scale) or scale <= 0) return 1.0; + return @min(scale, 1.0); + } + + fn calcMetrics(ct_font: *macos.text.Font) font.Metrics.FaceMetrics { // Read the 'head' table out of the font data. const head_: ?opentype.Head = head: { @@ -635,16 +686,16 @@ pub const Face = struct { if (head_) |head| @floatFromInt(head.unitsPerEm) else - @floatFromInt(self.font.getUnitsPerEm()); + @floatFromInt(ct_font.getUnitsPerEm()); const px_per_em: f64 = ct_font.getSize(); const px_per_unit: f64 = px_per_em / units_per_em; const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { // If we couldn't get the hhea table, rely on metrics from CoreText. const hhea = hhea_ orelse break :vertical_metrics .{ - self.font.getAscent(), - -self.font.getDescent(), - self.font.getLeading(), + ct_font.getAscent(), + -ct_font.getDescent(), + ct_font.getLeading(), }; const hhea_ascent: f64 = @floatFromInt(hhea.ascender); @@ -806,7 +857,7 @@ pub const Face = struct { // Measure "水" (CJK water ideograph, U+6C34) for our ic width. const ic_width: ?f64 = ic_width: { - const glyph = self.glyphIndex('水') orelse break :ic_width null; + const glyph = glyphIndexFont(ct_font, '水') orelse break :ic_width null; const advance = ct_font.getAdvancesForGlyphs( .horizontal, @@ -830,7 +881,7 @@ pub const Face = struct { // values so the advance ends up half the width of the actual glyph. if (bounds.size.width > advance) { var buf: [1024]u8 = undefined; - const font_name = self.name(&buf) catch ""; + const font_name = nameFont(ct_font, &buf) catch ""; log.warn( "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", .{