From 0f65e934be73eb878f130cf5e5f24359f33ff657 Mon Sep 17 00:00:00 2001 From: Martin Geisler Date: Sun, 10 May 2026 12:21:30 +0200 Subject: [PATCH] Implement bold-color support (bright and fixed) Add support for Ghostty's `bold-color` configuration, allowing bold text to be rendered using bright colors or a specific fixed color. Summary of changes: - src/terminal.zig: Add `BoldConfig` and `bold_config` field to `Terminal`. - src/module.zig: Add `ghostel--set-bold-config` to update bold settings. - src/render.zig: Implement color substitution logic in `readCellProps`. Bold text with palette colors 0-7 is mapped to 8-15. Bold text with default foreground uses `bold_config.fixed_color` if mode is `fixed`. - lisp/ghostel.el: Add `ghostel-bold-color` defcustom (default `bright`) and logic to apply it to new and existing terminals. - test/ghostel-test.el: Add unit tests for both 'bright and fixed-color bold modes. - src/emacs.zig: Add `bright` symbol to cached symbols. Matches Ghostty 1.2.0 behavior. > Written by Gemini, tested by me. --- lisp/ghostel.el | 45 +++++++++++++++++- src/Renderer.zig | 34 ++++++++++++-- src/emacs.zig | 1 + src/module.zig | 37 +++++++++++++++ test/ghostel-test.el | 107 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 4 deletions(-) diff --git a/lisp/ghostel.el b/lisp/ghostel.el index acee652..1ce84ac 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -873,6 +873,7 @@ Used when `cursor-in-non-selected-windows' resolves to box.") (declare-function ghostel--mouse-event "ghostel-module") (declare-function ghostel--new "ghostel-module") (declare-function ghostel--redraw "ghostel-module" (term &optional full)) +(declare-function ghostel--set-bold-config "ghostel-module") (declare-function ghostel--set-default-colors "ghostel-module") (declare-function ghostel--set-palette "ghostel-module") (declare-function ghostel--set-size "ghostel-module" (term rows cols &optional cell-w cell-h)) @@ -1234,6 +1235,36 @@ Updated whenever the terminal is created or resized.") "Column count of the native terminal. Updated whenever the terminal is created or resized.") +(defcustom ghostel-bold-color nil + "Configure how bold text is colored. + +If nil (default), bold text uses the same color as normal text. + +If `bright', bold text uses the bright version of the current +foreground color (ANSI colors 0-7 map to 8-15). + +If a string (hex color like \"#RRGGBB\"), bold text with the default +foreground color uses this specific color. Bold text with a palette +color (0-7) will use the bright version (8-15). + +Matches Ghostty 1.2.0's `bold-color' configuration." + :type '(choice (const :tag "None" nil) + (const :tag "Bright" bright) + (string :tag "Fixed color (#RRGGBB)" + :match (lambda (_widget val) + (and (stringp val) + (string-match-p + "\\`#[0-9A-Fa-f]\\{6\\}\\'" val))))) + :group 'ghostel + :set (lambda (sym val) + (set-default sym val) + (dolist (buf (buffer-list)) + (with-current-buffer buf + (when (and (derived-mode-p 'ghostel-mode) ghostel--term) + (ghostel--apply-bold-config ghostel--term) + (let ((inhibit-read-only t)) + (ghostel--redraw ghostel--term t))))))) + (defvar-local ghostel--cursor-pos nil "The position of the terminal cursor as (COL . ROW) in terminal screen coords.") @@ -5023,6 +5054,15 @@ Falls back to \"#000000\" if the color cannot be resolved." ""))) (ghostel--set-palette term colors))))) +(defun ghostel--apply-bold-config (term) + "Apply `ghostel-bold-color' to terminal handle TERM." + (when (user-ptrp term) + (ghostel--load-module) + (ghostel--set-bold-config + term (if (eq ghostel-bold-color 'bright) + 'bright + ghostel-bold-color)))) + ;;; Theme synchronization @@ -5034,6 +5074,7 @@ Call this after changing the Emacs theme so terminals match." (with-current-buffer buf (when (and (derived-mode-p 'ghostel-mode) ghostel--term) (ghostel--apply-palette ghostel--term) + (ghostel--apply-bold-config ghostel--term) (when (ghostel--terminal-live-p) (setq ghostel--force-next-redraw t) (ghostel--delayed-redraw buf)))))) @@ -6363,7 +6404,8 @@ buffer can be found again after title-tracking renames it." ;; and the terminal advances the cursor zero rows, leaving the ;; next prompt on top of the image. (ghostel--set-size-with-cell-dims ghostel--term height width) - (ghostel--apply-palette ghostel--term)) + (ghostel--apply-palette ghostel--term) + (ghostel--apply-bold-config ghostel--term)) (ghostel--start-process)))) (defun ghostel--find-buffer-by-identity (identity) @@ -6441,6 +6483,7 @@ Signals `user-error' if BUFFER already has a live ghostel process." ;; see the matching call in `ghostel--ensure-buffer-state'. (ghostel--set-size-with-cell-dims ghostel--term height width) (ghostel--apply-palette ghostel--term) + (ghostel--apply-bold-config ghostel--term) (ghostel--spawn-pty program args height width ghostel--default-stty nil remote-p))))) diff --git a/src/Renderer.zig b/src/Renderer.zig index 5c78238..fd730d1 100644 --- a/src/Renderer.zig +++ b/src/Renderer.zig @@ -38,6 +38,15 @@ row: RowContent = .{}, font_info: ?FontInfo = null, +/// Bold text coloring configuration. +bold_config: BoldConfig = .none, + +pub const BoldConfig = union(enum) { + none, + bright, + fixed: gt.ColorRgb, +}; + const FontInfo = struct { width: i64, height: i64, @@ -285,7 +294,7 @@ fn formatColor(color: gt.ColorRgb, buf: *[7]u8) []const u8 { } /// Read the style for the current cell from the render state. -fn readCellProps(cells: gt.RenderStateRowCells, key: CellPropKey) !?CellProps { +fn readCellProps(self: *Self, term: *Terminal, cells: gt.RenderStateRowCells, key: CellPropKey) !?CellProps { var props: CellProps = .{}; props.fg = gt.rs_row_cells.get(gt.ColorRgb, cells, gt.RS_CELLS_DATA_FG_COLOR) catch |err| switch (err) { @@ -310,6 +319,22 @@ fn readCellProps(cells: gt.RenderStateRowCells, key: CellPropKey) !?CellProps { if (gs.underline_color.tag == gt.c.GHOSTTY_STYLE_COLOR_RGB) { props.underline_color = gs.underline_color.value.rgb; } + + // Bold color handling (matches Ghostty 1.2.0+) + if (props.bold and self.bold_config != .none) { + if (gs.fg_color.tag == gt.c.GHOSTTY_STYLE_COLOR_PALETTE) { + const index = gs.fg_color.value.palette; + if (index < 8) { + const palette = try term.getColorPalette(); + props.fg = palette[index + 8]; + } + } else if (gs.fg_color.tag == gt.c.GHOSTTY_STYLE_COLOR_NONE) { + switch (self.bold_config) { + .fixed => |fixed_color| props.fg = fixed_color, + else => {}, + } + } + } } props.hyperlink = key.hyperlink; @@ -500,6 +525,7 @@ pub const RowContent = struct { /// styled blanks are preserved. pub fn build( self: *RowContent, + term: *Terminal, row: gt.RenderStateRowIterator, row_cells: *gt.RenderStateRowCells, adjustment_threshold: u32, @@ -568,7 +594,7 @@ pub const RowContent = struct { try self.runs.append(RowContent.allocator, .{ .start_char = self.char_len, .end_char = self.char_len, - .props = try readCellProps(row_cells.*, prop_key), + .props = try readCellProps(&term.renderer, term, row_cells.*, prop_key), }); current_prop_key = prop_key; } @@ -716,9 +742,11 @@ fn adjustGlyphs(self: *Self, env: emacs.Env, row_start: i64) void { fn insertRow( self: *Self, env: emacs.Env, + term: *Terminal, default_colors: *const BgFg, ) !void { try self.row.build( + term, self.row_iterator, &self.row_cells, if (self.font_info) |f| f.coverage else std.math.maxInt(u32), @@ -853,7 +881,7 @@ pub fn render(self: *Self, env: emacs.Env, term: *Terminal, skip: usize, force_f const dirty_row = dirty_full or try gt.rs_row.get(bool, self.row_iterator, gt.RS_ROW_DATA_DIRTY); if (dirty_row) { env.deleteRegion(env.point(), env.lineBeginningPosition2()); - try self.insertRow(env, &default_colors); + try self.insertRow(env, term, &default_colors); } else { _ = env.forwardLine(1); } diff --git a/src/emacs.zig b/src/emacs.zig index cc25e43..b7a6261 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -391,6 +391,7 @@ const interned_symbols = [_][:0]const u8{ ":underline", ":weight", "bold", + "bright", "char-before", "cons", "dash", diff --git a/src/module.zig b/src/module.zig index 0939085..d2d6512 100644 --- a/src/module.zig +++ b/src/module.zig @@ -91,6 +91,13 @@ export fn emacs_module_init(runtime: *c.struct_emacs_runtime) callconv(.c) c_int \\ \\(ghostel--set-default-colors TERM FG-HEX BG-HEX) ); + env.bindFunction("ghostel--set-bold-config", 2, 2, &fnSetBoldConfig, + \\Configure bold text coloring. + \\ + \\CONFIG can be nil (none), 'bright, or a hex color string. + \\ + \\(ghostel--set-bold-config TERM CONFIG) + ); env.bindFunction("ghostel--mode-enabled", 2, 2, &fnModeEnabled, \\Return t if terminal DEC private MODE is enabled. \\ @@ -1088,6 +1095,36 @@ fn fnSetDefaultColors(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, return env.t(); } +/// (ghostel--set-bold-config TERM CONFIG) +/// +/// CONFIG can be nil (none), 'bright, or a hex color string. +fn fnSetBoldConfig(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value { + const env = emacs.Env.init(raw_env.?); + const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); + const val = args[1]; + + if (env.isNil(val)) { + term.renderer.bold_config = .none; + } else if (env.eq(val, emacs.sym.bright)) { + term.renderer.bold_config = .bright; + } else { + var hex_buf: [16]u8 = undefined; + const hex = env.extractString(val, &hex_buf) orelse { + env.signalError("invalid bold config value", .{}); + return env.nil(); + }; + + if (parseHexColor(hex)) |color| { + term.renderer.bold_config = .{ .fixed = color }; + } else { + env.signalError("invalid bold color: %s", .{hex}); + return env.nil(); + } + } + + return env.t(); +} + /// (ghostel--debug-state TERM) /// Returns a string with render state debug info. /// diff --git a/test/ghostel-test.el b/test/ghostel-test.el index a1c0ef3..9638490 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -14720,6 +14720,113 @@ slip past the unit tests." `(and "^ghostel-test-" (not (member ,@ghostel-test--elisp-tests))))) +(defun ghostel-test--bold-color-palette () + "Return a 256-entry hex palette string with index 1 red and 9 green. +Used by bold-color tests so palette mapping is observable." + (concat "#000000" ;; 0 + "#ff0000" ;; 1 (red) + (apply #'concat (make-list 7 "#000000")) ;; 2..8 + "#00ff00" ;; 9 (bright red, distinguishable) + (apply #'concat (make-list 246 "#000000")))) + +(ert-deftest ghostel-test-bold-is-bright () + "Test that bold text uses bright colors when ghostel-bold-color is 'bright." + (let ((buf (generate-new-buffer " *ghostel-test-bold*")) + (ghostel-bold-color 'bright)) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + + ;; Write bold red text + (ghostel--write-input term "\e[1;31mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#00ff00" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-fixed-color () + "Test that bold text uses a fixed color when ghostel-bold-color is a hex string." + (let ((buf (generate-new-buffer " *ghostel-test-bold-fixed*")) + (ghostel-bold-color "#abcdef")) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--apply-bold-config term) + + ;; Write bold text without color + (ghostel--write-input term "\e[1mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#abcdef" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-color-nil-leaves-fg-alone () + "Test that bold text keeps its original color when `ghostel-bold-color' is nil." + (let ((buf (generate-new-buffer " *ghostel-test-bold-nil*")) + (ghostel-bold-color nil)) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + ;; Bold red (palette 1) must stay red — no brightening to palette 9. + (ghostel--write-input term "\e[1;31mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#ff0000" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-fixed-also-brightens-palette () + "Test that fixed-color bold still maps palette 0-7 to 8-15. +The fixed color only applies to default-fg cells; palette colors take +the bright variant just like in `bright' mode." + (let ((buf (generate-new-buffer " *ghostel-test-bold-fixed-palette*")) + (ghostel-bold-color "#abcdef")) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + ;; Bold red (palette 1) → bright red (palette 9 = #00ff00), + ;; NOT the fixed color #abcdef. + (ghostel--write-input term "\e[1;31mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#00ff00" (plist-get face :foreground)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-leaves-bright-palette-alone () + "Test that bold on palette 8-15 is not re-mapped (no overflow into 16-23)." + (let ((buf (generate-new-buffer " *ghostel-test-bold-bright-palette*")) + (ghostel-bold-color 'bright)) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + ;; SGR 91 selects palette 9 directly; bold must not shift it further. + (ghostel--write-input term "\e[1;91mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#00ff00" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + (defun ghostel-test-run () "Run all ghostel tests." (ert-run-tests-batch-and-exit "^ghostel-test-"))