From 32f7cd511216442c3efc44bc79ee61ecbc59efde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Sun, 17 May 2026 20:32:47 +0200 Subject: [PATCH 1/4] Refactor adjustGlyphs a bit --- src/Renderer.zig | 103 ++++++++++++++++++++++++++--------------------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/src/Renderer.zig b/src/Renderer.zig index fd730d10..ffdeab1b 100644 --- a/src/Renderer.zig +++ b/src/Renderer.zig @@ -36,6 +36,7 @@ pending_resize: ?ViewportSize = null, /// Reusable instance of RowContent to reduce allocations row: RowContent = .{}, +/// Cached information about font metrics, used for glyph scaling font_info: ?FontInfo = null, /// Bold text coloring configuration. @@ -491,9 +492,9 @@ pub const RowContent = struct { const CellInfo = struct { byte_start: usize, - byte_len: usize, + byte_end: usize, char_start: usize, - char_len: usize, + char_end: usize, wide: bool, }; @@ -610,9 +611,9 @@ pub const RowContent = struct { if (self.graphemes.items.len > 1 or self.graphemes.items[0] >= adjustment_threshold) { try self.adjust_cells.append(RowContent.allocator, .{ .byte_start = byte_start, - .byte_len = self.text.items.len - byte_start, + .byte_end = self.text.items.len, .char_start = char_start, - .char_len = self.char_len - char_start, + .char_end = self.char_len, .wide = wide == gt.c.GHOSTTY_CELL_WIDE_WIDE, }); } @@ -690,54 +691,64 @@ fn readRowHints(row: gt.c.GhosttyRow) !RowHints { fn adjustGlyphs(self: *Self, env: emacs.Env, row_start: i64) void { if (self.row.adjust_cells.items.len == 0) return; if (self.font_info == null) return; - const default_font_info = self.font_info.?; - - const s = emacs.sym; - const window = env.f("selected-window", .{}); if (env.isNil(window)) return; - for (self.row.adjust_cells.items) |cell| { - const start_val = env.makeInteger(row_start + @as(i64, @intCast(cell.char_start))); - const end_val = env.makeInteger(row_start + @as(i64, @intCast(cell.char_start + cell.char_len))); - const font = env.f("font-at", .{ start_val, window }); - // TODO: Maybe we should replace the cell with something else if there - // is no font. Today, it will just show the missing char glyph, - // which will push the line size bigger. This is rare, though. - // Most chars are covered by SOME font on the system. - if (env.isNil(font)) continue; - - const font_info = env.f("query-font", .{font}); - const ascent = env.extractInteger(env.vecGet(font_info, 4)); - const descent = env.extractInteger(env.vecGet(font_info, 5)); - const height = ascent + descent; - - const glyphs = env.f("font-get-glyphs", .{ font, start_val, end_val }); - if (env.vecSize(glyphs) == 0) continue; - - // Each element is a vector containing information of a glyph in this format: - // [FROM-IDX TO-IDX C CODE WIDTH LBEARING RBEARING ASCENT DESCENT ADJUSTMENT] - const glyph = env.vecGet(glyphs, 0); - const width = env.extractInteger(env.vecGet(glyph, 4)); - const num_cells: i64 = if (cell.wide) 2 else 1; - - const max_width = default_font_info.width * num_cells; - - // Skip adjustments if size already matches perfectly - if (max_width == width and default_font_info.height == height) continue; - - // We add a fudge factor of +1 to the denominator to ensure fit - const scale_width = @as(f64, @floatFromInt(max_width)) / @as(f64, @floatFromInt(width + 1)); - const scale_height = @as(f64, @floatFromInt(default_font_info.height)) / @as(f64, @floatFromInt(height + 1)); - const scale = @min(scale_width, scale_height); - - const min_width_spec = env.list(.{ s.@"min-width", env.list(.{num_cells}) }); - const scale_spec = env.list(.{ s.height, scale }); - const display_spec = env.list(.{ min_width_spec, scale_spec }); - _ = env.f("put-text-property", .{ start_val, end_val, s.display, display_spec }); + for (self.row.adjust_cells.items) |*cell| { + self.adjustGlyph(env, window, row_start, cell); } } +fn adjustGlyph( + self: *Self, + env: emacs.Env, + window: emacs.Value, + row_start: i64, + cell: *const RowContent.CellInfo, +) void { + const default_font_info = self.font_info.?; + + const s = emacs.sym; + + const start_val = env.makeInteger(row_start + @as(i64, @intCast(cell.char_start))); + const end_val = env.makeInteger(row_start + @as(i64, @intCast(cell.char_end))); + const font = env.f("font-at", .{ start_val, window }); + // TODO: Maybe we should replace the cell with something else if there + // is no font. Today, it will just show the missing char glyph, + // which will push the line size bigger. This is rare, though. + // Most chars are covered by SOME font on the system. + if (env.isNil(font)) return; + + const font_info = env.f("query-font", .{font}); + const ascent = env.extractInteger(env.vecGet(font_info, 4)); + const descent = env.extractInteger(env.vecGet(font_info, 5)); + const height = ascent + descent; + + const glyphs = env.f("font-get-glyphs", .{ font, start_val, end_val }); + if (env.vecSize(glyphs) == 0) return; + + // Each element is a vector containing information of a glyph in this format: + // [FROM-IDX TO-IDX C CODE WIDTH LBEARING RBEARING ASCENT DESCENT ADJUSTMENT] + const glyph = env.vecGet(glyphs, 0); + const width = env.extractInteger(env.vecGet(glyph, 4)); + const num_cells: i64 = if (cell.wide) 2 else 1; + + const max_width = default_font_info.width * num_cells; + + // Skip adjustments if size already matches perfectly + if (max_width == width and default_font_info.height == height) return; + + // We add a fudge factor of +1 to the denominator to ensure fit + const scale_width = @as(f64, @floatFromInt(max_width)) / @as(f64, @floatFromInt(width + 1)); + const scale_height = @as(f64, @floatFromInt(default_font_info.height)) / @as(f64, @floatFromInt(height + 1)); + const scale = @min(scale_width, scale_height); + + const min_width_spec = env.list(.{ s.@"min-width", env.list(.{num_cells}) }); + const scale_spec = env.list(.{ s.height, scale }); + const display_spec = env.list(.{ min_width_spec, scale_spec }); + _ = env.f("put-text-property", .{ start_val, end_val, s.display, display_spec }); +} + /// Insert row text and apply property runs. fn insertRow( self: *Self, From 4871fca1b886c0adb468f8193d13e414b2b1f218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Mon, 18 May 2026 08:55:05 +0200 Subject: [PATCH 2/4] Modify signature of putTextProperty --- src/Renderer.zig | 14 +++++++------- src/emacs.zig | 10 ++++++++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Renderer.zig b/src/Renderer.zig index ffdeab1b..22ce5243 100644 --- a/src/Renderer.zig +++ b/src/Renderer.zig @@ -427,21 +427,21 @@ fn applyProps(env: emacs.Env, start: i64, end: i64, props: CellProps, default_co if (face_props.len > 0) { const face = env.funcall(s.list, face_props.items()); - env.putTextProperty(start_val, end_val, s.face, face); + env.putTextProperty(start_val, end_val, "face", face); } if (props.hyperlink) { - env.putTextProperty(start_val, end_val, s.@"help-echo", s.@"ghostel--native-link-help-echo"); - env.putTextProperty(start_val, end_val, s.@"mouse-face", s.highlight); - env.putTextProperty(start_val, end_val, s.keymap, env.symbolValue("ghostel-link-map")); + env.putTextProperty(start_val, end_val, "help-echo", s.@"ghostel--native-link-help-echo"); + env.putTextProperty(start_val, end_val, "mouse-face", s.highlight); + env.putTextProperty(start_val, end_val, "keymap", env.symbolValue("ghostel-link-map")); } if (props.prompt) { - env.putTextProperty(start_val, end_val, emacs.sym.@"ghostel-prompt", env.t()); + env.putTextProperty(start_val, end_val, "ghostel-prompt", env.t()); } if (props.input) { - env.putTextProperty(start_val, end_val, emacs.sym.@"ghostel-input", env.t()); + env.putTextProperty(start_val, end_val, "ghostel-input", env.t()); } } @@ -782,7 +782,7 @@ fn insertRow( // Mark newlines from soft-wrapped rows so copy mode can filter them const point = env.point(); const nl_pos = env.makeInteger(env.extractInteger(point) - 1); - env.putTextProperty(nl_pos, point, emacs.sym.@"ghostel-wrap", env.t()); + env.putTextProperty(nl_pos, point, "ghostel-wrap", env.t()); } } diff --git a/src/emacs.zig b/src/emacs.zig index b7a6261d..5a9537cf 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -304,8 +304,14 @@ pub const Env = struct { _ = self.f("delete-region", .{ start, end }); } - pub fn putTextProperty(self: Env, start: Value, end: Value, prop: Value, value: Value) void { - _ = self.f("put-text-property", .{ start, end, prop, value }); + pub fn putTextProperty( + self: Env, + start: anytype, + end: anytype, + comptime prop: []const u8, + value: anytype, + ) void { + _ = self.f("put-text-property", .{ start, end, @field(sym, prop), value }); } /// Create a unibyte string (for binary data like PNG images). From d28f711b360dfbe7852602d4d947ce4a5d58f3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Mon, 18 May 2026 08:55:28 +0200 Subject: [PATCH 3/4] Promote characters to wide if they have space available after --- src/Renderer.zig | 70 +++++++++++++++++++++++++++++++++++------------- src/emacs.zig | 3 +++ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/Renderer.zig b/src/Renderer.zig index 22ce5243..d42e582c 100644 --- a/src/Renderer.zig +++ b/src/Renderer.zig @@ -491,10 +491,11 @@ pub const RowContent = struct { }; const CellInfo = struct { - byte_start: usize, - byte_end: usize, - char_start: usize, - char_end: usize, + col: i64, + byte_start: i64, + byte_end: i64, + char_start: i64, + char_end: i64, wide: bool, }; @@ -543,8 +544,9 @@ pub const RowContent = struct { const row_hints = try readRowHints(raw_row); var current_prop_key: ?CellPropKey = null; + var col: i64 = 0; try gt.rs_row.read(row, gt.RS_ROW_DATA_CELLS, row_cells); - while (gt.rs_row_cells_next(row_cells.*)) { + while (gt.rs_row_cells_next(row_cells.*)) : (col += 1) { const raw_cell = try gt.rs_row_cells.get( gt.c.GhosttyCell, row_cells.*, @@ -610,10 +612,11 @@ pub const RowContent = struct { // to fit into the monospace grid. if (self.graphemes.items.len > 1 or self.graphemes.items[0] >= adjustment_threshold) { try self.adjust_cells.append(RowContent.allocator, .{ - .byte_start = byte_start, - .byte_end = self.text.items.len, - .char_start = char_start, - .char_end = self.char_len, + .col = col, + .byte_start = @intCast(byte_start), + .byte_end = @intCast(self.text.items.len), + .char_start = @intCast(char_start), + .char_end = @intCast(self.char_len), .wide = wide == gt.c.GHOSTTY_CELL_WIDE_WIDE, }); } @@ -688,14 +691,14 @@ fn readRowHints(row: gt.c.GhosttyRow) !RowHints { }; } -fn adjustGlyphs(self: *Self, env: emacs.Env, row_start: i64) void { +fn adjustGlyphs(self: *Self, env: emacs.Env, row_start: i64, row_end: i64) void { if (self.row.adjust_cells.items.len == 0) return; if (self.font_info == null) return; const window = env.f("selected-window", .{}); if (env.isNil(window)) return; for (self.row.adjust_cells.items) |*cell| { - self.adjustGlyph(env, window, row_start, cell); + self.adjustGlyph(env, window, row_start, row_end, cell); } } @@ -704,6 +707,7 @@ fn adjustGlyph( env: emacs.Env, window: emacs.Value, row_start: i64, + row_end: i64, cell: *const RowContent.CellInfo, ) void { const default_font_info = self.font_info.?; @@ -731,19 +735,48 @@ fn adjustGlyph( // [FROM-IDX TO-IDX C CODE WIDTH LBEARING RBEARING ASCENT DESCENT ADJUSTMENT] const glyph = env.vecGet(glyphs, 0); const width = env.extractInteger(env.vecGet(glyph, 4)); - const num_cells: i64 = if (cell.wide) 2 else 1; - - const max_width = default_font_info.width * num_cells; + var char_width: i64 = if (cell.wide) 2 else 1; + var slot_width = default_font_info.width * char_width; // Skip adjustments if size already matches perfectly - if (max_width == width and default_font_info.height == height) return; + if (width == slot_width and height == default_font_info.height) return; + + // Let's check if we can claim some space after the glyph to be able to render + // it larger than the cell size while still maintaining alignment. + const pre_char_width = char_width; + while (cell.col + char_width < self.size.cols) : ({ + char_width += 1; + slot_width = default_font_info.width * char_width; + }) { + const cell_aspect = @as(f64, @floatFromInt(slot_width)) / @as(f64, @floatFromInt(default_font_info.height)); + const glyph_aspect = @as(f64, @floatFromInt(width)) / @as(f64, @floatFromInt(height)); + // If the aspect of the glyph is narrower than that of the cell, we're done + if (glyph_aspect < cell_aspect) break; + + const claim_pos = row_start + cell.char_end + (char_width - pre_char_width); + // Lines are right-trimmed of trailing spaces, so positions at and past + // the newline represent empty space we can freely claim. + if (claim_pos >= row_end - 1) continue; + + const c = env.extractInteger(env.f("char-after", .{claim_pos})); + if (c == ' ') { + env.putTextProperty( + claim_pos, + claim_pos + 1, + "display", + env.cons(s.space, env.list(.{ s.@":width", 0 })), + ); + } else { + break; + } + } // We add a fudge factor of +1 to the denominator to ensure fit - const scale_width = @as(f64, @floatFromInt(max_width)) / @as(f64, @floatFromInt(width + 1)); + const scale_width = @as(f64, @floatFromInt(slot_width)) / @as(f64, @floatFromInt(width + 1)); const scale_height = @as(f64, @floatFromInt(default_font_info.height)) / @as(f64, @floatFromInt(height + 1)); const scale = @min(scale_width, scale_height); - const min_width_spec = env.list(.{ s.@"min-width", env.list(.{num_cells}) }); + const min_width_spec = env.list(.{ s.@"min-width", env.list(.{char_width}) }); const scale_spec = env.list(.{ s.height, scale }); const display_spec = env.list(.{ min_width_spec, scale_spec }); _ = env.f("put-text-property", .{ start_val, end_val, s.display, display_spec }); @@ -765,6 +798,7 @@ fn insertRow( const row_start = env.extractInteger(env.point()); env.insert(self.row.text.items); + const row_end = env.extractInteger(env.point()); for (self.row.runs.items) |*run| { if (run.end_char <= run.start_char) continue; @@ -776,7 +810,7 @@ fn insertRow( } } - self.adjustGlyphs(env, row_start); + self.adjustGlyphs(env, row_start, row_end); if (try self.isRowWrapped()) { // Mark newlines from soft-wrapped rows so copy mode can filter them diff --git a/src/emacs.zig b/src/emacs.zig index 5a9537cf..2e4b527a 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -396,8 +396,10 @@ const interned_symbols = [_][:0]const u8{ ":style", ":underline", ":weight", + ":width", "bold", "bright", + "char-after", "char-before", "cons", "dash", @@ -472,6 +474,7 @@ const interned_symbols = [_][:0]const u8{ "selected-window", "set", "set-marker", + "space", "symbol-value", "t", "wave", From 80b97b0bb46c7faa26eec45a45b3e708cab5e052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Mon, 18 May 2026 15:27:52 +0200 Subject: [PATCH 4/4] Add adjustGlyph tests --- test/ghostel-test.el | 492 +++++++++++++++++++++++++++++++++---------- 1 file changed, 385 insertions(+), 107 deletions(-) diff --git a/test/ghostel-test.el b/test/ghostel-test.el index e400cb5e..5686c876 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -15341,6 +15341,391 @@ slip past the unit tests." (should (= (nth 7 args) 1))))) ; pixel-h = 1 (kill-buffer buf)))) +;;; Bold color palette + +(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)))) + +;;; glyph adjustment + +;; These tests validate the `adjustGlyph' logic in src/Renderer.zig. +;; Because `adjustGlyph' is called during the native redraw path, the +;; tests create a terminal, write a character, mock Emacs' font-query +;; functions, call `ghostel--redraw', and inspect the resulting +;; `display' text properties. +;; +;; Glyph adjustment only fires when: +;; 1. `font_info' is non-nil (a default font was detected). +;; 2. The cell is a grapheme cluster OR its codepoint is above the +;; `adjustment_threshold' (the coverage probe value). +;; 3. `selected-window' returns non-nil. +;; +;; In batch mode there is no display, so we mock: +;; - `face-attribute' → returns a synthetic default-font object +;; - `fontp' → returns t for our synthetic fonts +;; - `query-font' → returns a fake font-info vector +;; - `font-at' → returns a synthetic glyph-font object +;; - `font-get-glyphs' → returns a fake glyph-metrics vector +;; - `selected-window' → returns a dummy window value +;; - `char-after' → returns controlled values for claim tests + +(defun ghostel-test--mock-font-p (font) + "Return non-nil if FONT is a mock created by `ghostel-test--make-font'." + (and (consp font) (eq (car font) 'mock-font))) + +(defun ghostel-test--make-font (metrics &optional glyphs) + "Make a mock font carrying METRICS and optionally GLYPHS. +METRICS is a `query-font'-style vector. GLYPHS is a vector of glyph +info vectors as returned by `font-get-glyphs'." + (list 'mock-font :metrics metrics :glyphs glyphs)) + +(defmacro ghostel-test--with-glyph-mocks (specs &rest body) + "Bind font functions to mock implementations described by SPECS, then eval BODY. +SPECS is a plist with these keys: + :default-font -- mock font (from `ghostel-test--make-font') returned by + `face-attribute' for the default face; its :metrics is + used by the `query-font' mock. + :glyph-font -- mock font returned by `font-at'; its :metrics and :glyphs + are used by `query-font' and `font-get-glyphs'." + (declare (indent 1)) + `(let* ((--orig-face-attribute (symbol-function 'face-attribute)) + (--orig-fontp (symbol-function 'fontp)) + (--orig-font-at (symbol-function 'font-at)) + (--orig-query-font (symbol-function 'query-font)) + (--orig-font-get-glyphs (symbol-function 'font-get-glyphs))) + (cl-letf (,@(when-let ((df (plist-get specs :default-font))) + `(((symbol-function 'face-attribute) + (lambda (face attr &rest args) + (if (and (eq face 'default) (eq attr :font)) + ,df + (apply --orig-face-attribute face attr args)))) + ((symbol-function 'fontp) + (lambda (font &rest args) + (or (ghostel-test--mock-font-p font) + (apply --orig-fontp font args)))) + ((symbol-function 'font-has-char-p) + (lambda (_font _char) nil)) + ((symbol-function 'query-font) + (lambda (font) + (or (and (ghostel-test--mock-font-p font) + (plist-get (cdr font) :metrics)) + (funcall --orig-query-font font)))))) + ,@(when-let ((gf (plist-get specs :glyph-font))) + `(((symbol-function 'font-at) + (lambda (pos &optional window) + (if (>= pos (point-min)) + ,gf + (funcall --orig-font-at pos window)))) + ((symbol-function 'font-get-glyphs) + (lambda (font from to) + (or (and (ghostel-test--mock-font-p font) + (plist-get (cdr font) :glyphs)) + (funcall --orig-font-get-glyphs font from to))))))) + ,@body))) + +;; Default cell: 10px wide x 20px tall (ascent 10 + descent 10) +(defconst ghostel-test--default-font-info + ["MockDefault" "mock.ttf" 12 120 10 10 10 10 0]) + +(ert-deftest ghostel-test-glyph-adjust-single-width-small () + "An oversized single-width glyph gets a scale < 1.0 to fit the cell." + (let ((buf (generate-new-buffer " *ghostel-test-glyph-1*"))) + (unwind-protect + (save-window-excursion + (with-selected-window (display-buffer buf) + (ghostel-mode) + (let* ((term (ghostel--new 5 80 1000)) + (ghostel--term term) + (ghostel--term-rows 5) + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info)) + ;; Glyph: 12px wide x 25px tall (larger than 10x20 cell) + (glyph-font (ghostel-test--make-font + ["MockGlyph" "mock.ttf" 12 120 12 13 12 12 0] + [[0 1 ?\u0100 0 12 0 0 12 13 0]]))) + ;; Write a character above the coverage threshold. + (ghostel--write-input term "\u0100") + (ghostel-test--with-glyph-mocks + (:default-font df + :glyph-font glyph-font) + (ghostel--redraw term t) + (goto-char (point-min)) + (let ((disp (get-text-property (point) 'display))) + (should disp) + (let ((scale (cadr (assq 'height disp)))) + (should scale) + (should (< scale 1.0)))))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-glyph-adjust-double-width-small () + "A double-width glyph with narrower aspect than its slot gets min-width of 2." + (let ((buf (generate-new-buffer " *ghostel-test-glyph-2*"))) + (unwind-protect + (save-window-excursion + (with-selected-window (display-buffer buf) + (ghostel-mode) + (let* ((term (ghostel--new 5 80 1000)) + (ghostel--term term) + (ghostel--term-rows 5) + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info)) + ;; Glyph: 18px wide x 20px tall; narrower aspect breaks claim loop + (glyph-font (ghostel-test--make-font + ["MockGlyph" "mock.ttf" 12 120 10 10 18 18 0] + [[0 1 ?あ 0 18 0 0 10 10 0]]))) + ;; Write a CJK character (double-width). + (ghostel--write-input term "あ") + (ghostel-test--with-glyph-mocks + (:default-font df + :glyph-font glyph-font) + (ghostel--redraw term t) + (goto-char (point-min)) + (let ((disp (get-text-property (point) 'display))) + (should disp) + (let ((min-w (cadr (assq 'min-width disp)))) + (should (equal min-w '(2))))))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-glyph-adjust-identical-metrics () + "A glyph whose pixel size matches the cell perfectly is not adjusted." + (let ((buf (generate-new-buffer " *ghostel-test-glyph-3*"))) + (unwind-protect + (save-window-excursion + (with-selected-window (display-buffer buf) + (ghostel-mode) + (let* ((term (ghostel--new 5 80 1000)) + (ghostel--term term) + (ghostel--term-rows 5) + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info)) + ;; Glyph: exactly 10px wide x 20px tall + (glyph-font (ghostel-test--make-font + ["MockGlyph" "mock.ttf" 12 120 10 10 10 10 0] + [[0 1 ?\u0100 0 10 0 0 10 10 0]]))) + (ghostel--write-input term "\u0100") + (ghostel-test--with-glyph-mocks + (:default-font df + :glyph-font glyph-font) + (ghostel--redraw term t) + (goto-char (point-min)) + (should-not (get-text-property (point) 'display)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-glyph-adjust-claims-following-space () + "A wide glyph claims an adjacent space by giving it :width 0." + (let ((buf (generate-new-buffer " *ghostel-test-glyph-4*"))) + (unwind-protect + (save-window-excursion + (with-selected-window (display-buffer buf) + (ghostel-mode) + (let* ((term (ghostel--new 5 80 1000)) + (ghostel--term term) + (ghostel--term-rows 5) + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info)) + ;; Glyph: 12px wide x 20px tall \u2014 wider than 10px cell but aspect + ;; ratio 0.6 < 1.0, so one claimed space (2 cells) is sufficient. + (glyph-font (ghostel-test--make-font + ["MockGlyph" "mock.ttf" 12 120 10 10 12 12 0] + [[0 1 ?\u0100 0 12 0 0 10 10 0]]))) + ;; Write: [wide-glyph][space] + (ghostel--write-input term "\u0100 ") + (ghostel-test--with-glyph-mocks + (:default-font df + :glyph-font glyph-font) + (ghostel--redraw term t) + (goto-char (point-min)) + (let ((glyph-disp (get-text-property (point) 'display))) + (should (equal (cadr (assq 'min-width glyph-disp)) '(2)))) + (forward-char 1) + (should (equal (get-text-property (point) 'display) '(space :width 0))))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-glyph-adjust-claims-past-eol () + "A wide glyph claims trailing empty space past the written text." + (let ((buf (generate-new-buffer " *ghostel-test-glyph-5*"))) + (unwind-protect + (save-window-excursion + (with-selected-window (display-buffer buf) + (ghostel-mode) + (let* ((term (ghostel--new 5 80 1000)) + (ghostel--term term) + (ghostel--term-rows 5) + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info)) + ;; Glyph: 25px wide x 10px tall (needs >2 cells) + (glyph-font (ghostel-test--make-font + ["MockGlyph" "mock.ttf" 12 120 5 5 25 25 0] + [[0 1 ?\u0100 0 25 0 0 5 5 0]]))) + (ghostel--write-input term "\u0100") + (ghostel-test--with-glyph-mocks + (:default-font df + :glyph-font glyph-font) + (ghostel--redraw term t) + (goto-char (point-min)) + (let ((disp (get-text-property (point) 'display))) + (should disp) + (let ((min-w (cadr (assq 'min-width disp)))) + (should (>= (car min-w) 2)))))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-glyph-adjust-last-column-no-claim () + "When the glyph is at the last column, claiming loop never runs." + (let ((buf (generate-new-buffer " *ghostel-test-glyph-6*"))) + (unwind-protect + (save-window-excursion + (with-selected-window (display-buffer buf) + (ghostel-mode) + (let* ((term (ghostel--new 5 10 1000)) ;; only 10 columns! + (ghostel--term term) + (ghostel--term-rows 5) + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info)) + (glyph-font (ghostel-test--make-font + ["MockGlyph" "mock.ttf" 12 120 5 5 15 15 0] + [[0 1 ?\u0100 0 15 0 0 5 5 0]]))) + (ghostel--write-input term "\e[1;10H") + (ghostel--write-input term "\u0100") + (ghostel-test--with-glyph-mocks + (:default-font df + :glyph-font glyph-font) + (ghostel--redraw term t) + (goto-char (point-min)) + (end-of-line) + ;; cell.col + char_width < cols is 9 + 1 < 10 = false + (let ((disp (get-text-property (1- (point)) 'display))) + (should disp) + (let ((min-w (cadr (assq 'min-width disp)))) + (should (equal min-w '(1))))))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-glyph-adjust-covered-by-main-font () + "A codepoint below the coverage threshold is not registered in adjust_cells." + (let ((buf (generate-new-buffer " *ghostel-test-glyph-7*"))) + (unwind-protect + (save-window-excursion + (with-selected-window (display-buffer buf) + (ghostel-mode) + (let* ((term (ghostel--new 5 80 1000)) + (ghostel--term term) + (ghostel--term-rows 5) + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info))) + (ghostel--write-input term "a") + ;; No :glyph-font — if the code incorrectly tried to adjust this + ;; glyph, `font-at' would be unbound and the test would fail. + (ghostel-test--with-glyph-mocks + (:default-font df) + (ghostel--redraw term t) + (goto-char (point-min)) + (should (equal (char-after) ?a)) + (should-not (get-text-property (point) 'display)))))) + (kill-buffer buf)))) + (defconst ghostel-test--elisp-tests '(ghostel-test-focus-window-selection @@ -15755,113 +16140,6 @@ 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-"))