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