From bb25df0b0399f700486ffbb8cf7bcbde3375e349 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 11:23:28 +0100 Subject: [PATCH] quickshell: gnome-style calendar popup with weather, media controls, notifications Co-Authored-By: Claude Fable 5 --- settings/quickshell.nix | 673 ++++++++++++++++++++++++++++------------ 1 file changed, 476 insertions(+), 197 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 10773aa..1af0ea0 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -43,6 +43,16 @@ in nmcli = "${pkgs.networkmanager}/bin/nmcli"; # Follow stylix's monospace choice so a font swap propagates to the bar monoFont = osConfig.stylix.fonts.monospace.name; + # 7-day forecast JSON from Open-Meteo (no API key). Location is + # auto-detected by IP via ipinfo.io, falling back to London. + weatherFetchScript = pkgs.writeShellScript "weather-fetch" '' + loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true) + case "$loc" in + *,*) lat=''${loc%,*}; lon=''${loc#*,} ;; + *) lat=51.51; lon=-0.13 ;; + esac + ${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7" + ''; in { "quickshell/qmldir" = { onChange = qsRestart; @@ -91,6 +101,7 @@ in readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" readonly property string hyprlock: "${pkgs.hyprlock}/bin/hyprlock" readonly property string systemctl: "${pkgs.systemd}/bin/systemctl" + readonly property string weatherFetch: "${weatherFetchScript}" } ''; }; @@ -378,6 +389,7 @@ in import Quickshell.Services.Notifications import Quickshell.Services.Pipewire import Quickshell.Services.UPower + import Quickshell.Services.Mpris import Quickshell.Widgets import Quickshell.Io import QtQuick @@ -547,10 +559,10 @@ in MouseArea { anchors.fill: parent hoverEnabled: true - onClicked: bar.toggleDropdown(calPopup) + onClicked: bar.toggleDropdown(calPopup, function() { calPopup.resetView(); }) onEntered: { if (bar.activeDropdown) { - if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup); + if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); }); else bar.activeDropdown.resetAutoClose(); } } @@ -1696,240 +1708,507 @@ in } ''} - // Calendar popup + // Calendar popup — GNOME-style two-pane panel. + // Left: navigable month calendar + 7-day weather strip. + // Right: MPRIS media controls + notification list. BarDropdown { id: calPopup dropdownX: bar.width / 2 - fullWidth: calCol.width + 8 - fullHeight: calCol.height + 4 + fullWidth: calRow.width + 24 + fullHeight: calRow.height + 24 + autoCloseMs: 3000 - Column { - id: calCol + // Month being viewed; reset to today when the popup opens + // (via the setup function passed to bar.toggleDropdown). + property int viewYear: clockText.now.getFullYear() + property int viewMonth: clockText.now.getMonth() + function resetView() { + viewYear = clockText.now.getFullYear(); + viewMonth = clockText.now.getMonth(); + } + function shiftMonth(d) { + let m = viewMonth + d; + if (m < 0) { viewMonth = 11; viewYear--; } + else if (m > 11) { viewMonth = 0; viewYear++; } + else viewMonth = m; + } + + // --- Weather: 7-day forecast, refreshed every 30 min --- + property var weatherDays: [] + Process { + id: weatherProc + command: [Commands.weatherFetch] + stdout: StdioCollector { + onStreamFinished: { + try { + let j = JSON.parse(text); + let out = []; + for (let i = 0; i < j.daily.time.length && i < 7; i++) { + out.push({ + day: new Date(j.daily.time[i] + "T12:00:00").toLocaleDateString(Qt.locale(), "ddd").slice(0, 2), + code: j.daily.weather_code[i], + max: Math.round(j.daily.temperature_2m_max[i]), + min: Math.round(j.daily.temperature_2m_min[i]) + }); + } + if (out.length > 0) calPopup.weatherDays = out; + } catch (e) { /* keep previous forecast */ } + } + } + } + Timer { + interval: 1800000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: weatherProc.running = true + } + function weatherGlyph(code) { + if (code === 0) return "\u{f0599}"; // sunny + if (code <= 2) return "\u{f0595}"; // partly cloudy + if (code === 3) return "\u{f0590}"; // overcast + if (code <= 48) return "\u{f0591}"; // fog + if (code <= 57) return "\u{f0597}"; // drizzle + if (code <= 67) return "\u{f0596}"; // rain + if (code <= 77) return "\u{f0598}"; // snow + if (code <= 82) return "\u{f0597}"; // showers + if (code <= 86) return "\u{f0598}"; // snow showers + return "\u{f0593}"; // thunder + } + + // --- Media: prefer the actively playing MPRIS player --- + property var player: { + let ps = Mpris.players.values; + for (let i = 0; i < ps.length; i++) { + if (ps[i].playbackState === MprisPlaybackState.Playing) return ps[i]; + } + return ps.length > 0 ? ps[0] : null; + } + + Row { + id: calRow anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - - spacing: 8 + anchors.topMargin: 12 + spacing: 16 opacity: calPopup.open ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } - Text { - id: calTitle - anchors.horizontalCenter: parent.horizontalCenter - text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy") - color: Theme.base05 - font.family: Theme.fontFamily - font.pixelSize: 16 - font.weight: Font.Medium - } + // ── Left pane: calendar + weather ── + Column { + id: calLeftCol + width: 7 * 32 + spacing: 8 - Row { - id: weekdayRow - anchors.horizontalCenter: parent.horizontalCenter - spacing: 0 - Repeater { - model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] - Text { - required property var modelData - width: 32 - horizontalAlignment: Text.AlignHCenter - text: modelData - color: Theme.base04 - font.family: Theme.fontFamily - font.pixelSize: 13 - } - } - } + // Month header: ‹ [Month Year] › — label click jumps to today + Item { + width: parent.width + height: 28 - Grid { - columns: 7 - spacing: 0 - - Repeater { - id: calRepeater - model: 42 - - Rectangle { - required property int index - width: 32 - height: 26 - radius: 4 - color: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = index - startDay + 1; - let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); - return (dayNum === d.getDate() && dayNum >= 1 && dayNum <= daysInMonth) - ? Theme.base03 : "transparent"; - } - - Text { - anchors.centerIn: parent - text: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = parent.index - startDay + 1; - let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); - return (dayNum >= 1 && dayNum <= daysInMonth) ? dayNum.toString() : ""; - } - color: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = parent.index - startDay + 1; - return (dayNum === d.getDate()) ? Theme.base05 : Theme.base04; - } - font.family: Theme.fontFamily - font.pixelSize: 13 - } - } - } - } - - Rectangle { - width: 7 * 32 + 8 - height: 1 - color: Theme.base02 - anchors.horizontalCenter: parent.horizontalCenter - } - - Row { - width: 7 * 32 + 8 - anchors.horizontalCenter: parent.horizontalCenter + Rectangle { + width: 28; height: 28; radius: 6 + anchors.left: parent.left + color: calPrevMa.containsMouse ? Theme.base02 : "transparent" Text { - text: "Notifications" + anchors.centerIn: parent + text: "\u{f0141}" color: Theme.base05 font.family: Theme.fontFamily - font.pixelSize: 13 - font.weight: Font.Medium + font.pixelSize: 16 } - Item { Layout.fillWidth: true; width: 10 } - Text { - anchors.right: parent.right - text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" - color: Theme.base04 - font.family: Theme.fontFamily - font.pixelSize: 11 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - let notifs = bar.notifServer.trackedNotifications.values; - for (let i = notifs.length - 1; i >= 0; i--) { - notifs[i].dismiss(); - } - } - } + MouseArea { + id: calPrevMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: calPopup.shiftMonth(-1) } } - Column { - spacing: 4 - width: 7 * 32 + 8 - anchors.horizontalCenter: parent.horizontalCenter - - Text { - visible: bar.notifServer.trackedNotifications.values.length === 0 - text: "No notifications" - color: Theme.base03 - font.family: Theme.fontFamily - font.pixelSize: 11 - anchors.horizontalCenter: parent.horizontalCenter + Text { + anchors.centerIn: parent + text: new Date(calPopup.viewYear, calPopup.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy") + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 14 + font.weight: Font.Medium + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: calPopup.resetView() } + } - Repeater { - model: bar.notifServer.trackedNotifications + Rectangle { + width: 28; height: 28; radius: 6 + anchors.right: parent.right + color: calNextMa.containsMouse ? Theme.base02 : "transparent" + Text { + anchors.centerIn: parent + text: "\u{f0142}" + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 16 + } + MouseArea { + id: calNextMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: calPopup.shiftMonth(1) + } + } + } - Rectangle { - id: notifItem - required property var modelData - width: 7 * 32 + 8 - height: notifCol.height + 12 - radius: 6 - color: Theme.base01 + Row { + spacing: 0 + Repeater { + model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + Text { + required property var modelData + width: 32 + horizontalAlignment: Text.AlignHCenter + text: modelData + color: Theme.base04 + font.family: Theme.fontFamily + font.pixelSize: 13 + } + } + } - Column { - id: notifCol - anchors.left: parent.left - anchors.right: dismissBtn.left - anchors.top: parent.top - anchors.margins: 6 - spacing: 2 - - Text { - width: parent.width - text: notifItem.modelData.summary || notifItem.modelData.appName - color: Theme.base05 - font.family: Theme.fontFamily - font.pixelSize: 11 - font.weight: Font.Medium - elide: Text.ElideRight + Grid { + columns: 7 + spacing: 0 + Repeater { + model: 42 + Rectangle { + required property int index + property int dayNum: { + let first = new Date(calPopup.viewYear, calPopup.viewMonth, 1); + let startDay = (first.getDay() + 6) % 7; + return index - startDay + 1; } + property int daysInMonth: new Date(calPopup.viewYear, calPopup.viewMonth + 1, 0).getDate() + property bool isToday: dayNum === clockText.now.getDate() + && calPopup.viewMonth === clockText.now.getMonth() + && calPopup.viewYear === clockText.now.getFullYear() + width: 32 + height: 26 + radius: 4 + color: isToday ? Theme.base03 : "transparent" Text { - width: parent.width - text: notifItem.modelData.body || "" + anchors.centerIn: parent + text: parent.dayNum >= 1 && parent.dayNum <= parent.daysInMonth ? parent.dayNum.toString() : "" + color: parent.isToday ? Theme.base05 : Theme.base04 + font.family: Theme.fontFamily + font.pixelSize: 13 + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.base02 + } + + // 7-day weather strip + Row { + width: parent.width + visible: calPopup.weatherDays.length > 0 + Repeater { + model: calPopup.weatherDays + Column { + required property var modelData + width: 32 + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.day color: Theme.base04 font.family: Theme.fontFamily font.pixelSize: 10 - elide: Text.ElideRight - maximumLineCount: 2 - wrapMode: Text.Wrap - visible: text !== "" } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: calPopup.weatherGlyph(modelData.code) + color: Theme.base0C + font.family: Theme.fontFamily + font.pixelSize: 14 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.max + "°" + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 10 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.min + "°" + color: Theme.base03 + font.family: Theme.fontFamily + font.pixelSize: 10 + } + } + } + } + } - Row { - spacing: 4 - visible: notifItem.modelData.actions.length > 0 - Repeater { - model: notifItem.modelData.actions - Rectangle { - required property var modelData - width: actionText.width + 12 - height: actionText.height + 4 - radius: 4 - color: actionMa.containsMouse ? Theme.base02 : Theme.base01 - border.width: 1 - border.color: Theme.base02 - Text { - id: actionText - anchors.centerIn: parent - text: modelData.text - color: Theme.base05 - font.family: Theme.fontFamily - font.pixelSize: 10 - } - MouseArea { - id: actionMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: modelData.invoke() + // Vertical separator between panes + Rectangle { + width: 1 + height: Math.max(calLeftCol.height, calRightCol.height) + color: Theme.base02 + } + + // ── Right pane: media + notifications ── + Column { + id: calRightCol + width: 300 + spacing: 8 + + // Media player card + Rectangle { + width: parent.width + height: 64 + radius: 8 + color: Theme.base01 + visible: calPopup.player !== null + + Row { + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + Rectangle { + width: 48; height: 48 + radius: 6 + anchors.verticalCenter: parent.verticalCenter + color: Theme.base02 + clip: true + Text { + anchors.centerIn: parent + visible: albumArt.status !== Image.Ready + text: "\u{f0387}" + color: Theme.base04 + font.family: Theme.fontFamily + font.pixelSize: 20 + } + Image { + id: albumArt + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + source: calPopup.player ? calPopup.player.trackArtUrl : "" + } + } + + Column { + width: parent.width - 48 - 10 - 88 - 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: calPopup.player ? calPopup.player.trackTitle : "" + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 12 + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + width: parent.width + text: calPopup.player ? calPopup.player.trackArtist : "" + color: Theme.base04 + font.family: Theme.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Repeater { + model: [ + { glyph: "\u{f04ae}", act: "prev" }, + { glyph: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "\u{f03e4}" : "\u{f040a}", act: "toggle" }, + { glyph: "\u{f04ad}", act: "next" } + ] + Rectangle { + id: mediaBtn + required property var modelData + width: 28; height: 28; radius: 14 + color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" + Text { + anchors.centerIn: parent + text: mediaBtn.modelData.glyph + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 16 + } + MouseArea { + id: mediaBtnMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + let p = calPopup.player; + if (!p) return; + if (mediaBtn.modelData.act === "prev") p.previous(); + else if (mediaBtn.modelData.act === "next") p.next(); + else p.togglePlaying(); } } } } } + } + } - Text { - id: dismissBtn - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 6 - text: "\u{f0156}" - color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 - font.family: Theme.fontFamily - font.pixelSize: 12 - MouseArea { - id: dismissMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: notifItem.modelData.dismiss() + // Notifications header + Item { + width: parent.width + height: 20 + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "Notifications" + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 13 + font.weight: Font.Medium + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" + color: Theme.base04 + font.family: Theme.fontFamily + font.pixelSize: 11 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + let notifs = bar.notifServer.trackedNotifications.values; + for (let i = notifs.length - 1; i >= 0; i--) { + notifs[i].dismiss(); + } + } + } + } + } + + Column { + spacing: 4 + width: parent.width + + Text { + visible: bar.notifServer.trackedNotifications.values.length === 0 + text: "No notifications" + color: Theme.base03 + font.family: Theme.fontFamily + font.pixelSize: 11 + anchors.horizontalCenter: parent.horizontalCenter + } + + Repeater { + model: bar.notifServer.trackedNotifications + + Rectangle { + id: notifItem + required property var modelData + width: calRightCol.width + height: notifCol.height + 12 + radius: 6 + color: Theme.base01 + + Column { + id: notifCol + anchors.left: parent.left + anchors.right: dismissBtn.left + anchors.top: parent.top + anchors.margins: 6 + spacing: 2 + + Text { + width: parent.width + text: notifItem.modelData.summary || notifItem.modelData.appName + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 11 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: notifItem.modelData.body || "" + color: Theme.base04 + font.family: Theme.fontFamily + font.pixelSize: 10 + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.Wrap + visible: text !== "" + } + + Row { + spacing: 4 + visible: notifItem.modelData.actions.length > 0 + Repeater { + model: notifItem.modelData.actions + Rectangle { + required property var modelData + width: actionText.width + 12 + height: actionText.height + 4 + radius: 4 + color: actionMa.containsMouse ? Theme.base02 : Theme.base01 + border.width: 1 + border.color: Theme.base02 + Text { + id: actionText + anchors.centerIn: parent + text: modelData.text + color: Theme.base05 + font.family: Theme.fontFamily + font.pixelSize: 10 + } + MouseArea { + id: actionMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: modelData.invoke() + } + } + } + } + } + + Text { + id: dismissBtn + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 6 + text: "\u{f0156}" + color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 + font.family: Theme.fontFamily + font.pixelSize: 12 + MouseArea { + id: dismissMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: notifItem.modelData.dismiss() + } } } }