From 751ad07e151f0abfd111c4d800d172f726ffa33c Mon Sep 17 00:00:00 2001
From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
Date: Wed, 15 Jan 2025 20:58:01 +1100
Subject: brightness and volume osds
---
app.tsx | 14 ++--
config.ts | 15 ++++
modules/bar.tsx | 3 +-
modules/osds.tsx | 231 +++++++++++++++++++++++++++++++++++++++++++++++++++
scss/_lib.scss | 4 +
scss/osds.scss | 27 ++++++
services/monitors.ts | 127 ++++++++++++++++++++++++++++
style.scss | 1 +
utils/widgets.tsx | 4 +
9 files changed, 420 insertions(+), 6 deletions(-)
create mode 100644 modules/osds.tsx
create mode 100644 scss/osds.scss
create mode 100644 services/monitors.ts
diff --git a/app.tsx b/app.tsx
index e38c352..ee2c549 100644
--- a/app.tsx
+++ b/app.tsx
@@ -1,9 +1,10 @@
import { execAsync, GLib, writeFileAsync } from "astal";
import { App } from "astal/gtk3";
-import AstalHyprland from "gi://AstalHyprland";
import Bar from "./modules/bar";
import Launcher from "./modules/launcher";
import NotifPopups from "./modules/notifpopups";
+import Osds from "./modules/osds";
+import Monitors from "./services/monitors";
import Players from "./services/players";
const loadStyleAsync = async () => {
@@ -21,21 +22,24 @@ App.start({
;
;
- AstalHyprland.get_default().monitors.forEach(m => );
+ Monitors.get_default().forEach(m => {
+ ;
+ ;
+ });
console.log("Caelestia started");
},
requestHandler(request, res) {
- let log = true;
-
if (request === "reload css") loadStyleAsync().catch(console.error);
else if (request === "media play pause") Players.get_default().lastPlayer?.play_pause();
else if (request === "media next") Players.get_default().lastPlayer?.next();
else if (request === "media previous") Players.get_default().lastPlayer?.previous();
else if (request === "media stop") Players.get_default().lastPlayer?.stop();
+ else if (request === "brightness up") Monitors.get_default().active.brightness += 0.1;
+ else if (request === "brightness down") Monitors.get_default().active.brightness -= 0.1;
else return res("Unknown command: " + request);
- if (log) console.log(`Request handled: ${request}`);
+ console.log(`Request handled: ${request}`);
res("OK");
},
});
diff --git a/config.ts b/config.ts
index 39f0b58..597a9f7 100644
--- a/config.ts
+++ b/config.ts
@@ -1,3 +1,5 @@
+import { Astal } from "astal/gtk3";
+
// Modules
export const bar = {
wsPerGroup: 10,
@@ -21,6 +23,19 @@ export const notifpopups = {
expire: false,
};
+export const osds = {
+ volume: {
+ position: Astal.WindowAnchor.RIGHT,
+ margin: 20,
+ hideDelay: 1500,
+ },
+ brightness: {
+ position: Astal.WindowAnchor.LEFT,
+ margin: 20,
+ hideDelay: 1500,
+ },
+};
+
// Services
export const updates = {
interval: 900000,
diff --git a/modules/bar.tsx b/modules/bar.tsx
index b8e9f0b..933fa5e 100644
--- a/modules/bar.tsx
+++ b/modules/bar.tsx
@@ -7,6 +7,7 @@ import AstalNetwork from "gi://AstalNetwork";
import AstalNotifd from "gi://AstalNotifd";
import AstalTray from "gi://AstalTray";
import { bar as config } from "../config";
+import type { Monitor } from "../services/monitors";
import Players from "../services/players";
import Updates from "../services/updates";
import { getAppCategoryIcon } from "../utils/icons";
@@ -396,7 +397,7 @@ const Power = () => (
/>
);
-export default ({ monitor }: { monitor: AstalHyprland.Monitor }) => (
+export default ({ monitor }: { monitor: Monitor }) => (
context.get_property(prop, Gtk.StateFlags.NORMAL);
+const getNumStyle = (context: Gtk.StyleContext, prop: string) => getStyle(context, prop) as number;
+
+const mix = (a: number, b: number, r: number) => a * r + b * (1 - r);
+
+const pangoWeightToStr = (weight: Pango.Weight) => {
+ switch (weight) {
+ case Pango.Weight.ULTRALIGHT:
+ return "UltraLight";
+ case Pango.Weight.LIGHT:
+ return "Light";
+ case Pango.Weight.BOLD:
+ return "Bold";
+ case Pango.Weight.ULTRABOLD:
+ return "UltraBold";
+ case Pango.Weight.HEAVY:
+ return "Heavy";
+ default:
+ return "Normal";
+ }
+};
+
+const SliderOsd = ({
+ fillIcons,
+ monitor,
+ type,
+ windowSetup,
+ className = "",
+ initValue,
+ drawAreaSetup,
+}: {
+ fillIcons?: boolean;
+ monitor: Monitor;
+ type: keyof typeof config;
+ windowSetup: (self: Widget.Window, hideAfterTimeout: () => void) => void;
+ className?: string;
+ initValue: number;
+ drawAreaSetup: (self: Widget.DrawingArea, icon: Variable) => void;
+}) => (
+ {
+ let time: Time | null = null;
+ const hideAfterTimeout = () => {
+ time?.cancel();
+ time = timeout(config[type].hideDelay, () => self.hide());
+ };
+ self.connect("show", hideAfterTimeout);
+ windowSetup(self, hideAfterTimeout);
+ }}
+ >
+
+ {
+ const halfPi = Math.PI / 2;
+ const vertical =
+ config[type].position === Astal.WindowAnchor.LEFT ||
+ config[type].position === Astal.WindowAnchor.RIGHT;
+
+ const icon = Variable("");
+ drawAreaSetup(self, icon);
+
+ // Init size
+ const styleContext = self.get_style_context();
+ const width = getNumStyle(styleContext, "min-width");
+ const height = getNumStyle(styleContext, "min-height");
+ if (vertical) self.set_size_request(height, width);
+ else self.set_size_request(width, height);
+
+ let fontDesc: Pango.FontDescription | null = null;
+
+ self.connect("draw", (_, cr: cairo.Context) => {
+ const styleContext = self.get_style_context();
+
+ let width = getNumStyle(styleContext, "min-width");
+ let height = getNumStyle(styleContext, "min-height");
+
+ const progressValue = getNumStyle(styleContext, "font-size");
+ let radius = getNumStyle(styleContext, "border-radius");
+ // Flatten when near 0, do before swap cause its simpler
+ radius = Math.min(radius, Math.min(width * progressValue, height) / 2);
+
+ if (vertical) [width, height] = [height, width]; // Swap if vertical
+ self.set_size_request(width, height);
+
+ const progressPosition = vertical
+ ? height * (1 - progressValue) + radius // Top is 0, but we want it to start from the bottom
+ : width * progressValue - radius;
+
+ const bg = styleContext.get_background_color(Gtk.StateFlags.NORMAL);
+ cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
+
+ if (vertical) {
+ cr.arc(radius, progressPosition, radius, -Math.PI, -halfPi); // Top left
+ cr.arc(width - radius, progressPosition, radius, -halfPi, 0); // Top right
+ cr.arc(width - radius, height - radius, radius, 0, halfPi); // Bottom right
+ } else {
+ cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
+ cr.arc(progressPosition, radius, radius, -halfPi, 0); // Top right
+ cr.arc(progressPosition, height - radius, radius, 0, halfPi); // Bottom right
+ }
+ cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
+ cr.fill();
+
+ const parent = self.get_parent();
+ if (parent) {
+ if (fontDesc === null) {
+ const pContext = parent.get_style_context();
+ const families = (getStyle(pContext, "font-family") as string[]).join(",");
+ const weight = pangoWeightToStr(getStyle(pContext, "font-weight") as Pango.Weight);
+ const size = getNumStyle(pContext, "font-size");
+ fontDesc = Pango.font_description_from_string(`${families} ${weight} ${size}px`);
+ // Ugh GTK CSS doesn't support font-variations, so you need to manually create the layout and font desc instead of using Gtk.Widget#create_pango_layout
+ if (fillIcons) fontDesc.set_variations("FILL=1");
+ }
+
+ const layout = PangoCairo.create_layout(cr);
+ layout.set_font_description(fontDesc);
+ layout.set_text(icon.get(), -1);
+
+ const [w, h] = layout.get_pixel_size();
+ let diff;
+ if (vertical) {
+ diff = (progressValue * height) / h;
+ cr.moveTo((width - w) / 2, Math.min(height - h, progressPosition - h / 2 + radius));
+ } else {
+ diff = (progressValue * width) / w;
+ cr.moveTo(Math.max(0, progressPosition - w / 2 - radius), (height - h) / 2);
+ }
+ diff = Math.max(0, Math.min(1, diff));
+
+ const fg = styleContext.get_color(Gtk.StateFlags.NORMAL);
+ cr.setSourceRGBA(
+ mix(fg.red, bg.red, diff),
+ mix(fg.green, bg.green, diff),
+ mix(fg.blue, bg.blue, diff),
+ mix(fg.alpha, bg.alpha, diff)
+ );
+
+ cr.setAntialias(cairo.Antialias.BEST);
+ PangoCairo.show_layout(cr, layout);
+ }
+ });
+ }}
+ />
+
+
+);
+
+const Volume = ({ monitor, audio }: { monitor: Monitor; audio: AstalWp.Audio }) => (
+ {
+ self.hook(audio.defaultSpeaker, "notify::volume", () => {
+ self.show();
+ hideAfterTimeout();
+ });
+ }}
+ className={audio.defaultSpeaker.mute ? "mute" : ""}
+ initValue={audio.defaultSpeaker.volume}
+ drawAreaSetup={(self, icon) => {
+ const updateIcon = () => {
+ if (/head(phone|set)/i.test(audio.defaultSpeaker.icon)) icon.set("headphones");
+ else if (audio.defaultSpeaker.mute) icon.set("no_sound");
+ else if (audio.defaultSpeaker.volume === 0) icon.set("volume_mute");
+ else if (audio.defaultSpeaker.volume <= 0.5) icon.set("volume_down");
+ else icon.set("volume_up");
+ };
+ updateIcon();
+ self.hook(audio.defaultSpeaker, "notify::icon", updateIcon);
+ self.hook(audio.defaultSpeaker, "notify::mute", () => {
+ updateIcon();
+ self.toggleClassName("mute", audio.defaultSpeaker.mute);
+ });
+ self.hook(audio.defaultSpeaker, "notify::volume", () => {
+ updateIcon();
+ self.css = `font-size: ${audio.defaultSpeaker.volume}px`;
+ });
+ }}
+ />
+);
+
+const Brightness = ({ monitor }: { monitor: Monitor }) => (
+ {
+ self.hook(monitor, "notify::brightness", () => {
+ self.show();
+ hideAfterTimeout();
+ });
+ }}
+ initValue={monitor.brightness}
+ drawAreaSetup={(self, icon) => {
+ const update = () => {
+ if (monitor.brightness > 0.66) icon.set("brightness_high");
+ else if (monitor.brightness > 0.33) icon.set("brightness_medium");
+ else if (monitor.brightness > 0) icon.set("brightness_low");
+ else icon.set("brightness_empty");
+ self.css = `font-size: ${monitor.brightness}px`;
+ };
+ self.hook(monitor, "notify::brightness", update);
+ update();
+ }}
+ />
+);
+
+export default ({ monitor }: { monitor: Monitor }) => {
+ if (AstalWp.get_default()) ;
+ ;
+
+ return null;
+};
diff --git a/scss/_lib.scss b/scss/_lib.scss
index dda7dfd..e12140f 100644
--- a/scss/_lib.scss
+++ b/scss/_lib.scss
@@ -31,6 +31,10 @@ $scale: 0.068rem;
transition: $duration cubic-bezier(0, 0.55, 0.45, 1);
}
+@mixin fluent-decel($duration: 200ms) {
+ transition: $duration cubic-bezier(0.1, 1, 0, 1);
+}
+
@mixin overshot {
transition-timing-function: cubic-bezier(0.05, 0.9, 0.1, 1.1);
}
diff --git a/scss/osds.scss b/scss/osds.scss
new file mode 100644
index 0000000..62a38f5
--- /dev/null
+++ b/scss/osds.scss
@@ -0,0 +1,27 @@
+@use "scheme";
+@use "lib";
+@use "font";
+
+.brightness,
+.volume {
+ @include lib.rounded(8);
+ @include font.icon;
+
+ background-color: scheme.$mantle;
+ font-size: lib.s(28);
+ padding: lib.s(3);
+
+ .inner {
+ @include lib.rounded(8);
+ @include lib.fluent-decel(1000ms);
+
+ min-width: lib.s(300);
+ min-height: lib.s(32);
+ background-color: scheme.$teal;
+ color: scheme.$mantle;
+ }
+}
+
+.brightness {
+ font-size: lib.s(26);
+}
diff --git a/services/monitors.ts b/services/monitors.ts
new file mode 100644
index 0000000..78a0161
--- /dev/null
+++ b/services/monitors.ts
@@ -0,0 +1,127 @@
+import { GObject, execAsync, property, register } from "astal";
+import AstalHyprland from "gi://AstalHyprland";
+
+@register({ GTypeName: "Monitor" })
+export class Monitor extends GObject.Object {
+ readonly monitor: AstalHyprland.Monitor;
+ readonly width: number;
+ readonly height: number;
+ readonly id: number;
+ readonly serial: string;
+ readonly name: string;
+ readonly description: string;
+
+ @property(AstalHyprland.Workspace)
+ get activeWorkspace() {
+ return this.monitor.activeWorkspace;
+ }
+
+ isDdc: boolean = false;
+ busNum?: string;
+
+ #brightness: number = 0;
+
+ @property(Number)
+ get brightness() {
+ return this.#brightness;
+ }
+
+ set brightness(value) {
+ value = Math.min(1, Math.max(0, value));
+
+ this.#brightness = value;
+ this.notify("brightness");
+ execAsync(
+ this.isDdc
+ ? `ddcutil -b ${this.busNum} setvcp 10 ${Math.round(value * 100)}`
+ : `brightnessctl set ${Math.floor(value * 100)}% -q`
+ ).catch(console.error);
+ }
+
+ constructor(monitor: AstalHyprland.Monitor) {
+ super();
+
+ this.monitor = monitor;
+ this.width = monitor.width;
+ this.height = monitor.height;
+ this.id = monitor.id;
+ this.serial = monitor.serial;
+ this.name = monitor.name;
+ this.description = monitor.description;
+
+ monitor.connect("notify::active-workspace", () => this.notify("active-workspace"));
+
+ execAsync("ddcutil detect --brief")
+ .then(out => {
+ this.isDdc = out.split("\n\n").some(display => {
+ if (!/^Display \d+/.test(display)) return false;
+ const lines = display.split("\n");
+ if (lines[3].split(":")[3] !== monitor.serial) return false;
+ this.busNum = lines[1].split("/dev/i2c-")[1];
+ return true;
+ });
+ })
+ .catch(() => (this.isDdc = false))
+ .finally(async () => {
+ if (this.isDdc) {
+ const info = (await execAsync(`ddcutil -b ${this.busNum} getvcp 10 --brief`)).split(" ");
+ this.#brightness = Number(info[3]) / Number(info[4]);
+ } else
+ this.#brightness =
+ Number(await execAsync("brightnessctl get")) / Number(await execAsync("brightnessctl max"));
+ });
+ }
+}
+
+@register({ GTypeName: "Monitors" })
+export default class Monitors extends GObject.Object {
+ static instance: Monitors;
+ static get_default() {
+ if (!this.instance) this.instance = new Monitors();
+
+ return this.instance;
+ }
+
+ readonly #map: Map = new Map();
+
+ @property(Object)
+ get map() {
+ return this.#map;
+ }
+
+ @property(Object)
+ get list() {
+ return Array.from(this.#map.values());
+ }
+
+ @property(Monitor)
+ get active() {
+ return this.#map.get(AstalHyprland.get_default().focusedMonitor.id)!;
+ }
+
+ #notify() {
+ this.notify("map");
+ this.notify("list");
+ }
+
+ forEach(fn: (monitor: Monitor) => void) {
+ for (const monitor of this.#map.values()) fn(monitor);
+ }
+
+ constructor() {
+ super();
+
+ const hyprland = AstalHyprland.get_default();
+
+ for (const monitor of hyprland.monitors) this.#map.set(monitor.id, new Monitor(monitor));
+ if (this.#map.size > 0) this.#notify();
+
+ hyprland.connect("monitor-added", (_, monitor) => {
+ this.#map.set(monitor.id, new Monitor(monitor));
+ this.#notify();
+ });
+ hyprland.connect("monitor-removed", (_, id) => this.#map.delete(id) && this.#notify());
+
+ hyprland.connect("notify::focused-monitor", () => this.notify("active"));
+ }
+}
diff --git a/style.scss b/style.scss
index 3ccf193..99953a4 100644
--- a/style.scss
+++ b/style.scss
@@ -5,6 +5,7 @@
@use "scss/bar";
@use "scss/notifpopups";
@use "scss/launcher";
+@use "scss/osds";
* {
all: unset; // Remove GTK theme styles
diff --git a/utils/widgets.tsx b/utils/widgets.tsx
index c9d6019..562f1ec 100644
--- a/utils/widgets.tsx
+++ b/utils/widgets.tsx
@@ -52,12 +52,16 @@ const overrideProp = (
override: (prop: T | undefined) => T | undefined
) => prop && (prop instanceof Binding ? prop.as(override) : override(prop));
+// TODO: fix shadow by putting child in a box with padding, also add shadow to .popup
export const convertPopupWindowProps = (props: Widget.WindowProps): Widget.WindowProps => ({
keymode: Astal.Keymode.ON_DEMAND,
exclusivity: Astal.Exclusivity.IGNORE,
...props,
visible: false,
application: App,
+ name: props.monitor
+ ? overrideProp(props.name, n => (n && props.monitor ? n + props.monitor : undefined))
+ : props.name,
namespace: overrideProp(props.name, n => `caelestia-${n}`),
className: overrideProp(props.className, c => `popup ${c}`),
onKeyPressEvent: (self, event) => {
--
cgit v1.2.3-freya