sidebar: time pane

Also some fixes for calendar recurring events
Also fix reminders time
This commit is contained in:
2 * r + 2 * t 2025-04-02 13:20:26 +11:00
parent 4d9346e580
commit 0216fd3dd2
6 changed files with 446 additions and 67 deletions

View file

@ -1,4 +1,5 @@
@use "sass:color";
@use "sass:list";
@use "scheme";
@use "lib";
@use "font";
@ -315,9 +316,9 @@
$-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);
@for $i from 1 through list.length($-colours) {
&.calendar-#{$i} {
background-color: list.nth($-colours, $i);
}
}
}
@ -790,4 +791,166 @@
}
}
}
.calendar {
@include lib.rounded(20);
background-color: color.change(scheme.$surface1, $alpha: 0.4);
padding: lib.s(15);
.calendar-view {
@include lib.spacing(10, true);
.header {
@include lib.spacing(10);
& > button {
@include lib.rounded(1000);
@include lib.element-decel;
background-color: color.change(scheme.$surface2, $alpha: 0.4);
min-width: lib.s(28);
min-height: lib.s(28);
font-size: lib.s(18);
&:first-child {
padding: 0 lib.s(10);
}
&:hover,
&:focus {
background-color: color.change(scheme.$surface2, $alpha: 0.6);
}
&:active {
background-color: color.change(scheme.$surface2, $alpha: 0.8);
}
}
}
.weekdays {
@include lib.spacing(10);
& > label {
min-width: lib.s(40);
font-weight: bold;
color: scheme.$subtext1;
}
}
.month {
@include lib.spacing(10, true);
}
.week {
@include lib.spacing(10);
}
.day {
@include lib.rounded(1000);
@include lib.element-decel;
min-width: lib.s(40);
min-height: lib.s(40);
&.dim {
color: scheme.$subtext0;
}
&.today:not(.dim) {
background-color: scheme.$primary;
color: color.change(scheme.$base, $alpha: 1);
}
&:hover,
&:focus {
color: scheme.$subtext0;
}
&:active {
color: color.change(scheme.$overlay2, $alpha: 1);
}
&.dim {
color: scheme.$subtext0;
&:hover,
&:focus {
color: color.change(scheme.$overlay2, $alpha: 1);
}
&:active {
color: color.change(scheme.$overlay1, $alpha: 1);
}
}
&.today:not(.dim) {
background-color: scheme.$primary;
color: color.change(scheme.$base, $alpha: 1);
&:hover,
&:focus {
background-color: color.mix(scheme.$primary, scheme.$base, 80%);
}
&:active {
background-color: color.mix(scheme.$primary, scheme.$base, 70%);
}
}
label {
margin-top: lib.s(8);
}
.indicator {
@include lib.rounded(10);
@include lib.element-decel;
min-height: lib.s(3);
margin: 0 lib.s(8);
}
$-max: 5;
@for $i from 1 through $-max {
&.events-#{$i} {
$-colour: color.mix(scheme.$red, scheme.$green, calc(100% / $-max) * $i);
.indicator {
background-color: $-colour;
}
&:hover .indicator,
&:focus .indicator {
background-color: color.mix($-colour, scheme.$base, 80%);
}
&:active .indicator {
background-color: color.mix($-colour, scheme.$base, 70%);
}
&.dim .indicator {
background-color: color.mix($-colour, scheme.$base, 60%);
}
&.today:not(.dim) {
$-colour: color.mix($-colour, color.complement(scheme.$primary), 50%);
.indicator {
background-color: $-colour;
}
&:hover .indicator,
&:focus .indicator {
background-color: color.mix($-colour, scheme.$base, 80%);
}
&:active .indicator {
background-color: color.mix($-colour, scheme.$base, 70%);
}
}
}
}
}
}
}
}

View file

