Created
August 27, 2026 21:41
-
-
Save moderation/68b06f35c19aed738a4b30b80192ea93 to your computer and use it in GitHub Desktop.
Implement ghostty's cursor background color option for monstar. Written by Grok 4.6 (high)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/README.md b/README.md | |
| index ae1a3de..fe14fb0 100644 | |
| --- a/README.md | |
| +++ b/README.md | |
| @@ -135,6 +135,8 @@ Other settings: | |
| `0` disables the flash (default `200`). | |
| - **background / foreground / cursor-color / cursor-text** — Terminal colors | |
| as `#RRGGBB` or `RRGGBB`; explicit colors override the theme. | |
| + `cursor-color` and `cursor-text` also accept `cell-foreground` and | |
| + `cell-background` to match the cursor cell at runtime. | |
| - **selection-background / selection-foreground** — Selection colors. | |
| - **copy-highlight / copy-highlight-foreground** — Post-copy flash colors. | |
| - **palette** — Override one palette entry by index `0`–`255`; repeat the key | |
| diff --git a/dist/monstar.5 b/dist/monstar.5 | |
| index 0eb4711..e270708 100644 | |
| --- a/dist/monstar.5 | |
| +++ b/dist/monstar.5 | |
| @@ -219,6 +219,14 @@ For example, | |
| and | |
| .B ffe629 | |
| are equivalent. | |
| +.B cursor-color | |
| +and | |
| +.B cursor-text | |
| +also accept | |
| +.B cell-foreground | |
| +and | |
| +.B cell-background | |
| +to match the cursor cell's colors at runtime. | |
| .PP | |
| An explicit color in the main configuration overrides the named theme. | |
| Runtime OSC color changes may override terminal, cursor, and selection colors | |
| @@ -246,6 +254,14 @@ in both color schemes. | |
| .TP | |
| .BI cursor-color " = color" | |
| Set the cursor color. | |
| +A direct color uses six hexadecimal RGB digits, with an optional leading | |
| +.BR # . | |
| +The special values | |
| +.B cell-foreground | |
| +and | |
| +.B cell-background | |
| +match the cursor cell's foreground or background, after reverse video. | |
| +An OSC 12 cursor color from the application overrides this until it is reset. | |
| Built-in defaults are | |
| .B #242424 | |
| in light mode and | |
| @@ -254,7 +270,13 @@ in dark mode. | |
| .TP | |
| .BI cursor-text " = color" | |
| Set text beneath a focused block cursor. | |
| +Accepts the same values as | |
| +.BR cursor-color . | |
| When unset, the terminal background is used. | |
| +The usual pairing with | |
| +.B cursor-color = cell-foreground | |
| +is | |
| +.BR "cursor-text = cell-background" . | |
| .TP | |
| .BI foreground " = color" | |
| Set the default terminal foreground. | |
| diff --git a/src/App.zig b/src/App.zig | |
| index 1ab47d5..8264baf 100644 | |
| --- a/src/App.zig | |
| +++ b/src/App.zig | |
| @@ -117,8 +117,10 @@ selection_fg_override: ?vt.color.RGB, | |
| copy_highlight: vt.color.RGB, | |
| copy_highlight_fg: vt.color.RGB, | |
| copy_highlight_active: bool, | |
| +/// Configured cursor fill when OSC 12 has not set a VT cursor color. | |
| +cursor_color: Config.TerminalColor, | |
| /// Configured text color beneath a focused block cursor. | |
| -cursor_text: ?vt.color.RGB, | |
| +cursor_text: ?Config.TerminalColor, | |
| window: *Window, | |
| keyboard: Keyboard, | |
| /// Terminal contents changed since the last committed frame. | |
| @@ -646,6 +648,7 @@ pub fn init( | |
| .copy_highlight = config.effectiveCopyHighlight(.dark), | |
| .copy_highlight_fg = config.effectiveCopyHighlightForeground(.dark), | |
| .copy_highlight_active = false, | |
| + .cursor_color = config.effectiveCursorColor(.dark), | |
| .cursor_text = config.effectiveCursorText(.dark), | |
| .window = window, | |
| .keyboard = try .init(), | |
| @@ -2086,6 +2089,7 @@ fn applyColorDefaultsForConfig(self: *App, config: Config) void { | |
| ); | |
| self.copy_highlight = config.effectiveCopyHighlight(self.color_scheme); | |
| self.copy_highlight_fg = config.effectiveCopyHighlightForeground(self.color_scheme); | |
| + self.cursor_color = config.effectiveCursorColor(self.color_scheme); | |
| self.cursor_text = config.effectiveCursorText(self.color_scheme); | |
| } | |
| @@ -4994,6 +4998,7 @@ fn startAsyncRender(self: *App) !AsyncRenderStart { | |
| self.font.discovery(), | |
| selection_bg, | |
| selection_fg, | |
| + self.cursor_color, | |
| self.cursor_text, | |
| self.config.background_opacity, | |
| self.config.background_opacity_cells, | |
| @@ -5002,6 +5007,7 @@ fn startAsyncRender(self: *App) !AsyncRenderStart { | |
| self.font.discovery(), | |
| selection_bg, | |
| selection_fg, | |
| + self.cursor_color, | |
| self.cursor_text, | |
| self.config.background_opacity, | |
| self.config.background_opacity_cells, | |
| @@ -5279,6 +5285,7 @@ fn startAsyncRasterLoad(self: *App) void { | |
| self.font.discovery(), | |
| self.selectionBackgroundForRender(), | |
| self.selectionForegroundForRender(), | |
| + self.cursor_color, | |
| self.cursor_text, | |
| self.config.background_opacity, | |
| self.config.background_opacity_cells, | |
| @@ -5310,6 +5317,7 @@ fn finishAsyncRasterLoad(self: *App) void { | |
| self.font.discovery(), | |
| self.selectionBackgroundForRender(), | |
| self.selectionForegroundForRender(), | |
| + self.cursor_color, | |
| self.cursor_text, | |
| self.config.background_opacity, | |
| self.config.background_opacity_cells, | |
| diff --git a/src/AsyncRaster.zig b/src/AsyncRaster.zig | |
| index 8bc71cb..153bd5e 100644 | |
| --- a/src/AsyncRaster.zig | |
| +++ b/src/AsyncRaster.zig | |
| @@ -13,6 +13,7 @@ const std = @import("std"); | |
| const linux = std.os.linux; | |
| const posix = std.posix; | |
| const vt = @import("ghostty-vt"); | |
| +const Config = @import("Config.zig"); | |
| const Font = @import("Font.zig"); | |
| const Renderer = @import("Renderer.zig"); | |
| @@ -112,7 +113,8 @@ pub const Loader = struct { | |
| discovery: *Font.Discovery, | |
| selection_background: vt.color.RGB, | |
| selection_foreground: ?vt.color.RGB, | |
| - cursor_text: ?vt.color.RGB, | |
| + cursor_color: ?Config.TerminalColor, | |
| + cursor_text: ?Config.TerminalColor, | |
| background_alpha: u8, | |
| background_alpha_cells: bool, | |
| state: *vt.RenderState, | |
| @@ -128,7 +130,8 @@ pub const Loader = struct { | |
| discovery: *Font.Discovery, | |
| selection_background: vt.color.RGB, | |
| selection_foreground: ?vt.color.RGB, | |
| - cursor_text: ?vt.color.RGB, | |
| + cursor_color: ?Config.TerminalColor, | |
| + cursor_text: ?Config.TerminalColor, | |
| background_alpha: u8, | |
| background_alpha_cells: bool, | |
| state: *vt.RenderState, | |
| @@ -140,6 +143,7 @@ pub const Loader = struct { | |
| .discovery = discovery, | |
| .selection_background = selection_background, | |
| .selection_foreground = selection_foreground, | |
| + .cursor_color = cursor_color, | |
| .cursor_text = cursor_text, | |
| .background_alpha = background_alpha, | |
| .background_alpha_cells = background_alpha_cells, | |
| @@ -191,6 +195,7 @@ pub const Loader = struct { | |
| self.discovery, | |
| self.selection_background, | |
| self.selection_foreground, | |
| + self.cursor_color, | |
| self.cursor_text, | |
| self.background_alpha, | |
| self.background_alpha_cells, | |
| @@ -234,7 +239,8 @@ pub fn init( | |
| discovery: *Font.Discovery, | |
| selection_background: vt.color.RGB, | |
| selection_foreground: ?vt.color.RGB, | |
| - cursor_text: ?vt.color.RGB, | |
| + cursor_color: ?Config.TerminalColor, | |
| + cursor_text: ?Config.TerminalColor, | |
| background_alpha: u8, | |
| background_alpha_cells: bool, | |
| state: *vt.RenderState, | |
| @@ -247,6 +253,7 @@ pub fn init( | |
| var renderer: Renderer = try .init(alloc, font, .{ | |
| .selection_background = selection_background, | |
| .selection_foreground = selection_foreground, | |
| + .cursor_color = cursor_color, | |
| .cursor_text = cursor_text, | |
| .background_alpha = background_alpha, | |
| .background_alpha_cells = background_alpha_cells, | |
| @@ -304,7 +311,8 @@ pub fn reconfigure( | |
| discovery: *Font.Discovery, | |
| selection_background: vt.color.RGB, | |
| selection_foreground: ?vt.color.RGB, | |
| - cursor_text: ?vt.color.RGB, | |
| + cursor_color: ?Config.TerminalColor, | |
| + cursor_text: ?Config.TerminalColor, | |
| background_alpha: u8, | |
| background_alpha_cells: bool, | |
| ) !void { | |
| @@ -318,6 +326,7 @@ pub fn reconfigure( | |
| var renderer: Renderer = try .init(self.alloc, font, .{ | |
| .selection_background = selection_background, | |
| .selection_foreground = selection_foreground, | |
| + .cursor_color = cursor_color, | |
| .cursor_text = cursor_text, | |
| .background_alpha = background_alpha, | |
| .background_alpha_cells = background_alpha_cells, | |
| @@ -344,7 +353,8 @@ pub fn configuredFor( | |
| discovery: *Font.Discovery, | |
| selection_background: vt.color.RGB, | |
| selection_foreground: ?vt.color.RGB, | |
| - cursor_text: ?vt.color.RGB, | |
| + cursor_color: ?Config.TerminalColor, | |
| + cursor_text: ?Config.TerminalColor, | |
| background_alpha: u8, | |
| background_alpha_cells: bool, | |
| ) bool { | |
| @@ -353,7 +363,8 @@ pub fn configuredFor( | |
| return self.font.discovery() == discovery and | |
| self.renderer.selection_bg.eql(selection_background) and | |
| optionalRgbEql(self.renderer.selection_fg, selection_foreground) and | |
| - optionalRgbEql(self.renderer.cursor_text, cursor_text) and | |
| + optionalTerminalColorEql(self.renderer.cursor_color, cursor_color) and | |
| + optionalTerminalColorEql(self.renderer.cursor_text, cursor_text) and | |
| self.renderer.background_alpha == background_alpha and | |
| self.renderer.background_alpha_cells == background_alpha_cells; | |
| } | |
| @@ -406,6 +417,11 @@ fn optionalRgbEql(a: ?vt.color.RGB, b: ?vt.color.RGB) bool { | |
| return a.?.eql(b.?); | |
| } | |
| +fn optionalTerminalColorEql(a: ?Config.TerminalColor, b: ?Config.TerminalColor) bool { | |
| + if (a == null or b == null) return a == null and b == null; | |
| + return a.?.eql(b.?); | |
| +} | |
| + | |
| fn drainEventfd(self: *AsyncRaster) void { | |
| drainEventFd(self.complete_fd); | |
| } | |
| @@ -671,6 +687,7 @@ test "unchanged dirty rows report no damage" { | |
| .{ .r = 1, .g = 2, .b = 3 }, | |
| null, | |
| null, | |
| + null, | |
| 255, | |
| false, | |
| &state, | |
| diff --git a/src/Config.zig b/src/Config.zig | |
| index 3700231..83deba2 100644 | |
| --- a/src/Config.zig | |
| +++ b/src/Config.zig | |
| @@ -37,6 +37,7 @@ pub const MouseScrollMultiplier = struct { | |
| pub const Theme = config_theme.Theme; | |
| pub const ThemeColors = config_theme.ThemeColors; | |
| +pub const TerminalColor = config_theme.TerminalColor; | |
| pub const light_theme = config_theme.light_theme; | |
| pub const dark_theme = config_theme.dark_theme; | |
| const ThemeOverrides = config_theme.ThemeOverrides; | |
| @@ -103,8 +104,8 @@ light_theme_overrides: ?ThemeOverrides = null, | |
| dark_theme_overrides: ?ThemeOverrides = null, | |
| background: ?vt.color.RGB = null, | |
| foreground: ?vt.color.RGB = null, | |
| -cursor_color: ?vt.color.RGB = null, | |
| -cursor_text: ?vt.color.RGB = null, | |
| +cursor_color: ?TerminalColor = null, | |
| +cursor_text: ?TerminalColor = null, | |
| selection_background: ?vt.color.RGB = null, | |
| selection_foreground: ?vt.color.RGB = null, | |
| copy_highlight: ?vt.color.RGB = null, | |
| @@ -253,9 +254,9 @@ pub fn set(self: *Config, arena: std.mem.Allocator, key: []const u8, value: []co | |
| } else if (std.mem.eql(u8, key, "foreground")) { | |
| self.foreground = try config_theme.parseColor(value); | |
| } else if (std.mem.eql(u8, key, "cursor-color")) { | |
| - self.cursor_color = try config_theme.parseColor(value); | |
| + self.cursor_color = try config_theme.parseTerminalColor(value); | |
| } else if (std.mem.eql(u8, key, "cursor-text")) { | |
| - self.cursor_text = try config_theme.parseColor(value); | |
| + self.cursor_text = try config_theme.parseTerminalColor(value); | |
| } else if (std.mem.eql(u8, key, "selection-background")) { | |
| self.selection_background = try config_theme.parseColor(value); | |
| } else if (std.mem.eql(u8, key, "selection-foreground")) { | |
| @@ -424,7 +425,16 @@ pub fn effectiveCopyHighlightForeground(self: *const Config, color_scheme: vt.de | |
| return self.effectiveColor(color_scheme, "copy_highlight_foreground"); | |
| } | |
| -pub fn effectiveCursorText(self: *const Config, color_scheme: vt.device_status.ColorScheme) ?vt.color.RGB { | |
| +pub fn effectiveCursorColor(self: *const Config, color_scheme: vt.device_status.ColorScheme) TerminalColor { | |
| + const named = if (self.namedThemeOverrides(color_scheme)) |theme| theme.cursor_color else null; | |
| + return config_theme.resolveTerminalColor( | |
| + self.cursor_color, | |
| + named, | |
| + colorsForScheme(color_scheme).cursor_color, | |
| + ); | |
| +} | |
| + | |
| +pub fn effectiveCursorText(self: *const Config, color_scheme: vt.device_status.ColorScheme) ?TerminalColor { | |
| const named = if (self.namedThemeOverrides(color_scheme)) |theme| theme.cursor_text else null; | |
| return self.cursor_text orelse named; | |
| } | |
| @@ -441,7 +451,10 @@ pub fn terminalColors(self: *const Config, color_scheme: vt.device_status.ColorS | |
| return .{ | |
| .background = .init(self.effectiveColor(color_scheme, "background")), | |
| .foreground = .init(self.effectiveColor(color_scheme, "foreground")), | |
| - .cursor = .init(self.effectiveColor(color_scheme, "cursor_color")), | |
| + .cursor = if (self.effectiveCursorColor(color_scheme).toRgb()) |rgb| | |
| + .init(rgb) | |
| + else | |
| + .unset, | |
| .palette = .init(palette), | |
| }; | |
| } | |
| @@ -489,8 +502,8 @@ test "defaults" { | |
| try std.testing.expectEqual(@as(?ThemeOverrides, null), config.dark_theme_overrides); | |
| try std.testing.expectEqual(@as(?vt.color.RGB, null), config.background); | |
| try std.testing.expectEqual(@as(?vt.color.RGB, null), config.foreground); | |
| - try std.testing.expectEqual(@as(?vt.color.RGB, null), config.cursor_color); | |
| - try std.testing.expectEqual(@as(?vt.color.RGB, null), config.cursor_text); | |
| + try std.testing.expectEqual(@as(?TerminalColor, null), config.cursor_color); | |
| + try std.testing.expectEqual(@as(?TerminalColor, null), config.cursor_text); | |
| try std.testing.expectEqual(@as(?vt.color.RGB, null), config.selection_background); | |
| try std.testing.expectEqual(@as(?vt.color.RGB, null), config.selection_foreground); | |
| try std.testing.expectEqual(@as(?vt.color.RGB, null), config.copy_highlight); | |
| @@ -560,8 +573,8 @@ test "parse config" { | |
| try std.testing.expect(config.background_opacity_cells); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 0x1a, .g = 0x1b, .b = 0x26 }, config.background.?); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 0xc0, .g = 0xca, .b = 0xf5 }, config.foreground.?); | |
| - try std.testing.expectEqual(vt.color.RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, config.cursor_color.?); | |
| - try std.testing.expectEqual(vt.color.RGB{ .r = 1, .g = 2, .b = 3 }, config.cursor_text.?); | |
| + try std.testing.expectEqual(TerminalColor{ .rgb = .{ .r = 0xaa, .g = 0xbb, .b = 0xcc } }, config.cursor_color.?); | |
| + try std.testing.expectEqual(TerminalColor{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }, config.cursor_text.?); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 4, .g = 5, .b = 6 }, config.selection_background.?); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 7, .g = 8, .b = 9 }, config.selection_foreground.?); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 10, .g = 11, .b = 12 }, config.copy_highlight.?); | |
| @@ -811,10 +824,10 @@ test "named themes follow color scheme and remain below explicit colors" { | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 0xdd, .g = 0xdd, .b = 0xdd }, config.effectiveSelectionBackground(.light)); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 0xff, .g = 0xe6, .b = 0x29 }, config.effectiveCopyHighlight(.light)); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 0x1c, .g = 0x20, .b = 0x24 }, config.effectiveCopyHighlightForeground(.light)); | |
| - try std.testing.expectEqual(vt.color.RGB{ .r = 0xfe, .g = 0xdc, .b = 0xba }, config.effectiveCursorText(.light).?); | |
| + try std.testing.expectEqual(TerminalColor{ .rgb = .{ .r = 0xfe, .g = 0xdc, .b = 0xba } }, config.effectiveCursorText(.light).?); | |
| - config.cursor_text = .{ .r = 7, .g = 8, .b = 9 }; | |
| - try std.testing.expectEqual(vt.color.RGB{ .r = 7, .g = 8, .b = 9 }, config.effectiveCursorText(.light).?); | |
| + config.cursor_text = .{ .rgb = .{ .r = 7, .g = 8, .b = 9 } }; | |
| + try std.testing.expectEqual(TerminalColor{ .rgb = .{ .r = 7, .g = 8, .b = 9 } }, config.effectiveCursorText(.light).?); | |
| const dark = config.terminalColors(.dark); | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 0xf0, .g = 0xf0, .b = 0xf0 }, dark.foreground.get().?); | |
| @@ -822,6 +835,44 @@ test "named themes follow color scheme and remain below explicit colors" { | |
| try std.testing.expectEqual(vt.color.RGB{ .r = 0xe0, .g = 0xe0, .b = 0xe0 }, config.effectiveSelectionForeground(.dark)); | |
| } | |
| +test "cell cursor colors stay out of the VT default layer" { | |
| + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); | |
| + defer arena_state.deinit(); | |
| + const config = parse(arena_state.allocator(), | |
| + \\cursor-color = cell-foreground | |
| + \\cursor-text = cell-background | |
| + \\cursor-color = not-a-color | |
| + ); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_foreground), config.cursor_color.?); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_background), config.cursor_text.?); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_foreground), config.effectiveCursorColor(.dark)); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_background), config.effectiveCursorText(.dark).?); | |
| + try std.testing.expectEqual(@as(?vt.color.RGB, null), config.terminalColors(.dark).cursor.get()); | |
| + try std.testing.expectEqual(@as(?vt.color.RGB, null), config.terminalColors(.light).cursor.get()); | |
| +} | |
| + | |
| +test "named theme cell cursor colors stay below explicit colors" { | |
| + var config: Config = .{}; | |
| + config.light_theme_overrides = config_theme.parseOverrides( | |
| + \\cursor-color = cell-foreground | |
| + \\cursor-text = cell-background | |
| + ); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_foreground), config.effectiveCursorColor(.light)); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_background), config.effectiveCursorText(.light).?); | |
| + try std.testing.expectEqual(@as(?vt.color.RGB, null), config.terminalColors(.light).cursor.get()); | |
| + try std.testing.expectEqual( | |
| + TerminalColor{ .rgb = dark_theme.cursor_color }, | |
| + config.effectiveCursorColor(.dark), | |
| + ); | |
| + | |
| + config.cursor_color = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; | |
| + try std.testing.expectEqual( | |
| + TerminalColor{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }, | |
| + config.effectiveCursorColor(.light), | |
| + ); | |
| + try std.testing.expectEqual(vt.color.RGB{ .r = 1, .g = 2, .b = 3 }, config.terminalColors(.light).cursor.get().?); | |
| +} | |
| + | |
| test "absolute theme file resolves" { | |
| var tmp = std.testing.tmpDir(.{}); | |
| defer tmp.cleanup(); | |
| diff --git a/src/Renderer.zig b/src/Renderer.zig | |
| index efb9715..24112ec 100644 | |
| --- a/src/Renderer.zig | |
| +++ b/src/Renderer.zig | |
| @@ -52,9 +52,12 @@ selection_fg: ?vt.color.RGB, | |
| /// Highlight colors for the selected scrollback-search match. | |
| search_bg: vt.color.RGB, | |
| search_fg: vt.color.RGB, | |
| +/// Configured cursor fill when OSC 12 has not set a VT cursor color. | |
| +/// Null keeps the previous fallback of window foreground. | |
| +cursor_color: ?Config.TerminalColor, | |
| /// Explicit text color under a focused block cursor. Null preserves the | |
| /// terminal background fallback. | |
| -cursor_text: ?vt.color.RGB, | |
| +cursor_text: ?Config.TerminalColor, | |
| /// Alpha applied to the default terminal background and window padding. | |
| background_alpha: u8, | |
| /// Whether background alpha also applies to explicit terminal cell | |
| @@ -129,7 +132,8 @@ const KittyScaleKey = struct { | |
| pub const InitOptions = struct { | |
| selection_background: ?vt.color.RGB = null, | |
| selection_foreground: ?vt.color.RGB = null, | |
| - cursor_text: ?vt.color.RGB = null, | |
| + cursor_color: ?Config.TerminalColor = null, | |
| + cursor_text: ?Config.TerminalColor = null, | |
| background_alpha: u8 = 255, | |
| background_alpha_cells: bool = false, | |
| /// Benchmark escape hatch for comparing the superseded row path. | |
| @@ -175,6 +179,7 @@ pub fn init(alloc: std.mem.Allocator, font: *Font, opts: InitOptions) !Renderer | |
| .selection_fg = opts.selection_foreground, | |
| .search_bg = Config.dark_theme.copy_highlight, | |
| .search_fg = Config.dark_theme.copy_highlight_foreground, | |
| + .cursor_color = opts.cursor_color, | |
| .cursor_text = opts.cursor_text, | |
| .background_alpha = opts.background_alpha, | |
| .background_alpha_cells = opts.background_alpha_cells, | |
| @@ -1188,6 +1193,41 @@ fn renderRowCells( | |
| try self.renderRowForegroundCells(state, cells, cell_range, y, pixels, width, height); | |
| } | |
| +/// Cell SGR colors after reverse video, used to resolve `cell-foreground` | |
| +/// / `cell-background` cursor colors. Faint, selection, and search are | |
| +/// not applied, matching Ghostty. | |
| +fn cursorCellRgb( | |
| + style: vt.Style, | |
| + cell: anytype, | |
| + colors: *const vt.RenderState.Colors, | |
| +) struct { fg: vt.color.RGB, bg: vt.color.RGB } { | |
| + const fg = style.fg(.{ .default = colors.foreground, .palette = &colors.palette }); | |
| + const bg = style.bg(cell, &colors.palette) orelse colors.background; | |
| + if (style.flags.inverse) return .{ .fg = bg, .bg = fg }; | |
| + return .{ .fg = fg, .bg = bg }; | |
| +} | |
| + | |
| +fn cursorFill( | |
| + self: *const Renderer, | |
| + colors: *const vt.RenderState.Colors, | |
| + cell_fg: vt.color.RGB, | |
| + cell_bg: vt.color.RGB, | |
| +) vt.color.RGB { | |
| + if (colors.cursor) |color| return color; | |
| + if (self.cursor_color) |configured| return configured.resolve(cell_fg, cell_bg); | |
| + return colors.foreground; | |
| +} | |
| + | |
| +fn cursorGlyph( | |
| + self: *const Renderer, | |
| + colors: *const vt.RenderState.Colors, | |
| + cell_fg: vt.color.RGB, | |
| + cell_bg: vt.color.RGB, | |
| +) vt.color.RGB { | |
| + if (self.cursor_text) |configured| return configured.resolve(cell_fg, cell_bg); | |
| + return colors.background; | |
| +} | |
| + | |
| /// Which cell backgrounds prepareRow paints. `.all` covers the entire | |
| /// row rect (unstyled cells and the right margin get the default | |
| /// background), so callers need no separate clear pass. `.styled` | |
| @@ -1311,8 +1351,9 @@ fn prepareRowCells( | |
| if (cursor_x != null and cursor_x.? == x and | |
| state.cursor.visual_style == .block and self.focused) | |
| { | |
| - bg = colors.cursor orelse colors.foreground; | |
| - fg = self.cursor_text orelse colors.background; | |
| + const cell = cursorCellRgb(style, &raws[x], colors); | |
| + bg = self.cursorFill(colors, cell.fg, cell.bg); | |
| + fg = self.cursorGlyph(colors, cell.fg, cell.bg); | |
| reverse_color_glyph = false; | |
| dim_search_bg = false; | |
| background_uses_alpha = false; | |
| @@ -1514,7 +1555,8 @@ fn renderRowForegroundCells( | |
| .block_hollow => .cursor_hollow_rect, | |
| }; | |
| if (kind) |k| { | |
| - const color = colors.cursor orelse colors.foreground; | |
| + const cell = cursorCellRgb(state.cursor.style, &state.cursor.cell, colors); | |
| + const color = self.cursorFill(colors, cell.fg, cell.bg); | |
| try self.blitDecoration(k, cx, y, argb(color), pixels, width, height); | |
| } | |
| } | |
| @@ -2290,6 +2332,90 @@ test "cursor splits shaping runs" { | |
| try std.testing.expectEqual(@as(usize, 3), renderer.text_shaper.readStats().cache_misses); | |
| } | |
| +test "cursor colors resolve from cells unless OSC 12 is set" { | |
| + const alloc = std.testing.allocator; | |
| + const window_bg: vt.color.RGB = .{ .r = 10, .g = 20, .b = 30 }; | |
| + const window_fg: vt.color.RGB = .{ .r = 240, .g = 230, .b = 220 }; | |
| + const cell_fg: vt.color.RGB = .{ .r = 200, .g = 10, .b = 20 }; | |
| + const cell_bg: vt.color.RGB = .{ .r = 30, .g = 40, .b = 50 }; | |
| + const osc_cursor: vt.color.RGB = .{ .r = 9, .g = 8, .b = 7 }; | |
| + | |
| + var term: vt.Terminal = try .init(std.testing.io, alloc, .{ .cols = 2, .rows = 1 }); | |
| + defer term.deinit(alloc); | |
| + term.colors.background = .init(window_bg); | |
| + term.colors.foreground = .init(window_fg); | |
| + term.colors.cursor = .unset; | |
| + | |
| + var stream = term.vtStream(); | |
| + defer stream.deinit(); | |
| + stream.nextSlice("\x1b[38;2;200;10;20;48;2;30;40;50m \x1b[1;1H"); | |
| + | |
| + var state: vt.RenderState = .empty; | |
| + defer state.deinit(alloc); | |
| + try state.update(alloc, &term); | |
| + | |
| + var font: Font = try .init(alloc, "monospace", 16); | |
| + defer font.deinit(alloc); | |
| + var renderer: Renderer = try .init(alloc, &font, .{ | |
| + .cursor_color = .cell_foreground, | |
| + .cursor_text = .cell_background, | |
| + }); | |
| + defer renderer.deinit(); | |
| + | |
| + const width: u31 = font.cell_width * 2; | |
| + const height: u31 = font.cell_height; | |
| + const pixels = try alloc.alloc(u32, @as(usize, width) * height); | |
| + defer alloc.free(pixels); | |
| + | |
| + const center = @as(usize, font.cell_height / 2) * width + font.cell_width / 2; | |
| + try renderer.render(&state, pixels, width, height); | |
| + try std.testing.expectEqual(argb(cell_fg), pixels[center]); | |
| + | |
| + stream.nextSlice("\x1b[H\x1b[7;38;2;200;10;20;48;2;30;40;50m \x1b[1;1H"); | |
| + try state.update(alloc, &term); | |
| + try renderer.render(&state, pixels, width, height); | |
| + try std.testing.expectEqual(argb(cell_bg), pixels[center]); | |
| + | |
| + term.colors.cursor.set(osc_cursor); | |
| + try state.update(alloc, &term); | |
| + try renderer.render(&state, pixels, width, height); | |
| + try std.testing.expectEqual(argb(osc_cursor), pixels[center]); | |
| +} | |
| + | |
| +test "cursor fill and glyph helpers follow TerminalColor" { | |
| + const alloc = std.testing.allocator; | |
| + const colors: vt.RenderState.Colors = .{ | |
| + .background = .{ .r = 1, .g = 2, .b = 3 }, | |
| + .foreground = .{ .r = 4, .g = 5, .b = 6 }, | |
| + .cursor = null, | |
| + .palette = vt.color.default, | |
| + }; | |
| + const fg: vt.color.RGB = .{ .r = 10, .g = 20, .b = 30 }; | |
| + const bg: vt.color.RGB = .{ .r = 40, .g = 50, .b = 60 }; | |
| + | |
| + var font: Font = try .init(alloc, "monospace", 16); | |
| + defer font.deinit(alloc); | |
| + var renderer: Renderer = try .init(alloc, &font, .{}); | |
| + defer renderer.deinit(); | |
| + | |
| + try std.testing.expectEqual(colors.foreground, renderer.cursorFill(&colors, fg, bg)); | |
| + try std.testing.expectEqual(colors.background, renderer.cursorGlyph(&colors, fg, bg)); | |
| + | |
| + renderer.cursor_color = .cell_foreground; | |
| + renderer.cursor_text = .cell_background; | |
| + try std.testing.expectEqual(fg, renderer.cursorFill(&colors, fg, bg)); | |
| + try std.testing.expectEqual(bg, renderer.cursorGlyph(&colors, fg, bg)); | |
| + | |
| + renderer.cursor_color = .cell_background; | |
| + renderer.cursor_text = .cell_foreground; | |
| + try std.testing.expectEqual(bg, renderer.cursorFill(&colors, fg, bg)); | |
| + try std.testing.expectEqual(fg, renderer.cursorGlyph(&colors, fg, bg)); | |
| + | |
| + var osc_colors = colors; | |
| + osc_colors.cursor = .{ .r = 9, .g = 8, .b = 7 }; | |
| + try std.testing.expectEqual(osc_colors.cursor.?, renderer.cursorFill(&osc_colors, fg, bg)); | |
| +} | |
| + | |
| test "background opacity cells controls explicit cell backgrounds" { | |
| const alloc = std.testing.allocator; | |
| diff --git a/src/config_theme.zig b/src/config_theme.zig | |
| index f3acb73..d46094e 100644 | |
| --- a/src/config_theme.zig | |
| +++ b/src/config_theme.zig | |
| @@ -15,11 +15,51 @@ pub const Theme = struct { | |
| dark: [:0]const u8, | |
| }; | |
| +/// A configured color that can be a concrete RGB value or a runtime | |
| +/// reference to the cell's foreground or background. | |
| +pub const TerminalColor = union(enum) { | |
| + rgb: vt.color.RGB, | |
| + cell_foreground, | |
| + cell_background, | |
| + | |
| + pub fn toRgb(self: TerminalColor) ?vt.color.RGB { | |
| + return switch (self) { | |
| + .rgb => |color| color, | |
| + .cell_foreground, .cell_background => null, | |
| + }; | |
| + } | |
| + | |
| + pub fn resolve(self: TerminalColor, cell_fg: vt.color.RGB, cell_bg: vt.color.RGB) vt.color.RGB { | |
| + return switch (self) { | |
| + .rgb => |color| color, | |
| + .cell_foreground => cell_fg, | |
| + .cell_background => cell_bg, | |
| + }; | |
| + } | |
| + | |
| + pub fn eql(self: TerminalColor, other: TerminalColor) bool { | |
| + return switch (self) { | |
| + .rgb => |color| switch (other) { | |
| + .rgb => |other_color| color.eql(other_color), | |
| + else => false, | |
| + }, | |
| + .cell_foreground => switch (other) { | |
| + .cell_foreground => true, | |
| + else => false, | |
| + }, | |
| + .cell_background => switch (other) { | |
| + .cell_background => true, | |
| + else => false, | |
| + }, | |
| + }; | |
| + } | |
| +}; | |
| + | |
| pub const ThemeOverrides = struct { | |
| background: ?vt.color.RGB = null, | |
| foreground: ?vt.color.RGB = null, | |
| - cursor_color: ?vt.color.RGB = null, | |
| - cursor_text: ?vt.color.RGB = null, | |
| + cursor_color: ?TerminalColor = null, | |
| + cursor_text: ?TerminalColor = null, | |
| selection_background: ?vt.color.RGB = null, | |
| selection_foreground: ?vt.color.RGB = null, | |
| copy_highlight: ?vt.color.RGB = null, | |
| @@ -160,6 +200,13 @@ pub fn parseColor(value: []const u8) error{InvalidValue}!vt.color.RGB { | |
| }; | |
| } | |
| +/// Hex color, `cell-foreground`, or `cell-background`. | |
| +pub fn parseTerminalColor(value: []const u8) error{InvalidValue}!TerminalColor { | |
| + if (std.mem.eql(u8, value, "cell-foreground")) return .cell_foreground; | |
| + if (std.mem.eql(u8, value, "cell-background")) return .cell_background; | |
| + return .{ .rgb = try parseColor(value) }; | |
| +} | |
| + | |
| pub fn loadOverrides( | |
| io: std.Io, | |
| arena: std.mem.Allocator, | |
| @@ -225,12 +272,12 @@ pub fn parseOverrides(text: []const u8) ThemeOverrides { | |
| continue; | |
| }; | |
| } else if (std.mem.eql(u8, key, "cursor-color")) { | |
| - result.cursor_color = parseColor(value) catch { | |
| + result.cursor_color = parseTerminalColor(value) catch { | |
| warn("theme line {d}: invalid cursor-color", .{line_no}); | |
| continue; | |
| }; | |
| } else if (std.mem.eql(u8, key, "cursor-text")) { | |
| - result.cursor_text = parseColor(value) catch { | |
| + result.cursor_text = parseTerminalColor(value) catch { | |
| warn("theme line {d}: invalid cursor-text", .{line_no}); | |
| continue; | |
| }; | |
| @@ -278,6 +325,14 @@ pub fn resolveColor(explicit: ?vt.color.RGB, named: ?vt.color.RGB, built_in: vt. | |
| return explicit orelse named orelse built_in; | |
| } | |
| +pub fn resolveTerminalColor( | |
| + explicit: ?TerminalColor, | |
| + named: ?TerminalColor, | |
| + built_in: vt.color.RGB, | |
| +) TerminalColor { | |
| + return explicit orelse named orelse .{ .rgb = built_in }; | |
| +} | |
| + | |
| pub fn resolvePalette( | |
| explicit: *const [256]?vt.color.RGB, | |
| named: ?*const ThemeOverrides, | |
| @@ -314,3 +369,36 @@ fn readFile(arena: std.mem.Allocator, path: [:0]const u8) ?[]const u8 { | |
| } | |
| return buf.items; | |
| } | |
| + | |
| +test "parse terminal colors" { | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_foreground), try parseTerminalColor("cell-foreground")); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_background), try parseTerminalColor("cell-background")); | |
| + try std.testing.expectEqual( | |
| + TerminalColor{ .rgb = .{ .r = 0xaa, .g = 0xbb, .b = 0xcc } }, | |
| + try parseTerminalColor("#aabbcc"), | |
| + ); | |
| + try std.testing.expectError(error.InvalidValue, parseTerminalColor("cell-foo")); | |
| + try std.testing.expectError(error.InvalidValue, parseColor("cell-foreground")); | |
| +} | |
| + | |
| +test "terminal color resolve and equality" { | |
| + const fg: vt.color.RGB = .{ .r = 1, .g = 2, .b = 3 }; | |
| + const bg: vt.color.RGB = .{ .r = 4, .g = 5, .b = 6 }; | |
| + const rgb: TerminalColor = .{ .rgb = fg }; | |
| + try std.testing.expectEqual(fg, @as(TerminalColor, .cell_foreground).resolve(fg, bg)); | |
| + try std.testing.expectEqual(bg, @as(TerminalColor, .cell_background).resolve(fg, bg)); | |
| + try std.testing.expectEqual(fg, rgb.resolve(bg, bg)); | |
| + try std.testing.expect(rgb.eql(.{ .rgb = fg })); | |
| + try std.testing.expect(!rgb.eql(.cell_foreground)); | |
| + try std.testing.expect(@as(TerminalColor, .cell_foreground).eql(.cell_foreground)); | |
| + try std.testing.expect(!@as(TerminalColor, .cell_foreground).eql(.cell_background)); | |
| +} | |
| + | |
| +test "theme overrides parse cell cursor colors" { | |
| + const theme = parseOverrides( | |
| + \\cursor-color = cell-foreground | |
| + \\cursor-text = cell-background | |
| + ); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_foreground), theme.cursor_color.?); | |
| + try std.testing.expectEqual(@as(TerminalColor, .cell_background), theme.cursor_text.?); | |
| +} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment