diff --git a/dotfiles/.config/quickshell/config/WeatherData.qml b/dotfiles/.config/quickshell/config/WeatherData.qml new file mode 100644 index 0000000..0240842 --- /dev/null +++ b/dotfiles/.config/quickshell/config/WeatherData.qml @@ -0,0 +1,242 @@ +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() + } +} diff --git a/dotfiles/.config/quickshell/config/qmldir b/dotfiles/.config/quickshell/config/qmldir index 858c68b..99b09d3 100644 --- a/dotfiles/.config/quickshell/config/qmldir +++ b/dotfiles/.config/quickshell/config/qmldir @@ -2,3 +2,5 @@ module config singleton Colors 1.0 Colors.qml singleton Appearance 1.0 Appearance.qml singleton VpnExclusivity 1.0 VpnExclusivity.qml +singleton SystemUpdates 1.0 SystemUpdates.qml +singleton WeatherData 1.0 WeatherData.qml diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index 42827f2..e367414 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -35,8 +35,12 @@ PanelWindow { ActiveWindow {} } - Clock { + RowLayout { anchors.centerIn: parent + spacing: Appearance.spacing + Weather { barWindow: bar } + Clock {} + SystemUpdate { barWindow: bar } } RowLayout { diff --git a/dotfiles/.config/quickshell/modules/bar/Weather.qml b/dotfiles/.config/quickshell/modules/bar/Weather.qml new file mode 100644 index 0000000..b52ccce --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Weather.qml @@ -0,0 +1,413 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import "../../config" + +// Weather indicator — modeled on omarchy's weather bar pill +// (github.com/omacom/omarchy, quattro branch, +// shell/plugins/panels/weather) but rebuilt in this bar's own style: the +// actual location/polling logic lives in the WeatherData singleton (config/), +// shared across every monitor's bar; this is just the icon + panel UI, +// same split as SystemUpdate.qml. +Item { + id: root + implicitWidth: row.implicitWidth + 2 + implicitHeight: Appearance.barHeight + + // The bar's own PanelWindow — same barWindow-anchoring convention as + // every other popup-having module here (Volume.qml, SystemUpdate.qml, + // etc.), so the panel opens in a fixed spot rather than following this + // icon's exact position in its RowLayout. + required property var barWindow + + readonly property bool busy: WeatherData.locating || WeatherData.loading + + // Local to this popup instance — unlike WeatherData's own state, there's + // no need to share an in-progress search across monitors' bars. + property bool searchOpen: false + property string searchQuery: "" + + RowLayout { + id: row + anchors.centerIn: parent + spacing: 4 + + Text { + id: label + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 18 + color: Colors.foreground + // Same spinner treatment as SystemUpdate.qml/ + // BluetoothIndicator.qml's scan spinner while the location or + // conditions are still loading. + text: root.busy ? "progress_activity" : (WeatherData.hasLocation ? WeatherData.iconFor(WeatherData.weatherCode, WeatherData.isDay) : "cloud_off") + + RotationAnimation on rotation { + running: root.busy + loops: Animation.Infinite + from: 0 + to: 360 + duration: 900 + onRunningChanged: if (!running) label.rotation = 0 + } + } + + Text { + visible: WeatherData.hasLocation && !WeatherData.locating + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + text: Math.round(WeatherData.tempC) + "°" + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + WeatherData.refresh() + } else { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + } + + PopupWindow { + id: panel + visible: false + color: "transparent" + // Same contentWidth/contentPadding split as the other panels + // (Volume.qml, SystemUpdate.qml, etc.) rather than a single magic + // implicitWidth. + readonly property int contentWidth: 220 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + + anchor { + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar. Bottom alone (no Left/Right) + // anchors to the bar's bottom-center, which lines up with this + // icon since it sits in the bar's centered RowLayout. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom + gravity: Edges.Bottom + margins.bottom: -4 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 28 + color: Colors.foreground + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + elide: Text.ElideRight + text: { + if (WeatherData.locating) return "Locating…" + if (!WeatherData.hasLocation) return "Location unavailable" + return WeatherData.locationName || "Weather" + } + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + Text { + visible: WeatherData.hasLocation && !WeatherData.locating + text: WeatherData.loading ? "Updating…" : "Feels like " + Math.round(WeatherData.feelsLikeC) + "°" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + Text { + visible: WeatherData.hasLocation && !WeatherData.locating + text: Math.round(WeatherData.tempC) + "°" + font.family: Appearance.fontFamily + font.pixelSize: 28 + font.bold: true + color: Colors.foreground + } + + // Toggles the search box below — same tap target size + // and hover feedback as the picker-target rows in + // VpnIndicator.qml. + Text { + text: root.searchOpen ? "close" : "edit_location" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: editLocationMouse.containsMouse ? Colors.primary : Colors.muted + + MouseArea { + id: editLocationMouse + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.searchOpen = !root.searchOpen + root.searchQuery = "" + WeatherData.searchResults = [] + } + } + } + } + + // City search — always available regardless of how the + // current location was resolved (IP guess or a previous + // search), so a wrong auto-detected city is one click away + // from being fixed. Same search-box + result-list shape as + // VpnIndicator.qml's Mullvad location picker. + Column { + visible: root.searchOpen + width: parent.width + spacing: 6 + + Rectangle { + width: parent.width + implicitHeight: 24 + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + Text { + anchors.fill: parent + anchors.margins: 6 + visible: citySearchInput.text.length === 0 + text: "Search city…" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + TextInput { + id: citySearchInput + anchors.fill: parent + anchors.margins: 6 + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: root.searchOpen + text: root.searchQuery + onTextChanged: { + root.searchQuery = text + searchDebounce.restart() + } + } + + Timer { + id: searchDebounce + interval: 400 + onTriggered: WeatherData.search(root.searchQuery) + } + } + + Text { + visible: WeatherData.searching + text: "Searching…" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + visible: !WeatherData.searching && citySearchInput.text.length >= 2 && WeatherData.searchResults.length === 0 + text: "No matches" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Flickable { + width: parent.width + height: Math.min(resultsColumn.implicitHeight, 150) + contentWidth: width + contentHeight: resultsColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: resultsColumn + width: parent.width + spacing: 1 + + Repeater { + model: WeatherData.searchResults + + delegate: Rectangle { + required property var modelData + width: parent.width + implicitHeight: resultText.implicitHeight + 8 + radius: 0 + color: resultMouse.containsMouse ? Colors.primary : "transparent" + + Text { + id: resultText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 4 + elide: Text.ElideRight + text: modelData.description ? (modelData.name + " — " + modelData.description) : modelData.name + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: resultMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + + MouseArea { + id: resultMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + WeatherData.selectLocation(modelData.name, modelData.latitude, modelData.longitude) + root.searchOpen = false + root.searchQuery = "" + WeatherData.searchResults = [] + } + } + } + } + } + } + } + + // Humidity / wind — same two-stat layout, just laid out as + // a row of icon+value pairs rather than full sentences. + RowLayout { + visible: WeatherData.hasLocation && !WeatherData.locating + width: parent.width + spacing: 16 + + RowLayout { + spacing: 4 + Text { + text: "water_drop" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: Colors.muted + } + Text { + text: WeatherData.humidity + "%" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + } + + RowLayout { + spacing: 4 + Text { + text: "air" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: Colors.muted + } + Text { + text: Math.round(WeatherData.windKmh) + " km/h" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + } + } + + Rectangle { + visible: WeatherData.forecast.length > 0 + width: parent.width + height: 1 + color: Colors.muted + } + + RowLayout { + visible: WeatherData.forecast.length > 0 + width: parent.width + spacing: 4 + + Repeater { + model: WeatherData.forecast + + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.alignment: Qt.AlignHCenter + text: Qt.formatDateTime(new Date(modelData.date + "T12:00:00"), "ddd") + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: WeatherData.iconFor(modelData.code, true) + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 18 + color: Colors.foreground + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: Math.round(modelData.maxC) + "°/" + Math.round(modelData.minC) + "°" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + } + } + } + } + } + } +}