diff --git a/shell/plugins/panels/weather/Model.js b/shell/plugins/panels/weather/Model.js index 6d919396..8d122c47 100644 --- a/shell/plugins/panels/weather/Model.js +++ b/shell/plugins/panels/weather/Model.js @@ -179,10 +179,25 @@ function openMeteoCurrentCondition(dailyForecastReport) { 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) + 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 || "" +} + +function weatherResponseCompletesSave(hasConfiguredCoordinates, source) { + return hasConfiguredCoordinates ? source === "open-meteo" : source === "wttr" +} + function wttrNextForecastDays(report, todayString) { var days = report && report.weather ? report.weather : [] var result = [] @@ -225,17 +240,17 @@ function dayIcon(day) { return iconForCode(best.weatherCode, false) } -function iconForOpenMeteoCode(code) { +function iconForOpenMeteoCode(code, night) { var c = parseInt(String(code || "0"), 10) - if (c === 0) return iconForCode(113, false) - if (c === 1 || c === 2) return iconForCode(116, false) - if (c === 3) return iconForCode(119, false) - if (c === 45 || c === 48) return iconForCode(143, false) - if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, false) - if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, false) - if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, false) - if (c === 95 || c === 96 || c === 99) return iconForCode(389, false) - return iconForCode(119, false) + 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) { @@ -244,7 +259,7 @@ function iconForCode(code, night) { case 113: return night ? "" : "" case 116: return night ? "" : "" case 119: case 122: return "" - case 143: case 248: case 260: 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: @@ -274,6 +289,8 @@ if (typeof module !== "undefined") { dayName: dayName, openMeteoForecastDays: openMeteoForecastDays, openMeteoCurrentCondition: openMeteoCurrentCondition, + currentIcon: currentIcon, + weatherResponseCompletesSave: weatherResponseCompletesSave, wttrNextForecastDays: wttrNextForecastDays, buildForecastDays: buildForecastDays, bareTempForDay: bareTempForDay, diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index a9669822..9e6a0afd 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -58,13 +58,11 @@ Panel { 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. + // 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: { - report = null - dailyForecastReport = null - wttrLocation = "" + if (savingLocation) savingLocationQueryStarted = true forecastRetries = 0 forecastProc.running = false dailyForecastProc.running = false @@ -94,24 +92,23 @@ Panel { // 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: "" - // Bar pill state. Polled locally; populated by weatherProc below. + // Shared hero/bar icon state, updated with each successful weather response. property string label: "" property string klass: "" - function updateWeather(raw) { - var data = Model.parseWeatherStatus(raw) - label = data.label - klass = data.klass - } - // 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 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 string currentIcon: Model.currentIcon(current, label) 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 : "" @@ -154,7 +151,7 @@ Panel { + "?latitude=" + encodeURIComponent(String(lat)) + "&longitude=" + encodeURIComponent(String(lon)) + "&daily=weather_code,temperature_2m_max,temperature_2m_min" - + "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m" + + "¤t=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] @@ -166,6 +163,8 @@ Panel { // 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() { @@ -177,6 +176,8 @@ Panel { function cancelEditingLocation() { editingLocation = false + savingLocation = false + savingLocationQueryStarted = false locationSuggestions = [] geocodeDebounce.stop() Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) @@ -188,8 +189,14 @@ Panel { clearLocation() return } + savingLocation = true + savingLocationQueryStarted = false + configuredLocationState = { + name: location.name, + latitude: location.latitude, + longitude: location.longitude + } persistLocation(location.name, location.latitude, location.longitude) - cancelEditingLocation() } function clearLocation() { @@ -200,17 +207,28 @@ Panel { 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) - cancelEditingLocation() + } + + function finishSavingLocation() { + if (savingLocation && savingLocationQueryStarted) cancelEditingLocation() } function persistLocation(name, latitude, longitude) { if (name && latitude !== null && longitude !== null) - Quickshell.execDetached(["omarchy-weather-location", "--set", name, latitude + "," + longitude]) + locationSaveProc.command = ["omarchy-weather-location", "--set", name, latitude + "," + longitude] else if (name) - Quickshell.execDetached(["omarchy-weather-location", "--set", name]) + locationSaveProc.command = ["omarchy-weather-location", "--set", name] else - Quickshell.execDetached(["omarchy-weather-location", "--clear"]) + locationSaveProc.command = ["omarchy-weather-location", "--clear"] + locationSaveProc.running = true } // Debounced geocoding. Only one curl runs at a time; if the query moved on @@ -297,7 +315,11 @@ Panel { try { var parsed = JSON.parse(raw) root.report = parsed + if (!root.hasConfiguredCoordinates) + root.label = Model.currentIcon(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)))) @@ -332,7 +354,12 @@ Panel { var raw = String(text || "").trim() if (!raw) return try { - root.dailyForecastReport = JSON.parse(raw) + var parsed = JSON.parse(raw) + var parsedCurrent = Model.openMeteoCurrentCondition(parsed) + root.dailyForecastReport = parsed + root.label = Model.currentIcon(parsedCurrent, root.label) + if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo")) + root.finishSavingLocation() } catch (e) { // Keep last-good daily forecast on parse failure. } @@ -358,6 +385,24 @@ Panel { 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 + forecastProc.running = false + dailyForecastProc.running = false + Qt.callLater(root.refresh) + } + } + } + Process { id: locationProc command: ["curl", "-fsS", "--max-time", "4", "https://wttr.in/?format=%l"] @@ -439,7 +484,7 @@ Panel { id: heroIcon anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 5 - text: root.label || "—" + text: root.currentIcon || "—" color: root.bar.foreground font.family: root.bar.fontFamily // Decorative condition emoji; intentionally larger than the @@ -474,6 +519,7 @@ Panel { Column { id: heroRight + width: weatherStats.implicitWidth anchors.right: parent.right anchors.rightMargin: Style.space(20) anchors.verticalCenter: parent.verticalCenter @@ -514,11 +560,12 @@ Panel { 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) geocodeDebounce.restart() + onTextChanged: if (root.editingLocation && !root.savingLocation) geocodeDebounce.restart() Keys.onPressed: function(event) { if (event.key === Qt.Key_Escape) { @@ -537,34 +584,43 @@ Panel { } } - // Clear back to IP auto-detect. Committing an empty field does - // the same for keyboard users. + // 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: clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" + color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" Text { anchors.centerIn: parent - text: "✕" + 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: Qt.PointingHandCursor + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: root.clearLocation() } } } Row { + id: weatherStats visible: !!root.current spacing: Style.space(36) @@ -624,7 +680,7 @@ Panel { // ---- Geocoding suggestions while the location is being edited. Column { - visible: root.editingLocation && root.locationSuggestions.length > 0 + visible: root.editingLocation && !root.savingLocation && root.locationSuggestions.length > 0 width: parent.width spacing: 0 @@ -757,21 +813,4 @@ Panel { } } - // Poll the weather pill text/class every minute. Local to this widget. - Process { - id: weatherProc - command: ["bash", "-lc", Util.shellQuote(root.omarchyPath + "/shell/plugins/panels/weather/status.sh")] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: root.updateWeather(text) - } - } - - Timer { - interval: 60000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: if (!weatherProc.running) weatherProc.running = true - } } diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index a93568bd..30d8bb60 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -108,6 +108,12 @@ assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: ' assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'min', true), '55°', 'weather formats forecast imperial lows') assert(weather.dayIcon({ openMeteoWeatherCode: 95 }).length > 0, 'weather maps Open-Meteo weather icons') +assertEqual(weather.currentIcon({ openMeteoWeatherCode: 0, isDay: 1 }, ''), weather.iconForOpenMeteoCode(0), 'weather uses the current Open-Meteo icon with current values') +assertEqual(weather.currentIcon({ openMeteoWeatherCode: 0, isDay: 0 }, ''), weather.iconForCode(113, true), 'weather uses the nighttime Open-Meteo icon after sunset') +assert(weather.iconForOpenMeteoCode(45, true) !== weather.iconForOpenMeteoCode(45, false), 'weather distinguishes nighttime fog from daytime fog') +assert(!weather.weatherResponseCompletesSave(true, 'wttr'), 'weather keeps the spinner through a non-authoritative pinned-location response') +assert(weather.weatherResponseCompletesSave(true, 'open-meteo'), 'weather completes a pinned-location save with Open-Meteo data') +assert(weather.weatherResponseCompletesSave(false, 'wttr'), 'weather completes a name-only location save with wttr data') assertEqual( weather.dayIcon({ hourly: [{ time: '900', weatherCode: 113 }, { time: '1200', weatherCode: 389 }, { time: '1800', weatherCode: 116 }] }), weather.iconForCode(389, false),