;;; erc-nicks.el -- Nick colors for ERC -*- lexical-binding: t; -*- ;; Copyright (C) 2023 Free Software Foundation, Inc. ;; This file is part of GNU Emacs. ;; GNU Emacs is free software: you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published ;; by the Free Software Foundation, either version 3 of the License, ;; or (at your option) any later version. ;; GNU Emacs is distributed in the hope that it will be useful, but ;; WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU ;; General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with GNU Emacs. If not, see . ;;; Commentary: ;; This module is heavily influenced by the lovely and more featureful ;; ;; `erc-hl-nicks' by David Leatherman ;; ;; ;; which itself is based on ;; ;; `erc-highlight-nicknames' by André Riemann, Andy Stewart, and ;; others ;; ;;; Code: (require 'erc-button) (require 'color) (defgroup erc-nicks nil "Colorize nicknames in ERC buffers." :package-version '(ERC . "5.6") ; FIXME sync on release :group 'erc) (defcustom erc-nicks-ignore-chars-regexp (cons (rx bot (+ (any ",`'_-"))) (rx (+ (any ",`'_-")) eot)) "Characters surrounding a nick to ignore while highlighting. Regexps should be suitable for `string-trim'." :type '(choice (cons string string) (const nil))) (defcustom erc-nicks-skip-nicks nil "Nicks to not highlight." :type '(repeat string)) (defcustom erc-nicks-skip-faces '(erc-notice-face erc-current-nick-face erc-my-nick-face erc-pal-face erc-fool-face) "Faces to avoid highlighting atop." :type '(repeat symbol)) (defcustom erc-nicks-nickname-face erc-button-nickname-face "Face to mix with generated one for emphasizing non-speakers." :type '(choice face (const nil))) (defcustom erc-nicks-bg-color (frame-parameter (selected-frame) 'background-color) "Background color for calculating contrast. Set this explicitly when the background color isn't discoverable, which may be the case in terminal Emacs." :type 'string) (defcustom erc-nicks-color-contrast-strategy '(erc-nicks-invert erc-nicks-add-contrast) "Treatments applied to colors for increasing visibility. A value of `erc-nicks-invert' inverts a nick when it's too close to the background. A value of `erc-nicks-add-contrast' attempts to find a decent contrast ratio by brightening or darkening. This option can also be a list, in which case, members will be applied in the order they appear. For example, \\='(erc-nicks-invert erc-nicks-add-contrast) will invert as needed and likewise adjust the contrast. Note that anything specified by this option will still be applied when `erc-nicks-colors' is a user-defined list of colors." :type '(choice (function-item :tag "Invert" erc-nicks-invert) (function-item :tag "Contrast" erc-nicks-add-contrast) (repeat function) (const nil) function)) (defcustom erc-nicks-contrast-ratio 3.5 "Desired amount of contrast. For this to matter, `erc-nicks-add-contrast' must be present in the value of `erc-nicks-color-contrast-strategy'. When that's so, this specifies the amount of contrast between a buffer's background color and the foreground colors chosen. The closer the number is to the maximum, 21(:1), the greater the contrast. Depending on the background, nicks are either tinted in pastel or muted with dark gray. Somewhere between 3.0 and 4.5 seems ideal." :type '(number :match (lambda (_ n) (and (floatp n) (< 0 n 21))) :type-error "This should be a float between 0 and 21")) (defcustom erc-nicks-colors 'all "Pool of colors. This can be a list of hexes or color names, such as those provided by `defined-colors', which can itself be used when the value is the symbol `defined'. With `all', use any 24-bit color." :type '(choice (const all) (const defined) (list string))) (defvar-local erc-nicks--face-table nil "Hash table containing unique nick faces.") ;; https://stackoverflow.com/questions/596216#answer-56678483 (defun erc-nicks--get-luminance (color) "Return relative luminance of COLOR. COLOR can be a list of normalized values or a name." (let ((out 0) (coefficients '(0.2126 0.7152 0.0722)) (chnls (if (stringp color) (color-name-to-rgb color) color))) (dolist (ch chnls out) (cl-incf out (* (pop coefficients) (if (<= ch 0.04045) (/ ch 12.92) (expt (/ (+ ch 0.055) 1.055) 2.4))))))) (defvar-local erc-nicks--bg-mode-value nil) (defmacro erc-nicks--bg-mode () `(or erc-nicks--bg-mode-value (setq erc-nicks--bg-mode-value ,(cond ((fboundp 'frame--current-background-mode) '(frame--current-background-mode (selected-frame))) ((fboundp 'frame--current-backround-mode) '(frame--current-backround-mode (selected-frame))) (t '(frame-parameter (selected-frame) 'background-mode)))))) (defvar erc-nicks--grad-steps 9) (defvar-local erc-nicks--bg-luminance nil) ;; https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html ;; ;; We could cache results, which may help when `erc-nicks-colors' is ;; set to `defined'. (defun erc-nicks-add-contrast (color) "Adjust COLOR by blending it with white or black. Unless sufficient contrast exists between COLOR and the background, bring the contrast up to `erc-nicks-contrast-ratio'." (let* ((lum-bg (or erc-nicks--bg-luminance (setq erc-nicks--bg-luminance (erc-nicks--get-luminance erc-nicks-bg-color)))) (stop (if (eq 'dark (erc-nicks--bg-mode)) '(1.0 1.0 1.0) '(0.0 0.0 0.0))) (start (color-name-to-rgb color)) ;; From `color-gradient' in color.el (r (nth 0 start)) (g (nth 1 start)) (b (nth 2 start)) (interval (float (1+ (expt 2 erc-nicks--grad-steps)))) (r-step (/ (- (nth 0 stop) r) interval)) (g-step (/ (- (nth 1 stop) g) interval)) (b-step (/ (- (nth 2 stop) b) interval)) (maxtries erc-nicks--grad-steps) started) (while (let* ((lum-fg (erc-nicks--get-luminance (list r g b))) (darker (if (< lum-bg lum-fg) lum-bg lum-fg)) (lighter (if (= darker lum-bg) lum-fg lum-bg)) (cur (/ (+ 0.05 lighter) (+ 0.05 darker))) (scale (expt 2 maxtries))) (cond ((< cur erc-nicks-contrast-ratio) (setq r (+ r (* r-step scale)) g (+ g (* g-step scale)) b (+ b (* b-step scale)))) (started (setq r (- r (* r-step scale)) g (- g (* g-step scale)) b (- b (* b-step scale)))) (t (setq maxtries 1))) (unless started (setq started t)) (setq r (min 1.0 (max 0 r)) g (min 1.0 (max 0 g)) b (min 1.0 (max 0 b))) (not (zerop (cl-decf maxtries))))) (color-rgb-to-hex r g b))) ;; Inversion thresholds for dark and light, respectively. (defvar erc-nicks--min-lum (/ 1 3.0)) (defvar erc-nicks--max-lum (/ 2 3.0)) (defun erc-nicks-invert (color) "Invert COLOR based on luminance and background." (if (pcase (erc-nicks--bg-mode) ('dark (< (erc-nicks--get-luminance color) erc-nicks--min-lum)) ('light (> (erc-nicks--get-luminance color) erc-nicks--max-lum))) (pcase-let ((`(,r ,g ,b) (color-values color))) (format "#%04x%04x%04x" (- 65535 r) (- 65535 g) (- 65535 b))) color)) ;; http://www.cse.yorku.ca/~oz/hash.html ;; See also gui_nick_hash_djb2_64 in weechat/src/gui/gui-nick.c, ;; which is originally from https://savannah.nongnu.org/patch/?8062. ;; ;; Short strings of the same length and those differing only in their ;; low order bits tend to land in neighboring buckets, which are often ;; similar in color. Padding on the right with at least nine added ;; chars seems to scramble things sufficiently enough for our needs. (defun erc-nicks--hash (s &optional nchoices) (let ((h 5381) ; seed and multiplier (33) hardcoded for now (p (or nchoices 281474976710656)) ; 48-bits (expt 2 48) (i 0) (n (length s))) (while (< (setq h (% (+ (* h 33) (aref s i)) p) i (1+ i)) n)) h)) (defvar-local erc-nicks--colors-len nil) (defvar-local erc-nicks--custom-keywords '(:group erc-nicks :group erc-faces)) (defun erc-nicks--revive (new-face old-face nick net) (put new-face 'erc-nicks--custom-nick (cons nick net)) (apply #'custom-declare-face new-face (face-user-default-spec old-face) (format "Persistent `erc-nicks' color for %s on %s." nick net) erc-nicks--custom-keywords)) (defun erc-nicks--create-defface-template (face) (pop-to-buffer (get-buffer-create (format "*New face %s*" face))) (erase-buffer) (lisp-interaction-mode) (insert ";; If you *don't* use Customize, put something like this in your\n" (substitute-command-keys ";; init.el and use \\[eval-last-sexp] to apply any edits.\n\n") (format "(defface %s\n '%S\n %S" face (face-user-default-spec face) (face-documentation face)) (cl-loop for (k v) on erc-nicks--custom-keywords by #'cddr concat (format "\n %s %S" k (list 'quote v))) ")\n")) (defun erc-nicks--redirect-face-widget-link (args) (pcase args (`(,widget face-link . ,plist) (when-let* ((face (widget-value widget)) ((get face 'erc-nicks--custom-nick))) (unless (symbol-file face) (setf (plist-get plist :action) (lambda (&rest _) (erc-nicks--create-defface-template face)))) (setf (plist-get plist :help-echo) "Create or edit `defface'." (cddr args) plist)))) args) (defun erc-nicks--get-face (nick key) "Retrieve or create a face for NICK, stored locally under KEY. But favor a custom erc-nicks-NICK@NETWORK-face, when defined." (setq nick (erc-downcase nick)) (let ((table (buffer-local-value 'erc-nicks--face-table (erc-server-buffer)))) (or (gethash nick table) (and-let* ((face (intern-soft (concat "erc-nicks-" nick "@" (erc-network-name) "-face"))) ((or (and (facep face) face) (erc-nicks--revive face face nick (erc-network)))))) (let ((color (seq-reduce (lambda (color strategy) (funcall strategy color)) (erc-list erc-nicks-color-contrast-strategy) (pcase erc-nicks-colors ('all (format "#%012x" (erc-nicks--hash key))) ((or 'defined v) (unless v (setq v (defined-colors (selected-frame)))) (unless erc-nicks--colors-len (setq erc-nicks--colors-len (length v))) (nth (erc-nicks--hash key erc-nicks--colors-len) v))))) (new-face (make-symbol (concat "erc-nicks-" nick "-face")))) (face-spec-set new-face `((t :foreground ,color)) 'face-defface-spec) (set-face-documentation new-face (format "Internal face for %s on %s." nick (erc-network))) (puthash nick new-face table))))) (defvar erc-nicks--phony-face nil "Face to pretend is propertizing the nick at point. Modules needing to colorize nicks outside of a buttonizing context can use this instead of setting fictitious bounds on the `erc-button--nick' object passed to `erc-nicks--highlight'.") (defun erc-nicks--highlight (nick-object) "Possibly highlight a single nick." (when-let* ((nick-object) (server-user (erc-button--nick-user nick-object)) (trimmed (if erc-nicks-ignore-chars-regexp (string-trim (erc-server-user-nickname server-user) (car erc-nicks-ignore-chars-regexp) (cdr erc-nicks-ignore-chars-regexp)) (erc-server-user-nickname server-user))) ((not (member trimmed erc-nicks-skip-nicks))) (face (or erc-nicks--phony-face (get-text-property (car (erc-button--nick-bounds nick-object)) 'font-lock-face))) ((not (seq-some (lambda (f) (memq f erc-nicks-skip-faces)) (erc-list face)))) ; cl-notany ;; Ensure nicks are colored uniquely (per network) by padding ;; from the right, as mentioned above in `erc-nicks--hash'. (key (concat (erc-button--nick-downcased nick-object) (and-let* ((net (erc-network))) (format "%9s" net)))) (out (erc-nicks--get-face trimmed key))) ;; `font-lock-prepend-text-property' could also work if preserving ;; history isn't needed (in which case this var should be nil). (setf (erc-button--nick-erc-button-nickname-face nick-object) (if (or (not erc-nicks-nickname-face) (eq face erc-nicks-nickname-face)) out (cons out (erc-list erc-nicks-nickname-face))))) nick-object) (define-erc-module nicks nil "Uniquely colorize nicknames in target buffers." ((if erc--target (progn (add-function :filter-return (local 'erc-button--modify-nick-function) #'erc-nicks--highlight '((depth . 80))) (erc-button--phantom-users-mode +1)) (unless erc-button-mode (unless (memq 'button erc-modules) (erc--warn-once-before-connect 'erc-nicks-mode "Enabling default global module `button' needed by local" " module `nicks'. This will impact \C-]all\C-] ERC" " sessions. Add `nicks' to `erc-modules' to avoid this" " warning. See Info:\"(erc) Modules\" for more.")) (erc-button-mode +1)) (when (equal erc-nicks-bg-color "unspecified-bg") (let ((temp (if (eq (erc-nicks--bg-mode) 'light) "white" "black"))) (erc-button--display-error-notice-with-keys "Module `nicks' unable to determine background color. Setting to \"" temp "\" globally. Please see `erc-nicks-bg-color'.") (custom-set-variables (list 'erc-nicks-bg-color temp)))) (setq erc-nicks--face-table (make-hash-table :test #'equal))) (setf (alist-get "Edit face" erc--nick-popup-alist nil nil #'equal) #'erc-nicks-customize-face) (advice-add 'widget-create-child-and-convert :filter-args #'erc-nicks--redirect-face-widget-link)) ((kill-local-variable 'erc-nicks--face-table) (kill-local-variable 'erc-nicks--bg-mode-value) (kill-local-variable 'erc-nicks--bg-luminance) (kill-local-variable 'erc-nicks--colors-len) (when (fboundp 'erc-button--phantom-users-mode) (erc-button--phantom-users-mode -1)) (remove-function (local 'erc-button--modify-nick-function) #'erc-nicks--highlight) (setf (alist-get "Edit face" erc--nick-popup-alist nil 'remove #'equal) nil)) 'local) (defun erc-nicks-customize-face (nick) "Customize or create persistent face for NICK." (interactive (list (or (car (get-text-property (point) 'erc-data)) (completing-read "nick: " (or erc-channel-users erc-server-users))))) (setq nick (erc-downcase (substring-no-properties nick))) (let* ((net (erc-network)) (key (concat nick (and net (format "%9s" net)))) (old-face (erc-nicks--get-face nick key)) (new-face (intern (format "erc-nicks-%s@%s-face" nick net)))) (unless (eq new-face old-face) (erc-nicks--revive new-face old-face nick net) (set-face-attribute old-face nil :foreground 'unspecified) (set-face-attribute old-face nil :inherit new-face)) (customize-face new-face))) (provide 'erc-nicks) ;;; erc-nicks.el ends here