From 4d46eafaa087f5570ece9d2e5f5d2ba6bc0d824e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 23 Jun 2026 23:04:46 -0400 Subject: feat: expose themeable faces for mode-line and buffer text I added four customizable faces so themes and customize-face can restyle the text wttrin draws itself: wttrin-mode-line-stale (the dimmed stale mode-line emoji), wttrin-staleness-header (the "Last updated:" line), wttrin-instructions (the footer prose), and wttrin-key (the [a]/[g]/[q] chords). The package exposed no faces before and hardcoded one color. The stale-emoji dimming used a literal "gray60". Now it inherits a face, so the color tracks the theme. I changed make-emoji-icon's second argument from a color string to a face symbol applied via :inherit. wttrin-key inherits bold rather than help-key-binding, which is Emacs 28+ while the package supports 24.4. The weather ASCII art stays colored by xterm-color's ANSI faces. Only the package's own text is newly faced. --- README.org | 17 +++++++ tests/test-wttrin--add-buffer-instructions.el | 18 ++++++++ tests/test-wttrin--format-staleness-header.el | 12 +++++ tests/test-wttrin--mode-line-helpers.el | 30 ++++++------- tests/test-wttrin--mode-line-update-display.el | 10 ++--- tests/test-wttrin-faces.el | 26 +++++++++++ wttrin.el | 61 +++++++++++++++++++++----- 7 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 tests/test-wttrin-faces.el diff --git a/README.org b/README.org index 658c4ed..82b36b4 100644 --- a/README.org +++ b/README.org @@ -293,6 +293,23 @@ The default lookup provider is =ipapi.co=. Two alternatives ship with the packag *Note:* IP-based geolocation can be wrong when you are behind a VPN or using a mobile hotspot. The confirmation prompt lets you reject an inaccurate result. If you prefer, set =wttrin-favorite-location= directly to any city string that wttr.in understands. +*** Theming the Faces +The text wttrin draws itself uses named faces, so themes and =M-x customize-face= can restyle it. (The weather art itself is colored by the ANSI codes wttr.in returns, not by these faces.) + +| Face | Styles | Default | +|---------------------------+------------------------------------------------------+-------------------| +| =wttrin-mode-line-stale= | the mode-line emoji when its data has gone stale | inherits =shadow= | +| =wttrin-staleness-header= | the "Last updated: ..." line in the weather buffer | inherits =shadow= | +| =wttrin-instructions= | the key-hint footer prose in the weather buffer | inherits =shadow= | +| =wttrin-key= | the bracketed key chords ([a] [g] [q]) in the footer | inherits =bold= | + +Restyle them in your init file like any other face: + +#+begin_src emacs-lisp + (set-face-attribute 'wttrin-key nil :foreground "deep sky blue" :weight 'bold) + (set-face-attribute 'wttrin-staleness-header nil :slant 'italic) +#+end_src + ** Debugging and Troubleshooting If something isn't working, debug mode logs every fetch, every display update, and every error. diff --git a/tests/test-wttrin--add-buffer-instructions.el b/tests/test-wttrin--add-buffer-instructions.el index 4949c45..0f1c382 100644 --- a/tests/test-wttrin--add-buffer-instructions.el +++ b/tests/test-wttrin--add-buffer-instructions.el @@ -73,6 +73,24 @@ (setq count (1+ count)))) (should (= 2 count)))))) +(ert-deftest test-wttrin--add-buffer-instructions-normal-key-chords-carry-key-face () + "Bracketed key chords are styled with `wttrin-key'." + (with-temp-buffer + (wttrin--add-buffer-instructions) + (goto-char (point-min)) + (search-forward "[a]") + ;; point is just after the closing bracket; the bracket char is part + ;; of the "[a]" segment + (should (eq (get-text-property (1- (point)) 'face) 'wttrin-key)))) + +(ert-deftest test-wttrin--add-buffer-instructions-normal-prose-carries-instructions-face () + "The footer prose is styled with `wttrin-instructions'." + (with-temp-buffer + (wttrin--add-buffer-instructions) + (goto-char (point-min)) + (search-forward "Press:") + (should (eq (get-text-property (1- (point)) 'face) 'wttrin-instructions)))) + ;;; Boundary Cases (ert-deftest test-wttrin--add-buffer-instructions-boundary-point-at-beginning-appends-at-end () diff --git a/tests/test-wttrin--format-staleness-header.el b/tests/test-wttrin--format-staleness-header.el index 5658be0..7a39b9a 100644 --- a/tests/test-wttrin--format-staleness-header.el +++ b/tests/test-wttrin--format-staleness-header.el @@ -50,6 +50,18 @@ (should (string-match-p "just now" header))))) (test-wttrin--format-staleness-header-teardown))) +(ert-deftest test-wttrin--format-staleness-header-normal-carries-face () + "The returned header string is styled with `wttrin-staleness-header'." + (test-wttrin--format-staleness-header-setup) + (unwind-protect + (let ((now 1000000.0)) + (cl-letf (((symbol-function 'float-time) (lambda () now))) + (testutil-wttrin-add-to-cache "Paris" "weather data" 300) + (let ((header (wttrin--format-staleness-header "Paris"))) + (should (eq (get-text-property 0 'face header) + 'wttrin-staleness-header))))) + (test-wttrin--format-staleness-header-teardown))) + ;;; Boundary Cases (ert-deftest test-wttrin--format-staleness-header-boundary-no-cache-returns-nil () diff --git a/tests/test-wttrin--mode-line-helpers.el b/tests/test-wttrin--mode-line-helpers.el index 408711b..21ac167 100644 --- a/tests/test-wttrin--mode-line-helpers.el +++ b/tests/test-wttrin--mode-line-helpers.el @@ -46,40 +46,40 @@ (should (equal (plist-get face :family) "Noto Color Emoji")) (should (equal (plist-get face :height) 1.0)))))) -(ert-deftest test-wttrin--make-emoji-icon-normal-with-foreground () - "Foreground color should be applied when specified." +(ert-deftest test-wttrin--make-emoji-icon-normal-with-face () + "A face should be applied via :inherit when specified." (let ((wttrin-mode-line-emoji-font nil)) - (let ((result (wttrin--make-emoji-icon "☀" "gray60"))) + (let ((result (wttrin--make-emoji-icon "☀" 'wttrin-mode-line-stale))) (let ((face (get-text-property 0 'face result))) - (should (equal (plist-get face :foreground) "gray60")))))) + (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale)))))) -(ert-deftest test-wttrin--make-emoji-icon-normal-with-font-and-foreground () - "Both font and foreground should be applied together." +(ert-deftest test-wttrin--make-emoji-icon-normal-with-font-and-face () + "Both font and face should be applied together." (let ((wttrin-mode-line-emoji-font "Noto Color Emoji")) - (let ((result (wttrin--make-emoji-icon "⏳" "gray60"))) + (let ((result (wttrin--make-emoji-icon "⏳" 'wttrin-mode-line-stale))) (let ((face (get-text-property 0 'face result))) (should (equal (plist-get face :family) "Noto Color Emoji")) - (should (equal (plist-get face :foreground) "gray60")))))) + (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale)))))) ;;; Boundary Cases -(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-foreground-no-color () - "Nil foreground should not add any :foreground property when no font." +(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-face-no-font () + "Nil face with no font should return a plain string." (let ((wttrin-mode-line-emoji-font nil)) (let ((result (wttrin--make-emoji-icon "☀" nil))) - ;; Without font or foreground, should be plain string + ;; Without font or face, should be plain string (should (equal result "☀"))))) -(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-foreground-with-font () - "With font set and nil foreground, the face plist must omit :foreground entirely. -A literal `:foreground nil' entry triggers \"Invalid face attribute\" warnings on +(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-face-with-font () + "With font set and nil face, the face plist must omit :inherit entirely. +A literal `:inherit nil' entry triggers \"Invalid face attribute\" warnings on every redisplay. `plist-member' (not `plist-get') is required: `plist-get' can't distinguish a missing key from a present key bound to nil." (let ((wttrin-mode-line-emoji-font "Noto Color Emoji")) (let* ((result (wttrin--make-emoji-icon "☀" nil)) (face (get-text-property 0 'face result))) (should (equal (plist-get face :family) "Noto Color Emoji")) - (should-not (plist-member face :foreground))))) + (should-not (plist-member face :inherit))))) ;;; -------------------------------------------------------------------------- ;;; wttrin--set-mode-line-string diff --git a/tests/test-wttrin--mode-line-update-display.el b/tests/test-wttrin--mode-line-update-display.el index 0635b5f..721517e 100644 --- a/tests/test-wttrin--mode-line-update-display.el +++ b/tests/test-wttrin--mode-line-update-display.el @@ -170,7 +170,7 @@ (test-wttrin--mode-line-update-display-teardown))) (ert-deftest test-wttrin--mode-line-update-display-stale-emoji-dimmed () - "Stale data dims the emoji with gray foreground." + "Stale data dims the emoji via the `wttrin-mode-line-stale' face." (test-wttrin--mode-line-update-display-setup) (unwind-protect (let ((wttrin-mode-line-refresh-interval 900) @@ -178,13 +178,13 @@ (cl-letf (((symbol-function 'float-time) (lambda () 3000.0))) (setq wttrin--mode-line-cache (cons 1000.0 "Paris: X +61°F Clear")) (wttrin--mode-line-update-display) - ;; The emoji character should have a gray face + ;; The emoji character should inherit the stale face (let* ((str wttrin-mode-line-string) ;; Find the emoji position (after the space) (emoji-pos 1) (face (get-text-property emoji-pos 'face str))) (should face) - (should (equal (plist-get face :foreground) "gray60"))))) + (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale))))) (test-wttrin--mode-line-update-display-teardown))) ;;; Boundary Cases @@ -223,7 +223,7 @@ trigger an emoji re-render so dimming matches the tooltip's staleness state." (wttrin--mode-line-update-display) ;; Emoji should NOT be dimmed (let ((face (get-text-property 1 'face wttrin-mode-line-string))) - (should-not (and face (equal (plist-get face :foreground) "gray60"))))) + (should-not (and face (eq (plist-get face :inherit) 'wttrin-mode-line-stale))))) ;; Time passes: data is now stale (age=2001, threshold=1800) ;; Invoke the tooltip (simulating a hover) — this should trigger a re-render @@ -233,7 +233,7 @@ trigger an emoji re-render so dimming matches the tooltip's staleness state." ;; After hover detected staleness transition, emoji should now be dimmed (let ((face (get-text-property 1 'face wttrin-mode-line-string))) (should face) - (should (equal (plist-get face :foreground) "gray60"))))) + (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale))))) (test-wttrin--mode-line-update-display-teardown))) ;;; -------------------------------------------------------------------------- diff --git a/tests/test-wttrin-faces.el b/tests/test-wttrin-faces.el new file mode 100644 index 0000000..db30002 --- /dev/null +++ b/tests/test-wttrin-faces.el @@ -0,0 +1,26 @@ +;;; test-wttrin-faces.el --- Tests for wttrin themeable faces -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: + +;; Unit tests verifying the package's customizable faces are defined so +;; themes and `customize-face' can target them. + +;;; Code: + +(require 'ert) +(require 'wttrin) + +;;; Normal Cases + +(ert-deftest test-wttrin-faces-normal-all-defined () + "Normal: every package face is defined after loading wttrin." + (dolist (face '(wttrin-mode-line-stale + wttrin-staleness-header + wttrin-instructions + wttrin-key)) + (should (facep face)))) + +(provide 'test-wttrin-faces) +;;; test-wttrin-faces.el ends here diff --git a/wttrin.el b/wttrin.el index 150e934..b1c6562 100644 --- a/wttrin.el +++ b/wttrin.el @@ -57,6 +57,32 @@ :prefix "wttrin-" :group 'comm) +(defface wttrin-mode-line-stale + '((t :inherit shadow)) + "Face for the mode-line weather emoji when its data is stale. +Applied when a scheduled refresh has failed and the cached reading is +older than twice `wttrin-mode-line-refresh-interval'. A color emoji +font may ignore the foreground, in which case the dimming is only +visible on monochrome glyphs." + :group 'wttrin) + +(defface wttrin-staleness-header + '((t :inherit shadow)) + "Face for the \"Last updated: ...\" line in the weather buffer." + :group 'wttrin) + +(defface wttrin-instructions + '((t :inherit shadow)) + "Face for the key-hint footer prose in the weather buffer." + :group 'wttrin) + +(defface wttrin-key + '((t :inherit bold)) + "Face for the bracketed key chords in the weather buffer footer. +`help-key-binding' would be the natural parent, but it only exists in +Emacs 28+, and wttrin supports 24.4, so the default inherits `bold'." + :group 'wttrin) + (defcustom wttrin-font-name "Liberation Mono" "Preferred monospaced font name for weather display." :group 'wttrin @@ -628,9 +654,19 @@ Returns processed string ready for display." (buffer-string)))) (defun wttrin--add-buffer-instructions () - "Add user instructions at bottom of current buffer." + "Add the key-hint footer at the bottom of the current buffer. +Bracketed key chords use `wttrin-key'; the surrounding prose uses +`wttrin-instructions'." (goto-char (point-max)) - (insert "\n\nPress: [a] for another location [g] to refresh [q] to quit")) + (insert "\n\n") + (dolist (segment '(("Press: " . wttrin-instructions) + ("[a]" . wttrin-key) + (" for another location " . wttrin-instructions) + ("[g]" . wttrin-key) + (" to refresh " . wttrin-instructions) + ("[q]" . wttrin-key) + (" to quit" . wttrin-instructions))) + (insert (propertize (car segment) 'face (cdr segment))))) (defun wttrin--format-staleness-header (location) "Return a staleness header string for LOCATION, or nil if no cache entry. @@ -643,7 +679,8 @@ Looks up the cache timestamp for LOCATION and formats a line like (age (- (float-time) timestamp)) (time-str (format-time-string "%l:%M %p" (seconds-to-time timestamp))) (age-str (wttrin--format-age age))) - (format "Last updated: %s (%s)" (string-trim time-str) age-str))))) + (propertize (format "Last updated: %s (%s)" (string-trim time-str) age-str) + 'face 'wttrin-staleness-header))))) (defun wttrin--display-weather (location-name raw-string &optional error-msg) "Display weather data RAW-STRING for LOCATION-NAME in weather buffer. @@ -809,19 +846,19 @@ user's original casing so tooltips display what the user expects." (concat location (substring response (match-beginning 0))) response)) -(defun wttrin--make-emoji-icon (emoji &optional foreground) - "Create EMOJI string with optional font face and FOREGROUND color. -Uses `wttrin-mode-line-emoji-font' when configured. -Omits `:foreground' from the face plist when FOREGROUND is nil — a literal -`:foreground nil' entry triggers \"Invalid face attribute\" warnings on every +(defun wttrin--make-emoji-icon (emoji &optional face) + "Create EMOJI string, optionally styled with FACE and the emoji font. +Uses `wttrin-mode-line-emoji-font' when configured. FACE, when non-nil, +is applied via `:inherit'. Omitting it avoids a literal `:inherit nil' +entry, which triggers \"Invalid face attribute\" warnings on every redisplay." (if wttrin-mode-line-emoji-font (propertize emoji 'face `(:family ,wttrin-mode-line-emoji-font :height 1.0 - ,@(when foreground (list :foreground foreground)))) - (if foreground - (propertize emoji 'face (list :foreground foreground)) + ,@(when face (list :inherit face)))) + (if face + (propertize emoji 'face (list :inherit face)) emoji))) (defun wttrin--set-mode-line-string (icon tooltip) @@ -947,7 +984,7 @@ shows staleness info in tooltip." emoji stale-p) (setq wttrin--mode-line-rendered-stale stale-p) (setq wttrin-mode-line-string - (propertize (concat " " (wttrin--make-emoji-icon emoji (when stale-p "gray60"))) + (propertize (concat " " (wttrin--make-emoji-icon emoji (when stale-p 'wttrin-mode-line-stale))) 'help-echo #'wttrin--mode-line-tooltip 'mouse-face 'mode-line-highlight 'local-map wttrin--mode-line-map))))) -- cgit v1.2.3