import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Ui import "Model.js" as Model Panel { id: root moduleName: "omarchy.weather" ipcTarget: "omarchy.weather" manageIpc: false property string omarchyPath: Quickshell.env("OMARCHY_PATH") property var anchorItem: null property bool openedFromHotkey: false function open() { openedFromHotkey = false setCenterHoverRevealSuppressed(false) root.controller.show() locationFile.reload() root.refresh() } function openFromHotkey() { openedFromHotkey = true setCenterHoverRevealSuppressed(true) root.controller.show() locationFile.reload() root.refresh() } function close() { setCenterHoverRevealSuppressed(false) if (root.editingLocation) root.cancelEditingLocation() root.controller.hide() } function toggle() { if (root.opened) root.close() else root.openFromHotkey() } function setCenterHoverRevealSuppressed(value) { 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 // 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) // 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 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 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: "" property string 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 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() { 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" + "¤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] 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 = ["omarchy-weather-location", "--set", name, latitude + "," + longitude] else if (name) locationSaveProc.command = ["omarchy-weather-location", "--set", name] else locationSaveProc.command = ["omarchy-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 omarchy-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 } Process { id: dailyForecastProc stdout: StdioCollector { waitForEnd: true onStreamFinished: { var raw = String(text || "").trim() if (!raw) return try { 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. } } } } 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 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 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 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 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { text: root.bareTempForDay(modelData, "max") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { text: root.bareTempForDay(modelData, "min") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.body } } } } } } } } } } } }