summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app.tsx69
-rw-r--r--package-lock.json7
-rw-r--r--package.json1
-rwxr-xr-xrun.fish4
-rw-r--r--scss/_lib.scss54
-rw-r--r--scss/common.scss21
-rw-r--r--scss/notifpopups.scss8
-rw-r--r--scss/sidebar.scss785
-rw-r--r--src/config/defaults.ts8
-rw-r--r--src/config/funcs.ts14
-rw-r--r--src/config/index.ts2
-rw-r--r--src/config/types.ts5
-rw-r--r--src/modules/bar.tsx42
-rw-r--r--src/modules/launcher/actions.tsx41
-rw-r--r--src/modules/launcher/index.tsx1
-rw-r--r--src/modules/notifpopups.tsx1
-rw-r--r--src/modules/sidebar/audio.tsx13
-rw-r--r--src/modules/sidebar/connectivity.tsx10
-rw-r--r--src/modules/sidebar/dashboard.tsx134
-rw-r--r--src/modules/sidebar/index.tsx54
-rw-r--r--src/modules/sidebar/modules/bluetooth.tsx127
-rw-r--r--src/modules/sidebar/modules/deviceselector.tsx126
-rw-r--r--src/modules/sidebar/modules/hwresources.tsx67
-rw-r--r--src/modules/sidebar/modules/media.tsx172
-rw-r--r--src/modules/sidebar/modules/networks.tsx147
-rw-r--r--src/modules/sidebar/modules/news.tsx110
-rw-r--r--src/modules/sidebar/modules/notifications.tsx86
-rw-r--r--src/modules/sidebar/modules/streams.tsx110
-rw-r--r--src/modules/sidebar/modules/upcoming.tsx99
-rw-r--r--src/modules/sidebar/modules/updates.tsx109
-rw-r--r--src/modules/sidebar/notifpane.tsx7
-rw-r--r--src/modules/sidebar/packages.tsx10
-rw-r--r--src/services/calendar.ts168
-rw-r--r--src/services/monitors.ts2
-rw-r--r--src/services/schemes.ts3
-rw-r--r--src/services/wallpapers.ts3
-rw-r--r--src/utils/system.ts5
-rw-r--r--src/utils/widgets.ts46
-rw-r--r--src/widgets/notification.tsx28
-rw-r--r--src/widgets/slider.tsx64
-rw-r--r--style.scss2
41 files changed, 2600 insertions, 165 deletions
diff --git a/app.tsx b/app.tsx
index ff7601a..23f4be9 100644
--- a/app.tsx
+++ b/app.tsx
@@ -2,14 +2,14 @@ import Bar from "@/modules/bar";
import Launcher from "@/modules/launcher";
import NotifPopups from "@/modules/notifpopups";
import Osds from "@/modules/osds";
-import Popdowns from "@/modules/popdowns";
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 type PopupWindow from "@/widgets/popupwindow";
import { execAsync, idle, timeout, writeFileAsync } from "astal";
import { App } from "astal/gtk3";
import { style } from "config";
@@ -20,65 +20,76 @@ const isLayer = (name: string) =>
const applyTransparency = (name: string, hex: string) => {
if (style.transparency.get() === "off" || !isLayer(name)) return hex;
- const amount = style.transparency.get() === "high" ? 0.58 : 0.78;
+ let amount = style.transparency.get() === "high" ? 0.58 : 0.78;
+ if (Palette.get_default().mode === "light") amount = style.transparency.get() === "high" ? 0.53 : 0.63;
return `color.change(${hex}, $alpha: ${amount})`;
};
const applyVibrancy = (hex: string) => (style.vibrant.get() ? `color.scale(${hex}, $saturation: 40%)` : hex);
-export const loadStyleAsync = async () => {
- 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${schemeColours}`);
- App.apply_css(await execAsync(`sass ${SRC}/style.scss`), true);
-};
+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$light: ${Palette.get_default().mode === "light"};\n${schemeColours}`
+ );
+ App.apply_css(await execAsync(`sass ${SRC}/style.scss`), true);
+ }
+})();
+
+export const loadStyleAsync = () => styleLoader.run();
App.start({
instanceName: "caelestia",
icons: "assets/icons",
- main() {
+ async main() {
const now = Date.now();
+ await initConfig();
+
loadStyleAsync().catch(console.error);
style.transparency.subscribe(() => loadStyleAsync().catch(console.error));
Palette.get_default().connect("notify::colours", () => loadStyleAsync().catch(console.error));
-
- initConfig();
+ Palette.get_default().connect("notify::mode", () => loadStyleAsync().catch(console.error));
<Launcher />;
<NotifPopups />;
<Osds />;
<Session />;
+ Monitors.get_default().forEach(m => <SideBar monitor={m} />);
Monitors.get_default().forEach(m => <Bar monitor={m} />);
- <Popdowns />;
// Init services
timeout(1000, () => {
idle(() => Schemes.get_default());
idle(() => Wallpapers.get_default());
+ idle(() => Calendar.get_default());
});
console.log(`Caelestia started in ${Date.now() - now}ms`);
},
requestHandler(request, res) {
- if (request === "quit") App.quit();
- else if (request === "reload-css") loadStyleAsync().catch(console.error);
+ 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 === "toggle sideleft") {
- const window = App.get_window("sideleft") as PopupWindow | null;
- if (window) {
- if (window.visible) window.hide();
- else window.popup_at_corner("top left");
- }
- } else if (request === "toggle sideright") {
- const window = App.get_window("sideright") as PopupWindow | null;
- if (window) {
- if (window.visible) window.hide();
- else window.popup_at_corner("top right");
- }
- } else if (request === "media play-pause") Players.get_default().lastPlayer?.play_pause();
+ 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();
diff --git a/package-lock.json b/package-lock.json
index 6e56f15..c8c9317 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"fuzzysort": "^3.1.0",
+ "ical.js": "^2.1.0",
"mathjs": "^14.0.1"
},
"devDependencies": {
@@ -534,6 +535,12 @@
"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",
diff --git a/package.json b/package.json
index c530938..4574f96 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"dependencies": {
"fuzzysort": "^3.1.0",
+ "ical.js": "^2.1.0",
"mathjs": "^14.0.1"
},
"devDependencies": {
diff --git a/run.fish b/run.fish
index 2efd677..6f2b072 100755
--- a/run.fish
+++ b/run.fish
@@ -8,7 +8,9 @@ set -q XDG_STATE_HOME && set -l state_dir $XDG_STATE_HOME/caelestia || set -l st
mkdir -p $cache_dir
-./node_modules/.bin/esbuild app.tsx --bundle --outfile=$bundle_dir/caelestia.js \
+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
diff --git a/scss/_lib.scss b/scss/_lib.scss
index ff418c3..e222b59 100644
--- a/scss/_lib.scss
+++ b/scss/_lib.scss
@@ -43,57 +43,3 @@ $scale: 0.068rem;
@mixin ease-in-out {
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
}
-
-@mixin popdown-window($colour) {
- @include rounded(8);
- @include border($colour, 0.4, 2);
- @include shadow;
- @include font.mono;
-
- background-color: scheme.$mantle;
- color: $colour;
- padding: s(10) s(12);
- font-size: s(14);
-
- .header {
- @include spacing(8);
-
- padding: 0 s(5);
- margin-bottom: s(8);
- font-size: s(15);
-
- button {
- @include rounded(5);
- @include element-decel;
-
- padding: s(3) s(8);
-
- &:hover,
- &:focus {
- background-color: scheme.$surface0;
- }
-
- &:active {
- background-color: scheme.$surface1;
- }
-
- &.enabled {
- background-color: $colour;
- color: scheme.$base;
-
- &:hover,
- &:focus {
- background-color: color.mix($colour, scheme.$base, 80%);
- }
-
- &:active {
- background-color: color.mix($colour, scheme.$base, 70%);
- }
- }
- }
- }
-
- .icon {
- font-size: s(32);
- }
-}
diff --git a/scss/common.scss b/scss/common.scss
index 88f3326..1cf7249 100644
--- a/scss/common.scss
+++ b/scss/common.scss
@@ -61,25 +61,4 @@ label.icon {
font-size: lib.s(14);
color: scheme.$subtext0;
}
-
- .actions {
- @include lib.spacing;
-
- & > * {
- @include lib.rounded(5);
- @include lib.element-decel;
-
- padding: lib.s(5) lib.s(10);
- background-color: scheme.$surface0;
-
- &:hover,
- &:focus {
- background-color: scheme.$surface1;
- }
-
- &:active {
- background-color: scheme.$surface2;
- }
- }
- }
}
diff --git a/scss/notifpopups.scss b/scss/notifpopups.scss
index c4760b7..89e5eea 100644
--- a/scss/notifpopups.scss
+++ b/scss/notifpopups.scss
@@ -3,7 +3,7 @@
@use "lib";
@use "font";
-@mixin popup($colour, $alpha) {
+@mixin popup($colour) {
.separator {
background-color: $colour;
}
@@ -29,16 +29,16 @@
@include lib.shadow;
&.low {
- @include popup(scheme.$overlay0, 0.3);
+ @include popup(scheme.$overlay0);
}
&.normal {
- @include popup(scheme.$primary, 0.3);
+ @include popup(scheme.$primary);
}
&.critical {
@include lib.border(scheme.$error, 0.5);
- @include popup(scheme.$error, 0.8);
+ @include popup(scheme.$error);
}
}
}
diff --git a/scss/sidebar.scss b/scss/sidebar.scss
new file mode 100644
index 0000000..5616ce3
--- /dev/null
+++ b/scss/sidebar.scss
@@ -0,0 +1,785 @@
+@use "sass:color";
+@use "scheme";
+@use "lib";
+@use "font";
+
+@mixin notification($accent) {
+ .separator {
+ background-color: $accent;
+ }
+
+ .image {
+ @include lib.border($accent, 0.05);
+ }
+}
+
+@mixin button {
+ @include lib.element-decel;
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.5);
+
+ &:hover,
+ &:focus {
+ background-color: color.change(scheme.$surface2, $alpha: 0.5);
+ }
+
+ &:active {
+ background-color: color.change(scheme.$overlay0, $alpha: 0.5);
+ }
+
+ &:disabled {
+ color: scheme.$subtext0;
+ }
+}
+
+@mixin button-active {
+ @include lib.element-decel;
+
+ background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 50%), $alpha: 0.5);
+
+ &:hover,
+ &:focus {
+ background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 30%), $alpha: 0.5);
+ }
+
+ &:active {
+ background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 20%), $alpha: 0.5);
+ }
+}
+
+@mixin media-button {
+ @include lib.element-decel;
+
+ &:disabled {
+ color: scheme.$overlay2;
+ }
+
+ &:hover,
+ &:focus {
+ color: color.mix(scheme.$subtext1, scheme.$subtext0, 50%);
+ }
+
+ &:active {
+ color: scheme.$subtext0;
+ }
+}
+
+.sidebar {
+ @include font.mono;
+
+ background-color: scheme.$mantle;
+ color: scheme.$text;
+ padding: lib.s(18) lib.s(20);
+ min-width: lib.s(380);
+
+ .pane {
+ @include lib.spacing(20, true);
+ }
+
+ .separator {
+ background-color: if(scheme.$light, scheme.$surface1, scheme.$overlay0);
+ margin: 0 lib.s(10);
+ }
+
+ .header-bar {
+ margin-bottom: lib.s(10);
+
+ @include lib.spacing;
+
+ & > :not(button) {
+ font-weight: bold;
+ font-size: lib.s(16);
+ }
+
+ & > button {
+ @include lib.element-decel;
+ @include lib.rounded(10);
+
+ padding: lib.s(3) lib.s(8);
+
+ &:disabled {
+ color: scheme.$overlay0;
+ }
+
+ &:hover,
+ &:focus {
+ color: scheme.$subtext0;
+ }
+
+ &:active {
+ color: scheme.$overlay2;
+ }
+
+ &.enabled {
+ $-base: color.change(scheme.$base, $alpha: 1);
+
+ background-color: scheme.$primary;
+ color: $-base;
+
+ &:hover,
+ &:focus {
+ background-color: color.mix(scheme.$primary, $-base, 80%);
+ }
+
+ &:active {
+ background-color: color.mix(scheme.$primary, $-base, 70%);
+ }
+ }
+ }
+ }
+
+ .empty {
+ color: scheme.$subtext0;
+ font-size: lib.s(18);
+
+ .icon {
+ font-size: lib.s(48);
+ }
+ }
+
+ .user {
+ @include lib.spacing(15);
+
+ .face {
+ @include lib.rounded(10);
+
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ min-width: lib.s(96);
+ min-height: lib.s(96);
+ font-size: lib.s(48);
+ font-weight: bold;
+ background-color: scheme.$base;
+ }
+
+ .details {
+ font-size: lib.s(14);
+ color: scheme.$yellow;
+
+ @include lib.spacing(8, true);
+
+ .name {
+ font-size: lib.s(18);
+ color: scheme.$text;
+ margin-bottom: lib.s(10);
+ }
+
+ .uptime {
+ color: scheme.$blue;
+ }
+ }
+ }
+
+ .media {
+ @include lib.spacing(15);
+
+ .cover-art {
+ @include lib.rounded(10);
+ @include lib.element-decel;
+
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ min-width: lib.s(128);
+ min-height: lib.s(128);
+ font-size: lib.s(64);
+ font-weight: bold;
+ background-color: scheme.$base;
+ color: scheme.$subtext0;
+ }
+
+ .details {
+ font-size: lib.s(14);
+
+ .title {
+ font-size: lib.s(16);
+ color: scheme.$text;
+ }
+
+ .artist {
+ color: scheme.$green;
+ }
+
+ .controls {
+ margin-top: lib.s(20);
+ margin-bottom: lib.s(5);
+ font-size: lib.s(24);
+
+ & > button {
+ @include media-button;
+ }
+ }
+
+ .slider {
+ @include lib.rounded(5);
+ @include lib.fluent-decel(1000ms);
+
+ min-height: lib.s(8);
+ background-color: scheme.$overlay0;
+ color: scheme.$subtext1;
+ }
+
+ .time {
+ margin-top: lib.s(5);
+ font-size: lib.s(13);
+ color: scheme.$subtext0;
+ }
+ }
+ }
+
+ .notification {
+ .wrapper {
+ padding-bottom: lib.s(10);
+ }
+
+ .inner {
+ @include lib.rounded(20);
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+
+ &.low {
+ @include notification(if(scheme.$light, scheme.$surface1, scheme.$overlay0));
+ }
+
+ &.normal {
+ @include lib.border(scheme.$primary, if(scheme.$light, 0.5, 0.3));
+ @include notification(scheme.$primary);
+ }
+
+ &.critical {
+ @include lib.border(scheme.$error, 0.8);
+ @include notification(scheme.$error);
+ }
+ }
+
+ .actions {
+ @include lib.spacing;
+
+ & > button {
+ @include button;
+ @include lib.rounded(10);
+
+ padding: lib.s(5) lib.s(10);
+ }
+ }
+ }
+
+ .upcoming {
+ .list {
+ min-height: lib.s(300);
+ }
+
+ .day {
+ @include lib.spacing($vertical: true);
+
+ &:not(:first-child) {
+ margin-top: lib.s(20);
+ }
+
+ .date {
+ margin-left: lib.s(10);
+ }
+
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ .events {
+ @include lib.rounded(20);
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ @include lib.spacing(10, true);
+ }
+
+ .event {
+ @include lib.spacing(8);
+ }
+
+ .calendar-indicator {
+ @include lib.rounded(5);
+
+ min-width: lib.s(1);
+
+ $-colours: scheme.$red, scheme.$sapphire, scheme.$flamingo, scheme.$maroon, scheme.$pink, scheme.$sky,
+ scheme.$peach, scheme.$yellow, scheme.$green, scheme.$rosewater, scheme.$mauve, scheme.$teal,
+ scheme.$blue;
+ @for $i from 1 through length($-colours) {
+ &.c#{$i} {
+ background-color: nth($-colours, $i);
+ }
+ }
+ }
+ }
+ }
+
+ .players {
+ .player {
+ @include lib.spacing(40, true);
+
+ .cover-art {
+ @include lib.rounded(10);
+ @include lib.element-decel;
+ @include lib.shadow(scheme.$mantle, $blur: 5, $spread: 2);
+
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ min-width: lib.s(256);
+ min-height: lib.s(256);
+ font-size: lib.s(96);
+ font-weight: bold;
+ background-color: scheme.$base;
+ color: scheme.$subtext0;
+ margin-top: lib.s(20);
+ }
+
+ .progress {
+ margin: 0 lib.s(40);
+
+ .slider {
+ @include lib.rounded(8);
+ @include lib.fluent-decel(1000ms);
+
+ min-height: lib.s(15);
+ background-color: scheme.$overlay0;
+ color: scheme.$subtext1;
+ }
+
+ .time {
+ margin-top: lib.s(5);
+ font-size: lib.s(13);
+ color: scheme.$subtext1;
+ }
+ }
+
+ .details {
+ font-size: lib.s(14);
+ margin-top: lib.s(20);
+
+ @include lib.spacing(3, true);
+
+ .title {
+ font-size: lib.s(18);
+ color: scheme.$text;
+ font-weight: bold;
+ }
+
+ .artist {
+ color: scheme.$green;
+ }
+
+ .album {
+ color: scheme.$subtext0;
+ }
+ }
+
+ .controls {
+ margin-top: lib.s(-20);
+ margin-bottom: lib.s(5);
+
+ button {
+ @include media-button;
+
+ // Cause some nerd font icons don't have the correct width
+ &.needs-adjustment {
+ padding-right: lib.s(5);
+ }
+ }
+
+ .playback {
+ font-size: lib.s(32);
+
+ @include lib.spacing(40);
+ }
+
+ .options {
+ margin: 0 lib.s(40);
+ margin-top: lib.s(-10);
+ font-size: lib.s(20);
+
+ @include lib.spacing(20);
+ }
+ }
+ }
+
+ .indicators {
+ @include lib.spacing(10);
+
+ & > button {
+ @include lib.rounded(1000);
+ @include lib.element-decel;
+
+ min-width: lib.s(10);
+ min-height: lib.s(10);
+
+ background-color: color.change(scheme.$overlay0, $alpha: 0.5);
+
+ &:hover,
+ &:focus {
+ background-color: color.change(scheme.$overlay1, $alpha: 0.5);
+ }
+
+ &:active {
+ background-color: color.change(scheme.$overlay2, $alpha: 0.5);
+ }
+
+ &.active {
+ background-color: color.change(scheme.$primary, $alpha: 0.9);
+
+ &:hover,
+ &:focus {
+ background-color: color.change(scheme.$primary, $alpha: 0.7);
+ }
+
+ &:active {
+ background-color: color.change(scheme.$primary, $alpha: 0.6);
+ }
+ }
+ }
+ }
+ }
+
+ .no-wp-prompt {
+ font-size: lib.s(16);
+ color: scheme.$error;
+ margin-top: lib.s(8);
+ }
+
+ .streams {
+ .list {
+ @include lib.spacing(10, true);
+ }
+
+ .stream {
+ @include lib.rounded(20);
+ @include lib.element-decel;
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ @include lib.spacing(5);
+
+ &.playing {
+ background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 50%), $alpha: 0.4);
+ }
+
+ .icon {
+ font-size: lib.s(28);
+ margin-right: lib.s(12);
+ }
+
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ trough {
+ @include lib.rounded(10);
+
+ min-width: lib.s(100);
+ min-height: lib.s(10);
+ background-color: color.change(scheme.$error, $alpha: 0.3);
+
+ fill {
+ @include lib.rounded(10);
+
+ background-color: color.change(scheme.$overlay0, $alpha: 1);
+ }
+
+ highlight {
+ @include lib.rounded(10);
+
+ background-color: scheme.$subtext1;
+ }
+ }
+
+ & > button {
+ @include media-button;
+
+ font-size: lib.s(18);
+ min-width: lib.s(20);
+ min-height: lib.s(20);
+ }
+ }
+ }
+
+ .device-selector {
+ @include lib.spacing(10, true);
+
+ .selector {
+ @include lib.rounded(20);
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ .icon {
+ font-size: lib.s(20);
+ }
+
+ .separator {
+ margin-bottom: lib.s(8);
+ margin-top: lib.s(5);
+ background-color: if(scheme.$light, scheme.$overlay1, scheme.$overlay0);
+ }
+
+ .list {
+ color: scheme.$subtext0;
+
+ @include lib.spacing(3, true);
+ }
+
+ .device {
+ @include lib.spacing;
+ }
+
+ .selected {
+ color: scheme.$text;
+
+ @include lib.spacing(10);
+
+ .icon {
+ font-size: lib.s(32);
+ }
+
+ .sublabel {
+ color: scheme.$subtext0;
+ }
+ }
+
+ button {
+ @include lib.element-decel;
+
+ &:hover,
+ &:focus {
+ color: scheme.$subtext1;
+ }
+
+ &:active {
+ color: scheme.$text;
+ }
+ }
+ }
+
+ .stream {
+ @include lib.rounded(20);
+ @include lib.element-decel;
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ @include lib.spacing(5);
+
+ &.playing {
+ background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 50%), $alpha: 0.4);
+ }
+
+ .icon {
+ font-size: lib.s(28);
+ margin-right: lib.s(12);
+ }
+
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ trough {
+ @include lib.rounded(10);
+
+ min-width: lib.s(100);
+ min-height: lib.s(10);
+ background-color: color.change(scheme.$error, $alpha: 0.3);
+
+ fill {
+ @include lib.rounded(10);
+
+ background-color: color.change(scheme.$overlay0, $alpha: 1);
+ }
+
+ highlight {
+ @include lib.rounded(10);
+
+ background-color: scheme.$subtext1;
+ }
+ }
+
+ & > button {
+ @include media-button;
+
+ font-size: lib.s(18);
+ min-width: lib.s(20);
+ min-height: lib.s(20);
+ }
+ }
+ }
+
+ .networks {
+ .list {
+ @include lib.spacing(10, true);
+ }
+
+ .network {
+ @include lib.rounded(20);
+ @include lib.element-decel;
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ @include lib.spacing(5);
+
+ &.connected {
+ background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 50%), $alpha: 0.4);
+
+ & > button {
+ @include button-active;
+ }
+ }
+
+ .icon {
+ font-size: lib.s(28);
+ margin-right: lib.s(12);
+ }
+
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ & > button {
+ @include button;
+ @include lib.rounded(1000);
+ @include font.icon;
+
+ font-size: lib.s(18);
+ min-width: lib.s(30);
+ min-height: lib.s(30);
+ }
+ }
+ }
+
+ .bluetooth {
+ .list {
+ @include lib.spacing(10, true);
+ }
+
+ .device {
+ @include lib.rounded(20);
+ @include lib.element-decel;
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ @include lib.spacing(5);
+
+ &.connected {
+ background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 50%), $alpha: 0.4);
+
+ & > button {
+ @include button-active;
+ }
+ }
+
+ .icon {
+ font-size: lib.s(28);
+ margin-right: lib.s(12);
+ }
+
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ & > button {
+ @include button;
+ @include lib.rounded(1000);
+ @include font.icon;
+
+ font-size: lib.s(18);
+ min-width: lib.s(30);
+ min-height: lib.s(30);
+ }
+ }
+ }
+
+ .updates {
+ .list {
+ @include lib.spacing(10, true);
+ }
+
+ .repo {
+ @include lib.rounded(20);
+ @include lib.element-decel;
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ @include lib.spacing(5);
+
+ .icon {
+ font-size: lib.s(28);
+
+ &:not(:last-child) {
+ margin-right: lib.s(12);
+ }
+ }
+
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ .body {
+ margin-top: lib.s(10);
+ font-size: lib.s(14);
+ }
+ }
+ }
+
+ .news {
+ min-height: lib.s(200);
+
+ .expanded {
+ min-height: lib.s(400);
+ }
+
+ .empty {
+ margin-top: lib.s(40);
+ }
+
+ .list {
+ @include lib.spacing(10, true);
+ }
+
+ .article {
+ @include lib.rounded(20);
+ @include lib.element-decel;
+
+ background-color: color.change(scheme.$surface1, $alpha: 0.4);
+ padding: lib.s(10) lib.s(15);
+
+ @include lib.spacing(5);
+
+ .icon {
+ font-size: lib.s(28);
+
+ &:not(:last-child) {
+ margin-right: lib.s(12);
+ }
+ }
+
+ .sublabel {
+ font-size: lib.s(14);
+ color: scheme.$subtext0;
+ }
+
+ .body {
+ margin-top: lib.s(10);
+ font-size: lib.s(14);
+ }
+ }
+ }
+}
diff --git a/src/config/defaults.ts b/src/config/defaults.ts
index 8a3927a..5365d86 100644
--- a/src/config/defaults.ts
+++ b/src/config/defaults.ts
@@ -97,6 +97,9 @@ export default {
},
},
},
+ sidebar: {
+ showOnStartup: false,
+ },
sideleft: {
directories: {
left: {
@@ -144,4 +147,9 @@ export default {
},
],
},
+ 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,
+ },
};
diff --git a/src/config/funcs.ts b/src/config/funcs.ts
index 473c502..72823eb 100644
--- a/src/config/funcs.ts
+++ b/src/config/funcs.ts
@@ -1,4 +1,4 @@
-import { GLib, monitorFile, readFileAsync, Variable } from "astal";
+import { GLib, monitorFile, readFileAsync, Variable, writeFileAsync } from "astal";
import config from ".";
import { loadStyleAsync } from "../../app";
import defaults from "./defaults";
@@ -88,7 +88,15 @@ export const updateConfig = async () => {
loadStyleAsync().catch(console.error);
};
-export const initConfig = () => {
+export const initConfig = async () => {
monitorFile(CONFIG, () => updateConfig().catch(e => console.warn(`Invalid config: ${e}`)));
- updateConfig().catch(e => console.warn(`Invalid config: ${e}`));
+ await updateConfig().catch(e => console.warn(`Invalid config: ${e}`));
+};
+
+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));
};
diff --git a/src/config/index.ts b/src/config/index.ts
index d09a668..0cb8a60 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -9,6 +9,7 @@ export const {
launcher,
notifpopups,
osds,
+ sidebar,
sideleft,
math,
updates,
@@ -18,5 +19,6 @@ export const {
memory,
storage,
wallpapers,
+ calendar,
} = config;
export default config;
diff --git a/src/config/types.ts b/src/config/types.ts
index 51eb7cc..d3215c8 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -50,6 +50,8 @@ export default {
"osds.lock.spacing": NUM,
"osds.lock.caps.hideDelay": NUM,
"osds.lock.num.hideDelay": NUM,
+ // Sidebar
+ "sidebar.showOnStartup": BOOL,
// Sideleft
"sideleft.directories.left.top": STR,
"sideleft.directories.left.middle": STR,
@@ -69,4 +71,7 @@ export default {
"memory.interval": NUM,
"storage.interval": NUM,
"wallpapers.paths": OBJ_ARR({ recursive: BOOL, path: STR }),
+ "calendar.webcals": ARR(STR),
+ "calendar.upcomingDays": NUM,
+ "calendar.notify": BOOL,
} as { [k: string]: string | string[] | number[] };
diff --git a/src/modules/bar.tsx b/src/modules/bar.tsx
index 14a1ebd..124636a 100644
--- a/src/modules/bar.tsx
+++ b/src/modules/bar.tsx
@@ -1,3 +1,4 @@
+import type SideBar from "@/modules/sidebar";
import type { Monitor } from "@/services/monitors";
import Players from "@/services/players";
import Updates from "@/services/updates";
@@ -72,10 +73,19 @@ const togglePopup = (self: JSX.Element, event: Astal.ClickEvent, name: string) =
}
};
+const switchPane = (name: string) => {
+ const sidebar = App.get_window("sidebar") as SideBar | null;
+ if (sidebar) {
+ if (sidebar.visible && sidebar.shown.get() === name) sidebar.hide();
+ else sidebar.show();
+ sidebar.shown.set(name);
+ }
+};
+
const OSIcon = () => (
<button
className="module os-icon"
- onClick={(self, event) => event.button === Astal.MouseButton.PRIMARY && togglePopup(self, event, "sideleft")}
+ onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane("dashboard")}
>
{osIcon}
</button>
@@ -128,10 +138,9 @@ const MediaPlaying = () => {
players.lastPlayer ? `${players.lastPlayer.title} - ${players.lastPlayer.artist}` : fallback;
return (
<button
- onClick={(self, event) => {
- if (event.button === Astal.MouseButton.PRIMARY) {
- togglePopup(self, event, "media");
- } else if (event.button === Astal.MouseButton.SECONDARY) players.lastPlayer?.play_pause();
+ onClick={(_, event) => {
+ if (event.button === Astal.MouseButton.PRIMARY) switchPane("audio");
+ else if (event.button === Astal.MouseButton.SECONDARY) players.lastPlayer?.play_pause();
else if (event.button === Astal.MouseButton.MIDDLE) players.lastPlayer?.raise();
}}
setup={self => {
@@ -252,16 +261,15 @@ const Tray = () => (
const Network = () => (
<button
- onClick={(self, event) => {
+ onClick={(_, event) => {
const network = AstalNetwork.get_default();
- if (event.button === Astal.MouseButton.PRIMARY) {
- togglePopup(self, event, "networks");
- } else if (event.button === Astal.MouseButton.SECONDARY) network.wifi.enabled = !network.wifi.enabled;
+ if (event.button === Astal.MouseButton.PRIMARY) switchPane("connectivity");
+ else if (event.button === Astal.MouseButton.SECONDARY) network.wifi.enabled = !network.wifi.enabled;
else if (event.button === Astal.MouseButton.MIDDLE)
execAsync("uwsm app -- gnome-control-center wifi").catch(() => {
network.wifi.scan();
execAsync(
- "uwsm app -- foot -T nmtui fish -c 'sleep .1; set -e COLORTERM; TERM=xterm-old nmtui connect'"
+ "uwsm app -- foot -T nmtui -- fish -c 'sleep .1; set -e COLORTERM; TERM=xterm-old nmtui connect'"
).catch(() => {}); // Ignore errors
});
}}
@@ -357,8 +365,8 @@ const Network = () => (
const BluetoothDevice = (device: AstalBluetooth.Device) => (
<button
visible={bind(device, "connected")}
- onClick={(self, event) => {
- if (event.button === Astal.MouseButton.PRIMARY) togglePopup(self, event, "bluetooth-devices");
+ onClick={(_, event) => {
+ if (event.button === Astal.MouseButton.PRIMARY) switchPane("connectivity");
else if (event.button === Astal.MouseButton.SECONDARY)
device.disconnect_device((_, res) => device.disconnect_device_finish(res));
else if (event.button === Astal.MouseButton.MIDDLE)
@@ -377,8 +385,8 @@ const BluetoothDevice = (device: AstalBluetooth.Device) => (
const Bluetooth = () => (
<box vertical={bind(config.vertical)} className="bluetooth">
<button
- onClick={(self, event) => {
- if (event.button === Astal.MouseButton.PRIMARY) togglePopup(self, event, "bluetooth-devices");
+ onClick={(_, event) => {
+ if (event.button === Astal.MouseButton.PRIMARY) switchPane("connectivity");
else if (event.button === Astal.MouseButton.SECONDARY) AstalBluetooth.get_default().toggle();
else if (event.button === Astal.MouseButton.MIDDLE)
execAsync("uwsm app -- blueman-manager").catch(console.error);
@@ -429,7 +437,7 @@ const StatusIcons = () => (
const PkgUpdates = () => (
<button
- onClick={(self, event) => event.button === Astal.MouseButton.PRIMARY && togglePopup(self, event, "updates")}
+ onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane("packages")}
setup={self =>
setupCustomTooltip(
self,
@@ -446,9 +454,7 @@ const PkgUpdates = () => (
const NotifCount = () => (
<button
- onClick={(self, event) =>
- event.button === Astal.MouseButton.PRIMARY && togglePopup(self, event, "notifications")
- }
+ onClick={(_, event) => event.button === Astal.MouseButton.PRIMARY && switchPane("notifpane")}
setup={self =>
setupCustomTooltip(
self,
diff --git a/src/modules/launcher/actions.tsx b/src/modules/launcher/actions.tsx
index 053387c..11b07d0 100644
--- a/src/modules/launcher/actions.tsx
+++ b/src/modules/launcher/actions.tsx
@@ -8,6 +8,7 @@ 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";
@@ -24,6 +25,24 @@ interface ActionMap {
[k: string]: IAction;
}
+const transparencyActions = {
+ off: {
+ icon: "blur_off",
+ name: "Off",
+ description: "Completely opaque",
+ },
+ 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);
@@ -89,6 +108,12 @@ const actions = (mode: Variable<Mode>, entry: Widget.Entry): ActionMap => ({
description: "Change the current wallpaper",
action: () => autocomplete(entry, "wallpaper"),
},
+ transparency: {
+ icon: "opacity",
+ name: "Transparency",
+ description: "Change shell's transparency",
+ action: () => autocomplete(entry, "transparency"),
+ },
todo: {
icon: "checklist",
name: "Todo",
@@ -333,6 +358,17 @@ const Category = ({ path, wallpapers }: ICategory) => (
</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;
@@ -380,6 +416,11 @@ export default class Actions extends Widget.Box implements LauncherContent {
for (const { obj } of fuzzysort.go(term, list, { all: true, key: "path" }))
this.#content.add(random ? <Category {...(obj as ICategory)} /> : <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} />);
} else {
const list = this.#list.filter(
a => this.#map[a].available?.() ?? !config.disabledActions.get().includes(a)
diff --git a/src/modules/launcher/index.tsx b/src/modules/launcher/index.tsx
index 973f5cd..d355c79 100644
--- a/src/modules/launcher/index.tsx
+++ b/src/modules/launcher/index.tsx
@@ -67,6 +67,7 @@ export default class Launcher extends PopupWindow {
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];
diff --git a/src/modules/notifpopups.tsx b/src/modules/notifpopups.tsx
index 9d461b1..bf253c1 100644
--- a/src/modules/notifpopups.tsx
+++ b/src/modules/notifpopups.tsx
@@ -42,6 +42,7 @@ export default () => (
}}
// Close on hover lost
onHoverLost={() => popup.destroyWithAnims()}
+ setup={self => self.hook(popup, "destroy", () => self.destroy())}
>
{popup}
</eventbox>
diff --git a/src/modules/sidebar/audio.tsx b/src/modules/sidebar/audio.tsx
new file mode 100644
index 0000000..20a6551
--- /dev/null
+++ b/src/modules/sidebar/audio.tsx
@@ -0,0 +1,13 @@
+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>
+);
diff --git a/src/modules/sidebar/connectivity.tsx b/src/modules/sidebar/connectivity.tsx
new file mode 100644
index 0000000..2962b56
--- /dev/null
+++ b/src/modules/sidebar/connectivity.tsx
@@ -0,0 +1,10 @@
+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>
+);
diff --git a/src/modules/sidebar/dashboard.tsx b/src/modules/sidebar/dashboard.tsx
new file mode 100644
index 0000000..bad4695
--- /dev/null
+++ b/src/modules/sidebar/dashboard.tsx
@@ -0,0 +1,134 @@
+import Players from "@/services/players";
+import { osIcon, osId } 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 lengthStr = (length: number) =>
+ `${Math.floor(length / 60)}:${Math.floor(length % 60)
+ .toString()
+ .padStart(2, "0")}`;
+
+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));
+ monitorFile(HOME + "/.face", () => hasFace.set(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");`;
+ monitorFile(HOME + "/.face", () => (self.css = `background-image: url("${HOME}/.face");`));
+ }}
+ onDestroy={() => hasFace.drop()}
+ >
+ {bind(hasFace).as(h => (h ? <box visible={false} /> : <FaceFallback />))}
+ </box>
+ <box vertical hexpand valign={Gtk.Align.CENTER} className="details">
+ <label xalign={0} className="name" label={`${osIcon} ${GLib.get_user_name()}`} />
+ <label xalign={0} label={(GLib.getenv("XDG_CURRENT_DESKTOP") ?? osId).toUpperCase()} />
+ <label truncate xalign={0} className="uptime" label={bind(uptime)} onDestroy={() => uptime.drop()} />
+ </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>
+);
diff --git a/src/modules/sidebar/index.tsx b/src/modules/sidebar/index.tsx
new file mode 100644
index 0000000..d4c1855
--- /dev/null
+++ b/src/modules/sidebar/index.tsx
@@ -0,0 +1,54 @@
+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 Audio from "./audio";
+import Connectivity from "./connectivity";
+import Dashboard from "./dashboard";
+import NotifPane from "./notifpane";
+import Packages from "./packages";
+
+@register()
+export default class SideBar extends Widget.Window {
+ readonly shown: Variable<string>;
+
+ constructor({ monitor }: { monitor: Monitor }) {
+ super({
+ application: App,
+ name: "sidebar",
+ namespace: "caelestia-sidebar",
+ monitor: monitor.id,
+ anchor: Astal.WindowAnchor.LEFT | Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM,
+ exclusivity: Astal.Exclusivity.EXCLUSIVE,
+ visible: false,
+ });
+
+ const panes = [<Dashboard />, <Audio />, <Connectivity />, <Packages />, <NotifPane />];
+ this.shown = Variable(panes[0].name);
+
+ this.add(
+ <eventbox
+ onScroll={(_, event) => {
+ if (event.modifier & Gdk.ModifierType.BUTTON1_MASK) {
+ const index = panes.findIndex(p => p.name === this.shown.get()) + (event.delta_y < 0 ? -1 : 1);
+ if (index < 0 || index >= panes.length) return;
+ this.shown.set(panes[index].name);
+ }
+ }}
+ >
+ <box vertical className="sidebar">
+ <stack
+ vexpand
+ transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
+ transitionDuration={200}
+ shown={bind(this.shown)}
+ >
+ {panes}
+ </stack>
+ </box>
+ </eventbox>
+ );
+
+ if (config.showOnStartup.get()) idle(() => this.show());
+ }
+}
diff --git a/src/modules/sidebar/modules/bluetooth.tsx b/src/modules/sidebar/modules/bluetooth.tsx
new file mode 100644
index 0000000..60b1fa4
--- /dev/null
+++ b/src/modules/sidebar/modules/bluetooth.tsx
@@ -0,0 +1,127 @@
+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 !== -1 ? ` (${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>
+ );
+};
diff --git a/src/modules/sidebar/modules/deviceselector.tsx b/src/modules/sidebar/modules/deviceselector.tsx
new file mode 100644
index 0000000..9a69061
--- /dev/null
+++ b/src/modules/sidebar/modules/deviceselector.tsx
@@ -0,0 +1,126 @@
+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 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>
+ );
+};
diff --git a/src/modules/sidebar/modules/hwresources.tsx b/src/modules/sidebar/modules/hwresources.tsx
new file mode 100644
index 0000000..768d8bd
--- /dev/null
+++ b/src/modules/sidebar/modules/hwresources.tsx
@@ -0,0 +1,67 @@
+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>
+);
diff --git a/src/modules/sidebar/modules/media.tsx b/src/modules/sidebar/modules/media.tsx
new file mode 100644
index 0000000..4872f9e
--- /dev/null
+++ b/src/modules/sidebar/modules/media.tsx
@@ -0,0 +1,172 @@
+import Players from "@/services/players";
+import Slider from "@/widgets/slider";
+import { bind, timeout, Variable } from "astal";
+import { Gtk } from "astal/gtk3";
+import AstalMpris from "gi://AstalMpris";
+
+const lengthStr = (length: number) =>
+ `${Math.floor(length / 60)}:${Math.floor(length % 60)
+ .toString()
+ .padStart(2, "0")}`;
+
+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>
+ );
+};
diff --git a/src/modules/sidebar/modules/networks.tsx b/src/modules/sidebar/modules/networks.tsx
new file mode 100644
index 0000000..7186f35
--- /dev/null
+++ b/src/modules/sidebar/modules/networks.tsx
@@ -0,0 +1,147 @@
+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="󰀂 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>
+ );
+};
diff --git a/src/modules/sidebar/modules/news.tsx b/src/modules/sidebar/modules/news.tsx
new file mode 100644
index 0000000..aba37c7
--- /dev/null
+++ b/src/modules/sidebar/modules/news.tsx
@@ -0,0 +1,110 @@
+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_alt_1" />
+ <label label="No Arch news!" />
+ </box>
+ </box>
+);
+
+export default () => (
+ <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
+ className={bind(Updates.get_default(), "news").as(n => (n ? "expanded" : ""))}
+ hscroll={Gtk.PolicyType.NEVER}
+ name="list"
+ >
+ <List />
+ </scrollable>
+ </stack>
+ </box>
+);
diff --git a/src/modules/sidebar/modules/notifications.tsx b/src/modules/sidebar/modules/notifications.tsx
new file mode 100644
index 0000000..9a9f440
--- /dev/null
+++ b/src/modules/sidebar/modules/notifications.tsx
@@ -0,0 +1,86 @@
+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().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>
+);
diff --git a/src/modules/sidebar/modules/streams.tsx b/src/modules/sidebar/modules/streams.tsx
new file mode 100644
index 0000000..16812fd
--- /dev/null
+++ b/src/modules/sidebar/modules/streams.tsx
@@ -0,0 +1,110 @@
+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("");
+
+ label.observe(
+ ["streams", "speakers", "recorders"].map(k => [audio, `notify::${k}`]),
+ () => `${header(audio, "streams")} • ${header(audio, "speakers")} • ${header(audio, "recorders")}`
+ );
+
+ return (
+ <box vertical className="streams" onDestroy={() => label.drop()}>
+ <box 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>
+ );
+};
diff --git a/src/modules/sidebar/modules/upcoming.tsx b/src/modules/sidebar/modules/upcoming.tsx
new file mode 100644
index 0000000..816dff8
--- /dev/null
+++ b/src/modules/sidebar/modules/upcoming.tsx
@@ -0,0 +1,99 @@
+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].event.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.event.startDate.toUnixTime());
+ const time = `${start.format("%-I")}${start.get_minute() > 0 ? `:${start.get_minute()}` : ""}${start.format("%P")}`;
+ return `${time} <b>${e.event.summary}</b>`;
+};
+
+const getEventTooltip = (e: IEvent) => {
+ const start = GLib.DateTime.new_from_unix_local(e.event.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}`;
+};
+
+const Event = (event: IEvent) => (
+ <box className="event" setup={self => setupCustomTooltip(self, getEventTooltip(event), { useMarkup: true })}>
+ <box className={`calendar-indicator c${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].event.startDate.compare(b[0].event.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>
+);
diff --git a/src/modules/sidebar/modules/updates.tsx b/src/modules/sidebar/modules/updates.tsx
new file mode 100644
index 0000000..3b159c6
--- /dev/null
+++ b/src/modules/sidebar/modules/updates.tsx
@@ -0,0 +1,109 @@
+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", `xdg-open '${update.url}'`, false));
+ menu.append(constructItem("Open info in terminal", `uwsm app -- foot -H -- pacman -Qi ${update.name}`));
+ menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
+ menu.append(constructItem("Reinstall", `uwsm app -- foot -H -- yay -S ${update.name}`));
+ menu.append(constructItem("Remove with dependencies", `uwsm app -- 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>
+);
diff --git a/src/modules/sidebar/notifpane.tsx b/src/modules/sidebar/notifpane.tsx
new file mode 100644
index 0000000..79290e2
--- /dev/null
+++ b/src/modules/sidebar/notifpane.tsx
@@ -0,0 +1,7 @@
+import Notifications from "./modules/notifications";
+
+export default () => (
+ <box vertical className="pane notifpane" name="notifpane">
+ <Notifications />
+ </box>
+);
diff --git a/src/modules/sidebar/packages.tsx b/src/modules/sidebar/packages.tsx
new file mode 100644
index 0000000..c073850
--- /dev/null
+++ b/src/modules/sidebar/packages.tsx
@@ -0,0 +1,10 @@
+import News from "./modules/news";
+import Updates from "./modules/updates";
+
+export default () => (
+ <box vertical className="pane packages" name="packages">
+ <Updates />
+ <box className="separator" />
+ <News />
+ </box>
+);
diff --git a/src/services/calendar.ts b/src/services/calendar.ts
new file mode 100644
index 0000000..9743aad
--- /dev/null
+++ b/src/services/calendar.ts
@@ -0,0 +1,168 @@
+import { notify } from "@/utils/system";
+import { execAsync, GLib, GObject, property, register, timeout, type AstalIO } from "astal";
+import { calendar as config } from "config";
+import ical from "ical.js";
+
+export interface IEvent {
+ calendar: string;
+ event: ical.Event;
+}
+
+@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;
+ }
+
+ #calCount: number = 1;
+ #reminders: AstalIO.Time[] = [];
+ #loading: boolean = false;
+ #calendars: { [name: string]: ical.Component } = {};
+ #upcoming: { [date: string]: IEvent[] } = {};
+
+ @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;
+ }
+
+ 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 (const cal of cals) {
+ if (cal.status === "fulfilled") {
+ const comp = new ical.Component(ical.parse(cal.value));
+ const name = (comp.getFirstPropertyValue("x-wr-calname") ?? `Calendar ${this.#calCount++}`) as string;
+ this.#calendars[name] = comp;
+ } else console.error(`Failed to get calendar: ${cal.reason}`);
+ }
+ this.notify("calendars");
+
+ this.updateUpcoming();
+
+ this.#loading = false;
+ this.notify("loading");
+ }
+
+ updateUpcoming() {
+ this.#upcoming = {};
+
+ const today = ical.Time.now();
+ const upcoming = ical.Time.now().adjust(config.upcomingDays.get(), 0, 0, 0);
+ 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(upcoming) <= 0; next = iter.next())
+ if (next.compare(today) >= 0) {
+ const date = next.toJSDate().toDateString();
+ if (!this.#upcoming.hasOwnProperty(date)) this.#upcoming[date] = [];
+
+ const rEvent = new ical.Event(e);
+ rEvent.startDate = next;
+ this.#upcoming[date].push({ calendar: name, event: rEvent });
+ }
+ } else if (event.startDate.compare(today) >= 0 && event.startDate.compare(upcoming) <= 0) {
+ // Add to upcoming if in upcoming range
+ const date = event.startDate.toJSDate().toDateString();
+ if (!this.#upcoming.hasOwnProperty(date)) this.#upcoming[date] = [];
+ this.#upcoming[date].push({ calendar: name, event });
+ }
+ }
+ }
+
+ for (const events of Object.values(this.#upcoming))
+ events.sort((a, b) => a.event.startDate.compare(b.event.startDate));
+
+ this.notify("upcoming");
+ this.notify("num-upcoming");
+
+ this.setReminders();
+ }
+
+ #notifyEvent(event: ical.Event, calendar: string) {
+ 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.location ? ` ${event.location}\n` : "";
+ const descIfExists = event.description ? `󰒿 ${event.description}\n` : "";
+
+ notify({
+ summary: `󰨱 ${event.summary} 󰨱`,
+ body: `${time}\n${locIfExists}${descIfExists}󰃭 ${calendar}`,
+ }).catch(console.error);
+ }
+
+ #createReminder(event: ical.Event, calendar: string, next: ical.Time) {
+ const diff = next.toUnixTime() - ical.Time.now().toUnixTime();
+ if (diff > 0) this.#reminders.push(timeout(diff * 1000, () => this.#notifyEvent(event, calendar)));
+ }
+
+ setReminders() {
+ this.#reminders.forEach(r => r.cancel());
+ this.#reminders = [];
+
+ if (!config.notify.get()) return;
+
+ const today = ical.Time.now();
+ const upcoming = ical.Time.now().adjust(config.upcomingDays.get(), 0, 0, 0);
+ 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(upcoming) <= 0; next = iter.next())
+ if (next.compare(today) >= 0) this.#createReminder(event, name, next);
+ } else if (event.startDate.compare(today) >= 0 && event.startDate.compare(upcoming) <= 0)
+ // Create reminder if in upcoming range
+ this.#createReminder(event, name, event.startDate);
+ }
+ }
+ }
+
+ constructor() {
+ super();
+
+ this.updateCalendars().catch(console.error);
+ config.webcals.subscribe(() => this.updateCalendars().catch(console.error));
+ config.upcomingDays.subscribe(() => this.updateUpcoming());
+ config.notify.subscribe(() => this.setReminders());
+ }
+}
diff --git a/src/services/monitors.ts b/src/services/monitors.ts
index 4cef256..6ae7ecb 100644
--- a/src/services/monitors.ts
+++ b/src/services/monitors.ts
@@ -55,7 +55,7 @@ export class Monitor extends GObject.Object {
.then(out => {
this.isDdc = out.split("\n\n").some(display => {
if (!/^Display \d+/.test(display)) return false;
- const lines = display.split("\n");
+ 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;
diff --git a/src/services/schemes.ts b/src/services/schemes.ts
index 548975c..2808b55 100644
--- a/src/services/schemes.ts
+++ b/src/services/schemes.ts
@@ -32,7 +32,6 @@ export default class Schemes extends GObject.Object {
}
readonly #schemeDir: string = `${DATA}/scripts/data/schemes`;
- readonly #monitor;
#map: { [k: string]: Scheme } = {};
@@ -106,7 +105,7 @@ export default class Schemes extends GObject.Object {
super();
this.update().catch(console.error);
- this.#monitor = monitorDirectory(this.#schemeDir, (_m, file, _f, type) => {
+ monitorDirectory(this.#schemeDir, (_m, file, _f, type) => {
if (type !== Gio.FileMonitorEvent.DELETED) this.updateFile(file).catch(console.error);
});
}
diff --git a/src/services/wallpapers.ts b/src/services/wallpapers.ts
index 0e0e1de..4c7c49b 100644
--- a/src/services/wallpapers.ts
+++ b/src/services/wallpapers.ts
@@ -40,7 +40,8 @@ export default class Wallpapers extends GObject.Object {
async #thumbnail(path: string) {
const dir = path.slice(1, path.lastIndexOf("/")).replaceAll("/", "-");
const thumbPath = `${this.#thumbnailDir}/${dir}-${basename(path)}.jpg`;
- await execAsync(`magick -define jpeg:size=1000x500 ${path} -thumbnail 500x250 -unsharp 0x.5 ${thumbPath}`);
+ if (!GLib.file_test(thumbPath, GLib.FileTest.EXISTS))
+ await execAsync(`magick -define jpeg:size=1000x500 ${path} -thumbnail 500x250 -unsharp 0x.5 ${thumbPath}`);
return thumbPath;
}
diff --git a/src/utils/system.ts b/src/utils/system.ts
index 2e9fa4a..8ccb5d1 100644
--- a/src/utils/system.ts
+++ b/src/utils/system.ts
@@ -76,6 +76,7 @@ export const bindCurrentTime = (
return bind(time);
};
+const monitors = new Set();
export const monitorDirectory = (
path: string,
callback: (
@@ -102,5 +103,9 @@ export const monitorDirectory = (
}
}
+ // Keep ref to monitor so it doesn't get GCed
+ monitors.add(monitor);
+ monitor.connect("notify::cancelled", () => monitor.cancelled && monitors.delete(monitor));
+
return monitor;
};
diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts
index 7b1eb5c..e0e512d 100644
--- a/src/utils/widgets.ts
+++ b/src/utils/widgets.ts
@@ -3,7 +3,11 @@ 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>) => {
+export const setupCustomTooltip = (
+ self: AstalWidget,
+ text: string | Binding<string>,
+ labelProps: Widget.LabelProps = {}
+) => {
if (!text) return null;
self.set_has_tooltip(true);
@@ -15,39 +19,39 @@ export const setupCustomTooltip = (self: AstalWidget, text: string | Binding<str
keymode: Astal.Keymode.NONE,
exclusivity: Astal.Exclusivity.IGNORE,
anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT,
- child: new Widget.Label({ className: "tooltip", label: text }),
+ child: new Widget.Label({ ...labelProps, className: "tooltip", label: text }),
});
self.set_tooltip_window(window);
- let lastX = 0;
- window.connect("size-allocate", () => {
- const mWidth = AstalHyprland.get_default().get_focused_monitor().get_width();
- const pWidth = window.get_preferred_width()[1];
+ 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 = lastX - pWidth / 2;
+ 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;
- });
- if (text instanceof Binding) self.hook(text, (_, v) => !v && window.hide());
+ 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 mWidth = AstalHyprland.get_default().get_focused_monitor().get_width();
- const pWidth = window.get_preferred_width()[1];
- const { x, y } = AstalHyprland.get_default().get_cursor_position();
- 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;
-
- window.marginLeft = marginLeft;
- window.marginTop = y + cursorSize;
- lastX = x;
+ const cPos = AstalHyprland.get_default().get_cursor_position();
+ positionWindow(cPos);
+ lastPos = cPos;
return true;
});
diff --git a/src/widgets/notification.tsx b/src/widgets/notification.tsx
index 1048826..b2a10be 100644
--- a/src/widgets/notification.tsx
+++ b/src/widgets/notification.tsx
@@ -1,4 +1,5 @@
import { desktopEntrySubs } from "@/utils/icons";
+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";
@@ -51,19 +52,19 @@ const AppIcon = ({ appIcon, desktopEntry }: { appIcon: string; desktopEntry: str
return icon ? <icon className="app-icon" icon={icon} /> : null;
};
-const Image = ({ popup, icon }: { popup?: boolean; icon: string }) => {
+const Image = ({ compact, icon }: { compact?: boolean; icon: string }) => {
if (GLib.file_test(icon, GLib.FileTest.EXISTS))
return (
<box
valign={Gtk.Align.START}
- className={`image ${popup ? "small" : ""}`}
+ className={`image ${compact ? "small" : ""}`}
css={`
background-image: url("${icon}");
`}
/>
);
if (Astal.Icon.lookup_icon(icon))
- return <icon valign={Gtk.Align.START} className={`image ${popup ? "small" : ""}`} icon={icon} />;
+ return <icon valign={Gtk.Align.START} className={`image ${compact ? "small" : ""}`} icon={icon} />;
return null;
};
@@ -72,7 +73,15 @@ export default class Notification extends Widget.Box {
readonly #revealer;
#destroyed = false;
- constructor({ notification, popup }: { notification: AstalNotifd.Notification; popup?: boolean }) {
+ 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));
@@ -94,17 +103,18 @@ export default class Notification extends Widget.Box {
</box>
<box hexpand className="separator" />
<box className="content">
- {notification.image && <Image popup={popup} icon={notification.image} />}
+ {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={popup ? notification.body.split("\n")[0] : notification.body}
+ label={compact ? notification.body.split("\n")[0] : notification.body}
wrap
- lines={popup ? 1 : -1}
- truncate={popup}
+ lines={compact ? 1 : -1}
+ truncate={compact}
+ setup={self => compact && !popup && setupCustomTooltip(self, notification.body)}
/>
)}
</box>
@@ -132,7 +142,7 @@ export default class Notification extends Widget.Box {
// Init animation
const width = this.get_preferred_width()[1];
- this.css = `margin-left: ${width}px; margin-right: -${width}px;`;
+ 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;`;
diff --git a/src/widgets/slider.tsx b/src/widgets/slider.tsx
new file mode 100644
index 0000000..c047d5f
--- /dev/null
+++ b/src/widgets/slider.tsx
@@ -0,0 +1,64 @@
+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: ${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())
+ );
+ }}
+ />
+);
diff --git a/style.scss b/style.scss
index fa1d826..78068c1 100644
--- a/style.scss
+++ b/style.scss
@@ -10,8 +10,8 @@
@use "scss/notifpopups";
@use "scss/launcher";
@use "scss/osds";
-@use "scss/popdowns";
@use "scss/session";
+@use "scss/sidebar";
* {
all: unset; // Remove GTK theme styles