notification popups
This commit is contained in:
parent
b4aca729dd
commit
54a6267957
9 changed files with 298 additions and 44 deletions
3
app.tsx
3
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");
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
163
modules/notifpopups.tsx
Normal file
163
modules/notifpopups.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
107
scss/notifpopups.scss
Normal file
107
scss/notifpopups.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
// Modules
|
||||
@use "scss/bar";
|
||||
@use "scss/notifpopups";
|
||||
|
||||
* {
|
||||
all: unset; // Remove GTK theme styles
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue