diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | config.ts | 7 | ||||
| -rw-r--r-- | scss/popdowns/index.scss | 1 | ||||
| -rw-r--r-- | scss/popdowns/sideright.scss | 147 | ||||
| -rw-r--r-- | src/modules/bar.tsx | 25 | ||||
| -rw-r--r-- | src/modules/popdowns/index.tsx | 2 | ||||
| -rw-r--r-- | src/modules/popdowns/sideright.tsx | 127 | ||||
| -rw-r--r-- | src/services/weather.ts | 398 | ||||
| -rw-r--r-- | src/utils/system.ts | 6 | ||||
| -rw-r--r-- | src/utils/widgets.ts | 7 |
10 files changed, 705 insertions, 16 deletions
@@ -2,3 +2,4 @@ node_modules/ scss/scheme/_index.scss scss/scheme/_dynamic.scss +assets/weather-api-key.txt @@ -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); + } +} |