summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--config.ts7
-rw-r--r--scss/popdowns/index.scss1
-rw-r--r--scss/popdowns/sideright.scss147
-rw-r--r--src/modules/bar.tsx25
-rw-r--r--src/modules/popdowns/index.tsx2
-rw-r--r--src/modules/popdowns/sideright.tsx127
-rw-r--r--src/services/weather.ts398
-rw-r--r--src/utils/system.ts6
-rw-r--r--src/utils/widgets.ts7
10 files changed, 705 insertions, 16 deletions
diff --git a/.gitignore b/.gitignore
index 54c260e..906d358 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
node_modules/
scss/scheme/_index.scss
scss/scheme/_dynamic.scss
+assets/weather-api-key.txt
diff --git a/config.ts b/config.ts
index fb85d7e..f74c024 100644
--- a/config.ts
+++ b/config.ts
@@ -77,3 +77,10 @@ export const math = {
export const updates = {
interval: 900000,
};
+
+export const weather = {
+ interval: 600000,
+ key: "assets/weather-api-key.txt", // Path to file containing api key relative to the base directory. To get a key, visit https://weatherapi.com/
+ location: "", // Location as a string or empty to autodetect
+ // TODO: imperial
+};
diff --git a/scss/popdowns/index.scss b/scss/popdowns/index.scss
index 748ddd3..cae1473 100644
--- a/scss/popdowns/index.scss
+++ b/scss/popdowns/index.scss
@@ -3,3 +3,4 @@
@forward "bluetoothdevices";
@forward "networks";
@forward "media";
+@forward "sideright";
diff --git a/scss/popdowns/sideright.scss b/scss/popdowns/sideright.scss
new file mode 100644
index 0000000..8807efe
--- /dev/null
+++ b/scss/popdowns/sideright.scss
@@ -0,0 +1,147 @@
+@use "sass:color";
+@use "../scheme";
+@use "../lib";
+@use "../font";
+
+$-accent: scheme.$peach;
+$-accent2: scheme.$blue;
+
+.sideright {
+ @include lib.rounded(8);
+ @include lib.border($-accent, 0.4, 2);
+ @include lib.shadow;
+ @include font.mono;
+
+ background-color: scheme.$crust;
+ color: $-accent;
+ padding: lib.s(12);
+ font-size: lib.s(14);
+
+ @include lib.spacing(10, true);
+
+ & > * {
+ @include lib.rounded(5);
+
+ background-color: scheme.$base;
+ }
+
+ .time {
+ padding: lib.s(12) lib.s(8);
+ font-size: lib.s(48);
+ font-weight: bold;
+
+ & > * {
+ @include lib.spacing(3);
+ }
+
+ .ampm {
+ font-size: lib.s(24);
+ font-weight: normal;
+ margin-top: lib.s(18);
+ color: $-accent2;
+ }
+ }
+
+ .calendar {
+ padding: lib.s(5) lib.s(8) 0 lib.s(8);
+ min-width: lib.s(350);
+ color: scheme.$text;
+
+ calendar {
+ padding: lib.s(8); // Padding for each day
+
+ // Month and year
+ &.header {
+ color: $-accent2;
+ }
+
+ // Change month/year buttons
+ &.button {
+ &:hover {
+ color: scheme.$subtext0;
+ }
+
+ &:disabled {
+ color: color.mix(scheme.$text, scheme.$base, 65%);
+ }
+ }
+
+ // Weekday labels
+ &.highlight {
+ color: $-accent;
+ }
+
+ // Days in other month
+ &:indeterminate {
+ color: color.mix(scheme.$text, scheme.$base, 65%);
+ }
+
+ // Current day
+ &:selected {
+ box-shadow: inset 0 lib.s(-12) 0 0 $-accent, inset 0 lib.s(-9) 0 0 scheme.$base,
+ inset lib.s(-10) lib.s(-3) 0 0 scheme.$base, inset lib.s(10) 0 0 lib.s(0.1) scheme.$base;
+ color: $-accent;
+ }
+ }
+ }
+
+ .weather {
+ padding: lib.s(5) lib.s(15);
+
+ @include lib.spacing(10, true);
+
+ .current {
+ @include lib.spacing(20);
+
+ .status-icon {
+ font-size: lib.s(64);
+ padding: lib.s(10) lib.s(20) 0 0;
+ }
+
+ .status {
+ color: $-accent2;
+ font-size: lib.s(16);
+
+ @include lib.spacing($vertical: true);
+
+ .temperature {
+ font-size: lib.s(28);
+ font-weight: bold;
+ color: scheme.$text;
+
+ @include lib.spacing;
+
+ .temp-icon {
+ font-size: lib.s(30);
+
+ $-temps: freezing, cold, normal, hot, burning;
+ $-colours: scheme.$sky, scheme.$blue, scheme.$green, scheme.$yellow, scheme.$red;
+ @for $i from 1 through length($-temps) {
+ &.#{nth($-temps, $i)} {
+ color: nth($-colours, $i);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .separator {
+ background-color: scheme.$surface0;
+ }
+
+ .forecast {
+ padding: lib.s(10) 0 lib.s(15) 0;
+
+ @include lib.spacing(10);
+
+ .hour {
+ @include lib.spacing($vertical: true);
+
+ .icon {
+ font-size: lib.s(32);
+ }
+ }
+ }
+ }
+}
diff --git a/src/modules/bar.tsx b/src/modules/bar.tsx
index c7f168e..5faee7a 100644
--- a/src/modules/bar.tsx
+++ b/src/modules/bar.tsx
@@ -1,4 +1,4 @@
-import { execAsync, GLib, register, Variable } from "astal";
+import { execAsync, register, Variable } from "astal";
import { bind, kebabify } from "astal/binding";
import { App, Astal, astalify, Gdk, Gtk, type ConstructProps } from "astal/gtk3";
import AstalBluetooth from "gi://AstalBluetooth";
@@ -13,7 +13,7 @@ import Players from "../services/players";
import Updates from "../services/updates";
import { getAppCategoryIcon } from "../utils/icons";
import { ellipsize } from "../utils/strings";
-import { osIcon } from "../utils/system";
+import { bindCurrentTime, osIcon } from "../utils/system";
import { setupCustomTooltip } from "../utils/widgets";
import type PopupWindow from "../widgets/popupwindow";
@@ -438,19 +438,14 @@ const NotifCount = () => (
);
const DateTime = () => (
- <box className="module date-time">
- <label className="icon" label="calendar_month" />
- <label
- setup={self => {
- const pollVar = Variable(null).poll(5000, () => {
- self.label =
- GLib.DateTime.new_now_local().format(config.dateTimeFormat) ?? new Date().toLocaleString();
- return null;
- });
- self.connect("destroy", () => pollVar.drop());
- }}
- />
- </box>
+ <button
+ onClick={(self, event) => event.button === Astal.MouseButton.PRIMARY && togglePopup(self, event, "sideright")}
+ >
+ <box className="module date-time">
+ <label className="icon" label="calendar_month" />
+ <label label={bindCurrentTime(config.dateTimeFormat)} />
+ </box>
+ </button>
);
const Power = () => (
diff --git a/src/modules/popdowns/index.tsx b/src/modules/popdowns/index.tsx
index 9ee9f77..c4f4664 100644
--- a/src/modules/popdowns/index.tsx
+++ b/src/modules/popdowns/index.tsx
@@ -2,6 +2,7 @@ import BluetoothDevices from "./bluetoothdevices";
import Media from "./media";
import Networks from "./networks";
import Notifications from "./notifications";
+import SideRight from "./sideright";
import Updates from "./updates";
export default () => {
@@ -10,6 +11,7 @@ export default () => {
<BluetoothDevices />;
<Networks />;
<Media />;
+ <SideRight />;
return null;
};
diff --git a/src/modules/popdowns/sideright.tsx b/src/modules/popdowns/sideright.tsx
new file mode 100644
index 0000000..86c0547
--- /dev/null
+++ b/src/modules/popdowns/sideright.tsx
@@ -0,0 +1,127 @@
+import { bind } from "astal";
+import { Astal, Gtk, type Gdk } from "astal/gtk3";
+import SWeather, { type WeatherData } from "../../services/weather";
+import { ellipsize } from "../../utils/strings";
+import { bindCurrentTime } from "../../utils/system";
+import { Calendar as WCal } from "../../utils/widgets";
+import PopupWindow from "../../widgets/popupwindow";
+
+const getHoursFromUpdate = (data: WeatherData, hours: number) => {
+ const updateTime = data.location.localtime_epoch;
+ const updateHourStart = updateTime - (updateTime % 3600);
+
+ let nextHour = new Date((updateHourStart + hours * 3600) * 1000).getHours();
+ if (nextHour >= 24) nextHour -= 24;
+
+ return nextHour;
+};
+
+const Time = () => (
+ <box className="time">
+ <box hexpand halign={Gtk.Align.CENTER}>
+ <label label={bindCurrentTime("%I:%M:%S")} />
+ <label className="ampm" label={bindCurrentTime("%p", c => (c.get_hour() < 12 ? "AM" : "PM"))} />
+ </box>
+ </box>
+);
+
+const Calendar = () => (
+ <box className="calendar">
+ <eventbox
+ setup={self => {
+ self.connect("button-press-event", (_, event: Gdk.Event) => {
+ if (event.get_button()[1] === Astal.MouseButton.MIDDLE) {
+ const now = new Date();
+ const child = self.get_child() as WCal | null;
+ if (!child) return;
+ child.select_month(now.getMonth(), now.getFullYear());
+ }
+ });
+ }}
+ >
+ <WCal
+ hexpand
+ showDetails={false}
+ day={0}
+ setup={self => {
+ const update = () => {
+ const now = new Date();
+ if (self.month === now.getMonth() && self.year === now.getFullYear()) self.day = now.getDate();
+ else self.day = 0;
+ };
+ self.connect("month-changed", update);
+ self.connect("next-month", update);
+ self.connect("prev-month", update);
+ self.connect("next-year", update);
+ self.connect("prev-year", update);
+ update();
+ }}
+ />
+ </eventbox>
+ </box>
+);
+
+const Weather = () => {
+ const weather = SWeather.get_default();
+
+ return (
+ <box vertical className="weather">
+ <centerbox className="current">
+ <label
+ halign={Gtk.Align.START}
+ valign={Gtk.Align.CENTER}
+ className="status-icon"
+ label={bind(weather, "icon")}
+ />
+ <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="status">
+ <box halign={Gtk.Align.CENTER} className="temperature">
+ <label label={bind(weather, "temperature").as(t => `${Math.round(t)}°C`)} />
+ <label
+ className={bind(weather, "tempColour").as(c => `temp-icon ${c}`)}
+ label={bind(weather, "tempIcon")}
+ />
+ </box>
+ <label label={bind(weather, "condition").as(c => ellipsize(c, 16))} />
+ </box>
+ <box vertical halign={Gtk.Align.END} valign={Gtk.Align.CENTER} className="other-data">
+ <label xalign={0} label={bind(weather, "wind").as(w => ` ${Math.round(w)} kph`)} />
+ <label xalign={0} label={bind(weather, "rainChance").as(r => ` ${r}%`)} />
+ </box>
+ </centerbox>
+ <box className="separator" />
+ <box className="forecast">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <box vertical hexpand className="hour">
+ <label
+ label={bind(weather, "raw").as(r => {
+ const hour = getHoursFromUpdate(r, i + 1);
+ return `${hour % 12 || 12}${hour < 12 ? "AM" : "PM"}`;
+ })}
+ />
+ <label
+ className="icon"
+ label={bind(weather, "raw").as(r =>
+ weather.getIcon(weather.forecast[getHoursFromUpdate(r, i + 1)]?.condition.text ?? "")
+ )}
+ />
+ <label
+ label={bind(weather, "raw").as(
+ r => `${Math.round(weather.forecast[getHoursFromUpdate(r, i + 1)]?.temp_c) ?? "-"}°C`
+ )}
+ />
+ </box>
+ ))}
+ </box>
+ </box>
+ );
+};
+
+export default () => (
+ <PopupWindow name="sideright">
+ <box vertical className="sideright">
+ <Time />
+ <Calendar />
+ <Weather />
+ </box>
+ </PopupWindow>
+);
diff --git a/src/services/weather.ts b/src/services/weather.ts
new file mode 100644
index 0000000..bf595fa
--- /dev/null
+++ b/src/services/weather.ts
@@ -0,0 +1,398 @@
+import { execAsync, GLib, GObject, interval, property, readFileAsync, register, writeFileAsync } from "astal";
+import { weather as config } from "../../config";
+
+export interface WeatherCondition {
+ text: string;
+ icon: string;
+ code: number;
+}
+
+interface _WeatherState {
+ temp_c: number;
+ temp_f: number;
+ is_day: number;
+ condition: WeatherCondition;
+ wind_mph: number;
+ wind_kph: number;
+ wind_degree: number;
+ wind_dir: "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW";
+ pressure_mb: number;
+ pressure_in: number;
+ precip_mm: number;
+ precip_in: number;
+ humidity: number;
+ cloud: number;
+ feelslike_c: number;
+ feelslike_f: number;
+ windchill_c: number;
+ windchill_f: number;
+ heatindex_c: number;
+ heatindex_f: number;
+ dewpoint_c: number;
+ dewpoint_f: number;
+ vis_km: number;
+ vis_miles: number;
+ uv: number;
+ gust_mph: number;
+ gust_kph: number;
+}
+
+export interface WeatherCurrent extends _WeatherState {
+ last_updated_epoch: number;
+ last_updated: string;
+}
+
+export interface WeatherHour extends _WeatherState {
+ time_epoch: number;
+ time: string;
+}
+
+export interface WeatherDay {
+ date: string;
+ date_epoch: number;
+ day: {
+ maxtemp_c: number;
+ maxtemp_f: number;
+ mintemp_c: number;
+ mintemp_f: number;
+ avgtemp_c: number;
+ avgtemp_f: number;
+ maxwind_mph: number;
+ maxwind_kph: number;
+ totalprecip_mm: number;
+ totalprecip_in: number;
+ totalsnow_cm: number;
+ avgvis_km: number;
+ avgvis_miles: number;
+ avghumidity: number;
+ daily_will_it_rain: number;
+ daily_chance_of_rain: number;
+ daily_will_it_snow: number;
+ daily_chance_of_snow: number;
+ condition: WeatherCondition;
+ uv: number;
+ };
+ astro: {
+ sunrise: string;
+ sunset: string;
+ moonrise: string;
+ moonset: string;
+ moon_phase: string;
+ moon_illumination: string;
+ is_moon_up: number;
+ is_sun_up: number;
+ };
+ hour: WeatherHour[];
+}
+
+export interface WeatherLocation {
+ name: string;
+ region: string;
+ country: string;
+ lat: number;
+ lon: number;
+ tz_id: string;
+ localtime_epoch: number;
+ localtime: string;
+}
+
+export interface WeatherData {
+ current: WeatherCurrent;
+ forecast: { forecastday: WeatherDay[] };
+ location: WeatherLocation;
+}
+
+const DEFAULT: WeatherData = {
+ current: {
+ last_updated_epoch: 0,
+ last_updated: "",
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: { text: "", icon: "", code: 0 },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: "N",
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ uv: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ },
+ forecast: {
+ forecastday: [
+ {
+ date: "",
+ date_epoch: 0,
+ day: {
+ maxtemp_c: 0,
+ maxtemp_f: 0,
+ mintemp_c: 0,
+ mintemp_f: 0,
+ avgtemp_c: 0,
+ avgtemp_f: 0,
+ maxwind_mph: 0,
+ maxwind_kph: 0,
+ totalprecip_mm: 0,
+ totalprecip_in: 0,
+ totalsnow_cm: 0,
+ avgvis_km: 0,
+ avgvis_miles: 0,
+ avghumidity: 0,
+ daily_will_it_rain: 0,
+ daily_chance_of_rain: 0,
+ daily_will_it_snow: 0,
+ daily_chance_of_snow: 0,
+ condition: { text: "", icon: "", code: 0 },
+ uv: 0,
+ },
+ astro: {
+ sunrise: "",
+ sunset: "",
+ moonrise: "",
+ moonset: "",
+ moon_phase: "",
+ moon_illumination: "",
+ is_moon_up: 0,
+ is_sun_up: 0,
+ },
+ hour: [],
+ },
+ ],
+ },
+ location: {
+ name: "",
+ region: "",
+ country: "",
+ lat: 0,
+ lon: 0,
+ tz_id: "",
+ localtime_epoch: 0,
+ localtime: "",
+ },
+};
+
+const STATUS_ICONS: Record<string, string> = {
+ warning: "󰼯",
+ sunny: "󰖙",
+ clear: "󰖔",
+ partly_cloudy: "󰖕",
+ partly_cloudy_night: "󰼱",
+ cloudy: "󰖐",
+ overcast: "󰖕",
+ mist: "󰖑",
+ patchy_rain_nearby: "󰼳",
+ patchy_rain_possible: "󰼳",
+ patchy_snow_possible: "󰼴",
+ patchy_sleet_possible: "󰙿",
+ patchy_freezing_drizzle_possible: "󰙿",
+ thundery_outbreaks_possible: "󰙾",
+ blowing_snow: "󰼶",
+ blizzard: "󰼶",
+ fog: "󰖑",
+ freezing_fog: "󰖑",
+ patchy_light_drizzle: "󰼳",
+ light_drizzle: "󰼳",
+ freezing_drizzle: "󰙿",
+ heavy_freezing_drizzle: "󰙿",
+ patchy_light_rain: "󰼳",
+ light_rain: "󰼳",
+ moderate_rain_at_times: "󰖗",
+ moderate_rain: "󰼳",
+ heavy_rain_at_times: "󰖖",
+ heavy_rain: "󰖖",
+ light_freezing_rain: "󰙿",
+ moderate_or_heavy_freezing_rain: "󰙿",
+ light_sleet: "󰙿",
+ moderate_or_heavy_sleet: "󰙿",
+ patchy_light_snow: "󰼴",
+ light_snow: "󰼴",
+ patchy_moderate_snow: "󰼴",
+ moderate_snow: "󰼶",
+ patchy_heavy_snow: "󰼶",
+ heavy_snow: "󰼶",
+ ice_pellets: "󰖒",
+ light_rain_shower: "󰖖",
+ moderate_or_heavy_rain_shower: "󰖖",
+ torrential_rain_shower: "󰖖",
+ light_sleet_showers: "󰼵",
+ moderate_or_heavy_sleet_showers: "󰼵",
+ light_snow_showers: "󰼵",
+ moderate_or_heavy_snow_showers: "󰼵",
+ light_showers_of_ice_pellets: "󰖒",
+ moderate_or_heavy_showers_of_ice_pellets: "󰖒",
+ patchy_light_rain_with_thunder: "󰙾",
+ moderate_or_heavy_rain_with_thunder: "󰙾",
+ moderate_or_heavy_rain_in_area_with_thunder: "󰙾",
+ patchy_light_snow_with_thunder: "󰼶",
+ moderate_or_heavy_snow_with_thunder: "󰼶",
+};
+
+const TEMP_ICONS: Record<string, string> = {};
+
+@register({ GTypeName: "Weather" })
+export default class Weather extends GObject.Object {
+ static instance: Weather;
+ static get_default() {
+ if (!this.instance) this.instance = new Weather();
+
+ return this.instance;
+ }
+
+ readonly #cache: string = `${CACHE}/weather.json`;
+
+ #key: string = "";
+ #data: WeatherData = DEFAULT;
+
+ @property(Object)
+ get raw() {
+ return this.#data;
+ }
+
+ @property(Object)
+ get current() {
+ return this.#data.current;
+ }
+
+ @property(Object)
+ get forecast() {
+ return this.#data.forecast.forecastday[0].hour;
+ }
+
+ @property(Object)
+ get location() {
+ return this.#data.location;
+ }
+
+ @property(String)
+ get condition() {
+ return this.#data.current.condition.text;
+ }
+
+ @property(Number)
+ get temperature() {
+ return this.#data.current.temp_c;
+ }
+
+ @property(Number)
+ get wind() {
+ return this.#data.current.wind_kph;
+ }
+
+ @property(Number)
+ get rainChance() {
+ return this.#data.forecast.forecastday[0].day.daily_chance_of_rain;
+ }
+
+ @property(String)
+ get icon() {
+ return this.getIcon(this.#data.current.condition.text);
+ }
+
+ @property(String)
+ get tempIcon() {
+ return this.getTempIcon(this.#data.current.temp_c);
+ }
+
+ @property(String)
+ get tempColour() {
+ return this.getTempDesc(this.#data.current.temp_c);
+ }
+
+ getIcon(status: string) {
+ let query = status.trim().toLowerCase().replaceAll(" ", "_");
+ if (!this.#data.current.is_day && query + "_night" in STATUS_ICONS) query += "_night";
+ return STATUS_ICONS[query] ?? STATUS_ICONS.warning;
+ }
+
+ getTempIcon(temp: number) {
+ if (temp >= 40) return "";
+ if (temp >= 30) return "";
+ if (temp >= 20) return "";
+ if (temp >= 10) return "";
+ return "";
+ }
+
+ getTempDesc(temp: number) {
+ if (temp >= 40) return "burning";
+ if (temp >= 30) return "hot";
+ if (temp >= 20) return "normal";
+ if (temp >= 10) return "cold";
+ return "freezing";
+ }
+
+ #notify() {
+ this.notify("raw");
+ this.notify("current");
+ this.notify("forecast");
+ this.notify("location");
+ this.notify("condition");
+ this.notify("temperature");
+ this.notify("wind");
+ this.notify("rain-chance");
+ this.notify("icon");
+ this.notify("temp-icon");
+ this.notify("temp-colour");
+ }
+
+ async getWeather() {
+ const location = config.location || JSON.parse(await execAsync("curl ipinfo.io")).city;
+ return JSON.parse(
+ await execAsync([
+ "curl",
+ `https://api.weatherapi.com/v1/forecast.json?key=${this.#key}&q=${location}&days=1&aqi=no&alerts=no`,
+ ])
+ );
+ }
+
+ async updateWeather() {
+ if (GLib.file_test(this.#cache, GLib.FileTest.EXISTS)) {
+ const cache = await readFileAsync(this.#cache);
+ const cache_data: WeatherData = JSON.parse(cache);
+ if (cache_data.location.localtime_epoch * 1000 + config.interval > Date.now()) {
+ if (JSON.stringify(this.#data) !== cache) {
+ this.#data = cache_data;
+ this.#notify();
+ }
+ return;
+ }
+ }
+
+ try {
+ const data = await this.getWeather();
+ this.#data = data;
+ writeFileAsync(this.#cache, JSON.stringify(data)).catch(console.error); // Catch here so it doesn't propagate
+ } catch (e) {
+ console.error("Error getting weather:", e);
+ this.#data = DEFAULT;
+ }
+ this.#notify();
+ }
+
+ constructor() {
+ super();
+
+ readFileAsync(config.key)
+ .then(k => {
+ this.#key = k;
+ this.updateWeather().catch(console.error);
+ interval(config.interval, () => this.updateWeather().catch(console.error));
+ })
+ .catch(console.error);
+ }
+}
diff --git a/src/utils/system.ts b/src/utils/system.ts
index c318c64..31e9cfd 100644
--- a/src/utils/system.ts
+++ b/src/utils/system.ts
@@ -1,4 +1,4 @@
-import { execAsync, GLib, type Gio } from "astal";
+import { bind, execAsync, GLib, Variable, type Gio } from "astal";
import type AstalApps from "gi://AstalApps";
import { osIcons } from "./icons";
@@ -63,3 +63,7 @@ export const osIcon = String.fromCodePoint(
return 0xf31a;
})()
);
+
+export const currentTime = Variable(GLib.DateTime.new_now_local()).poll(1000, () => GLib.DateTime.new_now_local());
+export const bindCurrentTime = (format: string, fallback?: (time: GLib.DateTime) => string) =>
+ bind(currentTime).as(c => c.format(format) ?? fallback?.(c) ?? new Date().toLocaleString());
diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts
index 64325a0..454a7a6 100644
--- a/src/utils/widgets.ts
+++ b/src/utils/widgets.ts
@@ -50,3 +50,10 @@ export class MenuItem extends astalify(Gtk.MenuItem) {
super(props as any);
}
}
+
+@register()
+export class Calendar extends astalify(Gtk.Calendar) {
+ constructor(props: ConstructProps<Calendar, Gtk.Calendar.ConstructorProps>) {
+ super(props as any);
+ }
+}