sideright: make popdown window

Weather requires a weather api key from https://weatherapi.com
This commit is contained in:
2 * r + 2 * t 2025-01-27 21:55:02 +11:00
parent 751c3bc789
commit df3b51399c
10 changed files with 705 additions and 16 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
node_modules/
scss/scheme/_index.scss
scss/scheme/_dynamic.scss
assets/weather-api-key.txt

View file

@ -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
};

View file

@ -3,3 +3,4 @@
@forward "bluetoothdevices";
@forward "networks";
@forward "media";
@forward "sideright";

View file

@ -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);
}
}
}
}
}

View file

@ -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 = () => (

View file

@ -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;
};

View file

@ -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>
);

398
src/services/weather.ts Normal file
View file

@ -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);
}
}

View file

@ -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());

View file

@ -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);
}
}