summaryrefslogtreecommitdiff
path: root/src/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'src/widgets')
-rw-r--r--src/widgets/notification.tsx132
-rw-r--r--src/widgets/popupwindow.ts39
2 files changed, 171 insertions, 0 deletions
diff --git a/src/widgets/notification.tsx b/src/widgets/notification.tsx
new file mode 100644
index 0000000..0bef5ca
--- /dev/null
+++ b/src/widgets/notification.tsx
@@ -0,0 +1,132 @@
+import { GLib, register, timeout } from "astal";
+import { Astal, Gtk, Widget } from "astal/gtk3";
+import AstalNotifd from "gi://AstalNotifd";
+import { notifpopups as config } from "../../config";
+import { desktopEntrySubs } from "../utils/icons";
+
+const urgencyToString = (urgency: AstalNotifd.Urgency) => {
+ switch (urgency) {
+ case AstalNotifd.Urgency.LOW:
+ return "low";
+ case AstalNotifd.Urgency.NORMAL:
+ return "normal";
+ case AstalNotifd.Urgency.CRITICAL:
+ return "critical";
+ }
+};
+
+const getTime = (time: number) => {
+ const messageTime = GLib.DateTime.new_from_unix_local(time);
+ const todayDay = GLib.DateTime.new_now_local().get_day_of_year();
+ if (messageTime.get_day_of_year() === todayDay) {
+ const aMinuteAgo = GLib.DateTime.new_now_local().add_seconds(-60);
+ return aMinuteAgo !== null && messageTime.compare(aMinuteAgo) > 0 ? "Now" : messageTime.format("%H:%M");
+ } else if (messageTime.get_day_of_year() === todayDay - 1) return "Yesterday";
+ return messageTime.format("%d/%m");
+};
+
+const AppIcon = ({ appIcon, desktopEntry }: { appIcon: string; desktopEntry: string }) => {
+ // Try app icon
+ let icon = Astal.Icon.lookup_icon(appIcon) && appIcon;
+ // Try desktop entry
+ if (!icon) {
+ if (desktopEntrySubs.hasOwnProperty(desktopEntry)) icon = desktopEntrySubs[desktopEntry];
+ else if (Astal.Icon.lookup_icon(desktopEntry)) icon = desktopEntry;
+ }
+ return icon ? <icon className="app-icon" icon={icon} /> : null;
+};
+
+const Image = ({ icon }: { icon: string }) => {
+ if (GLib.file_test(icon, GLib.FileTest.EXISTS))
+ return (
+ <box
+ valign={Gtk.Align.START}
+ className="image"
+ css={`
+ background-image: url("${icon}");
+ `}
+ />
+ );
+ if (Astal.Icon.lookup_icon(icon)) return <icon valign={Gtk.Align.START} className="image" icon={icon} />;
+ return null;
+};
+
+@register()
+export default class Notification extends Widget.Box {
+ readonly #revealer;
+ #destroyed = false;
+
+ constructor({ notification, popup }: { notification: AstalNotifd.Notification; popup?: boolean }) {
+ super();
+
+ this.#revealer = (
+ <revealer revealChild transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} transitionDuration={150}>
+ <box className="wrapper">
+ <box vertical className={`notification ${urgencyToString(notification.urgency)}`}>
+ <box className="header">
+ <AppIcon appIcon={notification.appIcon} desktopEntry={notification.appName} />
+ <label className="app-name" label={notification.appName ?? "Unknown"} />
+ <box hexpand />
+ <label
+ className="time"
+ label={getTime(notification.time)!}
+ setup={self =>
+ timeout(60000, () => !this.#destroyed && (self.label = getTime(notification.time)!))
+ }
+ />
+ </box>
+ <box hexpand className="separator" />
+ <box className="content">
+ {notification.image && <Image icon={notification.image} />}
+ <box vertical>
+ <label className="summary" xalign={0} label={notification.summary} truncate />
+ <label className="body" xalign={0} label={notification.body} wrap useMarkup />
+ </box>
+ </box>
+ <box className="actions">
+ <button hexpand cursor="pointer" onClicked={() => notification.dismiss()} label="Close" />
+ {notification.actions.map(a => (
+ <button hexpand cursor="pointer" onClicked={() => notification.invoke(a.id)}>
+ {notification.actionIcons ? <icon icon={a.label} /> : a.label}
+ </button>
+ ))}
+ </box>
+ </box>
+ </box>
+ </revealer>
+ ) as Widget.Revealer;
+ this.add(this.#revealer);
+
+ // Init animation
+ const width = this.get_preferred_width()[1];
+ this.css = `margin-left: ${width}px; margin-right: -${width}px;`;
+ timeout(1, () => {
+ this.css = `transition: 300ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`;
+ });
+
+ // Close popup after timeout if transient or expire enabled in config
+ if (popup && (config.expire || notification.transient))
+ timeout(
+ notification.expireTimeout > 0
+ ? notification.expireTimeout
+ : notification.urgency === AstalNotifd.Urgency.CRITICAL
+ ? 10000
+ : 5000,
+ () => this.destroyWithAnims()
+ );
+ }
+
+ destroyWithAnims() {
+ if (this.#destroyed) return;
+ this.#destroyed = true;
+
+ const animTime = 120;
+ const animMargin = this.get_allocated_width();
+ this.css = `transition: ${animTime}ms cubic-bezier(0.85, 0, 0.15, 1);
+ margin-left: ${animMargin}px; margin-right: -${animMargin}px;`;
+ timeout(animTime, () => {
+ this.#revealer.revealChild = false;
+ timeout(this.#revealer.transitionDuration, () => this.destroy());
+ });
+ }
+}
diff --git a/src/widgets/popupwindow.ts b/src/widgets/popupwindow.ts
new file mode 100644
index 0000000..67aa0ff
--- /dev/null
+++ b/src/widgets/popupwindow.ts
@@ -0,0 +1,39 @@
+import { Binding, register } from "astal";
+import { App, Astal, Gdk, Widget } from "astal/gtk3";
+import AstalHyprland from "gi://AstalHyprland?version=0.1";
+
+const extendProp = <T>(
+ prop: T | Binding<T | undefined> | undefined,
+ override: (prop: T | undefined) => T | undefined
+) => prop && (prop instanceof Binding ? prop.as(override) : override(prop));
+
+@register()
+export default class PopupWindow extends Widget.Window {
+ constructor(props: Widget.WindowProps) {
+ super({
+ keymode: Astal.Keymode.ON_DEMAND,
+ exclusivity: Astal.Exclusivity.IGNORE,
+ ...props,
+ visible: false,
+ application: App,
+ name: props.monitor ? extendProp(props.name, n => (n ? n + props.monitor : undefined)) : props.name,
+ namespace: extendProp(props.name, n => `caelestia-${n}`),
+ onKeyPressEvent: (self, event) => {
+ // Close window on escape
+ if (event.get_keyval()[1] === Gdk.KEY_Escape) self.hide();
+
+ return props.onKeyPressEvent?.(self, event);
+ },
+ borderWidth: 20, // To allow shadow, cause if not it gets cut off
+ });
+ }
+
+ popup_at_widget(widget: JSX.Element, event: Gdk.Event) {
+ const { width, height } = widget.get_allocation();
+ const [_, x, y] = event.get_coords();
+ const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position();
+ this.marginLeft = cx + ((width - this.get_preferred_width()[1]) / 2 - x);
+ this.marginTop = cy + (height - y);
+ this.show();
+ }
+}