bar
This commit is contained in:
parent
0a7cc46048
commit
b4aca729dd
20 changed files with 1026 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
@girs/
|
||||
node_modules/
|
||||
scss/scheme/_index.scss
|
||||
|
|
|
|||
28
app.tsx
Normal file
28
app.tsx
Normal file
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
2
assets/icons/caelestia-media-generic-symbolic.svg
Normal file
2
assets/icons/caelestia-media-generic-symbolic.svg
Normal file
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 1 KiB |
19
assets/icons/caelestia-media-none-symbolic.svg
Normal file
19
assets/icons/caelestia-media-none-symbolic.svg
Normal file
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
21
assets/icons/caelestia-spotify-symbolic.svg
Normal file
21
assets/icons/caelestia-spotify-symbolic.svg
Normal file
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
283
modules/bar.tsx
Normal file
283
modules/bar.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
21
scss/_font.scss
Normal file
21
scss/_font.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
29
scss/_lib.scss
Normal file
29
scss/_lib.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
103
scss/bar.scss
Normal file
103
scss/bar.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
26
scss/scheme/_mocha.scss
Normal file
26
scss/scheme/_mocha.scss
Normal file
|
|
@ -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;
|
||||
120
scss/widgets.scss
Normal file
120
scss/widgets.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
services/apps.ts
Normal file
3
services/apps.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import AstalApps from "gi://AstalApps";
|
||||
|
||||
export const Apps = new AstalApps.Apps();
|
||||
157
services/players.ts
Normal file
157
services/players.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
9
style.scss
Normal file
9
style.scss
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Common widgets
|
||||
@use "scss/widgets";
|
||||
|
||||
// Modules
|
||||
@use "scss/bar";
|
||||
|
||||
* {
|
||||
all: unset; // Remove GTK theme styles
|
||||
}
|
||||
3
utils/constants.ts
Normal file
3
utils/constants.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { GLib } from "astal";
|
||||
|
||||
export const CACHE_DIR = GLib.get_user_cache_dir() + "/caelestia";
|
||||
118
utils/icons.ts
Normal file
118
utils/icons.ts
Normal file
|
|
@ -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";
|
||||
};
|
||||
16
utils/mpris.ts
Normal file
16
utils/mpris.ts
Normal file
|
|
@ -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"));
|
||||
1
utils/strings.ts
Normal file
1
utils/strings.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const ellipsize = (str: string, len = 40) => (str.length > len ? `${str.slice(0, len - 1)}…` : str);
|
||||
21
utils/system.ts
Normal file
21
utils/system.ts
Normal file
|
|
@ -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;
|
||||
})()
|
||||
);
|
||||
45
utils/widgets.tsx
Normal file
45
utils/widgets.tsx
Normal file
|
|
@ -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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue