aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-05 10:06:20 -0500
committerCraig Jennings <c@cjennings.net>2026-05-05 10:06:20 -0500
commit541586e3ff0606afcf0581210a62c95f5ee65c71 (patch)
treed67d1f08323879e72a10b42f945edff9004675a8
parentec00dbe1c03527ec46a0faa20545a7acd382da84 (diff)
downloademacs-wttrin-541586e3ff0606afcf0581210a62c95f5ee65c71.tar.gz
emacs-wttrin-541586e3ff0606afcf0581210a62c95f5ee65c71.zip
feat: support t for auto-detect via geolocation in wttrin-favorite-location
Completes the three-mode configuration the favorite-location feature was always meant to have: - nil — disabled (default; unchanged) - a string — explicit location (unchanged) - t — auto-detect via IP geolocation (NEW) When the user sets `wttrin-favorite-location` to t, wttrin runs the geolocation lookup once on first use and caches the result for the session. Subsequent reads return the cached string. The lookup happens in the background via the existing `wttrin-geolocation-detect`, so Emacs startup is never blocked. I added two private state vars (`wttrin--resolved-favorite-location`, `wttrin--favorite-location-pending`) and a resolver `wttrin--resolve-favorite-location` that maps the three modes onto a returned string or nil. When t is set and the cache is empty, the resolver kicks off the lookup and returns nil for that call — the next consumer tick after the callback completes gets the cached string. The pending flag prevents duplicate concurrent lookups when several consumers ask during the resolution window. Five consumer call sites now go through the resolver instead of reading `wttrin-favorite-location` directly: `wttrin--mode-line-fetch-weather`, `wttrin-mode-line-click`, `wttrin-mode-line-force-refresh`, `wttrin--buffer-cache-refresh`, and `wttrin--mode-line-start`. Two display sites (the placeholder and error tooltips) use a new `wttrin--favorite-location-display-name` helper that returns "current location" while a t-mode lookup is pending, instead of showing the literal `t` to the user. Tests cover the resolver across all three modes, including the pending state, the duplicate-suppression behavior, and detection-failure retry. Existing consumer tests stay green because the resolver returns the bound string unchanged when the variable is a string. One care: the test file requires wttrin-geolocation up front so cl-letf mocks of `wttrin-geolocation-detect` aren't undone by the resolver's lazy require — without that, the first run hit ipapi.co for real. README documents the new mode under "Setting the Favorite Location from IP Geolocation".
-rw-r--r--README.org12
-rw-r--r--tests/test-wttrin--resolve-favorite-location.el154
-rw-r--r--wttrin.el137
3 files changed, 270 insertions, 33 deletions
diff --git a/README.org b/README.org
index e3e5c98..b6a824d 100644
--- a/README.org
+++ b/README.org
@@ -247,7 +247,9 @@ If a refresh fails, the emoji dims to gray and the tooltip tells you what went w
*Note:* If the weather emoji appears as a monochrome symbol instead of a color icon, try setting `wttrin-mode-line-emoji-font` to match a color emoji font installed on your system. Use `M-x fc-list` or check your system fonts to see what's available.
*** Setting the Favorite Location from IP Geolocation
-If you don't want to type your city by hand, wttrin can detect it for you:
+If you don't want to type your city by hand, wttrin can detect it for you. Two ways:
+
+**Manual detection with confirmation:**
#+begin_src emacs-lisp
M-x wttrin-set-location-from-geolocation
@@ -255,6 +257,14 @@ If you don't want to type your city by hand, wttrin can detect it for you:
This looks up your city via IP geolocation, shows the detected location, and sets =wttrin-favorite-location= after you confirm. To make the setting persist across Emacs sessions, run =M-x customize-save-variable RET wttrin-favorite-location RET=, or add =(setq wttrin-favorite-location "Your City, State")= to your init file.
+**Automatic detection on first use:**
+
+#+begin_src emacs-lisp
+ (setq wttrin-favorite-location t)
+#+end_src
+
+When set to =t=, wttrin runs the geolocation lookup once on first use (when the mode-line first fetches, when the buffer cache first refreshes, etc.) and caches the result for the rest of the session. The lookup happens in the background, so Emacs startup isn't blocked. The first display tick shows a placeholder until the lookup returns; everything proceeds normally after that.
+
The default lookup provider is =ipapi.co=. Two alternatives ship with the package, both free and key-less:
#+begin_src emacs-lisp
diff --git a/tests/test-wttrin--resolve-favorite-location.el b/tests/test-wttrin--resolve-favorite-location.el
new file mode 100644
index 0000000..506c226
--- /dev/null
+++ b/tests/test-wttrin--resolve-favorite-location.el
@@ -0,0 +1,154 @@
+;;; test-wttrin--resolve-favorite-location.el --- Tests for wttrin--resolve-favorite-location -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests for `wttrin--resolve-favorite-location' and its helpers.
+;; The resolver maps the three modes of `wttrin-favorite-location'
+;; (nil / string / t) onto a returned location string, kicking off an
+;; async geolocation lookup the first time t is seen.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;; Load wttrin-geolocation up front so cl-letf mocks of
+;; `wttrin-geolocation-detect' aren't undone by the resolver's lazy
+;; (require 'wttrin-geolocation) — which would otherwise re-evaluate
+;; the defun and overwrite the mocked symbol-function.
+(require 'wttrin-geolocation
+ (expand-file-name "wttrin-geolocation.el"
+ (file-name-directory (locate-library "wttrin"))))
+
+;;; Helpers
+
+(defmacro test-wttrin--resolve-with-geolocation-mock (returned-location &rest body)
+ "Run BODY with `wttrin-geolocation-detect' mocked to call back with
+RETURNED-LOCATION synchronously. Resets resolver caches before BODY."
+ (declare (indent 1))
+ `(let ((wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback) (funcall callback ,returned-location))))
+ ,@body)))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--resolve-favorite-location-normal-nil-returns-nil ()
+ "Disabled mode (nil) resolves to nil."
+ (let ((wttrin-favorite-location nil)
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending nil))
+ (should-not (wttrin--resolve-favorite-location))))
+
+(ert-deftest test-wttrin--resolve-favorite-location-normal-string-returns-itself ()
+ "Explicit string resolves to itself."
+ (let ((wttrin-favorite-location "Berkeley, CA")
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending nil))
+ (should (string= "Berkeley, CA" (wttrin--resolve-favorite-location)))))
+
+(ert-deftest test-wttrin--resolve-favorite-location-normal-t-with-cache-returns-cached ()
+ "When t and cache populated, resolver returns cached value without re-fetching."
+ (let ((wttrin-favorite-location t)
+ (wttrin--resolved-favorite-location "Cached, Place")
+ (wttrin--favorite-location-pending nil)
+ (detect-calls 0))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (_callback) (cl-incf detect-calls))))
+ (should (string= "Cached, Place" (wttrin--resolve-favorite-location)))
+ (should (= 0 detect-calls)))))
+
+(ert-deftest test-wttrin--resolve-favorite-location-normal-t-detection-success-caches-result ()
+ "t-mode detect callback populates the resolver cache."
+ (let ((wttrin-favorite-location t))
+ (test-wttrin--resolve-with-geolocation-mock "Detected, City"
+ (wttrin--resolve-favorite-location)
+ (should (string= "Detected, City" wttrin--resolved-favorite-location))
+ (should-not wttrin--favorite-location-pending))))
+
+(ert-deftest test-wttrin--resolve-favorite-location-normal-second-call-returns-cached-from-first ()
+ "After detection completes, the second call returns the cached string."
+ (let ((wttrin-favorite-location t))
+ (test-wttrin--resolve-with-geolocation-mock "Detected, City"
+ (wttrin--resolve-favorite-location)
+ (should (string= "Detected, City" (wttrin--resolve-favorite-location))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin--resolve-favorite-location-boundary-t-without-cache-returns-nil ()
+ "First call with t and empty cache returns nil while detection runs."
+ (let ((wttrin-favorite-location t)
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (_callback)
+ ;; Simulate an async lookup that has NOT called back yet.
+ (setq wttrin--favorite-location-pending t))))
+ (should-not (wttrin--resolve-favorite-location)))))
+
+(ert-deftest test-wttrin--resolve-favorite-location-boundary-pending-suppresses-duplicate-detect ()
+ "When a lookup is already pending, the resolver does not start another."
+ (let ((wttrin-favorite-location t)
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending t)
+ (detect-calls 0))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (_callback) (cl-incf detect-calls))))
+ (wttrin--resolve-favorite-location)
+ (should (= 0 detect-calls)))))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin--resolve-favorite-location-error-detection-failure-leaves-cache-empty ()
+ "When the detect callback returns nil, the cache stays empty and pending clears."
+ (let ((wttrin-favorite-location t))
+ (test-wttrin--resolve-with-geolocation-mock nil
+ (wttrin--resolve-favorite-location)
+ (should-not wttrin--resolved-favorite-location)
+ (should-not wttrin--favorite-location-pending))))
+
+(ert-deftest test-wttrin--resolve-favorite-location-error-detection-failure-allows-retry ()
+ "After a failed detection, a subsequent resolve call kicks off a new lookup."
+ (let ((wttrin-favorite-location t)
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending nil)
+ (detect-calls 0))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback)
+ (cl-incf detect-calls)
+ (funcall callback nil))))
+ (wttrin--resolve-favorite-location)
+ (wttrin--resolve-favorite-location)
+ (should (= 2 detect-calls)))))
+
+;;; Display Name Helper
+
+(ert-deftest test-wttrin--favorite-location-display-name-resolved-string-returns-string ()
+ "Display name returns the resolved string when available."
+ (let ((wttrin-favorite-location "Tokyo, JP")
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending nil))
+ (should (string= "Tokyo, JP" (wttrin--favorite-location-display-name)))))
+
+(ert-deftest test-wttrin--favorite-location-display-name-t-pending-returns-current-location-label ()
+ "Display name returns \"current location\" when t-mode lookup is pending."
+ (let ((wttrin-favorite-location t)
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending t))
+ (should (string= "current location"
+ (wttrin--favorite-location-display-name)))))
+
+(ert-deftest test-wttrin--favorite-location-display-name-nil-returns-nil ()
+ "Display name returns nil when feature is disabled."
+ (let ((wttrin-favorite-location nil)
+ (wttrin--resolved-favorite-location nil)
+ (wttrin--favorite-location-pending nil))
+ (should-not (wttrin--favorite-location-display-name))))
+
+(provide 'test-wttrin--resolve-favorite-location)
+;;; test-wttrin--resolve-favorite-location.el ends here
diff --git a/wttrin.el b/wttrin.el
index 7e67af3..2fdf158 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -144,14 +144,76 @@ of entries, providing a reasonable buffer before the next cleanup.")
(defcustom wttrin-favorite-location nil
"Favorite location to display weather for.
-When nil, favorite location features are disabled.
-Set to a location string (e.g., \"New Orleans, LA\") to enable mode-line
-weather display and other location-based features.
-The weather icon and tooltip will update automatically in the background."
+
+Three modes:
+- nil Favorite-location features are disabled (default).
+- a string Use the string as the location, e.g. \"Berkeley, CA\".
+- t Auto-detect via IP geolocation. wttrin runs the lookup
+ once on first use and caches the result for the session.
+ To pick a specific provider, customize
+ `wttrin-geolocation-provider'.
+
+When set, the weather icon and tooltip update automatically in the
+background. IP-based auto-detection can be inaccurate behind a VPN
+or a mobile hotspot — use a string if you need accuracy."
:group 'wttrin
:type '(choice (const :tag "Disabled" nil)
+ (const :tag "Auto-detect via geolocation" t)
(string :tag "Location")))
+(defvar wttrin--resolved-favorite-location nil
+ "Cached geolocation result for `wttrin-favorite-location' = t.
+Holds the resolved \"City, Region\" string so subsequent reads
+do not re-fetch. Reset implicitly when the Emacs session ends.")
+
+(defvar wttrin--favorite-location-pending nil
+ "Non-nil while a geolocation lookup for the favorite is in flight.
+Prevents duplicate concurrent lookups when several consumers ask
+during the resolution window.")
+
+(defun wttrin--resolve-favorite-location ()
+ "Return the favorite location as a string, or nil if unavailable.
+Resolves `wttrin-favorite-location' across the three modes:
+- nil -> nil (disabled)
+- a string -> the string as-is
+- t -> the cached geolocation result. When the cache is empty
+ and no lookup is in flight, kicks off an async detect
+ and returns nil for this call. The next call after the
+ lookup completes returns the resolved string."
+ (cond
+ ((null wttrin-favorite-location) nil)
+ ((stringp wttrin-favorite-location) wttrin-favorite-location)
+ ((eq wttrin-favorite-location t)
+ (or wttrin--resolved-favorite-location
+ (progn
+ (wttrin--start-favorite-location-detect)
+ nil)))))
+
+(defun wttrin--start-favorite-location-detect ()
+ "Kick off an async geolocation lookup if one is not already pending.
+On success the resolved string is stored in
+`wttrin--resolved-favorite-location'. Failures (network error, parse
+error) leave the cache empty and clear the pending flag, so the next
+call retries."
+ (unless wttrin--favorite-location-pending
+ (setq wttrin--favorite-location-pending t)
+ (require 'wttrin-geolocation)
+ (wttrin-geolocation-detect
+ (lambda (location)
+ (setq wttrin--favorite-location-pending nil)
+ (when location
+ (setq wttrin--resolved-favorite-location location)
+ (wttrin--debug-log
+ "Resolved favorite-location via geolocation: %s" location))))))
+
+(defun wttrin--favorite-location-display-name ()
+ "Return a human-readable name for the favorite location.
+Returns the resolved string when available; otherwise returns
+\"current location\" if auto-detect is configured but pending,
+or nil if the favorite is disabled."
+ (or (wttrin--resolve-favorite-location)
+ (when (eq wttrin-favorite-location t) "current location")))
+
(defcustom wttrin-mode-line-refresh-interval 3600
"Interval in seconds to refresh mode-line weather data.
Default is 3600 seconds (1 hour). The wttr.in service updates its
@@ -675,29 +737,33 @@ e.g., \"Paris: ☀️ +61°F Clear\"."
"Update placeholder to show fetch error state.
Keeps the hourglass icon but updates tooltip to explain the failure
and indicate when retry will occur."
- (let ((retry-minutes (ceiling (/ wttrin-mode-line-refresh-interval 60.0))))
+ (let ((retry-minutes (ceiling (/ wttrin-mode-line-refresh-interval 60.0)))
+ (label (or (wttrin--favorite-location-display-name) "favorite")))
(wttrin--set-mode-line-string
(wttrin--make-emoji-icon "⏳")
(format "Weather fetch failed for %s — will retry in %d minutes"
- wttrin-favorite-location retry-minutes))))
+ label retry-minutes))))
(defun wttrin--mode-line-fetch-weather ()
"Fetch weather for favorite location and update mode-line display.
Uses wttr.in custom format for concise weather with emoji.
On success, writes to `wttrin--mode-line-cache' and updates display.
On failure with existing cache, shows stale data.
-On failure with no cache, shows error placeholder."
+On failure with no cache, shows error placeholder.
+When `wttrin-favorite-location' is t and geolocation has not yet
+resolved, this call is a no-op; the next tick after resolution
+proceeds normally."
(wttrin--debug-log "mode-line-fetch: Starting fetch for %s" wttrin-favorite-location)
- (if (not wttrin-favorite-location)
- (wttrin--debug-log "mode-line-fetch: No favorite location set, skipping")
- (let* ((location wttrin-favorite-location)
- ;; wttr.in format codes: %l=location %c=emoji %t=temp %C=conditions
- (format-params (if wttrin-unit-system
- (concat "?" wttrin-unit-system "&format=%l:+%c+%t+%C")
- "?format=%l:+%c+%t+%C"))
- (url (concat "https://wttr.in/"
- (url-hexify-string location)
- format-params)))
+ (let ((location (wttrin--resolve-favorite-location)))
+ (if (not location)
+ (wttrin--debug-log "mode-line-fetch: No favorite location available, skipping")
+ (let* (;; wttr.in format codes: %l=location %c=emoji %t=temp %C=conditions
+ (format-params (if wttrin-unit-system
+ (concat "?" wttrin-unit-system "&format=%l:+%c+%t+%C")
+ "?format=%l:+%c+%t+%C"))
+ (url (concat "https://wttr.in/"
+ (url-hexify-string location)
+ format-params)))
(wttrin--debug-log "mode-line-fetch: URL = %s" url)
(wttrin--fetch-url
url
@@ -718,7 +784,7 @@ On failure with no cache, shows error placeholder."
;; Have stale cache — update display to show staleness
(wttrin--mode-line-update-display)
;; No cache at all — show error placeholder
- (wttrin--mode-line-update-placeholder-error))))))))
+ (wttrin--mode-line-update-placeholder-error)))))))))
(defun wttrin--mode-line-extract-emoji (weather-string)
"Extract the emoji character from WEATHER-STRING.
@@ -783,14 +849,15 @@ shows staleness info in tooltip."
"Handle left-click on mode-line weather widget.
Check cache, refresh if needed, then open weather buffer."
(interactive)
- (when wttrin-favorite-location
- (wttrin wttrin-favorite-location)))
+ (let ((location (wttrin--resolve-favorite-location)))
+ (when location
+ (wttrin location))))
(defun wttrin-mode-line-force-refresh ()
"Handle right-click on mode-line weather widget.
Force-refresh cache and update tooltip without opening buffer."
(interactive)
- (when wttrin-favorite-location
+ (when (wttrin--resolve-favorite-location)
(let ((wttrin--force-refresh t))
(wttrin--mode-line-fetch-weather))))
@@ -798,7 +865,8 @@ Force-refresh cache and update tooltip without opening buffer."
"Set a placeholder icon in the mode-line while waiting for weather data."
(wttrin--set-mode-line-string
(wttrin--make-emoji-icon "⏳")
- (format "Fetching weather for %s..." wttrin-favorite-location)))
+ (format "Fetching weather for %s..."
+ (or (wttrin--favorite-location-display-name) "favorite"))))
(defvar wttrin--buffer-refresh-timer nil
"Timer object for proactive buffer cache refresh.")
@@ -807,16 +875,17 @@ Force-refresh cache and update tooltip without opening buffer."
"Proactively refresh the buffer cache for `wttrin-favorite-location'.
Fetches fresh weather data and updates the buffer cache entry without
displaying anything. This keeps buffer data fresh for when the user
-opens the weather buffer."
- (when wttrin-favorite-location
- (let* ((location wttrin-favorite-location)
- (cache-key (wttrin--make-cache-key location)))
- (wttrin-fetch-raw-string
- location
- (lambda (fresh-data &optional _error-msg)
- (when fresh-data
- (wttrin--cleanup-cache-if-needed)
- (puthash cache-key (cons (float-time) fresh-data) wttrin--cache)))))))
+opens the weather buffer. When the favorite is set to t and
+geolocation has not yet resolved, this call is a no-op."
+ (let ((location (wttrin--resolve-favorite-location)))
+ (when location
+ (let ((cache-key (wttrin--make-cache-key location)))
+ (wttrin-fetch-raw-string
+ location
+ (lambda (fresh-data &optional _error-msg)
+ (when fresh-data
+ (wttrin--cleanup-cache-if-needed)
+ (puthash cache-key (cons (float-time) fresh-data) wttrin--cache))))))))
(defun wttrin--mode-line-start ()
"Start mode-line weather display and refresh timer."
@@ -824,6 +893,10 @@ opens the weather buffer."
wttrin-favorite-location
wttrin-mode-line-refresh-interval)
(when wttrin-favorite-location
+ ;; Trigger geolocation resolution in the background if needed; the
+ ;; placeholder + scheduled fetch will pick up the resolved string
+ ;; on the next tick.
+ (wttrin--resolve-favorite-location)
(wttrin--mode-line-set-placeholder)
;; Delay first fetch — network/daemon may not be ready at startup
(run-at-time wttrin-mode-line-startup-delay nil #'wttrin--mode-line-fetch-weather)