diff options
41 files changed, 2600 insertions, 165 deletions
@@ -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": { @@ -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()) + ); + }} + /> +); @@ -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 |