@ -7,6 +7,7 @@ import Connectivity from "./connectivity";
import Dashboard from "./dashboard";
import NotifPane from "./notifpane";
import Packages from "./packages";
import Time from "./time";
@register()
export default class SideBar extends Widget.Window {
@ -23,7 +24,7 @@ export default class SideBar extends Widget.Window {
visible: false,
});
const panes = [<Dashboard />, <Audio />, <Connectivity />, <Packages />, <NotifPane />];
const panes = [<Dashboard />, <Audio />, <Connectivity />, <Packages />, <NotifPane />, <Time />];
this.shown = Variable(panes[0].name);
this.add(

View file

@ -0,0 +1,186 @@
import Calendar from "@/services/calendar";
import { setupCustomTooltip } from "@/utils/widgets";
import { bind, GLib, Variable } from "astal";
import { Gtk } from "astal/gtk3";
import ical from "ical.js";
const isLeapYear = (year: number) => year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
const getMonthDays = (month: number, year: number) => {
const leapYear = isLeapYear(year);
if (month === 2 && leapYear) return leapYear ? 29 : 28;
if ((month <= 7 && month % 2 === 1) || (month >= 8 && month % 2 === 0)) return 31;
return 30;
};
const getNextMonthDays = (month: number, year: number) => {
if (month === 12) return 31;
return getMonthDays(month + 1, year);
};
const getPrevMonthDays = (month: number, year: number) => {
if (month === 1) return 31;
return getMonthDays(month - 1, year);
};
export function getCalendarLayout(date: ical.Time) {
const weekdayOfMonthFirst = date.startOfMonth().dayOfWeek(ical.Time.MONDAY);
const daysInMonth = getMonthDays(date.month, date.year);
const daysInPrevMonth = getPrevMonthDays(date.month, date.year);
const calendar: ical.Time[][] = [];
let idx = -weekdayOfMonthFirst + 2;
for (let i = 0; i < 6; i++) {
calendar.push([]);
for (let j = 0; j < 7; j++) {
let cDay = idx++;
let cMonth = date.month;
let cYear = date.year;
if (idx < 0) {
cDay = daysInPrevMonth + cDay;
cMonth--;
if (cMonth < 0) {
cMonth += 12;
cYear--;
}
} else if (idx > daysInMonth) {
cDay -= daysInMonth;
cMonth++;
if (cMonth > 12) {
cMonth -= 12;
cYear++;
}
}
calendar[i].push(ical.Time.fromData({ day: cDay, month: cMonth, year: cYear }));
}
}
return calendar;
}
const dateToMonthYear = (date: ical.Time) => {
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return `${months[date.month - 1]} ${date.year}`;
};
const addMonths = (date: ical.Time, num: number) => {
date = date.clone();
if (num > 0) for (let i = 0; i < num; i++) date.adjust(getNextMonthDays(date.month, date.year), 0, 0, 0);
else for (let i = 0; i > num; i--) date.adjust(-getPrevMonthDays(date.month, date.year), 0, 0, 0);
return date;
};
const getDayClassName = (day: ical.Time, current: Variable<ical.Time>) => {
const isToday = day.toJSDate().toDateString() === new Date().toDateString() ? "today" : "";
const numEvents = Math.min(5, Calendar.get_default().getEventsForDay(day).length);
return `day ${isToday} ${day.month !== current.get().month ? "dim" : ""} events-${numEvents}`;
};
const getDayTooltip = (day: ical.Time) => {
const events = Calendar.get_default().getEventsForDay(day);
if (!events.length) return "";
const eventsStr = events
.map(e => {
const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime());
const end = GLib.DateTime.new_from_unix_local(e.endDate.toUnixTime());
const sameAmPm = start.format("%P") === end.format("%P");
const time = `${start.format(`%-I:%M${sameAmPm ? "" : "%P"}`)}${end.format("%-I:%M%P")}`;
return `<b>${e.event.summary.replaceAll("&", "&amp;")}</b> • ${time}`;
})
.join("\n");
return `${events.length} event${events.length === 1 ? "" : "s"}\n${eventsStr}`;
};
const Day = ({ day, shown, current }: { day: ical.Time; shown: Variable<string>; current: Variable<ical.Time> }) => (
<button
className={bind(Calendar.get_default(), "calendars").as(() => getDayClassName(day, current))}
cursor="pointer"
onClicked={() => {
shown.set("events");
current.set(day);
}}
setup={self =>
setupCustomTooltip(
self,
bind(Calendar.get_default(), "calendars").as(() => getDayTooltip(day)),
{ useMarkup: true }
)
}
>
<box vertical>
<label label={day.day.toString()} />
<box className="indicator" />
</box>
</button>
);
const CalendarView = ({ shown, current }: { shown: Variable<string>; current: Variable<ical.Time> }) => (
<box vertical className="calendar-view" name="calendar">
<box className="header">
<button
cursor="pointer"
onClicked={() => current.set(ical.Time.now())}
label={bind(current).as(dateToMonthYear)}
/>
<box hexpand />
<button cursor="pointer" onClicked={() => current.set(addMonths(current.get(), -1))} label="" />
<button cursor="pointer" onClicked={() => current.set(addMonths(current.get(), 1))} label="" />
</box>
<box halign={Gtk.Align.CENTER} className="weekdays">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(d => (
<label label={d} />
))}
</box>
<box vertical halign={Gtk.Align.CENTER} className="month">
{bind(current).as(c =>
getCalendarLayout(c).map(r => (
<box className="week">
{r.map(d => (
<Day day={d} shown={shown} current={current} />
))}
</box>
))
)}
</box>
</box>
);
const Events = ({ shown, current }: { shown: Variable<string>; current: Variable<ical.Time> }) => (
<box className="events" name="events"></box>
);
export default () => {
const shown = Variable("calendar");
const current = Variable(ical.Time.now());
return (
<box vertical className="calendar">
<stack
transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT}
transitionDuration={150}
shown={bind(shown)}
>
<CalendarView shown={shown} current={current} />
<Events shown={shown} current={current} />
</stack>
</box>
);
};

