Remove everything
This commit is contained in:
2 * r + 2 * t 2025-04-26 22:36:23 +10:00
parent 124742d74d
commit 3c579d0e27
99 changed files with 0 additions and 11724 deletions

3
.gitignore vendored
View file

@ -1,3 +0,0 @@
@girs/
node_modules/
scss/scheme/_index.scss

View file

@ -1,3 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

132
app.tsx
View file

@ -1,132 +0,0 @@
import Bar from "@/modules/bar";
import Launcher from "@/modules/launcher";
import MediaDisplay from "@/modules/mediadisplay";
import NavBar from "@/modules/navbar";
import NotifPopups from "@/modules/notifpopups";
import Osds from "@/modules/osds";
import ScreenCorners, { BarScreenCorners } from "@/modules/screencorners";
import Session from "@/modules/session";
import SideBar from "@/modules/sidebar";
import Calendar from "@/services/calendar";
import Monitors from "@/services/monitors";
import Palette from "@/services/palette";
import Players from "@/services/players";
import Schemes from "@/services/schemes";
import Wallpapers from "@/services/wallpapers";
import { execAsync, idle, timeout, writeFileAsync } from "astal";
import { App } from "astal/gtk3";
import { style } from "config";
import { initConfig, updateConfig } from "config/funcs";
const isLayer = (name: string) =>
["base", "mantle", "crust"].includes(name) || name.startsWith("surface") || name.startsWith("overlay");
const applyTransparency = (name: string, hex: string) => {
const mode = style.transparency.get();
if (mode === "off" || !isLayer(name)) return hex;
let amount = 0.78;
if (mode === "low") amount = 0.88;
else if (mode === "high") amount = 0.58;
return `color.change(${hex}, $alpha: ${amount})`;
};
const applyVibrancy = (hex: string) => (style.vibrant.get() ? `color.scale(${hex}, $saturation: 40%)` : hex);
const getVars = () => {
const vars = { light: Palette.get_default().mode === "light", borders: style.borders.get() };
return Object.entries(vars)
.map(([k, v]) => `$${k}: ${v}`)
.join(";");
};
const styleLoader = new (class {
#running = false;
#dirty = false;
async run() {
this.#dirty = true;
if (this.#running) return;
this.#running = true;
while (this.#dirty) {
this.#dirty = false;
await this.#run();
}
this.#running = false;
}
async #run() {
const schemeColours = Object.entries(Palette.get_default().colours)
.map(([name, hex]) => `$${name}: ${applyVibrancy(applyTransparency(name, hex))};`)
.join("\n");
await writeFileAsync(`${SRC}/scss/scheme/_index.scss`, `@use "sass:color";\n${getVars()};\n${schemeColours}`);
App.apply_css(await execAsync(`sass ${SRC}/style.scss`), true);
}
})();
export const loadStyleAsync = () => styleLoader.run();
App.start({
instanceName: "caelestia",
icons: "assets/icons",
async main() {
try {
const now = Date.now();
await initConfig();
loadStyleAsync().catch(console.error);
Palette.get_default().connect("notify::colours", () => loadStyleAsync().catch(console.error));
Palette.get_default().connect("notify::mode", () => loadStyleAsync().catch(console.error));
<Launcher />;
<Osds />;
<Session />;
Monitors.get_default().forEach(m => <NotifPopups monitor={m} />);
Monitors.get_default().forEach(m => <MediaDisplay monitor={m} />);
Monitors.get_default().forEach(m => <SideBar monitor={m} />);
Monitors.get_default().forEach(m => <NavBar monitor={m} />);
Monitors.get_default().forEach(m => <Bar monitor={m} />);
Monitors.get_default().forEach(m => <ScreenCorners monitor={m} />);
Monitors.get_default().forEach(m => <BarScreenCorners monitor={m} />);
// Init services
timeout(5000, () => {
idle(() => Schemes.get_default());
idle(() => Wallpapers.get_default());
idle(() => Calendar.get_default());
});
console.log(`Caelestia started in ${Date.now() - now}ms`);
} catch (e) {
console.error(e);
}
},
requestHandler(request, res) {
if (request === "reload-css") loadStyleAsync().catch(console.error);
else if (request === "reload-config") updateConfig();
else if (request.startsWith("show")) App.get_window(request.split(" ")[1])?.show();
else if (request.startsWith("toggle"))
App.toggle_window(request.split(" ")[1] + Monitors.get_default().active.id);
else if (request === "media play-pause") Players.get_default().lastPlayer?.play_pause();
else if (request === "media next") Players.get_default().lastPlayer?.next();
else if (request === "media previous") Players.get_default().lastPlayer?.previous();
else if (request === "media stop") Players.get_default().lastPlayer?.stop();
else if (request.startsWith("media")) {
const player = Players.get_default().lastPlayer;
const key = request.split(" ")[1];
if (player === null) return res("No media");
if (key in player) return res(player[key as keyof typeof player]);
return res(`Invalid key: ${key}`);
} else if (request.startsWith("brightness")) {
const value = request.split(" ")[1];
const num = parseFloat(value) / (value.includes("%") ? 100 : 1);
if (isNaN(num)) return res("Syntax: brightness <value>[%][+ | -]");
if (value.includes("+")) Monitors.get_default().active.brightness += num;
else if (value.includes("-")) Monitors.get_default().active.brightness -= num;
else Monitors.get_default().active.brightness = num;
} else return res("Unknown command: " + request);
console.log(`Request handled: ${request}`);
res("OK");
},
});

View file

@ -1,6 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg width="128" height="128" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="#000000" fill-rule="evenodd">
<path d="M9.00553049,9.09762359 L9.00553049,4 C9.00553049,3.12761864 10.0317148,2.69004793 10.6606665,3.24382091 L10.7403339,3.3217199 L15.3111206,8.27340543 C15.6715217,8.66383994 15.6600247,9.26064253 15.3045564,9.6371766 L15.216432,9.71996479 L12.4826517,11.9976955 L15.2168235,14.2781149 C15.6246647,14.6182724 15.6909323,15.2111346 15.3877981,15.6306432 L15.3112739,15.7241817 L10.7404873,20.678114 C10.1488415,21.3193544 9.09779432,20.9449025 9.01123422,20.1112902 L9.00553049,20 L9.00553049,14.8947629 L8.36532009,15.4281734 C7.94101118,15.781699 7.31045167,15.7243178 6.95692602,15.3000089 C6.63059464,14.9083391 6.65438507,14.3409292 6.99370654,13.9778955 L7.08509045,13.8916148 L9.00553049,12.2915428 L9.00553049,11.7019502 L7.08469887,10.0998922 C6.66057024,9.74615033 6.60351053,9.11556165 6.95725244,8.69143301 C7.28378345,8.29992966 7.84620754,8.22119509 8.26439085,8.48962627 L8.36571168,8.56398659 L9.00553049,9.09762359 L9.00553049,4 L9.00553049,9.09762359 Z M11.0055305,13.3700381 L11.0055305,17.4414967 L13.1284383,15.1406365 L11.0055305,13.3700381 Z M18.8818126,7.23876677 C19.3022301,6.88062215 19.9333794,6.9311043 20.291524,7.3515218 C21.3872593,8.63777952 22,10.2703887 22,11.9949347 C22,13.7248622 21.3834157,15.3621939 20.2814798,16.6501128 C19.9224324,17.0697596 19.291176,17.1188851 18.8715292,16.7598377 C18.4518825,16.4007904 18.4027569,15.769534 18.7618043,15.3498872 C19.5566103,14.4209351 20,13.2435196 20,11.9949347 C20,10.7502267 19.5593778,9.57621638 18.7690576,8.6484782 C18.410913,8.2280607 18.4613951,7.59691139 18.8818126,7.23876677 Z M16.6166672,9.23876677 C17.0370847,8.88062215 17.668234,8.9311043 18.0263786,9.3515218 C18.6506,10.0842805 19,11.0152349 19,11.9974674 C19,12.9827668 18.6484092,13.9164117 18.0206587,14.6501128 C17.6616114,15.0697596 17.030355,15.1188851 16.6107082,14.7598377 C16.1910614,14.4007904 16.1419359,13.769534 16.5009833,13.3498872 C16.8216038,12.9751529 17,12.5014241 17,11.9974674 C17,11.4950729 16.8227185,11.0227174 16.5039122,10.6484782 C16.1457676,10.2280607 16.1962497,9.59691139 16.6166672,9.23876677 Z M11.0055305,6.5576505 L11.0055305,10.6251831 L13.1278913,8.85687474 L11.0055305,6.5576505 Z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1,5 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 12 1 v 2 h -12 v 2 h 12 v 2 h 1 v -0.007812 c 0.265625 0.003906 0.519531 -0.101563 0.707031 -0.285157 l 2 -2 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 l -2 -2 c -0.1875 -0.183594 -0.441406 -0.289063 -0.707031 -0.285157 v -0.007812 z m 0 8 v 2 h -12 v 2 h 12 v 2 h 1 v -0.007812 c 0.265625 0.003906 0.519531 -0.101563 0.707031 -0.285157 l 2 -2 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 l -2 -2 c -0.1875 -0.183594 -0.441406 -0.289063 -0.707031 -0.285157 v -0.007812 z m 0 0" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 746 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,2 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,19 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M11.807 9.776c0.011 0 0.005 0 0 0zM8.109 7.927c0.011 0 0.005 0 0 0zM30.229 10.781c-0.667-1.604-2.021-3.333-3.079-3.885 0.865 1.692 1.365 3.396 1.552 4.661l0.005 0.027c-1.739-4.329-4.681-6.073-7.088-9.871-0.12-0.192-0.24-0.385-0.36-0.588-0.063-0.104-0.115-0.208-0.172-0.319-0.099-0.192-0.171-0.395-0.224-0.609 0-0.020-0.015-0.036-0.036-0.041-0.011 0-0.021 0-0.031 0l-0.005 0.005c-0.005 0-0.011 0.005-0.011 0.005s0-0.005 0.005-0.011c-3.417 2-4.828 5.505-5.193 7.729-1.057 0.063-2.088 0.328-3.041 0.776-0.183 0.093-0.265 0.303-0.197 0.489 0.077 0.213 0.317 0.319 0.525 0.224 0.833-0.391 1.729-0.625 2.651-0.687l0.089-0.011c0.125-0.005 0.255-0.011 0.38-0.011 0.745-0.005 1.489 0.099 2.203 0.307l0.125 0.037c0.12 0.036 0.235 0.077 0.355 0.12 0.083 0.031 0.172 0.063 0.255 0.099 0.068 0.025 0.136 0.057 0.203 0.083 0.105 0.048 0.209 0.1 0.313 0.152l0.14 0.067c0.104 0.053 0.204 0.109 0.303 0.167 0.063 0.037 0.125 0.073 0.187 0.115 1.111 0.688 2.037 1.641 2.683 2.776-0.817-0.572-2.287-1.145-3.697-0.895 5.52 2.76 4.036 12.265-3.615 11.905-0.683-0.025-1.355-0.156-1.995-0.385-0.156-0.057-0.308-0.12-0.453-0.183-0.088-0.041-0.177-0.083-0.26-0.124-1.876-0.969-3.423-2.803-3.615-5.027 0 0 0.708-2.64 5.072-2.64 0.475 0 1.824-1.319 1.849-1.699-0.011-0.125-2.683-1.187-3.724-2.213-0.557-0.547-0.817-0.812-1.052-1.011-0.125-0.109-0.26-0.208-0.401-0.301-0.348-1.224-0.364-2.521-0.041-3.751-1.579 0.719-2.803 1.855-3.693 2.855h-0.009c-0.609-0.771-0.563-3.313-0.532-3.844-0.005-0.036-0.453 0.229-0.511 0.271-0.536 0.385-1.041 0.813-1.5 1.287-0.525 0.531-1.004 1.104-1.437 1.719-0.984 1.396-1.687 2.979-2.057 4.645-0.005 0.021-0.145 0.647-0.249 1.417-0.021 0.12-0.037 0.24-0.052 0.359-0.043 0.292-0.073 0.589-0.089 0.881l-0.005 0.047c-0.009 0.172-0.020 0.339-0.031 0.511v0.077c0 8.48 6.875 15.355 15.355 15.355 7.593 0 13.9-5.516 15.135-12.756 0.027-0.197 0.047-0.395 0.068-0.593 0.307-2.631-0.031-5.401-0.995-7.713z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.8 KiB

View file

@ -1,8 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="#000000">
<path d="m 3 1 h 3 c 0.550781 0 1 0.449219 1 1 v 12 c 0 0.550781 -0.449219 1 -1 1 h -3 c -0.550781 0 -1 -0.449219 -1 -1 v -12 c 0 -0.550781 0.449219 -1 1 -1 z m 0 0"/>
<path d="m 10 1 h 3 c 0.550781 0 1 0.449219 1 1 v 12 c 0 0.550781 -0.449219 1 -1 1 h -3 c -0.550781 0 -1 -0.449219 -1 -1 v -12 c 0 -0.550781 0.449219 -1 1 -1 z m 0 0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 608 B

View file

@ -1,5 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 2 2.5 v 11 c 0 1.5 1.269531 1.492188 1.269531 1.492188 h 0.128907 c 0.246093 0.003906 0.488281 -0.050782 0.699218 -0.171876 l 9.796875 -5.597656 c 0.433594 -0.242187 0.65625 -0.734375 0.65625 -1.226562 c 0 -0.492188 -0.222656 -0.984375 -0.65625 -1.222656 l -9.796875 -5.597657 c -0.210937 -0.121093 -0.453125 -0.175781 -0.699218 -0.175781 h -0.128907 s -1.269531 0 -1.269531 1.5 z m 0 0" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 640 B

View file

@ -1,5 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 7 1 v 2 h -3 c -2.199219 0 -4 1.800781 -4 4 v 2 c 0 1.019531 0.386719 1.964844 1.019531 2.671875 c 0.367188 0.410156 1 0.445313 1.410157 0.078125 c 0.414062 -0.367188 0.449218 -1 0.078124 -1.414062 c -0.316406 -0.351563 -0.507812 -0.8125 -0.507812 -1.335938 v -2 c 0 -1.125 0.875 -2 2 -2 h 3 v 2 h 1 v -0.007812 c 0.265625 0.003906 0.519531 -0.101563 0.707031 -0.285157 l 2 -2 c 0.207031 -0.207031 0.3125 -0.496093 0.289063 -0.789062 c 0 -0.015625 -0.003906 -0.03125 -0.007813 -0.046875 c -0.007812 -0.070313 -0.023437 -0.136719 -0.046875 -0.203125 c -0.011718 -0.035157 -0.023437 -0.070313 -0.042968 -0.101563 c 0 -0.007812 -0.003907 -0.011718 -0.007813 -0.019531 c -0.011719 -0.023437 -0.027344 -0.046875 -0.039063 -0.070313 c -0.007812 -0.011718 -0.019531 -0.027343 -0.027343 -0.039062 c -0.015625 -0.019531 -0.027344 -0.042969 -0.042969 -0.0625 c -0.007812 -0.007812 -0.015625 -0.015625 -0.023438 -0.023438 c -0.015624 -0.019531 -0.03125 -0.039062 -0.050781 -0.058593 l -2 -2 c -0.1875 -0.183594 -0.441406 -0.289063 -0.707031 -0.285157 v -0.007812 z m 7 1 c -1.105469 0 -2 0.894531 -2 2 s 0.894531 2 2 2 s 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 1 5 c -0.550781 0 -1 0.449219 -1 1 v 1.007812 c 0 1.125 -0.875 2 -2 2 h -4 v -2.007812 h -1 v 0.007812 c -0.265625 -0.003906 -0.519531 0.101563 -0.707031 0.285157 l -2 2 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 2 2 c 0.1875 0.183594 0.441406 0.289063 0.707031 0.285157 v 0.007812 h 1 v -1.992188 h 4 c 2.199219 0 4 -1.804687 4 -4 v -1.007812 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,5 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 1 v 2 h -4 c -2.199219 0 -4 1.800781 -4 4 v 2 c 0 1.019531 0.386719 1.964844 1.019531 2.671875 c 0.367188 0.410156 1 0.445313 1.410157 0.078125 c 0.414062 -0.367188 0.449218 -1 0.078124 -1.414062 c -0.316406 -0.351563 -0.507812 -0.8125 -0.507812 -1.335938 v -2 c 0 -1.125 0.875 -2 2 -2 h 4 v 2 h 1 v -0.007812 c 0.265625 0.003906 0.519531 -0.101563 0.707031 -0.285157 l 2 -2 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 l -2 -2 c -0.1875 -0.183594 -0.441406 -0.289063 -0.707031 -0.285157 v -0.007812 z m 6.289062 3 c -0.265624 -0.011719 -0.523437 0.078125 -0.71875 0.257812 c -0.414062 0.367188 -0.449218 1 -0.078124 1.410157 c 0.316406 0.355469 0.507812 0.816406 0.507812 1.339843 v 2 c 0 1.125 -0.875 2 -2 2 h -4 v -2.007812 h -1 v 0.007812 c -0.265625 -0.003906 -0.519531 0.101563 -0.707031 0.285157 l -2 2 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 2 2 c 0.1875 0.183594 0.441406 0.289063 0.707031 0.285157 v 0.007812 h 1 v -1.992188 h 4 c 2.199219 0 4 -1.804687 4 -4 v -2 c 0 -1.023437 -0.386719 -1.96875 -1.019531 -2.675781 c -0.175781 -0.199219 -0.425781 -0.316406 -0.691407 -0.332031 z m 0 0" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,5 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 12 1 v 2 h -3 c -0.859375 0 -1.59375 0.480469 -2.011719 1.007812 c -0.417969 0.523438 -0.648437 1.074219 -0.882812 1.542969 l -0.105469 0.210938 l -0.105469 -0.210938 c -0.191406 -0.382812 -0.386719 -0.816406 -0.671875 -1.25 c -0.414062 -0.632812 -1.207031 -1.300781 -2.222656 -1.300781 h -3 v 2 h 3 c 0.296875 0 0.316406 0.039062 0.550781 0.398438 c 0.164063 0.246093 0.34375 0.625 0.554688 1.050781 l 0.777343 1.550781 l -0.777343 1.550781 c -0.261719 0.523438 -0.476563 0.96875 -0.65625 1.199219 c -0.183594 0.226562 -0.199219 0.25 -0.449219 0.25 h -3 v 2 h 3 c 0.859375 0 1.59375 -0.480469 2.011719 -1.007812 c 0.417969 -0.523438 0.648437 -1.074219 0.882812 -1.542969 l 0.105469 -0.210938 l 0.105469 0.210938 c 0.164062 0.328125 0.335937 0.703125 0.5625 1.078125 c 0.414062 0.6875 1.222656 1.472656 2.332031 1.472656 h 3 v 2 h 1 v -0.007812 c 0.265625 0.003906 0.519531 -0.101563 0.707031 -0.285157 l 2 -2 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 l -2 -2 c -0.1875 -0.183594 -0.441406 -0.289063 -0.707031 -0.285157 v -0.007812 h -1 v 2 h -3 c -0.324219 0 -0.351562 -0.058594 -0.617188 -0.503906 c -0.148437 -0.242188 -0.304687 -0.574219 -0.488281 -0.945313 l -0.777343 -1.550781 l 0.777343 -1.550781 c 0.261719 -0.523438 0.476563 -0.96875 0.65625 -1.199219 c 0.183594 -0.226562 0.199219 -0.25 0.449219 -0.25 h 3 v 2 h 1 v -0.007812 c 0.265625 0.003906 0.519531 -0.101563 0.707031 -0.285157 l 2 -2 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 l -2 -2 c -0.1875 -0.183594 -0.441406 -0.289063 -0.707031 -0.285157 v -0.007812 z m 0 0" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,8 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="#000000">
<path d="m 1 3 v 10 c 0 1 1.085938 1 1.085938 1 h 0.113281 c 0.210937 0 0.417969 -0.046875 0.601562 -0.148438 l 8.398438 -4.800781 c 0.375 -0.207031 0.5625 -0.628906 0.5625 -1.050781 s -0.1875 -0.84375 -0.5625 -1.050781 l -8.398438 -4.800781 c -0.183593 -0.101563 -0.390625 -0.148438 -0.601562 -0.148438 h -0.113281 s -1.085938 0 -1.085938 1 z m 0 0"/>
<path d="m 14.5 2 h -1 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 11 c 0 0.277344 0.222656 0.5 0.5 0.5 h 1 c 0.277344 0 0.5 -0.222656 0.5 -0.5 v -11 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m 0 0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 819 B

View file

@ -1,8 +0,0 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="#000000">
<path d="m 15 3 v 10 c 0 1 -1.085938 1 -1.085938 1 h -0.113281 c -0.210937 0 -0.417969 -0.046875 -0.601562 -0.148438 l -8.398438 -4.800781 c -0.375 -0.207031 -0.5625 -0.628906 -0.5625 -1.050781 s 0.1875 -0.84375 0.5625 -1.050781 l 8.398438 -4.800781 c 0.183593 -0.101563 0.390625 -0.148438 0.601562 -0.148438 h 0.113281 s 1.085938 0 1.085938 1 z m 0 0"/>
<path d="m 1.5 2 h 1 c 0.277344 0 0.5 0.222656 0.5 0.5 v 11 c 0 0.277344 -0.222656 0.5 -0.5 0.5 h -1 c -0.277344 0 -0.5 -0.222656 -0.5 -0.5 v -11 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 z m 0 0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 820 B

View file

@ -1,21 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.8 KiB

4
env.d.ts vendored
View file

@ -1,4 +0,0 @@
declare const HOME: string;
declare const CACHE: string;
declare const STATE: string;
declare const SRC: string;

616
package-lock.json generated
View file

@ -1,616 +0,0 @@
{
"name": "shell",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"fuzzysort": "^3.1.0",
"ical.js": "^2.1.0",
"mathjs": "^14.0.1"
},
"devDependencies": {
"esbuild": "^0.25.2",
"typescript": "5.7.3"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
"integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
"integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
"integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
"integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
"integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
"integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
"integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
"integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
"integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
"integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
"integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
"integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
"integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
"integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
"integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
"integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
"integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
"integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
"integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
"integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
"integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
"integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
"integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
"integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
"integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/complex.js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
"integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/decimal.js": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.2",
"@esbuild/android-arm": "0.25.2",
"@esbuild/android-arm64": "0.25.2",
"@esbuild/android-x64": "0.25.2",
"@esbuild/darwin-arm64": "0.25.2",
"@esbuild/darwin-x64": "0.25.2",
"@esbuild/freebsd-arm64": "0.25.2",
"@esbuild/freebsd-x64": "0.25.2",
"@esbuild/linux-arm": "0.25.2",
"@esbuild/linux-arm64": "0.25.2",
"@esbuild/linux-ia32": "0.25.2",
"@esbuild/linux-loong64": "0.25.2",
"@esbuild/linux-mips64el": "0.25.2",
"@esbuild/linux-ppc64": "0.25.2",
"@esbuild/linux-riscv64": "0.25.2",
"@esbuild/linux-s390x": "0.25.2",
"@esbuild/linux-x64": "0.25.2",
"@esbuild/netbsd-arm64": "0.25.2",
"@esbuild/netbsd-x64": "0.25.2",
"@esbuild/openbsd-arm64": "0.25.2",
"@esbuild/openbsd-x64": "0.25.2",
"@esbuild/sunos-x64": "0.25.2",
"@esbuild/win32-arm64": "0.25.2",
"@esbuild/win32-ia32": "0.25.2",
"@esbuild/win32-x64": "0.25.2"
}
},
"node_modules/escape-latex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
"integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==",
"license": "MIT"
},
"node_modules/fraction.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.2.2.tgz",
"integrity": "sha512-uXBDv5knpYmv/2gLzWQ5mBHGBRk9wcKTeWu6GLTUEQfjCxO09uM/mHDrojlL+Q1mVGIIFo149Gba7od1XPgSzQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fuzzysort": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
"integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
"license": "MIT"
},
"node_modules/ical.js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.1.0.tgz",
"integrity": "sha512-BOVfrH55xQ6kpS3muGvIXIg2l7p+eoe12/oS7R5yrO3TL/j/bLsR0PR+tYQESFbyTbvGgPHn9zQ6tI4FWyuSaQ==",
"license": "MPL-2.0"
},
"node_modules/javascript-natural-sort": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
"integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
"license": "MIT"
},
"node_modules/mathjs": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.4.0.tgz",
"integrity": "sha512-CpoYDhNENefjIG9wU9epr+0pBHzlaySfpWcblZdAf5qXik/j/U8eSmx/oNbmXO0F5PyfwPGVD/wK4VWsTho1SA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.26.10",
"complex.js": "^2.2.5",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
"fraction.js": "^5.2.1",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
"typed-function": "^4.2.1"
},
"bin": {
"mathjs": "bin/cli.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"license": "MIT"
},
"node_modules/typed-function": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz",
"integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View file

@ -1,11 +0,0 @@
{
"dependencies": {
"fuzzysort": "^3.1.0",
"ical.js": "^2.1.0",
"mathjs": "^14.0.1"
},
"devDependencies": {
"esbuild": "^0.25.2",
"typescript": "5.7.3"
}
}

View file

@ -1,17 +0,0 @@
#!/bin/fish
cd (dirname (status filename)) || exit 1
set -q XDG_RUNTIME_DIR && set -l bundle_dir $XDG_RUNTIME_DIR || set -l bundle_dir /tmp
set -q XDG_CACHE_HOME && set -l cache_dir $XDG_CACHE_HOME/caelestia || set -l cache_dir $HOME/.cache/caelestia
set -q XDG_STATE_HOME && set -l state_dir $XDG_STATE_HOME/caelestia || set -l state_dir $HOME/.local/state/caelestia
mkdir -p $cache_dir
set -q DEBUG || set -l minify --minify-identifiers
./node_modules/.bin/esbuild app.tsx --bundle --minify-whitespace $minify --outfile=$bundle_dir/caelestia.js \
--external:console --external:system --external:cairo --external:gettext --external:'file://*' --external:'gi://*' --external:'resource://*' \
--define:HOME=\"$HOME\" --define:CACHE=\"$cache_dir\" --define:STATE=\"$state_dir\" --define:SRC=\"(pwd)\" --format=esm --platform=neutral --main-fields=module,main
gjs -m $bundle_dir/caelestia.js

View file

@ -1,21 +0,0 @@
@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;
}

View file

@ -1,47 +0,0 @@
@use "sass:color";
@use "scheme";
@use "font";
$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, $alpha: 1, $width: 1, $style: solid, $force: false) {
@if $force or scheme.$borders {
border: s($width) $style color.change($colour, $alpha: $alpha);
}
}
@mixin shadow($colour: black, $alpha: 0.64, $x: 0, $y: 0, $blur: 3, $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);
& > *:not(:last-child) {
margin-#{$dir}: s($val);
}
}
@mixin element-decel($duration: 200ms) {
transition: $duration cubic-bezier(0, 0.55, 0.45, 1);
}
@mixin fluent-decel($duration: 200ms) {
transition: $duration cubic-bezier(0.1, 1, 0, 1);
}
@mixin overshot {
transition-timing-function: cubic-bezier(0.05, 0.9, 0.1, 1.1);
}
@mixin ease-in-out {
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
}

View file

@ -1,394 +0,0 @@
@use "sass:color";
@use "lib";
@use "scheme";
@use "font";
@mixin bar-spacing($vertical: false) {
@include lib.spacing(10, $vertical);
& > * {
@include lib.spacing(10, $vertical);
}
}
.bar {
@include font.mono;
font-size: lib.s(14);
label.icon {
font-size: lib.s(18);
}
.screen-corner {
background-color: transparent;
}
.os-icon {
color: scheme.$yellow;
}
.active-window {
color: scheme.$pink;
}
.media-playing {
color: scheme.$lavender;
icon {
font-size: lib.s(16);
}
}
.workspaces {
& > button {
@include lib.rounded(100);
@include lib.element-decel;
font-size: lib.s(13);
font-weight: bold;
&.focused {
background-color: scheme.$mauve;
}
}
&:not(.labels-shown) > button {
min-width: lib.s(8);
min-height: lib.s(8);
background-color: scheme.$surface1;
&.occupied {
background-color: scheme.$overlay1;
}
&.focused {
background-color: scheme.$mauve;
}
}
&.labels-shown > button {
color: color.change(scheme.$overlay1, $alpha: 1);
.icon {
font-size: lib.s(13);
color: color.change(scheme.$subtext0, $alpha: 1);
}
&.occupied {
color: color.mix(scheme.$text, scheme.$mauve, 50%);
}
&.focused {
color: color.change(scheme.$base, $alpha: 1);
.icon {
color: color.change(scheme.$surface0, $alpha: 1);
}
}
}
}
.tray {
font-size: lib.s(15);
color: scheme.$text;
}
.status-icons {
color: scheme.$rosewater;
}
.pkg-updates {
color: scheme.$blue;
}
.notif-count {
color: scheme.$mauve;
}
.battery {
color: scheme.$teal;
&.charging {
color: scheme.$success;
}
&.low {
color: scheme.$error;
}
}
.date-time {
color: scheme.$peach;
}
.power {
@include lib.element-decel;
@include font.icon;
color: scheme.$red;
font-weight: bold;
font-size: lib.s(16);
&:hover,
&:focus {
color: color.change(scheme.$red, $alpha: 0.8);
}
&:active {
color: color.change(scheme.$red, $alpha: 0.6);
}
}
&.horizontal {
margin: 10px 10px 0 10px;
.module {
padding: lib.s(5) lib.s(10);
@include lib.spacing;
}
.os-icon {
padding-right: lib.s(14);
}
.media-playing {
@include lib.spacing(8);
}
.workspaces {
@include lib.spacing(10);
& > .focused {
min-width: lib.s(30);
}
&.labels-shown > button {
padding: lib.s(3) lib.s(8);
&.focused {
min-width: 0;
padding-left: lib.s(20);
padding-right: lib.s(20);
}
.icon {
margin-left: lib.s(5);
&:nth-child(2) {
margin-left: lib.s(12);
}
}
}
}
.tray {
@include lib.spacing(10);
}
.status-icons .bluetooth {
@include lib.spacing(10);
// The spacing doesn't look right for some reason so this
& > :first-child:not(:last-child) {
margin-right: lib.s(5);
}
}
}
&.vertical {
margin: 10px 0 10px 10px;
.module {
padding: lib.s(8);
@include lib.spacing($vertical: true);
}
.os-icon > * {
margin-left: lib.s(-5);
}
.media-playing {
@include lib.spacing(8, true);
}
.workspaces {
@include lib.spacing(10, true);
& > .focused {
min-height: lib.s(30);
}
&.labels-shown > button {
padding: lib.s(3) lib.s(8);
&.focused {
min-height: 0;
padding-top: lib.s(15);
padding-bottom: lib.s(15);
}
.icon {
margin-top: lib.s(2);
&:nth-child(2) {
margin-top: lib.s(3);
}
}
}
}
.tray {
@include lib.spacing(10, true);
}
.status-icons .bluetooth {
@include lib.spacing(10, true);
}
}
&.gaps {
padding-right: lib.s(3);
.module {
@include lib.rounded(8);
background-color: scheme.$base;
}
.screen-corner {
background-color: transparent;
}
.os-icon {
@include lib.border(scheme.$yellow);
@if not scheme.$borders {
@include lib.shadow;
background-color: scheme.$yellow;
color: scheme.$base;
}
}
.power {
@include lib.border(scheme.$red);
@if not scheme.$borders {
@include lib.shadow;
background-color: scheme.$red;
color: scheme.$base;
}
}
&.horizontal {
@include bar-spacing;
.workspaces {
padding: lib.s(6) lib.s(15);
}
}
&.vertical {
@include bar-spacing(true);
.workspaces {
padding: lib.s(15) lib.s(6);
}
}
}
&.panel {
@include lib.rounded(20);
@include lib.border(scheme.$primary, 0.5, 2);
background-color: scheme.$base;
.os-icon {
font-size: lib.s(16);
}
&.horizontal {
padding: lib.s(5) lib.s(10);
}
&.vertical {
padding: lib.s(10) lib.s(5);
.os-icon > * {
margin-left: lib.s(-7);
}
}
}
&.embedded {
$-rounding: 23;
margin: 0;
.module {
background-color: scheme.$base;
}
.screen-corner {
@include lib.rounded($-rounding);
background-color: scheme.$base;
}
&.horizontal {
.module {
padding: lib.s(10) lib.s(10);
padding-left: lib.s(15);
}
.before-spacer {
border-bottom-right-radius: lib.s($-rounding);
padding-right: lib.s(15);
}
.after-spacer {
border-bottom-left-radius: lib.s($-rounding);
}
.workspaces.odd {
margin-right: -1px;
}
.last {
padding-right: lib.s(12);
}
}
&.vertical {
.module {
padding: lib.s(8) lib.s(10);
}
.before-spacer {
border-bottom-right-radius: lib.s($-rounding);
padding-bottom: lib.s(15);
}
.after-spacer {
border-top-right-radius: lib.s($-rounding);
padding-top: lib.s(15);
}
.workspaces.odd {
margin-bottom: -1px;
}
.first {
padding-top: lib.s(12);
}
.last {
padding-bottom: lib.s(12);
}
}
}
}

View file

@ -1,68 +0,0 @@
@use "scheme";
@use "lib";
@use "font";
label.icon {
@include font.icon;
}
.screen-corner {
@include lib.rounded(15);
background-color: scheme.$base;
}
.notification {
.inner {
@include font.main;
color: scheme.$text;
padding: lib.s(10) lib.s(12);
@include lib.spacing($vertical: true);
}
.header,
.content {
padding: 0 lib.s(5);
}
.header {
@include font.mono;
@include lib.spacing(8);
}
.content {
@include lib.spacing(10);
}
.app-icon {
font-size: lib.s(18);
}
.image {
@include lib.rounded(10);
background-size: cover;
background-position: center;
margin-top: lib.s(3);
min-width: lib.s(64);
min-height: lib.s(64);
&.small {
min-width: lib.s(48);
min-height: lib.s(48);
}
}
.summary {
@include font.title;
font-size: lib.s(16);
}
.body {
font-size: lib.s(14);
color: scheme.$subtext0;
}
}

View file

