aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 13:14:40 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 13:14:40 -0500
commit3bd514d9841429ba8a5b02819e7e10950c62ab8e (patch)
tree6ccc73edaca3dd7e8d43785f21dabd699afa98e5
parent32cfe216b4f5917b1a979e0372edf9b8f1ab62ea (diff)
downloaddotemacs-3bd514d9841429ba8a5b02819e7e10950c62ab8e.tar.gz
dotemacs-3bd514d9841429ba8a5b02819e7e10950c62ab8e.zip
feat(auto-dim): dim vterm windows by blending terminal colors
Window dimming via face-remap never reached vterm. The terminal resolves its own colors per cell while redrawing, so it bypasses the remapped faces, and agent and shell windows stayed bright when they lost focus. I advise vterm--get-color to blend each looked-up color toward the auto-dim faces whenever every window showing the buffer is dimmed. The foreground and background blend amounts are separate defcustoms (foreground stays more legible, background fades harder). After a dim-state change I force a full vterm repaint by briefly nudging the terminal size, because vterm only repaints the rows libvterm marked dirty. A post-command hook and a select-window advice cover the windmove and Shift-arrow focus paths that window-selection-change-functions misses. Tests cover the dimmed-buffer predicate, the color blend, the selection-change scheduling, and the auto-dim-before-repaint ordering.
-rw-r--r--modules/auto-dim-config.el180
-rw-r--r--tests/test-auto-dim-config.el103
2 files changed, 283 insertions, 0 deletions
diff --git a/modules/auto-dim-config.el b/modules/auto-dim-config.el
index 5ce426d2..e538e523 100644
--- a/modules/auto-dim-config.el
+++ b/modules/auto-dim-config.el
@@ -21,6 +21,177 @@
;;; Code:
+(require 'cl-lib)
+(require 'color)
+
+(declare-function auto-dim-other-buffers-mode "auto-dim-other-buffers")
+(declare-function adob--update "auto-dim-other-buffers")
+(declare-function vterm--get-color "vterm")
+(declare-function vterm--invalidate "vterm")
+(declare-function vterm--set-size "vterm")
+(declare-function vterm--get-margin-width "vterm")
+(defvar vterm-min-window-width)
+(defvar vterm--term)
+
+(defvar cj/auto-dim--last-selected-window nil
+ "Most recent selected window seen by `cj/auto-dim--refresh-vterm-on-command'.")
+
+(defvar cj/auto-dim--vterm-refresh-timer nil
+ "Timer used to defer vterm redraws until after auto-dim updates.")
+
+(defcustom cj/auto-dim-vterm-foreground-blend 0.45
+ "Blend amount for dimmed vterm foreground colors.
+
+0 keeps the original vterm color; 1 uses the
+`auto-dim-other-buffers' foreground color."
+ :type 'number
+ :group 'auto-dim-other-buffers)
+
+(defcustom cj/auto-dim-vterm-background-blend 0.7
+ "Blend amount for dimmed vterm background colors.
+
+0 keeps the original vterm color; 1 uses the
+`auto-dim-other-buffers' background color."
+ :type 'number
+ :group 'auto-dim-other-buffers)
+
+(defun cj/auto-dim--vterm-buffer-dimmed-p ()
+ "Return non-nil when the current vterm buffer should render dimmed.
+
+Vterm resolves terminal colors to concrete color strings while redrawing the
+buffer, so this integration is buffer-level. If the same vterm buffer is shown
+in multiple windows and any one of those windows is selected/undimmed, keep the
+buffer bright."
+ (and (eq major-mode 'vterm-mode)
+ (let ((windows (get-buffer-window-list (current-buffer) nil 'visible)))
+ (and windows
+ (not (catch 'undimmed
+ (dolist (window windows)
+ (unless (window-parameter window 'adob--dim)
+ (throw 'undimmed t)))))))))
+
+(defun cj/auto-dim--face-color (face attribute fallback-face)
+ "Return FACE ATTRIBUTE, falling back to FALLBACK-FACE."
+ (let ((color (face-attribute face attribute nil 'default)))
+ (if (or (null color) (eq color 'unspecified))
+ (face-attribute fallback-face attribute nil 'default)
+ color)))
+
+(defun cj/auto-dim--color-rgb (color)
+ "Return COLOR as a list of RGB floats, or nil if COLOR is unknown."
+ (cond
+ ((and (stringp color)
+ (string-match
+ "\\`#\\([[:xdigit:]]\\{2\\}\\)\\([[:xdigit:]]\\{2\\}\\)\\([[:xdigit:]]\\{2\\}\\)\\'"
+ color))
+ (mapcar (lambda (index)
+ (/ (string-to-number (match-string index color) 16) 255.0))
+ '(1 2 3)))
+ ((and (stringp color)
+ (string-match
+ "\\`#\\([[:xdigit:]]\\)\\([[:xdigit:]]\\)\\([[:xdigit:]]\\)\\'"
+ color))
+ (mapcar (lambda (index)
+ (/ (* 17 (string-to-number (match-string index color) 16)) 255.0))
+ '(1 2 3)))
+ (t
+ (ignore-errors
+ (mapcar (lambda (component) (/ component 65535.0))
+ (color-values color))))))
+
+(defun cj/auto-dim--blend-color (color target amount)
+ "Blend COLOR toward TARGET by AMOUNT and return a hex color string."
+ (if-let* ((rgb (cj/auto-dim--color-rgb color))
+ (target-rgb (cj/auto-dim--color-rgb target)))
+ (apply #'color-rgb-to-hex
+ (append
+ (cl-mapcar
+ (lambda (source dest)
+ (+ (* source (- 1 amount)) (* dest amount)))
+ rgb target-rgb)
+ '(2)))
+ color))
+
+(defun cj/auto-dim--vterm-dim-color (color foreground-p)
+ "Return dimmed vterm COLOR.
+
+When FOREGROUND-P is non-nil, blend toward the dimmed foreground face; otherwise
+blend toward the dimmed background face."
+ (let* ((attribute (if foreground-p :foreground :background))
+ (target (cj/auto-dim--face-color 'auto-dim-other-buffers attribute 'default))
+ (amount (if foreground-p
+ cj/auto-dim-vterm-foreground-blend
+ cj/auto-dim-vterm-background-blend)))
+ (cj/auto-dim--blend-color color target amount)))
+
+(defun cj/auto-dim--vterm-get-color (orig-fun index &rest args)
+ "Advise vterm color lookup ORIG-FUN for dimmed windows.
+
+INDEX and ARGS are passed through to `vterm--get-color'."
+ (let ((color (apply orig-fun index args)))
+ (if (and color (cj/auto-dim--vterm-buffer-dimmed-p))
+ (cj/auto-dim--vterm-dim-color color (memq :foreground args))
+ color)))
+
+(defun cj/auto-dim--refresh-vterm-windows (&optional frame)
+ "Refresh visible vterm buffers in FRAME after dim state changes."
+ (when (or (fboundp 'vterm--set-size) (fboundp 'vterm--invalidate))
+ (dolist (window (window-list frame 'no-minibuf))
+ (with-current-buffer (window-buffer window)
+ (when (eq major-mode 'vterm-mode)
+ (let ((inhibit-read-only t))
+ (if (and (bound-and-true-p vterm--term)
+ (window-live-p window)
+ (fboundp 'vterm--get-margin-width))
+ (let* ((height (max 2 (window-body-height window)))
+ (min-width (if (boundp 'vterm-min-window-width)
+ vterm-min-window-width
+ 80))
+ (width (max min-width
+ (- (window-body-width window)
+ (vterm--get-margin-width)))))
+ ;; `vterm--redraw' only repaints rows libvterm marked dirty.
+ ;; A resize marks the whole terminal grid dirty, so briefly
+ ;; nudge height and restore it to force a full repaint after
+ ;; dim-state changes.
+ (vterm--set-size vterm--term (1+ height) width)
+ (vterm--set-size vterm--term height width))
+ (when (fboundp 'vterm--invalidate)
+ (vterm--invalidate)))))))))
+
+(defun cj/auto-dim--refresh-vterm-after-auto-dim (&optional frame)
+ "Update auto-dim state, then refresh visible vterm buffers in FRAME."
+ (setq cj/auto-dim--vterm-refresh-timer nil)
+ (when (fboundp 'adob--update)
+ (adob--update))
+ (cj/auto-dim--refresh-vterm-windows frame))
+
+(defun cj/auto-dim--schedule-vterm-refresh (&optional frame)
+ "Schedule a deferred vterm refresh for FRAME.
+
+The delay lets selection-changing commands finish before we recompute
+auto-dim state and invalidate vterm."
+ (when cj/auto-dim--vterm-refresh-timer
+ (cancel-timer cj/auto-dim--vterm-refresh-timer))
+ (setq cj/auto-dim--vterm-refresh-timer
+ (run-with-timer 0 nil #'cj/auto-dim--refresh-vterm-after-auto-dim frame)))
+
+(defun cj/auto-dim--refresh-vterm-on-command ()
+ "Refresh visible vterm buffers when selected window changes.
+
+`window-selection-change-functions' does not catch every selection path used by
+windmove/Shift-arrow focus changes in this config, so this post-command hook is
+the fallback that makes vterm repaint after auto-dim changes window state."
+ (let ((window (selected-window)))
+ (unless (eq window cj/auto-dim--last-selected-window)
+ (setq cj/auto-dim--last-selected-window window)
+ (cj/auto-dim--schedule-vterm-refresh))))
+
+(defun cj/auto-dim--after-select-window (&rest _)
+ "Schedule vterm refresh after `select-window'."
+ (setq cj/auto-dim--last-selected-window (selected-window))
+ (cj/auto-dim--schedule-vterm-refresh))
+
(use-package auto-dim-other-buffers
:load-path "~/code/auto-dim-other-buffers.el"
:ensure nil
@@ -73,5 +244,14 @@
(dupre-org-priority-d . (dupre-org-priority-d-dim . nil))))
(auto-dim-other-buffers-mode 1))
+(with-eval-after-load 'vterm
+ (unless (advice-member-p #'cj/auto-dim--vterm-get-color #'vterm--get-color)
+ (advice-add #'vterm--get-color :around #'cj/auto-dim--vterm-get-color))
+ (unless (advice-member-p #'cj/auto-dim--after-select-window #'select-window)
+ (advice-add #'select-window :after #'cj/auto-dim--after-select-window))
+ (add-hook 'window-selection-change-functions
+ #'cj/auto-dim--schedule-vterm-refresh)
+ (add-hook 'post-command-hook #'cj/auto-dim--refresh-vterm-on-command))
+
(provide 'auto-dim-config)
;;; auto-dim-config.el ends here
diff --git a/tests/test-auto-dim-config.el b/tests/test-auto-dim-config.el
index 45e1db5f..f33a475a 100644
--- a/tests/test-auto-dim-config.el
+++ b/tests/test-auto-dim-config.el
@@ -10,6 +10,7 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(defconst test-auto-dim--fork
@@ -29,5 +30,107 @@
(when (fboundp 'auto-dim-other-buffers-mode)
(auto-dim-other-buffers-mode -1))))
+(ert-deftest test-auto-dim-config-vterm-dimmed-p-all-windows-dimmed ()
+ "Normal: a vterm buffer is dimmed when all displayed windows are dimmed."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let ((major-mode 'vterm-mode))
+ (cl-letf (((symbol-function 'get-buffer-window-list)
+ (lambda (&rest _) '(left right)))
+ ((symbol-function 'window-parameter)
+ (lambda (window parameter)
+ (and (eq parameter 'adob--dim)
+ (memq window '(left right))))))
+ (should (cj/auto-dim--vterm-buffer-dimmed-p)))))
+
+(ert-deftest test-auto-dim-config-vterm-dimmed-p-undimmed-window-keeps-buffer-bright ()
+ "Normal: a selected/undimmed vterm window keeps the buffer bright."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let ((major-mode 'vterm-mode))
+ (cl-letf (((symbol-function 'get-buffer-window-list)
+ (lambda (&rest _) '(left right)))
+ ((symbol-function 'window-parameter)
+ (lambda (window parameter)
+ (and (eq parameter 'adob--dim)
+ (eq window 'right)))))
+ (should-not (cj/auto-dim--vterm-buffer-dimmed-p)))))
+
+(ert-deftest test-auto-dim-config-vterm-get-color-dims-only-dimmed-vterm-buffers ()
+ "Normal: vterm color advice dims only buffers marked dimmed."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let ((major-mode 'vterm-mode)
+ (cj/auto-dim-vterm-foreground-blend 1.0))
+ (cl-letf (((symbol-function 'cj/auto-dim--vterm-buffer-dimmed-p)
+ (lambda () t))
+ ((symbol-function 'cj/auto-dim--face-color)
+ (lambda (&rest _) "#555555")))
+ (should (equal "#555555"
+ (cj/auto-dim--vterm-get-color
+ (lambda (&rest _) "#ffffff") 7 :foreground))))
+ (cl-letf (((symbol-function 'cj/auto-dim--vterm-buffer-dimmed-p)
+ (lambda () nil)))
+ (should (equal "#ffffff"
+ (cj/auto-dim--vterm-get-color
+ (lambda (&rest _) "#ffffff") 7 :foreground))))))
+
+(ert-deftest test-auto-dim-config-vterm-post-command-schedules-refresh-on-window-change ()
+ "Normal: post-command vterm refresh schedules only after selection changes."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let ((cj/auto-dim--last-selected-window 'old)
+ (calls 0))
+ (cl-letf (((symbol-function 'selected-window)
+ (lambda () 'new))
+ ((symbol-function 'cj/auto-dim--schedule-vterm-refresh)
+ (lambda (&optional _) (setq calls (1+ calls)))))
+ (cj/auto-dim--refresh-vterm-on-command)
+ (cj/auto-dim--refresh-vterm-on-command))
+ (should (eq cj/auto-dim--last-selected-window 'new))
+ (should (= calls 1))))
+
+(ert-deftest test-auto-dim-config-vterm-refresh-runs-auto-dim-before-invalidate ()
+ "Normal: deferred vterm refresh updates auto-dim before invalidating vterm."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let (events)
+ (cl-letf (((symbol-function 'adob--update)
+ (lambda () (push 'adob events)))
+ ((symbol-function 'cj/auto-dim--refresh-vterm-windows)
+ (lambda (&optional _) (push 'vterm events))))
+ (cj/auto-dim--refresh-vterm-after-auto-dim))
+ (should (equal events '(vterm adob)))))
+
+(ert-deftest test-auto-dim-config-vterm-refresh-nudges-size-for-full-redraw ()
+ "Normal: vterm refresh nudges size to force full-grid redraw."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let ((calls nil)
+ (vterm-min-window-width 80))
+ (with-temp-buffer
+ (setq major-mode 'vterm-mode)
+ (setq-local vterm--term 'term)
+ (let ((buffer (current-buffer)))
+ (cl-letf (((symbol-function 'window-list)
+ (lambda (&rest _) '(vterm-window)))
+ ((symbol-function 'window-buffer)
+ (lambda (_) buffer))
+ ((symbol-function 'window-live-p)
+ (lambda (_) t))
+ ((symbol-function 'window-body-height)
+ (lambda (_) 24))
+ ((symbol-function 'window-body-width)
+ (lambda (_) 100))
+ ((symbol-function 'vterm--get-margin-width)
+ (lambda () 3))
+ ((symbol-function 'vterm--set-size)
+ (lambda (term height width)
+ (push (list term height width) calls))))
+ (cj/auto-dim--refresh-vterm-windows))))
+ (should (equal (nreverse calls)
+ '((term 25 97)
+ (term 24 97))))))
+
(provide 'test-auto-dim-config)
;;; test-auto-dim-config.el ends here