import { execAsync, register, timeout, Variable, type Time } from "astal"; import { App, Astal, Gtk, Widget } from "astal/gtk3"; import cairo from "cairo"; import AstalWp from "gi://AstalWp"; import Cairo from "gi://cairo"; import Pango from "gi://Pango"; import PangoCairo from "gi://PangoCairo"; import { osds as config } from "../../config"; import Monitors, { type Monitor } from "../services/monitors"; import PopupWindow from "../widgets/popupwindow"; 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: "volume" | "brightness"; windowSetup: (self: Widget.Window, show: () => 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, () => { self.show(); 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); self.hook(icon, () => self.queue_draw()); // 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 parent = self.get_parent(); if (!parent) return; const styleContext = self.get_style_context(); const pContext = parent.get_style_context(); let width = getNumStyle(styleContext, "min-width"); let height = getNumStyle(styleContext, "min-height"); const progressValue = getNumStyle(styleContext, "font-size"); let radius = getNumStyle(pContext, "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); // Background 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 fg = pContext.get_background_color(Gtk.StateFlags.NORMAL); cr.setAntialias(cairo.Antialias.BEST); // Progress number, at top/right let nw = 0; let nh = 0; if (config[type].showValue) { const numLayout = parent.create_pango_layout(String(Math.round(progressValue * 100))); [nw, nh] = numLayout.get_pixel_size(); let diff; if (vertical) { diff = ((1 - progressValue) * height) / nh; cr.moveTo((width - nw) / 2, radius / 2); } else { diff = ((1 - progressValue) * width) / nw; cr.moveTo(width - nw - radius, (height - nh) / 2); } diff = Math.max(0, Math.min(1, diff)); cr.setSourceRGBA( mix(bg.red, fg.red, diff), mix(bg.green, fg.green, diff), mix(bg.blue, fg.blue, diff), mix(bg.alpha, fg.alpha, diff) ); PangoCairo.show_layout(cr, numLayout); } // Progress icon, follows progress if (fontDesc === null) { const weight = pangoWeightToStr(getStyle(pContext, "font-weight") as Pango.Weight); const size = getNumStyle(pContext, "font-size") * 1.5; fontDesc = Pango.font_description_from_string( `Material Symbols Rounded ${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 iconLayout = PangoCairo.create_layout(cr); iconLayout.set_font_description(fontDesc); iconLayout.set_text(icon.get(), -1); const [iw, ih] = iconLayout.get_pixel_size(); let diff; if (vertical) { diff = (progressValue * height) / ih; cr.moveTo( (width - iw) / 2, Math.max(nh, Math.min(height - ih, progressPosition - ih / 2 + radius)) ); } else { diff = (progressValue * width) / iw; cr.moveTo( Math.min( width - nw * 1.1 - iw - radius, Math.max(0, progressPosition - iw / 2 - radius) ), (height - ih) / 2 ); } diff = Math.max(0, Math.min(1, diff)); 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) ); PangoCairo.show_layout(cr, iconLayout); }); }} /> ); const Volume = ({ audio }: { audio: AstalWp.Audio }) => ( { self.hook(audio.defaultSpeaker, "notify::volume", show); self.hook(audio.defaultSpeaker, "notify::mute", show); }} 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", show)} 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(); }} /> ); @register() class LockOsd extends Widget.Window { readonly lockType: "caps" | "num"; #timeout: Time | null = null; constructor({ type, icon, right }: { type: "caps" | "num"; icon: string; right?: boolean }) { super({ visible: false, name: `lock-${type}`, application: App, namespace: `caelestia-lock-${type}`, anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT, exclusivity: Astal.Exclusivity.IGNORE, }); this.lockType = type; this.#update(); this.add( ); // Clickthrough this.connect("size-allocate", () => this.input_shape_combine_region(new Cairo.Region())); // Move over when other indicator opens/closes this.hook(App, "window-toggled", (_, window) => { if (window !== this && window instanceof LockOsd) { const child = this.get_child(); if (!child) return; this[right ? "marginLeft" : "marginRight"] = window.visible ? child.get_preferred_width()[1] + config.lock.spacing : 0; } }); } #update() { execAsync(`fish -c 'cat /sys/class/leds/input*::${this.lockType}lock/brightness'`) .then(out => (this.get_child() as Widget.Box | null)?.toggleClassName("enabled", out.includes("1"))) .catch(console.error); } show() { super.show(); this.#update(); this.#timeout?.cancel(); this.#timeout = timeout(config.lock[this.lockType].hideDelay, () => this.hide()); } } export default () => { if (AstalWp.get_default()) ; Monitors.get_default().forEach(monitor => ); ; ; return null; };