summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-01-12 23:00:18 +1100
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-01-12 23:00:18 +1100
commit54a62679574db230fd72a5c7819d5f7715cf17c0 (patch)
treed8b3761cba53a45179193f1b6c41cf0288bbf126
parentbar (diff)
downloadcaelestia-shell-54a62679574db230fd72a5c7819d5f7715cf17c0.tar.gz
caelestia-shell-54a62679574db230fd72a5c7819d5f7715cf17c0.tar.bz2
caelestia-shell-54a62679574db230fd72a5c7819d5f7715cf17c0.zip
notification popups
-rw-r--r--app.tsx3
-rw-r--r--modules/bar.tsx3
-rw-r--r--modules/notifpopups.tsx163
-rw-r--r--scss/_lib.scss11
-rw-r--r--scss/bar.scss4
-rw-r--r--scss/notifpopups.scss107
-rw-r--r--scss/widgets.scss11
-rw-r--r--style.scss1
-rw-r--r--utils/icons.ts39
9 files changed, 298 insertions, 44 deletions
diff --git a/app.tsx b/app.tsx
index 4f61699..27ca512 100644
--- a/app.tsx
+++ b/app.tsx
@@ -2,6 +2,7 @@ import { execAsync, GLib, writeFileAsync } from "astal";
import { App } from "astal/gtk3";
import AstalHyprland from "gi://AstalHyprland";
import Bar from "./modules/bar";
+import NotifPopups from "./modules/notifpopups";
const loadStyleAsync = async () => {
if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS))
@@ -12,9 +13,11 @@ const loadStyleAsync = async () => {
App.start({
instanceName: "caelestia",
icons: "assets/icons",
+ iconTheme: "Adwaita",
main() {
loadStyleAsync().catch(console.error);
+ <NotifPopups />;
AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />);
console.log("Caelestia started");
diff --git a/modules/bar.tsx b/modules/bar.tsx
index 1db5e82..b7ffb56 100644
--- a/modules/bar.tsx
+++ b/modules/bar.tsx
@@ -90,7 +90,7 @@ const MediaPlaying = () => {
<label
setup={self =>
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}
anchor={Astal.WindowAnchor.TOP}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
- visible
>
<centerbox className="bar" css={"min-width: " + monitor.width * 0.8 + "px;"}>
<box halign={Gtk.Align.START}>
diff --git a/modules/notifpopups.tsx b/modules/notifpopups.tsx
new file mode 100644
index 0000000..a9a898c
--- /dev/null
+++ b/modules/notifpopups.tsx
@@ -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>
+);
diff --git a/scss/_lib.scss b/scss/_lib.scss
index 19a2867..8c39778 100644
--- a/scss/_lib.scss
+++ b/scss/_lib.scss
@@ -1,3 +1,6 @@
+@use "sass:color";
+@use "scheme";
+
$scale: 0.068rem;
@function s($value: 1) {
@return $value * $scale;
@@ -12,6 +15,14 @@ $scale: 0.068rem;
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) {
$dir: if($vertical, bottom, right);
diff --git a/scss/bar.scss b/scss/bar.scss
index 618944a..9b4c60d 100644
--- a/scss/bar.scss
+++ b/scss/bar.scss
@@ -5,7 +5,7 @@
.bar {
@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 font.mono;
@@ -27,8 +27,6 @@
}
label.icon {
- @include font.icon;
-
font-size: lib.s(18);
}
diff --git a/scss/notifpopups.scss b/scss/notifpopups.scss
new file mode 100644
index 0000000..b8e2565
--- /dev/null
+++ b/scss/notifpopups.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/scss/widgets.scss b/scss/widgets.scss
index 0e11f46..4eb9f3f 100644
--- a/scss/widgets.scss
+++ b/scss/widgets.scss
@@ -3,6 +3,17 @@
@use "lib";
@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 {
from {
opacity: 0;
diff --git a/style.scss b/style.scss
index 8359137..60c0ccf 100644
--- a/style.scss
+++ b/style.scss
@@ -3,6 +3,7 @@
// Modules
@use "scss/bar";
+@use "scss/notifpopups";
* {
all: unset; // Remove GTK theme styles
diff --git a/utils/icons.ts b/utils/icons.ts
index 0293611..4b2d038 100644
--- a/utils/icons.ts
+++ b/utils/icons.ts
@@ -1,5 +1,4 @@
import { Gio } from "astal";
-import { Astal } from "astal/gtk3";
import { Apps } from "../services/apps";
// Code points from https://www.github.com/lukas-w/font-logos
@@ -31,44 +30,6 @@ export const osIcons: Record<string, number> = {
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> = {
WebBrowser: "web",
Printing: "print",