summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app.tsx2
-rw-r--r--package-lock.json7
-rw-r--r--package.json1
-rw-r--r--scss/sidebar.scss158
-rw-r--r--src/config/defaults.ts4
-rw-r--r--src/config/index.ts1
-rw-r--r--src/config/types.ts2
-rw-r--r--src/modules/sidebar/dashboard.tsx3
-rw-r--r--src/modules/sidebar/modules/upcoming.tsx83
-rw-r--r--src/services/calendar.ts116
-rw-r--r--src/utils/widgets.ts8
11 files changed, 332 insertions, 53 deletions
diff --git a/app.tsx b/app.tsx
index 5d862ca..135dce8 100644
--- a/app.tsx
+++ b/app.tsx
@@ -5,6 +5,7 @@ import Osds from "@/modules/osds";
import Popdowns from "@/modules/popdowns";
import Session from "@/modules/session";
import SideBar from "@/modules/sidebar";
+import Calendar from "@/services/calendar";
import Monitors from "@/services/monitors";
import Palette from "@/services/palette";
import Players from "@/services/players";
@@ -82,6 +83,7 @@ App.start({
timeout(1000, () => {
idle(() => Schemes.get_default());
idle(() => Wallpapers.get_default());
+ idle(() => Calendar.get_default());
});
console.log(`Caelestia started in ${Date.now() - now}ms`);
diff --git a/package-lock.json b/package-lock.json
index 6e56f15..c8c9317 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"fuzzysort": "^3.1.0",
+ "ical.js": "^2.1.0",
"mathjs": "^14.0.1"
},
"devDependencies": {
@@ -534,6 +535,12 @@
"integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
"license": "MIT"
},
+ "node_modules/ical.js": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.1.0.tgz",
+ "integrity": "sha512-BOVfrH55xQ6kpS3muGvIXIg2l7p+eoe12/oS7R5yrO3TL/j/bLsR0PR+tYQESFbyTbvGgPHn9zQ6tI4FWyuSaQ==",
+ "license": "MPL-2.0"
+ },
"node_modules/javascript-natural-sort": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
diff --git a/package.json b/package.json
index c530938..4574f96 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"dependencies": {
"fuzzysort": "^3.1.0",
+ "ical.js": "^2.1.0",
"mathjs": "^14.0.1"
},
"devDependencies": {
diff --git a/scss/sidebar.scss b/scss/sidebar.scss
index 84d1d61..249f516 100644
--- a/scss/sidebar.scss
+++ b/scss/sidebar.scss
@@ -30,6 +30,49 @@
margin: 0 lib.s(10);
}
+ .header-bar {
+ margin-bottom: lib.s(10);
+
+ @include lib.spacing;
+
+ & > :not(button) {
+ font-weight: bold;
+ font-size: lib.s(16);
+ }
+
+ & > button {
+ @include lib.element-decel;
+ @include lib.rounded(10);
+
+ padding: lib.s(3) lib.s(8);
+
+ &:hover,
+ &:focus {
+ color: scheme.$subtext0;
+ }
+
+ &:active {
+ color: scheme.$overlay2;
+ }
+
+ &.enabled {
+ $-base: color.change(scheme.$base, $alpha: 1);
+
+ background-color: scheme.$primary;
+ color: $-base;
+
+ &:hover,
+ &:focus {
+ background-color: color.mix(scheme.$primary, $-base, 80%);
+ }
+
+ &:active {
+ background-color: color.mix(scheme.$primary, $-base, 70%);
+ }
+ }
+ }
+ }
+
.user {
@include lib.spacing(15);
@@ -79,6 +122,7 @@
font-size: lib.s(64);
font-weight: bold;
background-color: scheme.$base;
+ color: scheme.$subtext0;
}
.details {
@@ -133,86 +177,98 @@
}
}
- .notifications {
- .header-bar {
- margin-bottom: lib.s(10);
- margin-right: lib.s(-10);
+ .notification {
+ .wrapper {
+ padding-bottom: lib.s(10);
+ }
+
+ .inner {
+ @include lib.rounded(20);
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+
+ &.low {
+ @include notification(if(scheme.$light, scheme.$surface1, scheme.$overlay0));
+ }
+
+ &.normal {
+ @include lib.border(scheme.$primary, if(scheme.$light, 0.5, 0.3));
+ @include notification(scheme.$primary);
+ }
+
+ &.critical {
+ @include lib.border(scheme.$error, 0.8);
+ @include notification(scheme.$error);
+ }
+ }
+ .actions {
@include lib.spacing;
& > button {
- @include lib.element-decel;
@include lib.rounded(10);
+ @include lib.element-decel;
- padding: lib.s(3) lib.s(8);
+ padding: lib.s(5) lib.s(10);
+ background-color: color.change(scheme.$surface1, $alpha: 0.5);
&:hover,
&:focus {
- color: scheme.$subtext0;
+ background-color: color.change(scheme.$surface2, $alpha: 0.5);
}
&:active {
- color: scheme.$overlay2;
+ background-color: color.change(scheme.$overlay0, $alpha: 0.5);
}
+ }
+ }
+ }
- &.enabled {
- background-color: scheme.$primary;
- color: scheme.$base;
+ .upcoming {
+ .list {
+ min-height: lib.s(300);
+ }
- &:hover,
- &:focus {
- background-color: color.mix(scheme.$primary, scheme.$base, 80%);
- }
+ .day {
+ @include lib.spacing($vertical: true);
- &:active {
- background-color: color.mix(scheme.$primary, scheme.$base, 70%);
- }
- }
+ &:not(:first-child) {
+ margin-top: lib.s(20);
}
- }
- .notification {
- .wrapper {
- padding-bottom: lib.s(10);
+ .date {
+ margin-left: lib.s(10);
}
- .inner {
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ .events {
@include lib.rounded(20);
background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
- &.low {
- @include notification(if(scheme.$light, scheme.$surface1, scheme.$overlay0));
- }
-
- &.normal {
- @include lib.border(scheme.$primary, if(scheme.$light, 0.5, 0.3));
- @include notification(scheme.$primary);
- }
-
- &.critical {
- @include lib.border(scheme.$error, 0.8);
- @include notification(scheme.$error);
- }
+ @include lib.spacing(10, true);
}
- .actions {
- @include lib.spacing;
-
- & > button {
- @include lib.rounded(10);
- @include lib.element-decel;
+ .event {
+ @include lib.spacing(8);
+ }
- padding: lib.s(5) lib.s(10);
- background-color: color.change(scheme.$surface1, $alpha: 0.5);
+ .calendar-indicator {
+ @include lib.rounded(5);
- &:hover,
- &:focus {
- background-color: color.change(scheme.$surface2, $alpha: 0.5);
- }
+ min-width: lib.s(1);
- &:active {
- background-color: color.change(scheme.$overlay0, $alpha: 0.5);
+ $-colours: scheme.$red, scheme.$sapphire, scheme.$flamingo, scheme.$maroon, scheme.$pink, scheme.$sky,
+ scheme.$peach, scheme.$yellow, scheme.$green, scheme.$rosewater, scheme.$mauve, scheme.$teal,
+ scheme.$blue;
+ @for $i from 1 through length($-colours) {
+ &.c#{$i} {
+ background-color: nth($-colours, $i);
}
}
}
diff --git a/src/config/defaults.ts b/src/config/defaults.ts
index d699a45..b6bee4f 100644
--- a/src/config/defaults.ts
+++ b/src/config/defaults.ts
@@ -143,4 +143,8 @@ export default {
},
],
},
+ calendar: {
+ webcals: [] as string[], // An array of urls to ICS files which you can curl
+ upcomingDays: 7, // Number of days which count as upcoming
+ },
};
diff --git a/src/config/index.ts b/src/config/index.ts
index d09a668..3f9bf7a 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -18,5 +18,6 @@ export const {
memory,
storage,
wallpapers,
+ calendar,
} = config;
export default config;
diff --git a/src/config/types.ts b/src/config/types.ts
index aa0d921..cf828b6 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -68,4 +68,6 @@ export default {
"memory.interval": NUM,
"storage.interval": NUM,
"wallpapers.paths": OBJ_ARR({ recursive: BOOL, path: STR }),
+ "calendar.webcals": ARR(STR),
+ "calendar.upcomingDays": NUM,
} as { [k: string]: string | string[] | number[] };
diff --git a/src/modules/sidebar/dashboard.tsx b/src/modules/sidebar/dashboard.tsx
index 86921e6..936502b 100644
--- a/src/modules/sidebar/dashboard.tsx
+++ b/src/modules/sidebar/dashboard.tsx
@@ -5,6 +5,7 @@ import { bind, GLib, monitorFile, Variable } from "astal";
import { Gtk } from "astal/gtk3";
import AstalMpris from "gi://AstalMpris";
import Notifications from "./modules/notifications";
+import Upcoming from "./modules/upcoming";
const lengthStr = (length: number) =>
`${Math.floor(length / 60)}:${Math.floor(length % 60)
@@ -127,5 +128,7 @@ export default () => (
))}
<box className="separator" />
<Notifications />
+ <box className="separator" />
+ <Upcoming />
</box>
);
diff --git a/src/modules/sidebar/modules/upcoming.tsx b/src/modules/sidebar/modules/upcoming.tsx
new file mode 100644
index 0000000..e2389e8
--- /dev/null
+++ b/src/modules/sidebar/modules/upcoming.tsx
@@ -0,0 +1,83 @@
+import Calendar, { type IEvent } from "@/services/calendar";
+import { setupCustomTooltip } from "@/utils/widgets";
+import { bind, GLib } from "astal";
+import { Gtk } from "astal/gtk3";
+
+const getDateHeader = (events: IEvent[]) => {
+ const date = events[0].event.startDate;
+ const isToday = date.toJSDate().toDateString() === new Date().toDateString();
+ return (
+ (isToday ? "Today • " : "") +
+ GLib.DateTime.new_from_unix_local(date.toUnixTime()).format("%B %-d • %A") +
+ ` • ${events.length} event${events.length === 1 ? "" : "s"}`
+ );
+};
+
+const getEventHeader = (e: IEvent) => {
+ const start = GLib.DateTime.new_from_unix_local(e.event.startDate.toUnixTime());
+ const time = `${start.format("%-I")}${start.get_minute() > 0 ? `:${start.get_minute()}` : ""}${start.format("%P")}`;
+ return `${time} <b>${e.event.summary}</b>`;
+};
+
+const getEventTooltip = (e: IEvent) => {
+ const start = GLib.DateTime.new_from_unix_local(e.event.startDate.toUnixTime());
+ const end = GLib.DateTime.new_from_unix_local(e.event.endDate.toUnixTime());
+ const sameAmPm = start.format("%P") === end.format("%P");
+ const time = `${start.format(`%A, %-d %B • %-I:%M${sameAmPm ? "" : "%P"}`)} — ${end.format("%-I:%M%P")}`;
+ const locIfExists = e.event.location ? ` ${e.event.location}\n` : "";
+ const descIfExists = e.event.description ? `󰒿 ${e.event.description}\n` : "";
+ return `<b>${e.event.summary}</b>\n${time}\n${locIfExists}${descIfExists}󰃭 ${e.calendar}`;
+};
+
+const Event = (event: IEvent) => (
+ <box className="event" setup={self => setupCustomTooltip(self, getEventTooltip(event), { useMarkup: true })}>
+ <box className={`calendar-indicator c${Calendar.get_default().getCalendarIndex(event.calendar)}`} />
+ <box vertical>
+ <label truncate useMarkup xalign={0} label={getEventHeader(event)} />
+ {event.event.location && <label truncate xalign={0} label={event.event.location} className="sublabel" />}
+ {event.event.description && (
+ <label truncate useMarkup xalign={0} label={event.event.description} className="sublabel" />
+ )}
+ </box>
+ </box>
+);
+
+const Day = ({ events }: { events: IEvent[] }) => (
+ <box vertical className="day">
+ <label className="date" xalign={0} label={getDateHeader(events)} />
+ <box vertical className="events">
+ {events.map(Event)}
+ </box>
+ </box>
+);
+
+const List = () => (
+ <box vertical valign={Gtk.Align.START}>
+ {bind(Calendar.get_default(), "upcoming").as(u =>
+ Object.values(u)
+ .sort((a, b) => a[0].event.startDate.compare(b[0].event.startDate))
+ .map(e => <Day events={e} />)
+ )}
+ </box>
+);
+
+export default () => (
+ <box vertical className="upcoming">
+ <box className="header-bar">
+ <label
+ label={bind(Calendar.get_default(), "numUpcoming").as(n => `${n} upcoming event${n === 1 ? "" : "s"}`)}
+ />
+ <box hexpand />
+ <button
+ className={bind(Calendar.get_default(), "loading").as(l => (l ? "enabled" : ""))}
+ sensitive={bind(Calendar.get_default(), "loading").as(l => !l)}
+ cursor="pointer"
+ onClicked={() => Calendar.get_default().updateCalendars().catch(console.error)}
+ label="󰑓 Reload"
+ />
+ </box>
+ <scrollable className="list" hscroll={Gtk.PolicyType.NEVER}>
+ <List />
+ </scrollable>
+ </box>
+);
diff --git a/src/services/calendar.ts b/src/services/calendar.ts
index e69de29..bc7c075 100644
--- a/src/services/calendar.ts
+++ b/src/services/calendar.ts
@@ -0,0 +1,116 @@
+import { execAsync, GObject, property, register } from "astal";
+import { calendar as config } from "config";
+import ical from "ical.js";
+
+export interface IEvent {
+ calendar: string;
+ event: ical.Event;
+}
+
+@register({ GTypeName: "Calendar" })
+export default class Calendar extends GObject.Object {
+ static instance: Calendar;
+ static get_default() {
+ if (!this.instance) this.instance = new Calendar();
+
+ return this.instance;
+ }
+
+ #calCount = 1;
+ #loading = false;
+ #calendars: { [name: string]: ical.Component } = {};
+ #upcoming: { [date: string]: IEvent[] } = {};
+
+ @property(Boolean)
+ get loading() {
+ return this.#loading;
+ }
+
+ @property(Object)
+ get calendars() {
+ return this.#calendars;
+ }
+
+ @property(Object)
+ get upcoming() {
+ return this.#upcoming;
+ }
+
+ @property(Number)
+ get numUpcoming() {
+ return Object.values(this.#upcoming).reduce((acc, e) => acc + e.length, 0);
+ }
+
+ getCalendarIndex(name: string) {
+ return Object.keys(this.#calendars).indexOf(name) + 1;
+ }
+
+ async updateCalendars() {
+ this.#loading = true;
+ this.notify("loading");
+
+ this.#calendars = {};
+ this.#calCount = 1;
+
+ const cals = await Promise.allSettled(config.webcals.get().map(c => execAsync(["curl", c])));
+ for (const cal of cals) {
+ if (cal.status === "fulfilled") {
+ const comp = new ical.Component(ical.parse(cal.value));
+ const name = (comp.getFirstPropertyValue("x-wr-calname") ?? `Calendar ${this.#calCount++}`) as string;
+ this.#calendars[name] = comp;
+ } else console.error(`Failed to get calendar: ${cal.reason}`);
+ }
+ this.notify("calendars");
+
+ this.updateUpcoming();
+
+ this.#loading = false;
+ this.notify("loading");
+ }
+
+ updateUpcoming() {
+ this.#upcoming = {};
+
+ for (const [name, cal] of Object.entries(this.#calendars)) {
+ const today = ical.Time.now();
+ const upcoming = ical.Time.now().adjust(config.upcomingDays.get(), 0, 0, 0);
+
+ for (const e of cal.getAllSubcomponents()) {
+ const event = new ical.Event(e);
+
+ // Skip invalid events
+ if (!event.startDate) continue;
+
+ if (event.isRecurring()) {
+ // Recurring events
+ const iter = event.iterator();
+ for (let next = iter.next(); next && next.compare(upcoming) <= 0; next = iter.next())
+ if (next.compare(today) >= 0) {
+ const date = next.toJSDate().toDateString();
+ if (!this.#upcoming.hasOwnProperty(date)) this.#upcoming[date] = [];
+ this.#upcoming[date].push({ calendar: name, event });
+ }
+ } else if (event.startDate.compare(today) >= 0 && event.startDate.compare(upcoming) <= 0) {
+ // Add to upcoming if in upcoming range
+ const date = event.startDate.toJSDate().toDateString();
+ if (!this.#upcoming.hasOwnProperty(date)) this.#upcoming[date] = [];
+ this.#upcoming[date].push({ calendar: name, event });
+ }
+ }
+ }
+
+ for (const events of Object.values(this.#upcoming))
+ events.sort((a, b) => a.event.startDate.compare(b.event.startDate));
+
+ this.notify("upcoming");
+ this.notify("num-upcoming");
+ }
+
+ constructor() {
+ super();
+
+ this.updateCalendars().catch(console.error);
+ config.webcals.subscribe(() => this.updateCalendars().catch(console.error));
+ config.upcomingDays.subscribe(() => this.updateUpcoming());
+ }
+}
diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts
index 7b1eb5c..ef952f2 100644
--- a/src/utils/widgets.ts
+++ b/src/utils/widgets.ts
@@ -3,7 +3,11 @@ import { Astal, astalify, Gtk, Widget, type ConstructProps } from "astal/gtk3";
import AstalHyprland from "gi://AstalHyprland";
import type { AstalWidget } from "./types";
-export const setupCustomTooltip = (self: AstalWidget, text: string | Binding<string>) => {
+export const setupCustomTooltip = (
+ self: AstalWidget,
+ text: string | Binding<string>,
+ labelProps: Widget.LabelProps = {}
+) => {
if (!text) return null;
self.set_has_tooltip(true);
@@ -15,7 +19,7 @@ export const setupCustomTooltip = (self: AstalWidget, text: string | Binding<str
keymode: Astal.Keymode.NONE,
exclusivity: Astal.Exclusivity.IGNORE,
anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT,
- child: new Widget.Label({ className: "tooltip", label: text }),
+ child: new Widget.Label({ ...labelProps, className: "tooltip", label: text }),
});
self.set_tooltip_window(window);