diff options
| author | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-12 23:00:18 +1100 |
|---|---|---|
| committer | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-12 23:00:18 +1100 |
| commit | 54a62679574db230fd72a5c7819d5f7715cf17c0 (patch) | |
| tree | d8b3761cba53a45179193f1b6c41cf0288bbf126 | |
| parent | bar (diff) | |
| download | caelestia-shell-54a62679574db230fd72a5c7819d5f7715cf17c0.tar.gz caelestia-shell-54a62679574db230fd72a5c7819d5f7715cf17c0.tar.bz2 caelestia-shell-54a62679574db230fd72a5c7819d5f7715cf17c0.zip | |
notification popups
| -rw-r--r-- | app.tsx | 3 | ||||
| -rw-r--r-- | modules/bar.tsx | 3 | ||||
| -rw-r--r-- | modules/notifpopups.tsx | 163 | ||||
| -rw-r--r-- | scss/_lib.scss | 11 | ||||
| -rw-r--r-- | scss/bar.scss | 4 | ||||
| -rw-r--r-- | scss/notifpopups.scss | 107 | ||||
| -rw-r--r-- | scss/widgets.scss | 11 | ||||
| -rw-r--r-- | style.scss | 1 | ||||
| -rw-r--r-- | utils/icons.ts | 39 |
9 files changed, 298 insertions, 44 deletions
@@ -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; @@ -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", |