notification popups

This commit is contained in:
2 * r + 2 * t 2025-01-12 23:00:18 +11:00
parent b4aca729dd
commit 54a6267957
9 changed files with 298 additions and 44 deletions

View file

@ -2,6 +2,7 @@ import { execAsync, GLib, writeFileAsync } from "astal";
import { App } from "astal/gtk3"; import { App } from "astal/gtk3";
import AstalHyprland from "gi://AstalHyprland"; import AstalHyprland from "gi://AstalHyprland";
import Bar from "./modules/bar"; import Bar from "./modules/bar";
import NotifPopups from "./modules/notifpopups";
const loadStyleAsync = async () => { const loadStyleAsync = async () => {
if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS)) if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS))
@ -12,9 +13,11 @@ const loadStyleAsync = async () => {
App.start({ App.start({
instanceName: "caelestia", instanceName: "caelestia",
icons: "assets/icons", icons: "assets/icons",
iconTheme: "Adwaita",
main() { main() {
loadStyleAsync().catch(console.error); loadStyleAsync().catch(console.error);
<NotifPopups />;
AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />); AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />);
console.log("Caelestia started"); console.log("Caelestia started");

View file

@ -90,7 +90,7 @@ const MediaPlaying = () => {
<label <label
setup={self => setup={self =>
players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => { players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => {
self.label = getLabel("No media"); self.label = ellipsize(getLabel("No media")); // TODO: scroll text
}) })
} }
/> />
@ -263,7 +263,6 @@ export default ({ monitor }: { monitor: AstalHyprland.Monitor }) => (
monitor={monitor.id} monitor={monitor.id}
anchor={Astal.WindowAnchor.TOP} anchor={Astal.WindowAnchor.TOP}
exclusivity={Astal.Exclusivity.EXCLUSIVE} exclusivity={Astal.Exclusivity.EXCLUSIVE}
visible
> >
<centerbox className="bar" css={"min-width: " + monitor.width * 0.8 + "px;"}> <centerbox className="bar" css={"min-width: " + monitor.width * 0.8 + "px;"}>
<box halign={Gtk.Align.START}> <box halign={Gtk.Align.START}>

163
modules/notifpopups.tsx Normal file
View file

@ -0,0 +1,163 @@
import { GLib, register, timeout } from "astal";
import { Astal, Gtk, Widget } from "astal/gtk3";
import AstalNotifd from "gi://AstalNotifd";
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 Icon = ({ 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()
class NotifPopup extends Widget.Box {
readonly #revealer;
#destroyed = false;
constructor({ notification }: { notification: AstalNotifd.Notification }) {
super();
this.#revealer = (
<revealer revealChild transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} transitionDuration={150}>
<box className="wrapper">
<box vertical className={`popup ${urgencyToString(notification.urgency)}`}>
<box className="header">
{(notification.appIcon || notification.desktopEntry) && (
<icon className="app-icon" icon={notification.appIcon || notification.desktopEntry} />
)}
<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 && <Icon 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: 150ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`;
});
// Close popup after timeout
// 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());
});
}
}
export default () => (
<window
namespace="notifpopups"
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM}
exclusivity={Astal.Exclusivity.IGNORE}
>
<box
vertical
valign={Gtk.Align.START}
className="notifpopups"
setup={self => {
const notifd = AstalNotifd.get_default();
const map = new Map<number, NotifPopup>();
self.hook(notifd, "notified", (self, id) => {
const notification = notifd.get_notification(id);
const popup = (<NotifPopup notification={notification} />) as NotifPopup;
map.set(notification.id, popup);
self.add(
<eventbox
// Dismiss on middle click
onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()}
// Close on hover lost
onHoverLost={() => popup.destroyWithAnims()}
>
{popup}
</eventbox>
);
});
self.hook(notifd, "resolved", (_, id) => {
const popup = map.get(id);
if (popup) {
popup.destroyWithAnims();
map.delete(id);
}
});
// Change input region to child region so can click through empty space
self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes());
}}
/>
</window>
);

View file

@ -1,3 +1,6 @@
@use "sass:color";
@use "scheme";
$scale: 0.068rem; $scale: 0.068rem;
@function s($value: 1) { @function s($value: 1) {
@return $value * $scale; @return $value * $scale;
@ -12,6 +15,14 @@ $scale: 0.068rem;
border: s($width) $style $colour; border: s($width) $style $colour;
} }
@mixin outer-border($colour, $alpha: 0.7, $width: 2, $style: solid) {
border: s($width) $style color.change($colour, $alpha: $alpha);
}
@mixin shadow($colour: scheme.$mantle, $alpha: 0.4, $x: 2, $y: 3, $blur: 8, $spread: 0) {
box-shadow: s($x) s($y) s($blur) s($spread) color.change($colour, $alpha: $alpha);
}
@mixin spacing($val: 5, $vertical: false) { @mixin spacing($val: 5, $vertical: false) {
$dir: if($vertical, bottom, right); $dir: if($vertical, bottom, right);

View file

@ -5,7 +5,7 @@
.bar { .bar {
@include lib.rounded(10, $tl: 0, $tr: 0); @include lib.rounded(10, $tl: 0, $tr: 0);
@include lib.border(color.change(scheme.$rosewater, $alpha: 0.7), 2); @include lib.outer-border(scheme.$rosewater);
@include lib.spacing(10); @include lib.spacing(10);
@include font.mono; @include font.mono;
@ -27,8 +27,6 @@
} }
label.icon { label.icon {
@include font.icon;
font-size: lib.s(18); font-size: lib.s(18);
} }

107
scss/notifpopups.scss Normal file
View file

@ -0,0 +1,107 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
@mixin popup($colour) {
@include lib.outer-border($colour);
border-right: none;
.separator {
background-color: $colour;
}
.image {
@include lib.border(color.change($colour, $alpha: 0.05));
}
}
.notifpopups {
margin-top: lib.s(50); // Bar offset
min-width: lib.s(410);
padding-left: lib.s(10); // So notifications can overshoot for init animation
.wrapper {
padding-bottom: lib.s(10);
}
.popup {
@include lib.spacing($vertical: true);
@include lib.rounded(8, $tr: 0, $br: 0);
@include lib.shadow;
@include font.main;
@include popup(scheme.$lavender);
background-color: scheme.$base;
color: scheme.$text;
padding: lib.s(10) lib.s(12);
&.low {
@include popup(scheme.$overlay0);
}
&.critical {
@include popup(scheme.$red);
}
}
.header,
.content {
padding: 0 lib.s(5);
}
.header {
@include lib.spacing(5);
@include font.mono;
}
.content {
@include lib.spacing(10);
}
.app-icon {
font-size: lib.s(18);
}
.image {
@include lib.rounded(10);
background-size: cover;
background-position: center;
min-width: lib.s(64);
min-height: lib.s(64);
margin-top: lib.s(3);
}
.summary {
@include font.title;
font-size: lib.s(16);
}
.body {
font-size: lib.s(14);
}
.actions {
@include lib.spacing;
& > * {
@include lib.rounded(5);
@include lib.element-decel;
padding: lib.s(5) lib.s(10);
background-color: scheme.$surface0;
&:hover,
&:focus {
background-color: scheme.$surface1;
}
&:active {
background-color: scheme.$surface2;
}
}
}
}

View file

@ -3,6 +3,17 @@
@use "lib"; @use "lib";
@use "font"; @use "font";
label.icon {
@include font.icon;
}
.separator {
@include lib.rounded(2);
min-width: lib.s(0.5);
min-height: lib.s(0.5);
}
@keyframes appear { @keyframes appear {
from { from {
opacity: 0; opacity: 0;

View file

@ -3,6 +3,7 @@
// Modules // Modules
@use "scss/bar"; @use "scss/bar";
@use "scss/notifpopups";
* { * {
all: unset; // Remove GTK theme styles all: unset; // Remove GTK theme styles

View file

@ -1,5 +1,4 @@
import { Gio } from "astal"; import { Gio } from "astal";
import { Astal } from "astal/gtk3";
import { Apps } from "../services/apps"; import { Apps } from "../services/apps";
// Code points from https://www.github.com/lukas-w/font-logos // Code points from https://www.github.com/lukas-w/font-logos
@ -31,44 +30,6 @@ export const osIcons: Record<string, number> = {
ubuntu: 0xf31b, ubuntu: 0xf31b,
}; };
const appIcons: Record<string, string> = {
"code-url-handler": "visual-studio-code",
code: "visual-studio-code",
"codium-url-handler": "vscodium",
codium: "vscodium",
"GitHub Desktop": "github-desktop",
"gnome-tweaks": "org.gnome.tweaks",
"org.pulseaudio.pavucontrol": "pavucontrol",
"pavucontrol-qt": "pavucontrol",
"jetbrains-pycharm-ce": "pycharm-community",
"Spotify Free": "Spotify",
safeeyes: "io.github.slgobinath.SafeEyes",
"yad-icon-browser": "yad",
xterm: "uxterm",
"com-atlauncher-App": "atlauncher",
avidemux3_qt5: "avidemux",
};
const appRegex = [
{ regex: /^steam_app_(\d+)$/, replace: "steam_icon_$1" },
{ regex: /^Minecraft\* [0-9\.]+$/, replace: "minecraft" },
];
export const getAppIcon = (name: string) => {
if (appIcons.hasOwnProperty(name)) return appIcons[name];
for (const { regex, replace } of appRegex) {
const postSub = name.replace(regex, replace);
if (postSub !== name) return postSub;
}
if (Astal.Icon.lookup_icon(name)) return name;
const apps = Apps.fuzzy_query(name);
if (apps.length > 0) return apps[0].iconName;
return "image";
};
const categoryIcons: Record<string, string> = { const categoryIcons: Record<string, string> = {
WebBrowser: "web", WebBrowser: "web",
Printing: "print", Printing: "print",