View file

@ -4,7 +4,7 @@ import { bind, GLib } from "astal";
import { Gtk } from "astal/gtk3";
const getDateHeader = (events: IEvent[]) => {
const date = events[0].event.startDate;
const date = events[0].startDate;
const isToday = date.toJSDate().toDateString() === new Date().toDateString();
return (
(isToday ? "Today • " : "") +
@ -14,13 +14,13 @@ const getDateHeader = (events: IEvent[]) => {
};
const getEventHeader = (e: IEvent) => {
const start = GLib.DateTime.new_from_unix_local(e.event.startDate.toUnixTime());
const start = GLib.DateTime.new_from_unix_local(e.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 start = GLib.DateTime.new_from_unix_local(e.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")}`;
@ -31,7 +31,7 @@ const getEventTooltip = (e: IEvent) => {
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 className={`calendar-indicator calendar-${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" />}
@ -55,7 +55,7 @@ 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))
.sort((a, b) => a[0].startDate.compare(b[0].startDate))
.map(e => <Day events={e} />)
)}
</box>

View file

@ -0,0 +1,14 @@
import Calendar from "./modules/calendar";
import Upcoming from "./modules/upcoming";
const TimeDate = () => <box></box>;
export default () => (
<box vertical className="pane time" name="time">
<TimeDate />
<box className="separator" />
<Upcoming />
<box className="separator" />
<Calendar />
</box>
);

View file

@ -17,6 +17,8 @@ import ical from "ical.js";
export interface IEvent {
calendar: string;
event: ical.Event;
startDate: ical.Time;
endDate: ical.Time;
}
@register({ GTypeName: "Calendar" })
@ -35,6 +37,8 @@ export default class Calendar extends GObject.Object {
#loading: boolean = false;
#calendars: { [name: string]: ical.Component } = {};
#upcoming: { [date: string]: IEvent[] } = {};
#cachedEvents: { [date: string]: IEvent[] } = {};
#cachedMonths: Set<string> = new Set();
@property(Boolean)
get loading() {
@ -60,6 +64,59 @@ export default class Calendar extends GObject.Object {
return Object.keys(this.#calendars).indexOf(name) + 1;
}
getEventsForMonth(date: ical.Time) {
const start = date.startOfMonth();
if (this.#cachedMonths.has(start.toJSDate().toDateString())) return this.#cachedEvents;
this.#cachedMonths.add(start.toJSDate().toDateString());
const end = date.endOfMonth();
const modDates = new Set<string>();
for (const [name, cal] of Object.entries(this.#calendars)) {
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(end) <= 0; next = iter.next())
if (next.compare(start) >= 0) {
const date = next.toJSDate().toDateString();
if (!this.#cachedEvents.hasOwnProperty(date)) this.#cachedEvents[date] = [];
const end = next.clone();
end.addDuration(event.duration);
this.#cachedEvents[date].push({ calendar: name, event, startDate: next, endDate: end });
modDates.add(date);
}
} else if (event.startDate.compare(start) >= 0 && event.startDate.compare(end) <= 0) {
const date = event.startDate.toJSDate().toDateString();
if (!this.#cachedEvents.hasOwnProperty(date)) this.#cachedEvents[date] = [];
this.#cachedEvents[date].push({
calendar: name,
event,
startDate: event.startDate,
endDate: event.endDate,
});
modDates.add(date);
}
}
}
for (const date of modDates) this.#cachedEvents[date].sort((a, b) => a.startDate.compare(b.startDate));
return this.#cachedEvents;
}
getEventsForDay(date: ical.Time) {
return this.getEventsForMonth(date)[date.toJSDate().toDateString()] ?? [];
}
async updateCalendars() {
this.#loading = true;
this.notify("loading");
@ -87,6 +144,9 @@ export default class Calendar extends GObject.Object {
writeFileAsync(`${this.#cacheDir}/${webcal}`, icalStr).catch(console.error);
}
}
this.#cachedEvents = {};
this.#cachedMonths.clear();
this.notify("calendars");
this.updateUpcoming();
@ -98,61 +158,34 @@ export default class Calendar extends GObject.Object {
updateUpcoming() {
this.#upcoming = {};
const today = ical.Time.now();
const upcoming = ical.Time.now().adjust(config.upcomingDays.get(), 0, 0, 0);
for (const [name, cal] of Object.entries(this.#calendars)) {
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] = [];
const rEvent = new ical.Event(e);
rEvent.startDate = next;
this.#upcoming[date].push({ calendar: name, event: rEvent });
}
} 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 (let i = 0; i < config.upcomingDays.get(); i++) {
const date = ical.Time.now().adjust(i, 0, 0, 0);
const events = this.getEventsForDay(date);
if (events.length > 0) this.#upcoming[date.toJSDate().toDateString()] = events;
}
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");
this.setReminders();
}
#notifyEvent(event: ical.Event, calendar: string) {
#notifyEvent(event: IEvent) {
const start = GLib.DateTime.new_from_unix_local(event.startDate.toUnixTime());
const end = GLib.DateTime.new_from_unix_local(event.endDate.toUnixTime());
const time = `${start.format(`%A, %-d %B`)} • Now — ${end.format("%-I:%M%P")}`;
const locIfExists = event.location ? `${event.location}\n` : "";
const descIfExists = event.description ? `󰒿 ${event.description}\n` : "";
const locIfExists = event.event.location ? `${event.event.location}\n` : "";
const descIfExists = event.event.description ? `󰒿 ${event.event.description}\n` : "";
notify({
summary: `󰨱 ${event.summary} 󰨱`,
body: `${time}\n${locIfExists}${descIfExists}󰃭 ${calendar}`,
summary: `󰨱 ${event.event.summary} 󰨱`,
body: `${time}\n${locIfExists}${descIfExists}󰃭 ${event.calendar}`,
}).catch(console.error);
}
#createReminder(event: ical.Event, calendar: string, next: ical.Time) {
const diff = next.toUnixTime() - ical.Time.now().toUnixTime();
if (diff > 0) this.#reminders.push(timeout(diff * 1000, () => this.#notifyEvent(event, calendar)));
#createReminder(event: IEvent) {
const diff = event.startDate.toJSDate().getTime() - ical.Time.now().toJSDate().getTime();
if (diff > 0) this.#reminders.push(timeout(diff, () => this.#notifyEvent(event)));
}
setReminders() {
@ -161,25 +194,7 @@ export default class Calendar extends GObject.Object {
if (!config.notify.get()) return;
const today = ical.Time.now();
const upcoming = ical.Time.now().adjust(config.upcomingDays.get(), 0, 0, 0);
for (const [name, cal] of Object.entries(this.#calendars)) {
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) this.#createReminder(event, name, next);
} else if (event.startDate.compare(today) >= 0 && event.startDate.compare(upcoming) <= 0)
// Create reminder if in upcoming range
this.#createReminder(event, name, event.startDate);
}
}
for (const events of Object.values(this.#upcoming)) for (const event of events) this.#createReminder(event);
}
constructor() {