aboutsummaryrefslogtreecommitdiff
path: root/wttrin.el
diff options
context:
space:
mode:
Diffstat (limited to 'wttrin.el')
-rw-r--r--wttrin.el502
1 files changed, 415 insertions, 87 deletions
diff --git a/wttrin.el b/wttrin.el
index ef83b45..a524d84 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -35,6 +35,7 @@
(require 'face-remap)
(require 'subr-x) ; string-trim
+(require 'json) ; forecast j1 parsing
(require 'url)
;; Declare xterm-color functions (loaded on-demand)
@@ -237,13 +238,28 @@ coordinates. For example:
(\"Home\" . \"41.37,-71.83\"))
A bare string S used anywhere a location is expected is shorthand for
-\(S . S) — name and query the same. Persisted across sessions via
-`savehist-mode'; add entries interactively with \\[wttrin-save-location] or the
-`d' key in a weather buffer, or set this in your init."
+\(S . S) — name and query the same. Entries added interactively with
+\\[wttrin-save-location] or the `d' key in a weather buffer are persisted in
+`wttrin-state-file'; entries set here in your init are re-read each start."
:group 'wttrin
:type '(alist :key-type (string :tag "Name")
:value-type (string :tag "Query")))
+(defvar wttrin--favorite-override nil
+ "Runtime favorite set by `d'/`wttrin-make-default' and geolocation commands.
+Persisted across sessions in `wttrin-state-file'. When non-nil it shadows
+the configured `wttrin-favorite-location', so a favorite chosen at runtime
+survives a restart even when the user also sets `wttrin-favorite-location'
+in their init. Carries the same tri-state shape as that option: nil (no
+override), a string, or t (auto-detect).")
+
+(defvar wttrin--saved-locations-runtime nil
+ "Saved-location entries added at runtime via `d' or `wttrin-save-location'.
+Persisted across sessions in `wttrin-state-file'. Overlaid on the configured
+`wttrin-saved-locations' by `wttrin--saved-locations', runtime winning on a name
+collision, so directory edits survive a restart without being clobbered by an
+init that also sets `wttrin-saved-locations'. An alist of (NAME . QUERY).")
+
(defvar wttrin--resolved-favorite-location nil
"Cached geolocation result for `wttrin-favorite-location' = t.
Holds the resolved \"City, Region\" string so subsequent reads
@@ -254,9 +270,19 @@ do not re-fetch. Reset implicitly when the Emacs session ends.")
Prevents duplicate concurrent lookups when several consumers ask
during the resolution window.")
+(defun wttrin--favorite-location ()
+ "Return the effective favorite: the runtime override, else the configured one.
+`wttrin--favorite-override' (set at runtime by `d' and the geolocation
+commands, persisted in `wttrin-state-file') wins over the
+`wttrin-favorite-location' defcustom, so a runtime choice is not clobbered
+by an init that also sets the option. Carries the option's tri-state
+shape: nil, a string, or t."
+ (or wttrin--favorite-override wttrin-favorite-location))
+
(defun wttrin--resolve-favorite-location ()
"Return the favorite location's query string, or nil if unavailable.
-Resolves `wttrin-favorite-location' across the three modes:
+Resolves the effective favorite (`wttrin--favorite-location') across the three
+modes:
- nil -> nil (disabled)
- a string -> its saved-locations query when the string is a saved name,
otherwise the string as-is (the query for a plain location)
@@ -264,15 +290,16 @@ Resolves `wttrin-favorite-location' across the three modes:
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--resolve-location-query wttrin-favorite-location))
- ((eq wttrin-favorite-location t)
- (or wttrin--resolved-favorite-location
- (progn
- (wttrin--start-favorite-location-detect)
- nil)))))
+ (let ((fav (wttrin--favorite-location)))
+ (cond
+ ((null fav) nil)
+ ((stringp fav)
+ (wttrin--resolve-location-query fav))
+ ((eq fav 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.
@@ -296,12 +323,14 @@ call retries."
"Return a human-readable name for the favorite location.
For a string favorite this is the string itself (a saved-location name shows as
its name, not its resolved query). For t it is the resolved geolocation place,
-or \"current location\" while a lookup is pending. Nil when disabled."
- (cond
- ((stringp wttrin-favorite-location) wttrin-favorite-location)
- ((eq wttrin-favorite-location t)
- (or wttrin--resolved-favorite-location "current location"))
- (t nil)))
+or \"current location\" while a lookup is pending. Nil when disabled.
+Reads the effective favorite (`wttrin--favorite-location')."
+ (let ((fav (wttrin--favorite-location)))
+ (cond
+ ((stringp fav) fav)
+ ((eq fav t)
+ (or wttrin--resolved-favorite-location "current location"))
+ (t nil))))
(defcustom wttrin-mode-line-refresh-interval 3600
"Interval in seconds to refresh mode-line weather data.
@@ -311,6 +340,17 @@ wastes their bandwidth. Be kind to the free service."
:group 'wttrin
:type 'integer)
+(defcustom wttrin-mode-line-tooltip-forecast-days 0
+ "Days of forecast to append to the mode-line weather tooltip.
+0 (the default) keeps the tooltip current-conditions-only. A positive
+value appends one line per day: the remainder of today, tomorrow, and
+the day after, labeled Today / Tomorrow / weekday. wttr.in's JSON feed
+carries at most three days, so larger values are capped at what the
+feed returns. When non-zero, the mode-line refresh performs a second
+fetch (?format=j1) for the forecast data."
+ :group 'wttrin
+ :type 'integer)
+
(defcustom wttrin-mode-line-startup-delay 3
"Seconds to delay initial mode-line weather fetch after Emacs starts.
This allows network stack and daemon initialization to complete before
@@ -405,6 +445,14 @@ Set this to t BEFORE loading wttrin, typically in your init file:
When non-nil, car is the `float-time' when data was fetched,
and cdr is the weather string from the API.")
+(defvar wttrin--mode-line-forecast-cache nil
+ "Cached forecast data as a (timestamp . DAY-LIST) cons cell, or nil.
+When non-nil, car is the `float-time' of the fetch and cdr is the parsed
+day list from `wttrin--forecast-parse'. The raw day list is cached (not
+rendered text) so `wttrin--mode-line-tooltip' formats it at hover time
+against the current `wttrin-mode-line-tooltip-forecast-days' and
+`wttrin-unit-system' values.")
+
(defvar wttrin--mode-line-rendered-stale nil
"Whether the mode-line emoji is currently rendered as stale (dimmed).")
@@ -599,21 +647,147 @@ Persisted across sessions via `savehist-mode'.")
(defvar savehist-additional-variables)
(defun wttrin--savehist-register ()
- "Ensure wttrin's persisted variables are saved by savehist.
-Registers `wttrin--location-history', `wttrin-favorite-location', and
-`wttrin-saved-locations' so they survive across restarts without the Emacs
-custom-variable mechanism.
+ "Register the search history with savehist.
+Only `wttrin--location-history' rides savehist: it is scrub-tolerant
+throwaway data, which is what savehist is for. The runtime favorite and
+runtime directory are deliberately NOT registered — savehist rewrites its
+whole file on every save keeping only currently-registered variables, so any
+Emacs process that saves savehist without wttrin loaded (a deferred-loading
+session where wttrin was never opened, a batch tool) would silently delete
+them. Those live in `wttrin-state-file' instead, which only wttrin writes.
Run both at load and on `savehist-save-hook', so the registration survives a
user `setq' of `savehist-additional-variables' (a common config pattern) that
-would otherwise drop the entries before they could be saved."
- (add-to-list 'savehist-additional-variables 'wttrin--location-history)
- (add-to-list 'savehist-additional-variables 'wttrin-favorite-location)
- (add-to-list 'savehist-additional-variables 'wttrin-saved-locations))
+would otherwise drop the entry before it could be saved."
+ (add-to-list 'savehist-additional-variables 'wttrin--location-history))
+
+;;;###autoload (with-eval-after-load 'savehist (add-to-list 'savehist-additional-variables 'wttrin--location-history))
(with-eval-after-load 'savehist
(wttrin--savehist-register)
(add-hook 'savehist-save-hook #'wttrin--savehist-register))
+(defun wttrin--state-file-set (symbol value)
+ "Set SYMBOL (`wttrin-state-file') to VALUE and restore state from it.
+The `:set' handler for the option. The load-time restore below runs against
+whatever path the option holds when wttrin loads, so a path customized
+afterward — the use-package `:config' shape, `(setopt wttrin-state-file ...)'
+after the require — would be written by the location commands but never
+read back on the next start. Re-running `wttrin--state-load' here closes
+that gap for `setopt', `customize-set-variable', and Customize. When the
+restore changes the effective favorite and the mode-line mode is on, the
+mode-line is refreshed so it shows the restored city rather than the
+configured one. At definition time the loader is not yet defined, so the
+`fboundp' guard makes that first call a plain `set-default'; the load-time
+restore covers the initial read."
+ (let ((before (and (fboundp 'wttrin--favorite-location)
+ (wttrin--favorite-location))))
+ (set-default symbol value)
+ (when (fboundp 'wttrin--state-load)
+ (wttrin--state-load)
+ (when (and (not (equal before (wttrin--favorite-location)))
+ (bound-and-true-p wttrin-mode-line-mode))
+ (wttrin--mode-line-refresh-now)))))
+
+(defcustom wttrin-state-file (locate-user-emacs-file "wttrin-state.el")
+ "File persisting wttrin's runtime state across sessions.
+Holds the runtime favorite (`wttrin--favorite-override') and the runtime
+saved-locations directory (`wttrin--saved-locations-runtime') — the values
+written by `d'/`wttrin-make-default', `wttrin-save-location', and the
+geolocation commands. A dedicated file rather than savehist because savehist
+rewrites its whole file keeping only the variables registered in the current
+process: any Emacs session that saved savehist without wttrin loaded would
+silently delete the persisted favorite. Only wttrin writes this file.
+The state restores at load time from this path. Set the option with
+`setopt', `customize-set-variable', or Customize (use-package `:custom' or
+`:config' both work): changing it after wttrin has loaded re-reads the state
+from the new path. A plain `setq' after load skips that restore and takes
+effect only from the next one (`savehist-mode-hook' or restart)."
+ :group 'wttrin
+ :type 'file
+ :set #'wttrin--state-file-set)
+
+(defun wttrin--state-save ()
+ "Write the runtime favorite and directory to `wttrin-state-file'.
+Writes atomically (temp file + rename) so a crash mid-write cannot leave a
+truncated file, always as UTF-8 so the file round-trips regardless of the
+session's default coding system (the same reason savehist pins
+`savehist-coding-system'). Never signals: persistence failure must not
+break the location command that triggered it — it messages and moves on."
+ (condition-case err
+ (let* ((file (expand-file-name wttrin-state-file))
+ (dir (file-name-directory file))
+ (tmp (make-temp-name (concat file ".tmp")))
+ (coding-system-for-write 'utf-8-emacs)
+ (print-length nil)
+ (print-level nil))
+ (unless (file-directory-p dir) (make-directory dir t))
+ (unwind-protect
+ (progn
+ (with-temp-buffer
+ (insert ";; wttrin runtime state -- managed by wttrin;"
+ " do not edit by hand.\n")
+ (prin1 (list :version 1
+ :favorite-override wttrin--favorite-override
+ :saved-locations wttrin--saved-locations-runtime)
+ (current-buffer))
+ (insert "\n")
+ (write-region (point-min) (point-max) tmp nil 'silent))
+ (rename-file tmp file t)
+ (setq tmp nil))
+ ;; Any failure above leaves the temp file behind; remove it. On
+ ;; success TMP is nil and there is nothing to clean.
+ (when (and tmp (file-exists-p tmp))
+ (delete-file tmp))))
+ (error (message "wttrin: could not save state to %s: %s"
+ wttrin-state-file (error-message-string err)))))
+
+(defun wttrin--state-read ()
+ "Read `wttrin-state-file' and return its state plist, or nil.
+Returns nil when the file is absent, unreadable, or does not contain a
+versioned plist. Reads with `read', never `eval', so the state file cannot
+execute code, and as UTF-8, matching how `wttrin--state-save' writes it.
+Never signals — a broken state file must not break load."
+ (when (file-readable-p wttrin-state-file)
+ (condition-case err
+ (with-temp-buffer
+ (let ((coding-system-for-read 'utf-8-emacs))
+ (insert-file-contents wttrin-state-file))
+ (let ((data (read (current-buffer))))
+ (if (and (listp data) (integerp (plist-get data :version)))
+ data
+ (message "wttrin: ignoring malformed state file %s"
+ wttrin-state-file)
+ nil)))
+ (error (message "wttrin: could not read state file %s: %s"
+ wttrin-state-file (error-message-string err))
+ nil))))
+
+(defun wttrin--state-load ()
+ "Restore the runtime favorite and directory from `wttrin-state-file'.
+An existing state file is authoritative: its values replace whatever is in
+memory, which is what defeats a later savehist restore of stale legacy
+entries (this function also runs on `savehist-mode-hook'). When the file
+does not exist but either variable is non-nil — savehist restored legacy
+values from a pre-state-file wttrin earlier in init — those values are
+adopted and written, migrating existing users with zero steps. A corrupt
+file is left in place and ignored: vars keep their current values and no
+adoption write happens over it."
+ (let ((data (wttrin--state-read)))
+ (cond
+ (data
+ (setq wttrin--favorite-override (plist-get data :favorite-override))
+ (setq wttrin--saved-locations-runtime (plist-get data :saved-locations)))
+ ((and (not (file-exists-p wttrin-state-file))
+ (or wttrin--favorite-override wttrin--saved-locations-runtime))
+ (wttrin--state-save)))))
+
+;; Restore persisted state at load — but not in batch (CI, test runners,
+;; scripts), which stays hermetic by design. The savehist-mode-hook entry
+;; re-asserts the state file after savehist restores, in case a savehist file
+;; still carrying legacy pre-state-file entries loads later in init.
+(unless noninteractive (wttrin--state-load))
+(add-hook 'savehist-mode-hook #'wttrin--state-load)
+
(defconst wttrin--geolocation-sentinel "Current location (detect)"
"Picker candidate that triggers geolocation detection.
Selecting it routes through `wttrin--query-selection' to a
@@ -650,8 +824,8 @@ other, never both."
(setq wttrin--location-history
(delete location wttrin--location-history)))))
-(defun wttrin--saved-locations ()
- "Return `wttrin-saved-locations' as a clean list of (NAME . QUERY) pairs.
+(defun wttrin--normalize-location-entries (entries)
+ "Return ENTRIES as a clean list of (NAME . QUERY) pairs.
Skips malformed entries — non-cons, a non-string name or query, or an empty
name or query — and trims surrounding whitespace, so stale or hand-edited
config never errors. A bare string S is read as (S . S)."
@@ -667,7 +841,24 @@ config never errors. A bare string S is read as (S . S)."
(let ((s (string-trim entry)))
(and (> (length s) 0) (cons s s))))
(t nil)))
- wttrin-saved-locations)))
+ entries)))
+
+(defun wttrin--saved-locations ()
+ "Return the effective saved-locations directory as clean (NAME . QUERY) pairs.
+The runtime layer `wttrin--saved-locations-runtime' (set by `d' and
+`wttrin-save-location', persisted by savehist) is overlaid on the configured
+`wttrin-saved-locations', runtime winning on a name collision. Both layers are
+normalized (malformed entries skipped, whitespace trimmed, a bare string S read
+as (S . S)); runtime entries are listed first, then config entries whose names
+the runtime does not already define."
+ (let* ((runtime (wttrin--normalize-location-entries wttrin--saved-locations-runtime))
+ (runtime-names (mapcar #'car runtime))
+ (config (wttrin--normalize-location-entries wttrin-saved-locations)))
+ (append runtime
+ (delq nil
+ (mapcar (lambda (entry)
+ (unless (member (car entry) runtime-names) entry))
+ config)))))
(defun wttrin--resolve-location-query (selection)
"Return the query string for a picker SELECTION.
@@ -683,31 +874,41 @@ Used to keep a raw geolocation fix out of history and to decide when the
(and (stringp string)
(string-match-p "\\`[ ]*-?[0-9.]+[ ]*,[ ]*-?[0-9.]+[ ]*\\'" string)))
-(defun wttrin--saved-locations-without (name)
- "Return `wttrin-saved-locations' with any entry named NAME removed."
+(defun wttrin--saved-locations-runtime-without (name)
+ "Return `wttrin--saved-locations-runtime' with any entry named NAME removed."
(delq nil
(mapcar (lambda (entry)
(unless (and (consp entry) (equal (car entry) name)) entry))
- wttrin-saved-locations)))
+ wttrin--saved-locations-runtime)))
(defun wttrin--put-saved-location (name query)
- "Add or update NAME -> QUERY in `wttrin-saved-locations'; return the saved name.
-Trims NAME and QUERY. Signals a `user-error' for an empty name or query, or a
-name equal to the geolocation sentinel. An existing name has its query updated."
+ "Add or update NAME -> QUERY in the runtime directory; return the saved name.
+Writes `wttrin--saved-locations-runtime' (persisted in `wttrin-state-file'),
+never the `wttrin-saved-locations' defcustom, so a runtime save is not
+clobbered by an init that also sets the option. Trims NAME and QUERY.
+Signals a `user-error'
+for an empty name or query, or a name equal to the geolocation sentinel. An
+existing runtime name has its query updated."
(let ((name (string-trim (or name "")))
(query (string-trim (or query ""))))
(when (string= name "") (user-error "Location name cannot be empty"))
(when (string= query "") (user-error "Location query cannot be empty"))
(when (string= name wttrin--geolocation-sentinel)
(user-error "That name is reserved for the geolocation entry"))
- (setq wttrin-saved-locations
- (append (wttrin--saved-locations-without name)
+ (setq wttrin--saved-locations-runtime
+ (append (wttrin--saved-locations-runtime-without name)
(list (cons name query))))
+ (wttrin--state-save)
name))
(defun wttrin--remove-saved-location (name)
- "Remove the saved location named NAME from `wttrin-saved-locations'."
- (setq wttrin-saved-locations (wttrin--saved-locations-without name)))
+ "Remove the saved location named NAME from the runtime directory.
+Only the runtime layer `wttrin--saved-locations-runtime' is mutated. An entry
+from the `wttrin-saved-locations' defcustom cannot be deleted here (it is
+re-read from the user's init) and remains in the effective directory."
+ (setq wttrin--saved-locations-runtime
+ (wttrin--saved-locations-runtime-without name))
+ (wttrin--state-save))
(defvar-local wttrin--current-location nil
"Query for the weather shown in this buffer (the fetch/cache identity).")
@@ -745,8 +946,8 @@ history (the explicit alias wins over a same-named default or history string),
so each place appears exactly once. The geolocation sentinel is prepended when
geolocation is enabled."
(let* ((saved (mapcar #'car (wttrin--saved-locations)))
- (favorite (and (stringp wttrin-favorite-location)
- (list wttrin-favorite-location)))
+ (favorite (let ((fav (wttrin--favorite-location)))
+ (and (stringp fav) (list fav))))
(deduped (delete-dups
(append saved favorite
(copy-sequence wttrin-default-locations)
@@ -865,7 +1066,7 @@ the favorite is updated to NEW."
(let ((query (cdr entry)))
(wttrin--remove-saved-location old)
(wttrin--put-saved-location new query)
- (when (equal wttrin-favorite-location old)
+ (when (equal (wttrin--favorite-location) old)
(wttrin--set-favorite-location new))
(message "Renamed %s to %s" old new))))))
@@ -880,16 +1081,22 @@ When NAME is the favorite, it is left as a literal query with a warning."
((not (assoc name (wttrin--saved-locations)))
(user-error "No saved location named %s" name))
((yes-or-no-p (format "Remove saved location \"%s\"? " name))
- (let ((query (cdr (assoc name (wttrin--saved-locations)))))
+ (let ((query (cdr (assoc name (wttrin--saved-locations))))
+ (was-favorite (equal (wttrin--favorite-location) name)))
(wttrin--remove-saved-location name)
- (wttrin--drop-from-location-history name query))
- (if (equal wttrin-favorite-location name)
- (progn
- (when (bound-and-true-p wttrin-mode-line-mode)
- (wttrin--mode-line-refresh-now))
- (message "Removed %s; it was your favorite and is now a literal query until you set a new one"
- name))
- (message "Removed %s" name)))
+ (wttrin--drop-from-location-history name query)
+ (when (and was-favorite (bound-and-true-p wttrin-mode-line-mode))
+ (wttrin--mode-line-refresh-now))
+ (cond
+ ;; Still resolvable means the name comes from the user's init, which the
+ ;; runtime removal can't touch, so the entry stays in the directory.
+ ((assoc name (wttrin--saved-locations))
+ (message "Removed the runtime entry %s; it is still defined in your init (wttrin-saved-locations) and remains in the directory"
+ name))
+ (was-favorite
+ (message "Removed %s; it was your favorite and is now a literal query until you set a new one"
+ name))
+ (t (message "Removed %s" name)))))
(t (message "Cancelled"))))
(defun wttrin--requery-location (new-location)
@@ -1249,6 +1456,25 @@ coordinates but can name the place)."
(defvar-local wttrin--current-request-id nil
"Request id of the most recent query for this weather buffer.")
+(defun wttrin--render-loading-placeholder (query display)
+ "Show the loading placeholder for QUERY in the current buffer.
+DISPLAY is the name to show (a saved-location name); when nil it falls back to
+QUERY. Erases the buffer, inserts the one-line placeholder, resets the font to
+the base height (dropping any auto-fit remap from the previous weather), and
+centers it via `wttrin--update-layout'. The explicit centering matters on an
+`a' switch: the *wttr.in* buffer is already displayed, so
+`window-configuration-change-hook' does not fire and the placeholder would
+otherwise keep the previous weather block's window margin."
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert "Loading weather for " (or display query) "..."))
+ (setq buffer-read-only t)
+ ;; The placeholder is one line; keep auto-fit off it (weather not yet rendered)
+ ;; and show it at the base font rather than the previous weather's size.
+ (setq-local wttrin--weather-rendered nil)
+ (wttrin--reset-font-height)
+ (wttrin--update-layout))
+
(defun wttrin-query (query &optional display address)
"Asynchronously query weather for QUERY, display the result when ready.
QUERY is what weather is fetched by (and the cache key). Optional DISPLAY is
@@ -1258,15 +1484,8 @@ coordinates from a geolocation command."
(let ((buffer (get-buffer-create (format "*wttr.in*")))
(request-id (setq wttrin--request-counter (1+ wttrin--request-counter))))
(switch-to-buffer buffer)
- (setq buffer-read-only nil)
- (erase-buffer)
- (insert "Loading weather for " (or display query) "...")
- (setq buffer-read-only t)
(setq-local wttrin--current-request-id request-id)
- ;; The placeholder is one line; keep auto-fit off it and show it at the base
- ;; font rather than the previous weather's auto-fitted (possibly capped) size.
- (setq-local wttrin--weather-rendered nil)
- (wttrin--reset-font-height)
+ (wttrin--render-loading-placeholder query display)
(wttrin--get-cached-or-fetch
query
(lambda (raw-string &optional error-msg)
@@ -1346,12 +1565,14 @@ This creates headroom to avoid frequent cleanups."
(defun wttrin-set-location-from-geolocation ()
"Detect your location via IP geolocation and set it as the favorite.
Uses the provider named by `wttrin-geolocation-provider' to fetch
-\"City, Region\", asks for confirmation, and on yes assigns the
-result to `wttrin-favorite-location'.
+\"City, Region\", asks for confirmation, and on yes makes the result
+the runtime favorite (`wttrin--favorite-override', which shadows the
+`wttrin-favorite-location' option).
With `savehist-mode' on, the favorite persists across sessions
-automatically (wttrin registers it with savehist); no
-`customize-save-variable' step is needed.
+automatically (wttrin registers the override with savehist); no
+`customize-save-variable' step is needed, and it is not overwritten by
+an init that also sets `wttrin-favorite-location'.
IP-based geolocation can be wrong behind a VPN or a mobile hotspot.
The confirmation prompt shows the detected location so you can
@@ -1373,7 +1594,7 @@ the detected city as your default."
((yes-or-no-p (format "Detected location: %s. Set as favorite? "
location))
(wttrin--set-favorite-location location)
- (message "Set wttrin-favorite-location to: %s%s"
+ (message "Set favorite location to: %s%s"
location
(if (bound-and-true-p savehist-mode)
" (persisted via savehist)."
@@ -1389,13 +1610,14 @@ the detected city as your default."
;;;###autoload
(defun wttrin-use-current-location ()
"Make your current location the persistent favorite (always auto-detect).
-Sets `wttrin-favorite-location' to t after confirmation, so the mode-line
-and buffer track wherever you are via geolocation rather than a fixed city.
-This is the labeled way to choose auto-detect without typing the bare symbol
-t into your init.
-
-With `savehist-mode' on, the choice persists across sessions automatically.
-Does nothing when `wttrin-geolocation-enabled' is nil."
+After confirmation, sets the runtime favorite (`wttrin--favorite-override') to
+t, so the mode-line and buffer track wherever you are via geolocation rather
+than a fixed city. This is the labeled way to choose auto-detect without typing
+the bare symbol t into your init.
+
+With `savehist-mode' on, the choice persists across sessions automatically, and
+is not overwritten by an init that also sets `wttrin-favorite-location'. Does
+nothing when `wttrin-geolocation-enabled' is nil."
(interactive)
(cond
((not wttrin-geolocation-enabled)
@@ -1421,16 +1643,18 @@ Does nothing when `wttrin-geolocation-enabled' is nil."
(message "No location to refresh")))
(defun wttrin--set-favorite-location (location)
- "Set `wttrin-favorite-location' to LOCATION and drop it from search history.
-LOCATION becomes a permanent default, so it no longer needs a history entry,
-mirroring how `wttrin-default-locations' entries are kept out of history.
-Persistence is handled by `wttrin--savehist-register', which registers the
-variable when savehist loads and again on `savehist-save-hook', so the value
-survives restarts without the Emacs custom-variable mechanism, and setting it
-here works whether or not savehist is loaded."
- (let ((changed (not (equal location wttrin-favorite-location))))
- (setq wttrin-favorite-location location)
+ "Set the runtime favorite to LOCATION and drop it from search history.
+Writes `wttrin--favorite-override', not the `wttrin-favorite-location'
+defcustom, so the choice survives a restart even when the user also sets the
+option in their init (the override shadows the config; see
+`wttrin--favorite-location'). LOCATION becomes a permanent default, so it no
+longer needs a history entry, mirroring how `wttrin-default-locations' entries
+are kept out of history. Persisted immediately to `wttrin-state-file', so the
+choice survives restarts regardless of savehist."
+ (let ((changed (not (equal location (wttrin--favorite-location)))))
+ (setq wttrin--favorite-override location)
(setq wttrin--location-history (delete location wttrin--location-history))
+ (wttrin--state-save)
(when (and changed (bound-and-true-p wttrin-mode-line-mode))
(wttrin--mode-line-refresh-now))))
@@ -1528,10 +1752,11 @@ 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)
+ (wttrin--debug-log "mode-line-fetch: Starting fetch for %s" (wttrin--favorite-location))
(let ((location (wttrin--resolve-favorite-location)))
(if (not location)
(wttrin--debug-log "mode-line-fetch: No favorite location available, skipping")
+ (wttrin--mode-line-fetch-forecast location)
(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")
@@ -1581,25 +1806,128 @@ without a separate guard."
(let ((age (- (float-time) (car cache-entry))))
(> age (* 2 wttrin-mode-line-refresh-interval)))))
+(defun wttrin--forecast-parse (json-string)
+ "Parse a wttr.in ?format=j1 JSON-STRING into a list of day alists.
+Returns the top-level weather array as a list (one alist per day), or
+nil when JSON-STRING is nil, empty, malformed, or missing the weather
+key. Never signals -- a broken forecast response must not take the
+mode-line fetch down with it."
+ (when (and (stringp json-string) (> (length json-string) 0))
+ (condition-case err
+ (let ((json-object-type 'alist)
+ (json-array-type 'list)
+ (json-key-type 'symbol))
+ (alist-get 'weather (json-read-from-string json-string)))
+ (error
+ (wttrin--debug-log "forecast-parse: %s" (error-message-string err))
+ nil))))
+
+(defun wttrin--forecast-day-label (day index)
+ "Return the tooltip label for DAY (a day alist) at INDEX in the forecast.
+Index 0 is \"Today\" and 1 is \"Tomorrow\"; later days use the abbreviated
+weekday name from DAY's date field (\"Fri\"). Falls back to the raw date
+string when it doesn't parse, and to the index when there's no date."
+ (let ((date (alist-get 'date day)))
+ (cond
+ ((= index 0) "Today")
+ ((= index 1) "Tomorrow")
+ ((stringp date)
+ (condition-case nil
+ (let ((parsed (parse-time-string date)))
+ (format-time-string
+ "%a" (encode-time 0 0 12 (nth 3 parsed) (nth 4 parsed)
+ (nth 5 parsed))))
+ (error date)))
+ (t (format "Day %d" (1+ index))))))
+
+(defun wttrin--forecast-midday-desc (day)
+ "Return DAY's midday weather description, or nil when unavailable.
+Reads the hourly entry whose time is \"1200\" (falling back to the middle
+entry) and returns the first weatherDesc value."
+ (let* ((hourly (alist-get 'hourly day))
+ (midday (or (seq-find (lambda (h) (equal (alist-get 'time h) "1200"))
+ hourly)
+ (nth (/ (length hourly) 2) hourly))))
+ (when midday
+ (let ((desc (alist-get 'value (car (alist-get 'weatherDesc midday)))))
+ ;; wttr.in pads some description values with trailing whitespace.
+ (when (stringp desc) (string-trim desc))))))
+
+(defun wttrin--forecast-format (days count)
+ "Format up to COUNT entries of DAYS (parsed day alists) for the tooltip.
+One line per day: \"<label> <min>-<max>°F <midday description>\" -- °F when
+`wttrin-unit-system' is \"u\", otherwise °C (both are present in the j1
+data; wttr.in's own location-based default can't be known here). The
+description is omitted when the day has no hourly data. Returns nil when
+DAYS is nil or COUNT is not positive."
+ (when (and days (> count 0))
+ (let* ((fahrenheit (equal wttrin-unit-system "u"))
+ (min-key (if fahrenheit 'mintempF 'mintempC))
+ (max-key (if fahrenheit 'maxtempF 'maxtempC))
+ (unit (if fahrenheit "°F" "°C"))
+ (index -1))
+ (mapconcat
+ (lambda (day)
+ (setq index (1+ index))
+ (let ((desc (wttrin--forecast-midday-desc day)))
+ (concat (wttrin--forecast-day-label day index)
+ (format " %s-%s%s"
+ (alist-get min-key day)
+ (alist-get max-key day)
+ unit)
+ (when desc (concat " " desc)))))
+ (seq-take days count)
+ "\n"))))
+
+(defun wttrin--mode-line-fetch-forecast (location)
+ "Fetch the j1 forecast for LOCATION into `wttrin--mode-line-forecast-cache'.
+Fires only when `wttrin-mode-line-tooltip-forecast-days' is positive.
+On a failed fetch or unparseable response the previous cache is kept --
+the tooltip just shows whatever forecast it last had."
+ (when (> wttrin-mode-line-tooltip-forecast-days 0)
+ (let ((url (concat "https://wttr.in/"
+ (url-hexify-string location)
+ "?format=j1")))
+ (wttrin--debug-log "forecast-fetch: URL = %s" url)
+ (wttrin--fetch-url
+ url
+ (lambda (data &optional _error-msg)
+ (let ((days (wttrin--forecast-parse data)))
+ (if days
+ (setq wttrin--mode-line-forecast-cache
+ (cons (float-time) days))
+ (wttrin--debug-log
+ "forecast-fetch: no usable forecast, keeping previous"))))))))
+
(defun wttrin--mode-line-tooltip (&optional _window _object _pos)
"Compute tooltip text from `wttrin--mode-line-cache'.
Calculates age at call time so the tooltip is always current.
If staleness has changed since the last render, triggers a re-render
so the emoji dimming matches.
+When `wttrin-mode-line-tooltip-forecast-days' is positive and a forecast
+has been fetched, the forecast lines sit between the conditions and the
+age line.
Optional arguments are ignored (required by `help-echo' function protocol)."
(when wttrin--mode-line-cache
(let* ((timestamp (car wttrin--mode-line-cache))
(weather-string (cdr wttrin--mode-line-cache))
(age (- (float-time) timestamp))
(stale-p (wttrin--mode-line-stale-p wttrin--mode-line-cache))
- (age-str (wttrin--format-age age)))
+ (age-str (wttrin--format-age age))
+ (forecast (when (> wttrin-mode-line-tooltip-forecast-days 0)
+ (wttrin--forecast-format
+ (cdr wttrin--mode-line-forecast-cache)
+ wttrin-mode-line-tooltip-forecast-days)))
+ (head (if forecast
+ (concat weather-string "\n" forecast)
+ weather-string)))
;; Re-render emoji if staleness state has changed
(unless (eq stale-p wttrin--mode-line-rendered-stale)
(wttrin--mode-line-update-display))
(if stale-p
(format "%s\nStale: updated %s — fetch failed, will retry"
- weather-string age-str)
- (format "%s\nUpdated %s" weather-string age-str)))))
+ head age-str)
+ (format "%s\nUpdated %s" head age-str)))))
(defun wttrin--mode-line-update-display ()
"Update mode-line display from `wttrin--mode-line-cache'.
@@ -1691,9 +2019,9 @@ mode-line state."
(defun wttrin--mode-line-start ()
"Start mode-line weather display and refresh timer."
(wttrin--debug-log "wttrin mode-line: Starting mode-line display (location=%s, interval=%s)"
- wttrin-favorite-location
+ (wttrin--favorite-location)
wttrin-mode-line-refresh-interval)
- (when wttrin-favorite-location
+ (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.