From 9a4256abd6ee2042475b5ab30b9a73904eb3b28e Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Mon, 13 Jul 2026 18:36:19 +0200 Subject: [PATCH] Add opt-in ghostel-line-spacing for padded terminal rows New defcustom applies buffer-local line-spacing (integer pixels or a float fraction of the line height) in ghostel buffers. The grid sizing already follows the taller rows through window-screen-lines; the reported cell pixel height now includes the spacing so XTWINOPS replies and kitty placement math match rendered rows, and the custom setter resizes open terminals immediately instead of leaving a stale grid until the next window resize. Default 0 keeps current behavior. Non-zero values leave gaps in everything that tiles vertically (box-drawing, block characters, kitty image rows) since Emacs draws no content into the spacing strip; the docstring and README row document this. --- CHANGELOG.md | 8 ++++++ README.org | 21 ++++++++------- lisp/ghostel.el | 32 ++++++++++++++++++++-- test/ghostel-modes-test.el | 54 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1388e2a7..4278971c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- New `ghostel-line-spacing` customization pads terminal rows with extra + vertical space (integer pixels or a float fraction of the line height). + The terminal grid and reported cell pixel sizes follow the taller rows; + changing it live resizes open terminals. Note that Emacs draws no content + into the spacing strip, so non-zero values leave gaps in box-drawing + borders, block characters, and between kitty-graphics image rows. + ## [0.43.0] — 2026-07-11 ### Added diff --git a/README.org b/README.org index 9530a235..b1ada15b 100644 --- a/README.org +++ b/README.org @@ -1285,16 +1285,17 @@ tables below group them by area; defaults shown are the out-of-the-box values. ** Rendering and performance -| Variable | Default | Description | -|-----------------------------------+---------+--------------------------------------------------------------------------------------------------------------------------------------| -| =ghostel-max-scrollback= | 5 MB | Max scrollback in bytes (materialized into the buffer; ~5,000 rows at 80 cols). | -| =ghostel-timer-delay= | =0.033= | Base redraw delay in seconds (~30 fps). | -| =ghostel-adaptive-fps= | =t= | Adaptive frame rate (shorter delay after idle, stop timer when idle). | -| =ghostel-immediate-redraw-interval= | =0.05= | Max seconds since last keystroke for immediate redraw. | -| =ghostel-inhibit-redraw-functions= | =nil= | Abnormal hook (each fn called with the buffer); if any returns non-nil the redraw is deferred. Used by add-ons such as =ghostel-ime=. | -| =ghostel-inhibit-anchor-functions= | =nil= | Abnormal hook (each fn called with WINDOW and FORCE); non-nil skips anchoring that window. Used by add-ons such as =evil-ghostel=. | -| =ghostel-cell-pixel-scale= | =auto= | Physical:logical pixel ratio for cell-size reporting (=auto= derives from DPI). | -| =ghostel-glyph-scale-floor= | =0.0= | Minimum scale for glyphs that don't fit the cell (=0.0= preserves grid alignment; =1.0= renders CJK at natural size). | +| Variable | Default | Description | +|-----------------------------------+---------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| =ghostel-max-scrollback= | 5 MB | Max scrollback in bytes (materialized into the buffer; ~5,000 rows at 80 cols). | +| =ghostel-timer-delay= | =0.033= | Base redraw delay in seconds (~30 fps). | +| =ghostel-adaptive-fps= | =t= | Adaptive frame rate (shorter delay after idle, stop timer when idle). | +| =ghostel-immediate-redraw-interval= | =0.05= | Max seconds since last keystroke for immediate redraw. | +| =ghostel-inhibit-redraw-functions= | =nil= | Abnormal hook (each fn called with the buffer); if any returns non-nil the redraw is deferred. Used by add-ons such as =ghostel-ime=. | +| =ghostel-inhibit-anchor-functions= | =nil= | Abnormal hook (each fn called with WINDOW and FORCE); non-nil skips anchoring that window. Used by add-ons such as =evil-ghostel=. | +| =ghostel-cell-pixel-scale= | =auto= | Physical:logical pixel ratio for cell-size reporting (=auto= derives from DPI). | +| =ghostel-glyph-scale-floor= | =0.0= | Minimum scale for glyphs that don't fit the cell (=0.0= preserves grid alignment; =1.0= renders CJK at natural size). | +| =ghostel-line-spacing= | =0= | Extra vertical space below each row (int = pixels, float = fraction of line height). Non-zero leaves gaps in box-drawing and block glyphs and between kitty image rows. | ** Images diff --git a/lisp/ghostel.el b/lisp/ghostel.el index ea2dae25..723af301 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -310,6 +310,24 @@ natural size, potentially making rows slightly taller and cells slightly wider." :type '(float 0.0 1.0) :local t) +(defcustom ghostel-line-spacing 0 + "Additional vertical space below each terminal row. +An integer is extra pixels; a float is a fraction of the default line height. +Applied buffer-locally as `line-spacing'. The cons form of `line-spacing' is +not supported. Non-zero values leave gaps in everything that tiles vertically: +box-drawing borders, block characters and kitty graphics image rows." + :type '(choice (integer :tag "Extra pixels below each line") + (float :tag "Fraction of default line height")) + :initialize #'custom-initialize-default + :set (lambda (sym val) + (set-default sym val) + (dolist (buf (buffer-list)) + (with-current-buffer buf + (when (derived-mode-p 'ghostel-mode) + (setq-local line-spacing val) + (dolist (window (get-buffer-window-list buf nil t)) + (ghostel--adjust-size window t))))))) + (defcustom ghostel-timer-delay 0.033 "Delay in seconds before redrawing after output (roughly 30fps). When `ghostel-adaptive-fps' is non-nil, this serves as the base @@ -4851,9 +4869,19 @@ reported (some multi-monitor setups), letting the caller fall back." "Return cell width to report to libghostty, in physical pixels." (round (* (frame-char-width) (ghostel--cell-pixel-scale)))) +(defun ghostel--cell-height () + "Return the terminal cell height in logical pixels. +`frame-char-height' plus the buffer's `line-spacing' in pixels." + (+ (frame-char-height) + (cond ((not (display-graphic-p)) 0) + ((integerp line-spacing) (max 0 line-spacing)) + ((floatp line-spacing) + (max 0 (round (* line-spacing (frame-char-height))))) + (t 0)))) + (defun ghostel--reported-cell-height () "Return cell height to report to libghostty, in physical pixels." - (round (* (frame-char-height) (ghostel--cell-pixel-scale)))) + (round (* (ghostel--cell-height) (ghostel--cell-pixel-scale)))) (defun ghostel--set-size-with-cell-dims (term rows cols) "Resize TERM to ROWS×COLS, including the reported cell pixel dimensions. @@ -5017,7 +5045,7 @@ for both native and Emacs PTY paths." (setq-local hscroll-margin 0) (setq-local truncate-lines t) (setq-local scroll-conservatively 101) - (setq-local line-spacing 0) + (setq-local line-spacing ghostel-line-spacing) ;; Shield row geometry from a global `default-text-properties': ;; `line-spacing'/`line-height' properties supplied through its fallback ;; inflate rendered rows invisibly to the `window-screen-lines' sizing math. diff --git a/test/ghostel-modes-test.el b/test/ghostel-modes-test.el index d6165d6b..9bc22e3d 100644 --- a/test/ghostel-modes-test.el +++ b/test/ghostel-modes-test.el @@ -35,6 +35,60 @@ invisibly to the grid sizing math (discussion #534)." (should (null (get-char-property (point-min) 'line-height))))) (setq-default default-text-properties old)))) +(ert-deftest ghostel-test-mode-applies-line-spacing-custom () + "`ghostel-line-spacing' seeds the buffer-local `line-spacing'." + (let ((ghostel-line-spacing 3)) + (with-temp-buffer + (ghostel-mode) + (should (eql line-spacing 3))))) + +(ert-deftest ghostel-test-line-spacing-set-updates-live-buffers () + "The `ghostel-line-spacing' setter updates live terminals only. +Ghostel buffers get the new buffer-local `line-spacing' and displayed +windows a forced size adjustment; other buffers are untouched." + (let ((setter (get 'ghostel-line-spacing 'custom-set)) + (old (default-value 'ghostel-line-spacing)) + (calls nil)) + (with-temp-buffer + (ghostel-mode) + (let ((ghostel-buf (current-buffer))) + (with-temp-buffer + (let ((plain-buf (current-buffer))) + (set-window-buffer (selected-window) ghostel-buf) + (unwind-protect + (cl-letf (((symbol-function 'ghostel--adjust-size) + (lambda (window force) (push (cons window force) calls)))) + (funcall setter 'ghostel-line-spacing 4) + (should (eql (buffer-local-value 'line-spacing ghostel-buf) 4)) + (should-not (buffer-local-value 'line-spacing plain-buf)) + (should (equal calls + (list (cons (get-buffer-window ghostel-buf) t))))) + (funcall setter 'ghostel-line-spacing old)))))))) + +(ert-deftest ghostel-test-cell-height-includes-line-spacing () + "`ghostel--cell-height' adds the buffer's `line-spacing' in pixels. +Integer spacing adds as-is, float spacing as a rounded fraction of +`frame-char-height'; unsupported values and non-graphic displays add +nothing." + (cl-letf (((symbol-function 'frame-char-height) (lambda (&optional _) 14)) + ((symbol-function 'display-graphic-p) (lambda (&optional _) t))) + (with-temp-buffer + (setq-local line-spacing 3) + (should (eql (ghostel--cell-height) 17)) + (setq-local line-spacing 0.1) + (should (eql (ghostel--cell-height) 15)) + (setq-local line-spacing 0) + (should (eql (ghostel--cell-height) 14)) + ;; Emacs 28/29 reject non-number values at `setq-local' time + ;; (C-level `numberp' check), so nil is the only portable way to + ;; exercise the fallback branch. + (setq-local line-spacing nil) + (should (eql (ghostel--cell-height) 14)) + (setq-local line-spacing 3) + (cl-letf (((symbol-function 'display-graphic-p) + (lambda (&optional _) nil))) + (should (eql (ghostel--cell-height) 14)))))) + (ert-deftest ghostel-test-emacs-mode-preserves-point () "In Emacs mode, point stays put while the terminal keeps running. The delayed redraw path always preserves point in Emacs mode,