diff options
| -rw-r--r-- | app.tsx | 2 | ||||
| -rw-r--r-- | package-lock.json | 7 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | scss/sidebar.scss | 158 | ||||
| -rw-r--r-- | src/config/defaults.ts | 4 | ||||
| -rw-r--r-- | src/config/index.ts | 1 | ||||
| -rw-r--r-- | src/config/types.ts | 2 | ||||
| -rw-r--r-- | src/modules/sidebar/dashboard.tsx | 3 | ||||
| -rw-r--r-- | src/modules/sidebar/modules/upcoming.tsx | 83 | ||||
| -rw-r--r-- | src/services/calendar.ts | 116 | ||||
| -rw-r--r-- | src/utils/widgets.ts | 8 |
11 files changed, 332 insertions, 53 deletions
@@ -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); |