@ -1,332 +0,0 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
@mixin launcher($mode, $colour) {
&.#{$mode} {
@include lib.border($colour, 0.7, 2);
label.icon {
color: $colour;
}
.separator {
background-color: color.change($colour, $alpha: 0.7);
}
.result:focus {
color: $colour;
.sublabel {
color: color.mix(scheme.$subtext0, $colour, 60%);
}
}
&.lines {
.search-bar {
.mode {
@include lib.border($colour, $width: 2, $force: true);
}
.entry {
border-bottom: lib.s(2) solid $colour;
}
}
.mode-switcher .mode {
&.selected {
border-top: lib.s(2) solid $colour;
}
&:hover,
&:focus {
color: $colour;
}
&:active {
color: color.mix($colour, scheme.$base, 80%);
}
}
.result:focus {
border-bottom: lib.s(2) solid $colour;
}
}
&.round {
.search-bar .mode {
background-color: $colour;
}
.mode-switcher .mode.selected {
color: $colour;
}
}
}
}
.launcher {
@include lib.rounded(10);
@include lib.element-decel;
@include lib.shadow;
@include font.mono;
background-color: scheme.$base;
color: scheme.$text;
padding: lib.s(14);
font-size: lib.s(16);
min-width: lib.s(700);
min-height: lib.s(420);
@include launcher(apps, scheme.$primary);
@include launcher(files, scheme.$secondary);
@include launcher(math, scheme.$tertiary);
.search-bar {
margin-bottom: lib.s(15);
@include lib.spacing(10);
.mode {
@include lib.rounded(5);
@include lib.element-decel;
padding: lib.s(5) lib.s(10);
@include lib.spacing(3);
.icon {
font-size: lib.s(20);
}
}
}
.mode-switcher .mode {
@include lib.element-decel;
padding-top: lib.s(10);
.icon {
font-size: lib.s(24);
}
& > box {
@include lib.spacing(10);
}
}
.result {
@include lib.element-decel;
padding-left: lib.s(10);
.icon {
font-size: lib.s(32);
}
.has-sublabel {
padding: lib.s(3) 0;
}
.sublabel {
@include lib.element-decel;
color: scheme.$subtext0;
font-size: lib.s(14);
}
& > box {
@include lib.spacing(10);
}
&.italic {
font-style: italic;
}
&:hover {
background-color: scheme.$surface0;
}
&:active {
background-color: color.mix(scheme.$surface0, scheme.$surface1, 70%);
}
}
.math {
.preview > * {
margin-bottom: lib.s(10);
}
.result {
@include lib.spacing(10);
}
}
.swatches {
margin-bottom: lib.s(5);
@include lib.spacing(3);
}
.swatch {
@include lib.rounded(100);
min-width: lib.s(16);
min-height: lib.s(16);
&.big {
min-height: lib.s(32);
&.left {
@include lib.border(scheme.$overlay0, 0.3, $force: true);
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&.right {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
.wallpaper {
.thumbnail {
background-size: cover;
background-position: center;
}
&.compact .thumbnail {
@include lib.rounded(100);
min-width: lib.s(32);
min-height: lib.s(32);
}
&:not(.compact) {
@include lib.spacing(3, true);
.thumbnail {
@include lib.rounded(10);
& > * {
background-size: cover;
background-position: center;
&:first-child {
@include lib.rounded(10, $tr: 0, $br: 0);
}
&:last-child {
@include lib.rounded(10, $tl: 0, $bl: 0);
}
}
}
}
&.medium .thumbnail {
min-height: lib.s(96);
}
&.large .thumbnail {
min-height: lib.s(160);
}
}
&.lines {
.mode-switcher .mode {
border-top: lib.s(2) solid transparent;
}
.result {
border-bottom: lib.s(2) solid transparent;
}
.wallpaper {
padding-top: lib.s(5);
&.compact {
padding-top: lib.s(3);
padding-bottom: lib.s(3);
}
}
}
&.round {
.search-bar {
.mode {
@include lib.rounded(10);
color: color.change(scheme.$base, $alpha: 1);
.icon {
color: color.change(scheme.$base, $alpha: 1);
}
}
.entry {
@include lib.rounded(10);
padding: lib.s(5) lib.s(10);
background-color: color.mix(scheme.$base, scheme.$surface0, 30%);
}
}
.mode-switcher {
padding-top: lib.s(10);
@include lib.spacing(10);
.mode {
@include lib.rounded(10);
padding: lib.s(5);
background-color: color.mix(scheme.$base, scheme.$surface0, 50%);
&:not(.selected) .icon {
color: scheme.$text;
}
&:hover,
&:focus {
background-color: scheme.$surface0;
}
&:active {
background-color: color.mix(scheme.$surface0, scheme.$surface1, 70%);
}
}
}
.result {
@include lib.rounded(10);
padding-right: lib.s(10);
margin-bottom: lib.s(5);
margin-right: lib.s(5);
&:focus {
background-color: scheme.$surface0;
}
}
.math .preview .result {
@include lib.rounded(20);
background-color: scheme.$surface0;
padding: lib.s(5) lib.s(10);
}
.wallpaper-container {
padding-right: 0;
.wallpaper:not(.compact) {
padding-top: lib.s(8);
padding-bottom: lib.s(3);
}
}
}
}

View file

@ -1,139 +0,0 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
.mediadisplay {
@include font.mono;
background-color: scheme.$base;
color: scheme.$text;
padding: lib.s(20);
min-height: lib.s(200);
.visualiser {
background-color: scheme.$primary; // Visualiser colour
margin-right: lib.s(5); // Gaps between bars
min-width: lib.s(10); // Bar width
color: scheme.$error;
font-size: lib.s(24);
font-weight: bold;
}
.cover-art {
@include lib.rounded(10);
@include lib.element-decel;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-width: lib.s(196);
min-height: lib.s(196);
font-size: lib.s(96);
font-weight: bold;
background-color: scheme.$surface0;
color: scheme.$subtext0;
}
.details {
@include font.title;
font-size: lib.s(14);
margin-top: lib.s(5);
margin-left: lib.s(15);
.title {
font-size: lib.s(28);
font-weight: 500;
color: scheme.$text;
}
.artist {
font-size: lib.s(18);
color: scheme.$secondary;
}
.controls {
@include lib.rounded(1000);
@include font.icon;
margin-top: lib.s(10);
background-color: color.change(scheme.$overlay0, $alpha: 0.4);
font-size: lib.s(28);
padding: lib.s(3) lib.s(8);
@include lib.spacing(10);
& > button {
@include lib.element-decel;
&:hover,
&:focus {
color: color.mix(scheme.$subtext1, scheme.$subtext0, 50%);
}
&:active {
color: scheme.$subtext0;
}
&:disabled {
color: scheme.$subtext0;
}
}
}
}
.center-module {
@include lib.rounded(20);
margin: 0 lib.s(40);
background-color: color.change(scheme.$surface1, $alpha: 0.4);
}
.selector {
@include lib.rounded(15);
@include lib.element-decel;
background-color: color.change(scheme.$overlay0, $alpha: 0.4);
padding: lib.s(8) lib.s(15);
.identity {
@include lib.spacing(8);
}
button {
@include lib.element-decel;
&:hover,
&:focus {
color: color.mix(scheme.$subtext1, scheme.$subtext0, 50%);
}
&:active {
color: scheme.$subtext0;
}
}
.list > button {
margin-top: lib.s(5);
color: scheme.$subtext1;
&:hover,
&:focus {
color: color.mix(scheme.$subtext1, scheme.$subtext0, 50%);
}
&:active {
color: scheme.$subtext0;
}
}
}
.time {
@include lib.rounded(1000);
font-size: lib.s(16);
background-color: color.change(scheme.$overlay0, $alpha: 0.4);
padding: lib.s(5) lib.s(10);
margin-bottom: lib.s(10);
}
}

View file

@ -1,65 +0,0 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
.navbar {
@include font.mono;
background-color: scheme.$base;
button {
color: scheme.$subtext1;
&:hover,
&:focus {
color: scheme.$subtext0;
}
&:active {
color: color.change(scheme.$overlay2, $alpha: 1);
}
&.current {
.nav-button {
background-color: scheme.$primary;
color: color.change(scheme.$base, $alpha: 1);
}
&:hover .nav-button,
&:focus .nav-button {
background-color: color.mix(scheme.$primary, scheme.$base, 80%);
}
&:active .nav-button {
background-color: color.mix(scheme.$primary, scheme.$base, 70%);
}
}
&:first-child .nav-button {
margin-top: lib.s(10);
}
&:last-child .nav-button {
margin-bottom: lib.s(10);
}
}
.nav-button {
@include lib.rounded(20);
@include lib.element-decel;
padding: lib.s(10) lib.s(8);
margin: lib.s(5) lib.s(8);
min-width: lib.s(40);
.icon {
font-size: lib.s(28);
}
.label {
font-size: lib.s(12);
margin-bottom: lib.s(5);
}
}
}

View file

@ -1,51 +0,0 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
@mixin popup($colour) {
.separator {
background-color: $colour;
}
.image {
@include lib.border($colour, 0.05);
}
}
.notifpopups {
min-width: lib.s(425);
padding-left: lib.s(10); // So notifications can overshoot for init animation
padding-right: lib.s(5);
padding-top: lib.s(5);
.notification {
.wrapper {
padding: lib.s(5); // For shadow
}
.inner {
@include lib.rounded(10);
@include lib.shadow;
background-color: scheme.$base;
&.low {
@include popup(scheme.$overlay0);
}
&.normal {
@include popup(scheme.$primary);
}
&.critical {
@include lib.border(scheme.$error, 0.5);
@include popup(scheme.$error);
@if not scheme.$borders {
background-color: color.mix(scheme.$base, scheme.$error, 95%);
}
}
}
}
}

View file

@ -1,51 +0,0 @@
@use "scheme";
@use "lib";
@use "font";
.brightness,
.volume {
@include lib.rounded(8);
@include lib.border(scheme.$overlay0, 0.1);
@include lib.shadow;
@include font.mono;
background-color: scheme.$base;
font-size: lib.s(16);
padding: lib.s(3);
.inner {
@include lib.fluent-decel(1000ms);
min-width: lib.s(300);
min-height: lib.s(32);
background-color: scheme.$teal;
}
}
.volume .inner.mute {
background-color: scheme.$overlay0;
}
.lock {
@include lib.rounded(10);
@include lib.border(scheme.$overlay0, 0.1);
@include lib.shadow;
@include lib.element-decel;
@include font.mono;
min-width: lib.s(80);
min-height: lib.s(80);
padding: lib.s(10);
background-color: scheme.$base;
color: scheme.$overlay0;
font-size: lib.s(16);
font-weight: bold;
&.enabled {
color: scheme.$text;
}
.icon {
font-size: lib.s(48);
}
}

View file

@ -1,31 +0,0 @@
$rosewater: #edcbc5;
$flamingo: #d3a4a4;
$pink: #d792c6;
$mauve: #c678dd;
$red: #be5046;
$maroon: #e06c75;
$peach: #d19a66;
$yellow: #e5c07b;
$green: #98c379;
$teal: #56b6c2;
$sky: #90ccd7;
$sapphire: #389dcc;
$blue: #61afef;
$lavender: #8e98d9;
$text: #abb2bf;
$subtext1: #95a0b5;
$subtext0: #838b9c;
$overlay2: #767f8f;
$overlay1: #666e7c;
$overlay0: #5c6370;
$surface2: #4b5263;
$surface1: #3c414f;
$surface0: #30343e;
$base: #282c34;
$mantle: #21242b;
$crust: #1e2126;
$success: #98c379;
$error: #be5046;
$primary: #d19a66;
$secondary: #61afef;
$tertiary: #98c379;

View file

@ -1,58 +0,0 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
.session {
background-color: rgba(0, 0, 0, 0.3);
.inner {
@include lib.rounded(10);
@include lib.border(scheme.$flamingo, 0.5, 2);
@include lib.shadow;
@include font.mono;
background-color: scheme.$mantle;
color: scheme.$text;
padding: lib.s(18) lib.s(20);
@include lib.spacing(10, true);
& > * {
@include lib.spacing(20);
}
.item {
font-size: lib.s(14);
@include lib.spacing($vertical: true);
button {
@include lib.rounded(100);
@include lib.element-decel(300ms);
background-color: color.change(scheme.$surface0, $alpha: 0.4);
min-width: lib.s(100);
min-height: lib.s(100);
font-size: lib.s(32);
&:hover {
background-color: color.change(scheme.$surface1, $alpha: 0.4);
}
&:focus {
background-color: color.mix(scheme.$flamingo, scheme.$base, 70%);
color: color.change(scheme.$base, $alpha: 1);
}
&:active {
background-color: color.mix(scheme.$flamingo, scheme.$base, 50%);
}
}
.label {
font-weight: 500;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,136 +0,0 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
separator,
.separator {
@include lib.rounded(2);
min-width: lib.s(0.5);
min-height: lib.s(0.5);
}
@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(10);
@include lib.border(scheme.$blue, 0.4);
@include font.mono;
padding: lib.s(8);
background-color: scheme.$surface0;
color: scheme.$text;
font-size: lib.s(14);
& > menuitem {
@include lib.element-decel;
@include lib.rounded(8);
padding: lib.s(5) lib.s(8);
&: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 {
background-color: color.mix(scheme.$blue, scheme.$surface0, 70%);
margin: lib.s(5) 0;
}
}
tooltip,
.tooltip {
@include lib.rounded(10);
@include lib.border(scheme.$primary, 0.7);
@include font.mono;
background-color: scheme.$surface0;
color: scheme.$text;
padding: lib.s(5) lib.s(10);
}
tooltip {
@include -appear(200ms);
}
.tooltip {
@include lib.shadow;
margin: lib.s(3);
}
scrollbar {
slider {
@include lib.rounded(1000);
@include lib.element-decel;
min-width: lib.s(3);
min-height: lib.s(30);
background-color: color.change(scheme.$overlay0, $alpha: 0.6);
&:hover,
&:focus {
min-width: lib.s(6);
background-color: color.change(scheme.$overlay0, $alpha: 0.7);
}
&:active {
background-color: color.change(scheme.$overlay1, $alpha: 0.8);
}
}
}
popover {
@include -appear;
@include lib.rounded(10);
@include lib.border(scheme.$yellow, 0.4);
@include font.mono;
padding: lib.s(8);
background-color: color.mix(scheme.$base, scheme.$yellow, 90%);
color: scheme.$text;
font-size: lib.s(14);
}

View file

@ -1,183 +0,0 @@
import { Astal } from "astal/gtk3";
export default {
style: {
transparency: "normal", // One of "off", "low", "normal", "high"
borders: true,
vibrant: false, // Extra saturation
},
config: {
notifyOnError: true,
},
// Modules
bar: {
vertical: true,
style: "gaps", // One of "gaps", "panel", "embedded"
layout: {
type: "centerbox", // One of "centerbox", "flowbox"
centerbox: {
start: ["osIcon", "activeWindow", "mediaPlaying", "brightnessSpacer"],
center: ["workspaces"],
end: [
"volumeSpacer",
"tray",
"statusIcons",
"pkgUpdates",
"notifCount",
"battery",
"dateTime",
"power",
],
},
flowbox: [
"osIcon",
"workspaces",
"brightnessSpacer",
"activeWindow",
"volumeSpacer",
"dateTime",
"tray",
"battery",
"statusIcons",
"notifCount",
"power",
],
},
modules: {
workspaces: {
shown: 5,
showLabels: false,
labels: ["󰮯", "󰮯", "󰮯", "󰮯", "󰮯"],
xalign: -1,
showWindows: false,
},
dateTime: {
format: "%d/%m/%y %R",
detailedFormat: "%c",
},
},
},
launcher: {
style: "lines", // One of "lines", "round"
actionPrefix: ">", // Prefix for launcher actions
apps: {
maxResults: 30, // Actual max results, -1 for infinite
},
files: {
maxResults: 40, // Actual max results, -1 for infinite
fdOpts: ["-a", "-t", "f"], // Options to pass to `fd`
shortenThreshold: 30, // Threshold to shorten paths in characters
},
math: {
maxResults: 40, // Actual max results, -1 for infinite
},
todo: {
notify: true,
},
wallpaper: {
maxResults: 20, // Actual max results, -1 for infinite
showAllEmpty: true, // Show all wallpapers when search is empty
style: "medium", // One of "compact", "medium", "large"
},
disabledActions: ["logout", "shutdown", "reboot", "hibernate"], // Actions to hide, see launcher/actions.tsx for available actions
},
notifpopups: {
maxPopups: -1,
expire: false,
agoTime: true, // Whether to show time in ago format, e.g. 10 mins ago, or raw time, e.g. 10:42
},
osds: {
volume: {
position: Astal.WindowAnchor.RIGHT, // Top = 2, Right = 4, Left = 8, Bottom = 16
margin: 20,
hideDelay: 1500,
showValue: true,
},
brightness: {
position: Astal.WindowAnchor.LEFT, // Top = 2, Right = 4, Left = 8, Bottom = 16
margin: 20,
hideDelay: 1500,
showValue: true,
},
lock: {
spacing: 5,
caps: {
hideDelay: 1000,
},
num: {
hideDelay: 1000,
},
},
},
sidebar: {
showOnStartup: false,
modules: {
headlines: {
enabled: true,
},
},
},
navbar: {
persistent: false, // Whether to show all the time or only on hover
appearWidth: 10, // The width in pixels of the hover area for the navbar to show up
showLabels: false, // Whether to show labels for active buttons
},
// Services
math: {
maxHistory: 100,
},
updates: {
interval: 900000,
},
weather: {
interval: 600000,
apiKey: "", // An API key from https://weatherapi.com for accessing weather data
location: "", // Location as a string or empty to autodetect
imperial: false,
},
cpu: {
interval: 2000,
},
gpu: {
interval: 2000,
},
memory: {
interval: 5000,
},
storage: {
interval: 5000,
},
wallpapers: {
paths: [
{
recursive: true, // Whether to search recursively
path: "~/Pictures/Wallpapers", // Path to search
threshold: 0.8, // The threshold to filter wallpapers by size (e.g. 0.8 means wallpaper must be at least 80% of the screen size), 0 to disable
},
],
},
calendar: {
webcals: [] as string[], // An array of urls to ICS files which you can curl
upcomingDays: 7, // Number of days which count as upcoming
notify: true,
},
thumbnailer: {
maxAttempts: 5,
timeBetweenAttempts: 300,
defaults: {
width: 100,
height: 100,
exact: true,
},
},
news: {
apiKey: "", // An API key from https://newsdata.io for accessing news
countries: ["current"], // A list of country codes or "current" for the current location
categories: ["business", "top", "technology", "world"], // A list of news categories to filter by
languages: ["en"], // A list of languages codes to filter by
domains: [] as string[], // A list of news domains to pull from, see https://newsdata.io/news-sources for available domains
excludeDomains: ["news.google.com"], // A list of news domains to exclude, e.g. bbc.co.uk
timezone: "", // A timezone to filter by, e.g. "America/New_York", see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
pages: 3, // Number of pages to pull (each page is 10 articles)
},
};

View file

@ -1,124 +0,0 @@
import { notify } from "@/utils/system";
import { Gio, GLib, monitorFile, readFileAsync, Variable, writeFileAsync } from "astal";
import config from ".";
import { loadStyleAsync } from "../../app";
import defaults from "./defaults";
import types from "./types";
type Settings<T> = { [P in keyof T]: T[P] extends object & { length?: never } ? Settings<T[P]> : Variable<T[P]> };
const CONFIG = `${GLib.get_user_config_dir()}/caelestia/shell.json`;
const warn = (msg: string) => {
console.warn(`[CONFIG] ${msg}`);
if (config.config.notifyOnError.get())
notify({
summary: "Invalid config",
body: msg,
icon: "dialog-error-symbolic",
urgency: "critical",
});
};
const isObject = (o: any): o is object => typeof o === "object" && o !== null && !Array.isArray(o);
const isCorrectType = (v: any, type: string | string[] | number[], path: string) => {
if (Array.isArray(type)) {
// type is array of valid values
if (!type.includes(v as never)) {
warn(`Invalid value for ${path}: ${v} != ${type.map(v => `"${v}"`).join(" | ")}`);
return false;
}
} else if (type.startsWith("array of ")) {
// Array of ...
if (Array.isArray(v)) {
// Remove invalid items but always return true
const arrType = type.slice(9);
try {
// Recursively check type
const type = JSON.parse(arrType);
if (Array.isArray(type)) {
v.splice(0, v.length, ...v.filter((item, i) => isCorrectType(item, type, `${path}[${i}]`)));
} else {
const valid = v.filter((item, i) =>
Object.entries(type).every(([k, t]) => {
if (!item.hasOwnProperty(k)) {
warn(`Invalid shape for ${path}[${i}]: ${JSON.stringify(item)} != ${arrType}`);
return false;
}
return isCorrectType(item[k], t as any, `${path}[${i}].${k}`);
})
);
v.splice(0, v.length, ...valid); // In-place filter
}
} catch {
const valid = v.filter((item, i) => {
if (typeof item !== arrType) {
warn(`Invalid type for ${path}[${i}]: ${typeof item} != ${arrType}`);
return false;
}
return true;
});
v.splice(0, v.length, ...valid); // In-place filter
}
} else {
// Type is array but value is not
warn(`Invalid type for ${path}: ${typeof v} != ${type}`);
return false;
}
} else if (typeof v !== type) {
// Value is not correct type
warn(`Invalid type for ${path}: ${typeof v} != ${type}`);
return false;
}
return true;
};
const deepMerge = <T extends object, U extends object>(a: T, b: U, path = ""): T & U => {
const merged: { [k: string]: any } = { ...b };
for (const [k, v] of Object.entries(a)) {
if (b.hasOwnProperty(k)) {
const bv = b[k as keyof U];
if (isObject(v) && isObject(bv)) merged[k] = deepMerge(v, bv, `${path}${k}.`);
else if (!isCorrectType(bv, types[path + k], path + k)) merged[k] = v;
} else merged[k] = v;
}
return merged as any;
};
export const convertSettings = <T extends object>(obj: T): Settings<T> =>
Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, isObject(v) ? convertSettings(v) : Variable(v)])) as any;
const updateSection = (from: { [k: string]: any }, to: { [k: string]: any }, path = "") => {
for (const [k, v] of Object.entries(from)) {
if (to.hasOwnProperty(k)) {
if (isObject(v)) updateSection(v, to[k], `${path}${k}.`);
else if (!Array.isArray(v) || JSON.stringify(to[k].get()) !== JSON.stringify(v)) to[k].set(v);
} else warn(`Unknown config key: ${path}${k}`);
}
};
export const updateConfig = async () => {
if (GLib.file_test(CONFIG, GLib.FileTest.EXISTS))
updateSection(deepMerge(defaults, JSON.parse(await readFileAsync(CONFIG))), config);
else updateSection(defaults, config);
await loadStyleAsync();
console.log("[LOG] Config updated");
};
export const initConfig = async () => {
monitorFile(CONFIG, (_, e) => {
if (e === Gio.FileMonitorEvent.CHANGES_DONE_HINT || e === Gio.FileMonitorEvent.DELETED)
updateConfig().catch(warn);
});
await updateConfig().catch(warn);
};
export const setConfig = async (path: string, value: any) => {
const conf = JSON.parse(await readFileAsync(CONFIG));
let obj = conf;
for (const p of path.split(".").slice(0, -1)) obj = obj[p];
obj[path.split(".").at(-1)!] = value;
await writeFileAsync(CONFIG, JSON.stringify(conf, null, 4));
};

View file

@ -1,26 +0,0 @@
import defaults from "./defaults";
import { convertSettings } from "./funcs";
const config = convertSettings(defaults);
export const {
style,
bar,
launcher,
notifpopups,
osds,
sidebar,
navbar,
math,
updates,
weather,
cpu,
gpu,
memory,
storage,
wallpapers,
calendar,
thumbnailer,
news,
} = config;
export default config;

View file

@ -1,336 +0,0 @@
export const BAR_MODULES = [
"osIcon",
"activeWindow",
"mediaPlaying",
"brightnessSpacer",
"workspaces",
"volumeSpacer",
"tray",
"statusIcons",
"pkgUpdates",
"notifCount",
"battery",
"dateTime",
"power",
];
export const NEWS_COUNTRIES = [
"af", // Afghanistan
"al", // Albania
"dz", // Algeria
"ad", // Andorra
"ao", // Angola
"ar", // Argentina
"am", // Armenia
"au", // Australia
"at", // Austria
"az", // Azerbaijan
"bs", // Bahamas
"bh", // Bahrain
"bd", // Bangladesh
"bb", // Barbados
"by", // Belarus
"be", // Belgium
"bz", // Belize
"bj", // Benin
"bm", // Bermuda
"bt", // Bhutan
"bo", // Bolivia
"ba", // Bosnia And Herzegovina
"bw", // Botswana
"br", // Brazil
"bn", // Brunei
"bg", // Bulgaria
"bf", // Burkina fasco
"bi", // Burundi
"kh", // Cambodia
"cm", // Cameroon
"ca", // Canada
"cv", // Cape Verde
"ky", // Cayman Islands
"cf", // Central African Republic
"td", // Chad
"cl", // Chile
"cn", // China
"co", // Colombia
"km", // Comoros
"cg", // Congo
"ck", // Cook islands
"cr", // Costa Rica
"hr", // Croatia
"cu", // Cuba
"cw", // Curaçao
"cy", // Cyprus
"cz", // Czech republic
"dk", // Denmark
"dj", // Djibouti
"dm", // Dominica
"do", // Dominican republic
"cd", // DR Congo
"ec", // Ecuador
"eg", // Egypt
"sv", // El Salvador
"gq", // Equatorial Guinea
"er", // Eritrea
"ee", // Estonia
"sz", // Eswatini
"et", // Ethiopia
"fj", // Fiji
"fi", // Finland
"fr", // France
"pf", // French polynesia
"ga", // Gabon
"gm", // Gambia
"ge", // Georgia
"de", // Germany
"gh", // Ghana
"gi", // Gibraltar
"gr", // Greece
"gd", // Grenada
"gt", // Guatemala
"gn", // Guinea
"gy", // Guyana
"ht", // Haiti
"hn", // Honduras
"hk", // Hong kong
"hu", // Hungary
"is", // Iceland
"in", // India
"id", // Indonesia
"ir", // Iran
"iq", // Iraq
"ie", // Ireland
"il", // Israel
"it", // Italy
"ci", // Ivory Coast
"jm", // Jamaica
"jp", // Japan
"je", // Jersey
"jo", // Jordan
"kz", // Kazakhstan
"ke", // Kenya
"ki", // Kiribati
"xk", // Kosovo
"kw", // Kuwait
"kg", // Kyrgyzstan
"la", // Laos
"lv", // Latvia
"lb", // Lebanon
"ls", // Lesotho
"lr", // Liberia
"ly", // Libya
"li", // Liechtenstein
"lt", // Lithuania
"lu", // Luxembourg
"mo", // Macau
"mk", // Macedonia
"mg", // Madagascar
"mw", // Malawi
"my", // Malaysia
"mv", // Maldives
"ml", // Mali
"mt", // Malta
"mh", // Marshall Islands
"mr", // Mauritania
"mu", // Mauritius
"mx", // Mexico
"fm", // Micronesia
"md", // Moldova
"mc", // Monaco
"mn", // Mongolia
"me", // Montenegro
"ma", // Morocco
"mz", // Mozambique
"mm", // Myanmar
"na", // Namibia
"nr", // Nauru
"np", // Nepal
"nl", // Netherland
"nc", // New caledonia
"nz", // New zealand
"ni", // Nicaragua
"ne", // Niger
"ng", // Nigeria
"kp", // North korea
"no", // Norway
"om", // Oman
"pk", // Pakistan
"pw", // Palau
"ps", // Palestine
"pa", // Panama
"pg", // Papua New Guinea
"py", // Paraguay
"pe", // Peru
"ph", // Philippines
"pl", // Poland
"pt", // Portugal
"pr", // Puerto rico
"qa", // Qatar
"ro", // Romania
"ru", // Russia
"rw", // Rwanda
"lc", // Saint lucia
"sx", // Saint martin(dutch)
"ws", // Samoa
"sm", // San Marino
"st", // Sao tome and principe
"sa", // Saudi arabia
"sn", // Senegal
"rs", // Serbia
"sc", // Seychelles
"sl", // Sierra Leone
"sg", // Singapore
"sk", // Slovakia
"si", // Slovenia
"sb", // Solomon Islands
"so", // Somalia
"za", // South africa
"kr", // South korea
"es", // Spain
"lk", // Sri Lanka
"sd", // Sudan
"sr", // Suriname
"se", // Sweden
"ch", // Switzerland
"sy", // Syria
"tw", // Taiwan
"tj", // Tajikistan
"tz", // Tanzania
"th", // Thailand
"tl", // Timor-Leste
"tg", // Togo
"to", // Tonga
"tt", // Trinidad and tobago
"tn", // Tunisia
"tr", // Turkey
"tm", // Turkmenistan
"tv", // Tuvalu
"ug", // Uganda
"ua", // Ukraine
"ae", // United arab emirates
"gb", // United kingdom
"us", // United states of america
"uy", // Uruguay
"uz", // Uzbekistan
"vu", // Vanuatu
"va", // Vatican
"ve", // Venezuela
"vi", // Vietnam
"vg", // Virgin Islands (British)
"wo", // World
"ye", // Yemen
"zm", // Zambia
"zw", // Zimbabwe
];
export const NEWS_CATEGORIES = [
"business",
"crime",
"domestic",
"education",
"entertainment",
"environment",
"food",
"health",
"lifestyle",
"other",
"politics",
"science",
"sports",
"technology",
"top",
"tourism",
"world",
];
export const NEWS_LANGUAGES = [
"af", // Afrikaans
"sq", // Albanian
"am", // Amharic
"ar", // Arabic
"hy", // Armenian
"as", // Assamese
"az", // Azerbaijani
"bm", // Bambara
"eu", // Basque
"be", // Belarusian
"bn", // Bengali
"bs", // Bosnian
"bg", // Bulgarian
"my", // Burmese
"ca", // Catalan
"ckb", // Central Kurdish
"zh", // Chinese
"hr", // Croatian
"cs", // Czech
"da", // Danish
"nl", // Dutch
"en", // English
"et", // Estonian
"pi", // Filipino
"fi", // Finnish
"fr", // French
"gl", // Galician
"ka", // Georgian
"de", // German
"el", // Greek
"gu", // Gujarati
"ha", // Hausa
"he", // Hebrew
"hi", // Hindi
"hu", // Hungarian
"is", // Icelandic
"id", // Indonesian
"it", // Italian
"jp", // Japanese
"kn", // Kannada
"kz", // Kazakh
"kh", // Khmer
"rw", // Kinyarwanda
"ko", // Korean
"ku", // Kurdish
"lv", // Latvian
"lt", // Lithuanian
"lb", // Luxembourgish
"mk", // Macedonian
"ms", // Malay
"ml", // Malayalam
"mt", // Maltese
"mi", // Maori
"mr", // Marathi
"mn", // Mongolian
"ne", // Nepali
"no", // Norwegian
"or", // Oriya
"ps", // Pashto
"fa", // Persian
"pl", // Polish
"pt", // Portuguese
"pa", // Punjabi
"ro", // Romanian
"ru", // Russian
"sm", // Samoan
"sr", // Serbian
"sn", // Shona
"sd", // Sindhi
"si", // Sinhala
"sk", // Slovak
"sl", // Slovenian
"so", // Somali
"es", // Spanish
"sw", // Swahili
"sv", // Swedish
"tg", // Tajik
"ta", // Tamil
"te", // Telugu
"th", // Thai
"zht", // Traditional chinese
"tr", // Turkish
"tk", // Turkmen
"uk", // Ukrainian
"ur", // Urdu
"uz", // Uzbek
"vi", // Vietnamese
"cy", // Welsh
"zu", // Zulu
];

View file

@ -1,93 +0,0 @@
import { BAR_MODULES, NEWS_CATEGORIES, NEWS_COUNTRIES, NEWS_LANGUAGES } from "./literals";
const BOOL = "boolean";
const STR = "string";
const NUM = "number";
const ARR = (type: string | string[]) => `array of ${typeof type === "string" ? type : JSON.stringify(type)}`;
const OBJ_ARR = (shape: object) => ARR(JSON.stringify(shape));
export default {
"style.transparency": ["off", "low", "normal", "high"],
"style.borders": BOOL,
"style.vibrant": BOOL,
"config.notifyOnError": BOOL,
// Bar
"bar.vertical": BOOL,
"bar.style": ["gaps", "panel", "embedded"],
"bar.layout.type": ["centerbox", "flowbox"],
"bar.layout.centerbox.start": ARR(BAR_MODULES),
"bar.layout.centerbox.center": ARR(BAR_MODULES),
"bar.layout.centerbox.end": ARR(BAR_MODULES),
"bar.layout.flowbox": ARR(BAR_MODULES),
"bar.modules.workspaces.shown": NUM,
"bar.modules.workspaces.showLabels": BOOL,
"bar.modules.workspaces.labels": ARR(STR),
"bar.modules.workspaces.xalign": NUM,
"bar.modules.workspaces.showWindows": BOOL,
"bar.modules.dateTime.format": STR,
"bar.modules.dateTime.detailedFormat": STR,
// Launcher
"launcher.style": ["lines", "round"],
"launcher.actionPrefix": STR,
"launcher.apps.maxResults": NUM,
"launcher.files.maxResults": NUM,
"launcher.files.fdOpts": ARR(STR),
"launcher.files.shortenThreshold": NUM,
"launcher.math.maxResults": NUM,
"launcher.todo.notify": BOOL,
"launcher.wallpaper.maxResults": NUM,
"launcher.wallpaper.showAllEmpty": BOOL,
"launcher.wallpaper.style": ["compact", "medium", "large"],
"launcher.disabledActions": ARR(STR),
// Notif popups
"notifpopups.maxPopups": NUM,
"notifpopups.expire": BOOL,
"notifpopups.agoTime": BOOL,
// OSDs
"osds.volume.position": [2, 4, 8, 16],
"osds.volume.margin": NUM,
"osds.volume.hideDelay": NUM,
"osds.volume.showValue": BOOL,
"osds.brightness.position": [2, 4, 8, 16],
"osds.brightness.margin": NUM,
"osds.brightness.hideDelay": NUM,
"osds.brightness.showValue": BOOL,
"osds.lock.spacing": NUM,
"osds.lock.caps.hideDelay": NUM,
"osds.lock.num.hideDelay": NUM,
// Sidebar
"sidebar.showOnStartup": BOOL,
"sidebar.modules.headlines.enabled": BOOL,
// Navbar
"navbar.persistent": BOOL,
"navbar.appearWidth": NUM,
"navbar.showLabels": BOOL,
// Services
"math.maxHistory": NUM,
"updates.interval": NUM,
"weather.interval": NUM,
"weather.apiKey": STR,
"weather.location": STR,
"weather.imperial": BOOL,
"cpu.interval": NUM,
"gpu.interval": NUM,
"memory.interval": NUM,
"storage.interval": NUM,
"wallpapers.paths": OBJ_ARR({ recursive: BOOL, path: STR, threshold: NUM }),
"calendar.webcals": ARR(STR),
"calendar.upcomingDays": NUM,
"calendar.notify": BOOL,
"thumbnailer.maxAttempts": NUM,
"thumbnailer.timeBetweenAttempts": NUM,
"thumbnailer.defaults.width": NUM,
"thumbnailer.defaults.height": NUM,
"thumbnailer.defaults.exact": BOOL,
"news.apiKey": STR,
"news.countries": ARR(NEWS_COUNTRIES),
"news.categories": ARR(NEWS_CATEGORIES),
"news.languages": ARR(NEWS_LANGUAGES),
"news.domains": ARR(STR),
"news.excludeDomains": ARR(STR),
"news.timezone": STR,
"news.pages": NUM,
} as { [k: string]: string | string[] | number[] };

View file

@ -1,703 +0,0 @@
import type { Monitor } from "@/services/monitors";
import Players from "@/services/players";
import Updates from "@/services/updates";
import { getAppCategoryIcon } from "@/utils/icons";
import { bindCurrentTime, osIcon } from "@/utils/system";
import type { AstalWidget } from "@/utils/types";
import { setupCustomTooltip } from "@/utils/widgets";
import ScreenCorner from "@/widgets/screencorner";
import { execAsync, GLib, Variable } from "astal";
import { bind, kebabify } from "astal/binding";
import { App, Astal, Gtk, Widget } from "astal/gtk3";
import { bar as config } from "config";
import AstalBattery from "gi://AstalBattery";
import AstalBluetooth from "gi://AstalBluetooth";
import AstalHyprland from "gi://AstalHyprland";
import AstalNetwork from "gi://AstalNetwork";
import AstalNotifd from "gi://AstalNotifd";
import AstalTray from "gi://AstalTray";
import AstalWp from "gi://AstalWp";
import { switchPane } from "./sidebar";
interface ClassNameProps {
beforeSpacer: boolean;
afterSpacer: boolean;
first: boolean;
last: boolean;
}
interface ModuleProps extends ClassNameProps {
monitor: Monitor;
}
const hyprland = AstalHyprland.get_default();
const getBatteryIcon = (perc: number) => {
if (perc < 0.1) return "󰁺";
if (perc < 0.2) return "󰁻";
if (perc < 0.3) return "󰁼";
if (perc < 0.4) return "󰁽";
if (perc < 0.5) return "󰁾";
if (perc < 0.6) return "󰁿";
if (perc < 0.7) return "󰂀";
if (perc < 0.8) return "󰂁";
if (perc < 0.9) return "󰂂";
return "󰁹";
};
const formatSeconds = (sec: number) => {
if (sec >= 3600) {
const hours = Math.floor(sec / 3600);
let str = `${hours} hour${hours === 1 ? "" : "s"}`;
const mins = Math.floor((sec % 3600) / 60);
if (mins > 0) str += ` ${mins} minute${mins === 1 ? "" : "s"}`;
return str;
} else if (sec >= 60) {
const mins = Math.floor(sec / 60);
return `${mins} minute${mins === 1 ? "" : "s"}`;
} else return `${sec} second${sec === 1 ? "" : "s"}`;
};
const hookFocusedClientProp = (
self: AstalWidget,
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 getClassName = ({ beforeSpacer, afterSpacer, first, last }: ClassNameProps) =>
`${beforeSpacer ? "before-spacer" : ""} ${afterSpacer ? "after-spacer" : ""}` +
` ${first ? "first" : ""} ${last ? "last" : ""}`;
const getModule = (module: string) => {
module = module.toLowerCase();
if (module === "osicon") return OSIcon;
if (module === "activewindow") return ActiveWindow;
if (module === "mediaplaying") return MediaPlaying;
if (module === "workspaces") return Workspaces;
if (module === "tray") return Tray;
if (module === "statusicons") return StatusIcons;
if (module === "pkgupdates") return PkgUpdates;
if (module === "notifcount") return NotifCount;
if (module === "battery") return Battery;
if (module === "datetime") return DateTime;
if (module === "power") return Power;
if (module === "brightnessspacer") return BrightnessSpacer;
if (module === "volumespacer") return VolumeSpacer;
return () => null;
};
const isSpacer = (module?: string) => module?.toLowerCase().endsWith("spacer") ?? false;
const OSIcon = ({ monitor, ...props }: ModuleProps) => (
<button
className={`module os-icon ${getClassName(props)}`}
onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "dashboard")}
>
{osIcon}
</button>
);
const ActiveWindow = ({ monitor, ...props }: ModuleProps) => (
<box
vertical={bind(config.vertical)}
className={`module active-window ${getClassName(props)}`}
setup={self => {
const title = Variable("");
const updateTooltip = (c: AstalHyprland.Client | null) =>
title.set(c?.class && c?.title ? `${c.class}: ${c.title}` : "");
hookFocusedClientProp(self, "class", updateTooltip);
hookFocusedClientProp(self, "title", updateTooltip);
updateTooltip(hyprland.focusedClient);
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?.class ? getAppCategoryIcon(c.class) : "desktop_windows";
})
}
/>
<label
truncate
angle={bind(config.vertical).as(v => (v ? 270 : 0))}
setup={self => {
const update = () =>
(self.label = hyprland.focusedClient?.title ? hyprland.focusedClient.title : "Desktop");
hookFocusedClientProp(self, "title", update);
self.hook(config.vertical, update);
}}
/>
</box>
);
const MediaPlaying = ({ monitor, ...props }: ModuleProps) => {
const players = Players.get_default();
const getLabel = (fallback = "") =>
players.lastPlayer ? `${players.lastPlayer.title} - ${players.lastPlayer.artist}` : fallback;
return (
<button
onClick={(_, event) => {
if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "audio");
else if (event.button === Astal.MouseButton.SECONDARY) players.lastPlayer?.play_pause();
else if (event.button === Astal.MouseButton.MIDDLE) players.lastPlayer?.raise();
}}
setup={self => {
const label = Variable(getLabel());
players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => label.set(getLabel()));
setupCustomTooltip(self, bind(label));
}}
>
<box vertical={bind(config.vertical)} className={`module media-playing ${getClassName(props)}`}>
<icon
setup={self =>
players.hookLastPlayer(self, "notify::identity", () => {
const icon = `caelestia-${players.lastPlayer?.identity
.toLowerCase()
.replaceAll(" ", "-")}-symbolic`;
self.icon = players.lastPlayer
? Astal.Icon.lookup_icon(icon)
? icon
: "caelestia-media-generic-symbolic"
: "caelestia-media-none-symbolic";
})
}
/>
<label
truncate
angle={bind(config.vertical).as(v => (v ? 270 : 0))}
setup={self => {
const update = () => (self.label = getLabel("No media"));
players.hookLastPlayer(self, ["notify::title", "notify::artist"], update);
self.hook(config.vertical, update);
}}
/>
</box>
</button>
);
};
const Workspace = ({ idx }: { idx: number }) => {
const wsId = Variable.derive([bind(hyprland, "focusedWorkspace"), config.modules.workspaces.shown], (f, s) =>
f ? Math.floor((f.id - 1) / s) * s + idx : idx
);
const label = (
<label
css={bind(config.modules.workspaces.xalign).as(a => `margin-left: ${a}px; margin-right: ${-a}px;`)}
label={bind(config.modules.workspaces.labels).as(l => l[idx - 1] ?? String(idx))}
/>
);
return (
<button
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
onClicked={() => hyprland.dispatch("workspace", String(wsId.get()))}
setup={self => {
const updateOccupied = () => {
const occupied = hyprland.clients.some(c => c.workspace?.id === wsId.get());
self.toggleClassName("occupied", occupied);
};
const updateFocused = () => {
self.toggleClassName("focused", hyprland.focusedWorkspace?.id === wsId.get());
updateOccupied();
};
self.hook(hyprland, "client-added", updateOccupied);
self.hook(hyprland, "client-moved", updateOccupied);
self.hook(hyprland, "client-removed", updateOccupied);
self.hook(hyprland, "notify::focused-workspace", updateFocused);
updateFocused();
}}
onDestroy={() => wsId.drop()}
>
<box
visible={bind(config.modules.workspaces.showLabels)}
vertical={bind(config.vertical)}
setup={self => {
const update = () => {
if (config.modules.workspaces.showWindows.get()) {
const clients = hyprland.clients.filter(c => c.workspace?.id === wsId.get());
self.children = [
label,
...clients.map(c => (
<label className="icon" label={bind(c, "class").as(getAppCategoryIcon)} />
)),
];
} else self.children = [label];
};
self.hook(wsId, update);
self.hook(hyprland, "client-added", update);
self.hook(hyprland, "client-moved", update);
self.hook(hyprland, "client-removed", update);
update();
}}
/>
</button>
);
};
const Workspaces = ({ monitor, ...props }: ModuleProps) => {
const className = Variable.derive(
[config.modules.workspaces.shown, config.modules.workspaces.showLabels],
(s, l) => `module workspaces ${s % 2 === 0 ? "even" : "odd"} ${l ? "labels-shown" : ""} ${getClassName(props)}`
);
return (
<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 vertical={bind(config.vertical)} className={bind(className)} onDestroy={() => className.drop()}>
{bind(config.modules.workspaces.shown).as(
n => Array.from({ length: n }).map((_, idx) => <Workspace idx={idx + 1} />) // Start from 1
)}
</box>
</eventbox>
);
};
const TrayItem = (item: AstalTray.TrayItem) => (
<menubutton
onButtonPressEvent={(_, event) => event.get_button()[1] === Astal.MouseButton.SECONDARY && item.activate(0, 0)}
usePopover={false}
direction={bind(config.vertical).as(v => (v ? Gtk.ArrowType.RIGHT : Gtk.ArrowType.DOWN))}
menuModel={bind(item, "menuModel")}
actionGroup={bind(item, "actionGroup").as(a => ["dbusmenu", a])}
setup={self => setupCustomTooltip(self, bind(item, "tooltipMarkup"))}
>
<icon halign={Gtk.Align.CENTER} gicon={bind(item, "gicon")} />
</menubutton>
);
const Tray = ({ monitor, ...props }: ModuleProps) => (
<box
visible={bind(AstalTray.get_default(), "items").as(i => i.length > 0)}
vertical={bind(config.vertical)}
className={`module tray ${getClassName(props)}`}
>
{bind(AstalTray.get_default(), "items").as(i => i.map(TrayItem))}
</box>
);
const Network = ({ monitor }: { monitor: Monitor }) => (
<button
onClick={(_, event) => {
const network = AstalNetwork.get_default();
if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "connectivity");
else if (event.button === Astal.MouseButton.SECONDARY) network.wifi.enabled = !network.wifi.enabled;
else if (event.button === Astal.MouseButton.MIDDLE) {
if (GLib.find_program_in_path("gnome-control-center"))
execAsync("app2unit -- gnome-control-center wifi").catch(console.error);
else {
network.wifi.scan();
execAsync(
"app2unit -- foot -T nmtui -- fish -c 'sleep .1; set -e COLORTERM; TERM=xterm-old nmtui connect'"
).catch(() => {}); // Ignore errors
}
}
}}
setup={self => {
const network = AstalNetwork.get_default();
const tooltipText = Variable("");
const update = () => {
if (network.primary === AstalNetwork.Primary.WIFI) {
if (network.wifi.internet === AstalNetwork.Internet.CONNECTED)
tooltipText.set(`${network.wifi.ssid} | Strength: ${network.wifi.strength}/100`);
else if (network.wifi.internet === AstalNetwork.Internet.CONNECTING)
tooltipText.set(`Connecting to ${network.wifi.ssid}`);
else tooltipText.set("Disconnected");
} else if (network.primary === AstalNetwork.Primary.WIRED) {
if (network.wired.internet === AstalNetwork.Internet.CONNECTED)
tooltipText.set(`Speed: ${network.wired.speed}`);
else if (network.wired.internet === AstalNetwork.Internet.CONNECTING) tooltipText.set("Connecting");
else tooltipText.set("Disconnected");
} else {
tooltipText.set("Unknown");
}
};
self.hook(network, "notify::primary", update);
self.hook(network.wifi, "notify::internet", update);
self.hook(network.wifi, "notify::ssid", update);
self.hook(network.wifi, "notify::strength", update);
if (network.wired) {
self.hook(network.wired, "notify::internet", update);
self.hook(network.wired, "notify::speed", update);
}
update();
setupCustomTooltip(self, bind(tooltipText));
}}
>
<stack
transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
transitionDuration={120}
shown={bind(AstalNetwork.get_default(), "primary").as(p =>
p === AstalNetwork.Primary.WIFI ? "wifi" : "wired"
)}
>
<stack
name="wifi"
transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
transitionDuration={120}
setup={self => {
const network = AstalNetwork.get_default();
const update = () => {
if (network.wifi.internet === AstalNetwork.Internet.CONNECTED)
self.shown = String(Math.ceil(network.wifi.strength / 25));
else if (network.wifi.internet === AstalNetwork.Internet.CONNECTING) self.shown = "connecting";
else self.shown = "disconnected";
};
self.hook(network.wifi, "notify::internet", update);
self.hook(network.wifi, "notify::strength", update);
update();
}}
>
<label className="icon" label="wifi_off" name="disconnected" />
<label className="icon" label="settings_ethernet" name="connecting" />
<label className="icon" label="signal_wifi_0_bar" name="0" />
<label className="icon" label="network_wifi_1_bar" name="1" />
<label className="icon" label="network_wifi_2_bar" name="2" />
<label className="icon" label="network_wifi_3_bar" name="3" />
<label className="icon" label="signal_wifi_4_bar" name="4" />
</stack>
<stack
name="wired"
transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
transitionDuration={120}
setup={self => {
const network = AstalNetwork.get_default();
const update = () => {
if (network.primary !== AstalNetwork.Primary.WIRED) return;
if (network.wired.internet === AstalNetwork.Internet.CONNECTED) self.shown = "connected";
else if (network.wired.internet === AstalNetwork.Internet.CONNECTING) self.shown = "connecting";
else self.shown = "disconnected";
};
self.hook(network, "notify::primary", update);
if (network.wired) self.hook(network.wired, "notify::internet", update);
update();
}}
>
<label className="icon" label="wifi_off" name="disconnected" />
<label className="icon" label="settings_ethernet" name="connecting" />
<label className="icon" label="lan" name="connected" />
</stack>
</stack>
</button>
);
const BluetoothDevice = ({ monitor, device }: { monitor: Monitor; device: AstalBluetooth.Device }) => (
<button
visible={bind(device, "connected")}
onClick={(_, event) => {
if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "connectivity");
else if (event.button === Astal.MouseButton.SECONDARY)
device.disconnect_device((_, res) => device.disconnect_device_finish(res));
else if (event.button === Astal.MouseButton.MIDDLE)
execAsync("app2unit -- blueman-manager").catch(console.error);
}}
setup={self => setupCustomTooltip(self, bind(device, "alias"))}
>
<icon
icon={bind(device, "icon").as(i =>
Astal.Icon.lookup_icon(`${i}-symbolic`) ? `${i}-symbolic` : "caelestia-bluetooth-device-symbolic"
)}
/>
</button>
);
const Bluetooth = ({ monitor }: { monitor: Monitor }) => (
<box vertical={bind(config.vertical)} className="bluetooth">
<button
onClick={(_, event) => {
if (event.button === Astal.MouseButton.PRIMARY) switchPane(monitor, "connectivity");
else if (event.button === Astal.MouseButton.SECONDARY) AstalBluetooth.get_default().toggle();
else if (event.button === Astal.MouseButton.MIDDLE)
execAsync("app2unit -- blueman-manager").catch(console.error);
}}
setup={self => {
const bluetooth = AstalBluetooth.get_default();
const tooltipText = Variable("");
const update = () => {
const devices = bluetooth.get_devices().filter(d => d.connected);
tooltipText.set(
devices.length > 0
? `Connected devices: ${devices.map(d => d.alias).join(", ")}`
: "No connected devices"
);
};
const hookDevice = (device: AstalBluetooth.Device) => {
self.hook(device, "notify::connected", update);
self.hook(device, "notify::alias", update);
};
bluetooth.get_devices().forEach(hookDevice);
self.hook(bluetooth, "device-added", (_, device) => {
hookDevice(device);
update();
});
update();
setupCustomTooltip(self, bind(tooltipText));
}}
>
<stack
transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
transitionDuration={120}
shown={bind(AstalBluetooth.get_default(), "isPowered").as(p => (p ? "enabled" : "disabled"))}
>
<label className="icon" label="bluetooth" name="enabled" />
<label className="icon" label="bluetooth_disabled" name="disabled" />
</stack>
</button>
{bind(AstalBluetooth.get_default(), "devices").as(d =>
d.map(d => <BluetoothDevice monitor={monitor} device={d} />)
)}
</box>
);
const StatusIcons = ({ monitor, ...props }: ModuleProps) => (
<box vertical={bind(config.vertical)} className={`module status-icons ${getClassName(props)}`}>
<Network monitor={monitor} />
<Bluetooth monitor={monitor} />
</box>
);
const PkgUpdates = ({ monitor, ...props }: ModuleProps) => (
<button
onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "packages")}
setup={self =>
setupCustomTooltip(
self,
bind(Updates.get_default(), "numUpdates").as(n => `${n} update${n === 1 ? "" : "s"} available`)
)
}
>
<box vertical={bind(config.vertical)} className={`module pkg-updates ${getClassName(props)}`}>
<label className="icon" label="download" />
<label label={bind(Updates.get_default(), "numUpdates").as(String)} />
</box>
</button>
);
const NotifCount = ({ monitor, ...props }: ModuleProps) => (
<button
onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "alerts")}
setup={self =>
setupCustomTooltip(
self,
bind(AstalNotifd.get_default(), "notifications").as(
n => `${n.length} notification${n.length === 1 ? "" : "s"}`
)
)
}
>
<box vertical={bind(config.vertical)} className={`module notif-count ${getClassName(props)}`}>
<label
className="icon"
label={bind(AstalNotifd.get_default(), "dontDisturb").as(d => (d ? "notifications_off" : "info"))}
/>
<revealer
transitionType={bind(config.vertical).as(v =>
v ? Gtk.RevealerTransitionType.SLIDE_DOWN : Gtk.RevealerTransitionType.SLIDE_RIGHT
)}
transitionDuration={120}
revealChild={bind(AstalNotifd.get_default(), "dontDisturb").as(d => !d)}
>
<label label={bind(AstalNotifd.get_default(), "notifications").as(n => String(n.length))} />
</revealer>
</box>
</button>
);
const Battery = ({ monitor, ...props }: ModuleProps) => {
const className = Variable.derive(
[bind(AstalBattery.get_default(), "percentage"), bind(AstalBattery.get_default(), "charging")],
(p, c) => `module battery ${c ? "charging" : p < 0.2 ? "low" : ""} ${getClassName(props)}`
);
const tooltip = Variable.derive(
[bind(AstalBattery.get_default(), "timeToEmpty"), bind(AstalBattery.get_default(), "timeToFull")],
(e, f) => (f > 0 ? `${formatSeconds(f)} until full` : `${formatSeconds(e)} remaining`)
);
return (
<box
visible={bind(AstalBattery.get_default(), "isBattery")}
vertical={bind(config.vertical)}
className={bind(className)}
setup={self => setupCustomTooltip(self, bind(tooltip))}
onDestroy={() => {
className.drop();
tooltip.drop();
}}
>
<label className="icon" label={bind(AstalBattery.get_default(), "percentage").as(getBatteryIcon)} />
<label label={bind(AstalBattery.get_default(), "percentage").as(p => `${Math.round(p * 100)}%`)} />
</box>
);
};
const DateTimeHoriz = (props: ClassNameProps) => (
<box className={`module date-time ${getClassName(props)}`}>
<label className="icon" label="calendar_month" />
<label
setup={self => {
const time = bindCurrentTime(bind(config.modules.dateTime.format), undefined, self);
self.label = time.get();
self.hook(time, (_, t) => (self.label = t));
}}
/>
</box>
);
const DateTimeVertical = (props: ClassNameProps) => (
<box vertical className={`module date-time ${getClassName(props)}`}>
<label className="icon" label="calendar_month" />
<label label={bindCurrentTime("%H")} />
<label label={bindCurrentTime("%M")} />
</box>
);
const DateTime = ({ monitor, ...props }: ModuleProps) => (
<button
onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane(monitor, "time")}
setup={self =>
setupCustomTooltip(self, bindCurrentTime(bind(config.modules.dateTime.detailedFormat), undefined, self))
}
>
{bind(config.vertical).as(v => (v ? <DateTimeVertical {...props} /> : <DateTimeHoriz {...props} />))}
</button>
);
const Power = ({ monitor, ...props }: ModuleProps) => (
<button
className={`module power ${getClassName(props)}`}
label="power_settings_new"
onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && App.toggle_window("session")}
/>
);
const Spacer = ({ onScroll }: { onScroll: (self: Widget.EventBox, event: Astal.ScrollEvent) => void }) => (
<eventbox onScroll={onScroll}>
<box vertical={bind(config.vertical)}>
<ScreenCorner place="topleft" />
<box expand />
<ScreenCorner place={bind(config.vertical).as(v => (v ? "bottomleft" : "topright"))} />
</box>
</eventbox>
);
const BrightnessSpacer = ({ monitor }: { monitor: Monitor }) => (
<Spacer onScroll={(_, event) => (event.delta_y > 0 ? (monitor.brightness -= 0.1) : (monitor.brightness += 0.1))} />
);
const VolumeSpacer = () => (
<Spacer
onScroll={(_, event) => {
const speaker = AstalWp.get_default()?.audio.defaultSpeaker;
if (!speaker) return console.error("Unable to connect to WirePlumber.");
speaker.mute = false;
if (event.delta_y > 0) speaker.volume -= 0.1;
else speaker.volume += 0.1;
}}
/>
);
const Bar = ({ monitor, layout }: { monitor: Monitor; layout: string }) => {
const className = Variable.derive(
[bind(config.vertical), bind(config.style)],
(v, s) => `bar ${v ? "vertical" : " horizontal"} ${s}`
);
const modules =
layout === "centerbox"
? Variable.derive(Object.values(config.layout.centerbox))
: bind(config.layout.flowbox).as(m => [m]);
const Layout = layout === "centerbox" ? Widget.CenterBox : Widget.Box;
return (
<Layout
vertical={bind(config.vertical)}
className={bind(className)}
onDestroy={() => {
className.drop();
if (modules instanceof Variable) modules.drop();
}}
>
{bind(modules).as(modules =>
modules.map((m, i) => (
<box vertical={bind(config.vertical)}>
{m.map((n, j) => {
let beforeSpacer = false;
if (j < m.length - 1) beforeSpacer = isSpacer(m[j + 1]);
else if (i < modules.length - 1) beforeSpacer = isSpacer(modules[i + 1][0]);
let afterSpacer = false;
if (j > 0) afterSpacer = isSpacer(m[j - 1]);
else if (i > 0) afterSpacer = isSpacer(modules[i - 1].at(-1));
const M = getModule(n);
return (
<M
monitor={monitor}
beforeSpacer={beforeSpacer}
afterSpacer={afterSpacer}
first={i === 0 && j === 0}
last={i === modules.length - 1 && j === m.length - 1}
/>
);
})}
</box>
))
)}
</Layout>
);
};
export default ({ monitor }: { monitor: Monitor }) => (
<window
namespace="caelestia-bar"
monitor={monitor.id}
anchor={bind(config.vertical).as(
v =>
Astal.WindowAnchor.TOP |
Astal.WindowAnchor.LEFT |
(v ? Astal.WindowAnchor.BOTTOM : Astal.WindowAnchor.RIGHT)
)}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
>
<overlay
passThrough
overlays={[
<ScreenCorner visible={bind(config.style).as(s => s !== "embedded")} place="topleft" />,
<ScreenCorner
visible={bind(config.style).as(s => s !== "embedded")}
halign={bind(config.vertical).as(v => (v ? undefined : Gtk.Align.END))}
valign={bind(config.vertical).as(v => (v ? Gtk.Align.END : undefined))}
place={bind(config.vertical).as(v => (v ? "bottomleft" : "topright"))}
/>,
]}
>
{bind(config.layout.type).as(l => (
<Bar monitor={monitor} layout={l} />
))}
</overlay>
</window>
);

