243 lines
9.5 KiB
QML
243 lines
9.5 KiB
QML
pragma Singleton
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
|
|
// Live weather for the bar's Weather.qml icon. A singleton for the same
|
|
// reason as SystemUpdates.qml: Bar.qml is instantiated once per monitor
|
|
// (shell.qml's Variants), so without this being shared, every extra screen
|
|
// would mean another set of network requests running in parallel.
|
|
//
|
|
// Location is auto-detected from the public IP (via ipwho.is, no API key)
|
|
// and cached to disk (~/.cache/quickshell/weather-location.json) so it's
|
|
// only ever looked up once, not on every quickshell restart — IP-geolocation
|
|
// free tiers rate-limit fast, and a desktop's location barely ever changes
|
|
// anyway. Only the weather itself re-polls on a timer. Conditions come from
|
|
// Open-Meteo (api.open-meteo.com) — also free and keyless, and it does its
|
|
// own °C/km-h conversion server-side via query params.
|
|
//
|
|
// Loosely modeled on omarchy's weather panel
|
|
// (github.com/omacom/omarchy, quattro branch,
|
|
// shell/plugins/panels/weather/Model.js) for the WMO weather-code
|
|
// grouping, but the icons are this bar's own Material Symbols glyphs
|
|
// rather than pulling in a separate icon font just for this.
|
|
QtObject {
|
|
id: root
|
|
|
|
// Location barely ever changes for a desktop machine, so it's fetched
|
|
// once (and cached); the weather itself is worth re-checking
|
|
// periodically.
|
|
readonly property int refreshIntervalMs: 15 * 60 * 1000
|
|
|
|
property bool locating: true
|
|
property bool loading: false
|
|
property bool hasLocation: false
|
|
property string locationName: ""
|
|
|
|
property real latitude: 0
|
|
property real longitude: 0
|
|
|
|
function saveLocation() {
|
|
_locationCache.setText(JSON.stringify({
|
|
latitude: root.latitude,
|
|
longitude: root.longitude,
|
|
name: root.locationName
|
|
}))
|
|
}
|
|
|
|
// Called from Weather.qml's search box once you pick a result — always
|
|
// wins over whatever IP-based guess was cached before, and persists so
|
|
// it sticks across restarts.
|
|
function selectLocation(name, lat, lon) {
|
|
root.latitude = lat
|
|
root.longitude = lon
|
|
root.locationName = name
|
|
root.hasLocation = true
|
|
root.locating = false
|
|
root.saveLocation()
|
|
root.refresh()
|
|
}
|
|
|
|
property bool searching: false
|
|
property var searchResults: []
|
|
|
|
function search(query) {
|
|
const q = String(query || "").trim()
|
|
if (q.length < 2) {
|
|
root.searchResults = []
|
|
return
|
|
}
|
|
root.searching = true
|
|
_searchProc.command = ["curl", "-s", "--max-time", "6",
|
|
"https://geocoding-api.open-meteo.com/v1/search?count=5&language=en&format=json&name=" + encodeURIComponent(q)]
|
|
_searchProc.running = true
|
|
}
|
|
|
|
property real tempC: 0
|
|
property real feelsLikeC: 0
|
|
property int humidity: 0
|
|
property real windKmh: 0
|
|
property int weatherCode: 0
|
|
property bool isDay: true
|
|
|
|
// [{date, code, maxC, minC}] — tomorrow onward; today is already
|
|
// covered by the current-conditions fields above.
|
|
property var forecast: []
|
|
|
|
function locate() {
|
|
if (_locateProc.running) return
|
|
root.locating = true
|
|
_locateProc.running = true
|
|
}
|
|
|
|
function refresh() {
|
|
if (!root.hasLocation || _weatherProc.running) return
|
|
root.loading = true
|
|
_weatherProc.command = ["curl", "-s", "--max-time", "8",
|
|
"https://api.open-meteo.com/v1/forecast?latitude=" + root.latitude
|
|
+ "&longitude=" + root.longitude
|
|
+ "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,is_day,wind_speed_10m"
|
|
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
|
+ "&temperature_unit=celsius&wind_speed_unit=kmh&timezone=auto&forecast_days=4"]
|
|
_weatherProc.running = true
|
|
}
|
|
|
|
// Material Symbols Rounded glyph for a WMO weather code (see
|
|
// https://open-meteo.com/en/docs#weathervariables), grouped the same
|
|
// way omarchy's Model.js groups them into its own icon set.
|
|
function iconFor(code, day) {
|
|
if (code === 0) return day ? "sunny" : "clear_night"
|
|
if (code === 1 || code === 2) return day ? "partly_cloudy_day" : "partly_cloudy_night"
|
|
if (code === 3) return "cloud"
|
|
if (code === 45 || code === 48) return "foggy"
|
|
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].indexOf(code) !== -1) return "rainy"
|
|
if ([71, 73, 75, 77, 85, 86].indexOf(code) !== -1) return "weather_snowy"
|
|
if (code === 95 || code === 96 || code === 99) return "thunderstorm"
|
|
return "cloud"
|
|
}
|
|
|
|
// QtObject has no default property, so these are attached via property
|
|
// assignment rather than as plain child elements (same as Colors.qml's
|
|
// FileView and SystemUpdates.qml's Process/Timer).
|
|
|
|
// Read once at startup; onLoaded/onLoadFailed (below) decide whether a
|
|
// network lookup is still needed.
|
|
readonly property FileView _locationCache: FileView {
|
|
path: Quickshell.env("HOME") + "/.cache/quickshell/weather-location.json"
|
|
onLoaded: {
|
|
try {
|
|
const data = JSON.parse(text())
|
|
const lat = parseFloat(data.latitude)
|
|
const lon = parseFloat(data.longitude)
|
|
if (!isNaN(lat) && !isNaN(lon)) {
|
|
root.latitude = lat
|
|
root.longitude = lon
|
|
root.locationName = data.name || ""
|
|
root.hasLocation = true
|
|
root.locating = false
|
|
root.refresh()
|
|
return
|
|
}
|
|
} catch (e) {
|
|
// fall through to a fresh lookup below
|
|
}
|
|
root.locate()
|
|
}
|
|
onLoadFailed: root.locate()
|
|
}
|
|
|
|
readonly property Process _locateProc: Process {
|
|
command: ["curl", "-s", "--max-time", "6", "https://ipwho.is/"]
|
|
stdout: StdioCollector {
|
|
id: locateCollector
|
|
onStreamFinished: {
|
|
try {
|
|
const data = JSON.parse(locateCollector.text)
|
|
const lat = parseFloat(data.latitude)
|
|
const lon = parseFloat(data.longitude)
|
|
root.hasLocation = data.success !== false && !isNaN(lat) && !isNaN(lon)
|
|
if (root.hasLocation) {
|
|
root.latitude = lat
|
|
root.longitude = lon
|
|
root.locationName = data.city || data.region || data.country || ""
|
|
root.saveLocation()
|
|
}
|
|
} catch (e) {
|
|
console.warn("Weather: locate parse failed:", e)
|
|
root.hasLocation = false
|
|
}
|
|
root.locating = false
|
|
if (root.hasLocation) root.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
readonly property Process _weatherProc: Process {
|
|
stdout: StdioCollector {
|
|
id: weatherCollector
|
|
onStreamFinished: {
|
|
try {
|
|
const data = JSON.parse(weatherCollector.text)
|
|
const current = data.current || {}
|
|
root.tempC = current.temperature_2m
|
|
root.feelsLikeC = current.apparent_temperature
|
|
root.humidity = current.relative_humidity_2m
|
|
root.windKmh = current.wind_speed_10m
|
|
root.weatherCode = current.weather_code
|
|
root.isDay = current.is_day === 1
|
|
|
|
const daily = data.daily || {}
|
|
const times = daily.time || []
|
|
const days = []
|
|
// Start at 1 — index 0 is today, already shown as the
|
|
// current conditions above; this is "what's coming".
|
|
for (let i = 1; i < times.length && days.length < 3; i++) {
|
|
days.push({
|
|
date: times[i],
|
|
code: (daily.weather_code || [])[i],
|
|
maxC: (daily.temperature_2m_max || [])[i],
|
|
minC: (daily.temperature_2m_min || [])[i]
|
|
})
|
|
}
|
|
root.forecast = days
|
|
} catch (e) {
|
|
console.warn("Weather: failed to parse Open-Meteo response:", e)
|
|
}
|
|
root.loading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
readonly property Process _searchProc: Process {
|
|
stdout: StdioCollector {
|
|
id: searchCollector
|
|
onStreamFinished: {
|
|
try {
|
|
const data = JSON.parse(searchCollector.text)
|
|
const results = data.results || []
|
|
root.searchResults = results.filter(function(r) {
|
|
return r && r.name && r.latitude !== undefined && r.longitude !== undefined
|
|
}).map(function(r) {
|
|
return {
|
|
name: r.name,
|
|
description: [r.admin1, r.country].filter(function(p) { return !!p }).join(", "),
|
|
latitude: r.latitude,
|
|
longitude: r.longitude
|
|
}
|
|
})
|
|
} catch (e) {
|
|
root.searchResults = []
|
|
}
|
|
root.searching = false
|
|
}
|
|
}
|
|
}
|
|
|
|
readonly property Timer _timer: Timer {
|
|
interval: root.refreshIntervalMs
|
|
running: true
|
|
repeat: true
|
|
onTriggered: root.refresh()
|
|
}
|
|
}
|