From 13c278d7d47d96b6033109a02446c027906253e1 Mon Sep 17 00:00:00 2001 From: Jaeseok Lee Date: Thu, 21 May 2026 18:46:15 +0900 Subject: [PATCH] Suppress cursor when cell face background matches cursor color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal app that draws its input cursor with SGR reverse-video (e.g. Claude Code) leaves the Emacs cursor visually fused with the cell beneath it. Emacs's GUI ports swap the cursor's fg/bg with the cell's fg/bg when cursor-color matches the cell background — see `set_cursor_gc' in xterm.c:8017 and the mac-port/w32/pgtk equivalents — and on a reverse-video cell, that swap reproduces the colors of the surrounding non-inverse cells. Detect the collision and force `cursor-type' to nil so the cell's own inverse video face represents the cursor. The check runs from a buffer-local `pre-redisplay-functions' hook rather than from `ghostel--set-cursor-style': a one-time assignment there would not stick, because cursor-type is restored to its default between module render passes, so the hook reapplies the suppression just before each redisplay. Track the DECTCEM hide state (`\e[?25l') in `ghostel--cursor-visible' along the way so the hook also keeps the cursor off when a TUI menu hides it. --- lisp/ghostel.el | 69 +++++++++++++++++++++++++++++++++++++- test/ghostel-modes-test.el | 66 +++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/lisp/ghostel.el b/lisp/ghostel.el index 1c5e2cc2..28bbc77f 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -1523,6 +1523,13 @@ Matches Ghostty 1.2.0's `bold-color' configuration." (defvar-local ghostel--cursor-char-pos nil "The position of the terminal cursor in the buffer.") +(defvar-local ghostel--cursor-visible nil + "Last VISIBLE value passed to `ghostel--set-cursor-style'. +Mirrors the terminal's DECTCEM (show/hide cursor) state. Consumed +by `ghostel--maybe-suppress-cursor' so the redisplay-time hook can +keep `cursor-type' off when the terminal has hidden the cursor, +even if something else has reset `cursor-type' in the meantime.") + (defvar-local ghostel--rendered-font nil "The font last used for rendering. Internally used by native code.") @@ -5294,13 +5301,72 @@ Only acts when the buffer has not been manually renamed by the user." (when ghostel-set-title-function (funcall ghostel-set-title-function title))) +(defun ghostel--cell-face-background (pos) + "Return the effective background color string of the face at POS. +Walks the `face' text property in its various shapes (symbol, +anonymous plist, or list of either) and returns the first +explicit background found, or nil." + (cl-labels + ((bg-of (f) + (cond + ((null f) nil) + ((symbolp f) + (let ((bg (face-attribute f :background nil t))) + (and (stringp bg) bg))) + ((and (listp f) (keywordp (car f))) + (plist-get f :background)) + ((listp f) (seq-some #'bg-of f))))) + (bg-of (get-text-property pos 'face)))) + +(defun ghostel--cursor-cell-conflicts-p () + "Return non-nil when the cursor cell's face background equals the cursor color. +Emacs's GUI ports (X11, mac-port, w32, pgtk) share a `set_cursor_gc' +distinct-swap fallback that silently swaps the cursor's fg/bg with +the cell's fg/bg whenever they match (e.g. xterm.c:8017, +macterm.c:1042). The swap result coincides with the surrounding +non-inverse cells, so the cursor becomes visually indistinguishable +from normal text. When this returns non-nil, callers suppress +`cursor-type' so the cell's own inverse video face represents the +cursor position." + (when-let* ((pos ghostel--cursor-char-pos) + ((< pos (point-max))) + (cell-bg (ghostel--cell-face-background pos)) + (cursor-color (frame-parameter nil 'cursor-color)) + (cell-rgb (color-values cell-bg)) + (cursor-rgb (color-values cursor-color))) + (equal cell-rgb cursor-rgb))) + +(defun ghostel--maybe-suppress-cursor (&optional _window) + "Force `cursor-type' to nil when the cursor should be hidden. + +Two conditions trigger suppression: +- The terminal sent DECTCEM hide (\\='\\=e[?25l), captured in + `ghostel--cursor-visible'. Without this branch, TUI menus that + hide the cursor would still show Emacs's box cursor on top of + whatever cell is at the saved cursor position. +- The cursor cell's face background collides with the cursor color; + see `ghostel--cursor-cell-conflicts-p' for why that hides the cursor. + +Installed as a buffer-local `pre-redisplay-functions' entry because +`ghostel--set-cursor-style' alone is not enough: `cursor-type' is +restored to its default between module render passes, and this hook +reapplies the suppression just before each redisplay." + (when (and (derived-mode-p 'ghostel-mode) + cursor-type + (or (not ghostel--cursor-visible) + (ghostel--cursor-cell-conflicts-p))) + (setq cursor-type nil))) + (defun ghostel--set-cursor-style (style visible) "Set the cursor style based on terminal state. STYLE is one of: 0=bar, 1=block, 2=underline, 3=hollow-block. VISIBLE is t or nil. Skipped in read-only input modes (copy, Emacs, line) where the user-facing cursor is managed by Emacs for navigation, or when -`ghostel-ignore-cursor-change' is non-nil." +`ghostel-ignore-cursor-change' is non-nil. +Face-background conflict suppression is handled separately by +`ghostel--maybe-suppress-cursor', which runs on each redisplay." + (setq ghostel--cursor-visible (and visible t)) (when (and (ghostel--buffer-editable-p) (not ghostel-ignore-cursor-change)) (setq cursor-type @@ -6760,6 +6826,7 @@ output is arriving." (add-hook 'window-buffer-change-functions #'ghostel--focus-change) (add-hook 'window-buffer-change-functions #'ghostel--reshow-snap nil t) (add-hook 'window-buffer-change-functions #'ghostel--sync-tty-composition nil t) + (add-hook 'pre-redisplay-functions #'ghostel--maybe-suppress-cursor nil t) (ghostel--suppress-interfering-modes) (ghostel-imenu-setup) (setq ghostel--scroll-intercept-active t) diff --git a/test/ghostel-modes-test.el b/test/ghostel-modes-test.el index fa300c69..ccaee001 100644 --- a/test/ghostel-modes-test.el +++ b/test/ghostel-modes-test.el @@ -3,7 +3,7 @@ ;;; Commentary: ;; Input mode state + char/emacs/copy mode transitions, fake cursor, -;; copy-mode cursor + hl-line. +;; copy-mode cursor + hl-line, cursor suppression on bg conflict. ;;; Code: @@ -712,5 +712,69 @@ Otherwise the window stays where the user navigated." (should-not buffer-read-only)))) (kill-buffer buf)))) +(ert-deftest ghostel-test-cell-face-background-shapes () + "`ghostel--cell-face-background' resolves the bg across face-property shapes." + (defface ghostel-test--no-bg '((t :foreground "#00ff00")) + "Test face without a background.") + (defface ghostel-test--with-bg '((t :background "#0000ff")) + "Test face with a background.") + (defface ghostel-test--inherits '((t :inherit ghostel-test--with-bg)) + "Test face inheriting a background.") + (with-temp-buffer + ;; Anonymous plist with an explicit background. + (insert (propertize "x" 'face '(:background "#ff0000"))) + (should (equal "#ff0000" (ghostel--cell-face-background (point-min)))) + ;; Plist without :background resolves to nil. + (erase-buffer) + (insert (propertize "y" 'face '(:foreground "#00ff00"))) + (should (null (ghostel--cell-face-background (point-min)))) + ;; No face property at all. + (erase-buffer) + (insert "z") + (should (null (ghostel--cell-face-background (point-min)))) + ;; Named face with an explicit background. + (erase-buffer) + (insert (propertize "a" 'face 'ghostel-test--with-bg)) + (should (equal "#0000ff" (ghostel--cell-face-background (point-min)))) + ;; List of faces: a leading face without a background must not mask the + ;; later face that supplies one. + (erase-buffer) + (insert (propertize "b" 'face '(ghostel-test--no-bg ghostel-test--with-bg))) + (should (equal "#0000ff" (ghostel--cell-face-background (point-min)))) + ;; The :inherit chain is followed when the face has no direct background. + (erase-buffer) + (insert (propertize "c" 'face 'ghostel-test--inherits)) + (should (equal "#0000ff" (ghostel--cell-face-background (point-min)))))) + +(ert-deftest ghostel-test-cursor-cell-conflicts () + "`ghostel--cursor-cell-conflicts-p' compares cursor/cell colors by value. +Stub `color-values' and `frame-parameter' so the result is independent +of the display (color names do not resolve on a display-less batch run)." + (with-temp-buffer + (insert (propertize "x" 'face '(:background "#ffffff"))) + (let ((cursor "white")) + (cl-letf (((symbol-function 'color-values) + (lambda (c) + (pcase (downcase c) + ((or "white" "#ffffff") '(65535 65535 65535)) + ((or "black" "#000000") '(0 0 0)) + (_ nil)))) + ((symbol-function 'frame-parameter) + (lambda (_frame param) + (when (eq param 'cursor-color) cursor)))) + (setq-local ghostel--cursor-char-pos (point-min)) + ;; Named "white" cursor vs "#ffffff" cell — same color, conflict. + (should (ghostel--cursor-cell-conflicts-p)) + ;; Distinct colors — no conflict. + (setq cursor "black") + (should-not (ghostel--cursor-cell-conflicts-p)) + ;; Cursor position at point-max is out of range — no conflict. + (setq cursor "white") + (setq-local ghostel--cursor-char-pos (point-max)) + (should-not (ghostel--cursor-cell-conflicts-p)) + ;; No saved cursor position — no conflict. + (setq-local ghostel--cursor-char-pos nil) + (should-not (ghostel--cursor-cell-conflicts-p)))))) + (provide 'ghostel-modes-test) ;;; ghostel-modes-test.el ends here