View file

@ -1,522 +0,0 @@
import { Apps } from "@/services/apps";
import Palette from "@/services/palette";
import Schemes, { type Colours } from "@/services/schemes";
import Wallpapers, { type ICategory, type IWallpaper } from "@/services/wallpapers";
import { basename } from "@/utils/strings";
import { notify } from "@/utils/system";
import { setupCustomTooltip, type FlowBox } from "@/utils/widgets";
import { bind, execAsync, GLib, readFile, register, type Variable } from "astal";
import { Gtk, Widget } from "astal/gtk3";
import { launcher as config } from "config";
import { setConfig } from "config/funcs";
import fuzzysort from "fuzzysort";
import AstalHyprland from "gi://AstalHyprland";
import { close, ContentBox, type LauncherContent, type Mode } from "./util";
interface IAction {
icon: string;
name: string;
description: string;
action: (...args: string[]) => void;
available?: () => boolean;
}
interface ActionMap {
[k: string]: IAction;
}
const variantActions = {
vibrant: {
icon: "sentiment_very_dissatisfied",
name: "Vibrant",
description: "A high chroma palette. The primary palette's chroma is at maximum.",
},
tonalspot: {
icon: "android",
name: "Tonal Spot",
description: "Default for Material theme colours. A pastel palette with a low chroma.",
},
expressive: {
icon: "compare_arrows",
name: "Expressive",
description:
"A medium chroma palette. The primary palette's hue is different from the seed colour, for variety.",
},
fidelity: {
icon: "compare",
name: "Fidelity",
description: "Matches the seed colour, even if the seed colour is very bright (high chroma).",
},
content: {
icon: "sentiment_calm",
name: "Content",
description: "Almost identical to fidelity.",
},
fruitsalad: {
icon: "nutrition",
name: "Fruit Salad",
description: "A playful theme - the seed colour's hue does not appear in the theme.",
},
rainbow: {
icon: "looks",
name: "Rainbow",
description: "A playful theme - the seed colour's hue does not appear in the theme.",
},
neutral: {
icon: "contrast",
name: "Neutral",
description: "Close to grayscale, a hint of chroma.",
},
monochrome: {
icon: "filter_b_and_w",
name: "Monochrome",
description: "All colours are grayscale, no chroma.",
},
};
const transparencyActions = {
off: {
icon: "blur_off",
name: "Off",
description: "Completely opaque",
},
low: {
icon: "blur_circular",
name: "Low",
description: "Less transparent",
},
normal: {
icon: "blur_linear",
name: "Normal",
description: "Somewhat transparent",
},
high: {
icon: "blur_on",
name: "High",
description: "Extremely transparent",
},
};
const autocomplete = (entry: Widget.Entry, action: string) => {
entry.set_text(`${config.actionPrefix.get()}${action} `);
entry.set_position(-1);
};
const actions = (mode: Variable<Mode>, entry: Widget.Entry): ActionMap => ({
apps: {
icon: "apps",
name: "Apps",
description: "Search for apps",
action: () => {
mode.set("apps");
entry.set_text("");
},
},
files: {
icon: "folder",
name: "Files",
description: "Search for files",
action: () => {
mode.set("files");
entry.set_text("");
},
},
math: {
icon: "calculate",
name: "Math",
description: "Do math calculations",
action: () => {
mode.set("math");
entry.set_text("");
},
},
light: {
icon: "light_mode",
name: "Light",
description: "Change scheme to light mode",
action: () => {
Palette.get_default().switchMode("light");
close();
},
available: () => Palette.get_default().hasMode("light"),
},
dark: {
icon: "dark_mode",
name: "Dark",
description: "Change scheme to dark mode",
action: () => {
Palette.get_default().switchMode("dark");
close();
},
available: () => Palette.get_default().hasMode("dark"),
},
scheme: {
icon: "palette",
name: "Scheme",
description: "Change the current colour scheme",
action: () => autocomplete(entry, "scheme"),
},
variant: {
icon: "colors",
name: "Variant",
description: "Change the current scheme variant",
action: () => autocomplete(entry, "variant"),
available: () => Palette.get_default().scheme === "dynamic",
},
wallpaper: {
icon: "image",
name: "Wallpaper",
description: "Change the current wallpaper",
action: () => autocomplete(entry, "wallpaper"),
},
transparency: {
icon: "opacity",
name: "Transparency",
description: "Change shell transparency",
action: () => autocomplete(entry, "transparency"),
},
todo: {
icon: "checklist",
name: "Todo",
description: "Create a todo in Todoist",
action: (...args) => {
// If no args, autocomplete cmd
if (args.length === 0) return autocomplete(entry, "todo");
// If tod not configured, notify
let token = null;
try {
token = JSON.parse(readFile(GLib.get_user_config_dir() + "/tod.cfg")).token;
} catch {} // Ignore
if (!token) {
notify({
summary: "Tod not configured",
body: "You need to configure tod first. Run any tod command to do this.",
icon: "dialog-warning-symbolic",
urgency: "critical",
});
} else {
// Create todo and notify if configured
execAsync(`tod t q -c ${args.join(" ")}`).catch(console.error);
if (config.todo.notify.get())
notify({
summary: "Todo created",
body: `Created todo with content: ${args.join(" ")}`,
icon: "view-list-bullet-symbolic",
urgency: "low",
transient: true,
actions: {
"Copy content": () => execAsync(`wl-copy -- ${args.join(" ")}`).catch(console.error),
View: () => {
const client = AstalHyprland.get_default().clients.find(c => c.class === "Todoist");
if (client) client.focus();
else execAsync("app2unit -- todoist").catch(console.error);
},
},
});
}
close();
},
available: () => !!GLib.find_program_in_path("tod"),
},
reload: {
icon: "refresh",
name: "Reload",
description: "Reload app list",
action: () => {
Apps.reload();
entry.set_text("");
},
},
lock: {
icon: "lock",
name: "Lock",
description: "Lock the current session",
action: () => {
execAsync("loginctl lock-session").catch(console.error);
close();
},
},
logout: {
icon: "logout",
name: "Logout",
description: "End the current session",
action: () => {
execAsync("uwsm stop").catch(console.error);
close();
},
},
sleep: {
icon: "bedtime",
name: "Sleep",
description: "Suspend then hibernate",
action: () => {
execAsync("systemctl suspend-then-hibernate").catch(console.error);
close();
},
},
reboot: {
icon: "cached",
name: "Reboot",
description: "Restart the machine",
action: () => {
execAsync("systemctl reboot").catch(console.error);
close();
},
},
hibernate: {
icon: "downloading",
name: "Hibernate",
description: "Suspend to RAM",
action: () => {
execAsync("systemctl hibernate").catch(console.error);
close();
},
},
shutdown: {
icon: "power_settings_new",
name: "Shutdown",
description: "Suspend to disk",
action: () => {
execAsync("systemctl poweroff").catch(console.error);
close();
},
},
});
const Action = ({ args, icon, name, description, action }: IAction & { args: string[] }) => (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result"
cursor="pointer"
onClicked={() => action(...args)}
setup={self => setupCustomTooltip(self, description)}
>
<box>
<label className="icon" label={icon} />
<box vertical className="has-sublabel">
<label truncate xalign={0} label={name} />
<label truncate xalign={0} label={description} className="sublabel" />
</box>
</box>
</button>
</Gtk.FlowBoxChild>
);
const Swatch = ({ colour }: { colour: string }) => <box className="swatch" css={"background-color: " + colour + ";"} />;
const Scheme = ({ scheme, name, colours }: { scheme?: string; name: string; colours?: Colours }) => {
const palette = colours![Palette.get_default().mode] ?? colours!.light ?? colours!.dark!;
return (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result"
cursor="pointer"
onClicked={() => {
execAsync(`caelestia scheme ${scheme ?? ""} ${name}`).catch(console.error);
close();
}}
>
<box>
<box valign={Gtk.Align.CENTER}>
<box className="swatch big left" css={"background-color: " + palette.base + ";"} />
<box className="swatch big right" css={"background-color: " + palette.primary + ";"} />
</box>
<box vertical className="has-sublabel">
<label truncate xalign={0} label={scheme ? `${scheme} (${name})` : name} />
<box className="swatches">
<Swatch colour={palette.rosewater} />
<Swatch colour={palette.flamingo} />
<Swatch colour={palette.pink} />
<Swatch colour={palette.mauve} />
<Swatch colour={palette.red} />
<Swatch colour={palette.maroon} />
<Swatch colour={palette.peach} />
<Swatch colour={palette.yellow} />
<Swatch colour={palette.green} />
<Swatch colour={palette.teal} />
<Swatch colour={palette.sky} />
<Swatch colour={palette.sapphire} />
<Swatch colour={palette.blue} />
<Swatch colour={palette.lavender} />
</box>
</box>
</box>
</button>
</Gtk.FlowBoxChild>
);
};
const Variant = ({ name }: { name: keyof typeof variantActions }) => (
<Action
{...variantActions[name]}
args={[]}
action={() => {
execAsync(`caelestia variant ${name}`).catch(console.error);
close();
}}
/>
);
const Wallpaper = ({ path, thumbnails }: IWallpaper) => (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result wallpaper-container"
cursor="pointer"
onClicked={() => {
execAsync(`caelestia wallpaper -f ${path}`).catch(console.error);
close();
}}
setup={self => setupCustomTooltip(self, path.replace(HOME, "~"))}
>
<box
vertical={config.wallpaper.style.get() !== "compact"}
className={`wallpaper ${config.wallpaper.style.get()}`}
>
<box
className="thumbnail"
css={bind(config.wallpaper.style).as(
s => "background-image: url('" + thumbnails[s as keyof typeof thumbnails] + "');"
)}
/>
<label truncate label={basename(path)} />
</box>
</button>
</Gtk.FlowBoxChild>
);
const CategoryThumbnail = ({ style, wallpapers }: { style: string; wallpapers: IWallpaper[] }) => (
<box className="thumbnail">
{wallpapers.slice(0, 3).map(w => (
<box hexpand css={"background-image: url('" + w.thumbnails[style as keyof typeof w.thumbnails] + "');"} />
))}
</box>
);
const Category = ({ path, wallpapers }: ICategory) => (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result wallpaper-container"
cursor="pointer"
onClicked={() => {
execAsync(`caelestia wallpaper -d ${path}`).catch(console.error);
close();
}}
setup={self => setupCustomTooltip(self, path.replace(HOME, "~"))}
>
<box
vertical={config.wallpaper.style.get() !== "compact"}
className={`wallpaper ${config.wallpaper.style.get()}`}
>
{bind(config.wallpaper.style).as(s =>
s === "compact" ? (
<box
className="thumbnail"
css={"background-image: url('" + wallpapers[0].thumbnails.compact + "');"}
/>
) : (
<CategoryThumbnail style={s} wallpapers={wallpapers} />
)
)}
<label truncate label={basename(path)} />
</box>
</button>
</Gtk.FlowBoxChild>
);
const Transparency = ({ amount }: { amount: keyof typeof transparencyActions }) => (
<Action
{...transparencyActions[amount]}
args={[]}
action={() => {
setConfig("style.transparency", amount).catch(console.error);
close();
}}
/>
);
@register()
export default class Actions extends Widget.Box implements LauncherContent {
#map: ActionMap;
#list: string[];
#content: FlowBox;
constructor(mode: Variable<Mode>, entry: Widget.Entry) {
super({ name: "actions", className: "actions" });
this.#map = actions(mode, entry);
this.#list = Object.keys(this.#map);
this.#content = (<ContentBox />) as FlowBox;
this.add(
<scrollable expand hscroll={Gtk.PolicyType.NEVER}>
{this.#content}
</scrollable>
);
}
updateContent(search: string): void {
this.#content.foreach(c => c.destroy());
const args = search.split(" ");
const action = args[0].slice(1).toLowerCase();
if (action === "scheme") {
const scheme = args[1] ?? "";
const schemes = Object.values(Schemes.get_default().map)
.flatMap(s => (s.colours ? s.name : Object.values(s.flavours!).map(f => `${f.scheme}-${f.name}`)))
.filter(s => s !== undefined)
.sort();
for (const { target } of fuzzysort.go(scheme, schemes, { all: true })) {
if (Schemes.get_default().map.hasOwnProperty(target))
this.#content.add(<Scheme {...Schemes.get_default().map[target]} />);
else {
const [scheme, flavour] = target.split("-");
this.#content.add(<Scheme {...Schemes.get_default().map[scheme].flavours![flavour]} />);
}
}
} else if (action === "variant") {
const list = Object.keys(variantActions);
for (const { target } of fuzzysort.go(args[1], list, { all: true }))
this.#content.add(<Variant name={target as keyof typeof variantActions} />);
} else if (action === "wallpaper") {
if (args[1]?.toLowerCase() === "random") {
const list = Wallpapers.get_default().categories;
for (const { obj } of fuzzysort.go(args[2] ?? "", list, { all: true, key: "path" }))
this.#content.add(<Category {...obj} />);
} else {
const list = Wallpapers.get_default().list;
let limit = undefined;
if ((args[1] || !config.wallpaper.showAllEmpty.get()) && config.wallpaper.maxResults.get() > 0)
limit = config.wallpaper.maxResults.get();
for (const { obj } of fuzzysort.go(args[1] ?? "", list, { all: true, key: "path", limit }))
this.#content.add(<Wallpaper {...obj} />);
}
} else if (action === "transparency") {
const list = Object.keys(transparencyActions);
for (const { target } of fuzzysort.go(args[1], list, { all: true }))
this.#content.add(<Transparency amount={target as keyof typeof transparencyActions} />);
} else {
const list = this.#list.filter(
a => this.#map[a].available?.() ?? !config.disabledActions.get().includes(a)
);
for (const { target } of fuzzysort.go(action, list, { all: true }))
this.#content.add(<Action {...this.#map[target]} args={args.slice(1)} />);
}
}
handleActivate(search: string): void {
const args = search.split(" ");
const action = args[0].slice(1).toLowerCase();
if (action === "scheme" && args[1]?.toLowerCase() === "random") {
execAsync(`caelestia scheme`).catch(console.error);
close();
} else this.#content.get_child_at_index(0)?.get_child()?.activate();
}
}

