quickshell: gnome-style calendar popup with weather, media controls, notifications

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-06-11 11:23:28 +01:00
parent 4d52da994c
commit bb25df0b03

View file

@ -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()
}
}
}
}