Let the weather panel be pinned to a chosen location
The weather widgets asked wttr.in to geolocate by IP, which lands on
whatever city the ISP registers its address block under -- Downey when
you're in Malibu. Now the location can be set explicitly:
* Click the location in the weather panel to search via open-meteo
geocoding, pick a suggestion (disambiguated by region), and it sticks.
The X next to the field returns to IP auto-detect, as does committing
an empty search.
* State lives in ~/.local/state/omarchy/settings/weather.json as
{name, latitude, longitude}, owned by the new omarchy-weather-location
command (bare call prints the current location, --set/--clear write).
The panel watches the file, so hand edits apply live.
* Stored coordinates make wttr.in and open-meteo exact. Open-meteo also
now supplies current conditions alongside the daily forecast it
already served, which fills the panel within a second of a location
change instead of waiting out wttr.in's multi-second responses; wttr
results still win once they land. Failed wttr fetches retry instead
of sitting stale until the next refresh cycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
95c1ff0901
commit
5044e711a0
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
# omarchy:summary=Returns a weather condition icon, adjusted for live sunrise and sunset.
|
# omarchy:summary=Returns a weather condition icon, adjusted for live sunrise and sunset.
|
||||||
|
|
||||||
weather_data=$(curl -fsS --max-time 3 "https://wttr.in?format=j1" 2>/dev/null | jq -er '[.current_condition[0].weatherCode, .weather[0].astronomy[0].sunrise, .weather[0].astronomy[0].sunset] | select(all(. != null and . != "")) | @tsv' 2>/dev/null) || exit 1
|
# Only consult the helper when a location is stored: this runs every minute,
|
||||||
|
# and the helper's dynamic fallback would cost an extra wttr.in request when
|
||||||
|
# nothing is set — the bare j1 fetch already auto-detects by IP.
|
||||||
|
query=""
|
||||||
|
if [[ -s "$HOME/.local/state/omarchy/settings/weather.json" ]]; then
|
||||||
|
location=$(omarchy-weather-location 2>/dev/null)
|
||||||
|
[[ -n $location ]] && query=$(jq -rn --arg location "$location" '$location | @uri')
|
||||||
|
fi
|
||||||
|
|
||||||
|
weather_data=$(curl -fsS --max-time 3 "https://wttr.in/${query}?format=j1" 2>/dev/null | jq -er '[.current_condition[0].weatherCode, .weather[0].astronomy[0].sunrise, .weather[0].astronomy[0].sunset] | select(all(. != null and . != "")) | @tsv' 2>/dev/null) || exit 1
|
||||||
|
|
||||||
IFS=$'\t' read -r weather_code sunrise sunset <<< "$weather_data"
|
IFS=$'\t' read -r weather_code sunrise sunset <<< "$weather_data"
|
||||||
if [[ ! $weather_code =~ ^[0-9]+$ || ! $sunrise =~ ^[0-9]{1,2}:[0-9]{2}\ [AP]M$ || ! $sunset =~ ^[0-9]{1,2}:[0-9]{2}\ [AP]M$ ]]; then
|
if [[ ! $weather_code =~ ^[0-9]+$ || ! $sunrise =~ ^[0-9]{1,2}:[0-9]{2}\ [AP]M$ || ! $sunset =~ ^[0-9]{1,2}:[0-9]{2}\ [AP]M$ ]]; then
|
||||||
|
|||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# omarchy:summary=Show or set the location used for weather reports
|
||||||
|
#
|
||||||
|
# State lives in ~/.local/state/omarchy/settings/weather.json as
|
||||||
|
# {"name": ..., "latitude": ..., "longitude": ...}. A missing file means the
|
||||||
|
# location is auto-detected from the IP address. Coordinates make the weather
|
||||||
|
# panel exact; a hand-written {"name": "Malibu"} alone works too.
|
||||||
|
#
|
||||||
|
# (no args) the current location: the stored name, or the
|
||||||
|
# IP-detected city when nothing is set
|
||||||
|
# --set <name> [lat,lon] store a location
|
||||||
|
# --clear return to IP auto-detect
|
||||||
|
|
||||||
|
LOC_FILE="$HOME/.local/state/omarchy/settings/weather.json"
|
||||||
|
COORDS_PATTERN='^(-?[0-9]+(\.[0-9]+)?),(-?[0-9]+(\.[0-9]+)?)$'
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
"")
|
||||||
|
name=""
|
||||||
|
[[ -f $LOC_FILE ]] && name=$(jq -r '.name // "" | if type == "string" then . else "" end' "$LOC_FILE" 2>/dev/null)
|
||||||
|
if [[ -z $name ]]; then
|
||||||
|
name=$(curl -fsS --max-time 4 "https://wttr.in/?format=%l" 2>/dev/null)
|
||||||
|
name=${name%%,*}
|
||||||
|
fi
|
||||||
|
[[ -n $name ]] && echo "$name"
|
||||||
|
;;
|
||||||
|
--set)
|
||||||
|
[[ -n ${2:-} ]] || { echo "Usage: omarchy-weather-location --set <name> [lat,lon]" >&2; exit 1; }
|
||||||
|
if [[ -n ${3:-} ]]; then
|
||||||
|
[[ ${3} =~ $COORDS_PATTERN ]] || { echo "Invalid coordinates: $3 (expected lat,lon)" >&2; exit 1; }
|
||||||
|
location=$(jq -n --arg name "$2" --argjson latitude "${3%,*}" --argjson longitude "${3#*,}" '{$name, $latitude, $longitude}')
|
||||||
|
else
|
||||||
|
location=$(jq -n --arg name "$2" '{$name}')
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$LOC_FILE")"
|
||||||
|
echo "$location" >"$LOC_FILE"
|
||||||
|
;;
|
||||||
|
--clear)
|
||||||
|
rm -f "$LOC_FILE"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: omarchy-weather-location [--set <name> [lat,lon]|--clear]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
# omarchy:summary=Returns a formatted weather status string with temperature and wind speed.
|
# omarchy:summary=Returns a formatted weather status string with temperature and wind speed.
|
||||||
|
|
||||||
weather=$(curl -fsS --max-time 4 "https://wttr.in?format=%l|%t|%w" 2>/dev/null | tr -d '\n')
|
place=$(omarchy-weather-location 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -z $place ]]; then
|
||||||
|
echo "Weather unavailable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
query=$(jq -rn --arg place "$place" '$place | @uri')
|
||||||
|
weather=$(curl -fsS --max-time 4 "https://wttr.in/${query}?format=%t|%w" 2>/dev/null | tr -d '\n')
|
||||||
|
|
||||||
if [[ -z $weather ]]; then
|
if [[ -z $weather ]]; then
|
||||||
echo "Weather unavailable"
|
echo "Weather unavailable"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
IFS='|' read -r place temperature wind <<< "$weather"
|
IFS='|' read -r temperature wind <<< "$weather"
|
||||||
place=${place%%,*}
|
|
||||||
place=${place^}
|
place=${place^}
|
||||||
temperature=${temperature#+}
|
temperature=${temperature#+}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,65 @@ function parseWeatherStatus(raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// weather.json holds {"name": ..., "latitude": ..., "longitude": ...} (see
|
||||||
|
// omarchy-weather-location, which owns the format). Missing, blank, or
|
||||||
|
// unparseable means the location is auto-detected from the IP address.
|
||||||
|
function parseLocationFile(raw) {
|
||||||
|
var unset = { name: "", latitude: null, longitude: null }
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(String(raw || ""))
|
||||||
|
if (!data || typeof data !== "object") return unset
|
||||||
|
|
||||||
|
var latitude = parseFloat(data.latitude)
|
||||||
|
var longitude = parseFloat(data.longitude)
|
||||||
|
var hasCoordinates = !isNaN(latitude) && !isNaN(longitude)
|
||||||
|
return {
|
||||||
|
name: typeof data.name === "string" ? data.name.replace(/^\s+|\s+$/g, "") : "",
|
||||||
|
latitude: hasCoordinates ? latitude : null,
|
||||||
|
longitude: hasCoordinates ? longitude : null
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return unset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wttr.in path segment for a configured location: exact coordinates when
|
||||||
|
// both are present, the URL-encoded name as a fallback (hand-edited
|
||||||
|
// weather.loc files may only carry a name), empty for IP auto-detect.
|
||||||
|
function wttrLocationQuery(location, latitude, longitude) {
|
||||||
|
var lat = parseFloat(String(latitude))
|
||||||
|
var lon = parseFloat(String(longitude))
|
||||||
|
if (!isNaN(lat) && !isNaN(lon)) return lat + "," + lon
|
||||||
|
|
||||||
|
var name = String(location || "").replace(/^\s+|\s+$/g, "")
|
||||||
|
return name === "" ? "" : encodeURIComponent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open-Meteo geocoding response → suggestion rows for the location picker.
|
||||||
|
function parseGeocodingResults(raw) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(String(raw || "{}"))
|
||||||
|
var results = data.results
|
||||||
|
if (!results || !results.length) return []
|
||||||
|
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < results.length; i++) {
|
||||||
|
var r = results[i]
|
||||||
|
if (!r || !r.name || r.latitude === undefined || r.longitude === undefined) continue
|
||||||
|
var region = [r.admin1, r.country].filter(function(part) { return !!part }).join(", ")
|
||||||
|
out.push({
|
||||||
|
name: String(r.name),
|
||||||
|
description: region,
|
||||||
|
latitude: r.latitude,
|
||||||
|
longitude: r.longitude
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isFutureForecastDate(dateString, todayString) {
|
function isFutureForecastDate(dateString, todayString) {
|
||||||
if (!dateString) return false
|
if (!dateString) return false
|
||||||
return String(dateString).slice(0, 10) > String(todayString || "")
|
return String(dateString).slice(0, 10) > String(todayString || "")
|
||||||
@@ -94,6 +153,24 @@ function openMeteoForecastDays(dailyForecastReport, todayString) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open-Meteo bundles current conditions with the daily forecast request and
|
||||||
|
// answers far faster than wttr.in. Normalize them to wttr's
|
||||||
|
// current_condition shape so the panel can use either source
|
||||||
|
// interchangeably. Open-Meteo reports metric (°C, km/h).
|
||||||
|
function openMeteoCurrentCondition(dailyForecastReport) {
|
||||||
|
var current = dailyForecastReport && dailyForecastReport.current ? dailyForecastReport.current : null
|
||||||
|
if (!current || current.temperature_2m === undefined || current.temperature_2m === null) return null
|
||||||
|
return {
|
||||||
|
temp_C: roundedTemp(current.temperature_2m),
|
||||||
|
temp_F: roundedTemp(celsiusToFahrenheit(current.temperature_2m)),
|
||||||
|
FeelsLikeC: roundedTemp(current.apparent_temperature),
|
||||||
|
FeelsLikeF: roundedTemp(celsiusToFahrenheit(current.apparent_temperature)),
|
||||||
|
windspeedKmph: roundedTemp(current.wind_speed_10m),
|
||||||
|
windspeedMiles: roundedTemp(current.wind_speed_10m * 0.621371),
|
||||||
|
humidity: roundedTemp(current.relative_humidity_2m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function wttrNextForecastDays(report, todayString) {
|
function wttrNextForecastDays(report, todayString) {
|
||||||
var days = report && report.weather ? report.weather : []
|
var days = report && report.weather ? report.weather : []
|
||||||
var result = []
|
var result = []
|
||||||
@@ -170,6 +247,9 @@ function iconForCode(code, night) {
|
|||||||
if (typeof module !== "undefined") {
|
if (typeof module !== "undefined") {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parseWeatherStatus: parseWeatherStatus,
|
parseWeatherStatus: parseWeatherStatus,
|
||||||
|
parseLocationFile: parseLocationFile,
|
||||||
|
wttrLocationQuery: wttrLocationQuery,
|
||||||
|
parseGeocodingResults: parseGeocodingResults,
|
||||||
isFutureForecastDate: isFutureForecastDate,
|
isFutureForecastDate: isFutureForecastDate,
|
||||||
roundedTemp: roundedTemp,
|
roundedTemp: roundedTemp,
|
||||||
celsiusToFahrenheit: celsiusToFahrenheit,
|
celsiusToFahrenheit: celsiusToFahrenheit,
|
||||||
@@ -180,6 +260,7 @@ if (typeof module !== "undefined") {
|
|||||||
shouldUseImperial: shouldUseImperial,
|
shouldUseImperial: shouldUseImperial,
|
||||||
dayName: dayName,
|
dayName: dayName,
|
||||||
openMeteoForecastDays: openMeteoForecastDays,
|
openMeteoForecastDays: openMeteoForecastDays,
|
||||||
|
openMeteoCurrentCondition: openMeteoCurrentCondition,
|
||||||
wttrNextForecastDays: wttrNextForecastDays,
|
wttrNextForecastDays: wttrNextForecastDays,
|
||||||
buildForecastDays: buildForecastDays,
|
buildForecastDays: buildForecastDays,
|
||||||
bareTempForDay: bareTempForDay,
|
bareTempForDay: bareTempForDay,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Panel {
|
|||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
setCenterHoverRevealSuppressed(false)
|
setCenterHoverRevealSuppressed(false)
|
||||||
|
if (root.editingLocation) root.cancelEditingLocation()
|
||||||
root.controller.hide()
|
root.controller.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,55 @@ Panel {
|
|||||||
property var dailyForecastReport: null
|
property var dailyForecastReport: null
|
||||||
property string wttrLocation: ""
|
property string wttrLocation: ""
|
||||||
|
|
||||||
|
// Configured location, read from the weather.json state file (owned by
|
||||||
|
// omarchy-weather-location). The query is the wttr.in path segment
|
||||||
|
// (coordinates when stored, else the encoded name); empty means IP
|
||||||
|
// auto-detect. The watch makes hand edits take effect live.
|
||||||
|
property var configuredLocationState: ({ name: "", latitude: null, longitude: null })
|
||||||
|
readonly property string configuredLocation: configuredLocationState.name
|
||||||
|
readonly property string locationQuery: Model.wttrLocationQuery(configuredLocationState.name, configuredLocationState.latitude, configuredLocationState.longitude)
|
||||||
|
|
||||||
|
// A location change makes the previous report misleading (the old city's
|
||||||
|
// numbers under the new label), so drop it, abort any in-flight fetch for
|
||||||
|
// the old location, and refetch from scratch.
|
||||||
|
onLocationQueryChanged: {
|
||||||
|
report = null
|
||||||
|
dailyForecastReport = null
|
||||||
|
wttrLocation = ""
|
||||||
|
forecastRetries = 0
|
||||||
|
forecastProc.running = false
|
||||||
|
dailyForecastProc.running = false
|
||||||
|
Qt.callLater(refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
property FileView locationFile: FileView {
|
||||||
|
path: Quickshell.env("HOME") + "/.local/state/omarchy/settings/weather.json"
|
||||||
|
watchChanges: true
|
||||||
|
printErrors: false
|
||||||
|
onFileChanged: reload()
|
||||||
|
onLoaded: root.configuredLocationState = Model.parseLocationFile(text())
|
||||||
|
onLoadFailed: root.configuredLocationState = Model.parseLocationFile("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first read can race shell startup (observed sporadically), leaving a
|
||||||
|
// stored location unhonored until the next file write. One delayed reload
|
||||||
|
// self-corrects; if the first read was fine it's a no-op, since identical
|
||||||
|
// state doesn't change locationQuery and so triggers no refetch.
|
||||||
|
Timer {
|
||||||
|
interval: 1500
|
||||||
|
running: true
|
||||||
|
onTriggered: locationFile.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
property int forecastRetries: 0
|
||||||
|
|
||||||
|
// Click-to-edit state for the location label.
|
||||||
|
property bool editingLocation: false
|
||||||
|
property var locationSuggestions: []
|
||||||
|
property int suggestionIndex: 0
|
||||||
|
property string geocodePendingQuery: ""
|
||||||
|
property string geocodeActiveQuery: ""
|
||||||
|
|
||||||
// Bar pill state. Polled locally; populated by weatherProc below.
|
// Bar pill state. Polled locally; populated by weatherProc below.
|
||||||
property string label: ""
|
property string label: ""
|
||||||
property string klass: ""
|
property string klass: ""
|
||||||
@@ -59,7 +109,9 @@ Panel {
|
|||||||
klass = data.klass
|
klass = data.klass
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null
|
// wttr's current conditions when available; open-meteo's (bundled with the
|
||||||
|
// much faster daily forecast fetch) fill the hero while wttr is in flight.
|
||||||
|
readonly property var current: (report && report.current_condition && report.current_condition[0]) ? report.current_condition[0] : Model.openMeteoCurrentCondition(dailyForecastReport)
|
||||||
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
|
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
|
||||||
readonly property var forecastDays: buildForecastDays()
|
readonly property var forecastDays: buildForecastDays()
|
||||||
readonly property string reportCountry: areaInfo && areaInfo.country && areaInfo.country[0] ? areaInfo.country[0].value : ""
|
readonly property string reportCountry: areaInfo && areaInfo.country && areaInfo.country[0] ? areaInfo.country[0].value : ""
|
||||||
@@ -69,7 +121,7 @@ Panel {
|
|||||||
// Auto-refresh interval in minutes; clamped to a sane minimum.
|
// Auto-refresh interval in minutes; clamped to a sane minimum.
|
||||||
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15)
|
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15)
|
||||||
|
|
||||||
readonly property string reportLocation: wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "")
|
readonly property string reportLocation: configuredLocation || wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "")
|
||||||
readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : ""
|
readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : ""
|
||||||
readonly property string tempUnit: "°" + (useImperial ? "F" : "C")
|
readonly property string tempUnit: "°" + (useImperial ? "F" : "C")
|
||||||
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
|
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
|
||||||
@@ -78,27 +130,104 @@ Panel {
|
|||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
if (!forecastProc.running) forecastProc.running = true
|
if (!forecastProc.running) forecastProc.running = true
|
||||||
if (!locationProc.running) locationProc.running = true
|
if (root.locationQuery === "" && !locationProc.running) locationProc.running = true
|
||||||
|
// With stored coordinates this fetches open-meteo right away — no need
|
||||||
|
// to wait for the slow wttr response. Without them it's a no-op until
|
||||||
|
// wttr reports the detected area.
|
||||||
|
refreshDailyForecast(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshDailyForecast(sourceReport) {
|
function refreshDailyForecast(sourceReport) {
|
||||||
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
|
if (dailyForecastProc.running) return
|
||||||
if (!area || dailyForecastProc.running) return
|
|
||||||
|
|
||||||
var lat = parseFloat(String(area.latitude || ""))
|
var lat = parseFloat(String(root.configuredLocationState.latitude))
|
||||||
var lon = parseFloat(String(area.longitude || ""))
|
var lon = parseFloat(String(root.configuredLocationState.longitude))
|
||||||
|
if (isNaN(lat) || isNaN(lon)) {
|
||||||
|
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
|
||||||
|
if (!area) return
|
||||||
|
lat = parseFloat(String(area.latitude || ""))
|
||||||
|
lon = parseFloat(String(area.longitude || ""))
|
||||||
|
}
|
||||||
if (isNaN(lat) || isNaN(lon)) return
|
if (isNaN(lat) || isNaN(lon)) return
|
||||||
|
|
||||||
var url = "https://api.open-meteo.com/v1/forecast"
|
var url = "https://api.open-meteo.com/v1/forecast"
|
||||||
+ "?latitude=" + encodeURIComponent(String(lat))
|
+ "?latitude=" + encodeURIComponent(String(lat))
|
||||||
+ "&longitude=" + encodeURIComponent(String(lon))
|
+ "&longitude=" + encodeURIComponent(String(lon))
|
||||||
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
||||||
|
+ "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m"
|
||||||
+ "&forecast_days=4"
|
+ "&forecast_days=4"
|
||||||
+ "&timezone=auto"
|
+ "&timezone=auto"
|
||||||
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
|
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
|
||||||
dailyForecastProc.running = true
|
dailyForecastProc.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Location editing. Clicking the location label swaps it for a search
|
||||||
|
// field; picking a geocoded suggestion persists name + coordinates to
|
||||||
|
// the module's shell.json entry. An empty commit returns to auto.
|
||||||
|
function startEditingLocation() {
|
||||||
|
editingLocation = true
|
||||||
|
locationSuggestions = []
|
||||||
|
suggestionIndex = 0
|
||||||
|
Qt.callLater(function() {
|
||||||
|
locationField.text = root.configuredLocation
|
||||||
|
locationField.selectAll()
|
||||||
|
locationField.forceActiveFocus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditingLocation() {
|
||||||
|
editingLocation = false
|
||||||
|
locationSuggestions = []
|
||||||
|
geocodeDebounce.stop()
|
||||||
|
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitLocation() {
|
||||||
|
if (locationField.text.trim() === "") {
|
||||||
|
clearLocation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pickSuggestion(locationSuggestions[Math.min(suggestionIndex, locationSuggestions.length - 1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocation() {
|
||||||
|
persistLocation("", null, null)
|
||||||
|
wttrLocation = ""
|
||||||
|
cancelEditingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSuggestion(suggestion) {
|
||||||
|
if (!suggestion) return
|
||||||
|
persistLocation(suggestion.name, suggestion.latitude, suggestion.longitude)
|
||||||
|
cancelEditingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistLocation(name, latitude, longitude) {
|
||||||
|
if (name)
|
||||||
|
Quickshell.execDetached(["omarchy-weather-location", "--set", name, latitude + "," + longitude])
|
||||||
|
else
|
||||||
|
Quickshell.execDetached(["omarchy-weather-location", "--clear"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced geocoding. Only one curl runs at a time; if the query moved on
|
||||||
|
// while a fetch was in flight, the latest query is fetched right after.
|
||||||
|
function requestGeocode() {
|
||||||
|
var query = locationField.text.trim()
|
||||||
|
if (query.length < 2) {
|
||||||
|
locationSuggestions = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
geocodePendingQuery = query
|
||||||
|
if (!geocodeProc.running) startGeocode()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGeocode() {
|
||||||
|
geocodeActiveQuery = geocodePendingQuery
|
||||||
|
geocodeProc.command = ["curl", "-fsS", "--max-time", "5",
|
||||||
|
"https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(geocodeActiveQuery) + "&count=5&language=en&format=json"]
|
||||||
|
geocodeProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
function buildForecastDays() {
|
function buildForecastDays() {
|
||||||
return Model.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
return Model.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||||
}
|
}
|
||||||
@@ -152,23 +281,45 @@ Panel {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: forecastProc
|
id: forecastProc
|
||||||
command: ["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=j1' 2>/dev/null"]
|
command: ["curl", "-fsS", "--max-time", "10", "https://wttr.in/" + root.locationQuery + "?format=j1"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
waitForEnd: true
|
waitForEnd: true
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
var raw = String(text || "").trim()
|
var raw = String(text || "").trim()
|
||||||
if (!raw) return
|
if (!raw) {
|
||||||
|
root.scheduleForecastRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
var parsed = JSON.parse(raw)
|
var parsed = JSON.parse(raw)
|
||||||
root.report = parsed
|
root.report = parsed
|
||||||
root.refreshDailyForecast(parsed)
|
root.forecastRetries = 0
|
||||||
|
// Stored coordinates already drove the fast open-meteo fetch from
|
||||||
|
// refresh(); only auto-detect needs the area wttr reported.
|
||||||
|
if (isNaN(parseFloat(String(root.configuredLocationState.latitude))))
|
||||||
|
root.refreshDailyForecast(parsed)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keep last-good report on parse failure so the popup isn't blanked.
|
// Keep last-good report visible, but try again shortly.
|
||||||
|
root.scheduleForecastRetry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wttr.in can be slow or flaky, especially for a location it hasn't
|
||||||
|
// cached yet. Retry a few times before leaving it to the refresh timer.
|
||||||
|
function scheduleForecastRetry() {
|
||||||
|
if (forecastRetries >= 3) return
|
||||||
|
forecastRetries++
|
||||||
|
forecastRetryTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: forecastRetryTimer
|
||||||
|
interval: 2500
|
||||||
|
onTriggered: if (!forecastProc.running) forecastProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: dailyForecastProc
|
id: dailyForecastProc
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
@@ -185,9 +336,27 @@ Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: geocodeProc
|
||||||
|
stdout: StdioCollector {
|
||||||
|
waitForEnd: true
|
||||||
|
onStreamFinished: {
|
||||||
|
root.locationSuggestions = root.editingLocation ? Model.parseGeocodingResults(text) : []
|
||||||
|
root.suggestionIndex = 0
|
||||||
|
if (root.geocodePendingQuery !== root.geocodeActiveQuery) Qt.callLater(root.startGeocode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: geocodeDebounce
|
||||||
|
interval: 300
|
||||||
|
onTriggered: root.requestGeocode()
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: locationProc
|
id: locationProc
|
||||||
command: ["bash", "-lc", "curl -fsS --max-time 4 'https://wttr.in?format=%l' 2>/dev/null"]
|
command: ["curl", "-fsS", "--max-time", "4", "https://wttr.in/?format=%l"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
waitForEnd: true
|
waitForEnd: true
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
@@ -215,6 +384,7 @@ Panel {
|
|||||||
function show(): void { root.openFromHotkey() }
|
function show(): void { root.openFromHotkey() }
|
||||||
function hide(): void { root.close() }
|
function hide(): void { root.close() }
|
||||||
function toggle(): void { root.toggle() }
|
function toggle(): void { root.toggle() }
|
||||||
|
function edit(): void { root.openFromHotkey(); root.startEditingLocation() }
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardPanel {
|
KeyboardPanel {
|
||||||
@@ -231,6 +401,7 @@ Panel {
|
|||||||
PanelKeyCatcher {
|
PanelKeyCatcher {
|
||||||
id: keyCatcher
|
id: keyCatcher
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
blocked: root.editingLocation
|
||||||
onCloseRequested: root.close()
|
onCloseRequested: root.close()
|
||||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||||
|
|
||||||
@@ -305,9 +476,16 @@ Panel {
|
|||||||
spacing: Style.space(12)
|
spacing: Style.space(12)
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
visible: root.reportLocation !== ""
|
visible: !root.editingLocation && root.reportLocation !== ""
|
||||||
spacing: Style.space(6)
|
spacing: Style.space(6)
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: root.startEditingLocation()
|
||||||
|
}
|
||||||
|
HoverHandler {
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "" // nf-fa-map_marker
|
text: "" // nf-fa-map_marker
|
||||||
color: Qt.darker(root.bar.foreground, 1.4)
|
color: Qt.darker(root.bar.foreground, 1.4)
|
||||||
@@ -325,6 +503,63 @@ Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
visible: root.editingLocation
|
||||||
|
spacing: Style.space(6)
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: locationField
|
||||||
|
width: Style.space(190)
|
||||||
|
placeholderText: "Search city"
|
||||||
|
foreground: root.bar.foreground
|
||||||
|
font.family: root.bar.fontFamily
|
||||||
|
|
||||||
|
onTextChanged: if (root.editingLocation) geocodeDebounce.restart()
|
||||||
|
|
||||||
|
Keys.onPressed: function(event) {
|
||||||
|
if (event.key === Qt.Key_Escape) {
|
||||||
|
root.cancelEditingLocation()
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Down) {
|
||||||
|
if (root.suggestionIndex < root.locationSuggestions.length - 1) root.suggestionIndex++
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Up) {
|
||||||
|
if (root.suggestionIndex > 0) root.suggestionIndex--
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||||
|
root.commitLocation()
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear back to IP auto-detect. Committing an empty field does
|
||||||
|
// the same for keyboard users.
|
||||||
|
Rectangle {
|
||||||
|
width: Style.space(18)
|
||||||
|
height: Style.space(18)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
radius: Math.min(4, Style.cornerRadius)
|
||||||
|
color: clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "✕"
|
||||||
|
font.family: root.bar.fontFamily
|
||||||
|
color: Qt.darker(root.bar.foreground, 1.4)
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: clearLocationArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.clearLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
visible: !!root.current
|
visible: !!root.current
|
||||||
spacing: Style.space(36)
|
spacing: Style.space(36)
|
||||||
@@ -383,6 +618,57 @@ Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Geocoding suggestions while the location is being edited.
|
||||||
|
Column {
|
||||||
|
visible: root.editingLocation && root.locationSuggestions.length > 0
|
||||||
|
width: parent.width
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.locationSuggestions
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
width: parent.width
|
||||||
|
height: suggestionRow.implicitHeight + Style.space(12)
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
color: index === root.suggestionIndex ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: suggestionRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Style.space(16)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: modelData.name
|
||||||
|
color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground
|
||||||
|
font.family: root.bar.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: text !== ""
|
||||||
|
text: modelData.description
|
||||||
|
color: Qt.darker(root.bar.foreground, 1.5)
|
||||||
|
font.family: root.bar.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPositionChanged: root.suggestionIndex = index
|
||||||
|
onClicked: root.pickSuggestion(modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
visible: !root.current
|
visible: !root.current
|
||||||
text: "Fetching forecast…"
|
text: "Fetching forecast…"
|
||||||
|
|||||||
@@ -14,6 +14,38 @@ assertDeepEqual(
|
|||||||
)
|
)
|
||||||
assertDeepEqual(weather.parseWeatherStatus('{'), { label: '', klass: '' }, 'weather handles invalid pill status JSON')
|
assertDeepEqual(weather.parseWeatherStatus('{'), { label: '', klass: '' }, 'weather handles invalid pill status JSON')
|
||||||
|
|
||||||
|
assertDeepEqual(weather.parseLocationFile('{"name": "Malibu", "latitude": 34.02577, "longitude": -118.7804}\n'), { name: 'Malibu', latitude: 34.02577, longitude: -118.7804 }, 'weather parses name plus coordinates from weather.json')
|
||||||
|
assertDeepEqual(weather.parseLocationFile('{"name": "New York"}'), { name: 'New York', latitude: null, longitude: null }, 'weather parses a name-only weather.json')
|
||||||
|
assertDeepEqual(weather.parseLocationFile('{"name": "Malibu", "latitude": 34.02577}'), { name: 'Malibu', latitude: null, longitude: null }, 'weather requires both coordinates')
|
||||||
|
assertDeepEqual(weather.parseLocationFile('not json'), { name: '', latitude: null, longitude: null }, 'weather treats an unparseable weather.json as auto-detect')
|
||||||
|
assertDeepEqual(weather.parseLocationFile(''), { name: '', latitude: null, longitude: null }, 'weather treats a missing weather.json as auto-detect')
|
||||||
|
|
||||||
|
assertEqual(weather.wttrLocationQuery('Malibu', 34.02577, -118.7804), '34.02577,-118.7804', 'weather prefers coordinates for the wttr query')
|
||||||
|
assertEqual(weather.wttrLocationQuery('Malibu', '34.02577', '-118.7804'), '34.02577,-118.7804', 'weather accepts string coordinates')
|
||||||
|
assertEqual(weather.wttrLocationQuery('New York', null, null), 'New%20York', 'weather URL-encodes a name-only location')
|
||||||
|
assertEqual(weather.wttrLocationQuery('Malibu', 'nope', -118.7804), 'Malibu', 'weather ignores unparseable coordinates')
|
||||||
|
assertEqual(weather.wttrLocationQuery('', null, null), '', 'weather falls back to IP auto-detect without a location')
|
||||||
|
assertEqual(weather.wttrLocationQuery(' ', null, null), '', 'weather treats a blank location as unset')
|
||||||
|
|
||||||
|
assertDeepEqual(
|
||||||
|
weather.parseGeocodingResults(JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{ name: 'Malibu', latitude: 34.02577, longitude: -118.7804, admin1: 'California', country: 'United States' },
|
||||||
|
{ name: 'Malibu', latitude: -7.18333, longitude: 29.65, admin1: 'Tanganyika', country: 'Democratic Republic of Congo' },
|
||||||
|
{ name: 'Broken', latitude: 1.0 },
|
||||||
|
{ name: 'Bare', latitude: 2.0, longitude: 3.0 }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ name: 'Malibu', description: 'California, United States', latitude: 34.02577, longitude: -118.7804 },
|
||||||
|
{ name: 'Malibu', description: 'Tanganyika, Democratic Republic of Congo', latitude: -7.18333, longitude: 29.65 },
|
||||||
|
{ name: 'Bare', description: '', latitude: 2.0, longitude: 3.0 }
|
||||||
|
],
|
||||||
|
'weather parses geocoding suggestions and drops incomplete rows'
|
||||||
|
)
|
||||||
|
assertDeepEqual(weather.parseGeocodingResults('{}'), [], 'weather handles empty geocoding responses')
|
||||||
|
assertDeepEqual(weather.parseGeocodingResults('{'), [], 'weather handles invalid geocoding JSON')
|
||||||
|
|
||||||
assertEqual(weather.roundedTemp('21.6'), '22', 'weather rounds temperatures')
|
assertEqual(weather.roundedTemp('21.6'), '22', 'weather rounds temperatures')
|
||||||
assertEqual(weather.roundedTemp('nope'), '', 'weather ignores invalid temperatures')
|
assertEqual(weather.roundedTemp('nope'), '', 'weather ignores invalid temperatures')
|
||||||
assertEqual(weather.formatTemp(72, true), '72°F', 'weather formats imperial temperatures')
|
assertEqual(weather.formatTemp(72, true), '72°F', 'weather formats imperial temperatures')
|
||||||
@@ -49,6 +81,14 @@ assertDeepEqual(
|
|||||||
'weather builds future Open-Meteo forecast days'
|
'weather builds future Open-Meteo forecast days'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assertDeepEqual(
|
||||||
|
weather.openMeteoCurrentCondition({ current: { temperature_2m: 21.4, apparent_temperature: 19.8, wind_speed_10m: 14.3, relative_humidity_2m: 63 } }),
|
||||||
|
{ temp_C: '21', temp_F: '71', FeelsLikeC: '20', FeelsLikeF: '68', windspeedKmph: '14', windspeedMiles: '9', humidity: '63' },
|
||||||
|
'weather normalizes open-meteo current conditions to the wttr shape'
|
||||||
|
)
|
||||||
|
assertEqual(weather.openMeteoCurrentCondition({}), null, 'weather returns no current conditions without open-meteo data')
|
||||||
|
assertEqual(weather.openMeteoCurrentCondition({ current: {} }), null, 'weather requires a current temperature')
|
||||||
|
|
||||||
const wttr = {
|
const wttr = {
|
||||||
weather: [
|
weather: [
|
||||||
{ date: '2026-05-25', maxtempC: '20', mintempC: '12' },
|
{ date: '2026-05-25', maxtempC: '20', mintempC: '12' },
|
||||||
@@ -66,3 +106,31 @@ assertEqual(
|
|||||||
'weather picks hourly forecast icon nearest noon'
|
'weather picks hourly forecast icon nearest noon'
|
||||||
)
|
)
|
||||||
JS
|
JS
|
||||||
|
|
||||||
|
test_tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$test_tmp"' EXIT
|
||||||
|
|
||||||
|
weather_location() {
|
||||||
|
HOME="$test_tmp" "$ROOT/bin/omarchy-weather-location" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
weather_location --set "Malibu" "34.02577,-118.7804"
|
||||||
|
[[ $(jq -c . "$test_tmp/.local/state/omarchy/settings/weather.json") == '{"name":"Malibu","latitude":34.02577,"longitude":-118.7804}' ]] || fail "weather location stores name and coordinates as JSON"
|
||||||
|
pass "weather location stores name and coordinates as JSON"
|
||||||
|
|
||||||
|
[[ $(weather_location) == "Malibu" ]] || fail "weather location returns the stored name"
|
||||||
|
pass "weather location returns the stored name"
|
||||||
|
|
||||||
|
weather_location --set "New York"
|
||||||
|
[[ $(jq -c . "$test_tmp/.local/state/omarchy/settings/weather.json") == '{"name":"New York"}' ]] || fail "weather location stores a bare name as JSON"
|
||||||
|
[[ $(weather_location) == "New York" ]] || fail "weather location returns a bare stored name"
|
||||||
|
pass "weather location stores and returns a bare name"
|
||||||
|
|
||||||
|
if weather_location --set "bad" "not,coords" 2>/dev/null; then
|
||||||
|
fail "weather location rejects malformed coordinates"
|
||||||
|
fi
|
||||||
|
pass "weather location rejects malformed coordinates"
|
||||||
|
|
||||||
|
weather_location --clear
|
||||||
|
[[ ! -e "$test_tmp/.local/state/omarchy/settings/weather.json" ]] || fail "weather location clear removes the state file"
|
||||||
|
pass "weather location clear removes the state file"
|
||||||
|
|||||||
Reference in New Issue
Block a user