View file

@ -1,144 +0,0 @@
import PopupWindow from "@/widgets/popupwindow";
import { bind, register, Variable } from "astal";
import { Astal, Gtk, Widget } from "astal/gtk3";
import { launcher as config } from "config";
import Actions from "./actions";
import Modes from "./modes";
import type { Mode } from "./util";
const getModeIcon = (mode: Mode) => {
if (mode === "apps") return "apps";
if (mode === "files") return "folder";
if (mode === "math") return "calculate";
return "search";
};
const getPrettyMode = (mode: Mode) => {
if (mode === "apps") return "Apps";
if (mode === "files") return "Files";
if (mode === "math") return "Math";
return mode;
};
const isAction = (text: string, action: string = "") => text.startsWith(config.actionPrefix.get() + action);
const SearchBar = ({ mode, entry }: { mode: Variable<Mode>; entry: Widget.Entry }) => (
<box className="search-bar">
<box className="mode">
<label className="icon" label={bind(mode).as(getModeIcon)} />
<label label={bind(mode).as(getPrettyMode)} />
</box>
{entry}
</box>
);
const ModeSwitcher = ({ mode, modes }: { mode: Variable<Mode>; modes: Mode[] }) => (
<box homogeneous hexpand className="mode-switcher">
{modes.map(m => (
<button
className={bind(mode).as(c => `mode ${c === m ? "selected" : ""}`)}
cursor="pointer"
onClicked={() => mode.set(m)}
>
<box halign={Gtk.Align.CENTER}>
<label className="icon" label={getModeIcon(m)} />
<label label={getPrettyMode(m)} />
</box>
</button>
))}
</box>
);
@register()
export default class Launcher extends PopupWindow {
readonly mode: Variable<Mode>;
constructor() {
const entry = (
<entry
hexpand
className="entry"
placeholderText={bind(config.actionPrefix).as(p => `Type "${p}" for subcommands`)}
/>
) as Widget.Entry;
const mode = Variable<Mode>("apps");
const content = Modes();
const actions = new Actions(mode, entry);
const className = Variable.derive([mode, config.style], (m, s) => `launcher ${m} ${s}`);
super({
name: "launcher",
anchor:
Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT,
keymode: Astal.Keymode.EXCLUSIVE,
exclusivity: Astal.Exclusivity.IGNORE,
borderWidth: 0,
onKeyPressEvent(_, event) {
const keyval = event.get_keyval()[1];
// Focus entry on typing
if (!entry.isFocus && keyval >= 32 && keyval <= 126) {
entry.text += String.fromCharCode(keyval);
entry.grab_focus();
entry.set_position(-1);
// Consume event, if not consumed it will duplicate character in entry
return true;
}
},
child: (
<box
vertical
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
className={bind(className)}
onDestroy={() => className.drop()}
>
<SearchBar mode={mode} entry={entry} />
<stack
expand
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={100}
shown={bind(entry, "text").as(t => (isAction(t) ? "actions" : "content"))}
>
<stack
name="content"
transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT}
transitionDuration={200}
shown={bind(mode)}
>
{Object.values(content)}
</stack>
{actions}
</stack>
<ModeSwitcher mode={mode} modes={Object.keys(content) as Mode[]} />
</box>
),
});
this.mode = mode;
content[mode.get()].updateContent(entry.get_text());
this.hook(mode, (_, v: Mode) => {
entry.set_text("");
content[v].updateContent(entry.get_text());
});
this.hook(entry, "changed", () =>
(isAction(entry.get_text()) ? actions : content[mode.get()]).updateContent(entry.get_text())
);
this.hook(entry, "activate", () => {
(isAction(entry.get_text()) ? actions : content[mode.get()]).handleActivate(entry.get_text());
if (mode.get() === "math" && !isAction(entry.get_text())) entry.set_text(""); // Cause math mode doesn't auto clear
});
// Clear search on hide if not in math mode or creating a todo
this.connect("hide", () => {
if ((mode.get() !== "math" || isAction(entry.get_text())) && !isAction(entry.get_text(), "todo"))
entry.set_text("");
});
}
open(mode: Mode) {
this.mode.set(mode);
this.show();
}
}

View file

@ -1,225 +0,0 @@
import { Apps as AppsService } from "@/services/apps";
import MathService, { type HistoryItem } from "@/services/math";
import { getAppCategoryIcon } from "@/utils/icons";
import { launch } from "@/utils/system";
import { type FlowBox, setupCustomTooltip } from "@/utils/widgets";
import { bind, execAsync, Gio, register, Variable } from "astal";
import { Astal, Gtk, Widget } from "astal/gtk3";
import { launcher as config } from "config";
import type AstalApps from "gi://AstalApps";
import { close, ContentBox, type LauncherContent, limitLength } from "./util";
const AppResult = ({ app }: { app: AstalApps.Application }) => (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result"
cursor="pointer"
onClicked={() => {
launch(app);
close();
}}
setup={self => setupCustomTooltip(self, app.description ? `${app.name}: ${app.description}` : app.name)}
>
<box>
{app.iconName && Astal.Icon.lookup_icon(app.iconName) ? (
<icon className="icon" icon={app.iconName} />
) : (
<label className="icon" label={getAppCategoryIcon(app)} />
)}
<label truncate label={app.name} />
</box>
</button>
</Gtk.FlowBoxChild>
);
const FileResult = ({ path }: { path: string }) => (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result"
cursor="pointer"
onClicked={() => {
execAsync([
"bash",
"-c",
`dbus-send --session --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:'file://${path}' string:'' || app2unit -O '${path}'`,
]).catch(console.error);
close();
}}
>
<box setup={self => setupCustomTooltip(self, path.replace(HOME, "~"))}>
<icon
className="icon"
gicon={
Gio.File.new_for_path(path)
.query_info(Gio.FILE_ATTRIBUTE_STANDARD_ICON, Gio.FileQueryInfoFlags.NONE, null)
.get_icon()!
}
/>
<label
truncate
label={
path.replace(HOME, "~").length > config.files.shortenThreshold.get()
? path
.replace(HOME, "~")
.split("/")
.map((n, i, arr) => (i === 0 || i === arr.length - 1 ? n : n.slice(0, 1)))
.join("/")
: path.replace(HOME, "~")
}
/>
</box>
</button>
</Gtk.FlowBoxChild>
);
const MathResult = ({ icon, equation, result }: HistoryItem) => (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result"
cursor="pointer"
onClicked={() => {
execAsync(["wl-copy", "--", result]).catch(console.error);
close();
}}
setup={self => setupCustomTooltip(self, `${equation} -> ${result}`)}
>
<box>
<label className="icon" label={icon} />
<box vertical className="has-sublabel">
<label truncate xalign={0} label={equation} />
<label truncate xalign={0} label={result} className="sublabel" />
</box>
</box>
</button>
</Gtk.FlowBoxChild>
);
@register()
class Apps extends Widget.Box implements LauncherContent {
#content: FlowBox;
constructor() {
super({ name: "apps", className: "apps" });
this.#content = (<ContentBox />) as FlowBox;
this.add(
<scrollable expand hscroll={Gtk.PolicyType.NEVER}>
{this.#content}
</scrollable>
);
}
updateContent(search: string): void {
this.#content.foreach(c => c.destroy());
for (const app of limitLength(AppsService.fuzzy_query(search), config.apps))
this.#content.add(<AppResult app={app} />);
}
handleActivate(): void {
this.#content.get_child_at_index(0)?.get_child()?.grab_focus();
this.#content.get_child_at_index(0)?.get_child()?.activate();
}
}
@register()
class Files extends Widget.Box implements LauncherContent {
#content: FlowBox;
constructor() {
super({ name: "files", className: "files" });
this.#content = (<ContentBox />) as FlowBox;
this.add(
<scrollable expand hscroll={Gtk.PolicyType.NEVER}>
{this.#content}
</scrollable>
);
}
updateContent(search: string): void {
execAsync(["fd", ...config.files.fdOpts.get(), search, HOME])
.then(out => {
this.#content.foreach(c => c.destroy());
const paths = out.split("\n").filter(path => path);
for (const path of limitLength(paths, config.files)) this.#content.add(<FileResult path={path} />);
})
.catch(() => {}); // Ignore errors
}
handleActivate(): void {
this.#content.get_child_at_index(0)?.get_child()?.grab_focus();
this.#content.get_child_at_index(0)?.get_child()?.activate();
}
}
@register()
class Math extends Widget.Box implements LauncherContent {
#showResult: Variable<boolean>;
#result: Variable<HistoryItem>;
#content: FlowBox;
constructor() {
super({ name: "math", className: "math", vertical: true });
this.#showResult = Variable(false);
this.#result = Variable({ equation: "", result: "", icon: "" });
this.#content = (<ContentBox />) as FlowBox;
this.add(
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
revealChild={bind(this.#showResult)}
>
<box vertical className="preview">
<box className="result">
<label className="icon" label={bind(this.#result).as(r => r.icon)} />
<box vertical>
<label xalign={0} label="Result" />
<label
truncate
xalign={0}
className="sublabel"
label={bind(this.#result).as(r => r.result)}
/>
</box>
</box>
<box visible={bind(config.style).as(s => s === "lines")} className="separator" />
</box>
</revealer>
);
this.add(
<scrollable expand hscroll={Gtk.PolicyType.NEVER}>
{this.#content}
</scrollable>
);
}
updateContent(search: string): void {
this.#showResult.set(search.length > 0);
this.#result.set(MathService.get_default().evaluate(search));
this.#content.foreach(c => c.destroy());
for (const item of limitLength(MathService.get_default().history, config.math))
this.#content.add(<MathResult {...item} />);
}
handleActivate(search: string): void {
if (!search) return;
MathService.get_default().commit();
const res = this.#result.get();
// Copy and close if not assignment, help or error
if (!["equal", "help", "error"].includes(res.icon)) {
execAsync(["wl-copy", "--", res.result]).catch(console.error);
close();
}
}
}
export default () => ({
apps: new Apps(),
files: new Files(),
math: new Math(),
});

View file

@ -1,19 +0,0 @@
import { FlowBox } from "@/utils/widgets";
import type { Variable } from "astal";
import { App, Gtk } from "astal/gtk3";
export type Mode = "apps" | "files" | "math";
export interface LauncherContent {
updateContent(search: string): void;
handleActivate(search: string): void;
}
export const close = () => App.get_window("launcher")?.hide();
export const limitLength = <T,>(arr: T[], cfg: { maxResults: Variable<number> }) =>
cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr;
export const ContentBox = () => (
<FlowBox homogeneous valign={Gtk.Align.START} minChildrenPerLine={2} maxChildrenPerLine={2} />
);

View file

@ -1,188 +0,0 @@
import type { Monitor } from "@/services/monitors";
import Players from "@/services/players";
import { lengthStr } from "@/utils/strings";
import { bind, Variable } from "astal";
import { App, Astal, Gtk } from "astal/gtk3";
import AstalMpris from "gi://AstalMpris";
import Visualiser from "./visualiser";
type Selected = Variable<AstalMpris.Player | null>;
const bindIcon = (player: AstalMpris.Player) =>
bind(player, "identity").as(i => {
const icon = `caelestia-${i?.toLowerCase().replaceAll(" ", "-")}-symbolic`;
return Astal.Icon.lookup_icon(icon) ? icon : "caelestia-media-generic-symbolic";
});
const PlayerButton = ({
player,
selected,
showDropdown,
}: {
player: AstalMpris.Player;
selected: Selected;
showDropdown: Variable<boolean>;
}) => (
<button
cursor="pointer"
onClicked={() => {
showDropdown.set(false);
selected.set(player);
}}
>
<box className="identity" halign={Gtk.Align.CENTER}>
<label label={bind(player, "identity").as(i => i ?? "-")} />
<label label="•" />
<label label={bind(player, "title").as(t => t ?? "-")} />
</box>
</button>
);
const Selector = ({ player, selected }: { player?: AstalMpris.Player; selected: Selected }) => {
const showDropdown = Variable(false);
return (
<box vertical valign={Gtk.Align.START} className="selector">
<button
sensitive={bind(Players.get_default(), "list").as(ps => ps.length > 1)}
cursor="pointer"
onClicked={() => showDropdown.set(!showDropdown.get())}
>
<box className="identity" halign={Gtk.Align.CENTER}>
<icon icon={player ? bindIcon(player) : "caelestia-media-none-symbolic"} />
<label label={player ? bind(player, "identity").as(i => i ?? "") : "No media"} />
</box>
</button>
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
revealChild={bind(showDropdown)}
>
<box vertical className="list">
{bind(Players.get_default(), "list").as(ps =>
ps
.filter(p => p !== player)
.map(p => <PlayerButton player={p} selected={selected} showDropdown={showDropdown} />)
)}
</box>
</revealer>
</box>
);
};
const NoMedia = ({ selected }: { selected: Selected }) => (
<box>
<box homogeneous halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="cover-art">
<label xalign={0.36} label="" />
</box>
<box>
<box vertical className="details">
<label truncate xalign={0} className="title" label="No media" />
<label truncate xalign={0} className="artist" label="Try play something!" />
<box halign={Gtk.Align.START} className="controls">
<button sensitive={false} label="skip_previous" />
<button sensitive={false} label="play_arrow" />
<button sensitive={false} label="skip_next" />
</box>
</box>
<box className="center-module">
<overlay
expand
overlay={<label halign={Gtk.Align.CENTER} valign={Gtk.Align.END} className="time" label="-1:-1" />}
>
<Visualiser />
</overlay>
</box>
<Selector selected={selected} />
</box>
</box>
);
const Player = ({ player, selected }: { player: AstalMpris.Player; selected: Selected }) => {
const time = Variable.derive(
[bind(player, "position"), bind(player, "length")],
(p, l) => lengthStr(p) + " / " + lengthStr(l)
);
return (
<box>
<box
homogeneous
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
className="cover-art"
css={bind(player, "coverArt").as(a => `background-image: url("${a}");`)}
>
{bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.36} label="" />))}
</box>
<box>
<box vertical className="details">
<label truncate xalign={0} className="title" label={bind(player, "title").as(t => t ?? "-")} />
<label truncate xalign={0} className="artist" label={bind(player, "artist").as(t => t ?? "-")} />
<box halign={Gtk.Align.START} className="controls">
<button
sensitive={bind(player, "canGoPrevious")}
cursor="pointer"
onClicked={() => player.next()}
label="skip_previous"
/>
<button
sensitive={bind(player, "canControl")}
cursor="pointer"
onClicked={() => player.play_pause()}
label={bind(player, "playbackStatus").as(s =>
s === AstalMpris.PlaybackStatus.PLAYING ? "pause" : "play_arrow"
)}
/>
<button
sensitive={bind(player, "canGoNext")}
cursor="pointer"
onClicked={() => player.next()}
label="skip_next"
/>
</box>
</box>
<box className="center-module">
<overlay
expand
overlay={
<label
halign={Gtk.Align.CENTER}
valign={Gtk.Align.END}
className="time"
label={bind(time)}
onDestroy={() => time.drop()}
/>
}
>
<Visualiser />
</overlay>
</box>
<Selector player={player} selected={selected} />
</box>
</box>
);
};
export default ({ monitor }: { monitor: Monitor }) => {
const selected = Variable(Players.get_default().lastPlayer);
selected.observe(Players.get_default(), "notify::last-player", () => Players.get_default().lastPlayer);
return (
<window
application={App}
name={`mediadisplay${monitor.id}`}
namespace="caelestia-mediadisplay"
monitor={monitor.id}
anchor={Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
visible={false}
>
<box className="mediadisplay" onDestroy={() => selected.drop()}>
{bind(selected).as(p =>
p ? <Player player={p} selected={selected} /> : <NoMedia selected={selected} />
)}
</box>
</window>
);
};

View file

@ -1,71 +0,0 @@
import { Gtk } from "astal/gtk3";
import cairo from "cairo";
import AstalCava from "gi://AstalCava";
import PangoCairo from "gi://PangoCairo";
export default () => (
<drawingarea
className="visualiser"
setup={self => {
const cava = AstalCava.get_default();
if (cava) {
cava.set_stereo(true);
cava.set_noise_reduction(0.77);
cava.set_input(AstalCava.Input.PIPEWIRE);
self.hook(cava, "notify::values", () => self.queue_draw());
self.connect("size-allocate", () => {
const width = self.get_allocated_width();
const barWidth = self
.get_style_context()
.get_property("min-width", Gtk.StateFlags.NORMAL) as number;
const gaps = self.get_style_context().get_margin(Gtk.StateFlags.NORMAL).right;
const bars = Math.floor((width - gaps) / (barWidth + gaps));
if (bars > 0) cava.set_bars(bars % 2 ? bars : bars - 1);
});
}
self.connect("draw", (_, cr: cairo.Context) => {
const { width, height } = self.get_allocation();
if (!cava) {
// Show error text if cava unavailable
const fg = self.get_style_context().get_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha);
const layout = self.create_pango_layout("Visualiser module requires Cava");
const [w, h] = layout.get_pixel_size();
cr.moveTo((width - w) / 2, (height - h) / 2);
cr.setAntialias(cairo.Antialias.BEST);
PangoCairo.show_layout(cr, layout);
return;
}
const bg = self.get_style_context().get_background_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
const barWidth = self.get_style_context().get_property("min-width", Gtk.StateFlags.NORMAL) as number;
const gaps = self.get_style_context().get_margin(Gtk.StateFlags.NORMAL).right;
const values = cava.get_values();
const len = values.length - 1;
const radius = barWidth / 2;
const xOff = (width - len * (barWidth + gaps) - gaps) / 2 - radius;
const center = height / 2;
const half = len / 2;
const renderPill = (x: number, value: number) => {
x = x * (barWidth + gaps) + xOff;
value *= center;
cr.arc(x, center + value, radius, 0, Math.PI);
cr.arc(x, center - value, radius, Math.PI, Math.PI * 2);
cr.fill();
};
// Render channels facing each other
for (let i = half - 1; i >= 0; i--) renderPill(half - i, values[i]);
for (let i = half; i < len; i++) renderPill(i + 1, values[i]);
});
}}
/>
);

View file

@ -1,203 +0,0 @@
import type { Monitor } from "@/services/monitors";
import { capitalize } from "@/utils/strings";
import type { AstalWidget } from "@/utils/types";
import { bind, execAsync, Variable } from "astal";
import { App, Astal, Gtk } from "astal/gtk3";
import { navbar as config } from "config";
import AstalHyprland from "gi://AstalHyprland";
import Pango from "gi://Pango";
import SideBar, { awaitSidebar, paneNames, switchPane, type PaneName } from "./sidebar";
const layerNames = ["mediadisplay"] as const;
type LayerName = `${(typeof layerNames)[number]}${number}`;
const specialWsNames = ["sysmon", "communication", "music", "todo"] as const;
type SpecialWsName = (typeof specialWsNames)[number];
const getPaneIcon = (name: PaneName) => {
if (name === "dashboard") return "dashboard";
if (name === "audio") return "tune";
if (name === "connectivity") return "settings_ethernet";
if (name === "packages") return "package_2";
if (name === "alerts") return "notifications";
return "date_range";
};
const getLayerIcon = (name: LayerName) => {
return "graphic_eq";
};
const getSpecialWsIcon = (name: SpecialWsName) => {
if (name === "sysmon") return "speed";
if (name === "communication") return "communication";
if (name === "music") return "music_note";
return "checklist";
};
const hookIsCurrent = (
self: AstalWidget,
sidebar: Variable<SideBar | null>,
name: PaneName,
callback: (isCurrent: boolean) => void
) => {
const unsub = sidebar.subscribe(s => {
if (!s) return;
self.hook(s.shown, (_, v) => callback(s.visible && v === name));
self.hook(s, "notify::visible", () => callback(s.visible && s.shown.get() === name));
callback(s.visible && s.shown.get() === name);
unsub();
});
};
const PaneButton = ({
monitor,
name,
sidebar,
}: {
monitor: Monitor;
name: PaneName;
sidebar: Variable<SideBar | null>;
}) => (
<button
cursor="pointer"
onClicked={() => switchPane(monitor, name)}
setup={self => hookIsCurrent(self, sidebar, name, c => self.toggleClassName("current", c))}
>
<box vertical className="nav-button">
<label className="icon" label={getPaneIcon(name)} />
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
setup={self => {
let isCurrent = false;
hookIsCurrent(self, sidebar, name, c => {
isCurrent = c;
self.set_reveal_child(config.showLabels.get() && c);
});
self.hook(config.showLabels, (_, v) => self.set_reveal_child(v && isCurrent));
}}
>
<label truncate wrapMode={Pango.WrapMode.WORD_CHAR} className="label" label={capitalize(name)} />
</revealer>
</box>
</button>
);
const LayerButton = ({ name }: { name: LayerName }) => (
<button
cursor="pointer"
onClicked={() => App.toggle_window(name)}
setup={self =>
self.hook(App, "window-toggled", (_, window) => {
if (window.name === name) self.toggleClassName("current", window.visible);
})
}
>
<box vertical className="nav-button">
<label className="icon" label={getLayerIcon(name)} />
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
setup={self => {
let visible = false;
self.hook(config.showLabels, (_, v) => self.toggleClassName(v && visible));
self.hook(App, "window-toggled", (_, window) => {
if (window.name === name)
self.toggleClassName("current", config.showLabels.get() && window.visible);
});
}}
>
<label truncate wrapMode={Pango.WrapMode.WORD_CHAR} className="label" label={capitalize(name)} />
</revealer>
</box>
</button>
);
const SpecialWsButton = ({ name }: { name: SpecialWsName }) => {
const revealChild = Variable.derive(
[config.showLabels, bind(AstalHyprland.get_default(), "focusedClient")],
(l, c) => l && c?.get_workspace().get_name() === `special:${name}`
);
return (
<button
className={bind(AstalHyprland.get_default(), "focusedClient").as(c =>
c?.get_workspace().get_name() === `special:${name}` ? "current" : ""
)}
cursor="pointer"
onClicked={() => execAsync(`caelestia toggle ${name}`).catch(console.error)}
>
<box vertical className="nav-button">
<label className="icon" label={getSpecialWsIcon(name)} />
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
revealChild={bind(revealChild)}
onDestroy={() => revealChild.drop()}
>
<label truncate wrapMode={Pango.WrapMode.WORD_CHAR} className="label" label={capitalize(name)} />
</revealer>
</box>
</button>
);
};
export default ({ monitor }: { monitor: Monitor }) => {
const sidebar = Variable<SideBar | null>(null);
awaitSidebar(monitor).then(s => sidebar.set(s));
return (
<window
namespace="caelestia-navbar"
monitor={monitor.id}
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
visible={config.persistent.get()}
setup={self => {
const hyprland = AstalHyprland.get_default();
const visible = Variable(config.persistent.get());
visible.poll(100, () => {
const width = self.visible
? Math.max(config.appearWidth.get(), self.get_allocated_width())
: config.appearWidth.get();
return hyprland.get_cursor_position().x < width;
});
if (config.persistent.get()) visible.stopPoll();
self.hook(config.persistent, (_, v) => {
if (v) {
visible.stopPoll();
visible.set(true);
} else visible.startPoll();
});
self.hook(visible, (_, v) => self.set_visible(v));
self.connect("destroy", () => visible.drop());
}}
>
<eventbox
onScroll={(_, event) => {
const shown = sidebar.get()?.shown;
if (!shown) return;
const idx = paneNames.indexOf(shown.get());
if (event.delta_y > 0) shown.set(paneNames[Math.min(paneNames.length - 1, idx + 1)]);
else shown.set(paneNames[Math.max(0, idx - 1)]);
}}
>
<box vertical className="navbar">
{paneNames.map(n => (
<PaneButton monitor={monitor} name={n} sidebar={sidebar} />
))}
{layerNames.map(n => (
<LayerButton name={`${n}${monitor.id}`} />
))}
<box vexpand />
{specialWsNames.map(n => (
<SpecialWsButton name={n} />
))}
</box>
</eventbox>
</window>
);
};

View file

@ -1,72 +0,0 @@
import type { Monitor } from "@/services/monitors";
import { setupChildClickthrough } from "@/utils/widgets";
import Notification from "@/widgets/notification";
import { Astal, Gtk } from "astal/gtk3";
import { notifpopups as config } from "config";
import AstalNotifd from "gi://AstalNotifd";
import type SideBar from "./sidebar";
import { awaitSidebar } from "./sidebar";
export default ({ monitor }: { monitor: Monitor }) => (
<window
monitor={monitor.id}
namespace="caelestia-notifpopups"
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM}
>
<box
vertical
valign={Gtk.Align.START}
className="notifpopups"
setup={self => {
const notifd = AstalNotifd.get_default();
const map = new Map<number, Notification>();
self.hook(notifd, "notified", (self, id) => {
if (notifd.dontDisturb) return;
const notification = notifd.get_notification(id);
const popup = (<Notification popup notification={notification} />) as Notification;
popup.connect("destroy", () => map.get(notification.id) === popup && map.delete(notification.id));
map.get(notification.id)?.destroyWithAnims();
map.set(notification.id, popup);
self.add(
<eventbox
onClick={(_, event) => {
// Activate notif or go to notif center on primary click
if (event.button === Astal.MouseButton.PRIMARY) {
if (notification.actions.length === 1)
notification.invoke(notification.actions[0].id);
else {
sidebar?.shown.set("alerts");
sidebar?.show();
popup.destroyWithAnims();
}
}
// Dismiss on middle click
else if (event.button === Astal.MouseButton.MIDDLE) notification.dismiss();
}}
// Close on hover lost
onHoverLost={() => popup.destroyWithAnims()}
setup={self => self.hook(popup, "destroy", () => self.destroy())}
>
{popup}
</eventbox>
);
// Limit number of popups
if (config.maxPopups.get() > 0 && self.children.length > config.maxPopups.get())
map.values().next().value?.destroyWithAnims();
});
self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims());
let sidebar: SideBar | null = null;
awaitSidebar(monitor).then(s => (sidebar = s));
// Change input region to child region so can click through empty space
setupChildClickthrough(self);
}}
/>
</window>
);

View file

@ -1,327 +0,0 @@
import Monitors, { type Monitor } from "@/services/monitors";
import { capitalize } from "@/utils/strings";
import PopupWindow from "@/widgets/popupwindow";
import { bind, execAsync, register, timeout, Variable, type Time } from "astal";
import { App, Astal, Gtk, Widget } from "astal/gtk3";
import cairo from "cairo";
import { osds as config } from "config";
import AstalWp from "gi://AstalWp";
import Cairo from "gi://cairo";
import Pango from "gi://Pango";
import PangoCairo from "gi://PangoCairo";
const getStyle = (context: Gtk.StyleContext, prop: string) => context.get_property(prop, Gtk.StateFlags.NORMAL);
const getNumStyle = (context: Gtk.StyleContext, prop: string) => getStyle(context, prop) as number;
const mix = (a: number, b: number, r: number) => a * r + b * (1 - r);
const pangoWeightToStr = (weight: Pango.Weight) => {
switch (weight) {
case Pango.Weight.ULTRALIGHT:
return "UltraLight";
case Pango.Weight.LIGHT:
return "Light";
case Pango.Weight.BOLD:
return "Bold";
case Pango.Weight.ULTRABOLD:
return "UltraBold";
case Pango.Weight.HEAVY:
return "Heavy";
default:
return "Normal";
}
};
const SliderOsd = ({
fillIcons,
monitor,
type,
windowSetup,
className = "",
initValue,
drawAreaSetup,
}: {
fillIcons?: boolean;
monitor?: Monitor;
type: "volume" | "brightness";
windowSetup: (self: Widget.Window, show: () => void) => void;
className?: string;
initValue: number;
drawAreaSetup: (self: Widget.DrawingArea, icon: Variable<string>) => void;
}) => (
<PopupWindow
name={type}
monitor={monitor?.id}
keymode={Astal.Keymode.NONE}
anchor={bind(config[type].position)}
margin={bind(config[type].margin)}
setup={self => {
let time: Time | null = null;
const hideAfterTimeout = () => {
time?.cancel();
time = timeout(config[type].hideDelay.get(), () => self.hide());
};
self.connect("show", hideAfterTimeout);
windowSetup(self, () => {
self.show();
hideAfterTimeout();
});
}}
>
<box className={type}>
<drawingarea
className={`inner ${className}`}
css={"font-size: " + initValue + "px;"}
setup={self => {
const halfPi = Math.PI / 2;
const vertical =
config[type].position.get() === Astal.WindowAnchor.LEFT ||
config[type].position.get() === Astal.WindowAnchor.RIGHT;
const icon = Variable("");
drawAreaSetup(self, icon);
self.hook(icon, () => self.queue_draw());
// Init size
const styleContext = self.get_style_context();
const width = getNumStyle(styleContext, "min-width");
const height = getNumStyle(styleContext, "min-height");
if (vertical) self.set_size_request(height, width);
else self.set_size_request(width, height);
let fontDesc: Pango.FontDescription | null = null;
self.connect("draw", (_, cr: cairo.Context) => {
const parent = self.get_parent();
if (!parent) return;
const styleContext = self.get_style_context();
const pContext = parent.get_style_context();
let width = getNumStyle(styleContext, "min-width");
let height = getNumStyle(styleContext, "min-height");
const progressValue = getNumStyle(styleContext, "font-size");
let radius = getNumStyle(pContext, "border-radius");
// Flatten when near 0, do before swap cause its simpler
radius = Math.min(radius, Math.min(width * progressValue, height) / 2);
if (vertical) [width, height] = [height, width]; // Swap if vertical
self.set_size_request(width, height);
const progressPosition = vertical
? height * (1 - progressValue) + radius // Top is 0, but we want it to start from the bottom
: width * progressValue - radius;
const bg = styleContext.get_background_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
// Background
if (vertical) {
cr.arc(radius, progressPosition, radius, -Math.PI, -halfPi); // Top left
cr.arc(width - radius, progressPosition, radius, -halfPi, 0); // Top right
cr.arc(width - radius, height - radius, radius, 0, halfPi); // Bottom right
} else {
cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
cr.arc(progressPosition, radius, radius, -halfPi, 0); // Top right
cr.arc(progressPosition, height - radius, radius, 0, halfPi); // Bottom right
}
cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
cr.fill();
const fg = pContext.get_background_color(Gtk.StateFlags.NORMAL);
cr.setAntialias(cairo.Antialias.BEST);
// Progress number, at top/right
let nw = 0;
let nh = 0;
if (config[type].showValue.get()) {
const numLayout = parent.create_pango_layout(String(Math.round(progressValue * 100)));
[nw, nh] = numLayout.get_pixel_size();
let diff;
if (vertical) {
diff = ((1 - progressValue) * height) / nh;
cr.moveTo((width - nw) / 2, radius / 2);
} else {
diff = ((1 - progressValue) * width) / nw;
cr.moveTo(width - nw - radius, (height - nh) / 2);
}
diff = Math.max(0, Math.min(1, diff));
cr.setSourceRGBA(
mix(bg.red, fg.red, diff),
mix(bg.green, fg.green, diff),
mix(bg.blue, fg.blue, diff),
mix(bg.alpha, fg.alpha, diff)
);
PangoCairo.show_layout(cr, numLayout);
}
// Progress icon, follows progress
if (fontDesc === null) {
const weight = pangoWeightToStr(getStyle(pContext, "font-weight") as Pango.Weight);
const size = getNumStyle(pContext, "font-size") * 1.5;
fontDesc = Pango.font_description_from_string(
`Material Symbols Rounded ${weight} ${size}px`
);
// Ugh GTK CSS doesn't support font-variations, so you need to manually create the layout and font desc instead of using Gtk.Widget#create_pango_layout
if (fillIcons) fontDesc.set_variations("FILL=1");
}
const iconLayout = PangoCairo.create_layout(cr);
iconLayout.set_font_description(fontDesc);
iconLayout.set_text(icon.get(), -1);
const [iw, ih] = iconLayout.get_pixel_size();
let diff;
if (vertical) {
diff = (progressValue * height) / ih;
cr.moveTo(
(width - iw) / 2,
Math.max(nh, Math.min(height - ih, progressPosition - ih / 2 + radius))
);
} else {
diff = (progressValue * width) / iw;
cr.moveTo(
Math.min(
width - nw * 1.1 - iw - radius,
Math.max(0, progressPosition - iw / 2 - radius)
),
(height - ih) / 2
);
}
diff = Math.max(0, Math.min(1, diff));
cr.setSourceRGBA(
mix(fg.red, bg.red, diff),
mix(fg.green, bg.green, diff),
mix(fg.blue, bg.blue, diff),
mix(fg.alpha, bg.alpha, diff)
);
PangoCairo.show_layout(cr, iconLayout);
});
}}
/>
</box>
</PopupWindow>
);
const Volume = ({ audio }: { audio: AstalWp.Audio }) => (
<SliderOsd
fillIcons
type="volume"
windowSetup={(self, show) => {
self.hook(audio.defaultSpeaker, "notify::volume", show);
self.hook(audio.defaultSpeaker, "notify::mute", show);
}}
className={audio.defaultSpeaker.mute ? "mute" : ""}
initValue={audio.defaultSpeaker.volume}
drawAreaSetup={(self, icon) => {
const updateIcon = () => {
if (/head(phone|set)/i.test(audio.defaultSpeaker.icon)) icon.set("headphones");
else if (audio.defaultSpeaker.mute) icon.set("no_sound");
else if (audio.defaultSpeaker.volume === 0) icon.set("volume_mute");
else if (audio.defaultSpeaker.volume <= 0.5) icon.set("volume_down");
else icon.set("volume_up");
};
updateIcon();
self.hook(audio.defaultSpeaker, "notify::icon", updateIcon);
self.hook(audio.defaultSpeaker, "notify::mute", () => {
updateIcon();
self.toggleClassName("mute", audio.defaultSpeaker.mute);
});
self.hook(audio.defaultSpeaker, "notify::volume", () => {
updateIcon();
self.css = `font-size: ${audio.defaultSpeaker.volume}px`;
});
}}
/>
);
const Brightness = ({ monitor }: { monitor: Monitor }) => (
<SliderOsd
monitor={monitor}
type="brightness"
windowSetup={(self, show) => self.hook(monitor, "notify::brightness", show)}
initValue={monitor.brightness}
drawAreaSetup={(self, icon) => {
const update = () => {
if (monitor.brightness > 0.66) icon.set("brightness_high");
else if (monitor.brightness > 0.33) icon.set("brightness_medium");
else if (monitor.brightness > 0) icon.set("brightness_low");
else icon.set("brightness_empty");
self.css = `font-size: ${monitor.brightness}px`;
};
self.hook(monitor, "notify::brightness", update);
update();
}}
/>
);
@register()
class LockOsd extends Widget.Window {
readonly lockType: "caps" | "num";
#timeout: Time | null = null;
constructor({ type, icon, right }: { type: "caps" | "num"; icon: string; right?: boolean }) {
super({
visible: false,
name: `lock-${type}`,
application: App,
namespace: `caelestia-lock-${type}`,
anchor:
Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT,
exclusivity: Astal.Exclusivity.IGNORE,
});
this.lockType = type;
this.#update();
this.add(
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className={`lock ${type}`}>
<label vexpand className="icon" label={icon} />
<label vexpand className="text" label={capitalize(type) + "lock"} />
</box>
);
// Clickthrough
this.connect("size-allocate", () => this.input_shape_combine_region(new Cairo.Region()));
// Move over when other indicator opens/closes
this.hook(App, "window-toggled", (_, window) => {
if (window !== this && window instanceof LockOsd) {
const child = this.get_child();
if (!child) return;
this[right ? "marginLeft" : "marginRight"] = window.visible
? child.get_preferred_width()[1] + config.lock.spacing.get()
: 0;
}
});
}
#update() {
execAsync(`fish -c 'cat /sys/class/leds/input*::${this.lockType}lock/brightness'`)
.then(out => (this.get_child() as Widget.Box | null)?.toggleClassName("enabled", out.includes("1")))
.catch(console.error);
}
show() {
super.show();
this.#update();
this.#timeout?.cancel();
this.#timeout = timeout(config.lock[this.lockType].hideDelay.get(), () => this.hide());
}
}
export default () => {
if (AstalWp.get_default()) <Volume audio={AstalWp.get_default()!.audio} />;
Monitors.get_default().forEach(monitor => <Brightness monitor={monitor} />);
<LockOsd type="caps" icon="keyboard_capslock" />;
<LockOsd right type="num" icon="filter_1" />;
return null;
};

View file

@ -1,51 +0,0 @@
import type { Monitor } from "@/services/monitors";
import ScreenCorner from "@/widgets/screencorner";
import { bind } from "astal/binding";
import { Astal } from "astal/gtk3";
import { bar } from "config";
import Cairo from "gi://cairo";
export default ({ monitor }: { monitor: Monitor }) => (
<window
namespace="caelestia-screencorners"
monitor={monitor.id}
anchor={bind(bar.vertical).as(
v =>
Astal.WindowAnchor.BOTTOM |
Astal.WindowAnchor.RIGHT |
(v ? Astal.WindowAnchor.TOP : Astal.WindowAnchor.LEFT)
)}
setup={self =>
self.connect("size-allocate", () => self.get_window()?.input_shape_combine_region(new Cairo.Region(), 0, 0))
}
>
<box vertical={bind(bar.vertical)}>
<ScreenCorner place={bind(bar.vertical).as(v => (v ? "topright" : "bottomleft"))} />
<box expand />
<ScreenCorner place="bottomright" />
</box>
</window>
);
export const BarScreenCorners = ({ monitor }: { monitor: Monitor }) => (
<window
namespace="caelestia-screencorners"
monitor={monitor.id}
anchor={bind(bar.vertical).as(
v =>
Astal.WindowAnchor.TOP |
Astal.WindowAnchor.LEFT |
(v ? Astal.WindowAnchor.BOTTOM : Astal.WindowAnchor.RIGHT)
)}
visible={bind(bar.style).as(s => s === "embedded")}
setup={self =>
self.connect("size-allocate", () => self.get_window()?.input_shape_combine_region(new Cairo.Region(), 0, 0))
}
>
<box vertical={bind(bar.vertical)}>
<ScreenCorner place="topleft" />
<box expand />
<ScreenCorner place={bind(bar.vertical).as(v => (v ? "bottomleft" : "topright"))} />
</box>
</window>
);

View file

@ -1,44 +0,0 @@
import PopupWindow from "@/widgets/popupwindow";
import { execAsync } from "astal";
import { App, Astal, Gtk } from "astal/gtk3";
const Item = ({ icon, label, cmd, isDefault }: { icon: string; label: string; cmd: string; isDefault?: boolean }) => (
<box vertical className="item">
<button
cursor="pointer"
onClicked={() => execAsync(cmd).catch(console.error)}
setup={self =>
isDefault &&
self.hook(App, "window-toggled", (_, window) => {
if (window.name === "session" && window.visible) self.grab_focus();
})
}
>
<label className="icon" label={icon} />
</button>
<label className="label" label={label} />
</box>
);
export default () => (
<PopupWindow
className="session"
name="session"
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT}
exclusivity={Astal.Exclusivity.IGNORE}
keymode={Astal.Keymode.EXCLUSIVE}
layer={Astal.Layer.OVERLAY}
borderWidth={0} // Don't need border width cause takes up entire screen
>
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="inner">
<box>
<Item icon="logout" label="Logout" cmd="uwsm stop" isDefault />
<Item icon="cached" label="Reboot" cmd="systemctl reboot" />
</box>
<box>
<Item icon="downloading" label="Hibernate" cmd="systemctl hibernate" />
<Item icon="power_settings_new" label="Shutdown" cmd="systemctl poweroff" />
</box>
</box>
</PopupWindow>
);

View file

@ -1,11 +0,0 @@
import type { Monitor } from "@/services/monitors";
import Headlines from "./modules/headlines";
import Notifications from "./modules/notifications";
export default ({ monitor }: { monitor: Monitor }) => (
<box vertical className="pane alerts" name="alerts">
<Notifications />
<box className="separator" />
<Headlines monitor={monitor} />
</box>
);

View file

@ -1,13 +0,0 @@
import DeviceSelector from "./modules/deviceselector";
import Media from "./modules/media";
import Streams from "./modules/streams";
export default () => (
<box vertical className="pane audio" name="audio">
<Media />
<box className="separator" />
<Streams />
<box className="separator" />
<DeviceSelector />
</box>
);

View file

@ -1,10 +0,0 @@
import Bluetooth from "./modules/bluetooth";
import Networks from "./modules/networks";
export default () => (
<box vertical className="pane connectivity" name="connectivity">
<Networks />
<box className="separator" />
<Bluetooth />
</box>
);

View file

@ -1,132 +0,0 @@
import Players from "@/services/players";
import { lengthStr } from "@/utils/strings";
import { bindCurrentTime, osIcon } from "@/utils/system";
import Slider from "@/widgets/slider";
import { bind, GLib, monitorFile, Variable } from "astal";
import { Gtk } from "astal/gtk3";
import AstalMpris from "gi://AstalMpris";
import Notifications from "./modules/notifications";
import Upcoming from "./modules/upcoming";
const noNull = (s: string | null) => s ?? "-";
const FaceFallback = () => (
<label
setup={self => {
const name = GLib.get_real_name();
if (name !== "Unknown")
self.label = name
.split(" ")
.map(s => s[0].toUpperCase())
.join("");
else {
self.label = "";
self.xalign = 0.44;
}
}}
/>
);
const User = () => {
const uptime = Variable("").poll(5000, "uptime -p");
const hasFace = Variable(GLib.file_test(HOME + "/.face", GLib.FileTest.EXISTS));
return (
<box className="user">
<box
homogeneous
className="face"
setup={self => {
self.css = `background-image: url("${HOME}/.face");`;
const monitor = monitorFile(HOME + "/.face", () => {
hasFace.set(GLib.file_test(HOME + "/.face", GLib.FileTest.EXISTS));
self.css = `background-image: url("${HOME}/.face");`;
});
self.connect("destroy", () => monitor.cancel());
}}
>
{bind(hasFace).as(h => (h ? <box visible={false} /> : <FaceFallback />))}
</box>
<box vertical hexpand valign={Gtk.Align.CENTER} className="details">
<label truncate xalign={0} className="name" label={`${osIcon} ${GLib.get_user_name()}`} />
<label truncate xalign={0} label={bind(uptime)} onDestroy={() => uptime.drop()} />
<label truncate xalign={0} label={bindCurrentTime("%A, %e %B")} />
</box>
</box>
);
};
const Media = ({ player }: { player: AstalMpris.Player | null }) => {
const position = player
? Variable.derive([bind(player, "position"), bind(player, "length")], (p, l) => p / l)
: Variable(0);
return (
<box className="media" onDestroy={() => position.drop()}>
<box
homogeneous
className="cover-art"
css={player ? bind(player, "coverArt").as(a => `background-image: url("${a}");`) : ""}
>
{player ? (
bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.31} label="" />))
) : (
<label xalign={0.31} label="" />
)}
</box>
<box vertical className="details">
<label truncate className="title" label={player ? bind(player, "title").as(noNull) : ""} />
<label truncate className="artist" label={player ? bind(player, "artist").as(noNull) : "No media"} />
<box hexpand className="controls">
<button
hexpand
sensitive={player ? bind(player, "canGoPrevious") : false}
cursor="pointer"
onClicked={() => player?.next()}
label="󰒮"
/>
<button
hexpand
sensitive={player ? bind(player, "canControl") : false}
cursor="pointer"
onClicked={() => player?.play_pause()}
label={
player
? bind(player, "playbackStatus").as(s =>
s === AstalMpris.PlaybackStatus.PLAYING ? "󰏤" : "󰐊"
)
: "󰐊"
}
/>
<button
hexpand
sensitive={player ? bind(player, "canGoNext") : false}
cursor="pointer"
onClicked={() => player?.next()}
label="󰒭"
/>
</box>
<Slider value={bind(position)} onChange={(_, v) => player?.set_position(v * player.length)} />
<box className="time">
<label label={player ? bind(player, "position").as(lengthStr) : "-1:-1"} />
<box hexpand />
<label label={player ? bind(player, "length").as(lengthStr) : "-1:-1"} />
</box>
</box>
</box>
);
};
export default () => (
<box vertical className="pane dashboard" name="dashboard">
<User />
<box className="separator" />
{bind(Players.get_default(), "lastPlayer").as(p => (
<Media player={p} />
))}
<box className="separator" />
<Notifications compact />
<box className="separator" />
<Upcoming />
</box>
);

