Krolyxon
Commit b101de2

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-29 11:39 +0530
Parent
7a221ea
Commit
b101de2731bb662b1f4530794e6d9c3f09f606c5

+849 −86

6 files changed

  1. M .config/quickshell/aurora/CalPopup.qml +484 −85
  2. M .config/quickshell/aurora/SetShell.qml +18 −0
  3. M .config/quickshell/aurora/Settings.qml +3 −1
  4. M .config/quickshell/aurora/SysStats.qml +159 −0
  5. M .config/quickshell/aurora/Theme.qml +14 −0
  6. A .config/quickshell/aurora/Weather.qml +171 −0
M .config/quickshell/aurora/CalPopup.qml View
@@ -4,9 +4,11 @@ import Quickshell
44 import Quickshell.Wayland
55 import Quickshell.Hyprland
66
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.
1012 PanelWindow {
1113 id: win
1214 property bool opened: false
@@ -33,8 +35,12 @@ PanelWindow {
3335 onCleared: win.closeRequested()
3436 }
3537
38+ Binding { target: SysStats; property: "detailed"; value: win.opened }
39+
3640 SystemClock { id: clock; precision: SystemClock.Minutes }
3741 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
3844
3945 property int year: 0
4046 property int month: 0 // 0..11
@@ -48,7 +54,7 @@ PanelWindow {
4854 year += Math.floor(m / 12)
4955 month = ((m % 12) + 12) % 12
5056 }
51− onOpenedChanged: if (opened) today()
57+ onOpenedChanged: if (opened) { today(); Weather.poke() }
5258
5359 function isoWeek(d) {
5460 var t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
@@ -66,14 +72,26 @@ PanelWindow {
6672 out.push(new Date(start.getFullYear(), start.getMonth(), start.getDate() + i))
6773 return out
6874 }
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+ }
7088
7189 Rectangle {
7290 id: card
7391 y: -16 * (1 - win.t)
7492 opacity: win.t
75− width: 384
76− height: col.implicitHeight + 44
93+ width: row.width + 52
94+ height: row.height + 48
7795 radius: 36
7896 color: Theme.fade(Theme.s1, Theme.panelAlpha)
7997 border.width: 1
@@ -86,102 +104,483 @@ PanelWindow {
86104 Keys.onDownPressed: win.shift(12)
87105 Keys.onSpacePressed: win.today()
88106
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
93126
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+ }
105211 }
106212
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 }
118238 }
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" : ""
119249 }
120250 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) : "--:--" }
123306 }
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) }
127307 }
128308
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"
148339 }
149340 }
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
163443 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
170454 }
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
171496 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
181514 }
182515 }
183516 }
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+ }
184537 }
185538 }
186539 }
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+ }
187586 }
M .config/quickshell/aurora/SetShell.qml View
@@ -52,6 +52,24 @@ Flickable {
5252 SectionLabel { text: "bar" }
5353 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 }
5454
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+
5573 SectionLabel { text: "notifications" }
5674 Switch { label: "do not disturb"; hint: "hide popups and mute the sound (critical ones still show)"; checked: Notifs.dnd; onToggled: on => Notifs.dnd = on }
5775 Switch { label: "sound"; checked: Settings.notifSound; onToggled: on => Settings.notifSound = on }
M .config/quickshell/aurora/Settings.qml View
@@ -20,7 +20,7 @@ Singleton {
2020 privacyIndicators: true, powerAuto: false, powerAc: "balanced", powerBat: "saver",
2121 motion: true, clock24: true, appIcons: "dots", barSize: 40,
2222 wallLook: "dots", wallPitch: 10, wallBright: 85, dotGrid: true,
23− blink: true
23+ blink: true, weatherLoc: ""
2424 })
2525
2626 property alias barAlpha: a.barAlpha
@@ -59,6 +59,7 @@ Singleton {
5959 property alias wallBright: a.wallBright
6060 property alias dotGrid: a.dotGrid
6161 property alias blink: a.blink
62+ property alias weatherLoc: a.weatherLoc
6263
6364 function reset() {
6465 for (var k in defaults) a[k] = defaults[k]
@@ -119,6 +120,7 @@ Singleton {
119120 property int wallBright: 85 // %, from the background to the full image
120121 property bool dotGrid: true // dot grid on an empty desktop
121122 property bool blink: true // the desktop clock's colon blinks
123+ property string weatherLoc: "" // Weather.qml: "" = by IP, "lat,lon" or a place name
122124 }
123125 }
124126 // first run: create the file once the directory exists
M .config/quickshell/aurora/SysStats.qml View
@@ -51,6 +51,165 @@ Singleton {
5151
5252 Timer { interval: 1000; running: true; repeat: true; triggeredOnStart: true; onTriggered: root.sample() }
5353
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+
54213 // every mounted *local* block device (internal nvme, external drives, usb sticks...).
55214 // pseudo filesystems and network mounts are skipped; one entry per physical source
56215 // (so btrfs subvolumes / bind mounts of the same partition don't repeat).
M .config/quickshell/aurora/Theme.qml View
@@ -222,6 +222,20 @@ Singleton {
222222 readonly property string iText: cp(0xF0262) // format-align-left
223223 readonly property string iLink: cp(0xF0339) // link-variant
224224 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)
225239
226240 readonly property var batLevels: [0xF007A, 0xF007B, 0xF007C, 0xF007D, 0xF007E,
227241 0xF007F, 0xF0080, 0xF0081, 0xF0082, 0xF0079]
A .config/quickshell/aurora/Weather.qml View
@@ -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+ + "&current=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+}