From a67fec962e0e380d6d0f617b8aa482dbe6990da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Wed, 20 May 2026 22:09:38 +0200 Subject: [PATCH] Add ghostel-glyph-scale-floor: configurable minimum glyph scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #298. Adds a buffer-local defcustom `ghostel-glyph-scale-floor` (number, 0.0–1.0, default 0.0) that clamps the computed glyph scale from below. At 0.0 the existing strict-grid behavior is unchanged; at 1.0 CJK and other fallback glyphs render at natural font size, at the cost of slightly taller rows when fallback metrics exceed the primary cell. Being buffer-local means different ghostel buffers can use different settings independently. Implementation: - One @max in adjustGlyph (Renderer.zig) after the existing @min - FontInfo carries glyph_scale_floor so a change triggers a full viewport invalidation without an extra symbol-value call per cell - emacs.zig gains asFloat(val, default) which uses (numberp)+(float) to coerce integers to f64 and avoids a wrong-type-argument signal corrupting the env's non-local exit state; std.math.clamp enforces the [0.0, 1.0] range on the Zig side - updateFontInfo fast-path keeps env.eq as the outer gate so the nil-font case (no font before, no font now) correctly returns false - ERT test: ghostel-test-glyph-scale-floor-clamps-scale verifies the floor prevents shrinking below 1.0 --- lisp/ghostel.el | 139 ++++++++++++++++--------------- src/Renderer.zig | 42 ++++++++-- src/emacs.zig | 11 +++ test/ghostel-glyph-kitty-test.el | 35 ++++++++ 4 files changed, 153 insertions(+), 74 deletions(-) diff --git a/lisp/ghostel.el b/lisp/ghostel.el index 00d7caa5..f836f544 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -278,6 +278,15 @@ or backing scale factor." :type '(choice (const :tag "Auto-detect from display DPI" auto) (number :tag "Explicit ratio"))) +(defcustom ghostel-glyph-scale-floor 0.0 + "Minimum scale for glyphs whose font metrics don't fit the cell. +0.0 (default) preserves strict grid alignment. 1.0 disables +shrinking entirely so CJK and other fallback glyphs render at +natural size, potentially making rows slightly taller and cells slightly wider." + :type '(float 0.0 1.0) + :local t + :group 'ghostel) + (defcustom ghostel-kitty-graphics-storage-limit (* 320 1024 1024) ; 320 MiB "Kitty graphics image storage cap, in bytes, per terminal. @@ -4185,8 +4194,8 @@ mis-rendering is visible as an error instead of silent." (list src-x src-y src-w src-h pixel-w pixel-h)))) (defun ghostel--kitty-apply-row-slice (row cw ch img - vp-col-clamped visible-cols - slice-x slice-w) + vp-col-clamped visible-cols + slice-x slice-w) "Apply one row of the sliced image at point. ROW is the slice index (0-based) into the image's grid of cells. CW / CH are the cell pixel dimensions. IMG is the Emacs image object. @@ -5755,69 +5764,69 @@ Returns nil on failure." (tinfo (and (ghostel--ssh-install-enabled-p) (ghostel--push-remote-terminfo remote-prefix))) (base (pcase shell-type - ;; Bash: --rcfile replaces normal rc loading, so we source - ;; startup files explicitly before the integration. - ('bash - (let* ((temp (make-temp-file - (concat remote-prefix "ghostel-") nil ".bash")) - (path (file-remote-p temp 'localname))) - (ghostel--write-remote-file temp - (concat - "# Source standard startup files\n" - "if shopt -q login_shell 2>/dev/null; then\n" - " [ -r /etc/profile ] && . /etc/profile\n" - " for __gf in ~/.bash_profile ~/.bash_login ~/.profile; do\n" - " [ -r \"$__gf\" ] && { . \"$__gf\"; break; }; done\n" - " unset __gf\n" - "else\n" - " for __gf in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do\n" - " [ -r \"$__gf\" ] && { . \"$__gf\"; break; }; done\n" - " unset __gf\n" - " [ -r ~/.bashrc ] && . ~/.bashrc\n" - "fi\n" - integration)) - (list :env nil :args (list "--rcfile" path) - :stty ghostel--default-stty :temp-files (list temp)))) - ;; Zsh: ZDOTDIR replaces .zshenv search, so we restore it, - ;; source the user's .zshenv, then load integration. - ('zsh - (let* ((temp-dir (make-temp-file - (concat remote-prefix "ghostel-") t)) - (temp-zshenv (concat (file-name-as-directory temp-dir) - ".zshenv")) - (remote-dir (file-remote-p temp-dir 'localname))) - (ghostel--write-remote-file temp-zshenv - (concat - "if [[ -n \"${GHOSTEL_ZSH_ZDOTDIR+X}\" ]]; then\n" - " 'builtin' 'export' ZDOTDIR=\"$GHOSTEL_ZSH_ZDOTDIR\"\n" - " 'builtin' 'unset' 'GHOSTEL_ZSH_ZDOTDIR'\n" - "else\n" - " 'builtin' 'unset' 'ZDOTDIR'\n" - "fi\n" - "{\n" - " 'builtin' 'typeset' _ghostel_file=" - "\"${ZDOTDIR-$HOME}/.zshenv\"\n" - " [[ ! -r \"$_ghostel_file\" ]] || " - "'builtin' 'source' '--' \"$_ghostel_file\"\n" - "} always {\n" - " if [[ -o 'interactive' ]]; then\n" - integration "\n" - " fi\n" - " 'builtin' 'unset' '_ghostel_file'\n" - "}\n")) - (list :env (list (format "ZDOTDIR=%s" remote-dir)) - :args nil :stty ghostel--default-stty - :temp-dirs (list temp-dir)))) - ;; Fish: -C runs after config, so just source the script. - ('fish - (let* ((temp (make-temp-file - (concat remote-prefix "ghostel-") nil ".fish")) - (path (file-remote-p temp 'localname))) - (ghostel--write-remote-file temp integration) - (list :env nil - :args (list "-C" (format "source %s" - (shell-quote-argument path))) - :stty ghostel--default-stty :temp-files (list temp))))))) + ;; Bash: --rcfile replaces normal rc loading, so we source + ;; startup files explicitly before the integration. + ('bash + (let* ((temp (make-temp-file + (concat remote-prefix "ghostel-") nil ".bash")) + (path (file-remote-p temp 'localname))) + (ghostel--write-remote-file temp + (concat + "# Source standard startup files\n" + "if shopt -q login_shell 2>/dev/null; then\n" + " [ -r /etc/profile ] && . /etc/profile\n" + " for __gf in ~/.bash_profile ~/.bash_login ~/.profile; do\n" + " [ -r \"$__gf\" ] && { . \"$__gf\"; break; }; done\n" + " unset __gf\n" + "else\n" + " for __gf in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do\n" + " [ -r \"$__gf\" ] && { . \"$__gf\"; break; }; done\n" + " unset __gf\n" + " [ -r ~/.bashrc ] && . ~/.bashrc\n" + "fi\n" + integration)) + (list :env nil :args (list "--rcfile" path) + :stty ghostel--default-stty :temp-files (list temp)))) + ;; Zsh: ZDOTDIR replaces .zshenv search, so we restore it, + ;; source the user's .zshenv, then load integration. + ('zsh + (let* ((temp-dir (make-temp-file + (concat remote-prefix "ghostel-") t)) + (temp-zshenv (concat (file-name-as-directory temp-dir) + ".zshenv")) + (remote-dir (file-remote-p temp-dir 'localname))) + (ghostel--write-remote-file temp-zshenv + (concat + "if [[ -n \"${GHOSTEL_ZSH_ZDOTDIR+X}\" ]]; then\n" + " 'builtin' 'export' ZDOTDIR=\"$GHOSTEL_ZSH_ZDOTDIR\"\n" + " 'builtin' 'unset' 'GHOSTEL_ZSH_ZDOTDIR'\n" + "else\n" + " 'builtin' 'unset' 'ZDOTDIR'\n" + "fi\n" + "{\n" + " 'builtin' 'typeset' _ghostel_file=" + "\"${ZDOTDIR-$HOME}/.zshenv\"\n" + " [[ ! -r \"$_ghostel_file\" ]] || " + "'builtin' 'source' '--' \"$_ghostel_file\"\n" + "} always {\n" + " if [[ -o 'interactive' ]]; then\n" + integration "\n" + " fi\n" + " 'builtin' 'unset' '_ghostel_file'\n" + "}\n")) + (list :env (list (format "ZDOTDIR=%s" remote-dir)) + :args nil :stty ghostel--default-stty + :temp-dirs (list temp-dir)))) + ;; Fish: -C runs after config, so just source the script. + ('fish + (let* ((temp (make-temp-file + (concat remote-prefix "ghostel-") nil ".fish")) + (path (file-remote-p temp 'localname))) + (ghostel--write-remote-file temp integration) + (list :env nil + :args (list "-C" (format "source %s" + (shell-quote-argument path))) + :stty ghostel--default-stty :temp-files (list temp))))))) (if tinfo (ghostel--merge-integration-plists base tinfo) base)) diff --git a/src/Renderer.zig b/src/Renderer.zig index 627168e7..0c987f52 100644 --- a/src/Renderer.zig +++ b/src/Renderer.zig @@ -30,7 +30,8 @@ pending_resize: ?ViewportSize = null, /// Reusable instance of RowContent to reduce allocations row: RowContent = .{}, -/// Cached information about font metrics, used for glyph scaling +/// Cached font metrics and rendering parameters that affect glyph layout. +/// When any field changes between redraws the viewport is fully invalidated. font_info: ?FontInfo = null, /// Bold text coloring configuration. @@ -40,6 +41,7 @@ const FontInfo = struct { width: i64, height: i64, coverage: u32, + glyph_scale_floor: f64, }; pub fn init(term: *gt.Terminal) !Self { @@ -106,8 +108,9 @@ pub fn redraw(self: *Self, env: emacs.Env, term: *Terminal, force_full_arg: bool const scrollbar = term.terminal.screens.active.pages.scrollbar(); - // If the font changed, the font metrics are no longer valid, so we rebuild. - const font_changed = self.updateFontInfo(env); + // If the font metrics or related parameters changed, the cached metrics + // are no longer valid, so we rebuild. + const font_info_changed = self.updateFontInfo(env); // We always reset scrollback if the number of columns changed const cols_changed = if (self.pending_resize) |rz| rz.cols != self.size.cols else false; @@ -123,14 +126,17 @@ pub fn redraw(self: *Self, env: emacs.Env, term: *Terminal, force_full_arg: bool // cap and do not know how much we missed. const scrollbar_hit_cap = had_scrollback and scrollbar.offset == 0; - var force_full = false; - if (force_full_arg or font_changed or cols_changed or scrollbar_reset or scrollbar_hit_cap) { + const force_full = + force_full_arg or + font_info_changed or + cols_changed or + scrollbar_reset or + scrollbar_hit_cap; + if (force_full) { env.eraseBuffer(); // Commit any pending resize since we're doing a rebuild anyway. try self.commitResize(&term.terminal); - self.rows_in_buffer = 0; - force_full = true; } // Unpark the viewport. When we have scrollback the viewport is sitting at @@ -194,10 +200,26 @@ pub fn redraw(self: *Self, env: emacs.Env, term: *Terminal, force_full_arg: bool term.terminal.scrollViewport(.{ .delta = -1 }); } +/// Read the default font and rendering parameters from Emacs, compare +/// against the cached values, and signal whether a full invalidation is +/// required. fn updateFontInfo(self: *Self, env: emacs.Env) bool { const new_font = getDefaultFont(env); const current_font = env.symbolValue("ghostel--rendered-font"); - if (env.eq(new_font, current_font)) return false; + + const raw_floor = env.symbolValue("ghostel-glyph-scale-floor"); + const floor = std.math.clamp(env.asFloat(raw_floor, 0.0), 0.0, 1.0); + + // Fast path: nothing changed since last redraw. + if (env.eq(new_font, current_font)) { + if (self.font_info) |cached| { + const old_bits: u64 = @bitCast(cached.glyph_scale_floor); + const new_bits: u64 = @bitCast(floor); + if (old_bits == new_bits) return false; + } else { + return false; // no font before, no font now + } + } _ = env.set("ghostel--rendered-font", new_font); @@ -215,6 +237,7 @@ fn updateFontInfo(self: *Self, env: emacs.Env) bool { .width = env.extractInteger(env.vecGet(default_font_info, 6)), .height = cell_ascent + cell_descent, .coverage = probeCoverage(env, new_font), + .glyph_scale_floor = floor, }; } return true; @@ -697,7 +720,8 @@ fn adjustGlyph( // We add a fudge factor of +1 to the denominator to ensure fit 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 computed_scale = @min(scale_width, scale_height); + const scale = @max(computed_scale, default_font_info.glyph_scale_floor); const min_width_spec = env.list(.{ s.@"min-width", env.list(.{char_width}) }); const scale_spec = env.list(.{ s.height, scale }); diff --git a/src/emacs.zig b/src/emacs.zig index 995708bd..6d279646 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -117,6 +117,14 @@ pub const Env = struct { return self.raw.extract_float.?(self.raw, val); } + /// Extract val as f64, coercing Emacs integers to floats. + /// Returns default if val is not a number, avoiding a wrong-type-argument + /// signal that would corrupt the env's non-local exit state. + pub fn asFloat(self: Env, val: Value, default: f64) f64 { + if (self.isNil(self.f("numberp", .{val}))) return default; + return self.extractFloat(self.f("float", .{val})); + } + pub fn extractString(self: Env, val: Value, buf: []u8) ?[]const u8 { var len: isize = @intCast(buf.len); if (self.raw.copy_string_contents.?(self.raw, val, buf.ptr, &len)) { @@ -414,6 +422,7 @@ const interned_symbols = [_][:0]const u8{ "error", "face", "face-attribute", + "float", "font-at", "font-get-glyphs", "font-has-char-p", @@ -439,6 +448,7 @@ const interned_symbols = [_][:0]const u8{ "ghostel--osc52-handle", "ghostel--query-font-cached", "ghostel--rendered-font", + "ghostel-glyph-scale-floor", "ghostel--set-buffer-face", "ghostel--set-cursor-style", "ghostel--set-title", @@ -466,6 +476,7 @@ const interned_symbols = [_][:0]const u8{ "mouse-face", "move-to-column", "nil", + "numberp", "point", "point-max", "point-min", diff --git a/test/ghostel-glyph-kitty-test.el b/test/ghostel-glyph-kitty-test.el index 2557da2c..8c6c3223 100644 --- a/test/ghostel-glyph-kitty-test.el +++ b/test/ghostel-glyph-kitty-test.el @@ -841,6 +841,41 @@ the bright variant just like in `bright' mode." (should (equal min-w '(1))))))))) (kill-buffer buf)))) +(ert-deftest ghostel-test-glyph-scale-floor-clamps-scale () + "A non-zero `ghostel-glyph-scale-floor' prevents shrinking below the floor. +Sets floor to 1.0 and feeds a glyph larger than the cell. With floor +0.0 the glyph would be scaled to ~0.8; with floor 1.0 it stays at 1.0." + :tags '(native) + (let ((buf (generate-new-buffer " *ghostel-test-glyph-floor*"))) + (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) + (ghostel-glyph-scale-floor 1.0) ; clamp: never shrink + (inhibit-read-only t) + (df (ghostel-test--make-font ghostel-test--default-font-info)) + ;; Glyph: 12px wide x 25px tall (larger than 10x20 cell); + ;; without floor this would scale to ~0.8. + (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]]))) + (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) + ;; Floor 1.0 clamps the scale so the glyph is NOT shrunk. + (should (>= scale 1.0)))))))) + (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." :tags '(native)