Fork the desktop off Omarchy as a self-contained system

This commit is contained in:
2026-09-19 23:50:39 -04:00
parent 16a56f49a1
commit 9501f2bb4d
559 changed files with 43273 additions and 0 deletions
@@ -0,0 +1,83 @@
import QtQuick
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.weather"
function injectPanel() {
var target = panelLoader.item
if (!target) return
if ("bar" in target) target.bar = root.bar
if ("settings" in target) target.settings = root.settings
if ("anchorItem" in target) target.anchorItem = button
if ("hostWidget" in target) target.hostWidget = root
}
function refresh() {
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
}
function togglePanel() {
if (panelLoader.item && panelLoader.item.toggle) panelLoader.item.toggle()
}
// Shape contract for shell.summon/hide/toggle routing (Bar.findPanelWidget
// requires open/close/opened on the bar-widget root). Open maps to the
// panel's hotkey path so summoning suppresses the center hover reveal,
// matching what the old per-plugin IpcHandler did.
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
function open() {
if (panelLoader.item && panelLoader.item.openFromHotkey) panelLoader.item.openFromHotkey()
}
function close() {
if (panelLoader.item && panelLoader.item.close) panelLoader.item.close()
}
// Forwarded so this widget can stand in for the panel as the bar's popout
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
// KeyboardPanel reads popoutSwitchClosing back off its owner.
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
visible: panelLoader.item && panelLoader.item.label !== ""
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
onSettingsChanged: injectPanel()
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: panelLoader.item ? panelLoader.item.label : ""
slotSize: Style.bar.statusSlot
// Tooltip suppressed because the panel is the detail view.
tooltipText: ""
onPressed: function(b) {
if (!root.bar) return
if (b === Qt.RightButton) root.bar.run("blob-notify-send \"$(blob-weather-status)\"")
else if (b === Qt.MiddleButton) root.refresh()
else root.togglePanel()
}
}
}
+295
View File
@@ -0,0 +1,295 @@
// weather.json holds {"name": ..., "latitude": ..., "longitude": ...} (see
// blob-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 locationCommit(text, suggestions, selectedIndex) {
var name = String(text || "").replace(/^\s+|\s+$/g, "")
if (name === "") return { name: "", latitude: null, longitude: null }
var choices = suggestions || []
var index = Math.max(0, Math.min(parseInt(selectedIndex, 10) || 0, choices.length - 1))
var suggestion = choices[index]
if (suggestion) return suggestion
return { name: name, latitude: null, longitude: null }
}
function isFutureForecastDate(dateString, todayString) {
if (!dateString) return false
return String(dateString).slice(0, 10) > String(todayString || "")
}
function roundedTemp(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : String(Math.round(n))
}
function celsiusToFahrenheit(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : (n * 9 / 5) + 32
}
function formatTemp(value, useImperial) {
if (value === undefined || value === null || value === "") return ""
return value + "°" + (useImperial ? "F" : "C")
}
function normalizedUnit(value) {
return String(value || "").replace(/^\s+|\s+$/g, "").toLowerCase()
}
function localeUsesImperial(localeName) {
var name = String(localeName || "").replace(".", "_")
return /^en[_-]US($|[_.-])/.test(name) || /^en[_-]LR($|[_.-])/.test(name) || /^my($|[_.-])/.test(name)
}
function countryUsesImperial(countryName) {
var country = String(countryName || "")
.replace(/^\s+|\s+$/g, "")
.replace(/[._-]+/g, " ")
.toLowerCase()
if (!country) return null
if (country === "us" || country === "usa" || country === "united states" || country === "united states of america") return true
if (country === "liberia" || country === "myanmar" || country === "burma") return true
return false
}
function shouldUseImperial(unitOverride, localeName, countryName) {
var unit = normalizedUnit(unitOverride)
if (unit === "imperial") return true
if (unit === "metric") return false
var countryPreference = countryUsesImperial(countryName)
if (countryPreference !== null) return countryPreference
return localeUsesImperial(localeName)
}
function dayName(dateString, formatter) {
if (!dateString) return ""
var d = new Date(dateString + "T12:00:00")
if (isNaN(d.getTime())) return ""
if (formatter) return formatter(d)
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()]
}
function openMeteoForecastDays(dailyForecastReport, todayString) {
var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null
if (!daily || !daily.time) return []
var result = []
for (var i = 0; i < daily.time.length && result.length < 3; ++i) {
var date = daily.time[i]
if (!isFutureForecastDate(date, todayString)) continue
var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : ""
var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : ""
result.push({
date: date,
maxtempC: roundedTemp(maxC),
mintempC: roundedTemp(minC),
maxtempF: roundedTemp(celsiusToFahrenheit(maxC)),
mintempF: roundedTemp(celsiusToFahrenheit(minC)),
openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null
})
}
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),
openMeteoWeatherCode: current.weather_code,
isDay: current.is_day
}
}
function currentIcon(current, fallback) {
if (!current) return fallback || ""
if (current.openMeteoWeatherCode !== undefined && current.openMeteoWeatherCode !== null)
return iconForOpenMeteoCode(current.openMeteoWeatherCode, Number(current.isDay) === 0)
if (current.weatherCode !== undefined && current.weatherCode !== null)
return iconForCode(current.weatherCode, false)
return fallback || ""
}
// wttr.in has no day/night flag. Use its icon only to fill an empty initial
// state, never to replace a day/night-aware icon resolved by Open-Meteo.
function provisionalCurrentIcon(current, resolvedIcon) {
return resolvedIcon || currentIcon(current, "")
}
function weatherResponseCompletesSave(hasConfiguredCoordinates, source) {
return hasConfiguredCoordinates ? source === "open-meteo" : source === "wttr"
}
function wttrNextForecastDays(report, todayString) {
var days = report && report.weather ? report.weather : []
var result = []
for (var i = 0; i < days.length && result.length < 3; ++i) {
if (isFutureForecastDate(days[i].date, todayString)) result.push(days[i])
}
return result
}
function buildForecastDays(report, dailyForecastReport, todayString) {
var days = openMeteoForecastDays(dailyForecastReport, todayString)
return days.length > 0 ? days : wttrNextForecastDays(report, todayString)
}
function bareTempForDay(day, kind, useImperial) {
if (!day) return ""
var v = useImperial
? (kind === "max" ? day.maxtempF : day.mintempF)
: (kind === "max" ? day.maxtempC : day.mintempC)
if (v === undefined || v === null || v === "") return ""
return v + "°"
}
function dayIcon(day) {
if (!day) return ""
if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null)
return iconForOpenMeteoCode(day.openMeteoWeatherCode)
if (!day.hourly || day.hourly.length === 0) return ""
var best = day.hourly[0]
var bestDist = 9999
for (var i = 0; i < day.hourly.length; ++i) {
var t = parseInt(String(day.hourly[i].time || "0"), 10)
var dist = Math.abs(t - 1200)
if (dist < bestDist) {
bestDist = dist
best = day.hourly[i]
}
}
return iconForCode(best.weatherCode, false)
}
function iconForOpenMeteoCode(code, night) {
var c = parseInt(String(code || "0"), 10)
if (c === 0) return iconForCode(113, night)
if (c === 1 || c === 2) return iconForCode(116, night)
if (c === 3) return iconForCode(119, night)
if (c === 45 || c === 48) return iconForCode(143, night)
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, night)
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, night)
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, night)
if (c === 95 || c === 96 || c === 99) return iconForCode(389, night)
return iconForCode(119, night)
}
function iconForCode(code, night) {
var c = parseInt(String(code || "0"), 10)
switch (c) {
case 113: return night ? "" : ""
case 116: return night ? "" : ""
case 119: case 122: return ""
case 143: case 248: case 260: return night ? "\ue346" : "\ue313"
case 176: case 263: case 353: return night ? "" : ""
case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : ""
case 182: case 185: case 281: case 284: case 311: case 314:
case 317: case 320: case 350: case 362: case 365: case 374: case 377: return ""
case 200: case 386: case 389: case 392: case 395: return ""
case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return ""
case 329: case 332: case 335: case 338: case 371: return ""
default: return ""
}
}
if (typeof module !== "undefined") {
module.exports = {
parseLocationFile: parseLocationFile,
wttrLocationQuery: wttrLocationQuery,
parseGeocodingResults: parseGeocodingResults,
locationCommit: locationCommit,
isFutureForecastDate: isFutureForecastDate,
roundedTemp: roundedTemp,
celsiusToFahrenheit: celsiusToFahrenheit,
formatTemp: formatTemp,
normalizedUnit: normalizedUnit,
localeUsesImperial: localeUsesImperial,
countryUsesImperial: countryUsesImperial,
shouldUseImperial: shouldUseImperial,
dayName: dayName,
openMeteoForecastDays: openMeteoForecastDays,
openMeteoCurrentCondition: openMeteoCurrentCondition,
currentIcon: currentIcon,
provisionalCurrentIcon: provisionalCurrentIcon,
weatherResponseCompletesSave: weatherResponseCompletesSave,
wttrNextForecastDays: wttrNextForecastDays,
buildForecastDays: buildForecastDays,
bareTempForDay: bareTempForDay,
dayIcon: dayIcon,
iconForOpenMeteoCode: iconForOpenMeteoCode,
iconForCode: iconForCode
}
}
+881
View File
@@ -0,0 +1,881 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.weather"
ipcTarget: "blob.weather"
manageIpc: false
property var anchorItem: null
property bool openedFromHotkey: false
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
// nested panel. Everything the bar identifies a panel by has to be that
// widget: the popout coordinator (and with it the open-panel dot under the
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
// slot up the same way.
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
function open() {
openedFromHotkey = false
setCenterHoverRevealSuppressed(false)
root.controller.show()
locationFile.reload()
root.refresh()
}
function openFromHotkey() {
openedFromHotkey = true
root.controller.show()
locationFile.reload()
root.refresh()
// Set after showing, not before: showing hands the popout coordinator
// over, which closes whichever panel was open, and that close clears the
// shared flag. Deferring means the panel taking over always wins, while
// a handoff to a panel that does not manage the flag still leaves it
// cleared rather than stuck on.
Qt.callLater(function() {
if (root.opened) setCenterHoverRevealSuppressed(true)
})
}
function close() {
setCenterHoverRevealSuppressed(false)
if (root.editingLocation) root.cancelEditingLocation()
root.controller.hide()
}
function toggle() {
if (root.opened) root.close()
else root.openFromHotkey()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
function setCenterHoverRevealSuppressed(value) {
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
root.bar.setCenterHoverRevealSuppressed(value)
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
root.bar.centerHoverRevealSuppressed = value
}
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
property var report: null
property var dailyForecastReport: null
property string wttrLocation: ""
// Configured location, read from the weather.json state file (owned by
// blob-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)
// Keep the previous report visible while the new location loads. The
// editor remains open with a spinner, so stale data is never presented
// under the newly configured location label.
onLocationQueryChanged: {
if (savingLocation) savingLocationQueryStarted = true
forecastRetries = 0
dailyForecastRetries = 0
forecastProc.running = false
dailyForecastProc.running = false
Qt.callLater(refresh)
}
property FileView locationFile: FileView {
path: Quickshell.env("HOME") + "/.local/state/blob/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
property int dailyForecastRetries: 0
// Click-to-edit state for the location label.
property bool editingLocation: false
property bool savingLocation: false
property bool savingLocationQueryStarted: false
property var locationSuggestions: []
property int suggestionIndex: 0
property string geocodePendingQuery: ""
property string geocodeActiveQuery: ""
// Shared hero/bar icon state, updated with each successful weather response.
property string label: ""
// 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 bool hasConfiguredCoordinates: !isNaN(parseFloat(String(configuredLocationState.latitude))) && !isNaN(parseFloat(String(configuredLocationState.longitude)))
readonly property var openMeteoCurrent: Model.openMeteoCurrentCondition(dailyForecastReport)
readonly property var current: (hasConfiguredCoordinates && openMeteoCurrent) ? openMeteoCurrent : ((report && report.current_condition && report.current_condition[0]) ? report.current_condition[0] : openMeteoCurrent)
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
readonly property var forecastDays: buildForecastDays()
readonly property string reportCountry: areaInfo && areaInfo.country && areaInfo.country[0] ? areaInfo.country[0].value : ""
readonly property bool useImperial: Model.shouldUseImperial(setting("unit", ""), Qt.locale().name, reportCountry)
// 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 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 tempUnit: "°" + (useImperial ? "F" : "C")
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : ""
readonly property string reportHumidity: current ? (current.humidity + "%") : ""
function refresh() {
// Each full refresh cycle gets a fresh retry budget, so an earlier
// exhausted round (e.g. waking with the network still down) doesn't
// starve retries for the rest of the session.
forecastRetries = 0
dailyForecastRetries = 0
if (!forecastProc.running) forecastProc.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) {
if (dailyForecastProc.running) return
var lat = parseFloat(String(root.configuredLocationState.latitude))
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
var url = "https://api.open-meteo.com/v1/forecast"
+ "?latitude=" + encodeURIComponent(String(lat))
+ "&longitude=" + encodeURIComponent(String(lon))
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
+ "&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day"
+ "&forecast_days=4"
+ "&timezone=auto"
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
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
savingLocation = false
savingLocationQueryStarted = false
locationSuggestions = []
suggestionIndex = 0
Qt.callLater(function() {
locationField.text = root.configuredLocation
locationField.selectAll()
locationField.forceActiveFocus()
})
}
function cancelEditingLocation() {
editingLocation = false
savingLocation = false
savingLocationQueryStarted = false
locationSuggestions = []
geocodeDebounce.stop()
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
function commitLocation() {
var location = Model.locationCommit(locationField.text, locationSuggestions, suggestionIndex)
if (location.name === "") {
clearLocation()
return
}
savingLocation = true
savingLocationQueryStarted = false
configuredLocationState = {
name: location.name,
latitude: location.latitude,
longitude: location.longitude
}
persistLocation(location.name, location.latitude, location.longitude)
}
function clearLocation() {
persistLocation("", null, null)
wttrLocation = ""
cancelEditingLocation()
}
function pickSuggestion(suggestion) {
if (!suggestion) return
savingLocation = true
savingLocationQueryStarted = false
configuredLocationState = {
name: suggestion.name,
latitude: suggestion.latitude,
longitude: suggestion.longitude
}
persistLocation(suggestion.name, suggestion.latitude, suggestion.longitude)
}
function finishSavingLocation() {
if (savingLocation && savingLocationQueryStarted) cancelEditingLocation()
}
function persistLocation(name, latitude, longitude) {
if (name && latitude !== null && longitude !== null)
locationSaveProc.command = ["blob-weather-location", "--set", name, latitude + "," + longitude]
else if (name)
locationSaveProc.command = ["blob-weather-location", "--set", name]
else
locationSaveProc.command = ["blob-weather-location", "--clear"]
locationSaveProc.running = true
}
// 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() {
return Model.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function openMeteoForecastDays() {
return Model.openMeteoForecastDays(dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function wttrNextForecastDays() {
return Model.wttrNextForecastDays(report, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function isFutureForecastDate(dateString) {
return Model.isFutureForecastDate(dateString, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function roundedTemp(value) {
return Model.roundedTemp(value)
}
function celsiusToFahrenheit(value) {
return Model.celsiusToFahrenheit(value)
}
function formatTemp(value) {
return Model.formatTemp(value, useImperial)
}
function dayName(dateString) {
return Model.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") })
}
// Bare degree value (no unit letter), used in the forecast row.
function bareTempForDay(day, kind) {
return Model.bareTempForDay(day, kind, useImperial)
}
// Representative icon for a forecast day: the hourly entry nearest noon.
function dayIcon(day) {
return Model.dayIcon(day)
}
function iconForOpenMeteoCode(code) {
return Model.iconForOpenMeteoCode(code)
}
// Mirrors blob-weather-icon's wttr.in code → nerd-font glyph mapping.
function iconForCode(code, night) {
return Model.iconForCode(code, night)
}
Process {
id: forecastProc
command: ["curl", "-fsS", "--max-time", "10", "https://wttr.in/" + root.locationQuery + "?format=j1"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) {
root.scheduleForecastRetry()
return
}
try {
var parsed = JSON.parse(raw)
root.report = parsed
if (!root.hasConfiguredCoordinates)
root.label = Model.provisionalCurrentIcon(parsed.current_condition && parsed.current_condition[0], root.label)
root.forecastRetries = 0
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "wttr"))
root.finishSavingLocation()
// 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) {
// 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
}
// With configured coordinates this fetch is the only thing that updates the
// bar icon, so a dropped response (e.g. waking before the network is back)
// must retry rather than wait out the refresh timer with a stale icon.
function scheduleDailyForecastRetry() {
if (dailyForecastRetries >= 3) return
dailyForecastRetries++
dailyForecastRetryTimer.restart()
}
Timer {
id: dailyForecastRetryTimer
interval: 2500
onTriggered: root.refreshDailyForecast(null)
}
Process {
id: dailyForecastProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) {
root.scheduleDailyForecastRetry()
return
}
try {
var parsed = JSON.parse(raw)
var parsedCurrent = Model.openMeteoCurrentCondition(parsed)
root.dailyForecastReport = parsed
root.label = Model.currentIcon(parsedCurrent, root.label)
root.dailyForecastRetries = 0
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo"))
root.finishSavingLocation()
} catch (e) {
// Keep last-good daily forecast visible, but try again shortly.
root.scheduleDailyForecastRetry()
}
}
}
}
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 {
id: locationSaveProc
onExited: function(exitCode) {
if (exitCode !== 0 || !root.savingLocation) return
// FileView handles changed locations. Explicitly refresh here too so
// saving the already-active location cannot strand the spinner.
locationFile.reload()
if (!root.savingLocationQueryStarted) {
root.savingLocationQueryStarted = true
root.forecastRetries = 0
root.dailyForecastRetries = 0
forecastProc.running = false
dailyForecastProc.running = false
Qt.callLater(root.refresh)
}
}
}
Process {
id: locationProc
command: ["curl", "-fsS", "--max-time", "4", "https://wttr.in/?format=%l"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) return
root.wttrLocation = raw.split(",")[0]
}
}
}
Timer {
id: refreshTimer
interval: root.refreshMinutes * 60 * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.openFromHotkey() }
function close(): void { root.close() }
function show(): void { root.openFromHotkey() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function edit(): void { root.openFromHotkey(); root.startEditingLocation() }
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
centerOnBar: true
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(480))
contentHeight: panel.fittedContentHeight(weatherColumn.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: root.editingLocation
onReturnRequested: root.startEditingLocation()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Flickable {
id: weatherScroll
anchors.fill: parent
contentWidth: width
contentHeight: weatherColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height
Column {
id: weatherColumn
width: weatherScroll.width
spacing: Style.space(14)
// ---- Hero row: big icon + temp on the left; location and stats stacked on the right.
Item {
width: parent.width
height: Math.max(heroLeft.height, heroRight.height)
Row {
id: heroLeft
anchors.left: parent.left
anchors.leftMargin: Style.space(16)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(16)
Text {
id: heroIcon
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 5
text: root.label || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
// Decorative condition emoji; intentionally larger than the
// Style.font.* scale's displayLarge (28).
font.pixelSize: 64
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
id: tempBig
textFormat: Text.PlainText
text: root.reportTempNum || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
// Hero temperature read-out; deliberately oversized, outside
// the Style.font.* scale.
font.pixelSize: 56
font.bold: true
}
Text {
textFormat: Text.PlainText
text: root.current ? root.tempUnit : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
anchors.top: tempBig.top
anchors.topMargin: Style.space(10)
}
}
}
Column {
id: heroRight
width: weatherStats.implicitWidth
anchors.right: parent.right
anchors.rightMargin: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(12)
Row {
visible: !root.editingLocation && root.reportLocation !== ""
spacing: Style.space(6)
TapHandler {
onTapped: root.startEditingLocation()
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
Text {
text: "" // nf-fa-map_marker
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
anchors.verticalCenter: parent.verticalCenter
}
Text {
textFormat: Text.PlainText
text: (root.reportLocation || "").toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
font.letterSpacing: 1
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
visible: root.editingLocation
spacing: Style.space(6)
TextField {
id: locationField
width: Style.space(190)
enabled: !root.savingLocation
placeholderText: "Search city"
foreground: root.bar.foreground
font.family: root.bar.fontFamily
onTextChanged: if (root.editingLocation && !root.savingLocation) 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. While a committed location is
// loading, this same compact affordance becomes a spinner.
Rectangle {
width: Style.space(18)
height: Style.space(18)
anchors.verticalCenter: parent.verticalCenter
radius: Math.min(4, Style.cornerRadius)
color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.savingLocation ? "󰦖" : "✕"
font.family: root.bar.fontFamily
color: Qt.darker(root.bar.foreground, 1.4)
font.pixelSize: Style.font.bodySmall
RotationAnimator on rotation {
running: root.savingLocation
from: 0; to: 360
duration: 800
loops: Animation.Infinite
}
}
MouseArea {
id: clearLocationArea
anchors.fill: parent
enabled: !root.savingLocation
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.clearLocation()
}
}
}
Row {
id: weatherStats
visible: !!root.current
spacing: Style.space(36)
Column {
spacing: Style.space(5)
Text {
text: "FEELS"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportFeels
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
Column {
spacing: Style.space(5)
Text {
text: "WIND"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportWind
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
Column {
spacing: Style.space(5)
Text {
text: "HUMID"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportHumidity
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
}
}
}
// ---- Geocoding suggestions while the location is being edited.
Column {
visible: root.editingLocation && !root.savingLocation && 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 {
textFormat: Text.PlainText
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 {
textFormat: Text.PlainText
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 {
visible: !root.current
text: "Fetching forecast…"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.italic: true
}
// ---- Divider between current conditions and forecast.
Rectangle {
visible: root.forecastDays.length > 0
width: parent.width
height: Style.spacing.hairline
color: root.bar.foreground
opacity: 0.12
}
// ---- Forecast row: each cell has the day icon left of a day-name + hi/lo column.
// Wrapped in an Item so the block of cells can be centered within the popup.
Item {
visible: root.forecastDays.length > 0
width: parent.width
height: forecastRow.height
Row {
id: forecastRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(44)
Repeater {
model: root.forecastDays
Row {
required property var modelData
required property int index
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.dayIcon(modelData)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
textFormat: Text.PlainText
text: root.dayName(modelData.date).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
}
Row {
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: root.bareTempForDay(modelData, "max")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
text: root.bareTempForDay(modelData, "min")
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
}
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "blob.weather",
"name": "Weather",
"version": "1.0.0",
"author": "Blob",
"description": "Weather pill with detail popup",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Weather",
"description": "Weather pill with detail popup",
"category": "Info",
"allowMultiple": false,
"settingsForm": "weatherSettings"
}
}