View file

@ -1,87 +0,0 @@
import type { Monitor } from "@/services/monitors";
import { bind, idle, register, Variable } from "astal";
import { App, Astal, Gdk, Gtk, Widget } from "astal/gtk3";
import { sidebar as config } from "config";
import Alerts from "./alerts";
import Audio from "./audio";
import Connectivity from "./connectivity";
import Dashboard from "./dashboard";
import Packages from "./packages";
import Time from "./time";
export const paneNames = ["dashboard", "audio", "connectivity", "packages", "alerts", "time"] as const;
export type PaneName = (typeof paneNames)[number];
export const switchPane = (monitor: Monitor, name: PaneName) => {
const sidebar = App.get_window(`sidebar${monitor.id}`) as SideBar | null;
if (sidebar) {
if (sidebar.visible && sidebar.shown.get() === name) sidebar.hide();
else sidebar.show();
sidebar.shown.set(name);
}
};
export const awaitSidebar = (monitor: Monitor) =>
new Promise<SideBar>(resolve => {
let sidebar: SideBar | null = null;
const awaitSidebar = () => {
sidebar = App.get_window(`sidebar${monitor.id}`) as SideBar | null;
if (sidebar) resolve(sidebar);
else idle(awaitSidebar);
};
idle(awaitSidebar);
});
const getPane = (monitor: Monitor, name: PaneName) => {
if (name === "dashboard") return <Dashboard />;
if (name === "audio") return <Audio />;
if (name === "connectivity") return <Connectivity />;
if (name === "packages") return <Packages monitor={monitor} />;
if (name === "alerts") return <Alerts monitor={monitor} />;
return <Time />;
};
@register()
export default class SideBar extends Widget.Window {
readonly shown: Variable<PaneName>;
constructor({ monitor }: { monitor: Monitor }) {
super({
application: App,
name: `sidebar${monitor.id}`,
namespace: "caelestia-sidebar",
monitor: monitor.id,
anchor: Astal.WindowAnchor.LEFT | Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM,
exclusivity: Astal.Exclusivity.EXCLUSIVE,
visible: false,
});
this.shown = Variable(paneNames[0]);
this.add(
<eventbox
onScroll={(_, event) => {
if (event.modifier & Gdk.ModifierType.BUTTON1_MASK) {
const index = paneNames.indexOf(this.shown.get()) + (event.delta_y < 0 ? -1 : 1);
if (index < 0 || index >= paneNames.length) return;
this.shown.set(paneNames[index]);
}
}}
>
<box vertical className="sidebar">
<stack
vexpand
transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
transitionDuration={200}
shown={bind(this.shown)}
>
{paneNames.map(n => getPane(monitor, n))}
</stack>
</box>
</eventbox>
);
if (config.showOnStartup.get()) idle(() => this.show());
}
}

View file

@ -1,127 +0,0 @@
import { bind, Variable } from "astal";
import { Astal, Gtk } from "astal/gtk3";
import AstalBluetooth from "gi://AstalBluetooth";
const sortDevices = (a: AstalBluetooth.Device, b: AstalBluetooth.Device) => {
if (a.connected || b.connected) return a.connected ? -1 : 1;
if (a.paired || b.paired) return a.paired ? -1 : 1;
return 0;
};
const BluetoothDevice = (device: AstalBluetooth.Device) => (
<box className={bind(device, "connected").as(c => `device ${c ? "connected" : ""}`)}>
<icon
className="icon"
icon={bind(device, "icon").as(i =>
Astal.Icon.lookup_icon(`${i}-symbolic`) ? `${i}-symbolic` : "bluetooth-symbolic"
)}
/>
<box vertical hexpand>
<label truncate xalign={0} label={bind(device, "alias")} />
<label
truncate
className="sublabel"
xalign={0}
setup={self => {
const update = () => {
self.label =
(device.connected ? "Connected" : "Paired") +
(device.batteryPercentage >= 0 ? ` (${device.batteryPercentage * 100}%)` : "");
self.visible = device.connected || device.paired;
};
self.hook(device, "notify::connected", update);
self.hook(device, "notify::paired", update);
self.hook(device, "notify::battery-percentage", update);
update();
}}
/>
</box>
<button
valign={Gtk.Align.CENTER}
visible={bind(device, "paired")}
cursor="pointer"
onClicked={() => AstalBluetooth.get_default().adapter.remove_device(device)}
label="delete"
/>
<button
valign={Gtk.Align.CENTER}
cursor="pointer"
onClicked={self => {
if (device.connected)
device.disconnect_device((_, res) => {
self.sensitive = true;
device.disconnect_device_finish(res);
});
else
device.connect_device((_, res) => {
self.sensitive = true;
device.connect_device_finish(res);
});
self.sensitive = false;
}}
label={bind(device, "connected").as(c => (c ? "bluetooth_disabled" : "bluetooth_searching"))}
/>
</box>
);
const List = ({ devNotify }: { devNotify: Variable<boolean> }) => (
<box vertical valign={Gtk.Align.START} className="list">
{bind(devNotify).as(() => AstalBluetooth.get_default().devices.sort(sortDevices).map(BluetoothDevice))}
</box>
);
const NoDevices = () => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="bluetooth_searching" />
<label label="No Bluetooth devices" />
</box>
</box>
);
export default () => {
const bluetooth = AstalBluetooth.get_default();
const devNotify = Variable(false); // Aggregator for device state changes (connected/paired)
const update = () => devNotify.set(!devNotify.get());
const connectSignals = (device: AstalBluetooth.Device) => {
device.connect("notify::connected", update);
device.connect("notify::paired", update);
};
bluetooth.get_devices().forEach(connectSignals);
bluetooth.connect("device-added", (_, device) => connectSignals(device));
bluetooth.connect("notify::devices", update);
return (
<box vertical className="bluetooth">
<box className="header-bar">
<label
label={bind(devNotify).as(() => {
const nConnected = bluetooth.get_devices().filter(d => d.connected).length;
return `${nConnected} connected device${nConnected === 1 ? "" : "s"}`;
})}
/>
<box hexpand />
<button
className={bind(bluetooth.adapter, "discovering").as(d => (d ? "enabled" : ""))}
cursor="pointer"
onClicked={() => {
if (bluetooth.adapter.discovering) bluetooth.adapter.start_discovery();
else bluetooth.adapter.stop_discovery();
}}
label="󰀂 Discovery"
/>
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(bluetooth, "devices").as(d => (d.length > 0 ? "list" : "empty"))}
>
<NoDevices />
<scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
<List devNotify={devNotify} />
</scrollable>
</stack>
</box>
);
};

View file

@ -1,252 +0,0 @@
import Calendar, { type IEvent } from "@/services/calendar";
import { setupCustomTooltip } from "@/utils/widgets";
import { bind, GLib, Variable } from "astal";
import { Gtk } from "astal/gtk3";
import ical from "ical.js";
const isLeapYear = (year: number) => year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
const getMonthDays = (month: number, year: number) => {
const leapYear = isLeapYear(year);
if (month === 2 && leapYear) return leapYear ? 29 : 28;
if ((month <= 7 && month % 2 === 1) || (month >= 8 && month % 2 === 0)) return 31;
return 30;
};
const getNextMonthDays = (month: number, year: number) => {
if (month === 12) return 31;
return getMonthDays(month + 1, year);
};
const getPrevMonthDays = (month: number, year: number) => {
if (month === 1) return 31;
return getMonthDays(month - 1, year);
};
export function getCalendarLayout(date: ical.Time) {
const weekdayOfMonthFirst = date.startOfMonth().dayOfWeek(ical.Time.MONDAY);
const daysInMonth = getMonthDays(date.month, date.year);
const daysInPrevMonth = getPrevMonthDays(date.month, date.year);
const calendar: ical.Time[][] = [];
let idx = -weekdayOfMonthFirst + 2;
for (let i = 0; i < 6; i++) {
calendar.push([]);
for (let j = 0; j < 7; j++) {
let cDay = idx++;
let cMonth = date.month;
let cYear = date.year;
if (idx < 0) {
cDay = daysInPrevMonth + cDay;
cMonth--;
if (cMonth < 0) {
cMonth += 12;
cYear--;
}
} else if (idx > daysInMonth) {
cDay -= daysInMonth;
cMonth++;
if (cMonth > 12) {
cMonth -= 12;
cYear++;
}
}
calendar[i].push(ical.Time.fromData({ day: cDay, month: cMonth, year: cYear }));
}
}
return calendar;
}
const dateToMonthYear = (date: ical.Time) => {
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return `${months[date.month - 1]} ${date.year}`;
};
const addMonths = (date: ical.Time, num: number) => {
date = date.clone();
if (num > 0) for (let i = 0; i < num; i++) date.adjust(getNextMonthDays(date.month, date.year), 0, 0, 0);
else for (let i = 0; i > num; i--) date.adjust(-getPrevMonthDays(date.month, date.year), 0, 0, 0);
return date;
};
const getDayClassName = (day: ical.Time, current: Variable<ical.Time>) => {
const isToday = day.toJSDate().toDateString() === new Date().toDateString() ? "today" : "";
const numEvents = Math.min(5, Calendar.get_default().getEventsForDay(day).length);
return `day ${isToday} ${day.month !== current.get().month ? "dim" : ""} events-${numEvents}`;
};
const getDayTooltip = (day: ical.Time) => {
const events = Calendar.get_default().getEventsForDay(day);
if (!events.length) return "";
const eventsStr = events
.map(e => {
const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime());
const end = GLib.DateTime.new_from_unix_local(e.endDate.toUnixTime());
const sameAmPm = start.format("%P") === end.format("%P");
const time = `${start.format(`%-I:%M${sameAmPm ? "" : "%P"}`)}${end.format("%-I:%M%P")}`;
return `<b>${e.event.summary.replaceAll("&", "&amp;")}</b> • ${time}`;
})
.join("\n");
return `${events.length} event${events.length === 1 ? "" : "s"}\n${eventsStr}`;
};
const getEventsHeader = (current: ical.Time) => {
const events = Calendar.get_default().getEventsForDay(current);
const isToday = current.toJSDate().toDateString() === new Date().toDateString();
return (
(isToday ? "Today • " : "") +
GLib.DateTime.new_from_unix_local(current.toUnixTime()).format("%B %-d • %A") +
`${events.length} event${events.length === 1 ? "" : "s"}`
);
};
const getEventHeader = (e: IEvent) => {
const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime());
const time = `${start.format("%-I")}${start.get_minute() > 0 ? `:${start.get_minute()}` : ""}${start.format("%P")}`;
return `${time} <b>${e.event.summary.replaceAll("&", "&amp;")}</b>`;
};
const getEventTooltip = (e: IEvent) => {
const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime());
const end = GLib.DateTime.new_from_unix_local(e.event.endDate.toUnixTime());
const sameAmPm = start.format("%P") === end.format("%P");
const time = `${start.format(`%A, %-d %B • %-I:%M${sameAmPm ? "" : "%P"}`)}${end.format("%-I:%M%P")}`;
const locIfExists = e.event.location ? `${e.event.location}\n` : "";
const descIfExists = e.event.description ? `󰒿 ${e.event.description}\n` : "";
return `<b>${e.event.summary}</b>\n${time}\n${locIfExists}${descIfExists}󰃭 ${e.calendar}`.replaceAll("&", "&amp;");
};
const Day = ({ day, shown, current }: { day: ical.Time; shown: Variable<string>; current: Variable<ical.Time> }) => (
<button
className={bind(Calendar.get_default(), "calendars").as(() => getDayClassName(day, current))}
cursor="pointer"
onClicked={() => {
shown.set("events");
current.set(day);
}}
setup={self =>
setupCustomTooltip(
self,
bind(Calendar.get_default(), "calendars").as(() => getDayTooltip(day)),
{ useMarkup: true }
)
}
>
<box vertical>
<label label={day.day.toString()} />
<box className="indicator" />
</box>
</button>
);
const CalendarView = ({ shown, current }: { shown: Variable<string>; current: Variable<ical.Time> }) => (
<box vertical className="calendar-view" name="calendar">
<box className="header">
<button
cursor="pointer"
onClicked={() => current.set(ical.Time.now())}
label={bind(current).as(dateToMonthYear)}
/>
<box hexpand />
<button cursor="pointer" onClicked={() => current.set(addMonths(current.get(), -1))} label="" />
<button cursor="pointer" onClicked={() => current.set(addMonths(current.get(), 1))} label="" />
</box>
<box halign={Gtk.Align.CENTER} className="weekdays">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(d => (
<label label={d} />
))}
</box>
<box vertical halign={Gtk.Align.CENTER} className="month">
{bind(current).as(c =>
getCalendarLayout(c).map(r => (
<box className="week">
{r.map(d => (
<Day day={d} shown={shown} current={current} />
))}
</box>
))
)}
</box>
</box>
);
const Event = (event: IEvent) => (
<box className="event" setup={self => setupCustomTooltip(self, getEventTooltip(event), { useMarkup: true })}>
<box className={`calendar-indicator calendar-${Calendar.get_default().getCalendarIndex(event.calendar)}`} />
<box vertical>
<label truncate useMarkup xalign={0} label={getEventHeader(event)} />
{event.event.location && <label truncate xalign={0} label={event.event.location} className="sublabel" />}
{event.event.description && (
<label truncate useMarkup xalign={0} label={event.event.description} className="sublabel" />
)}
</box>
</box>
);
const List = ({ current }: { current: Variable<ical.Time> }) => (
<box vertical valign={Gtk.Align.START} className="list">
{bind(current).as(c => Calendar.get_default().getEventsForDay(c).map(Event))}
</box>
);
const NoEvents = () => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="calendar_month" />
<label label="Day all clear!" />
</box>
</box>
);
const Events = ({ shown, current }: { shown: Variable<string>; current: Variable<ical.Time> }) => (
<box vertical className="events" name="events">
<box className="header">
<button cursor="pointer" onClicked={() => shown.set("calendar")} label="" />
<label hexpand truncate xalign={0} label={bind(current).as(getEventsHeader)} />
</box>
<stack shown={bind(current).as(c => (Calendar.get_default().getEventsForDay(c).length > 0 ? "list" : "empty"))}>
<NoEvents />
<scrollable hscroll={Gtk.PolicyType.NEVER} name="list">
<List current={current} />
</scrollable>
</stack>
</box>
);
export default () => {
const shown = Variable<"calendar" | "events">("calendar");
const current = Variable(ical.Time.now());
return (
<box vertical className="calendar">
<stack
transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT}
transitionDuration={150}
shown={bind(shown)}
>
<CalendarView shown={shown} current={current} />
<Events shown={shown} current={current} />
</stack>
</box>
);
};

View file

@ -1,126 +0,0 @@
import { bind, execAsync, Variable, type Binding } from "astal";
import { Astal, Gtk } from "astal/gtk3";
import AstalWp from "gi://AstalWp";
const Device = ({
input,
defaultDevice,
showDropdown,
device,
}: {
input?: boolean;
defaultDevice: Binding<AstalWp.Endpoint>;
showDropdown: Variable<boolean>;
device: AstalWp.Endpoint;
}) => (
<button
visible={defaultDevice.get().id !== device.id}
cursor="pointer"
onClicked={() => {
execAsync(`wpctl set-default ${device.id}`).catch(console.error);
showDropdown.set(false);
}}
setup={self => {
let last: { d: AstalWp.Endpoint; id: number } | null = {
d: defaultDevice.get(),
id: defaultDevice
.get()
.connect("notify::id", () => self.set_visible(defaultDevice.get().id !== device.id)),
};
self.hook(defaultDevice, (_, d) => {
last?.d.disconnect(last.id);
self.set_visible(d.id !== device.id);
last = {
d,
id: d.connect("notify::id", () => self.set_visible(d.id !== device.id)),
};
});
self.connect("destroy", () => last?.d.disconnect(last.id));
}}
>
<box className="device">
{bind(device, "icon").as(i =>
Astal.Icon.lookup_icon(i) ? (
<icon className="icon" icon={device.icon} />
) : (
<label className="icon" label={input ? "mic" : "media_output"} />
)
)}
<label truncate label={bind(device, "description")} />
</box>
</button>
);
const DefaultDevice = ({ input, device }: { input?: boolean; device: AstalWp.Endpoint }) => (
<box className="selected">
<label className="icon" label={input ? "mic" : "media_output"} />
<box vertical>
<label
truncate
xalign={0}
label={bind(device, "description").as(d => (input ? "[In] " : "[Out] ") + (d ?? "Unknown"))}
/>
<label
xalign={0}
className="sublabel"
label={bind(device, "volume").as(v => `Volume ${Math.round(v * 100)}%`)}
/>
</box>
</box>
);
const Selector = ({ input, audio }: { input?: boolean; audio: AstalWp.Audio }) => {
const showDropdown = Variable(false);
const defaultDevice = bind(audio, input ? "defaultMicrophone" : "defaultSpeaker");
return (
<box vertical className="selector">
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
transitionDuration={150}
revealChild={bind(showDropdown)}
>
<box vertical className="list">
{bind(audio, input ? "microphones" : "speakers").as(ds =>
ds.map(d => (
<Device
input={input}
defaultDevice={defaultDevice}
showDropdown={showDropdown}
device={d}
/>
))
)}
<box className="separator" />
</box>
</revealer>
<button cursor="pointer" onClick={() => showDropdown.set(!showDropdown.get())}>
{defaultDevice.as(d => (
<DefaultDevice input={input} device={d} />
))}
</button>
</box>
);
};
const NoWp = () => (
<box homogeneous>
<box vertical valign={Gtk.Align.CENTER}>
<label label="Device selector unavailable" />
<label className="no-wp-prompt" label="WirePlumber is required for this module" />
</box>
</box>
);
export default () => {
const audio = AstalWp.get_default()?.get_audio();
if (!audio) return <NoWp />;
return (
<box vertical className="device-selector">
<Selector input audio={audio} />
<Selector audio={audio} />
</box>
);
};

View file

@ -1,204 +0,0 @@
import type { Monitor } from "@/services/monitors";
import News, { type IArticle } from "@/services/news";
import Palette, { type IPalette } from "@/services/palette";
import { capitalize } from "@/utils/strings";
import { bind, execAsync, Variable } from "astal";
import { Gtk } from "astal/gtk3";
import { sidebar } from "config";
import { setConfig } from "config/funcs";
const fixGoogleNews = (colours: IPalette, title: string, desc: string) => {
// Add separator, bold and split at domain (domain is at the end of each headline)
const domain = title.split(" - ").at(-1);
if (domain) desc = desc.replaceAll(domain, `— <span foreground="${colours.subtext0}">${domain}</span>\n\n`);
// Split headlines
desc = desc.replace(/(( |\.)[^A-Z][a-z]+)([A-Z])/g, "$1\n\n$3");
desc = desc.replace(/( [A-Z]+)([A-Z](?![s])[a-z])/g, "$1\n\n$2");
// Add separator and bold domains
desc = desc.replace(/ ([a-zA-Z.]+)\n\n/g, ` — <span foreground="${colours.subtext0}">$1</span>\n\n`);
desc = desc.replace(/ ([a-zA-Z.]+)$/, ` — <span foreground="${colours.subtext0}">$1</span>`); // Last domain
return desc.trim();
};
const fixNews = (colours: IPalette, title: string, desc: string, source: string) => {
// Add spaces between sentences
desc = desc.replace(/\.([A-Z])/g, ". $1");
// Google News needs some other fixes
if (source === "Google News") desc = fixGoogleNews(colours, title, desc);
return desc.replaceAll("&", "&amp;");
};
const getCategoryIcon = (category: string) => {
if (category === "business") return "monitoring";
if (category === "crime") return "speed_camera";
if (category === "domestic") return "home";
if (category === "education") return "school";
if (category === "entertainment") return "tv";
if (category === "environment") return "eco";
if (category === "food") return "restaurant";
if (category === "health") return "health_and_safety";
if (category === "lifestyle") return "digital_wellbeing";
if (category === "politics") return "account_balance";
if (category === "science") return "science";
if (category === "sports") return "sports_basketball";
if (category === "technology") return "account_tree";
if (category === "top") return "breaking_news";
if (category === "tourism") return "travel";
if (category === "world") return "public";
return "newsmode";
};
const Article = ({ title, description, creator, pubDate, source_name, link }: IArticle) => {
const expanded = Variable(false);
return (
<box vertical className="article">
<button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}>
<box hexpand className="header">
<box vertical>
<label truncate xalign={0} label={title} />
<label
truncate
xalign={0}
className="sublabel"
label={source_name + (creator ? ` (${creator.join(", ")})` : "")}
/>
</box>
</box>
</button>
<revealer
revealChild={bind(expanded)}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={200}
>
<button onClicked={() => execAsync(`app2unit -O -- ${link}`)}>
<box vertical className="article-body">
<label wrap className="title" xalign={0} label={title} />
<label wrap xalign={0} label={`Published on ${new Date(pubDate).toLocaleString()}`} />
<label
wrap
xalign={0}
className="sublabel"
label={`By ${
creator?.join(", ") ??
(source_name === "Google News" ? title.split(" - ").at(-1) : source_name)
}`}
/>
{description && (
<label
wrap
useMarkup
xalign={0}
label={bind(Palette.get_default(), "colours").as(c =>
fixNews(c, title, description, source_name)
)}
/>
)}
</box>
</button>
</revealer>
</box>
);
};
const Category = ({ title, articles }: { title: string; articles: IArticle[] }) => {
const expanded = Variable(false);
return (
<box vertical className="category">
<button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}>
<box className="header">
<label className="icon" label={getCategoryIcon(title)} />
<label label={`${capitalize(title)} (${articles.length})`} />
<box hexpand />
<label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} />
</box>
</button>
<revealer
revealChild={bind(expanded)}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={200}
>
<box vertical className="body">
{articles
.sort((a, b) => a.source_priority - b.source_priority)
.map(a => (
<Article {...a} />
))}
</box>
</revealer>
</box>
);
};
const List = () => (
<box vertical valign={Gtk.Align.START} className="list">
{bind(News.get_default(), "categories").as(c =>
Object.entries(c).map(([k, v]) => <Category title={k} articles={v} />)
)}
</box>
);
const NoNews = ({ disabled }: { disabled?: boolean }) => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="full_coverage" />
<label label={disabled ? "Headlines disabled" : "No news headlines!"} />
</box>
</box>
);
const HeadlinesDisabled = () => (
<>
<box vertical className="headlines">
<box className="header-bar">
<label label="Top news headlines" />
<box hexpand />
<button
cursor="pointer"
onClicked={() => setConfig("sidebar.modules.headlines.enabled", true)}
label="󰞉 Enable"
/>
</box>
<NoNews disabled />
</box>
</>
);
const Headlines = ({ monitor }: { monitor: Monitor }) => (
<>
<box className="header-bar">
<label label="Top news headlines" />
<box hexpand />
<button
className={bind(News.get_default(), "loading").as(l => (l ? "enabled" : ""))}
sensitive={bind(News.get_default(), "loading").as(l => !l)}
cursor="pointer"
onClicked={() => News.get_default().getNews()}
label={bind(News.get_default(), "loading").as(l => (l ? "󰑓 Loading" : "󰑓 Reload"))}
/>
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(News.get_default(), "articles").as(a => (a.length > 0 ? "list" : "empty"))}
>
<NoNews />
<scrollable
css={bind(News.get_default(), "articles").as(a =>
a.length > 0 ? `min-height: ${Math.round(monitor.height * 0.4)}px;` : ""
)}
hscroll={Gtk.PolicyType.NEVER}
name="list"
>
<List />
</scrollable>
</stack>
</>
);
export default ({ monitor }: { monitor: Monitor }) => (
<box vertical className="headlines">
{bind(sidebar.modules.headlines.enabled).as(e => (e ? <Headlines monitor={monitor} /> : <HeadlinesDisabled />))}
</box>
);

View file

