aurorab101de2
aurora: weather and system dashboard in the calendar popup
The bar clock's popup is now one card after glyph-rice's deck: calendar, weather (now, next 4 days, daylight, sunrise/sunset), system (cpu/mem/gpu rings, top processes, threads), net (rx/tx + 40s dot matrix) and media. Weather: open-meteo via curl every 30 min, cached in ~/.cache/aurora; location by IP, or a city / lat,lon in settings > shell > weather. SysStats samples the details only while the popup is open and never wakes a runtime-suspended dGPU for nvidia-smi.
+849 −86
6 files changed
- M .config/quickshell/aurora/CalPopup.qml +484 −85
- M .config/quickshell/aurora/SetShell.qml +18 −0
- M .config/quickshell/aurora/Settings.qml +3 −1
- M .config/quickshell/aurora/SysStats.qml +159 −0
- M .config/quickshell/aurora/Theme.qml +14 −0
- A .config/quickshell/aurora/Weather.qml +171 −0
| @@ -4,9 +4,11 @@ import Quickshell | |||
| 4 | 4 | import Quickshell.Wayland | |
| 5 | 5 | import Quickshell.Hyprland | |
| 6 | 6 | ||
| 7 | − | // Month calendar under the bar clock (top centre): Doto month, Monday first, ISO week numbers, today in a primary circle. | |
| 8 | − | // Scroll / arrows change the month, up / down the year, space = back to today, Esc closes. | |
| 9 | − | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), cal/Cal.qml. | |
| 7 | + | // Calendar dashboard under the bar clock (top centre): one card, sections split by dotted lines: | |
| 8 | + | // month calendar | weather (now, next 4 days, daylight) | system (cpu/mem/gpu rings, top processes, threads) | net | media. | |
| 9 | + | // Calendar: Monday first, ISO week numbers, today in a primary circle. Scroll over it / arrows change the month, | |
| 10 | + | // up / down the year, space = back to today, Esc closes. SysStats samples its details only while this is open. | |
| 11 | + | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), cal/Cal.qml and deck/Deck.qml. | |
| 10 | 12 | PanelWindow { | |
| 11 | 13 | id: win | |
| 12 | 14 | property bool opened: false | |
| @@ -33,8 +35,12 @@ PanelWindow { | |||
| 33 | 35 | onCleared: win.closeRequested() | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 38 | + | Binding { target: SysStats; property: "detailed"; value: win.opened } | |
| 39 | + | ||
| 36 | 40 | SystemClock { id: clock; precision: SystemClock.Minutes } | |
| 37 | 41 | readonly property var en: Qt.locale("en_US") | |
| 42 | + | readonly property var now: clock.date | |
| 43 | + | readonly property real h: calCol.implicitHeight // every section is as tall as the calendar | |
| 38 | 44 | ||
| 39 | 45 | property int year: 0 | |
| 40 | 46 | property int month: 0 // 0..11 | |
| @@ -48,7 +54,7 @@ PanelWindow { | |||
| 48 | 54 | year += Math.floor(m / 12) | |
| 49 | 55 | month = ((m % 12) + 12) % 12 | |
| 50 | 56 | } | |
| 51 | − | onOpenedChanged: if (opened) today() | |
| 57 | + | onOpenedChanged: if (opened) { today(); Weather.poke() } | |
| 52 | 58 | ||
| 53 | 59 | function isoWeek(d) { | |
| 54 | 60 | var t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) | |
| @@ -66,14 +72,26 @@ PanelWindow { | |||
| 66 | 72 | out.push(new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)) | |
| 67 | 73 | return out | |
| 68 | 74 | } | |
| 69 | − | readonly property var now: clock.date | |
| 75 | + | function hm(d) { return Theme.hh(d) + ":" + Qt.formatDateTime(d, "mm") } | |
| 76 | + | ||
| 77 | + | // weather of today (sunrise/sunset) and the next days | |
| 78 | + | readonly property var wToday: Weather.dayOf(now) | |
| 79 | + | readonly property var wNext: Weather.after(now, 4) | |
| 80 | + | ||
| 81 | + | // MPRIS doesn't push position updates, so poke it while playing | |
| 82 | + | Timer { | |
| 83 | + | running: win.visible && Media.playing && Media.seekable | |
| 84 | + | interval: 500 | |
| 85 | + | repeat: true | |
| 86 | + | onTriggered: Media.player.positionChanged() | |
| 87 | + | } | |
| 70 | 88 | ||
| 71 | 89 | Rectangle { | |
| 72 | 90 | id: card | |
| 73 | 91 | y: -16 * (1 - win.t) | |
| 74 | 92 | opacity: win.t | |
| 75 | − | width: 384 | |
| 76 | − | height: col.implicitHeight + 44 | |
| 93 | + | width: row.width + 52 | |
| 94 | + | height: row.height + 48 | |
| 77 | 95 | radius: 36 | |
| 78 | 96 | color: Theme.fade(Theme.s1, Theme.panelAlpha) | |
| 79 | 97 | border.width: 1 | |
| @@ -86,102 +104,483 @@ PanelWindow { | |||
| 86 | 104 | Keys.onDownPressed: win.shift(12) | |
| 87 | 105 | Keys.onSpacePressed: win.today() | |
| 88 | 106 | ||
| 89 | − | MouseArea { | |
| 90 | − | anchors.fill: parent | |
| 91 | − | onWheel: w => win.shift(w.angleDelta.y > 0 ? -1 : 1) | |
| 92 | − | } | |
| 107 | + | Row { | |
| 108 | + | id: row | |
| 109 | + | x: 26 | |
| 110 | + | y: 24 | |
| 111 | + | spacing: 22 | |
| 112 | + | ||
| 113 | + | // ---------------- calendar ---------------- | |
| 114 | + | Item { | |
| 115 | + | width: calCol.implicitWidth | |
| 116 | + | height: calCol.implicitHeight | |
| 117 | + | MouseArea { | |
| 118 | + | anchors.fill: parent | |
| 119 | + | acceptedButtons: Qt.NoButton | |
| 120 | + | onWheel: w => win.shift(w.angleDelta.y > 0 ? -1 : 1) | |
| 121 | + | } | |
| 122 | + | ColumnLayout { | |
| 123 | + | id: calCol | |
| 124 | + | width: 288 | |
| 125 | + | spacing: 0 | |
| 93 | 126 | ||
| 94 | − | ColumnLayout { | |
| 95 | − | id: col | |
| 96 | − | x: 22 | |
| 97 | − | y: 22 | |
| 98 | − | width: parent.width - 44 | |
| 99 | − | spacing: 14 | |
| 100 | − | ||
| 101 | − | Lb { | |
| 102 | − | Layout.leftMargin: 6 | |
| 103 | − | px: 10 | |
| 104 | − | text: win.en.toString(win.now, "dddd · d MMMM yyyy") + " · week " + win.isoWeek(win.now) | |
| 127 | + | Lb { | |
| 128 | + | Layout.leftMargin: 6 | |
| 129 | + | px: 10 | |
| 130 | + | text: win.en.toString(win.now, "dddd · d MMMM") + " · week " + win.isoWeek(win.now) | |
| 131 | + | } | |
| 132 | + | RowLayout { | |
| 133 | + | Layout.fillWidth: true | |
| 134 | + | Layout.topMargin: 12 | |
| 135 | + | Layout.leftMargin: 6 | |
| 136 | + | spacing: 10 | |
| 137 | + | Num { | |
| 138 | + | text: win.en.standaloneMonthName(win.month, Locale.ShortFormat) | |
| 139 | + | font.pixelSize: 44 | |
| 140 | + | MouseArea { | |
| 141 | + | anchors.fill: parent | |
| 142 | + | cursorShape: Qt.PointingHandCursor | |
| 143 | + | onClicked: win.today() | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | Lb { | |
| 147 | + | Layout.alignment: Qt.AlignVCenter | |
| 148 | + | text: win.year | |
| 149 | + | } | |
| 150 | + | Item { Layout.fillWidth: true } | |
| 151 | + | IconBtn { implicitHeight: 36; icon: Theme.iChevronLeft; onClicked: win.shift(-1) } | |
| 152 | + | IconBtn { implicitHeight: 36; icon: Theme.iChevron; onClicked: win.shift(1) } | |
| 153 | + | } | |
| 154 | + | Grid { | |
| 155 | + | id: grid | |
| 156 | + | Layout.fillWidth: true | |
| 157 | + | Layout.topMargin: 12 | |
| 158 | + | columns: 8 | |
| 159 | + | readonly property real cw: calCol.width / 8 | |
| 160 | + | ||
| 161 | + | Repeater { | |
| 162 | + | model: ["wk", "m", "t", "w", "t", "f", "s", "s"] | |
| 163 | + | delegate: Lb { | |
| 164 | + | required property var modelData | |
| 165 | + | required property int index | |
| 166 | + | width: grid.cw | |
| 167 | + | height: 24 | |
| 168 | + | horizontalAlignment: Text.AlignHCenter | |
| 169 | + | verticalAlignment: Text.AlignVCenter | |
| 170 | + | text: modelData | |
| 171 | + | px: 10 | |
| 172 | + | color: index === 0 ? Theme.mute : Theme.grey | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | Repeater { | |
| 176 | + | model: 48 | |
| 177 | + | delegate: Item { | |
| 178 | + | id: cell | |
| 179 | + | required property int index | |
| 180 | + | readonly property bool isWeek: index % 8 === 0 | |
| 181 | + | // week-number cells use that row's Monday | |
| 182 | + | readonly property var d: win.days[Math.floor(index / 8) * 7 + Math.max(0, (index % 8) - 1)] | |
| 183 | + | readonly property bool inMonth: d.getMonth() === win.month | |
| 184 | + | readonly property bool isToday: !isWeek && d.toDateString() === win.now.toDateString() | |
| 185 | + | readonly property bool weekend: (index % 8) >= 6 | |
| 186 | + | width: grid.cw | |
| 187 | + | height: 34 | |
| 188 | + | Rectangle { | |
| 189 | + | visible: cell.isToday | |
| 190 | + | anchors.centerIn: parent | |
| 191 | + | width: 30 | |
| 192 | + | height: 30 | |
| 193 | + | radius: 15 | |
| 194 | + | color: Theme.fill | |
| 195 | + | } | |
| 196 | + | Text { | |
| 197 | + | anchors.centerIn: parent | |
| 198 | + | text: cell.isWeek ? win.isoWeek(cell.d) : cell.d.getDate() | |
| 199 | + | color: cell.isToday ? Theme.fillFg | |
| 200 | + | : cell.isWeek ? Theme.mute | |
| 201 | + | : !cell.inMonth ? Theme.mute | |
| 202 | + | : cell.weekend ? Theme.grey : Theme.fg | |
| 203 | + | font.family: Theme.mono | |
| 204 | + | font.pixelSize: cell.isWeek ? 10 : 13 | |
| 205 | + | font.bold: cell.isToday | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 105 | 211 | } | |
| 106 | 212 | ||
| 107 | − | RowLayout { | |
| 108 | − | Layout.fillWidth: true | |
| 109 | − | Layout.leftMargin: 6 | |
| 110 | − | spacing: 12 | |
| 111 | − | Num { | |
| 112 | − | text: win.en.standaloneMonthName(win.month, Locale.ShortFormat) | |
| 113 | − | font.pixelSize: 44 | |
| 114 | − | MouseArea { | |
| 115 | − | anchors.fill: parent | |
| 116 | − | cursorShape: Qt.PointingHandCursor | |
| 117 | − | onClicked: win.today() | |
| 213 | + | Sep { h: win.h } | |
| 214 | + | ||
| 215 | + | // ---------------- weather ---------------- | |
| 216 | + | ColumnLayout { | |
| 217 | + | width: 208 | |
| 218 | + | height: win.h | |
| 219 | + | spacing: 0 | |
| 220 | + | ||
| 221 | + | Lb { | |
| 222 | + | Layout.fillWidth: true | |
| 223 | + | elide: Text.ElideRight | |
| 224 | + | px: 10 | |
| 225 | + | color: Weather.error !== "" && !Weather.ok ? Theme.err : Theme.grey | |
| 226 | + | text: !Weather.ok ? (Weather.busy ? "looking outside" : Weather.error || "no weather") | |
| 227 | + | : (Weather.place !== "" ? Weather.place + " · " : "") + Weather.name(Weather.now.code) | |
| 228 | + | } | |
| 229 | + | RowLayout { | |
| 230 | + | Layout.topMargin: 14 | |
| 231 | + | spacing: 12 | |
| 232 | + | Icon { | |
| 233 | + | text: Weather.ok ? Weather.icon(Weather.now.code, Weather.now.day) : Theme.iCloudy | |
| 234 | + | font.pixelSize: 46 | |
| 235 | + | opacity: Weather.ok ? 1 : 0.4 | |
| 236 | + | layer.enabled: true | |
| 237 | + | layer.effect: DotMask { step: 3 } | |
| 118 | 238 | } | |
| 239 | + | Num { | |
| 240 | + | text: Weather.ok ? Math.round(Weather.now.temp) + "°" : "--" | |
| 241 | + | font.pixelSize: 52 | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | Lb { | |
| 245 | + | Layout.topMargin: 12 | |
| 246 | + | visible: Weather.ok | |
| 247 | + | px: 10 | |
| 248 | + | text: Weather.ok ? "feels " + Math.round(Weather.now.feels) + "° · " + Math.round(Weather.now.wind) + " km/h" : "" | |
| 119 | 249 | } | |
| 120 | 250 | Lb { | |
| 121 | − | Layout.alignment: Qt.AlignVCenter | |
| 122 | − | text: win.year | |
| 251 | + | Layout.topMargin: 4 | |
| 252 | + | visible: Weather.ok | |
| 253 | + | px: 10 | |
| 254 | + | text: Weather.ok ? "humid " + Math.round(Weather.now.humidity) + "% · rain " + Math.round(Weather.now.rain || 0) + "%" : "" | |
| 255 | + | } | |
| 256 | + | Item { Layout.fillHeight: true } | |
| 257 | + | Row { | |
| 258 | + | spacing: 8 | |
| 259 | + | Repeater { | |
| 260 | + | model: win.wNext | |
| 261 | + | Column { | |
| 262 | + | required property var modelData | |
| 263 | + | width: 46 | |
| 264 | + | Lb { | |
| 265 | + | anchors.horizontalCenter: parent.horizontalCenter | |
| 266 | + | px: 10 | |
| 267 | + | text: win.en.toString(modelData.date, "ddd") | |
| 268 | + | } | |
| 269 | + | Icon { | |
| 270 | + | anchors.horizontalCenter: parent.horizontalCenter | |
| 271 | + | topPadding: lead + 6 | |
| 272 | + | text: Weather.icon(modelData.code, true) | |
| 273 | + | font.pixelSize: 24 | |
| 274 | + | layer.enabled: true | |
| 275 | + | layer.effect: DotMask { step: 2 } | |
| 276 | + | } | |
| 277 | + | Lb { | |
| 278 | + | anchors.horizontalCenter: parent.horizontalCenter | |
| 279 | + | topPadding: 6 | |
| 280 | + | px: 11 | |
| 281 | + | color: Theme.fg | |
| 282 | + | text: Math.round(modelData.max) + "°" | |
| 283 | + | } | |
| 284 | + | Lb { | |
| 285 | + | anchors.horizontalCenter: parent.horizontalCenter | |
| 286 | + | px: 10 | |
| 287 | + | text: Math.round(modelData.min) + "°" | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | // daylight: lit from sunrise to now | |
| 293 | + | Seg { | |
| 294 | + | Layout.fillWidth: true | |
| 295 | + | Layout.topMargin: 16 | |
| 296 | + | Layout.preferredHeight: 6 | |
| 297 | + | n: 24 | |
| 298 | + | f: win.wToday ? (win.now - win.wToday.rise) / (win.wToday.set - win.wToday.rise) : 0 | |
| 299 | + | } | |
| 300 | + | RowLayout { | |
| 301 | + | Layout.fillWidth: true | |
| 302 | + | Layout.topMargin: 10 | |
| 303 | + | Meta { icon: Theme.iSunrise; text: win.wToday ? win.hm(win.wToday.rise) : "--:--" } | |
| 304 | + | Item { Layout.fillWidth: true } | |
| 305 | + | Meta { icon: Theme.iSunset; text: win.wToday ? win.hm(win.wToday.set) : "--:--" } | |
| 123 | 306 | } | |
| 124 | − | Item { Layout.fillWidth: true } | |
| 125 | − | IconBtn { implicitHeight: 40; icon: Theme.iChevronLeft; onClicked: win.shift(-1) } | |
| 126 | − | IconBtn { implicitHeight: 40; icon: Theme.iChevron; onClicked: win.shift(1) } | |
| 127 | 307 | } | |
| 128 | 308 | ||
| 129 | − | Grid { | |
| 130 | − | id: grid | |
| 131 | − | Layout.fillWidth: true | |
| 132 | − | Layout.preferredWidth: 1 | |
| 133 | − | columns: 8 | |
| 134 | − | readonly property real cw: width / 8 | |
| 135 | − | ||
| 136 | − | Repeater { | |
| 137 | − | model: ["wk", "m", "t", "w", "t", "f", "s", "s"] | |
| 138 | − | delegate: Lb { | |
| 139 | − | required property var modelData | |
| 140 | − | required property int index | |
| 141 | − | width: grid.cw | |
| 142 | − | height: 28 | |
| 143 | − | horizontalAlignment: Text.AlignHCenter | |
| 144 | − | verticalAlignment: Text.AlignVCenter | |
| 145 | − | text: modelData | |
| 146 | − | px: 10 | |
| 147 | − | color: index === 0 ? Theme.mute : Theme.grey | |
| 309 | + | Sep { h: win.h } | |
| 310 | + | ||
| 311 | + | // ---------------- system ---------------- | |
| 312 | + | ColumnLayout { | |
| 313 | + | width: 320 | |
| 314 | + | height: win.h | |
| 315 | + | spacing: 0 | |
| 316 | + | ||
| 317 | + | RowLayout { | |
| 318 | + | Layout.fillWidth: true | |
| 319 | + | spacing: 0 | |
| 320 | + | Gauge { | |
| 321 | + | on: win.opened | |
| 322 | + | f: SysStats.cpu | |
| 323 | + | label: "cpu" + (SysStats.cpuTemp > 0 ? " · " + Math.round(SysStats.cpuTemp) + "°" : "") | |
| 324 | + | } | |
| 325 | + | Item { Layout.fillWidth: true } | |
| 326 | + | Gauge { | |
| 327 | + | on: win.opened | |
| 328 | + | f: SysStats.memTotal > 0 ? SysStats.memUsed / SysStats.memTotal : 0 | |
| 329 | + | label: "mem · " + SysStats.memUsed.toFixed(1) + "g" | |
| 330 | + | } | |
| 331 | + | Item { Layout.fillWidth: true } | |
| 332 | + | // the dGPU if there is one (asleep = idle, never woken for this), else the root disk | |
| 333 | + | Gauge { | |
| 334 | + | readonly property var disk: SysStats.disks.length ? SysStats.disks[0] : null | |
| 335 | + | on: win.opened | |
| 336 | + | f: SysStats.gpuDev !== "" ? SysStats.gpu : disk ? disk.used / disk.size : 0 | |
| 337 | + | label: SysStats.gpuDev === "" ? "disk · " + (disk ? disk.label : "") | |
| 338 | + | : SysStats.gpuAwake ? "gpu · " + Math.round(SysStats.gpuTemp) + "°" : "gpu · asleep" | |
| 148 | 339 | } | |
| 149 | 340 | } | |
| 150 | − | Repeater { | |
| 151 | − | model: 48 | |
| 152 | − | delegate: Item { | |
| 153 | − | id: cell | |
| 154 | − | required property int index | |
| 155 | − | readonly property bool isWeek: index % 8 === 0 | |
| 156 | − | // week-number cells use that row's Monday | |
| 157 | − | readonly property var d: win.days[Math.floor(index / 8) * 7 + Math.max(0, (index % 8) - 1)] | |
| 158 | − | readonly property bool inMonth: d.getMonth() === win.month | |
| 159 | − | readonly property bool isToday: !isWeek && d.toDateString() === win.now.toDateString() | |
| 160 | − | readonly property bool weekend: (index % 8) >= 6 | |
| 161 | − | width: grid.cw | |
| 162 | − | height: 40 | |
| 341 | + | Item { Layout.fillHeight: true } | |
| 342 | + | RowLayout { | |
| 343 | + | Layout.fillWidth: true | |
| 344 | + | spacing: 16 | |
| 345 | + | ColumnLayout { | |
| 346 | + | Layout.preferredWidth: 160 | |
| 347 | + | Layout.alignment: Qt.AlignTop | |
| 348 | + | spacing: 5 | |
| 349 | + | Lb { px: 10; text: "processes" } | |
| 350 | + | Repeater { | |
| 351 | + | model: SysStats.procs | |
| 352 | + | RowLayout { | |
| 353 | + | required property var modelData | |
| 354 | + | spacing: 6 | |
| 355 | + | Lb { | |
| 356 | + | Layout.preferredWidth: 78 | |
| 357 | + | elide: Text.ElideRight | |
| 358 | + | px: 10 | |
| 359 | + | color: Theme.fg | |
| 360 | + | text: modelData[0] | |
| 361 | + | } | |
| 362 | + | Lb { | |
| 363 | + | Layout.preferredWidth: 34 | |
| 364 | + | horizontalAlignment: Text.AlignRight | |
| 365 | + | px: 10 | |
| 366 | + | text: Math.round(modelData[1]) + "%" | |
| 367 | + | } | |
| 368 | + | Seg { | |
| 369 | + | Layout.preferredWidth: 36 | |
| 370 | + | Layout.preferredHeight: 5 | |
| 371 | + | n: 6 | |
| 372 | + | spacing: 3 | |
| 373 | + | f: modelData[1] / 100 | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ColumnLayout { | |
| 379 | + | Layout.fillWidth: true | |
| 380 | + | Layout.alignment: Qt.AlignTop | |
| 381 | + | spacing: 8 | |
| 382 | + | Lb { | |
| 383 | + | px: 10 | |
| 384 | + | text: "threads" + (SysStats.cpuFreq > 0 ? " · " + SysStats.cpuFreq.toFixed(1) + " ghz" : "") | |
| 385 | + | } | |
| 386 | + | // a column per thread: the top dot is the level, the ones below it trail | |
| 387 | + | Grid { | |
| 388 | + | id: cores | |
| 389 | + | readonly property int n: Math.max(1, SysStats.cores.length) | |
| 390 | + | readonly property real d: (width - (n - 1) * spacing) / n | |
| 391 | + | Layout.fillWidth: true | |
| 392 | + | columns: n | |
| 393 | + | spacing: 2 | |
| 394 | + | Repeater { | |
| 395 | + | model: cores.n * 8 | |
| 396 | + | Rectangle { | |
| 397 | + | required property int index | |
| 398 | + | readonly property int up: 8 - Math.floor(index / cores.n) // 8 top .. 1 bottom | |
| 399 | + | readonly property int lvl: Math.round(1 + 7 * (SysStats.cores[index % cores.n] || 0)) | |
| 400 | + | width: cores.d | |
| 401 | + | height: cores.d | |
| 402 | + | radius: cores.d / 2 | |
| 403 | + | color: up === lvl ? Theme.fill : up < lvl ? Theme.fade(Theme.fill, 0.4) : Theme.s3 | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | Sep { h: win.h } | |
| 412 | + | ||
| 413 | + | // ---------------- net ---------------- | |
| 414 | + | ColumnLayout { | |
| 415 | + | width: 180 | |
| 416 | + | height: win.h | |
| 417 | + | spacing: 0 | |
| 418 | + | ||
| 419 | + | Lb { | |
| 420 | + | Layout.fillWidth: true | |
| 421 | + | elide: Text.ElideRight | |
| 422 | + | px: 10 | |
| 423 | + | text: "net · " + (Net.connected !== "" ? Net.connected : SysStats.iface !== "" ? SysStats.iface : "offline") | |
| 424 | + | } | |
| 425 | + | Speed { Layout.topMargin: 16; icon: Theme.iArrowDown; b: SysStats.rx } | |
| 426 | + | Speed { Layout.topMargin: 10; icon: Theme.iArrowUp; b: SysStats.tx } | |
| 427 | + | Item { Layout.fillHeight: true } | |
| 428 | + | // the last 40s: download grows up from the middle, upload (dimmer) down | |
| 429 | + | Grid { | |
| 430 | + | id: hist | |
| 431 | + | readonly property var pts: SysStats.netHistory | |
| 432 | + | readonly property real peak: { | |
| 433 | + | var p = 10240 | |
| 434 | + | for (var i = 0; i < pts.length; i++) p = Math.max(p, pts[i][0], pts[i][1]) | |
| 435 | + | return p | |
| 436 | + | } | |
| 437 | + | readonly property real d: (width - 19 * spacing) / 20 | |
| 438 | + | Layout.fillWidth: true | |
| 439 | + | columns: 20 | |
| 440 | + | spacing: 3 | |
| 441 | + | Repeater { | |
| 442 | + | model: 20 * 10 | |
| 163 | 443 | Rectangle { | |
| 164 | − | visible: cell.isToday | |
| 165 | − | anchors.centerIn: parent | |
| 166 | − | width: 34 | |
| 167 | − | height: 34 | |
| 168 | − | radius: 17 | |
| 169 | − | color: Theme.fill | |
| 444 | + | required property int index | |
| 445 | + | readonly property int r: Math.floor(index / 20) // 0..9 top to bottom | |
| 446 | + | readonly property bool up: r >= 5 | |
| 447 | + | readonly property var p: hist.pts[index % 20 - 20 + hist.pts.length] || [0, 0] | |
| 448 | + | readonly property real v: up ? p[1] : p[0] | |
| 449 | + | readonly property bool lit: v > 0 && (up ? r - 4 : 5 - r) <= Math.ceil(5 * v / hist.peak) | |
| 450 | + | width: hist.d | |
| 451 | + | height: hist.d | |
| 452 | + | radius: hist.d / 2 | |
| 453 | + | color: lit ? (up ? Theme.grey : Theme.fill) : Theme.s3 | |
| 170 | 454 | } | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | Sep { h: win.h; visible: Media.player !== null } | |
| 460 | + | ||
| 461 | + | // ---------------- media ---------------- | |
| 462 | + | ColumnLayout { | |
| 463 | + | visible: Media.player !== null | |
| 464 | + | width: 250 | |
| 465 | + | height: win.h | |
| 466 | + | spacing: 0 | |
| 467 | + | ||
| 468 | + | Lb { | |
| 469 | + | Layout.fillWidth: true | |
| 470 | + | elide: Text.ElideRight | |
| 471 | + | px: 10 | |
| 472 | + | text: Media.player ? Media.player.identity : "" | |
| 473 | + | } | |
| 474 | + | RowLayout { | |
| 475 | + | Layout.fillWidth: true | |
| 476 | + | Layout.topMargin: 16 | |
| 477 | + | spacing: 14 | |
| 478 | + | // the cover as dots, turning while it plays | |
| 479 | + | DotArt { | |
| 480 | + | Layout.preferredWidth: 84 | |
| 481 | + | Layout.preferredHeight: 84 | |
| 482 | + | step: 5 | |
| 483 | + | url: Media.player ? Media.player.trackArtUrl : "" | |
| 484 | + | RotationAnimation on rotation { | |
| 485 | + | from: 0 | |
| 486 | + | to: 360 | |
| 487 | + | duration: 14000 | |
| 488 | + | loops: Animation.Infinite | |
| 489 | + | running: win.visible && Theme.motion | |
| 490 | + | paused: running && !Media.playing | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | ColumnLayout { | |
| 494 | + | Layout.fillWidth: true | |
| 495 | + | spacing: 2 | |
| 171 | 496 | Text { | |
| 172 | − | anchors.centerIn: parent | |
| 173 | − | text: cell.isWeek ? win.isoWeek(cell.d) : cell.d.getDate() | |
| 174 | − | color: cell.isToday ? Theme.fillFg | |
| 175 | − | : cell.isWeek ? Theme.mute | |
| 176 | − | : !cell.inMonth ? Theme.mute | |
| 177 | − | : cell.weekend ? Theme.grey : Theme.fg | |
| 178 | − | font.family: Theme.mono | |
| 179 | − | font.pixelSize: cell.isWeek ? 10 : 13 | |
| 180 | − | font.bold: cell.isToday | |
| 497 | + | Layout.fillWidth: true | |
| 498 | + | wrapMode: Text.Wrap | |
| 499 | + | maximumLineCount: 2 | |
| 500 | + | elide: Text.ElideRight | |
| 501 | + | text: Media.player && Media.player.trackTitle ? Media.player.trackTitle : "nothing on" | |
| 502 | + | color: Theme.fg | |
| 503 | + | font.family: Theme.ui | |
| 504 | + | font.pixelSize: 17 | |
| 505 | + | font.weight: Font.DemiBold | |
| 506 | + | } | |
| 507 | + | Text { | |
| 508 | + | Layout.fillWidth: true | |
| 509 | + | elide: Text.ElideRight | |
| 510 | + | text: Media.player ? Media.player.trackArtist : "" | |
| 511 | + | color: Theme.fgDim | |
| 512 | + | font.family: Theme.ui | |
| 513 | + | font.pixelSize: 13 | |
| 181 | 514 | } | |
| 182 | 515 | } | |
| 183 | 516 | } | |
| 517 | + | Item { Layout.fillHeight: true } | |
| 518 | + | Seg { | |
| 519 | + | Layout.fillWidth: true | |
| 520 | + | Layout.preferredHeight: 6 | |
| 521 | + | n: 20 | |
| 522 | + | f: Media.seekable ? Media.player.position / Media.player.length : 0 | |
| 523 | + | } | |
| 524 | + | RowLayout { | |
| 525 | + | Layout.fillWidth: true | |
| 526 | + | Layout.topMargin: 8 | |
| 527 | + | Lb { px: 10; text: Media.seekable ? Media.fmt(Media.player.position) : "" } | |
| 528 | + | Item { Layout.fillWidth: true } | |
| 529 | + | Lb { px: 10; text: Media.seekable ? Media.fmt(Media.player.length) : "" } | |
| 530 | + | } | |
| 531 | + | PlayControls { | |
| 532 | + | Layout.alignment: Qt.AlignHCenter | |
| 533 | + | Layout.topMargin: 12 | |
| 534 | + | size: 20 | |
| 535 | + | play: 48 | |
| 536 | + | } | |
| 184 | 537 | } | |
| 185 | 538 | } | |
| 186 | 539 | } | |
| 540 | + | ||
| 541 | + | // dotted line between sections (inline components can't see the file's ids: everything comes in as properties) | |
| 542 | + | component Sep: Column { | |
| 543 | + | id: sep | |
| 544 | + | property real h | |
| 545 | + | spacing: 7 | |
| 546 | + | Repeater { | |
| 547 | + | model: Math.floor((sep.h + 7) / 10) | |
| 548 | + | Rectangle { width: 3; height: 3; radius: 1.5; color: Theme.s3 } | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | // dot ring with the percentage inside and a label under it; lights in a wave when the popup opens | |
| 552 | + | component Gauge: Column { | |
| 553 | + | id: g | |
| 554 | + | property real f | |
| 555 | + | property string label | |
| 556 | + | property bool on | |
| 557 | + | spacing: 8 | |
| 558 | + | Item { | |
| 559 | + | anchors.horizontalCenter: parent.horizontalCenter | |
| 560 | + | width: 88 | |
| 561 | + | height: 88 | |
| 562 | + | Ring { anchors.fill: parent; anchors.margins: 2; n: 32; dot: 4; f: g.on ? g.f : 0 } | |
| 563 | + | Num { anchors.centerIn: parent; text: Math.round(g.f * 100); font.pixelSize: 30 } | |
| 564 | + | } | |
| 565 | + | Lb { anchors.horizontalCenter: parent.horizontalCenter; px: 10; text: g.label } | |
| 566 | + | } | |
| 567 | + | // arrow, Doto number, unit | |
| 568 | + | component Speed: RowLayout { | |
| 569 | + | id: sp | |
| 570 | + | property string icon | |
| 571 | + | property real b | |
| 572 | + | readonly property var v: SysStats.rate(b) | |
| 573 | + | spacing: 8 | |
| 574 | + | Icon { text: sp.icon; font.pixelSize: 18 } | |
| 575 | + | Num { text: sp.v[0]; font.pixelSize: 44 } | |
| 576 | + | Lb { px: 10; text: sp.v[1] } | |
| 577 | + | } | |
| 578 | + | component Meta: RowLayout { | |
| 579 | + | id: m | |
| 580 | + | property string icon | |
| 581 | + | property string text | |
| 582 | + | spacing: 8 | |
| 583 | + | Icon { text: m.icon; font.pixelSize: 16; color: Theme.grey } | |
| 584 | + | Lb { px: 10; text: m.text } | |
| 585 | + | } | |
| 187 | 586 | } | |
| @@ -52,6 +52,24 @@ Flickable { | |||
| 52 | 52 | SectionLabel { text: "bar" } | |
| 53 | 53 | Switch { label: "privacy indicators"; hint: "show in the bar when an app records the mic, uses the camera or shares the screen"; checked: Settings.privacyIndicators; onToggled: on => Settings.privacyIndicators = on } | |
| 54 | 54 | ||
| 55 | + | SectionLabel { text: "weather" } | |
| 56 | + | Field { | |
| 57 | + | label: "location" | |
| 58 | + | placeholder: "by IP · or a city · or lat,lon" | |
| 59 | + | value: Settings.weatherLoc | |
| 60 | + | onSubmitted: v => Settings.weatherLoc = v.trim() | |
| 61 | + | } | |
| 62 | + | Text { | |
| 63 | + | Layout.fillWidth: true | |
| 64 | + | Layout.leftMargin: 18 | |
| 65 | + | text: Weather.ok ? (Weather.place !== "" ? Weather.place + ": " : "") + Math.round(Weather.now.temp) + "°, " + Weather.name(Weather.now.code) | |
| 66 | + | + (Weather.error !== "" ? " (" + Weather.error + ")" : "") | |
| 67 | + | : Weather.busy ? "looking outside..." : Weather.error || "no weather yet" | |
| 68 | + | color: Weather.error !== "" ? Theme.err : Theme.fgDim | |
| 69 | + | font.family: Theme.ui | |
| 70 | + | font.pixelSize: 13 | |
| 71 | + | } | |
| 72 | + | ||
| 55 | 73 | SectionLabel { text: "notifications" } | |
| 56 | 74 | Switch { label: "do not disturb"; hint: "hide popups and mute the sound (critical ones still show)"; checked: Notifs.dnd; onToggled: on => Notifs.dnd = on } | |
| 57 | 75 | Switch { label: "sound"; checked: Settings.notifSound; onToggled: on => Settings.notifSound = on } | |
| @@ -20,7 +20,7 @@ Singleton { | |||
| 20 | 20 | privacyIndicators: true, powerAuto: false, powerAc: "balanced", powerBat: "saver", | |
| 21 | 21 | motion: true, clock24: true, appIcons: "dots", barSize: 40, | |
| 22 | 22 | wallLook: "dots", wallPitch: 10, wallBright: 85, dotGrid: true, | |
| 23 | − | blink: true | |
| 23 | + | blink: true, weatherLoc: "" | |
| 24 | 24 | }) | |
| 25 | 25 | ||
| 26 | 26 | property alias barAlpha: a.barAlpha | |
| @@ -59,6 +59,7 @@ Singleton { | |||
| 59 | 59 | property alias wallBright: a.wallBright | |
| 60 | 60 | property alias dotGrid: a.dotGrid | |
| 61 | 61 | property alias blink: a.blink | |
| 62 | + | property alias weatherLoc: a.weatherLoc | |
| 62 | 63 | ||
| 63 | 64 | function reset() { | |
| 64 | 65 | for (var k in defaults) a[k] = defaults[k] | |
| @@ -119,6 +120,7 @@ Singleton { | |||
| 119 | 120 | property int wallBright: 85 // %, from the background to the full image | |
| 120 | 121 | property bool dotGrid: true // dot grid on an empty desktop | |
| 121 | 122 | property bool blink: true // the desktop clock's colon blinks | |
| 123 | + | property string weatherLoc: "" // Weather.qml: "" = by IP, "lat,lon" or a place name | |
| 122 | 124 | } | |
| 123 | 125 | } | |
| 124 | 126 | // first run: create the file once the directory exists | |
| @@ -51,6 +51,165 @@ Singleton { | |||
| 51 | 51 | ||
| 52 | 52 | Timer { interval: 1000; running: true; repeat: true; triggeredOnStart: true; onTriggered: root.sample() } | |
| 53 | 53 | ||
| 54 | + | // ---- details for the calendar dashboard: sampled every 2s, only while `detailed` (CalPopup sets it while open) ---- | |
| 55 | + | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), services/SysStats.qml. | |
| 56 | + | property bool detailed: false | |
| 57 | + | property var cores: [] // 0..1 per thread | |
| 58 | + | property real cpuTemp: 0 // °C, 0 = no sensor found | |
| 59 | + | property real cpuFreq: 0 // GHz, average over threads | |
| 60 | + | property string gpuDev: "" // the nvidia card in sysfs; "" = none (integrated mode, or no nvidia-smi) | |
| 61 | + | property bool gpuAwake: false // not runtime-suspended | |
| 62 | + | property real gpu: 0 // 0..1 | |
| 63 | + | property real gpuTemp: 0 | |
| 64 | + | property string iface: "" // interface of the default route | |
| 65 | + | property real rx: 0 // bytes/s | |
| 66 | + | property real tx: 0 | |
| 67 | + | property var netHistory: [] // last 20 [rx, tx] | |
| 68 | + | property var procs: [] // top 4 [name, % of one thread] (processes with the same name summed) | |
| 69 | + | property string tempPath: "" | |
| 70 | + | property var corePrev: ({}) | |
| 71 | + | property var netPrev: null // [rx, tx, ms, iface] | |
| 72 | + | property var procPrev: ({}) // pid -> cpu ticks | |
| 73 | + | property double procAt: 0 | |
| 74 | + | ||
| 75 | + | FileView { id: tempFile; path: root.tempPath; blockLoading: true; printErrors: false } | |
| 76 | + | FileView { id: infoFile; path: "/proc/cpuinfo"; blockLoading: true } | |
| 77 | + | FileView { id: routeFile; path: "/proc/net/route"; blockLoading: true } | |
| 78 | + | FileView { id: devFile; path: "/proc/net/dev"; blockLoading: true } | |
| 79 | + | FileView { id: gpuPm; path: root.gpuDev !== "" ? root.gpuDev + "/power/runtime_status" : ""; blockLoading: true; printErrors: false } | |
| 80 | + | ||
| 81 | + | // sensors by name (hwmonN numbers change between boots); the dGPU only if nvidia-smi can read it | |
| 82 | + | Process { | |
| 83 | + | running: true | |
| 84 | + | command: ["sh", "-c", 'for h in /sys/class/hwmon/hwmon*; do case "$(cat "$h/name" 2>/dev/null)" in ' | |
| 85 | + | + 'coretemp|k10temp|zenpower|cpu_thermal) echo "temp $h/temp1_input"; break;; esac; done; ' | |
| 86 | + | + 'command -v nvidia-smi >/dev/null && for d in /sys/bus/pci/drivers/nvidia/*:*; do [ -e "$d" ] && echo "gpu $d" && break; done'] | |
| 87 | + | stdout: StdioCollector { | |
| 88 | + | onStreamFinished: { | |
| 89 | + | var l = text.trim().split("\n") | |
| 90 | + | for (var i = 0; i < l.length; i++) { | |
| 91 | + | var s = l[i].indexOf(" ") | |
| 92 | + | if (l[i].slice(0, s) === "temp") root.tempPath = l[i].slice(s + 1) | |
| 93 | + | else if (l[i].slice(0, s) === "gpu") root.gpuDev = l[i].slice(s + 1) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | onDetailedChanged: { | |
| 100 | + | if (!detailed) return | |
| 101 | + | corePrev = {}; netPrev = null; procPrev = {}; netHistory = [] | |
| 102 | + | } | |
| 103 | + | Timer { interval: 2000; running: root.detailed; repeat: true; triggeredOnStart: true; onTriggered: root.details() } | |
| 104 | + | ||
| 105 | + | function details() { | |
| 106 | + | statFile.reload() | |
| 107 | + | var lines = statFile.text().split("\n"), next = {}, out = [] | |
| 108 | + | for (var i = 0; i < lines.length; i++) { | |
| 109 | + | var m = lines[i].match(/^cpu(\d+)\s+(.*)$/) | |
| 110 | + | if (!m) continue | |
| 111 | + | var f = m[2].trim().split(/\s+/).map(Number) | |
| 112 | + | var idle = f[3] + (f[4] || 0), total = 0 | |
| 113 | + | for (var k = 0; k < Math.min(8, f.length); k++) total += f[k] | |
| 114 | + | var p = corePrev[m[1]] | |
| 115 | + | out.push(p && total > p[1] ? Math.max(0, Math.min(1, 1 - (idle - p[0]) / (total - p[1]))) : 0) | |
| 116 | + | next[m[1]] = [idle, total] | |
| 117 | + | } | |
| 118 | + | corePrev = next | |
| 119 | + | cores = out | |
| 120 | + | ||
| 121 | + | if (tempPath !== "") { tempFile.reload(); cpuTemp = (parseInt(tempFile.text()) || 0) / 1000 } | |
| 122 | + | infoFile.reload() | |
| 123 | + | var mhz = infoFile.text().match(/^cpu MHz\s*:\s*[\d.]+/gm) || [], sum = 0 | |
| 124 | + | for (var j = 0; j < mhz.length; j++) sum += parseFloat(mhz[j].split(":")[1]) | |
| 125 | + | cpuFreq = mhz.length ? sum / mhz.length / 1000 : 0 | |
| 126 | + | ||
| 127 | + | // a sleeping dGPU idles: never wake it (nvidia-smi would, and polling keeps it awake) | |
| 128 | + | if (gpuDev !== "") { | |
| 129 | + | gpuPm.reload() | |
| 130 | + | gpuAwake = gpuPm.text().indexOf("suspend") !== 0 | |
| 131 | + | if (gpuAwake) { if (!nvsmi.running) nvsmi.running = true } | |
| 132 | + | else { gpu = 0; gpuTemp = 0 } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // net: bytes of the default route's interface | |
| 136 | + | routeFile.reload() | |
| 137 | + | var r = routeFile.text().split("\n"), dev = "" | |
| 138 | + | for (var a = 1; a < r.length; a++) { | |
| 139 | + | var c = r[a].split("\t") | |
| 140 | + | if (c[1] === "00000000") { dev = c[0]; break } | |
| 141 | + | } | |
| 142 | + | iface = dev | |
| 143 | + | devFile.reload() | |
| 144 | + | var d = devFile.text().split("\n"), now = Date.now(), got = false | |
| 145 | + | for (var b = 0; b < d.length && dev !== ""; b++) { | |
| 146 | + | var s = d[b].indexOf(":") | |
| 147 | + | if (s < 0 || d[b].slice(0, s).trim() !== dev) continue | |
| 148 | + | var v = d[b].slice(s + 1).trim().split(/\s+/).map(Number) | |
| 149 | + | if (netPrev && netPrev[3] === dev && now > netPrev[2]) { | |
| 150 | + | rx = Math.max(0, (v[0] - netPrev[0]) / ((now - netPrev[2]) / 1000)) | |
| 151 | + | tx = Math.max(0, (v[8] - netPrev[1]) / ((now - netPrev[2]) / 1000)) | |
| 152 | + | } | |
| 153 | + | netPrev = [v[0], v[8], now, dev] | |
| 154 | + | got = true | |
| 155 | + | break | |
| 156 | + | } | |
| 157 | + | if (!got) { rx = 0; tx = 0; netPrev = null } | |
| 158 | + | var h = netHistory.slice() | |
| 159 | + | h.push([rx, tx]) | |
| 160 | + | if (h.length > 20) h.shift() | |
| 161 | + | netHistory = h | |
| 162 | + | ||
| 163 | + | if (!procProc.running) procProc.running = true | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | Process { | |
| 167 | + | id: nvsmi | |
| 168 | + | command: ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] | |
| 169 | + | stdout: StdioCollector { | |
| 170 | + | onStreamFinished: { | |
| 171 | + | var v = text.trim().split("\n")[0].split(",").map(Number) | |
| 172 | + | if (v.length < 2 || isNaN(v[0])) return | |
| 173 | + | root.gpu = v[0] / 100 | |
| 174 | + | root.gpuTemp = v[1] | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // current cpu use per process name: utime + stime deltas from /proc/<pid>/stat (ps only knows lifetime averages) | |
| 180 | + | Process { | |
| 181 | + | id: procProc | |
| 182 | + | command: ["sh", "-c", "cat /proc/[0-9]*/stat 2>/dev/null"] | |
| 183 | + | stdout: StdioCollector { | |
| 184 | + | onStreamFinished: { | |
| 185 | + | var l = text.split("\n"), now = Date.now(), dt = (now - root.procAt) / 1000 | |
| 186 | + | var prev = root.procPrev, cur = {}, by = {} | |
| 187 | + | for (var i = 0; i < l.length; i++) { | |
| 188 | + | var a = l[i].indexOf(" ("), b = l[i].lastIndexOf(") ") | |
| 189 | + | if (a < 0 || b < a) continue | |
| 190 | + | var pid = l[i].slice(0, a), f = l[i].slice(b + 2).split(" ") | |
| 191 | + | var ticks = (+f[11]) + (+f[12]) | |
| 192 | + | cur[pid] = ticks | |
| 193 | + | if (prev[pid] === undefined) continue | |
| 194 | + | var n = l[i].slice(a + 2, b) | |
| 195 | + | by[n] = (by[n] || 0) + ticks - prev[pid] | |
| 196 | + | } | |
| 197 | + | var out = [] | |
| 198 | + | if (root.procAt > 0 && dt > 0) | |
| 199 | + | for (var k in by) if (by[k] > 0) out.push([k, by[k] / dt]) // 100 ticks/s = 100% | |
| 200 | + | out.sort(function (x, y) { return y[1] - x[1] }) | |
| 201 | + | root.procs = out.slice(0, 4) | |
| 202 | + | root.procPrev = cur | |
| 203 | + | root.procAt = now | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | function rate(b) { // bytes/s -> [number, unit] | |
| 209 | + | if (b >= 1048576) return [(b / 1048576).toFixed(b >= 10485760 ? 0 : 1), "mb/s"] | |
| 210 | + | return [String(Math.round(b / 1024)), "kb/s"] | |
| 211 | + | } | |
| 212 | + | ||
| 54 | 213 | // every mounted *local* block device (internal nvme, external drives, usb sticks...). | |
| 55 | 214 | // pseudo filesystems and network mounts are skipped; one entry per physical source | |
| 56 | 215 | // (so btrfs subvolumes / bind mounts of the same partition don't repeat). | |
| @@ -222,6 +222,20 @@ Singleton { | |||
| 222 | 222 | readonly property string iText: cp(0xF0262) // format-align-left | |
| 223 | 223 | readonly property string iLink: cp(0xF0339) // link-variant | |
| 224 | 224 | readonly property string iCode: cp(0xF0169) // code-braces | |
| 225 | + | // weather (Weather.icon picks one per WMO code) | |
| 226 | + | readonly property string iSunny: cp(0xF0599) | |
| 227 | + | readonly property string iPartlyCloudy: cp(0xF0595) | |
| 228 | + | readonly property string iNightCloudy: cp(0xF0F31) // weather-night-partly-cloudy | |
| 229 | + | readonly property string iCloudy: cp(0xF0590) | |
| 230 | + | readonly property string iFog: cp(0xF0591) | |
| 231 | + | readonly property string iRain: cp(0xF0597) | |
| 232 | + | readonly property string iPouring: cp(0xF0596) | |
| 233 | + | readonly property string iSnow: cp(0xF0598) | |
| 234 | + | readonly property string iThunder: cp(0xF0593) | |
| 235 | + | readonly property string iSunrise: cp(0xF059C) // weather-sunset-up | |
| 236 | + | readonly property string iSunset: cp(0xF059B) // weather-sunset-down | |
| 237 | + | readonly property string iArrowDown: cp(0xF0045) | |
| 238 | + | readonly property string iArrowUp: cp(0xF005D) | |
| 225 | 239 | ||
| 226 | 240 | readonly property var batLevels: [0xF007A, 0xF007B, 0xF007C, 0xF007D, 0xF007E, | |
| 227 | 241 | 0xF007F, 0xF0080, 0xF0081, 0xF0082, 0xF0079] | |
| @@ -0,0 +1,171 @@ | |||
| 1 | + | pragma Singleton | |
| 2 | + | import QtQuick | |
| 3 | + | import Quickshell | |
| 4 | + | import Quickshell.Io | |
| 5 | + | ||
| 6 | + | // Weather for the calendar popup: open-meteo (no API key) every 30 min, cached in ~/.cache/aurora/weather.json so a | |
| 7 | + | // restart shows the last one at once. Place = Settings.weatherLoc: "" = by IP (ipinfo.io), "lat,lon", or a place name | |
| 8 | + | // (open-meteo geocoding). Offline / overloaded -> retry in 2 min. QML timers stand still during suspend, so the popup | |
| 9 | + | // calls poke() on open to catch up. | |
| 10 | + | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), services/Weather.qml. | |
| 11 | + | Singleton { | |
| 12 | + | id: root | |
| 13 | + | readonly property string cacheFile: Quickshell.env("HOME") + "/.cache/aurora/weather.json" | |
| 14 | + | readonly property int every: 30 * 60 * 1000 | |
| 15 | + | ||
| 16 | + | property var now: null // {temp, feels, code, wind (km/h), humidity, rain (%), day} | |
| 17 | + | property var days: [] // [{date, code, max, min, rain, rise, set}], today first; dates are local | |
| 18 | + | property string place: "" // city, when the lookup gave one | |
| 19 | + | property double fetched: 0 // Date.now() of the last good fetch | |
| 20 | + | property string error: "" // why there is no weather | |
| 21 | + | readonly property bool ok: now !== null | |
| 22 | + | readonly property bool busy: where.running || get.running | |
| 23 | + | ||
| 24 | + | // coordinates and the Settings.weatherLoc they were resolved for | |
| 25 | + | property string key: "" | |
| 26 | + | property real lat: NaN | |
| 27 | + | property real lon: NaN | |
| 28 | + | ||
| 29 | + | readonly property var names: ({ 0: "clear", 1: "mostly clear", 2: "partly cloudy", 3: "overcast", 45: "fog", 48: "fog", | |
| 30 | + | 51: "drizzle", 53: "drizzle", 55: "drizzle", 56: "ice drizzle", 57: "ice drizzle", 61: "light rain", 63: "rain", | |
| 31 | + | 65: "heavy rain", 66: "ice rain", 67: "ice rain", 71: "light snow", 73: "snow", 75: "heavy snow", 77: "snow grains", | |
| 32 | + | 80: "showers", 81: "showers", 82: "storm showers", 85: "snow showers", 86: "snow showers", 95: "thunder", | |
| 33 | + | 96: "thunder", 99: "thunder" }) | |
| 34 | + | function name(code) { return names[code] !== undefined ? names[code] : "weather" } | |
| 35 | + | function icon(code, day) { | |
| 36 | + | if (code <= 1) return day === false ? Theme.iNight : Theme.iSunny | |
| 37 | + | if (code === 2) return day === false ? Theme.iNightCloudy : Theme.iPartlyCloudy | |
| 38 | + | if (code === 3) return Theme.iCloudy | |
| 39 | + | if (code < 50) return Theme.iFog | |
| 40 | + | if (code >= 95) return Theme.iThunder | |
| 41 | + | if ((code >= 71 && code <= 77) || code === 85 || code === 86) return Theme.iSnow | |
| 42 | + | if (code === 65 || code === 82) return Theme.iPouring | |
| 43 | + | return Theme.iRain | |
| 44 | + | } | |
| 45 | + | // the entry for date d's day, null if the forecast doesn't cover it | |
| 46 | + | function dayOf(d) { | |
| 47 | + | for (var i = 0; i < days.length; i++) | |
| 48 | + | if (days[i].date.toDateString() === d.toDateString()) return days[i] | |
| 49 | + | return null | |
| 50 | + | } | |
| 51 | + | // up to n days after d | |
| 52 | + | function after(d, n) { | |
| 53 | + | var out = [], end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1) | |
| 54 | + | for (var i = 0; i < days.length && out.length < n; i++) | |
| 55 | + | if (days[i].date >= end) out.push(days[i]) | |
| 56 | + | return out | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | function refresh() { | |
| 60 | + | if (busy) { later.restart(); return } | |
| 61 | + | var want = (Settings.weatherLoc || "").trim() | |
| 62 | + | if (want !== key || isNaN(lat)) resolve(want) | |
| 63 | + | else pull() | |
| 64 | + | } | |
| 65 | + | function poke() { | |
| 66 | + | if (!busy && Date.now() - fetched > every) refresh() | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | function resolve(want) { | |
| 70 | + | var m = want.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/) | |
| 71 | + | if (m) { | |
| 72 | + | key = want; lat = +m[1]; lon = +m[2]; place = "" | |
| 73 | + | pull() | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | where.want = want | |
| 77 | + | where.command = want === "" | |
| 78 | + | ? ["curl", "-sf", "--max-time", "10", "https://ipinfo.io/json"] | |
| 79 | + | : ["curl", "-sfG", "--max-time", "10", "--data-urlencode", "name=" + want, "-d", "count=1", | |
| 80 | + | "https://geocoding-api.open-meteo.com/v1/search"] | |
| 81 | + | where.running = true | |
| 82 | + | } | |
| 83 | + | Process { | |
| 84 | + | id: where | |
| 85 | + | property string want: "" | |
| 86 | + | stdout: StdioCollector { | |
| 87 | + | onStreamFinished: { | |
| 88 | + | var j | |
| 89 | + | try { j = JSON.parse(text) } catch (e) { return } | |
| 90 | + | var w = where.want | |
| 91 | + | if (w === "") { | |
| 92 | + | var p = String(j.loc || "").split(",") | |
| 93 | + | if (p.length !== 2 || isNaN(+p[0]) || isNaN(+p[1])) return | |
| 94 | + | root.lat = +p[0]; root.lon = +p[1]; root.place = j.city || "" | |
| 95 | + | } else { | |
| 96 | + | if (!j.results || !j.results.length) { root.error = "no place called " + w; return } | |
| 97 | + | var r = j.results[0] | |
| 98 | + | root.lat = r.latitude; root.lon = r.longitude; root.place = r.name || w | |
| 99 | + | } | |
| 100 | + | root.key = w | |
| 101 | + | root.pull() | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | onExited: code => { if (code !== 0) { root.error = "offline"; retry.restart() } } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | function pull() { | |
| 108 | + | get.command = ["curl", "-sf", "--max-time", "15", "https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon | |
| 109 | + | + "¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,precipitation_probability,is_day" | |
| 110 | + | + "&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max" | |
| 111 | + | + "&timezone=auto&forecast_days=6"] | |
| 112 | + | get.running = true | |
| 113 | + | } | |
| 114 | + | Process { | |
| 115 | + | id: get | |
| 116 | + | stdout: StdioCollector { | |
| 117 | + | onStreamFinished: { | |
| 118 | + | var j | |
| 119 | + | try { j = JSON.parse(text) } catch (e) { return } | |
| 120 | + | if (!root.parse(j)) return | |
| 121 | + | root.fetched = Date.now() | |
| 122 | + | root.error = "" | |
| 123 | + | cache.setText(JSON.stringify({ key: root.key, lat: root.lat, lon: root.lon, place: root.place, | |
| 124 | + | fetched: root.fetched, raw: j })) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | onExited: code => { if (code !== 0) { root.error = "offline"; retry.restart() } } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // open-meteo's times have no offset ("2026-10-29T06:32", in the place's zone): JS reads them as local time | |
| 131 | + | function parse(j) { | |
| 132 | + | var c = j.current, d = j.daily | |
| 133 | + | if (!c || !d || !d.time) return false | |
| 134 | + | now = { temp: c.temperature_2m, feels: c.apparent_temperature, code: c.weather_code, wind: c.wind_speed_10m, | |
| 135 | + | humidity: c.relative_humidity_2m, rain: c.precipitation_probability, day: c.is_day === 1 } | |
| 136 | + | var out = [] | |
| 137 | + | for (var i = 0; i < d.time.length; i++) | |
| 138 | + | out.push({ date: new Date(d.time[i] + "T00:00"), code: d.weather_code[i], max: d.temperature_2m_max[i], | |
| 139 | + | min: d.temperature_2m_min[i], rain: d.precipitation_probability_max[i], | |
| 140 | + | rise: new Date(d.sunrise[i]), set: new Date(d.sunset[i]) }) | |
| 141 | + | days = out | |
| 142 | + | return true | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | property bool started: false | |
| 146 | + | FileView { | |
| 147 | + | id: cache | |
| 148 | + | path: root.cacheFile | |
| 149 | + | printErrors: false | |
| 150 | + | onLoaded: { | |
| 151 | + | if (root.started) return | |
| 152 | + | root.started = true | |
| 153 | + | try { | |
| 154 | + | var c = JSON.parse(text()) | |
| 155 | + | if (root.parse(c.raw)) { | |
| 156 | + | root.key = c.key; root.lat = c.lat; root.lon = c.lon; root.place = c.place || ""; root.fetched = c.fetched | |
| 157 | + | } | |
| 158 | + | } catch (e) {} | |
| 159 | + | if (root.key !== (Settings.weatherLoc || "").trim() || Date.now() - root.fetched > root.every) root.refresh() | |
| 160 | + | } | |
| 161 | + | onLoadFailed: { if (!root.started) { root.started = true; root.refresh() } } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | Connections { | |
| 165 | + | target: Settings | |
| 166 | + | function onWeatherLocChanged() { if (root.started) root.refresh() } | |
| 167 | + | } | |
| 168 | + | Timer { id: retry; interval: 120000; onTriggered: root.refresh() } | |
| 169 | + | Timer { id: later; interval: 3000; onTriggered: root.refresh() } | |
| 170 | + | Timer { interval: root.every; running: true; repeat: true; onTriggered: root.refresh() } | |
| 171 | + | } | |