diff options
| author | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-12 18:00:54 +1100 |
|---|---|---|
| committer | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-12 18:00:54 +1100 |
| commit | b4aca729ddae0526b66822698db7066cb09e1682 (patch) | |
| tree | 2a406cca4cfc616dd22ce7c1be61cc20d5db85bc | |
| parent | Initial commit (diff) | |
| download | caelestia-shell-b4aca729ddae0526b66822698db7066cb09e1682.tar.gz caelestia-shell-b4aca729ddae0526b66822698db7066cb09e1682.tar.bz2 caelestia-shell-b4aca729ddae0526b66822698db7066cb09e1682.zip | |
bar
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | app.tsx | 28 | ||||
| -rw-r--r-- | assets/icons/caelestia-media-generic-symbolic.svg | 2 | ||||
| -rw-r--r-- | assets/icons/caelestia-media-none-symbolic.svg | 19 | ||||
| -rw-r--r-- | assets/icons/caelestia-spotify-symbolic.svg | 21 | ||||
| -rw-r--r-- | modules/bar.tsx | 283 | ||||
| -rw-r--r-- | scss/_font.scss | 21 | ||||
| -rw-r--r-- | scss/_lib.scss | 29 | ||||
| -rw-r--r-- | scss/bar.scss | 103 | ||||
| -rw-r--r-- | scss/scheme/_mocha.scss | 26 | ||||
| -rw-r--r-- | scss/widgets.scss | 120 | ||||
| -rw-r--r-- | services/apps.ts | 3 | ||||
| -rw-r--r-- | services/players.ts | 157 | ||||
| -rw-r--r-- | style.scss | 9 | ||||
| -rw-r--r-- | utils/constants.ts | 3 | ||||
| -rw-r--r-- | utils/icons.ts | 118 | ||||
| -rw-r--r-- | utils/mpris.ts | 16 | ||||
| -rw-r--r-- | utils/strings.ts | 1 | ||||
| -rw-r--r-- | utils/system.ts | 21 | ||||
| -rw-r--r-- | utils/widgets.tsx | 45 |
20 files changed, 1026 insertions, 0 deletions
@@ -1,2 +1,3 @@ @girs/ node_modules/ +scss/scheme/_index.scss @@ -0,0 +1,28 @@ +import { execAsync, GLib, writeFileAsync } from "astal"; +import { App } from "astal/gtk3"; +import AstalHyprland from "gi://AstalHyprland"; +import Bar from "./modules/bar"; + +const loadStyleAsync = async () => { + if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS)) + await writeFileAsync(`${SRC}/scss/scheme/_index.scss`, '@forward "mocha";'); + App.apply_css(await execAsync(`sass ${SRC}/style.scss`), true); +}; + +App.start({ + instanceName: "caelestia", + icons: "assets/icons", + main() { + loadStyleAsync().catch(console.error); + + AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />); + + console.log("Caelestia started"); + }, + requestHandler(request, res) { + if (request === "reload css") loadStyleAsync().catch(console.error); + else return res("Unknown command: " + request); + console.log(`Request handled: ${request}`); + res("OK"); + }, +}); diff --git a/assets/icons/caelestia-media-generic-symbolic.svg b/assets/icons/caelestia-media-generic-symbolic.svg new file mode 100644 index 0000000..8ff60ed --- /dev/null +++ b/assets/icons/caelestia-media-generic-symbolic.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.772 4.28c.56-.144 1.097.246 1.206.814.1.517-.263 1.004-.771 1.14A7 7 0 1 0 19 12.9c.009-.5.4-.945.895-1 .603-.067 1.112.371 1.106.977L21 13c0 .107-.002.213-.006.32a.898.898 0 0 1 0 .164l-.008.122a9 9 0 0 1-9.172 8.392A9 9 0 0 1 9.772 4.28z" fill="#000000"/><path d="M15.93 13.753a4.001 4.001 0 1 1-6.758-3.581A4 4 0 0 1 12 9c.75 0 1.3.16 2 .53 0 0 .15.09.25.17-.1-.35-.228-1.296-.25-1.7a58.75 58.75 0 0 1-.025-2.035V2.96c0-.52.432-.94.965-.94.103 0 .206.016.305.048l4.572 1.689c.446.145.597.23.745.353.148.122.258.27.33.446.073.176.108.342.108.801v1.16c0 .518-.443.94-.975.94a.987.987 0 0 1-.305-.049l-1.379-.447-.151-.05c-.437-.14-.618-.2-.788-.26a5.697 5.697 0 0 1-.514-.207 3.53 3.53 0 0 1-.213-.107c-.098-.05-.237-.124-.521-.263L16 6l.011 7c0 .255-.028.507-.082.753h.001z" fill="#000000"/></svg>
\ No newline at end of file diff --git a/assets/icons/caelestia-media-none-symbolic.svg b/assets/icons/caelestia-media-none-symbolic.svg new file mode 100644 index 0000000..20ea19a --- /dev/null +++ b/assets/icons/caelestia-media-none-symbolic.svg @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="800px" height="800px" viewBox="0 0 45.793 45.793"
+ xml:space="preserve">
+<g>
+ <g>
+ <circle cx="22.899" cy="12.692" r="2.524"/>
+ <path d="M22.899,26.661c-2.893,0-5.245,2.354-5.245,5.245c0,2.893,2.353,5.244,5.245,5.244s5.246-2.353,5.246-5.244
+ C28.145,29.016,25.791,26.661,22.899,26.661z"/>
+ <path d="M30.701,0H15.093c-4.647,0-8.415,3.768-8.415,8.414v28.965c0,4.646,3.768,8.414,8.415,8.414H30.7
+ c4.647,0,8.415-3.768,8.415-8.414V8.414C39.116,3.768,35.348,0,30.701,0z M22.899,7.182c3.042,0,5.511,2.467,5.511,5.511
+ c0,3.043-2.469,5.511-5.511,5.511c-3.044,0-5.511-2.468-5.511-5.511C17.388,9.648,19.855,7.182,22.899,7.182z M22.899,42.13
+ c-5.646,0-10.223-4.577-10.223-10.224s4.576-10.223,10.223-10.223c5.646,0,10.223,4.577,10.223,10.223S28.544,42.13,22.899,42.13z
+ "/>
+ </g>
+</g>
+</svg>
\ No newline at end of file diff --git a/assets/icons/caelestia-spotify-symbolic.svg b/assets/icons/caelestia-spotify-symbolic.svg new file mode 100644 index 0000000..bf01823 --- /dev/null +++ b/assets/icons/caelestia-spotify-symbolic.svg @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 305 305" xml:space="preserve">
+<g id="XMLID_85_">
+ <path id="XMLID_86_" d="M152.441,0C68.385,0,0,68.39,0,152.453C0,236.568,68.385,305,152.441,305
+ C236.562,305,305,236.568,305,152.453C305,68.39,236.562,0,152.441,0z M75.08,208.47c17.674-5.38,35.795-8.108,53.857-8.108
+ c30.676,0,60.96,7.774,87.592,22.49c1.584,0.863,3.024,3.717,3.67,7.27c0.646,3.552,0.389,7.205-0.648,9.105
+ c-1.309,2.438-3.965,4.014-6.768,4.014c-1.389,0-2.61-0.312-3.831-0.972c-24.448-13.438-52.116-20.542-80.015-20.542
+ c-16.855,0-33.402,2.495-49.167,7.409c-0.768,0.233-1.558,0.352-2.348,0.352c-3.452,0.001-6.448-2.198-7.453-5.461
+ C68.612,219.566,71.419,209.667,75.08,208.47z M68.43,152.303c19.699-5.355,40.057-8.071,60.508-8.071
+ c36.765,0,73.273,8.896,105.601,25.739c2.266,1.15,3.936,3.1,4.701,5.49c0.776,2.421,0.542,5.024-0.669,7.347
+ c-2.885,5.646-6.257,9.44-8.393,9.44c-1.514,0-2.975-0.363-4.43-1.09c-30.019-15.632-62.59-23.558-96.811-23.558
+ c-19.035,0-37.71,2.503-55.489,7.435c-0.827,0.224-1.676,0.337-2.521,0.337c-4.277,0.001-8.046-2.888-9.162-7.013
+ C60.336,162.994,63.601,153.616,68.43,152.303z M66.727,115.606c-0.903,0.223-1.826,0.335-2.744,0.335
+ c-5.169,0.001-9.648-3.492-10.892-8.487c-1.559-6.323,2.397-13.668,8.126-15.111c22.281-5.473,45.065-8.248,67.72-8.248
+ c43.856,0,85.857,9.86,124.851,29.312c2.708,1.336,4.727,3.642,5.687,6.493c0.96,2.854,0.748,5.926-0.592,8.64
+ c-1.826,3.655-5.772,7.59-10.121,7.59c-1.677,0-3.399-0.393-4.924-1.109c-35.819-17.921-74.477-27.008-114.9-27.008
+ C108.164,108.014,87.234,110.568,66.727,115.606z"/>
+</g>
+</svg>
\ No newline at end of file diff --git a/modules/bar.tsx b/modules/bar.tsx new file mode 100644 index 0000000..1db5e82 --- /dev/null +++ b/modules/bar.tsx @@ -0,0 +1,283 @@ +import { GLib, register, Variable } from "astal"; +import { bind, kebabify } from "astal/binding"; +import { App, Astal, astalify, Gdk, Gtk, type ConstructProps } from "astal/gtk3"; +import AstalHyprland from "gi://AstalHyprland"; +import AstalNotifd from "gi://AstalNotifd"; +import AstalTray from "gi://AstalTray"; +import Players from "../services/players"; +import { getAppCategoryIcon } from "../utils/icons"; +import { ellipsize } from "../utils/strings"; +import { osIcon } from "../utils/system"; +import { setupCustomTooltip } from "../utils/widgets"; + +const hyprland = AstalHyprland.get_default(); + +const wsPerGroup = 10; + +const hookFocusedClientProp = ( + self: any, // Ugh why is there no base Widget type + prop: keyof AstalHyprland.Client, + callback: (c: AstalHyprland.Client | null) => void +) => { + let id: number | null = null; + let lastClient: AstalHyprland.Client | null = null; + self.hook(hyprland, "notify::focused-client", () => { + if (id) lastClient?.disconnect(id); + lastClient = hyprland.focusedClient; // Can be null + id = lastClient?.connect(`notify::${kebabify(prop)}`, () => callback(lastClient)); + callback(lastClient); + }); + self.connect("destroy", () => id && lastClient?.disconnect(id)); + callback(lastClient); +}; + +const OSIcon = () => <label className="module os-icon" label={osIcon} />; + +const ActiveWindow = () => ( + <box + hasTooltip + className="module active-window" + setup={self => { + const title = Variable(hyprland.focusedClient?.title ?? ""); + hookFocusedClientProp(self, "title", c => title.set(c?.title ?? "")); + + const window = setupCustomTooltip(self, bind(title)); + if (window) { + self.hook(title, (_, v) => !v && window.hide()); + self.hook(window, "map", () => !title.get() && window.hide()); + } + }} + > + <label + className="icon" + setup={self => + hookFocusedClientProp(self, "class", c => { + self.label = c ? getAppCategoryIcon(c.class) : "desktop_windows"; + }) + } + /> + <label + setup={self => hookFocusedClientProp(self, "title", c => (self.label = c ? ellipsize(c.title) : "Desktop"))} + /> + </box> +); + +const MediaPlaying = () => { + const players = Players.get_default(); + const getLabel = (fallback = "") => + players.lastPlayer ? `${players.lastPlayer.title} - ${players.lastPlayer.artist}` : fallback; + return ( + <box + className="module media-playing" + setup={self => { + const label = Variable(getLabel()); + players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => label.set(getLabel())); + setupCustomTooltip(self, bind(label)); + }} + > + <icon + setup={self => + players.hookLastPlayer(self, "notify::entry", () => { + const icon = `caelestia-${players.lastPlayer?.entry}-symbolic`; + self.icon = players.lastPlayer + ? Astal.Icon.lookup_icon(icon) + ? icon + : "caelestia-media-generic-symbolic" + : "caelestia-media-none-symbolic"; + }) + } + /> + <label + setup={self => + players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => { + self.label = getLabel("No media"); + }) + } + /> + </box> + ); +}; + +const Workspace = ({ idx }: { idx: number }) => { + let wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / wsPerGroup) * wsPerGroup + idx; + return ( + <button + halign={Gtk.Align.CENTER} + valign={Gtk.Align.CENTER} + onClicked={() => hyprland.dispatch("workspace", String(wsId))} + setup={self => { + const update = () => { + self.toggleClassName( + "occupied", + hyprland.clients.some(c => c.workspace.id === wsId) + ); + self.toggleClassName("focused", hyprland.focusedWorkspace.id === wsId); + }; + + self.hook(hyprland, "notify::focused-workspace", () => { + wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / wsPerGroup) * wsPerGroup + idx; + update(); + }); + self.hook(hyprland, "client-added", update); + self.hook(hyprland, "client-moved", update); + self.hook(hyprland, "client-removed", update); + + update(); + }} + /> + ); +}; + +const Workspaces = () => ( + <eventbox + onScroll={(_, event) => { + const activeWs = hyprland.focusedClient?.workspace.name; + if (activeWs?.startsWith("special:")) hyprland.dispatch("togglespecialworkspace", activeWs.slice(8)); + else if (event.delta_y > 0 || hyprland.focusedWorkspace.id > 1) + hyprland.dispatch("workspace", (event.delta_y < 0 ? "-" : "+") + 1); + }} + > + <box className="module workspaces"> + {Array.from({ length: wsPerGroup }).map((_, idx) => ( + <Workspace idx={idx + 1} /> // Start from 1 + ))} + </box> + </eventbox> +); + +@register() +class TrayItemMenu extends astalify(Gtk.Menu) { + constructor(props: ConstructProps<TrayItemMenu, Gtk.Menu.ConstructorProps> & { item: AstalTray.TrayItem }) { + const { item, ...sProps } = props; + super(sProps as any); + + this.hook(item, "notify::menu-model", () => this.bind_model(item.menuModel, null, true)); + this.hook(item, "notify::action-group", () => this.insert_action_group("dbusmenu", item.actionGroup)); + this.bind_model(item.menuModel, null, true); + this.insert_action_group("dbusmenu", item.actionGroup); + } +} + +const TrayItem = (item: AstalTray.TrayItem) => { + const menu = (<TrayItemMenu item={item} />) as TrayItemMenu; + return ( + <button + onClick={(self, event) => { + if (event.button === Astal.MouseButton.PRIMARY) { + if (item.isMenu) menu.popup_at_widget(self, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null); + else item.activate(0, 0); + } else if (event.button === Astal.MouseButton.SECONDARY) + menu.popup_at_widget(self, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null); + }} + onScroll={(_, event) => { + if (event.delta_x !== 0) item.scroll(event.delta_x, "horizontal"); + if (event.delta_y !== 0) item.scroll(event.delta_y, "vertical"); + }} + onDestroy={() => menu.destroy()} + setup={self => setupCustomTooltip(self, bind(item, "tooltipMarkup"))} + > + <icon halign={Gtk.Align.CENTER} gicon={bind(item, "gicon")} /> + </button> + ); +}; + +const Tray = () => <box className="module tray">{bind(AstalTray.get_default(), "items").as(i => i.map(TrayItem))}</box>; + +const Notifications = () => { + const unreadCount = Variable(0); + return ( + <box + className="module notifications" + setup={self => + setupCustomTooltip( + self, + bind(unreadCount).as(n => n + " unread notifications") + ) + } + > + <label className="icon" label="info" /> + <label + label="0" + setup={self => { + const notifd = AstalNotifd.get_default(); + let notifsOpen = false; + let unread = new Set<number>(); + + self.hook(notifd, "notified", (self, id, replaced) => { + if (!notifsOpen && !replaced) { + unread.add(id); + unreadCount.set(unread.size); + self.label = String(unread.size); + } + }); + self.hook(notifd, "resolved", (self, id) => { + if (unread.delete(id)) { + unreadCount.set(unread.size); + self.label = String(unread.size); + } + }); + self.hook(App, "window-toggled", (_, window) => { + if (window.name === "notifications") { + notifsOpen = window.visible; + if (notifsOpen) { + unread.clear(); + unreadCount.set(0); + } + } + }); + }} + /> + </box> + ); +}; + +const DateTime = () => ( + <box className="module date-time"> + <label className="icon" label="calendar_month" /> + <label + setup={self => { + const pollVar = Variable(null).poll(5000, () => { + self.label = GLib.DateTime.new_now_local().format("%d/%m/%y %R") ?? new Date().toLocaleString(); + return null; + }); + self.connect("destroy", () => pollVar.drop()); + }} + /> + </box> +); + +const Power = () => ( + <button + className="module power" + label="power_settings_new" + cursor="pointer" + onClicked={() => { + // TODO: Power menu + }} + /> +); + +export default ({ monitor }: { monitor: AstalHyprland.Monitor }) => ( + <window + namespace="bar" + 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}> + <OSIcon /> + <ActiveWindow /> + <MediaPlaying /> + </box> + <Workspaces /> + <box halign={Gtk.Align.END}> + <Tray /> + <Notifications /> + <DateTime /> + <Power /> + </box> + </centerbox> + </window> +); diff --git a/scss/_font.scss b/scss/_font.scss new file mode 100644 index 0000000..405a850 --- /dev/null +++ b/scss/_font.scss @@ -0,0 +1,21 @@ +@mixin title { + font-family: "Gabarito", "Poppins", "Readex Pro", "Lexend", sans-serif; +} + +@mixin main { + font-family: "Rubik", "Geist", "AR One Sans", "Reddit Sans", "Inter", "Roboto", "Ubuntu", "Noto Sans", sans-serif; +} + +@mixin icon { + font-family: "Material Symbols Rounded", "MaterialSymbolsRounded", "Material Symbols Outlined", + "Material Symbols Sharp"; +} + +@mixin mono { + font-family: "JetBrains Mono NF", "JetBrains Mono Nerd Font", "JetBrains Mono NL", "SpaceMono NF", + "SpaceMono Nerd Font", monospace; +} + +@mixin reading { + font-family: "Readex Pro", "Lexend", "Noto Sans", sans-serif; +} diff --git a/scss/_lib.scss b/scss/_lib.scss new file mode 100644 index 0000000..19a2867 --- /dev/null +++ b/scss/_lib.scss @@ -0,0 +1,29 @@ +$scale: 0.068rem; +@function s($value: 1) { + @return $value * $scale; +} + +@mixin rounded($all, $tl: $all, $tr: $all, $br: $all, $bl: $all) { + border-radius: s($tl) s($tr) s($br) s($bl); + -gtk-outline-radius: s($tl) s($tr) s($br) s($bl); +} + +@mixin border($colour, $width: 1, $style: solid) { + border: s($width) $style $colour; +} + +@mixin spacing($val: 5, $vertical: false) { + $dir: if($vertical, bottom, right); + + & > * { + margin-#{$dir}: s($val); + + &:last-child { + margin-#{$dir}: 0; + } + } +} + +@mixin element-decel { + transition: 200ms cubic-bezier(0, 0.55, 0.45, 1); +} diff --git a/scss/bar.scss b/scss/bar.scss new file mode 100644 index 0000000..618944a --- /dev/null +++ b/scss/bar.scss @@ -0,0 +1,103 @@ +@use "sass:color"; +@use "lib"; +@use "scheme"; +@use "font"; + +.bar { + @include lib.rounded(10, $tl: 0, $tr: 0); + @include lib.border(color.change(scheme.$rosewater, $alpha: 0.7), 2); + @include lib.spacing(10); + @include font.mono; + + border-top: none; + background-color: scheme.$base; + padding: lib.s(8) lib.s(20); + font-size: lib.s(14); + + & > * { + @include lib.spacing(10); + } + + .module { + @include lib.rounded(5); + @include lib.spacing; + + padding: lib.s(3) lib.s(8); + background-color: scheme.$surface0; + } + + label.icon { + @include font.icon; + + font-size: lib.s(18); + } + + .os-icon { + @include lib.border(scheme.$yellow); + + color: scheme.$yellow; + font-size: lib.s(14); + padding-right: lib.s(12); + } + + .active-window { + color: scheme.$pink; + } + + .media-playing { + @include lib.spacing(8); + + color: scheme.$lavender; + + icon { + font-size: lib.s(16); + } + } + + .workspaces { + @include lib.border(scheme.$maroon); + + padding: lib.s(3) lib.s(18); + + & > * { + @include lib.rounded(2); + @include lib.element-decel; + + min-width: lib.s(20); + min-height: lib.s(4); + background-color: scheme.$surface1; + + &.occupied { + background-color: scheme.$sky; + } + + &.focused { + min-width: lib.s(30); + background-color: scheme.$mauve; + } + } + } + + .tray { + @include lib.spacing(10); + + font-size: lib.s(15); + } + + .notifications { + color: scheme.$mauve; + } + + .date-time { + color: scheme.$peach; + } + + .power { + @include lib.border(scheme.$red); + @include font.icon; + + color: scheme.$red; + font-weight: bold; + font-size: lib.s(16); + } +} diff --git a/scss/scheme/_mocha.scss b/scss/scheme/_mocha.scss new file mode 100644 index 0000000..728949d --- /dev/null +++ b/scss/scheme/_mocha.scss @@ -0,0 +1,26 @@ +$rosewater: #f5e0dc; +$flamingo: #f2cdcd; +$pink: #f5c2e7; +$mauve: #cba6f7; +$red: #f38ba8; +$maroon: #eba0ac; +$peach: #fab387; +$yellow: #f9e2af; +$green: #a6e3a1; +$teal: #94e2d5; +$sky: #89dceb; +$sapphire: #74c7ec; +$blue: #89b4fa; +$lavender: #b4befe; +$text: #cdd6f4; +$subtext1: #bac2de; +$subtext0: #a6adc8; +$overlay2: #9399b2; +$overlay1: #7f849c; +$overlay0: #6c7086; +$surface2: #585b70; +$surface1: #45475a; +$surface0: #313244; +$base: #1e1e2e; +$mantle: #181825; +$crust: #11111b; diff --git a/scss/widgets.scss b/scss/widgets.scss new file mode 100644 index 0000000..0e11f46 --- /dev/null +++ b/scss/widgets.scss @@ -0,0 +1,120 @@ +@use "sass:color"; +@use "scheme"; +@use "lib"; +@use "font"; + +@keyframes appear { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@mixin -appear($duration: 100ms) { + animation-name: appear; + animation-duration: $duration; + animation-timing-function: ease-out; + animation-iteration-count: 1; +} + +menu { + @include -appear; + @include lib.rounded(5); + @include lib.border(color.change(scheme.$blue, $alpha: 0.7), 2); + @include font.main; + + padding: lib.s(10); + background-color: scheme.$surface0; + color: scheme.$text; + + & > menuitem { + @include lib.rounded(8); + + padding: lib.s(8) lib.s(16); + background: transparent; + transition: 0.2s ease background-color; + + &:hover, + &:focus { + background-color: scheme.$surface1; + } + + &:active { + background-color: scheme.$surface2; + } + + &:disabled { + color: scheme.$subtext0; + } + + & > arrow { + @include lib.rounded(1000); + + min-width: lib.s(5); + min-height: lib.s(5); + background-color: scheme.$blue; + + &.right { + margin-left: lib.s(12); + } + + &.left { + margin-right: lib.s(12); + } + } + } + + & > separator { + @include lib.rounded(1); + + background-color: scheme.$blue; + min-width: lib.s(0.5); + min-height: lib.s(0.5); + margin: lib.s(8) 0; + } +} + +tooltip, +.tooltip { + @include lib.rounded(5); + @include lib.border(color.change(scheme.$teal, $alpha: 0.7)); + @include font.reading; + + background-color: scheme.$surface0; + color: scheme.$text; + padding: lib.s(4) lib.s(8); +} + +tooltip { + @include -appear(200ms); +} + +scrollbar { + trough { + @include lib.rounded(1000); + + min-width: lib.s(12); + background-color: transparent; + } + + slider { + @include lib.rounded(1000); + @include lib.element-decel; + + min-width: lib.s(6); + min-height: lib.s(30); + background-color: color.change(scheme.$overlay0, $alpha: 0.3); + + &:hover, + &:focus { + background-color: color.change(scheme.$overlay0, $alpha: 0.4); + } + + &:active { + background-color: color.change(scheme.$overlay1, $alpha: 0.5); + } + } +} diff --git a/services/apps.ts b/services/apps.ts new file mode 100644 index 0000000..5396ac7 --- /dev/null +++ b/services/apps.ts @@ -0,0 +1,3 @@ +import AstalApps from "gi://AstalApps"; + +export const Apps = new AstalApps.Apps(); diff --git a/services/players.ts b/services/players.ts new file mode 100644 index 0000000..2a32960 --- /dev/null +++ b/services/players.ts @@ -0,0 +1,157 @@ +import { execAsync, GLib, GObject, property, readFile, register, writeFileAsync } from "astal"; +import AstalMpris from "gi://AstalMpris"; +import { CACHE_DIR } from "../utils/constants"; +import { isRealPlayer } from "../utils/mpris"; + +@register({ GTypeName: "Players" }) +export default class Players extends GObject.Object { + static instance: Players; + static get_default() { + if (!this.instance) this.instance = new Players(); + + return this.instance; + } + + readonly #path = `${CACHE_DIR}/players.txt`; + readonly #players: AstalMpris.Player[] = []; + readonly #subs = new Map< + JSX.Element, + { signals: string[]; callback: () => void; ids: number[]; player: AstalMpris.Player | null } + >(); + + @property(AstalMpris.Player) + get lastPlayer(): AstalMpris.Player | null { + return this.#players.length > 0 && this.#players[0].identity !== null ? this.#players[0] : null; + } + + /** + * List of real players. + */ + @property(Object) + get list() { + return this.#players; + } + + hookLastPlayer(widget: JSX.Element, signal: string, callback: () => void): this; + hookLastPlayer(widget: JSX.Element, signals: string[], callback: () => void): this; + hookLastPlayer(widget: JSX.Element, signals: string | string[], callback: () => void) { + if (!Array.isArray(signals)) signals = [signals]; + // Add subscription + if (this.lastPlayer) + this.#subs.set(widget, { + signals, + callback, + ids: signals.map(s => this.lastPlayer!.connect(s, callback)), + player: this.lastPlayer, + }); + else this.#subs.set(widget, { signals, callback, ids: [], player: null }); + + // Remove subscription on widget destroyed + widget.connect("destroy", () => { + const sub = this.#subs.get(widget); + if (sub?.player) sub.ids.forEach(id => sub.player!.disconnect(id)); + this.#subs.delete(widget); + }); + + // Initial run of callback + callback(); + + // For chaining + return this; + } + + makeCurrent(player: AstalMpris.Player) { + const index = this.#players.indexOf(player); + // Ignore if already current + if (index === 0) return; + // Remove if present + else if (index > 0) this.#players.splice(index, 1); + // Connect signals if not already in list (i.e. new player) + else this.#connectPlayerSignals(player); + + // Add to front + this.#players.unshift(player); + this.#updatePlayer(); + + // Save to file + this.#save(); + } + + #updatePlayer() { + this.notify("last-player"); + + for (const sub of this.#subs.values()) { + sub.callback(); + if (sub.player) sub.ids.forEach(id => sub.player!.disconnect(id)); + sub.ids = this.lastPlayer ? sub.signals.map(s => this.lastPlayer!.connect(s, sub.callback)) : []; + sub.player = this.lastPlayer; + } + } + + #save() { + execAsync(`mkdir -p ${CACHE_DIR}`) + .then(() => writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).catch(console.error)) + .catch(console.error); + } + + #connectPlayerSignals(player: AstalMpris.Player) { + // Change order on attribute change + for (const signal of [ + "notify::playback-status", + "notify::shuffle-status", + "notify::loop-status", + "notify::volume", + "notify::rate", + ]) + player.connect(signal, () => this.makeCurrent(player)); + } + + constructor() { + super(); + + const mpris = AstalMpris.get_default(); + + // Load players + if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) { + this.#players = readFile(this.#path) + .split("\n") + .map(p => mpris.players.find(p2 => p2.busName === p)) + .filter(isRealPlayer) as AstalMpris.Player[]; + // Add new players from in between sessions + for (const player of mpris.players) + if (!this.#players.includes(player) && isRealPlayer(player)) this.#players.push(player); + } else { + const sortOrder = [ + AstalMpris.PlaybackStatus.PLAYING, + AstalMpris.PlaybackStatus.PAUSED, + AstalMpris.PlaybackStatus.STOPPED, + ]; + this.#players = mpris.players + .filter(isRealPlayer) + .sort((a, b) => sortOrder.indexOf(a.playbackStatus) - sortOrder.indexOf(b.playbackStatus)); + } + this.#updatePlayer(); + this.#save(); + // Connect signals to loaded players + for (const player of this.#players) this.#connectPlayerSignals(player); + + // Add and connect signals when added + mpris.connect("player-added", (_, player) => { + if (isRealPlayer(player)) { + this.makeCurrent(player); + this.notify("list"); + } + }); + + // Remove when closed + mpris.connect("player-closed", (_, player) => { + const index = this.#players.indexOf(player); + if (index >= 0) { + this.#players.splice(index, 1); + this.notify("list"); + if (index === 0) this.#updatePlayer(); + this.#save(); + } + }); + } +} diff --git a/style.scss b/style.scss new file mode 100644 index 0000000..8359137 --- /dev/null +++ b/style.scss @@ -0,0 +1,9 @@ +// Common widgets +@use "scss/widgets"; + +// Modules +@use "scss/bar"; + +* { + all: unset; // Remove GTK theme styles +} diff --git a/utils/constants.ts b/utils/constants.ts new file mode 100644 index 0000000..d907014 --- /dev/null +++ b/utils/constants.ts @@ -0,0 +1,3 @@ +import { GLib } from "astal"; + +export const CACHE_DIR = GLib.get_user_cache_dir() + "/caelestia"; diff --git a/utils/icons.ts b/utils/icons.ts new file mode 100644 index 0000000..0293611 --- /dev/null +++ b/utils/icons.ts @@ -0,0 +1,118 @@ +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 +export const osIcons: Record<string, number> = { + almalinux: 0xf31d, + alpine: 0xf300, + arch: 0xf303, + arcolinux: 0xf346, + centos: 0x304, + debian: 0xf306, + elementary: 0xf309, + endeavouros: 0xf322, + fedora: 0xf30a, + gentoo: 0xf30d, + kali: 0xf327, + linuxmint: 0xf30e, + mageia: 0xf310, + manjaro: 0xf312, + nixos: 0xf313, + opensuse: 0xf314, + suse: 0xf314, + sles: 0xf314, + sles_sap: 0xf314, + pop: 0xf32a, + raspbian: 0xf315, + rhel: 0xf316, + rocky: 0xf32b, + slackware: 0xf318, + 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", + Security: "security", + Network: "chat", + Archiving: "archive", + Compression: "archive", + Development: "code", + IDE: "code", + TextEditor: "edit_note", + Audio: "music_note", + Music: "music_note", + Player: "music_note", + Recorder: "mic", + Game: "sports_esports", + FileTools: "files", + FileManager: "files", + Filesystem: "files", + FileTransfer: "files", + Settings: "settings", + DesktopSettings: "settings", + HardwareSettings: "settings", + TerminalEmulator: "terminal", + ConsoleOnly: "terminal", + Utility: "build", + Monitor: "monitor_heart", + Midi: "graphic_eq", + Mixer: "graphic_eq", + AudioVideoEditing: "video_settings", + AudioVideo: "music_video", + Video: "videocam", + Building: "construction", + Graphics: "photo_library", + "2DGraphics": "photo_library", + RasterGraphics: "photo_library", + TV: "tv", + System: "host", +}; + +export const getAppCategoryIcon = (name: string) => { + const categories = + Gio.DesktopAppInfo.new(`${name}.desktop`)?.get_categories()?.split(";") ?? + Apps.fuzzy_query(name)[0]?.categories; + if (categories) + for (const [key, value] of Object.entries(categoryIcons)) if (categories.includes(key)) return value; + return "terminal"; +}; diff --git a/utils/mpris.ts b/utils/mpris.ts new file mode 100644 index 0000000..8f6923a --- /dev/null +++ b/utils/mpris.ts @@ -0,0 +1,16 @@ +import AstalMpris from "gi://AstalMpris"; +import { inPath } from "./system"; + +const hasPlasmaIntegration = inPath("plasma-browser-integration-host"); + +export const isRealPlayer = (player?: AstalMpris.Player) => + player !== undefined && + // Player closed + player.identity !== null && + // Remove unecessary native buses from browsers if there's plasma integration + !(hasPlasmaIntegration && player.busName.startsWith("org.mpris.MediaPlayer2.firefox")) && + !(hasPlasmaIntegration && player.busName.startsWith("org.mpris.MediaPlayer2.chromium")) && + // playerctld just copies other buses and we don't need duplicates + !player.busName.startsWith("org.mpris.MediaPlayer2.playerctld") && + // Non-instance mpd bus + !(player.busName.endsWith(".mpd") && !player.busName.endsWith("MediaPlayer2.mpd")); diff --git a/utils/strings.ts b/utils/strings.ts new file mode 100644 index 0000000..e5bc43e --- /dev/null +++ b/utils/strings.ts @@ -0,0 +1 @@ +export const ellipsize = (str: string, len = 40) => (str.length > len ? `${str.slice(0, len - 1)}…` : str); diff --git a/utils/system.ts b/utils/system.ts new file mode 100644 index 0000000..9a328d5 --- /dev/null +++ b/utils/system.ts @@ -0,0 +1,21 @@ +import { exec, GLib } from "astal"; +import { osIcons } from "./icons"; + +export const inPath = (bin: string) => { + try { + exec(`which ${bin}`); + } catch { + return false; + } + return true; +}; + +export const osId = GLib.get_os_info("ID") ?? "unknown"; +export const osIdLike = GLib.get_os_info("ID_LIKE"); +export const osIcon = String.fromCodePoint( + (() => { + if (osIcons.hasOwnProperty(osId)) return osIcons[osId]; + if (osIdLike) for (const id of osIdLike.split(" ")) if (osIcons.hasOwnProperty(id)) return osIcons[id]; + return 0xf31a; + })() +); diff --git a/utils/widgets.tsx b/utils/widgets.tsx new file mode 100644 index 0000000..2078aad --- /dev/null +++ b/utils/widgets.tsx @@ -0,0 +1,45 @@ +import { Binding } from "astal"; +import { Astal, type Widget } from "astal/gtk3"; +import AstalHyprland from "gi://AstalHyprland"; + +export const setupCustomTooltip = (self: any, text: string | Binding<string>) => { + if (!text) return null; + + const window = ( + <window + visible={false} + namespace="tooltip" + keymode={Astal.Keymode.NONE} + exclusivity={Astal.Exclusivity.IGNORE} + anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT} + > + <label className="tooltip" label={text} /> + </window> + ) as Widget.Window; + self.set_tooltip_window(window); + + let dirty = true; + let lastX = 0; + self.connect("size-allocate", () => (dirty = true)); + window.connect("size-allocate", () => { + window.marginLeft = lastX + (self.get_allocated_width() - window.get_preferred_width()[1]) / 2; + }); + if (text instanceof Binding) self.hook(text, (_: any, v: string) => !v && window.hide()); + + self.connect("query-tooltip", (_: any, x: number, y: number) => { + if (text instanceof Binding && !text.get()) return false; + if (dirty) { + const { width, height } = self.get_allocation(); + const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position(); + window.marginLeft = cx + ((width - window.get_preferred_width()[1]) / 2 - x); + window.marginTop = cy + (height - y); + lastX = cx - x; + dirty = false; + } + return true; + }); + + self.connect("destroy", () => window.destroy()); + + return window; +}; |