brightness and volume osds

This commit is contained in:
2 * r + 2 * t 2025-01-15 20:58:01 +11:00
parent 47aa362b88
commit 751ad07e15
9 changed files with 420 additions and 6 deletions

14
app.tsx
View file

@ -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({
<Launcher />;
<NotifPopups />;
AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />);
Monitors.get_default().forEach(m => {
<Osds monitor={m} />;
<Bar monitor={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");
},
});

View file

@ -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,

View file

@ -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 }) => (
<window
namespace="caelestia-bar"
monitor={monitor.id}

231
modules/osds.tsx Normal file
View file

@ -0,0 +1,231 @@
import { timeout, Variable, type Time } from "astal";
import { Astal, Gtk, type Widget } from "astal/gtk3";
import cairo from "cairo";
import AstalWp from "gi://AstalWp";
import Pango from "gi://Pango";
import PangoCairo from "gi://PangoCairo";
import { osds as config } from "../config";
import { type Monitor } from "../services/monitors";
import { PopupWindow } from "../utils/widgets";
const getStyle = (context: Gtk.StyleContext, prop: string) => context.get_property(prop, Gtk.StateFlags.NORMAL);
const getNumStyle = (context: Gtk.StyleContext, prop: string) => getStyle(context, prop) as number;
const mix = (a: number, b: number, r: number) => a * r + b * (1 - r);
const pangoWeightToStr = (weight: Pango.Weight) => {
switch (weight) {
case Pango.Weight.ULTRALIGHT:
return "UltraLight";
case Pango.Weight.LIGHT:
return "Light";
case Pango.Weight.BOLD:
return "Bold";
case Pango.Weight.ULTRABOLD:
return "UltraBold";
case Pango.Weight.HEAVY:
return "Heavy";
default:
return "Normal";
}
};
const SliderOsd = ({
fillIcons,
monitor,
type,
windowSetup,
className = "",
initValue,
drawAreaSetup,
}: {
fillIcons?: boolean;
monitor: Monitor;
type: keyof typeof config;
windowSetup: (self: Widget.Window, hideAfterTimeout: () => void) => void;
className?: string;
initValue: number;
drawAreaSetup: (self: Widget.DrawingArea, icon: Variable<string>) => void;
}) => (
<PopupWindow
name={type}
monitor={monitor.id}
keymode={Astal.Keymode.NONE}
anchor={config[type].position}
margin={config[type].margin}
setup={self => {
let time: Time | null = null;
const hideAfterTimeout = () => {
time?.cancel();
time = timeout(config[type].hideDelay, () => self.hide());
};
self.connect("show", hideAfterTimeout);
windowSetup(self, hideAfterTimeout);
}}
>
<box className={type}>
<drawingarea
className={`inner ${className}`}
css={"font-size: " + initValue + "px;"}
setup={self => {
const halfPi = Math.PI / 2;
const vertical =
config[type].position === 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);
}
});
}}
/>
</box>
</PopupWindow>
);
const Volume = ({ monitor, audio }: { monitor: Monitor; audio: AstalWp.Audio }) => (
<SliderOsd
fillIcons
monitor={monitor}
type="volume"
windowSetup={(self, hideAfterTimeout) => {
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 }) => (
<SliderOsd
monitor={monitor}
type="brightness"
windowSetup={(self, hideAfterTimeout) => {
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()) <Volume monitor={monitor} audio={AstalWp.get_default()!.audio} />;
<Brightness monitor={monitor} />;
return null;
};

View file

@ -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);
}

27
scss/osds.scss Normal file
View file

@ -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);
}

127
services/monitors.ts Normal file
View file

@ -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<number, Monitor> = new Map();
@property(Object)
get map() {
return this.#map;
}
@property(Object)
get list() {
return Array.from(this.#map.values());
}
@property(Monitor)
get active() {
return this.#map.get(AstalHyprland.get_default().focusedMonitor.id)!;
}
#notify() {
this.notify("map");
this.notify("list");
}
forEach(fn: (monitor: Monitor) => void) {
for (const monitor of this.#map.values()) fn(monitor);
}
constructor() {
super();
const hyprland = AstalHyprland.get_default();
for (const monitor of hyprland.monitors) this.#map.set(monitor.id, new Monitor(monitor));
if (this.#map.size > 0) this.#notify();
hyprland.connect("monitor-added", (_, monitor) => {
this.#map.set(monitor.id, new Monitor(monitor));
this.#notify();
});
hyprland.connect("monitor-removed", (_, id) => this.#map.delete(id) && this.#notify());
hyprland.connect("notify::focused-monitor", () => this.notify("active"));
}
}

View file

@ -5,6 +5,7 @@
@use "scss/bar";
@use "scss/notifpopups";
@use "scss/launcher";
@use "scss/osds";
* {
all: unset; // Remove GTK theme styles

View file

@ -52,12 +52,16 @@ const overrideProp = <T,>(
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) => {