diff --git a/config/Config.qml b/config/Config.qml index d0b24935f..c3ab03ee2 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -225,6 +225,7 @@ Singleton { mediaUpdateInterval: dashboard.mediaUpdateInterval, resourceUpdateInterval: dashboard.resourceUpdateInterval, dragThreshold: dashboard.dragThreshold, + showUpcoming: dashboard.showUpcoming, performance: { showBattery: dashboard.performance.showBattery, showGpu: dashboard.performance.showGpu, @@ -366,6 +367,7 @@ Singleton { function serializeSidebar(): var { return { enabled: sidebar.enabled, + showUpcoming: sidebar.showUpcoming, dragThreshold: sidebar.dragThreshold }; } @@ -385,7 +387,16 @@ Singleton { defaultPlayer: services.defaultPlayer, playerAliases: services.playerAliases, showLyrics: services.showLyrics, - lyricsBackend: services.lyricsBackend + lyricsBackend: services.lyricsBackend, + calendar: { + enabled: services.calendar.enabled, + command: services.calendar.command, + agendaDays: services.calendar.agendaDays, + dashUpcomingHours: services.calendar.dashUpcomingHours, + sidebarUpcomingHours: services.calendar.sidebarUpcomingHours, + reminderMinutes: services.calendar.reminderMinutes, + refreshInterval: services.calendar.refreshInterval + } }; } diff --git a/config/DashboardConfig.qml b/config/DashboardConfig.qml index 0a16cc1f9..be042b242 100644 --- a/config/DashboardConfig.qml +++ b/config/DashboardConfig.qml @@ -10,6 +10,7 @@ JsonObject { property bool showMedia: true property bool showPerformance: true property bool showWeather: true + property bool showUpcoming: false property Sizes sizes: Sizes {} property Performance performance: Performance {} diff --git a/config/ServiceConfig.qml b/config/ServiceConfig.qml index a9d7c3dcf..ad881e3c6 100644 --- a/config/ServiceConfig.qml +++ b/config/ServiceConfig.qml @@ -21,4 +21,15 @@ JsonObject { ] property bool showLyrics: false property string lyricsBackend: "Auto" + property GCalendarConfig calendar: GCalendarConfig {} + + component GCalendarConfig: JsonObject { + property bool enabled: false // Requires gws CLI in PATH + property string command: "gws" // Path or name of the gws CLI binary + property int agendaDays: 30 // How many days ahead to fetch events + property int dashUpcomingHours: 24 // Hours ahead to show in dashboard upcoming list + property int sidebarUpcomingHours: 120 // Hours ahead to show in sidebar upcoming list + property int reminderMinutes: 10 // Minutes before event to send notification, 0 to disable + property int refreshInterval: 900 // Refresh interval in seconds + } } diff --git a/config/SidebarConfig.qml b/config/SidebarConfig.qml index a871562b9..24951bdb9 100644 --- a/config/SidebarConfig.qml +++ b/config/SidebarConfig.qml @@ -2,6 +2,7 @@ import Quickshell.Io JsonObject { property bool enabled: true + property bool showUpcoming: true property int dragThreshold: 80 property Sizes sizes: Sizes {} diff --git a/modules/dashboard/Dash.qml b/modules/dashboard/Dash.qml index 59620d88a..804428327 100644 --- a/modules/dashboard/Dash.qml +++ b/modules/dashboard/Dash.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import "dash" import QtQuick.Layouts import qs.components @@ -86,7 +88,7 @@ GridLayout { Rect { Layout.row: 0 Layout.column: 5 - Layout.rowSpan: 2 + Layout.rowSpan: Config.dashboard.showUpcoming && GCalendar.upcomingDash.length > 0 ? 3 : 2 Layout.preferredWidth: media.implicitWidth Layout.fillHeight: true @@ -97,6 +99,86 @@ GridLayout { } } + // Upcoming events + Rect { + Layout.row: 2 + Layout.column: 0 + Layout.columnSpan: 5 + Layout.fillWidth: true + Layout.preferredHeight: eventsCol.implicitHeight + + visible: Config.dashboard.showUpcoming && GCalendar.upcomingDash.length > 0 + radius: Appearance.rounding.large + + ColumnLayout { + id: eventsCol + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + StyledText { + Layout.topMargin: Appearance.padding.small + text: qsTr("Upcoming") + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.small + font.weight: 600 + } + + Repeater { + model: GCalendar.upcomingDash // qmllint disable missing-property + + RowLayout { // qmllint disable missing-property + id: eventRow + + required property var modelData + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Rectangle { + Layout.preferredWidth: 3 + Layout.fillHeight: true + radius: 1.5 // qmllint disable missing-property + color: Colours.palette.m3tertiary // qmllint disable missing-property + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: eventRow.modelData.summary + color: Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.small + font.weight: 500 + elide: Text.ElideRight // qmllint disable unqualified + } + + StyledText { + Layout.fillWidth: true + text: { + let line = GCalendar.formatEventTime(eventRow.modelData, Config.services.calendar.dashUpcomingHours); + if (eventRow.modelData.location) + line += ` · ${eventRow.modelData.location}`; + return line; + } + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small * 0.9 + elide: Text.ElideRight // qmllint disable unqualified + } + } + } + } + + Item { + Layout.preferredHeight: Appearance.padding.small + } + } + } + component Rect: StyledRect { color: Colours.tPalette.m3surfaceContainer } diff --git a/modules/dashboard/dash/Calendar.qml b/modules/dashboard/dash/Calendar.qml index 64e43f5c0..46d377855 100644 --- a/modules/dashboard/dash/Calendar.qml +++ b/modules/dashboard/dash/Calendar.qml @@ -166,6 +166,8 @@ CustomMouseArea { required property var model + readonly property bool hasEvent: GCalendar.hasEvent(model.date) + implicitWidth: implicitHeight implicitHeight: text.implicitHeight + Appearance.padding.small * 2 @@ -187,6 +189,18 @@ CustomMouseArea { font.pointSize: Appearance.font.size.normal font.weight: 500 } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 1 + width: 4 + height: 4 + radius: 2 + color: Colours.palette.m3tertiary + visible: dayItem.hasEvent + opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4 + } } } diff --git a/modules/sidebar/Content.qml b/modules/sidebar/Content.qml index 7dbbd06c3..88533c8a7 100644 --- a/modules/sidebar/Content.qml +++ b/modules/sidebar/Content.qml @@ -1,6 +1,10 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Layouts import qs.components +import qs.components.containers +import qs.components.controls import qs.services import qs.config @@ -29,6 +33,111 @@ Item { } } + // Upcoming events + StyledRect { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(upcomingCol.implicitHeight, 300) + + visible: Config.sidebar.showUpcoming && GCalendar.enabled && GCalendar.upcomingSidebar.length > 0 + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainerLow + + ColumnLayout { + id: upcomingHeader + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.normal + + StyledText { + Layout.topMargin: Appearance.padding.small + text: qsTr("Upcoming Events") + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.small + font.weight: 600 + } + } + + StyledFlickable { + id: upcomingView + + clip: true + anchors.top: upcomingHeader.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.normal + anchors.topMargin: Appearance.spacing.small + + flickableDirection: Flickable.VerticalFlick + contentWidth: width + contentHeight: upcomingCol.implicitHeight + + StyledScrollBar.vertical: StyledScrollBar { + flickable: upcomingView + } + + ColumnLayout { + id: upcomingCol + + width: parent.width + spacing: Appearance.spacing.small + + Repeater { + model: GCalendar.upcomingSidebar + + RowLayout { + id: sidebarEventRow + + required property var modelData + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Rectangle { + Layout.preferredWidth: 3 + Layout.fillHeight: true + radius: 1.5 + color: Colours.palette.m3tertiary + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: sidebarEventRow.modelData.summary + color: Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.small + font.weight: 500 + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: { + let line = GCalendar.formatEventTime(sidebarEventRow.modelData, Config.services.calendar.sidebarUpcomingHours); + if (sidebarEventRow.modelData.location) + line += ` · ${sidebarEventRow.modelData.location}`; + return line; + } + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small * 0.9 + elide: Text.ElideRight + } + } + } + } + + Item { + Layout.preferredHeight: Appearance.padding.small + } + } + } + } + StyledRect { Layout.topMargin: Appearance.padding.large - layout.spacing Layout.fillWidth: true diff --git a/services/GCalendar.qml b/services/GCalendar.qml new file mode 100644 index 000000000..a8f3eddc5 --- /dev/null +++ b/services/GCalendar.qml @@ -0,0 +1,192 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +import qs.utils + +Singleton { + id: root + + readonly property bool enabled: Config.services.calendar.enabled + + property list events: [] + readonly property var eventDateSet: { + const s = new Set(); + for (const ev of events) + s.add(ev.dateKey); + return s; + } + readonly property list upcomingDash: { + const now = Date.now(); + const cutoff = now + Config.services.calendar.dashUpcomingHours * 3600000; + return events.filter(ev => { + const t = ev.startTime; + return t >= now && t < cutoff; + }); + } + readonly property list upcomingSidebar: { + const now = Date.now(); + const cutoff = now + Config.services.calendar.sidebarUpcomingHours * 3600000; + return events.filter(ev => { + const t = ev.startTime; + return t >= now && t < cutoff; + }); + } + + property var notifiedSet: new Set() + + function hasEvent(date: date): bool { + if (!enabled) + return false; + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + return eventDateSet.has(`${y}-${m}-${d}`); + } + + function formatTime(isoStr: string): string { + if (!isoStr || isoStr.length === 10) + return qsTr("All day"); + const d = new Date(isoStr); + return Config.services.useTwelveHourClock ? Qt.formatDateTime(d, "h:mm AP") : Qt.formatDateTime(d, "hh:mm"); + } + + function formatEventTime(ev: var, upcomingHours: int): string { + let parts = []; + if (upcomingHours > 24) { + const d = new Date(ev.start); + const target = ev.isAllDay ? new Date(ev.start + "T00:00:00") : d; + const now = new Date(); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + const dayAfter = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2); + if (target >= now && target < tomorrow) + parts.push(qsTr("Today")); + else if (target >= tomorrow && target < dayAfter) + parts.push(qsTr("Tomorrow")); + else + parts.push(Qt.formatDateTime(target, "ddd, MMM d")); + } + parts.push(formatTime(ev.start)); + return parts.join(" · "); + } + + function parseEvents(data: var): list { + const parsed = []; + for (const ev of data) { + const startStr = ev.start ?? ""; + const startDate = new Date(startStr); + const isAllDay = startStr.length === 10; + let dateKey; + if (isAllDay) { + dateKey = startStr; + } else { + const y = startDate.getFullYear(); + const m = String(startDate.getMonth() + 1).padStart(2, "0"); + const d = String(startDate.getDate()).padStart(2, "0"); + dateKey = `${y}-${m}-${d}`; + } + parsed.push({ + summary: ev.summary ?? "", + start: startStr, + end: ev.end ?? "", + location: ev.location ?? "", + calendar: ev.calendar ?? "", + startTime: startDate.getTime(), + dateKey: dateKey, + isAllDay: isAllDay + }); + } + parsed.sort((a, b) => a.startTime - b.startTime); + return parsed; + } + + function saveCache(): void { + cache.setText(JSON.stringify(root.events.map(ev => ({ + summary: ev.summary, + start: ev.start, + end: ev.end, + location: ev.location, + calendar: ev.calendar + })))); + } + + function fetch(): void { + if (!enabled) + return; + fetchProc.running = true; + } + + // Initial fetch (refreshes cache in background) + Component.onCompleted: fetch() + + // Load cached events on startup + FileView { + id: cache + + path: `${Paths.state}/gcalendar.json` + onLoaded: { + try { + const data = JSON.parse(text()); + if (root.events.length === 0) + root.events = root.parseEvents(data); + } catch (e) { + // Ignore corrupt cache + } + } + onLoadFailed: err => { + if (err === FileViewError.FileNotFound) + setText("[]"); + } + } + + Process { + id: fetchProc + + command: [Config.services.calendar.command, "calendar", "+agenda", "--days", String(Config.services.calendar.agendaDays), "--format", "json"] + stdout: StdioCollector { + onStreamFinished: { + try { + const json = JSON.parse(text); + root.events = root.parseEvents(json.events ?? []); + root.saveCache(); + } catch (e) { + console.warn("GCalendar: failed to parse gws output:", e); + } + } + } + } + + // Check for reminders + Timer { + interval: 30000 + running: root.enabled && Config.services.calendar.reminderMinutes > 0 + repeat: true + onTriggered: { + const now = Date.now(); + const reminderMs = Config.services.calendar.reminderMinutes * 60000; + for (const ev of root.events) { + if (ev.isAllDay) + continue; + const diff = ev.startTime - now; + if (diff > 0 && diff <= reminderMs + 60000 && diff > reminderMs - 60000) { + const key = ev.summary + ev.start; + if (!root.notifiedSet.has(key)) { + root.notifiedSet.add(key); + const timeStr = root.formatTime(ev.start); + Quickshell.execDetached(["notify-send", "-a", "caelestia-shell", "-u", "normal", "-i", "calendar", ev.summary, `Starts at ${timeStr}${ev.location ? " - " + ev.location : ""}`]); + } + } + } + } + } + + // Periodic refresh + Timer { + interval: Config.services.calendar.refreshInterval * 1000 + running: root.enabled + repeat: true + onTriggered: root.fetch() + } +}