From 6c808ff4a4c54d543e065b722bc8517fe5141b45 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 1 Jul 2026 22:57:03 -0400 Subject: feat(mode-line): optional multi-day forecast in the weather tooltip The tooltip showed current conditions only. I added wttrin-mode-line-tooltip-forecast-days (default 0, capped at wttr.in's three days). When positive, the mode-line refresh also fetches ?format=j1 and caches the parsed day list, and the tooltip renders one line per day between the conditions and the age line: Today / Tomorrow / weekday label, min-max temps in the configured unit, and the midday description. A failed or malformed forecast fetch keeps the previous forecast and never disturbs the main fetch. --- wttrin.el | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 3 deletions(-) (limited to 'wttrin.el') diff --git a/wttrin.el b/wttrin.el index dd821b6..435359a 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) @@ -338,6 +339,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 @@ -432,6 +444,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).") @@ -1610,6 +1630,7 @@ proceeds normally." (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") @@ -1659,25 +1680,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: \"