@ -1,67 +0,0 @@
import Cpu from "@/services/cpu";
import Gpu from "@/services/gpu";
import Memory from "@/services/memory";
import Storage from "@/services/storage";
import Slider from "@/widgets/slider";
import { bind, type Binding } from "astal";
import { Gtk, type Widget } from "astal/gtk3";
const fmt = (bytes: number, pow: number) => +(bytes / 1024 ** pow).toFixed(2);
const format = ({ total, used }: { total: number; used: number }) => {
if (total >= 1024 ** 4) return `${fmt(used, 4)}/${fmt(total, 4)} TiB`;
if (total >= 1024 ** 3) return `${fmt(used, 3)}/${fmt(total, 3)} GiB`;
if (total >= 1024 ** 2) return `${fmt(used, 2)}/${fmt(total, 2)} MiB`;
if (total >= 1024) return `${fmt(used, 1)}/${fmt(total, 1)} KiB`;
return `${used}/${total} B`;
};
const Resource = ({
icon,
name,
value,
labelSetup,
}: {
icon: string;
name: string;
value: Binding<number>;
labelSetup?: (self: Widget.Label) => void;
}) => (
<box vertical className={`resource ${name}`}>
<box className="inner">
<label label={icon} />
<Slider value={value.as(v => v / 100)} />
</box>
<label halign={Gtk.Align.END} label={labelSetup ? "" : value.as(v => `${+v.toFixed(2)}%`)} setup={labelSetup} />
</box>
);
export default () => (
<box vertical className="hw-resources">
{Gpu.get_default().available && <Resource icon="󰢮" name="gpu" value={bind(Gpu.get_default(), "usage")} />}
<Resource icon="" name="cpu" value={bind(Cpu.get_default(), "usage")} />
<Resource
icon=""
name="memory"
value={bind(Memory.get_default(), "usage")}
labelSetup={self => {
const mem = Memory.get_default();
const update = () => (self.label = format(mem));
self.hook(mem, "notify::used", update);
self.hook(mem, "notify::total", update);
update();
}}
/>
<Resource
icon="󰋊"
name="storage"
value={bind(Storage.get_default(), "usage")}
labelSetup={self => {
const storage = Storage.get_default();
const update = () => (self.label = format(storage));
self.hook(storage, "notify::used", update);
self.hook(storage, "notify::total", update);
update();
}}
/>
</box>
);

View file

@ -1,168 +0,0 @@
import Players from "@/services/players";
import { lengthStr } from "@/utils/strings";
import Slider from "@/widgets/slider";
import { bind, timeout, Variable } from "astal";
import { Gtk } from "astal/gtk3";
import AstalMpris from "gi://AstalMpris";
const noNull = (s: string | null) => s ?? "-";
const NoMedia = () => (
<box vertical className="player" name="none">
<box homogeneous halign={Gtk.Align.CENTER} className="cover-art">
<label xalign={0.4} label="" />
</box>
<box vertical className="progress">
<Slider value={bind(Variable(0))} />
<box className="time">
<label label="-1:-1" />
<box hexpand />
<label label="-1:-1" />
</box>
</box>
<box vertical className="details">
<label truncate className="title" label="No media" />
<label truncate className="artist" label="Try play some music!" />
<label truncate className="album" label="" />
</box>
<box vertical className="controls">
<box halign={Gtk.Align.CENTER} className="playback">
<button sensitive={false} cursor="pointer" label="󰒮" />
<button sensitive={false} cursor="pointer" label="󰐊" />
<button sensitive={false} cursor="pointer" label="󰒭" />
</box>
<box className="options">
<button sensitive={false} cursor="pointer" label="󰊓" />
<button sensitive={false} cursor="pointer" label="󰒞" />
<box hexpand />
<button className="needs-adjustment" sensitive={false} cursor="pointer" label="󰑗" />
<button className="needs-adjustment" sensitive={false} cursor="pointer" label="󰀽" />
</box>
</box>
</box>
);
const Player = ({ player }: { player: AstalMpris.Player }) => {
const position = Variable.derive([bind(player, "position"), bind(player, "length")], (p, l) => p / l);
return (
<box vertical className="player" name={player.busName} onDestroy={() => position.drop()}>
<box
homogeneous
halign={Gtk.Align.CENTER}
className="cover-art"
css={bind(player, "coverArt").as(a => `background-image: url("${a}");`)}
>
{bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.4} label="" />))}
</box>
<box vertical className="progress">
<Slider value={bind(position)} onChange={(_, v) => player.set_position(v * player.length)} />
<box className="time">
<label label={bind(player, "position").as(lengthStr)} />
<box hexpand />
<label label={bind(player, "length").as(lengthStr)} />
</box>
</box>
<box vertical className="details">
<label truncate className="title" label={bind(player, "title").as(noNull)} />
<label truncate className="artist" label={bind(player, "artist").as(noNull)} />
<label truncate className="album" label={bind(player, "album").as(noNull)} />
</box>
<box vertical className="controls">
<box halign={Gtk.Align.CENTER} className="playback">
<button
sensitive={bind(player, "canGoPrevious")}
cursor="pointer"
onClicked={() => player.next()}
label="󰒮"
/>
<button
sensitive={bind(player, "canControl")}
cursor="pointer"
onClicked={() => player.play_pause()}
label={bind(player, "playbackStatus").as(s =>
s === AstalMpris.PlaybackStatus.PLAYING ? "󰏤" : "󰐊"
)}
/>
<button
sensitive={bind(player, "canGoNext")}
cursor="pointer"
onClicked={() => player.next()}
label="󰒭"
/>
</box>
<box className="options">
<button
sensitive={bind(player, "canSetFullscreen")}
cursor="pointer"
onClicked={() => player.toggle_fullscreen()}
label={bind(player, "fullscreen").as(f => (f ? "󰊔" : "󰊓"))}
/>
<button
sensitive={bind(player, "canControl")}
cursor="pointer"
onClicked={() => player.shuffle()}
label={bind(player, "shuffleStatus").as(s => (s === AstalMpris.Shuffle.ON ? "󰒝" : "󰒞"))}
/>
<box hexpand />
<button
className="needs-adjustment"
sensitive={bind(player, "canControl")}
cursor="pointer"
onClicked={() => player.loop()}
label={bind(player, "loopStatus").as(l =>
l === AstalMpris.Loop.TRACK ? "󰑘" : l === AstalMpris.Loop.PLAYLIST ? "󰑖" : "󰑗"
)}
/>
<button
className="needs-adjustment"
sensitive={bind(player, "canRaise")}
cursor="pointer"
onClicked={() => player.raise()}
label="󰀽"
/>
</box>
</box>
</box>
);
};
const Indicator = ({ active, player }: { active: Variable<string>; player: AstalMpris.Player }) => (
<button
className={bind(active).as(a => (a === player.busName ? "active" : ""))}
cursor="pointer"
onClicked={() => active.set(player.busName)}
/>
);
export default () => {
const players = Players.get_default();
const active = Variable(players.lastPlayer?.busName ?? "none");
active.observe(players, "notify::list", () => {
timeout(10, () => active.set(players.lastPlayer?.busName ?? "none"));
return "none";
});
return (
<box vertical className="players" onDestroy={() => active.drop()}>
<stack
transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT}
transitionDuration={150}
shown={bind(active)}
>
<NoMedia />
{bind(players, "list").as(ps => ps.map(p => <Player player={p} />))}
</stack>
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={120}
revealChild={bind(players, "list").as(l => l.length > 1)}
>
<box halign={Gtk.Align.CENTER} className="indicators">
{bind(players, "list").as(ps => ps.map(p => <Indicator active={active} player={p} />))}
</box>
</revealer>
</box>
);
};

View file

@ -1,151 +0,0 @@
import { bind, execAsync, Variable, type Binding } from "astal";
import { Gtk } from "astal/gtk3";
import AstalNetwork from "gi://AstalNetwork";
const sortAPs = (saved: string[], a: AstalNetwork.AccessPoint, b: AstalNetwork.AccessPoint) => {
const { wifi } = AstalNetwork.get_default();
if (a === wifi.activeAccessPoint || b === wifi.activeAccessPoint) return a === wifi.activeAccessPoint ? -1 : 1;
if (saved.includes(a.ssid) || saved.includes(b.ssid)) return saved.includes(a.ssid) ? -1 : 1;
return b.strength - a.strength;
};
const Network = (accessPoint: AstalNetwork.AccessPoint) => (
<box
className={bind(AstalNetwork.get_default().wifi, "activeAccessPoint").as(
a => `network ${a === accessPoint ? "connected" : ""}`
)}
>
<icon className="icon" icon={bind(accessPoint, "iconName")} />
<box vertical hexpand>
<label truncate xalign={0} label={bind(accessPoint, "ssid").as(s => s ?? "Unknown")} />
<label
truncate
xalign={0}
className="sublabel"
label={bind(accessPoint, "strength").as(s => `${accessPoint.frequency > 5000 ? 5 : 2.4}GHz • ${s}/100`)}
/>
</box>
<box hexpand />
<button
valign={Gtk.Align.CENTER}
visible={false}
cursor="pointer"
onClicked={() => execAsync(`nmcli c delete id '${accessPoint.ssid}'`).catch(console.error)}
label="delete_forever"
setup={self => {
let destroyed = false;
execAsync(`fish -c "nmcli -t -f name,type c show | sed -nE 's/(.*)\\:.*wireless/\\1/p'"`)
.then(out => !destroyed && (self.visible = out.split("\n").includes(accessPoint.ssid)))
.catch(console.error);
self.connect("destroy", () => (destroyed = true));
}}
/>
<button
valign={Gtk.Align.CENTER}
cursor="pointer"
onClicked={self => {
let destroyed = false;
const id = self.connect("destroy", () => (destroyed = true));
const cmd =
AstalNetwork.get_default().wifi.activeAccessPoint === accessPoint ? "c down id" : "d wifi connect";
execAsync(`nmcli ${cmd} '${accessPoint.ssid}'`)
.then(() => {
if (!destroyed) {
self.sensitive = true;
self.disconnect(id);
}
})
.catch(console.error);
self.sensitive = false;
}}
label={bind(AstalNetwork.get_default().wifi, "activeAccessPoint").as(a =>
a === accessPoint ? "wifi_off" : "wifi"
)}
/>
</box>
);
const List = () => {
const { wifi } = AstalNetwork.get_default();
const children = Variable<JSX.Element[]>([]);
const update = async () => {
const out = await execAsync(`fish -c "nmcli -t -f name,type c show | sed -nE 's/(.*)\\:.*wireless/\\1/p'"`);
const saved = out.split("\n");
const aps = wifi.accessPoints
.filter(a => a.ssid)
.sort((a, b) => sortAPs(saved, a, b))
.map(Network);
children.set(aps);
};
wifi.connect("notify::active-access-point", () => update().catch(console.error));
wifi.connect("notify::access-points", () => update().catch(console.error));
update().catch(console.error);
return (
<box vertical valign={Gtk.Align.START} className="list" onDestroy={() => children.drop()}>
{bind(children)}
</box>
);
};
const NoNetworks = ({ label }: { label: Binding<string> | string }) => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="wifi_off" />
<label label={label} />
</box>
</box>
);
export default () => {
const network = AstalNetwork.get_default();
const label = Variable("");
const update = () => {
if (network.primary === AstalNetwork.Primary.WIFI) label.set(network.wifi.ssid ?? "Disconnected");
else if (network.primary === AstalNetwork.Primary.WIRED) label.set(`Ethernet (${network.wired.speed})`);
else label.set("No Wifi");
};
network.connect("notify::primary", update);
network.get_wifi()?.connect("notify::ssid", update);
network.get_wired()?.connect("notify::speed", update);
update();
return (
<box vertical className="networks">
<box className="header-bar">
<label label={bind(label)} />
<box hexpand />
<button
sensitive={network.get_wifi() ? bind(network.wifi, "scanning").as(e => !e) : false}
className={network.get_wifi() ? bind(network.wifi, "scanning").as(s => (s ? "enabled" : "")) : ""}
cursor="pointer"
onClicked={() => network.get_wifi()?.scan()}
label={
network.get_wifi()
? bind(network.wifi, "scanning").as(s => (s ? "󰀂 Scanning" : "󰀂 Scan"))
: "󰀂 Scan"
}
/>
</box>
{network.get_wifi() ? (
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(network.wifi, "accessPoints").as(a => (a.length > 0 ? "list" : "empty"))}
>
<NoNetworks
label={bind(network.wifi, "enabled").as(p => (p ? "No available networks" : "Wifi is off"))}
/>
<scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
<List />
</scrollable>
</stack>
) : (
<NoNetworks label="Wifi not available" />
)}
</box>
);
};

View file

@ -1,113 +0,0 @@
import type { Monitor } from "@/services/monitors";
import Palette from "@/services/palette";
import Updates from "@/services/updates";
import { setupCustomTooltip } from "@/utils/widgets";
import { bind, Variable } from "astal";
import { Gtk } from "astal/gtk3";
const countNews = (news: string) => news.match(/^([0-9]{4}-[0-9]{2}-[0-9]{2} .+)$/gm)?.length ?? 0;
const News = ({ header, body }: { header: string; body: string }) => {
const expanded = Variable(false);
body = body
.slice(0, -5) // Remove last unopened \x1b[0m
.replaceAll("\x1b[0m", "</span>"); // Replace reset code with end span
return (
<box vertical className="article">
<button
className="wrapper"
cursor="pointer"
onClicked={() => expanded.set(!expanded.get())}
setup={self => setupCustomTooltip(self, header)}
>
<box hexpand className="header">
<label className="icon" label="newspaper" />
<box vertical>
<label xalign={0} label={header.split(" ")[0]} />
<label
truncate
xalign={0}
className="sublabel"
label={header.replace(/[0-9]{4}-[0-9]{2}-[0-9]{2} /, "")}
/>
</box>
<label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} />
</box>
</button>
<revealer
revealChild={bind(expanded)}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={200}
>
<label
wrap
useMarkup
xalign={0}
className="body"
label={bind(Palette.get_default(), "teal").as(
c => body.replaceAll("\x1b[36m", `<span foreground="${c}">`) // Replace colour codes with html spans
)}
/>
</revealer>
</box>
);
};
const List = () => (
<box vertical valign={Gtk.Align.START} className="list">
{bind(Updates.get_default(), "news").as(n => {
const children = [];
const news = n.split(/^([0-9]{4}-[0-9]{2}-[0-9]{2} .+)$/gm);
for (let i = 1; i < news.length - 1; i += 2)
children.push(<News header={news[i].trim()} body={news[i + 1].trim()} />);
return children;
})}
</box>
);
const NoNews = () => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="breaking_news" />
<label label="No Arch news!" />
</box>
</box>
);
export default ({ monitor }: { monitor: Monitor }) => (
<box vertical className="news">
<box className="header-bar">
<label
label={bind(Updates.get_default(), "news")
.as(countNews)
.as(n => `${n} news article${n === 1 ? "" : "s"}`)}
/>
<box hexpand />
<button
className={bind(Updates.get_default(), "loading").as(l => (l ? "enabled" : ""))}
sensitive={bind(Updates.get_default(), "loading").as(l => !l)}
cursor="pointer"
onClicked={() => Updates.get_default().getUpdates()}
label={bind(Updates.get_default(), "loading").as(l => (l ? "󰑓 Loading" : "󰑓 Reload"))}
/>
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(Updates.get_default(), "news").as(n => (n ? "list" : "empty"))}
>
<NoNews />
<scrollable
css={bind(Updates.get_default(), "news").as(n =>
n ? `min-height: ${Math.round(monitor.height * 0.4)}px;` : ""
)}
hscroll={Gtk.PolicyType.NEVER}
name="list"
>
<List />
</scrollable>
</stack>
</box>
);

View file

@ -1,90 +0,0 @@
import Notification from "@/widgets/notification";
import { bind } from "astal";
import { Astal, Gtk } from "astal/gtk3";
import AstalNotifd from "gi://AstalNotifd";
const List = ({ compact }: { compact?: boolean }) => (
<box
vertical
valign={Gtk.Align.START}
className="list"
setup={self => {
const notifd = AstalNotifd.get_default();
const map = new Map<number, Notification>();
const addNotification = (notification: AstalNotifd.Notification) => {
const notif = (<Notification notification={notification} compact={compact} />) as Notification;
notif.connect("destroy", () => map.get(notification.id) === notif && map.delete(notification.id));
map.get(notification.id)?.destroyWithAnims();
map.set(notification.id, notif);
const widget = (
<eventbox
// Dismiss on middle click
onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()}
setup={self => self.hook(notif, "destroy", () => self.destroy())}
>
{notif}
</eventbox>
);
self.pack_end(widget, false, false, 0);
};
notifd
.get_notifications()
.sort((a, b) => a.time - b.time)
.forEach(addNotification);
self.hook(notifd, "notified", (_, id) => addNotification(notifd.get_notification(id)));
self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims());
}}
/>
);
const NoNotifs = () => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="mark_email_unread" />
<label label="All caught up!" />
</box>
</box>
);
export default ({ compact }: { compact?: boolean }) => (
<box vertical className="notifications">
<box className="header-bar">
<label
label={bind(AstalNotifd.get_default(), "notifications").as(
n => `${n.length} notification${n.length === 1 ? "" : "s"}`
)}
/>
<box hexpand />
<button
className={bind(AstalNotifd.get_default(), "dontDisturb").as(d => (d ? "enabled" : ""))}
cursor="pointer"
onClicked={() => (AstalNotifd.get_default().dontDisturb = !AstalNotifd.get_default().dontDisturb)}
label="󰂛 Silence"
/>
<button
cursor="pointer"
onClicked={() =>
AstalNotifd.get_default()
.get_notifications()
.forEach(n => n.dismiss())
}
label="󰎟 Clear"
/>
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(AstalNotifd.get_default(), "notifications").as(n => (n.length > 0 ? "list" : "empty"))}
>
<NoNotifs />
<scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
<List compact={compact} />
</scrollable>
</stack>
</box>
);

View file

@ -1,110 +0,0 @@
import { bind, execAsync, Variable } from "astal";
import { Gtk } from "astal/gtk3";
import AstalWp from "gi://AstalWp";
interface IStream {
stream: AstalWp.Endpoint;
playing: boolean;
}
const header = (audio: AstalWp.Audio, key: "streams" | "speakers" | "recorders") =>
`${audio[key].length} ${audio[key].length === 1 ? key.slice(0, -1) : key}`;
const sortStreams = (a: IStream, b: IStream) => {
if (a.playing || b.playing) return a.playing ? -1 : 1;
return 0;
};
const Stream = ({ stream, playing }: IStream) => (
<box className={`stream ${playing ? "playing" : ""}`}>
<icon className="icon" icon={bind(stream, "icon")} />
<box vertical hexpand>
<label truncate xalign={0} label={bind(stream, "name")} />
<label truncate xalign={0} className="sublabel" label={bind(stream, "description")} />
</box>
<button valign={Gtk.Align.CENTER} cursor="pointer" onClicked={() => (stream.volume -= 0.05)} label="-" />
<slider
showFillLevel
restrictToFillLevel={false}
fillLevel={2 / 3}
value={bind(stream, "volume").as(v => v * (2 / 3))}
setup={self => self.connect("value-changed", () => stream.set_volume(self.value * 1.5))}
/>
<button valign={Gtk.Align.CENTER} cursor="pointer" onClicked={() => (stream.volume += 0.05)} label="+" />
</box>
);
const List = ({ audio }: { audio: AstalWp.Audio }) => {
const streams = Variable<IStream[]>([]);
const update = async () => {
const paStreams = JSON.parse(await execAsync("pactl -f json list sink-inputs"));
streams.set(
audio.streams.map(s => ({
stream: s,
playing: paStreams.find((p: any) => p.properties["object.serial"] == s.serial)?.corked === false,
}))
);
};
streams.watch("pactl -f json subscribe", out => {
if (JSON.parse(out).on === "sink-input") update().catch(console.error);
return streams.get();
});
audio.connect("notify::streams", () => update().catch(console.error));
return (
<box vertical valign={Gtk.Align.START} className="list" onDestroy={() => streams.drop()}>
{bind(streams).as(ps => ps.sort(sortStreams).map(s => <Stream stream={s.stream} playing={s.playing} />))}
</box>
);
};
const NoSources = ({ icon, label }: { icon: string; label: string }) => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label={icon} />
<label label={label} />
</box>
</box>
);
const NoWp = () => (
<box vexpand homogeneous>
<box vertical valign={Gtk.Align.CENTER}>
<NoSources icon="no_sound" label="Streams module unavailable" />
<label className="no-wp-prompt" label="WirePlumber is required for this module" />
</box>
</box>
);
export default () => {
const audio = AstalWp.get_default()?.get_audio();
if (!audio) return <NoWp />;
const label = Variable(`${header(audio, "streams")}${header(audio, "recorders")}`);
label.observe(
["streams", "recorders"].map(k => [audio, `notify::${k}`]),
() => `${header(audio, "streams")}${header(audio, "recorders")}`
);
return (
<box vertical className="streams" onDestroy={() => label.drop()}>
<box halign={Gtk.Align.CENTER} className="header-bar">
<label label={bind(label)} />
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(audio, "streams").as(s => (s.length > 0 ? "list" : "empty"))}
>
<NoSources icon="stream" label="No audio sources" />
<scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
<List audio={audio} />
</scrollable>
</stack>
</box>
);
};

View file

@ -1,99 +0,0 @@
import Calendar, { type IEvent } from "@/services/calendar";
import { setupCustomTooltip } from "@/utils/widgets";
import { bind, GLib } from "astal";
import { Gtk } from "astal/gtk3";
const getDateHeader = (events: IEvent[]) => {
const date = events[0].startDate;
const isToday = date.toJSDate().toDateString() === new Date().toDateString();
return (
(isToday ? "Today • " : "") +
GLib.DateTime.new_from_unix_local(date.toUnixTime()).format("%B %-d • %A") +
`${events.length} event${events.length === 1 ? "" : "s"}`
);
};
const getEventHeader = (e: IEvent) => {
const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime());
const time = `${start.format("%-I")}${start.get_minute() > 0 ? `:${start.get_minute()}` : ""}${start.format("%P")}`;
return `${time} <b>${e.event.summary.replaceAll("&", "&amp;")}</b>`;
};
const getEventTooltip = (e: IEvent) => {
const start = GLib.DateTime.new_from_unix_local(e.startDate.toUnixTime());
const end = GLib.DateTime.new_from_unix_local(e.event.endDate.toUnixTime());
const sameAmPm = start.format("%P") === end.format("%P");
const time = `${start.format(`%A, %-d %B • %-I:%M${sameAmPm ? "" : "%P"}`)}${end.format("%-I:%M%P")}`;
const locIfExists = e.event.location ? `${e.event.location}\n` : "";
const descIfExists = e.event.description ? `󰒿 ${e.event.description}\n` : "";
return `<b>${e.event.summary}</b>\n${time}\n${locIfExists}${descIfExists}󰃭 ${e.calendar}`.replaceAll("&", "&amp;");
};
const Event = (event: IEvent) => (
<box className="event" setup={self => setupCustomTooltip(self, getEventTooltip(event), { useMarkup: true })}>
<box className={`calendar-indicator calendar-${Calendar.get_default().getCalendarIndex(event.calendar)}`} />
<box vertical>
<label truncate useMarkup xalign={0} label={getEventHeader(event)} />
{event.event.location && <label truncate xalign={0} label={event.event.location} className="sublabel" />}
{event.event.description && (
<label truncate useMarkup xalign={0} label={event.event.description} className="sublabel" />
)}
</box>
</box>
);
const Day = ({ events }: { events: IEvent[] }) => (
<box vertical className="day">
<label className="date" xalign={0} label={getDateHeader(events)} />
<box vertical className="events">
{events.map(Event)}
</box>
</box>
);
const List = () => (
<box vertical valign={Gtk.Align.START}>
{bind(Calendar.get_default(), "upcoming").as(u =>
Object.values(u)
.sort((a, b) => a[0].startDate.compare(b[0].startDate))
.map(e => <Day events={e} />)
)}
</box>
);
const NoEvents = () => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="calendar_month" />
<label label="No upcoming events" />
</box>
</box>
);
export default () => (
<box vertical className="upcoming">
<box className="header-bar">
<label
label={bind(Calendar.get_default(), "numUpcoming").as(n => `${n} upcoming event${n === 1 ? "" : "s"}`)}
/>
<box hexpand />
<button
className={bind(Calendar.get_default(), "loading").as(l => (l ? "enabled" : ""))}
sensitive={bind(Calendar.get_default(), "loading").as(l => !l)}
cursor="pointer"
onClicked={() => Calendar.get_default().updateCalendars().catch(console.error)}
label={bind(Calendar.get_default(), "loading").as(l => (l ? "󰑓 Loading" : "󰑓 Reload"))}
/>
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(Calendar.get_default(), "numUpcoming").as(n => (n > 0 ? "list" : "empty"))}
>
<NoEvents />
<scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
<List />
</scrollable>
</stack>
</box>
);

View file

@ -1,109 +0,0 @@
import Palette from "@/services/palette";
import Updates, { Repo as IRepo, Update as IUpdate } from "@/services/updates";
import { MenuItem, setupCustomTooltip } from "@/utils/widgets";
import { bind, execAsync, GLib, Variable } from "astal";
import { Astal, Gtk } from "astal/gtk3";
const constructItem = (label: string, exec: string, quiet = true) =>
new MenuItem({ label, onActivate: () => execAsync(exec).catch(e => !quiet && console.error(e)) });
const Update = (update: IUpdate) => {
const menu = new Gtk.Menu();
menu.append(constructItem("Open info in browser", `app2unit -O '${update.url}'`, false));
menu.append(constructItem("Open info in terminal", `app2unit -- foot -H -- pacman -Qi ${update.name}`));
menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
menu.append(constructItem("Reinstall", `app2unit -- foot -H -- yay -S ${update.name}`));
menu.append(constructItem("Remove with dependencies", `app2unit -- foot -H -- yay -Rns ${update.name}`));
return (
<button
onClick={(_, event) => event.button === Astal.MouseButton.SECONDARY && menu.popup_at_pointer(null)}
onDestroy={() => menu.destroy()}
>
<label
truncate
useMarkup
xalign={0}
label={bind(Palette.get_default(), "colours").as(
c =>
`${update.name} <span foreground="${c.teal}">(${update.version.old} -> ${
update.version.new
})</span>\n <span foreground="${c.subtext0}">${GLib.markup_escape_text(
update.description,
update.description.length
)}</span>`
)}
setup={self => setupCustomTooltip(self, `${update.name}${update.description}`)}
/>
</button>
);
};
const Repo = ({ repo }: { repo: IRepo }) => {
const expanded = Variable(false);
return (
<box vertical className="repo">
<button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}>
<box className="header">
<label className="icon" label={repo.icon} />
<label label={`${repo.name} (${repo.updates.length})`} />
<box hexpand />
<label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} />
</box>
</button>
<revealer
revealChild={bind(expanded)}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={200}
>
<box vertical className="body">
{repo.updates.map(Update)}
</box>
</revealer>
</box>
);
};
const List = () => (
<box vertical valign={Gtk.Align.START} className="list">
{bind(Updates.get_default(), "updateData").as(d => d.repos.map(r => <Repo repo={r} />))}
</box>
);
const NoUpdates = () => (
<box homogeneous name="empty">
<box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
<label className="icon" label="deployed_code_history" />
<label label="All packages up to date!" />
</box>
</box>
);
export default () => (
<box vertical className="updates">
<box className="header-bar">
<label
label={bind(Updates.get_default(), "numUpdates").as(n => `${n} update${n === 1 ? "" : "s"} available`)}
/>
<box hexpand />
<button
className={bind(Updates.get_default(), "loading").as(l => (l ? "enabled" : ""))}
sensitive={bind(Updates.get_default(), "loading").as(l => !l)}
cursor="pointer"
onClicked={() => Updates.get_default().getUpdates()}
label={bind(Updates.get_default(), "loading").as(l => (l ? "󰑓 Loading" : "󰑓 Reload"))}
/>
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
shown={bind(Updates.get_default(), "numUpdates").as(n => (n > 0 ? "list" : "empty"))}
>
<NoUpdates />
<scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
<List />
</scrollable>
</stack>
</box>
);

View file

@ -1,11 +0,0 @@
import type { Monitor } from "@/services/monitors";
import News from "./modules/news";
import Updates from "./modules/updates";
export default ({ monitor }: { monitor: Monitor }) => (
<box vertical className="pane packages" name="packages">
<Updates />
<box className="separator" />
<News monitor={monitor} />
</box>
);

View file

@ -1,24 +0,0 @@
import { bindCurrentTime } from "@/utils/system";
import { Gtk } from "astal/gtk3";
import Calendar from "./modules/calendar";
import Upcoming from "./modules/upcoming";
const TimeDate = () => (
<box vertical className="time-date">
<box halign={Gtk.Align.CENTER}>
<label label={bindCurrentTime("%I:%M:%S")} />
<label className="ampm" label={bindCurrentTime("%p", c => (c.get_hour() < 12 ? "AM" : "PM"))} />
</box>
<label className="date" label={bindCurrentTime("%A, %d %B")} />
</box>
);
export default () => (
<box vertical className="pane time" name="time">
<TimeDate />
<box className="separator" />
<Upcoming />
<box className="separator" />
<Calendar />
</box>
);

View file

@ -1,3 +0,0 @@
import AstalApps from "gi://AstalApps";
export const Apps = new AstalApps.Apps();

View file

