Merge pull request #5838 from jweaver60/weather-flyout-forecast

Add multi-day forecast to weather widget; click-only popup
This commit is contained in:
Ryan Hughes
2026-05-16 00:20:56 -04:00
committed by GitHub
3 changed files with 325 additions and 134 deletions
@@ -312,7 +312,7 @@ Item {
"calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false, settingsForm: "calendarSettings" },
"notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND", category: "Status", allowMultiple: false },
"systemStats": { displayName: "System stats", description: "CPU icon — hover for graphs, click to open btop", category: "System", allowMultiple: false },
"weatherFlyout": { displayName: "Weather", description: "Weather pill with detail popup", category: "Info", allowMultiple: false },
"weatherFlyout": { displayName: "Weather", description: "Weather pill with detail popup", category: "Info", allowMultiple: false, settingsForm: "weatherSettings" },
"idleInhibitor": { displayName: "Keep awake", description: "Toggle idle inhibitor", category: "System", allowMultiple: false },
"microphone": { displayName: "Microphone", description: "Mic input state and mute toggle", category: "Audio", allowMultiple: false },
"activeWindow": { displayName: "Active window", description: "Title of the focused window", category: "Compositor", allowMultiple: false },
@@ -13,62 +13,139 @@ Item {
property bool popupOpen: false
function closePopout() { popupOpen = false }
property string fullReport: ""
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
property var report: null
readonly property string label: bar ? bar.weatherText : ""
readonly property string klass: bar ? bar.weatherClass : ""
// Pipe-delimited fields from wttr.in: location|condition|temp|feels|wind|humidity
readonly property var reportFields: String(fullReport || "").split("|")
readonly property string reportLocation: reportFields.length > 0 ? String(reportFields[0] || "").trim() : ""
readonly property string reportCondition: reportFields.length > 1 ? String(reportFields[1] || "").trim() : ""
readonly property string reportTemp: reportFields.length > 2 ? String(reportFields[2] || "").trim() : ""
readonly property string reportFeels: reportFields.length > 3 ? String(reportFields[3] || "").trim() : ""
readonly property string reportWind: reportFields.length > 4 ? String(reportFields[4] || "").trim() : ""
readonly property string reportHumidity: reportFields.length > 5 ? String(reportFields[5] || "").trim() : ""
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: report && report.weather ? report.weather : []
readonly property bool useImperial: {
var override = setting("unit", "")
if (override === "imperial") return true
if (override === "metric") return false
var name = String(Qt.locale().name || "")
return /^en_US/.test(name) || /^en_LR/.test(name) || /^my/.test(name)
}
// 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: areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : ""
readonly property string reportCondition: current && current.weatherDesc && current.weatherDesc[0] ? current.weatherDesc[0].value : ""
readonly property string reportTemp: current ? formatTemp(useImperial ? current.temp_F : current.temp_C) : ""
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 + "%") : ""
visible: label !== ""
implicitWidth: button.implicitWidth + 8
implicitHeight: button.implicitHeight
function setting(name, fallback) {
var v = settings ? settings[name] : undefined
return v === undefined || v === null ? fallback : v
}
function refresh() {
if (!forecastProc.running) forecastProc.running = true
}
// Hover state across the trigger button and the popup.
property bool buttonHovered: false
property bool popupHovered: popup.containsMouse
function showPopup() {
hideTimer.stop()
if (!popupOpen) refresh()
popupOpen = true
function formatTemp(value) {
if (value === undefined || value === null || value === "") return ""
return value + "°" + (useImperial ? "F" : "C")
}
function scheduleHide() {
hideTimer.restart()
function dayName(dateString) {
if (!dateString) return ""
var d = new Date(dateString + "T12:00:00")
if (isNaN(d.getTime())) return ""
return Qt.formatDate(d, "ddd")
}
Timer {
id: hideTimer
interval: 220
onTriggered: {
if (!root.buttonHovered && !root.popupHovered) root.popupOpen = false
function maxTempForDay(day) {
if (!day) return ""
return formatTemp(useImperial ? day.maxtempF : day.maxtempC)
}
function minTempForDay(day) {
if (!day) return ""
return formatTemp(useImperial ? day.mintempF : day.mintempC)
}
// Bare degree value (no unit letter), used in the forecast row.
function bareTempForDay(day, kind) {
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 + "°"
}
// Representative icon for a forecast day: the hourly entry nearest noon.
function dayIcon(day) {
if (!day || !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)
}
// Mirrors omarchy-weather-icon's wttr.in code → nerd-font glyph mapping.
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 ""
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 ""
}
}
onButtonHoveredChanged: buttonHovered ? showPopup() : scheduleHide()
onPopupHoveredChanged: popupHovered ? hideTimer.stop() : scheduleHide()
Process {
id: forecastProc
command: ["bash", "-lc", "curl -fsS --max-time 5 'wttr.in/?T0&format=%l|%C|%t|%f|%w|%h' 2>/dev/null"]
command: ["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=j1' 2>/dev/null"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.fullReport = String(text || "").trim()
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) return
try {
root.report = JSON.parse(raw)
} catch (e) {
// Keep last-good report on parse failure so the popup isn't blanked.
}
}
}
}
Timer {
id: refreshTimer
interval: root.refreshMinutes * 60 * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
Common.WidgetButton {
id: button
anchors.left: parent.left
@@ -83,71 +160,163 @@ Item {
tooltipText: ""
onPressed: function(b) {
if (b === Qt.RightButton) root.bar.run("omarchy-notification-send \"$(omarchy-weather-status)\"")
else if (b === Qt.MiddleButton) root.refresh()
if (b === Qt.RightButton) {
root.bar.run("omarchy-notification-send \"$(omarchy-weather-status)\"")
} else if (b === Qt.MiddleButton) {
root.refresh()
} else {
var willOpen = !root.popupOpen
root.popupOpen = willOpen
if (willOpen) root.refresh()
}
}
}
HoverHandler {
id: hoverHandler
target: button
onHoveredChanged: root.buttonHovered = hovered
}
Common.PopupCard {
id: popup
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
triggerMode: "hover"
contentWidth: 320
triggerMode: "click"
contentWidth: 520
contentHeight: card.implicitHeight + 28
Column {
id: card
anchors.fill: parent
spacing: 12
spacing: 14
Row {
// ---- Hero row: big icon + temp on the left; location and stats stacked on the right.
Item {
width: parent.width
spacing: 10
height: Math.max(heroLeft.height, heroRight.height)
Text {
id: glyph
text: root.label || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 24
anchors.verticalCenter: location.verticalCenter
}
Text {
id: location
text: root.reportLocation || "Weather"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 13
font.bold: true
Row {
id: heroLeft
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: parent.width - glyph.width - parent.spacing
elide: Text.ElideRight
spacing: 12
Text {
id: heroIcon
anchors.verticalCenter: parent.verticalCenter
text: root.label || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 64
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
id: tempBig
text: root.reportTempNum || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 56
font.bold: true
}
Text {
text: root.current ? root.tempUnit : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 22
anchors.top: tempBig.top
anchors.topMargin: 10
}
}
}
Column {
id: heroRight
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Row {
visible: root.reportLocation !== ""
spacing: 6
Text {
text: "" // nf-fa-map_marker
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: (root.reportLocation || "").toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 12
font.letterSpacing: 1
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
visible: !!root.current
spacing: 36
Column {
spacing: 5
Text {
text: "FEELS"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.letterSpacing: 1
}
Text {
text: root.reportFeels
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 15
}
}
Column {
spacing: 5
Text {
text: "WIND"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.letterSpacing: 1
}
Text {
text: root.reportWind
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 15
}
}
Column {
spacing: 5
Text {
text: "HUMIDITY"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.letterSpacing: 1
}
Text {
text: root.reportHumidity
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 15
}
}
}
}
}
Text {
visible: text !== ""
text: root.reportCondition
color: Qt.darker(root.bar.foreground, 1.2)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.italic: true
wrapMode: Text.WordWrap
width: parent.width
}
Text {
visible: root.fullReport === ""
visible: !root.current
text: "Fetching forecast…"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
@@ -155,74 +324,68 @@ Item {
font.italic: true
}
// Key/value pairs — each metric on its own line for scanability.
Grid {
visible: root.reportTemp !== ""
// ---- Divider between current conditions and forecast.
Rectangle {
visible: root.forecastDays.length > 0
width: parent.width
columns: 2
columnSpacing: 12
rowSpacing: 4
Text {
text: "Temperature"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 11
}
Text {
text: root.reportTemp + (root.reportFeels && root.reportFeels !== root.reportTemp ? " (feels " + root.reportFeels + ")" : "")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
}
Text {
text: "Wind"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 11
}
Text {
text: root.reportWind
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
}
Text {
text: "Humidity"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 11
}
Text {
text: root.reportHumidity
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
}
height: 1
color: root.bar.foreground
opacity: 0.12
}
// ---- Forecast row: each cell has the day icon left of a day-name + hi/lo column.
Row {
id: forecastRow
visible: root.forecastDays.length > 0
width: parent.width
spacing: 8
Common.PillButton {
iconText: "󰑐"
text: "Refresh"
foreground: root.bar.foreground
horizontalPadding: 12
verticalPadding: 6
onClicked: root.refresh()
}
Repeater {
model: root.forecastDays
Common.PillButton {
iconText: "󰏌"
text: "wttr.in"
foreground: root.bar.foreground
horizontalPadding: 12
verticalPadding: 6
onClicked: { root.bar.run("xdg-open https://wttr.in"); root.popupOpen = false }
Row {
required property var modelData
required property int index
width: forecastRow.width / Math.max(1, root.forecastDays.length)
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.dayIcon(modelData)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 24
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: root.dayName(modelData.date).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 10
font.letterSpacing: 1
}
Row {
spacing: 6
Text {
text: root.bareTempForDay(modelData, "max")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
}
Text {
text: root.bareTempForDay(modelData, "min")
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 12
}
}
}
}
}
}
}
@@ -2497,6 +2497,7 @@ Item {
switch (meta.settingsForm) {
case "spacerSettings": return spacerSettingsComponent
case "calendarSettings": return calendarSettingsComponent
case "weatherSettings": return weatherSettingsComponent
}
}
var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null
@@ -2644,6 +2645,33 @@ Item {
}
}
Component {
id: weatherSettingsComponent
Column {
id: weatherForm
signal fieldChanged(string key, var value)
property var entry: ({})
spacing: 8
width: parent ? parent.width : 0
Text {
text: "Auto-refresh interval (minutes)"
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: 11
}
SpinBox {
from: 1
to: 1440
value: weatherForm.entry.refreshMinutes !== undefined ? weatherForm.entry.refreshMinutes : 15
onValueModified: weatherForm.fieldChanged("refreshMinutes", value)
}
}
}
// ===================== plugins category ==================================
component PluginManager: Column {
id: pm