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() root.refresh() } function openFromHotkey() { openedFromHotkey = true setCenterHoverRevealSuppressed(true) root.controller.show() root.refresh() } function close() { setCenterHoverRevealSuppressed(false) 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: "" // Bar pill state. Polled locally; populated by weatherProc below. property string label: "" property string klass: "" function updateWeather(raw) { var data = Model.parseWeatherStatus(raw) label = data.label klass = data.klass } readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[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 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: 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 (!locationProc.running) locationProc.running = true } function refreshDailyForecast(sourceReport) { var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo if (!area || dailyForecastProc.running) return var lat = parseFloat(String(area.latitude || "")) var 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" + "&forecast_days=4" + "&timezone=auto" dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url] dailyForecastProc.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: ["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=j1' 2>/dev/null"] stdout: StdioCollector { waitForEnd: true onStreamFinished: { var raw = String(text || "").trim() if (!raw) return try { var parsed = JSON.parse(raw) root.report = parsed root.refreshDailyForecast(parsed) } catch (e) { // Keep last-good report on parse failure so the popup isn't blanked. } } } } Process { id: dailyForecastProc stdout: StdioCollector { waitForEnd: true onStreamFinished: { var raw = String(text || "").trim() if (!raw) return try { root.dailyForecastReport = JSON.parse(raw) } catch (e) { // Keep last-good daily forecast on parse failure. } } } } Process { id: locationProc command: ["bash", "-lc", "curl -fsS --max-time 4 'https://wttr.in?format=%l' 2>/dev/null"] 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() } } 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 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 anchors.right: parent.right anchors.rightMargin: Style.space(20) anchors.verticalCenter: parent.verticalCenter spacing: Style.space(12) Row { visible: root.reportLocation !== "" spacing: Style.space(6) 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.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 } } } } } 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 } } } } } } } } } } } // 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 } }