@ -1,228 +0,0 @@
import { pathToFileName } from "@/utils/strings";
import { notify } from "@/utils/system";
import {
execAsync,
GLib,
GObject,
property,
readFileAsync,
register,
timeout,
writeFileAsync,
type AstalIO,
} from "astal";
import { calendar as config } from "config";
import ical from "ical.js";
export interface IEvent {
calendar: string;
event: ical.Event;
startDate: ical.Time;
endDate: ical.Time;
}
@register({ GTypeName: "Calendar" })
export default class Calendar extends GObject.Object {
static instance: Calendar;
static get_default() {
if (!this.instance) this.instance = new Calendar();
return this.instance;
}
readonly #cacheDir = `${CACHE}/calendars`;
#calCount: number = 1;
#reminders: AstalIO.Time[] = [];
#loading: boolean = false;
#calendars: { [name: string]: ical.Component } = {};
#upcoming: { [date: string]: IEvent[] } = {};
#cachedEvents: { [date: string]: IEvent[] } = {};
#cachedMonths: Set<string> = new Set();
@property(Boolean)
get loading() {
return this.#loading;
}
@property(Object)
get calendars() {
return this.#calendars;
}
@property(Object)
get upcoming() {
return this.#upcoming;
}
@property(Number)
get numUpcoming() {
return Object.values(this.#upcoming).reduce((acc, e) => acc + e.length, 0);
}
getCalendarIndex(name: string) {
return Object.keys(this.#calendars).indexOf(name) + 1;
}
getEventsForMonth(date: ical.Time) {
const start = date.startOfMonth();
if (this.#cachedMonths.has(start.toJSDate().toDateString())) return this.#cachedEvents;
this.#cachedMonths.add(start.toJSDate().toDateString());
const end = date.endOfMonth();
const modDates = new Set<string>();
for (const [name, cal] of Object.entries(this.#calendars)) {
for (const e of cal.getAllSubcomponents()) {
const event = new ical.Event(e);
// Skip invalid events
if (!event.startDate) continue;
if (event.isRecurring()) {
// Recurring events
const iter = event.iterator();
for (let next = iter.next(); next && next.compare(end) <= 0; next = iter.next())
if (next.compare(start) >= 0) {
const date = next.toJSDate().toDateString();
if (!this.#cachedEvents.hasOwnProperty(date)) this.#cachedEvents[date] = [];
const end = next.clone();
end.addDuration(event.duration);
this.#cachedEvents[date].push({ calendar: name, event, startDate: next, endDate: end });
modDates.add(date);
}
} else if (event.startDate.compare(start) >= 0 && event.startDate.compare(end) <= 0) {
const date = event.startDate.toJSDate().toDateString();
if (!this.#cachedEvents.hasOwnProperty(date)) this.#cachedEvents[date] = [];
this.#cachedEvents[date].push({
calendar: name,
event,
startDate: event.startDate,
endDate: event.endDate,
});
modDates.add(date);
}
}
}
for (const date of modDates) this.#cachedEvents[date].sort((a, b) => a.startDate.compare(b.startDate));
return this.#cachedEvents;
}
getEventsForDay(date: ical.Time) {
return this.getEventsForMonth(date)[date.toJSDate().toDateString()] ?? [];
}
async updateCalendars() {
this.#loading = true;
this.notify("loading");
this.#calendars = {};
this.#calCount = 1;
const cals = await Promise.allSettled(config.webcals.get().map(c => execAsync(["curl", c])));
for (let i = 0; i < cals.length; i++) {
const cal = cals[i];
const webcal = pathToFileName(config.webcals.get()[i]);
let icalStr;
if (cal.status === "fulfilled") {
icalStr = cal.value;
} else {
console.error(`Failed to get calendar from ${config.webcals.get()[i]}:\n${cal.reason}`);
if (GLib.file_test(`${this.#cacheDir}/${webcal}`, GLib.FileTest.EXISTS))
icalStr = await readFileAsync(`${this.#cacheDir}/${webcal}`);
}
if (icalStr) {
const comp = new ical.Component(ical.parse(icalStr));
const name = (comp.getFirstPropertyValue("x-wr-calname") ?? `Calendar ${this.#calCount++}`) as string;
this.#calendars[name] = comp;
writeFileAsync(`${this.#cacheDir}/${webcal}`, icalStr).catch(console.error);
}
}
this.#cachedEvents = {};
this.#cachedMonths.clear();
this.notify("calendars");
this.updateUpcoming();
this.#loading = false;
this.notify("loading");
}
updateUpcoming() {
this.#upcoming = {};
for (let i = 0; i < config.upcomingDays.get(); i++) {
const date = ical.Time.now().adjust(i, 0, 0, 0);
const events = this.getEventsForDay(date);
if (events.length > 0) this.#upcoming[date.toJSDate().toDateString()] = events;
}
this.notify("upcoming");
this.notify("num-upcoming");
this.setReminders();
}
#notifyEvent(event: IEvent) {
const start = GLib.DateTime.new_from_unix_local(event.startDate.toUnixTime());
const end = GLib.DateTime.new_from_unix_local(event.endDate.toUnixTime());
const time = `${start.format(`%A, %-d %B`)} • Now — ${end.format("%-I:%M%P")}`;
const locIfExists = event.event.location ? `${event.event.location}\n` : "";
const descIfExists = event.event.description ? `󰒿 ${event.event.description}\n` : "";
notify({
summary: `󰨱 ${event.event.summary} 󰨱`,
body: `${time}\n${locIfExists}${descIfExists}󰃭 ${event.calendar}`,
}).catch(console.error);
}
#createReminder(event: IEvent) {
const diff = event.startDate.toJSDate().getTime() - ical.Time.now().toJSDate().getTime();
if (diff > 0) this.#reminders.push(timeout(diff, () => this.#notifyEvent(event)));
}
setReminders() {
this.#reminders.forEach(r => r.cancel());
this.#reminders = [];
if (!config.notify.get()) return;
for (const events of Object.values(this.#upcoming)) for (const event of events) this.#createReminder(event);
}
constructor() {
super();
GLib.mkdir_with_parents(this.#cacheDir, 0o755);
const cals = config.webcals.get().map(async c => {
const webcal = pathToFileName(c);
if (GLib.file_test(`${this.#cacheDir}/${webcal}`, GLib.FileTest.EXISTS)) {
const data = await readFileAsync(`${this.#cacheDir}/${webcal}`);
const comp = new ical.Component(ical.parse(data));
const name = (comp.getFirstPropertyValue("x-wr-calname") ?? `Calendar ${this.#calCount++}`) as string;
this.#calendars[name] = comp;
}
});
Promise.allSettled(cals).then(() => {
this.#cachedEvents = {};
this.#cachedMonths.clear();
this.notify("calendars");
this.updateUpcoming();
});
this.updateCalendars().catch(console.error);
config.webcals.subscribe(() => this.updateCalendars().catch(console.error));
config.upcomingDays.subscribe(() => this.updateUpcoming());
config.notify.subscribe(() => this.setReminders());
}
}

View file

@ -1,49 +0,0 @@
import { GObject, interval, property, register } from "astal";
import { cpu as config } from "config";
import GTop from "gi://GTop";
@register({ GTypeName: "Cpu" })
export default class Cpu extends GObject.Object {
static instance: Cpu;
static get_default() {
if (!this.instance) this.instance = new Cpu();
return this.instance;
}
#previous: GTop.glibtop_cpu = new GTop.glibtop_cpu();
#usage: number = 0;
@property(Number)
get usage() {
return this.#usage;
}
calculateUsage() {
const current = new GTop.glibtop_cpu();
GTop.glibtop_get_cpu(current);
// Calculate the differences from the previous to current data
const total = current.total - this.#previous.total;
const idle = current.idle - this.#previous.idle;
this.#previous = current;
return total > 0 ? ((total - idle) / total) * 100 : 0;
}
update() {
this.#usage = this.calculateUsage();
this.notify("usage");
}
constructor() {
super();
let source = interval(config.interval.get(), () => this.update());
config.interval.subscribe(i => {
source.cancel();
source = interval(i, () => this.update());
});
}
}

View file

@ -1,63 +0,0 @@
import { execAsync, Gio, GLib, GObject, interval, property, register } from "astal";
import { gpu as config } from "config";
@register({ GTypeName: "Gpu" })
export default class Gpu extends GObject.Object {
static instance: Gpu;
static get_default() {
if (!this.instance) this.instance = new Gpu();
return this.instance;
}
readonly available: boolean = false;
#usage: number = 0;
@property(Number)
get usage() {
return this.#usage;
}
async calculateUsage() {
const percs = (await execAsync("fish -c 'cat /sys/class/drm/card*/device/gpu_busy_percent'")).split("\n");
return percs.reduce((a, b) => a + parseFloat(b), 0) / percs.length;
}
update() {
this.calculateUsage()
.then(usage => {
this.#usage = usage;
this.notify("usage");
})
.catch(console.error);
}
constructor() {
super();
let enumerator = null;
try {
enumerator = Gio.File.new_for_path("/sys/class/drm").enumerate_children(
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
Gio.FileQueryInfoFlags.NONE,
null
);
} catch {}
let info: Gio.FileInfo | undefined | null;
while ((info = enumerator?.next_file(null))) {
if (GLib.file_test(`/sys/class/drm/${info.get_name()}/device/gpu_busy_percent`, GLib.FileTest.EXISTS)) {
this.available = true;
break;
}
}
if (this.available) {
let source = interval(config.interval.get(), () => this.update());
config.interval.subscribe(i => {
source.cancel();
source = interval(i, () => this.update());
});
}
}
}

View file

@ -1,155 +0,0 @@
import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
import { math as config } from "config";
import { derivative, evaluate, rationalize, simplify } from "mathjs/number";
export interface HistoryItem {
equation: string;
result: string;
icon: string;
}
@register({ GTypeName: "Math" })
export default class Math extends GObject.Object {
static instance: Math;
static get_default() {
if (!this.instance) this.instance = new Math();
return this.instance;
}
readonly #path = `${STATE}/math-history.json`;
readonly #history: HistoryItem[] = [];
#variables: Record<string, string> = {};
#lastExpression: HistoryItem | null = null;
@property(Object)
get history() {
return this.#history;
}
#save() {
writeFileAsync(this.#path, JSON.stringify(this.#history)).catch(console.error);
}
/**
* Commits the last evaluated expression to the history
*/
commit() {
if (!this.#lastExpression) return;
// Try select first to prevent duplicates, if it fails, add it
if (!this.select(this.#lastExpression)) {
this.#history.unshift(this.#lastExpression);
while (this.#history.length > config.maxHistory.get()) this.#history.pop();
this.notify("history");
this.#save();
}
this.#lastExpression = null;
}
/**
* Moves an item in the history to the top
* @param item The item to select
* @returns If the item was successfully selected
*/
select(item: HistoryItem) {
const idx = this.#history.findIndex(i => i.equation === item.equation && i.result === item.result);
if (idx >= 0) {
this.#history.splice(idx, 1);
this.#history.unshift(item);
this.notify("history");
this.#save();
return true;
}
return false;
}
/**
* Clears the history and variables
*/
clear() {
if (this.#history.length > 0) {
this.#history.length = 0;
this.notify("history");
this.#save();
}
this.#lastExpression = null;
this.#variables = {};
}
/**
* Evaluates an equation and adds it to the history
* @param equation The equation to evaluate
* @returns A {@link HistoryItem} representing the result of the equation
*/
evaluate(equation: string): HistoryItem {
if (equation.startsWith("clear"))
return {
equation: "Clear history",
result: "Delete history and previously set variables",
icon: "delete_forever",
};
let result: string, icon: string;
try {
if (equation.startsWith("help")) {
equation = "Help";
result =
"This is a calculator powered by Math.js.\nAvailable functions:\n\thelp: show help\n\tclear: clear history\n\t<x> = <equation>: sets <x> to <equation>\n\tsimplify <equation>: simplifies <equation>\n\tderive <x> <equation>: derives <equation> with respect to <x>\n\tdd<x> <equation>: short form of derive\n\trationalize <equation>: rationalizes <equation>\n\t<equation>: evaluates <equation>\nSee the documentation for syntax and inbuilt functions.";
icon = "help";
} else if (equation.includes("=")) {
const [left, right] = equation.split("=");
try {
this.#variables[left.trim()] = simplify(right, this.#variables).toString();
} catch {
this.#variables[left.trim()] = right.trim();
}
result = this.#variables[left.trim()];
icon = "equal";
} else if (equation.startsWith("simplify")) {
result = simplify(equation.slice(8), this.#variables).toString();
icon = "function";
} else if (equation.startsWith("derive") || equation.startsWith("dd")) {
const isShortForm = equation.startsWith("dd");
const respectTo = isShortForm ? equation.split(" ")[0].slice(2) : equation.split(" ")[1];
if (!respectTo) throw new Error(`Format: ${isShortForm ? "dd" : "derive "}<respect-to> <equation>`);
result = derivative(equation.slice((isShortForm ? 2 : 7) + respectTo.length), respectTo).toString();
icon = "function";
} else if (equation.startsWith("rationalize")) {
result = rationalize(equation.slice(11), this.#variables).toString();
icon = "function";
} else {
result = evaluate(equation, this.#variables).toString();
icon = "calculate";
}
} catch (e) {
equation = "Invalid equation: " + equation;
result = String(e);
icon = "error";
}
return (this.#lastExpression = { equation, result, icon });
}
constructor() {
super();
// Load history
if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) {
try {
this.#history = JSON.parse(readFile(this.#path));
// Init eval to create variables and last expression
for (const item of this.#history) this.evaluate(item.equation);
} catch (e) {
console.error("Math - Unable to load history", e);
}
}
config.maxHistory.subscribe(n => {
while (this.#history.length > n) this.#history.pop();
});
}
}

View file

@ -1,64 +0,0 @@
import { GObject, interval, property, readFileAsync, register } from "astal";
import { memory as config } from "config";
@register({ GTypeName: "Memory" })
export default class Memory extends GObject.Object {
static instance: Memory;
static get_default() {
if (!this.instance) this.instance = new Memory();
return this.instance;
}
#total: number = 0;
#free: number = 0;
#used: number = 0;
#usage: number = 0;
@property(Number)
get total() {
return this.#total;
}
@property(Number)
get free() {
return this.#free;
}
@property(Number)
get used() {
return this.#used;
}
@property(Number)
get usage() {
return this.#usage;
}
async update() {
const info = await readFileAsync("/proc/meminfo");
this.#total = parseInt(info.match(/MemTotal:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
this.#free = parseInt(info.match(/MemAvailable:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
if (isNaN(this.#total)) this.#total = 0;
if (isNaN(this.#free)) this.#free = 0;
this.#used = this.#total - this.#free;
this.#usage = this.#total > 0 ? (this.#used / this.#total) * 100 : 0;
this.notify("total");
this.notify("free");
this.notify("used");
this.notify("usage");
}
constructor() {
super();
let source = interval(config.interval.get(), () => this.update().catch(console.error));
config.interval.subscribe(i => {
source.cancel();
source = interval(i, () => this.update().catch(console.error));
});
}
}

View file

@ -1,127 +0,0 @@
import { GObject, execAsync, property, register } from "astal";
import AstalHyprland from "gi://AstalHyprland";
@register({ GTypeName: "Monitor" })
export class Monitor extends GObject.Object {
readonly monitor: AstalHyprland.Monitor;
readonly width: number;
readonly height: number;
readonly id: number;
readonly serial: string;
readonly name: string;
readonly description: string;
@property(AstalHyprland.Workspace)
get activeWorkspace() {
return this.monitor.activeWorkspace;
}
isDdc: boolean = false;
busNum?: string;
#brightness: number = 0;
@property(Number)
get brightness() {
return this.#brightness;
}
set brightness(value) {
value = Math.min(1, Math.max(0, value));
this.#brightness = value;
this.notify("brightness");
execAsync(
this.isDdc
? `ddcutil -b ${this.busNum} setvcp 10 ${Math.round(value * 100)}`
: `brightnessctl set ${Math.floor(value * 100)}% -q`
).catch(console.error);
}
constructor(monitor: AstalHyprland.Monitor) {
super();
this.monitor = monitor;
this.width = monitor.width;
this.height = monitor.height;
this.id = monitor.id;
this.serial = monitor.serial;
this.name = monitor.name;
this.description = monitor.description;
monitor.connect("notify::active-workspace", () => this.notify("active-workspace"));
execAsync("ddcutil detect --brief")
.then(out => {
this.isDdc = out.split("\n\n").some(display => {
if (!/^Display \d+/.test(display)) return false;
const lines = display.split("\n").map(l => l.trimStart());
if (lines.find(l => l.startsWith("Monitor:"))?.split(":")[3] !== monitor.serial) return false;
this.busNum = lines.find(l => l.startsWith("I2C bus:"))?.split("/dev/i2c-")[1];
return this.busNum !== undefined;
});
})
.catch(() => (this.isDdc = false))
.finally(async () => {
if (this.isDdc) {
const info = (await execAsync(`ddcutil -b ${this.busNum} getvcp 10 --brief`)).split(" ");
this.#brightness = Number(info[3]) / Number(info[4]);
} else
this.#brightness =
Number(await execAsync("brightnessctl get")) / Number(await execAsync("brightnessctl max"));
});
}
}
@register({ GTypeName: "Monitors" })
export default class Monitors extends GObject.Object {
static instance: Monitors;
static get_default() {
if (!this.instance) this.instance = new Monitors();
return this.instance;
}
readonly #map: Map<number, Monitor> = new Map();
@property(Object)
get map() {
return this.#map;
}
@property(Object)
get list() {
return Array.from(this.#map.values());
}
@property(Monitor)
get active() {
return this.#map.get(AstalHyprland.get_default().focusedMonitor.id)!;
}
#notify() {
this.notify("map");
this.notify("list");
}
forEach(fn: (monitor: Monitor) => void) {
for (const monitor of this.#map.values()) fn(monitor);
}
constructor() {
super();
const hyprland = AstalHyprland.get_default();
for (const monitor of hyprland.monitors) this.#map.set(monitor.id, new Monitor(monitor));
if (this.#map.size > 0) this.#notify();
hyprland.connect("monitor-added", (_, monitor) => {
this.#map.set(monitor.id, new Monitor(monitor));
this.#notify();
});
hyprland.connect("monitor-removed", (_, id) => this.#map.delete(id) && this.#notify());
hyprland.connect("notify::focused-monitor", () => this.notify("active"));
}
}

View file

@ -1,153 +0,0 @@
import { notify } from "@/utils/system";
import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
import { news as config } from "config";
import { setConfig } from "config/funcs";
export interface IArticle {
title: string;
link: string;
keywords: string[] | null;
creator: string[] | null;
description: string | null;
pubDate: string;
source_name: string;
source_priority: number;
category: string[];
}
@register({ GTypeName: "News" })
export default class News extends GObject.Object {
static instance: News;
static get_default() {
if (!this.instance) this.instance = new News();
return this.instance;
}
readonly #cachePath = `${CACHE}/news.json`;
#notified = false;
#loading: boolean = false;
#articles: IArticle[] = [];
#categories: { [category: string]: IArticle[] } = {};
@property(Boolean)
get loading() {
return this.#loading;
}
@property(Object)
get articles() {
return this.#articles;
}
@property(Object)
get categories() {
return this.#categories;
}
async getNews() {
if (!config.apiKey.get()) {
if (!this.#notified) {
notify({
summary: "A newsdata.io API key is required",
body: "You can get one by creating an account at https://newsdata.io",
icon: "dialog-warning-symbolic",
urgency: "critical",
actions: {
"Get API key": () => execAsync("app2unit -O -- https://newsdata.io").catch(console.error),
Disable: () => setConfig("sidebar.modules.headlines.enabled", false),
},
});
this.#notified = true;
}
return;
}
this.#loading = true;
this.notify("loading");
let countries = config.countries.get().join(",");
const categories = config.categories.get().join(",");
const languages = config.languages.get().join(",");
const domains = config.domains.get().join(",");
const excludeDomains = config.excludeDomains.get().join(",");
const timezone = config.timezone.get();
if (countries.includes("current")) {
const out = JSON.parse(await execAsync("curl ipinfo.io")).country.toLowerCase();
countries = countries.replace("current", out);
}
let args = "removeduplicate=1&prioritydomain=top";
if (countries) args += `&country=${countries}`;
if (categories) args += `&category=${categories}`;
if (languages) args += `&language=${languages}`;
if (domains) args += `&domain=${domains}`;
if (excludeDomains) args += `&excludedomain=${excludeDomains}`;
if (timezone) args += `&timezone=${timezone}`;
const url = `https://newsdata.io/api/1/latest?apikey=${config.apiKey.get()}&${args}`;
try {
const res = JSON.parse(await execAsync(["curl", url]));
if (res.status !== "success") throw new Error(`Failed to get news: ${res.results.message}`);
this.#articles = [...res.results];
let page = res.nextPage;
for (let i = 1; i < config.pages.get(); i++) {
const res = JSON.parse(await execAsync(["curl", `${url}&page=${page}`]));
if (res.status !== "success") throw new Error(`Failed to get news: ${res.results.message}`);
this.#articles.push(...res.results);
page = res.nextPage;
}
writeFileAsync(this.#cachePath, JSON.stringify(this.#articles)).catch(console.error);
} catch (e) {
console.error(e);
if (GLib.file_test(this.#cachePath, GLib.FileTest.EXISTS))
this.#articles = JSON.parse(await readFileAsync(this.#cachePath));
}
this.notify("articles");
this.updateCategories();
this.#loading = false;
this.notify("loading");
}
updateCategories() {
this.#categories = {};
for (const article of this.#articles) {
for (const category of article.category) {
if (!this.#categories.hasOwnProperty(category)) this.#categories[category] = [];
this.#categories[category].push(article);
}
}
this.notify("categories");
}
constructor() {
super();
if (GLib.file_test(this.#cachePath, GLib.FileTest.EXISTS))
readFileAsync(this.#cachePath)
.then(data => {
this.#articles = JSON.parse(data);
this.notify("articles");
this.updateCategories();
})
.catch(console.error);
this.getNews().catch(console.error);
config.apiKey.subscribe(() => this.getNews().catch(console.error));
config.countries.subscribe(() => this.getNews().catch(console.error));
config.categories.subscribe(() => this.getNews().catch(console.error));
config.languages.subscribe(() => this.getNews().catch(console.error));
config.domains.subscribe(() => this.getNews().catch(console.error));
config.excludeDomains.subscribe(() => this.getNews().catch(console.error));
config.timezone.subscribe(() => this.getNews().catch(console.error));
config.pages.subscribe(() => this.getNews().catch(console.error));
}
}

View file

@ -1,298 +0,0 @@
import { execAsync, GLib, GObject, monitorFile, property, readFile, readFileAsync, register } from "astal";
import Schemes from "./schemes";
export type ColourMode = "light" | "dark";
export type Hex = `#${string}`;
export interface IPalette {
rosewater: Hex;
flamingo: Hex;
pink: Hex;
mauve: Hex;
red: Hex;
maroon: Hex;
peach: Hex;
yellow: Hex;
green: Hex;
teal: Hex;
sky: Hex;
sapphire: Hex;
blue: Hex;
lavender: Hex;
text: Hex;
subtext1: Hex;
subtext0: Hex;
overlay2: Hex;
overlay1: Hex;
overlay0: Hex;
surface2: Hex;
surface1: Hex;
surface0: Hex;
base: Hex;
mantle: Hex;
crust: Hex;
primary: Hex;
secondary: Hex;
tertiary: Hex;
}
@register({ GTypeName: "Palette" })
export default class Palette extends GObject.Object {
static instance: Palette;
static get_default() {
if (!this.instance) this.instance = new Palette();
return this.instance;
}
#mode: ColourMode;
#scheme: string;
#flavour?: string;
#colours!: IPalette;
@property(Boolean)
get mode() {
return this.#mode;
}
@property(String)
get scheme() {
return this.#scheme;
}
@property(String)
get flavour() {
return this.#flavour;
}
@property(Object)
get colours() {
return this.#colours;
}
@property(String)
get rosewater() {
return this.#colours.rosewater;
}
@property(String)
get flamingo() {
return this.#colours.flamingo;
}
@property(String)
get pink() {
return this.#colours.pink;
}
@property(String)
get mauve() {
return this.#colours.mauve;
}
@property(String)
get red() {
return this.#colours.red;
}
@property(String)
get maroon() {
return this.#colours.maroon;
}
@property(String)
get peach() {
return this.#colours.peach;
}
@property(String)
get yellow() {
return this.#colours.yellow;
}
@property(String)
get green() {
return this.#colours.green;
}
@property(String)
get teal() {
return this.#colours.teal;
}
@property(String)
get sky() {
return this.#colours.sky;
}
@property(String)
get sapphire() {
return this.#colours.sapphire;
}
@property(String)
get blue() {
return this.#colours.blue;
}
@property(String)
get lavender() {
return this.#colours.lavender;
}
@property(String)
get text() {
return this.#colours.text;
}
@property(String)
get subtext1() {
return this.#colours.subtext1;
}
@property(String)
get subtext0() {
return this.#colours.subtext0;
}
@property(String)
get overlay2() {
return this.#colours.overlay2;
}
@property(String)
get overlay1() {
return this.#colours.overlay1;
}
@property(String)
get overlay0() {
return this.#colours.overlay0;
}
@property(String)
get surface2() {
return this.#colours.surface2;
}
@property(String)
get surface1() {
return this.#colours.surface1;
}
@property(String)
get surface0() {
return this.#colours.surface0;
}
@property(String)
get base() {
return this.#colours.base;
}
@property(String)
get mantle() {
return this.#colours.mantle;
}
@property(String)
get crust() {
return this.#colours.crust;
}
@property(String)
get primary() {
return this.#colours.primary;
}
@property(String)
get secondary() {
return this.#colours.secondary;
}
@property(String)
get tertiary() {
return this.#colours.tertiary;
}
#notify() {
this.notify("colours");
this.notify("rosewater");
this.notify("flamingo");
this.notify("pink");
this.notify("mauve");
this.notify("red");
this.notify("maroon");
this.notify("peach");
this.notify("yellow");
this.notify("green");
this.notify("teal");
this.notify("sky");
this.notify("sapphire");
this.notify("blue");
this.notify("lavender");
this.notify("text");
this.notify("subtext1");
this.notify("subtext0");
this.notify("overlay2");
this.notify("overlay1");
this.notify("overlay0");
this.notify("surface2");
this.notify("surface1");
this.notify("surface0");
this.notify("base");
this.notify("mantle");
this.notify("crust");
this.notify("primary");
this.notify("secondary");
this.notify("tertiary");
}
update() {
let schemeColours;
if (GLib.file_test(`${STATE}/scheme/current.txt`, GLib.FileTest.EXISTS)) {
const currentScheme = readFile(`${STATE}/scheme/current.txt`);
schemeColours = currentScheme.split("\n").map(l => l.split(" "));
} else
schemeColours = readFile(`${SRC}/scss/scheme/_default.scss`)
.split("\n")
.map(l => {
const [name, hex] = l.split(":");
return [name.slice(1), hex.trim().slice(1, -1)];
});
this.#colours = schemeColours.reduce((acc, [name, hex]) => ({ ...acc, [name]: `#${hex}` }), {} as IPalette);
this.#notify();
}
switchMode(mode: ColourMode) {
execAsync(`caelestia scheme ${this.scheme} ${this.flavour ?? ""} ${mode}`).catch(console.error);
}
hasMode(mode: ColourMode) {
const scheme = Schemes.get_default().map[this.scheme];
if (scheme?.colours?.[mode]) return true;
return scheme?.flavours?.[this.flavour ?? ""]?.colours?.[mode] !== undefined;
}
constructor() {
super();
this.#mode = readFile(`${STATE}/scheme/current-mode.txt`) === "light" ? "light" : "dark";
monitorFile(`${STATE}/scheme/current-mode.txt`, async file => {
this.#mode = (await readFileAsync(file)) === "light" ? "light" : "dark";
this.notify("mode");
});
[this.#scheme, this.#flavour] = readFile(`${STATE}/scheme/current-name.txt`).split("-");
monitorFile(`${STATE}/scheme/current-name.txt`, async file => {
[this.#scheme, this.#flavour] = (await readFileAsync(file)).split("-");
this.notify("scheme");
this.notify("flavour");
});
this.update();
monitorFile(`${STATE}/scheme/current.txt`, () => this.update());
}
}

View file

@ -1,148 +0,0 @@
import { isRealPlayer } from "@/utils/mpris";
import { GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
import AstalMpris from "gi://AstalMpris";
@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 = `${STATE}/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.findIndex(p => p.busName === player.busName);
// Ignore if already current
if (index === 0 || !isRealPlayer(player)) 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();
this.notify("list");
// 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() {
writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).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)) {
readFileAsync(this.#path).then(out => {
for (const busName of out.split("\n").reverse()) {
const player = mpris.get_players().find(p => p.busName === busName);
if (player) this.makeCurrent(player);
}
// Add new players from in between sessions
for (const player of mpris.get_players()) this.makeCurrent(player);
});
} else {
const sortOrder = [
AstalMpris.PlaybackStatus.PLAYING,
AstalMpris.PlaybackStatus.PAUSED,
AstalMpris.PlaybackStatus.STOPPED,
];
const players = mpris
.get_players()
.sort((a, b) => sortOrder.indexOf(b.playbackStatus) - sortOrder.indexOf(a.playbackStatus));
for (const player of players) this.makeCurrent(player);
}
// Add and connect signals when added
mpris.connect("player-added", (_, player) => this.makeCurrent(player));
// 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();
}
});
}
}

View file

@ -1,109 +0,0 @@
import { basename } from "@/utils/strings";
import { monitorDirectory } from "@/utils/system";
import { execAsync, Gio, GLib, GObject, property, readFileAsync, register } from "astal";
import type { IPalette } from "./palette";
export interface Colours {
light?: IPalette;
dark?: IPalette;
}
export interface Flavour {
name: string;
scheme: string;
colours: Colours;
}
export interface Scheme {
name: string;
flavours?: { [k: string]: Flavour };
colours?: Colours;
}
const DATA = `${GLib.get_user_data_dir()}/caelestia`;
@register({ GTypeName: "Schemes" })
export default class Schemes extends GObject.Object {
static instance: Schemes;
static get_default() {
if (!this.instance) this.instance = new Schemes();
return this.instance;
}
readonly #schemeDir: string = `${DATA}/scripts/data/schemes`;
#map: { [k: string]: Scheme } = {};
@property(Object)
get map() {
return this.#map;
}
async parseMode(path: string): Promise<IPalette | undefined> {
const schemeColours = (await readFileAsync(path).catch(() => undefined))?.split("\n").map(l => l.split(" "));
return schemeColours?.reduce((acc, [name, hex]) => ({ ...acc, [name]: `#${hex}` }), {} as IPalette);
}
async parseColours(path: string): Promise<Colours> {
const light = await this.parseMode(`${path}/light.txt`);
const dark = await this.parseMode(`${path}/dark.txt`);
return { light, dark };
}
async parseFlavour(scheme: string, name: string): Promise<Flavour> {
const path = `${this.#schemeDir}/${scheme}/${name}`;
return { name, scheme, colours: await this.parseColours(path) };
}
async parseScheme(name: string): Promise<Scheme> {
const path = `${this.#schemeDir}/${name}`;
const flavours = await execAsync(`find ${path}/ -mindepth 1 -maxdepth 1 -type d`);
if (flavours.trim())
return {
name,
flavours: (
await Promise.all(flavours.split("\n").map(f => this.parseFlavour(name, basename(f))))
).reduce((acc, f) => ({ ...acc, [f.name]: f }), {} as { [k: string]: Flavour }),
};
return { name, colours: await this.parseColours(path) };
}
async update() {
const schemes = await execAsync(`find ${this.#schemeDir}/ -mindepth 1 -maxdepth 1 -type d`);
(await Promise.all(schemes.split("\n").map(s => this.parseScheme(basename(s))))).forEach(
s => (this.#map[s.name] = s)
);
this.notify("map");
}
async updateFile(file: Gio.File) {
if (file.get_basename() !== "light.txt" && file.get_basename() !== "dark.txt") {
await this.update();
return;
}
const mode = file.get_basename()!.slice(0, -4) as "light" | "dark";
const parent = file.get_parent()!;
const parentParent = parent.get_parent()!;
if (parentParent.get_basename() === "schemes")
this.#map[parent.get_basename()!].colours![mode] = await this.parseMode(file.get_path()!);
else
this.#map[parentParent.get_basename()!].flavours![parent.get_basename()!].colours![mode] =
await this.parseMode(file.get_path()!);
this.notify("map");
}
constructor() {
super();
this.update().catch(console.error);
monitorDirectory(this.#schemeDir, (_m, file, _f, type) => {
if (type !== Gio.FileMonitorEvent.DELETED) this.updateFile(file).catch(console.error);
});
}
}

View file

@ -1,65 +0,0 @@
import { GObject, interval, property, register } from "astal";
import { storage as config } from "config";
import GTop from "gi://GTop";
@register({ GTypeName: "Storage" })
export default class Storage extends GObject.Object {
static instance: Storage;
static get_default() {
if (!this.instance) this.instance = new Storage();
return this.instance;
}
#total: number = 0;
#free: number = 0;
#used: number = 0;
#usage: number = 0;
@property(Number)
get total() {
return this.#total;
}
@property(Number)
get free() {
return this.#free;
}
@property(Number)
get used() {
return this.#used;
}
@property(Number)
get usage() {
return this.#usage;
}
update() {
const root = new GTop.glibtop_fsusage();
GTop.glibtop_get_fsusage(root, "/");
const home = new GTop.glibtop_fsusage();
GTop.glibtop_get_fsusage(home, "/home");
this.#total = root.blocks * root.block_size + home.blocks * home.block_size;
this.#free = root.bavail * root.block_size + home.bavail * home.block_size;
this.#used = this.#total - this.#free;
this.#usage = this.#total > 0 ? (this.#used / this.#total) * 100 : 0;
this.notify("total");
this.notify("free");
this.notify("used");
this.notify("usage");
}
constructor() {
super();
let source = interval(config.interval.get(), () => this.update());
config.interval.subscribe(i => {
source.cancel();
source = interval(i, () => this.update());
});
}
}

View file

@ -1,191 +0,0 @@
import { capitalize } from "@/utils/strings";
import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
import { updates as config } from "config";
export interface Update {
full: string;
name: string;
description: string;
url: string;
version: {
old: string;
new: string;
};
}
export interface Repo {
repo?: string[];
updates: Update[];
icon: string;
name: string;
}
export interface Data {
cached?: boolean;
repos: Repo[];
errors: string[];
news: string;
}
@register({ GTypeName: "Updates" })
export default class Updates extends GObject.Object {
static instance: Updates;
static get_default() {
if (!this.instance) this.instance = new Updates();
return this.instance;
}
readonly #cachePath = `${CACHE}/updates.json`;
#timeout?: GLib.Source;
#loading = false;
#data: Data = { cached: true, repos: [], errors: [], news: "" };
@property(Boolean)
get loading() {
return this.#loading;
}
@property(Object)
get updateData() {
return this.#data;
}
@property(Object)
get list() {
return this.#data.repos.map(r => r.updates).flat();
}
@property(Number)
get numUpdates() {
return this.#data.repos.reduce((acc, repo) => acc + repo.updates.length, 0);
}
@property(String)
get news() {
return this.#data.news;
}
async #updateFromCache() {
this.#data = JSON.parse(await readFileAsync(this.#cachePath));
this.notify("update-data");
this.notify("list");
this.notify("num-updates");
this.notify("news");
}
async getRepo(repo: string) {
return (await execAsync(`bash -c "comm -12 <(pacman -Qq | sort) <(pacman -Slq '${repo}' | sort)"`)).split("\n");
}
async constructUpdate(update: string) {
const info = await execAsync(`pacman -Qi ${update.split(" ")[0]}`);
return info.split("\n").reduce(
(acc, line) => {
let [key, value] = line.split(" : ");
key = key.trim().toLowerCase();
if (key === "name" || key === "description" || key === "url") acc[key] = value.trim();
else if (key === "version") acc.version.old = value.trim();
return acc;
},
{ version: { new: update.split("->")[1].trim() } } as Update
);
}
getRepoIcon(repo: string) {
switch (repo) {
case "core":
return "hub";
case "extra":
return "add_circle";
case "multilib":
return "account_tree";
default:
return "deployed_code_update";
}
}
getUpdates() {
// Return if already getting updates
if (this.#loading) return;
this.#loading = true;
this.notify("loading");
// Get new updates
Promise.allSettled([execAsync("checkupdates"), execAsync("yay -Qua"), execAsync("yay -Pw")])
.then(async ([pacman, yay, news]) => {
const data: Data = { repos: [], errors: [], news: news.status === "fulfilled" ? news.value : "" };
// Pacman updates (checkupdates)
if (pacman.status === "fulfilled") {
const repos: Repo[] = await Promise.all(
(await execAsync("pacman-conf -l")).split("\n").map(async r => ({
repo: await this.getRepo(r),
updates: [],
icon: this.getRepoIcon(r),
name: capitalize(r) + " repository",
}))
);
for (const update of pacman.value.split("\n")) {
const pkg = update.split(" ")[0];
for (const repo of repos)
if (repo.repo?.includes(pkg)) repo.updates.push(await this.constructUpdate(update));
}
for (const repo of repos) if (repo.updates.length > 0) data.repos.push(repo);
}
// AUR and devel updates (yay -Qua)
if (yay.status === "fulfilled") {
const aur: Repo = { updates: [], icon: "deployed_code_account", name: "AUR" };
for (const update of yay.value.split("\n")) {
if (/^\s*->/.test(update)) data.errors.push(update); // Error
else aur.updates.push(await this.constructUpdate(update));
}
if (aur.updates.length > 0) data.repos.push(aur);
}
if (data.errors.length > 0 && data.repos.length === 0) {
this.#updateFromCache().catch(console.error);
} else {
// Sort updates by name
for (const repo of data.repos) repo.updates.sort((a, b) => a.name.localeCompare(b.name));
// Cache and set
writeFileAsync(this.#cachePath, JSON.stringify({ cached: true, ...data })).catch(console.error);
this.#data = data;
this.notify("update-data");
this.notify("list");
this.notify("num-updates");
this.notify("news");
}
this.#loading = false;
this.notify("loading");
this.#timeout?.destroy();
this.#timeout = setTimeout(() => this.getUpdates(), config.interval.get());
})
.catch(console.error);
}
constructor() {
super();
// Initial update from cache, if fail then write valid data to cache so future reads don't fail
this.#updateFromCache().catch(() =>
writeFileAsync(this.#cachePath, JSON.stringify(this.#data)).catch(console.error)
);
this.getUpdates();
config.interval.subscribe(i => {
this.#timeout?.destroy();
this.#timeout = setTimeout(() => this.getUpdates(), i);
});
}
}

View file

@ -1,127 +0,0 @@
import { monitorDirectory } from "@/utils/system";
import Thumbnailer from "@/utils/thumbnailer";
import { execAsync, GObject, property, register } from "astal";
import { wallpapers as config } from "config";
import Monitors from "./monitors";
export interface IWallpaper {
path: string;
thumbnails: {
compact: string;
medium: string;
large: string;
};
}
export interface ICategory {
path: string;
wallpapers: IWallpaper[];
}
@register({ GTypeName: "Wallpapers" })
export default class Wallpapers extends GObject.Object {
static instance: Wallpapers;
static get_default() {
if (!this.instance) this.instance = new Wallpapers();
return this.instance;
}
#list: IWallpaper[] = [];
#categories: ICategory[] = [];
@property(Object)
get list() {
return this.#list;
}
@property(Object)
get categories() {
return this.#categories;
}
async #listDir(path: { path: string; recursive: boolean; threshold: number }, type: "f" | "d") {
const absPath = path.path.replace("~", HOME);
const maxDepth = path.recursive ? "" : "-maxdepth 1";
const files = await execAsync(`find ${absPath} ${maxDepth} -path '*/.*' -prune -o -type ${type} -print`);
if (type === "f" && path.threshold > 0) {
const data = (
await execAsync([
"fish",
"-c",
`identify -ping -format '%i %w %h\n' ${files.replaceAll("\n", " ")} ; true`,
])
).split("\n");
return data
.filter(l => l && this.#filterSize(l, path.threshold))
.map(l => l.split(" ").slice(0, -2).join(" "))
.join("\n");
}
return files;
}
#filterSize(line: string, threshold: number) {
const [width, height] = line.split(" ").slice(-2).map(Number);
const mWidth = Math.max(...Monitors.get_default().list.map(m => m.width));
const mHeight = Math.max(...Monitors.get_default().list.map(m => m.height));
return width >= mWidth * threshold && height >= mHeight * threshold;
}
async update() {
const results = await Promise.allSettled(
config.paths.get().map(async p => ({ path: p, files: await this.#listDir(p, "f") }))
);
const successes = results.filter(r => r.status === "fulfilled").map(r => r.value);
if (!successes.length) {
this.#list = [];
this.notify("list");
this.#categories = [];
this.notify("categories");
return;
}
const files = successes.map(r => r.files.replaceAll("\n", " ")).join(" ");
const list = (await execAsync(["fish", "-c", `identify -ping -format '%i\n' ${files} ; true`])).split("\n");
this.#list = await Promise.all(
list.map(async p => ({
path: p,
thumbnails: {
compact: await Thumbnailer.thumbnail(p, { width: 60, height: 60, exact: true }),
medium: await Thumbnailer.thumbnail(p, { width: 400, height: 150, exact: true }),
large: await Thumbnailer.thumbnail(p, { width: 400, height: 200, exact: true }),
},
}))
);
this.#list.sort((a, b) => a.path.localeCompare(b.path));
this.notify("list");
const categories = await Promise.all(successes.map(r => this.#listDir(r.path, "d")));
this.#categories = categories
.flatMap(c => c.split("\n"))
.map(c => ({ path: c, wallpapers: this.#list.filter(w => w.path.startsWith(c)) }))
.filter(c => c.wallpapers.length > 0)
.sort((a, b) => a.path.localeCompare(b.path));
this.notify("categories");
}
constructor() {
super();
this.update().catch(console.error);
let monitors = config.paths
.get()
.map(p => monitorDirectory(p.path, () => this.update().catch(console.error), p.recursive));
config.paths.subscribe(v => {
this.update().catch(console.error);
for (const m of monitors) m.cancel();
monitors = v.map(p => monitorDirectory(p.path, () => this.update().catch(console.error), p.recursive));
});
}
}

View file

@ -1,388 +0,0 @@
import { weatherIcons } from "@/utils/icons";
import { notify } from "@/utils/system";
import {
execAsync,
GLib,
GObject,
interval,
property,
readFileAsync,
register,
writeFileAsync,
type Time,
} from "astal";
import { weather as config } from "config";
export interface WeatherCondition {
text: string;
icon: string;
code: number;
}
interface _WeatherState {
temp_c: number;
temp_f: number;
is_day: number;
condition: WeatherCondition;
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW";
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
}
export interface WeatherCurrent extends _WeatherState {
last_updated_epoch: number;
last_updated: string;
}
export interface WeatherHour extends _WeatherState {
time_epoch: number;
time: string;
}
export interface WeatherDay {
date: string;
date_epoch: number;
day: {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: WeatherCondition;
uv: number;
};
astro: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: string;
is_moon_up: number;
is_sun_up: number;
};
hour: WeatherHour[];
}
export interface WeatherLocation {
name: string;
region: string;
country: string;
lat: number;
lon: number;
tz_id: string;
localtime_epoch: number;
localtime: string;
}
export interface WeatherData {
current: WeatherCurrent;
forecast: { forecastday: WeatherDay[] };
location: WeatherLocation;
}
const DEFAULT_STATE: _WeatherState = {
temp_c: 0,
temp_f: 0,
is_day: 0,
condition: { text: "", icon: "", code: 0 },
wind_mph: 0,
wind_kph: 0,
wind_degree: 0,
wind_dir: "N",
pressure_mb: 0,
pressure_in: 0,
precip_mm: 0,
precip_in: 0,
humidity: 0,
cloud: 0,
feelslike_c: 0,
feelslike_f: 0,
windchill_c: 0,
windchill_f: 0,
heatindex_c: 0,
heatindex_f: 0,
dewpoint_c: 0,
dewpoint_f: 0,
vis_km: 0,
vis_miles: 0,
uv: 0,
gust_mph: 0,
gust_kph: 0,
};
const DEFAULT: WeatherData = {
current: {
last_updated_epoch: 0,
last_updated: "",
...DEFAULT_STATE,
},
forecast: {
forecastday: [
{
date: "",
date_epoch: 0,
day: {
maxtemp_c: 0,
maxtemp_f: 0,
mintemp_c: 0,
mintemp_f: 0,
avgtemp_c: 0,
avgtemp_f: 0,
maxwind_mph: 0,
maxwind_kph: 0,
totalprecip_mm: 0,
totalprecip_in: 0,
totalsnow_cm: 0,
avgvis_km: 0,
avgvis_miles: 0,
avghumidity: 0,
daily_will_it_rain: 0,
daily_chance_of_rain: 0,
daily_will_it_snow: 0,
daily_chance_of_snow: 0,
condition: { text: "", icon: "", code: 0 },
uv: 0,
},
astro: {
sunrise: "",
sunset: "",
moonrise: "",
moonset: "",
moon_phase: "",
moon_illumination: "",
is_moon_up: 0,
is_sun_up: 0,
},
hour: Array.from({ length: 24 }, () => ({
time_epoch: 0,
time: "",
...DEFAULT_STATE,
})),
},
],
},
location: {
name: "",
region: "",
country: "",
lat: 0,
lon: 0,
tz_id: "",
localtime_epoch: 0,
localtime: "",
},
};
@register({ GTypeName: "Weather" })
export default class Weather extends GObject.Object {
static instance: Weather;
static get_default() {
if (!this.instance) this.instance = new Weather();
return this.instance;
}
readonly #cache: string = `${CACHE}/weather.json`;
#notified = false;
#data: WeatherData = DEFAULT;
#interval: Time | null = null;
@property(Object)
get raw() {
return this.#data;
}
@property(Object)
get current() {
return this.#data.current;
}
@property(Object)
get forecast() {
return this.#data.forecast.forecastday[0].hour;
}
@property(Object)
get location() {
return this.#data.location;
}
@property(String)
get condition() {
return this.#data.current.condition.text;
}
@property(String)
get temperature() {
return this.getTemp(this.#data.current);
}
@property(String)
get wind() {
return `${Math.round(this.#data.current[`wind_${config.imperial.get() ? "m" : "k"}ph`])} ${
config.imperial.get() ? "m" : "k"
}ph`;
}
@property(String)
get rainChance() {
return this.#data.forecast.forecastday[0].day.daily_chance_of_rain + "%";
}
@property(String)
get icon() {
return this.getIcon(this.#data.current.condition.text);
}
@property(String)
get tempIcon() {
return this.getTempIcon(this.#data.current.temp_c);
}
@property(String)
get tempColour() {
return this.getTempDesc(this.#data.current.temp_c);
}
getIcon(status: string) {
let query = status.trim().toLowerCase().replaceAll(" ", "_");
if (!this.#data.current.is_day && query + "_night" in weatherIcons) query += "_night";
return weatherIcons[query] ?? weatherIcons.warning;
}
getTemp(data: _WeatherState) {
return `${Math.round(data[`temp_${config.imperial.get() ? "f" : "c"}`])}°${config.imperial.get() ? "F" : "C"}`;
}
getTempIcon(temp: number) {
if (temp >= 40) return "";
if (temp >= 30) return "";
if (temp >= 20) return "";
if (temp >= 10) return "";
return "";
}
getTempDesc(temp: number) {
if (temp >= 40) return "burning";
if (temp >= 30) return "hot";
if (temp >= 20) return "normal";
if (temp >= 10) return "cold";
return "freezing";
}
#notify() {
this.notify("raw");
this.notify("current");
this.notify("forecast");
this.notify("location");
this.notify("condition");
this.notify("temperature");
this.notify("wind");
this.notify("rain-chance");
this.notify("icon");
this.notify("temp-icon");
this.notify("temp-colour");
}
async getWeather() {
const location = config.location || JSON.parse(await execAsync("curl ipinfo.io")).city;
const opts = `key=${config.apiKey.get()}&q=${location}&days=1&aqi=no&alerts=no`;
const url = `https://api.weatherapi.com/v1/forecast.json?${opts}`;
return JSON.parse(await execAsync(["curl", url]));
}
async updateWeather() {
if (!config.apiKey.get()) {
if (!this.#notified) {
notify({
summary: "Weather API key required",
body: `A weather API key is required to get weather data. Get one from https://www.weatherapi.com.`,
icon: "dialog-warning-symbolic",
urgency: "critical",
actions: {
"Get API key": () => execAsync(`app2unit -O 'https://www.weatherapi.com'`).catch(print),
},
});
this.#notified = true;
}
return;
}
if (GLib.file_test(this.#cache, GLib.FileTest.EXISTS)) {
const cache = await readFileAsync(this.#cache);
const cache_data: WeatherData = JSON.parse(cache);
if (cache_data.location.localtime_epoch * 1000 + config.interval.get() > Date.now()) {
if (JSON.stringify(this.#data) !== cache) {
this.#data = cache_data;
this.#notify();
}
return;
}
}
try {
const data = await this.getWeather();
this.#data = data;
writeFileAsync(this.#cache, JSON.stringify(data)).catch(console.error); // Catch here so it doesn't propagate
} catch (e) {
console.error("Error getting weather:", e);
this.#data = DEFAULT;
}
this.#notify();
}
constructor() {
super();
this.updateWeather().catch(console.error);
this.#interval = interval(config.interval.get(), () => this.updateWeather().catch(console.error));
config.apiKey.subscribe(() => this.updateWeather());
config.interval.subscribe(i => {
this.#interval?.cancel();
this.#interval = interval(i, () => this.updateWeather().catch(console.error));
});
config.imperial.subscribe(() => {
this.notify("temperature");
this.notify("wind");
});
}
}

View file

@ -1,158 +0,0 @@
import { Apps } from "@/services/apps";
import { Gio } from "astal";
import type AstalApps from "gi://AstalApps";
export const osIcons: Record<string, string> = {
almalinux: "",
alpine: "",
arch: "",
archcraft: "",
arcolinux: "",
artix: "",
centos: "",
debian: "",
devuan: "",
elementary: "",
endeavouros: "",
fedora: "",
freebsd: "",
garuda: "",
gentoo: "",
hyperbola: "",
kali: "",
linuxmint: "󰣭",
mageia: "",
openmandriva: "",
manjaro: "",
neon: "",
nixos: "",
opensuse: "",
suse: "",
sles: "",
sles_sap: "",
"opensuse-tumbleweed": "",
parrot: "",
pop: "",
raspbian: "",
rhel: "",
rocky: "",
slackware: "",
solus: "",
steamos: "",
tails: "",
trisquel: "",
ubuntu: "",
vanilla: "",
void: "",
zorin: "",
};
export const weatherIcons: Record<string, string> = {
warning: "󰼯",
sunny: "󰖙",
clear: "󰖔",
partly_cloudy: "󰖕",
partly_cloudy_night: "󰼱",
cloudy: "󰖐",
overcast: "󰖕",
mist: "󰖑",
patchy_rain_nearby: "󰼳",
patchy_rain_possible: "󰼳",
patchy_snow_possible: "󰼴",
patchy_sleet_possible: "󰙿",
patchy_freezing_drizzle_possible: "󰙿",
thundery_outbreaks_possible: "󰙾",
blowing_snow: "󰼶",
blizzard: "󰼶",
fog: "󰖑",
freezing_fog: "󰖑",
patchy_light_drizzle: "󰼳",
light_drizzle: "󰼳",
freezing_drizzle: "󰙿",
heavy_freezing_drizzle: "󰙿",
patchy_light_rain: "󰼳",
light_rain: "󰼳",
moderate_rain_at_times: "󰖗",
moderate_rain: "󰼳",
heavy_rain_at_times: "󰖖",
heavy_rain: "󰖖",
light_freezing_rain: "󰙿",
moderate_or_heavy_freezing_rain: "󰙿",
light_sleet: "󰙿",
moderate_or_heavy_sleet: "󰙿",
patchy_light_snow: "󰼴",
light_snow: "󰼴",
patchy_moderate_snow: "󰼴",
moderate_snow: "󰼶",
patchy_heavy_snow: "󰼶",
heavy_snow: "󰼶",
ice_pellets: "󰖒",
light_rain_shower: "󰖖",
moderate_or_heavy_rain_shower: "󰖖",
torrential_rain_shower: "󰖖",
light_sleet_showers: "󰼵",
moderate_or_heavy_sleet_showers: "󰼵",
light_snow_showers: "󰼵",
moderate_or_heavy_snow_showers: "󰼵",
light_showers_of_ice_pellets: "󰖒",
moderate_or_heavy_showers_of_ice_pellets: "󰖒",
patchy_light_rain_with_thunder: "󰙾",
moderate_or_heavy_rain_with_thunder: "󰙾",
moderate_or_heavy_rain_in_area_with_thunder: "󰙾",
patchy_light_snow_with_thunder: "󰼶",
moderate_or_heavy_snow_with_thunder: "󰼶",
};
export const desktopEntrySubs: Record<string, string> = {
Firefox: "firefox",
};
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 = (nameOrApp: string | AstalApps.Application) => {
const categories =
typeof nameOrApp === "string"
? Gio.DesktopAppInfo.new(`${nameOrApp}.desktop`)?.get_categories()?.split(";") ??
Apps.fuzzy_query(nameOrApp)[0]?.categories
: nameOrApp.categories;
if (categories)
for (const [key, value] of Object.entries(categoryIcons)) if (categories.includes(key)) return value;
return "terminal";
};

View file

@ -1,16 +0,0 @@
import { GLib } from "astal";
import AstalMpris from "gi://AstalMpris";
const hasPlasmaIntegration = GLib.find_program_in_path("plasma-browser-integration-host") !== null;
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"));

View file

@ -1,18 +0,0 @@
export const basename = (path: string, stripExt = true) => {
const lastSlash = path.lastIndexOf("/");
const lastDot = path.lastIndexOf(".");
return path.slice(lastSlash + 1, stripExt && lastDot > lastSlash ? lastDot : undefined);
};
export const pathToFileName = (path: string, ext?: string) => {
const start = /[a-z]+:\/\//.test(path) ? 0 : path.indexOf("/") + 1;
const dir = path.slice(start, path.lastIndexOf("/")).replaceAll("/", "-");
return `${dir}-${basename(path, ext !== undefined)}${ext ? `.${ext}` : ""}`;
};
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
export const lengthStr = (length: number) =>
`${Math.floor(length / 60)}:${Math.floor(length % 60)
.toString()
.padStart(2, "0")}`;

View file

@ -1,111 +0,0 @@
import { bind, execAsync, Gio, GLib, Variable, type Binding } from "astal";
import type AstalApps from "gi://AstalApps";
import { osIcons } from "./icons";
/**
* See https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html
* @param exec The exec field in a desktop file
*/
const execToCmd = (app: AstalApps.Application) => {
let exec = app.executable.replace(/%[fFuUdDnNvm]/g, ""); // Remove useless field codes
exec = exec.replace(/%i/g, app.iconName ? `--icon ${app.iconName}` : ""); // Replace %i app icon
exec = exec.replace(/%c/g, app.name); // Replace %c with app name
exec = exec.replace(/%k/g, (app.app as Gio.DesktopAppInfo).get_filename() ?? ""); // Replace %k with desktop file path
return exec;
};
export const launch = (app: AstalApps.Application) => {
let now = Date.now();
execAsync(["app2unit", "--", app.entry]).catch(() => {
// Try manual exec if launch fails (exits with error within 1 second)
if (Date.now() - now < 1000) {
now = Date.now();
execAsync(["app2unit", "--", execToCmd(app)]).catch(() => {
// Fallback to regular launch
if (Date.now() - now < 1000) {
app.frequency--; // Decrement frequency cause launch also increments it
app.launch();
}
});
}
});
app.frequency++;
};
export const notify = (props: {
summary: string;
body?: string;
icon?: string;
urgency?: "low" | "normal" | "critical";
transient?: boolean;
actions?: Record<string, () => void>;
}) =>
execAsync([
"notify-send",
"-a",
"caelestia-shell",
...(props.icon ? ["-i", props.icon] : []),
...(props.urgency ? ["-u", props.urgency] : []),
...(props.transient ? ["-e"] : []),
...Object.keys(props.actions ?? {}).flatMap((k, i) => ["-A", `${i}=${k}`]),
props.summary,
...(props.body ? [props.body] : []),
])
.then(action => props.actions && Object.values(props.actions)[parseInt(action, 10)]?.())
.catch(console.error);
export const osId = GLib.get_os_info("ID") ?? "unknown";
export const osIdLike = GLib.get_os_info("ID_LIKE");
export const osIcon = (() => {
if (osIcons.hasOwnProperty(osId)) return osIcons[osId];
if (osIdLike) for (const id of osIdLike.split(" ")) if (osIcons.hasOwnProperty(id)) return osIcons[id];
return "";
})();
export const currentTime = Variable(GLib.DateTime.new_now_local()).poll(1000, () => GLib.DateTime.new_now_local());
export const bindCurrentTime = (
format: Binding<string> | string,
fallback?: (time: GLib.DateTime) => string,
self?: JSX.Element
) => {
const fmt = (c: GLib.DateTime, format: string) => c.format(format) ?? fallback?.(c) ?? new Date().toLocaleString();
if (typeof format === "string") return bind(currentTime).as(c => fmt(c, format));
if (!self) throw new Error("bindCurrentTime: self is required when format is a Binding");
const time = Variable.derive([currentTime, format], (c, f) => fmt(c, f));
self?.connect("destroy", () => time.drop());
return bind(time);
};
const monitors = new Set();
export const monitorDirectory = (
path: string,
callback: (
source: Gio.FileMonitor,
file: Gio.File,
other_file: Gio.File | null,
type: Gio.FileMonitorEvent
) => void,
recursive: boolean = true
) => {
const file = Gio.file_new_for_path(path.replace("~", HOME));
const monitor = file.monitor_directory(null, null);
monitor.connect("changed", (m, f1, f2, t) => {
if (t === Gio.FileMonitorEvent.CHANGES_DONE_HINT || t === Gio.FileMonitorEvent.DELETED) callback(m, f1, f2, t);
});
if (recursive) {
const enumerator = file.enumerate_children("standard::*", null, null);
let child;
while ((child = enumerator.next_file(null)))
if (child.get_file_type() === Gio.FileType.DIRECTORY) {
const m = monitorDirectory(`${path}/${child.get_name()}`, callback, recursive);
monitor.connect("notify::cancelled", () => m.cancel());
}
}
// Keep ref to monitor so it doesn't get GC'd
monitors.add(monitor);
monitor.connect("notify::cancelled", () => monitor.cancelled && monitors.delete(monitor));
return monitor;
};

View file

@ -1,80 +0,0 @@
import { execAsync, GLib, type Variable } from "astal";
import { thumbnailer as config } from "config";
export interface ThumbOpts {
width?: number;
height?: number;
exact?: boolean;
}
export default class Thumbnailer {
static readonly thumbnailDir = `${CACHE}/thumbnails`;
static readonly #running = new Set<string>();
static getOpt<T extends keyof ThumbOpts>(opt: T, opts: ThumbOpts) {
return opts[opt] ?? (config.defaults[opt] as Variable<NonNullable<ThumbOpts[T]>>).get();
}
static async getThumbPath(path: string, opts: ThumbOpts) {
const hash = (await execAsync(`sha1sum ${path}`)).split(" ")[0];
const size = `${this.getOpt("width", opts)}x${this.getOpt("height", opts)}`;
const exact = this.getOpt("exact", opts) ? "-exact" : "";
return `${this.thumbnailDir}/${hash}@${size}${exact}.png`;
}
static async shouldThumbnail(path: string, opts: ThumbOpts) {
const [w, h] = (await execAsync(`identify -ping -format "%w %h" ${path}`)).split(" ").map(parseInt);
return w > this.getOpt("width", opts) || h > this.getOpt("height", opts);
}
static async #thumbnail(path: string, opts: ThumbOpts, attempts: number): Promise<string> {
const thumbPath = await this.getThumbPath(path, opts);
try {
const width = this.getOpt("width", opts);
const height = this.getOpt("height", opts);
const cropCmd = this.getOpt("exact", opts)
? `-background none -gravity center -extent ${width}x${height}`
: "";
await execAsync(`magick ${path} -thumbnail ${width}x${height}^ ${cropCmd} -unsharp 0x.5 ${thumbPath}`);
} catch {
if (attempts >= config.maxAttempts.get()) {
console.error(`Failed to generate thumbnail for ${path}`);
return path;
}
await new Promise(r => setTimeout(r, config.timeBetweenAttempts.get()));
return this.#thumbnail(path, opts, attempts + 1);
}
return thumbPath;
}
static async thumbnail(path: string, opts: ThumbOpts = {}): Promise<string> {
if (!(await this.shouldThumbnail(path, opts))) return path;
let thumbPath = await this.getThumbPath(path, opts);
// Wait for existing thumbnail for path to finish
while (this.#running.has(path)) await new Promise(r => setTimeout(r, 100));
// If no thumbnail, generate
if (!GLib.file_test(thumbPath, GLib.FileTest.EXISTS)) {
this.#running.add(path);
thumbPath = await this.#thumbnail(path, opts, 0);
this.#running.delete(path);
}
return thumbPath;
}
// Static class
private constructor() {}
static {
GLib.mkdir_with_parents(this.thumbnailDir, 0o755);
}
}

View file

@ -1,35 +0,0 @@
import type { astalify } from "astal/gtk3";
import type AstalHyprland from "gi://AstalHyprland";
export type AstalWidget = InstanceType<ReturnType<typeof astalify>>;
export type Address = `0x${string}`;
export interface Client {
address: Address;
mapped: boolean;
hidden: boolean;
at: [number, number];
size: [number, number];
workspace: {
id: number;
name: string;
};
floating: boolean;
pseudo: boolean;
monitor: number;
class: string;
title: string;
initialClass: string;
initialTitle: string;
pid: number;
xwayland: boolean;
pinned: boolean;
fullscreen: AstalHyprland.Fullscreen;
fullscreenClient: AstalHyprland.Fullscreen;
grouped: Address[];
tags: string[];
swallowing: string;
focusHistoryID: number;
inhibitingIdle: boolean;
}

View file

@ -1,82 +0,0 @@
import { Binding, idle, register } from "astal";
import { Astal, astalify, Gtk, Widget, type ConstructProps } from "astal/gtk3";
import AstalHyprland from "gi://AstalHyprland";
import type { AstalWidget } from "./types";
export const setupCustomTooltip = (
self: AstalWidget,
text: string | Binding<string>,
labelProps: Widget.LabelProps = {}
) => {
if (!text) return null;
self.set_has_tooltip(true);
const window = new Widget.Window({
visible: false,
namespace: "caelestia-tooltip",
layer: Astal.Layer.OVERLAY,
keymode: Astal.Keymode.NONE,
exclusivity: Astal.Exclusivity.IGNORE,
anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT,
child: new Widget.Label({ ...labelProps, className: "tooltip", label: text }),
});
self.set_tooltip_window(window);
if (text instanceof Binding) self.hook(text, (_, v) => !v && window.hide());
const positionWindow = ({ x, y }: { x: number; y: number }) => {
const { width: mWidth, height: mHeight } = AstalHyprland.get_default().get_focused_monitor();
const { width: pWidth, height: pHeight } = window.get_preferred_size()[1]!;
const cursorSize = Gtk.Settings.get_default()?.gtkCursorThemeSize ?? 0;
let marginLeft = x - pWidth / 2;
if (marginLeft < 0) marginLeft = 0;
else if (marginLeft + pWidth > mWidth) marginLeft = mWidth - pWidth;
let marginTop = y + cursorSize;
if (marginTop < 0) marginTop = 0;
else if (marginTop + pHeight > mHeight) marginTop = y - pHeight;
window.marginLeft = marginLeft;
window.marginTop = marginTop;
};
let lastPos = { x: 0, y: 0 };
window.connect("size-allocate", () => positionWindow(lastPos));
self.connect("query-tooltip", () => {
if (text instanceof Binding && !text.get()) return false;
if (window.visible) return true;
const cPos = AstalHyprland.get_default().get_cursor_position();
positionWindow(cPos);
lastPos = cPos;
return true;
});
self.connect("destroy", () => window.destroy());
return window;
};
export const setupChildClickthrough = (self: AstalWidget) => {
self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes());
self.set_size_request(1, 1);
idle(() => self.set_size_request(-1, -1));
};
@register()
export class MenuItem extends astalify(Gtk.MenuItem) {
constructor(props: ConstructProps<MenuItem, Gtk.MenuItem.ConstructorProps, { onActivate: [] }>) {
super(props as any);
}
}
@register()
export class FlowBox extends astalify(Gtk.FlowBox) {
constructor(props: ConstructProps<FlowBox, Gtk.FlowBox.ConstructorProps>) {
super(props as any);
}
}

View file

@ -1,179 +0,0 @@
import { desktopEntrySubs } from "@/utils/icons";
import Thumbnailer from "@/utils/thumbnailer";
import { setupCustomTooltip } from "@/utils/widgets";
import { bind, GLib, register, timeout, Variable } from "astal";
import { Astal, Gtk, Widget } from "astal/gtk3";
import { notifpopups as config } from "config";
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 now = GLib.DateTime.new_now_local();
const todayDay = now.get_day_of_year();
if (config.agoTime.get()) {
const diff = now.difference(messageTime) / 1e6;
if (diff < 60) return "Now";
if (diff < 3600) {
const d = Math.floor(diff / 60);
return `${d} min${d === 1 ? "" : "s"} ago`;
}
if (diff < 86400) {
const d = Math.floor(diff / 3600);
return `${d} hour${d === 1 ? "" : "s"} ago`;
}
} else 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")!;
}
if (messageTime.get_day_of_year() === todayDay - 1) return "Yesterday";
return messageTime.format("%d/%m")!;
};
const AppIcon = ({ appIcon, desktopEntry }: { appIcon: string; desktopEntry: string }) => {
// Try app icon
let icon = Astal.Icon.lookup_icon(appIcon) && appIcon;
// Try desktop entry
if (!icon) {
if (desktopEntrySubs.hasOwnProperty(desktopEntry)) icon = desktopEntrySubs[desktopEntry];
else if (Astal.Icon.lookup_icon(desktopEntry)) icon = desktopEntry;
}
return icon ? <icon className="app-icon" icon={icon} /> : null;
};
const Image = ({ compact, icon }: { compact?: boolean; icon: string }) => {
if (GLib.file_test(icon, GLib.FileTest.EXISTS))
return (
<box
valign={Gtk.Align.START}
className={`image ${compact ? "small" : ""}`}
setup={self =>
Thumbnailer.thumbnail(icon)
.then(p => (self.css = `background-image: url("${p}");`))
.catch(console.error)
}
/>
);
if (Astal.Icon.lookup_icon(icon))
return <icon valign={Gtk.Align.START} className={`image ${compact ? "small" : ""}`} icon={icon} />;
return null;
};
@register()
export default class Notification extends Widget.Box {
readonly #revealer;
#destroyed = false;
constructor({
notification,
popup,
compact = popup,
}: {
notification: AstalNotifd.Notification;
popup?: boolean;
compact?: boolean;
}) {
super({ className: "notification" });
const time = Variable(getTime(notification.time)).poll(60000, () => getTime(notification.time));
this.hook(config.agoTime, () => time.set(getTime(notification.time)));
this.#revealer = (
<revealer
revealChild={popup}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
>
<box className="wrapper">
<box vertical className={`inner ${urgencyToString(notification.urgency)}`}>
<box className="header">
<AppIcon appIcon={notification.appIcon} desktopEntry={notification.appName} />
<label className="app-name" label={notification.appName ?? "Unknown"} />
<box hexpand />
<label className="time" label={bind(time)} onDestroy={() => time.drop()} />
</box>
<box hexpand className="separator" />
<box className="content">
{notification.image && <Image compact={compact} icon={notification.image} />}
<box vertical>
<label className="summary" xalign={0} label={notification.summary} truncate />
{notification.body && (
<label
className="body"
xalign={0}
label={compact ? notification.body.split("\n")[0] : notification.body}
wrap
lines={compact ? 1 : -1}
truncate={compact}
setup={self => compact && !popup && setupCustomTooltip(self, notification.body)}
/>
)}
</box>
</box>
{!popup && (
<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];
if (popup) this.css = `margin-left: ${width}px; margin-right: -${width}px;`;
timeout(1, () => {
this.#revealer.revealChild = true;
this.css = `transition: 300ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`;
});
// Close popup after timeout if transient or expire enabled in config
if (popup && (config.expire.get() || notification.transient))
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());
});
}
}

View file

@ -1,75 +0,0 @@
import { Binding, register } from "astal";
import { App, Astal, Gdk, Widget } from "astal/gtk3";
import { bar } from "config";
import AstalHyprland from "gi://AstalHyprland";
const extendProp = <T>(
prop: T | Binding<T | undefined> | undefined,
override: (prop: T | undefined) => T | undefined
) => prop && (prop instanceof Binding ? prop.as(override) : override(prop));
@register()
export default class PopupWindow extends Widget.Window {
constructor(props: Widget.WindowProps) {
super({
keymode: Astal.Keymode.ON_DEMAND,
borderWidth: 20, // To allow shadow, cause if not it gets cut off
...props,
visible: false,
application: App,
name: props.monitor ? extendProp(props.name, n => (n ? n + props.monitor : undefined)) : props.name,
namespace: extendProp(props.name, n => `caelestia-${n}`),
onKeyPressEvent: (self, event) => {
// Close window on escape
if (event.get_keyval()[1] === Gdk.KEY_Escape) self.hide();
return props.onKeyPressEvent?.(self, event);
},
});
}
popup_at_widget(widget: JSX.Element, event: Gdk.Event | Astal.ClickEvent) {
const { width, height } = widget.get_allocation();
const { width: mWidth, height: mHeight } = AstalHyprland.get_default().get_focused_monitor();
const pWidth = this.get_preferred_width()[1];
const pHeight = this.get_preferred_height()[1];
const [, x, y] = event instanceof Gdk.Event ? event.get_coords() : [null, event.x, event.y];
const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position();
let marginLeft = 0;
let marginTop = 0;
if (bar.vertical.get()) {
marginLeft = cx + (width - x);
marginTop = cy + ((height - pHeight) / 2 - y);
if (marginTop < 0) marginTop = 0;
else if (marginTop + pHeight > mHeight) marginTop = mHeight - pHeight;
} else {
marginLeft = cx + ((width - pWidth) / 2 - x);
if (marginLeft < 0) marginLeft = 0;
else if (marginLeft + pWidth > mWidth) marginLeft = mWidth - pWidth;
marginTop = cy + (height - y);
}
this.anchor = Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT;
this.exclusivity = Astal.Exclusivity.IGNORE;
this.marginLeft = marginLeft;
this.marginTop = marginTop;
this.show();
}
popup_at_corner(corner: `${"top" | "bottom"} ${"left" | "right"}`) {
let anchor = 0;
if (corner.includes("top")) anchor |= Astal.WindowAnchor.TOP;
else anchor |= Astal.WindowAnchor.BOTTOM;
if (corner.includes("left")) anchor |= Astal.WindowAnchor.LEFT;
else anchor |= Astal.WindowAnchor.RIGHT;
this.anchor = anchor;
this.exclusivity = Astal.Exclusivity.NORMAL;
this.marginLeft = 0;
this.marginTop = 0;
this.show();
}
}

View file

@ -1,49 +0,0 @@
import type { Binding } from "astal";
import { Gtk, type Widget } from "astal/gtk3";
import type cairo from "cairo";
type Place = "topleft" | "topright" | "bottomleft" | "bottomright";
export default ({ place, ...rest }: Widget.DrawingAreaProps & { place: Place | Binding<Place> }) => (
<drawingarea
{...rest}
className="screen-corner"
setup={self => {
self.connect("realize", () => self.get_window()?.set_pass_through(true));
const r = self.get_style_context().get_property("border-radius", Gtk.StateFlags.NORMAL) as number;
self.set_size_request(r, r);
self.connect("draw", (_, cr: cairo.Context) => {
const c = self.get_style_context().get_background_color(Gtk.StateFlags.NORMAL);
const r = self.get_style_context().get_property("border-radius", Gtk.StateFlags.NORMAL) as number;
self.set_size_request(r, r);
switch (typeof place === "string" ? place : place.get()) {
case "topleft":
cr.arc(r, r, r, Math.PI, (3 * Math.PI) / 2);
cr.lineTo(0, 0);
break;
case "topright":
cr.arc(0, r, r, (3 * Math.PI) / 2, 2 * Math.PI);
cr.lineTo(r, 0);
break;
case "bottomleft":
cr.arc(r, 0, r, Math.PI / 2, Math.PI);
cr.lineTo(0, r);
break;
case "bottomright":
cr.arc(0, 0, r, 0, Math.PI / 2);
cr.lineTo(r, r);
break;
}
cr.closePath();
cr.setSourceRGBA(c.red, c.green, c.blue, c.alpha);
cr.fill();
});
}}
/>
);

View file

@ -1,64 +0,0 @@
import { bind, type Binding } from "astal";
import { Gdk, Gtk, type Widget } from "astal/gtk3";
import type cairo from "cairo";
export default ({
value,
onChange,
}: {
value: Binding<number>;
onChange?: (self: Widget.DrawingArea, value: number) => void;
}) => (
<drawingarea
hexpand
valign={Gtk.Align.CENTER}
className="slider"
css={bind(value).as(v => `font-size: ${Math.min(1, Math.max(0, v))}px;`)}
setup={self => {
const halfPi = Math.PI / 2;
const styleContext = self.get_style_context();
self.set_size_request(-1, styleContext.get_property("min-height", Gtk.StateFlags.NORMAL) as number);
self.connect("draw", (_, cr: cairo.Context) => {
const styleContext = self.get_style_context();
const width = self.get_allocated_width();
const height = styleContext.get_property("min-height", Gtk.StateFlags.NORMAL) as number;
self.set_size_request(-1, height);
const progressValue = styleContext.get_property("font-size", Gtk.StateFlags.NORMAL) as number;
let radius = styleContext.get_property("border-radius", Gtk.StateFlags.NORMAL) as number;
const bg = styleContext.get_background_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
// Background
cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
cr.arc(width - radius, radius, radius, -halfPi, 0); // Top right
cr.arc(width - radius, height - radius, radius, 0, halfPi); // Bottom right
cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
cr.fill();
// Flatten when near 0
radius = Math.min(radius, Math.min(width * progressValue, height) / 2);
const progressPosition = width * progressValue - radius;
const fg = styleContext.get_color(Gtk.StateFlags.NORMAL);
cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha);
// Foreground
cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
cr.arc(progressPosition, radius, radius, -halfPi, 0); // Top right
cr.arc(progressPosition, height - radius, radius, 0, halfPi); // Bottom right
cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
cr.fill();
});
self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK);
self.connect("button-press-event", (_, event: Gdk.Event) =>
onChange?.(self, event.get_coords()[1] / self.get_allocated_width())
);
}}
/>
);

View file

@ -1,25 +0,0 @@
@use "sass:color";
@use "scss/scheme";
// Common styles
@use "scss/widgets";
@use "scss/common";
// Modules
@use "scss/bar";
@use "scss/notifpopups";
@use "scss/launcher";
@use "scss/osds";
@use "scss/session";
@use "scss/sidebar";
@use "scss/navbar";
@use "scss/mediadisplay";
* {
all: unset; // Remove GTK theme styles
caret-color: scheme.$rosewater;
selection {
background-color: color.change(scheme.$overlay2, $alpha: 0.3);
}
}

View file

@ -1,21 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"experimentalDecorators": true,
"strict": true,
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
// "checkJs": true,
// "allowJs": true,
"jsx": "react-jsx",
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
"paths": {
"astal": ["/usr/share/astal/gjs"],
"astal/*": ["/usr/share/astal/gjs/*"],
"config": ["./src/config/index"],
"config/*": ["./src/config/*"],
"@/*": ["./src/*"]
}
}
}