diff --git a/resources/styles/_osd.scss b/resources/styles/_osd.scss index 57ea437..21dc72c 100644 --- a/resources/styles/_osd.scss +++ b/resources/styles/_osd.scss @@ -12,29 +12,23 @@ -gtk-icon-size: 24px; } - .volume { + .level { margin-top: -6px; - .device { - margin-bottom: 6px; + .text { + margin-bottom: 4px; font-size: 14px; font-weight: 600; } levelbar trough block { - border-radius: 3px; + border-radius: 4px; background: colors.$bg-primary; &.filled { - min-height: 8px; + min-height: 10px; background: colors.$bg-secondary; } } - - .value { - font-size: 11px; - font-weight: 400; - padding: 0 4px; - } } } diff --git a/src/app.ts b/src/app.ts index 2a05352..f81c976 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,15 +22,15 @@ import { Clipboard } from "./modules/clipboard"; import { Config } from "./modules/config"; import { Gdk, Gtk } from "ags/gtk4"; import { createRoot, getScope } from "ags"; -import { triggerOSD } from "./window/OSD"; +import { OSDModes, triggerOSD } from "./window/OSD"; import { programArgs, programInvocationName } from "system"; import { setConsoleLogDomain } from "console"; import { initPlayer } from "./modules/media"; import { encoder } from "./modules/utils"; import { exec } from "ags/process"; +import { Backlights } from "./modules/backlight"; import GObject, { register } from "ags/gobject"; -import AstalNotifd from "gi://AstalNotifd"; import GLib from "gi://GLib?version=2.0"; import Gio from "gi://Gio?version=2.0"; import Adw from "gi://Adw?version=1"; @@ -283,15 +283,38 @@ you should use the socket in the XDG_RUNTIME_DIR/colorshell.sock for a faster re this.#connections.set(Wireplumber.getDefault(), Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () => - triggerOSD()) + !Windows.getDefault().isOpen("control-center") && + triggerOSD(OSDModes.SINK) + ) + ); + + // dinamically connect to default backlight (if there's any) + let lastDefaultBk: Backlights.Backlight|null = null; + this.#connections.set(Backlights.getDefault(), + Backlights.getDefault().connect("notify::default", (_, defaultBk: Backlights.Backlight|null) => { + if(!lastDefaultBk) return; + + if(this.#connections.has(lastDefaultBk)) + lastDefaultBk.disconnect((this.#connections.get(lastDefaultBk) as number)); + + lastDefaultBk = null; + if(!defaultBk) return; + + lastDefaultBk = defaultBk; + + this.#connections.set(defaultBk, defaultBk.connect("brightness-changed", () => + !Windows.getDefault().isOpen("control-center") && + triggerOSD(OSDModes.BRIGHTNESS) + )); + }) ); this.#connections.set(Notifications.getDefault(), [ - Notifications.getDefault().connect("notification-added", (_, _notif: AstalNotifd.Notification) => { + Notifications.getDefault().connect("notification-added", () => { Windows.getDefault().open("floating-notifications"); }), - Notifications.getDefault().connect("notification-removed", (_: Notifications, _id: number) => { - _.notifications.length === 0 && Windows.getDefault().close("floating-notifications"); + Notifications.getDefault().connect("notification-removed", (self) => { + self.notifications.length === 0 && Windows.getDefault().close("floating-notifications"); }) ]); diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 550a246..057c67e 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -1,14 +1,17 @@ -import { execAsync, Gio, GLib, register } from "astal"; -import Polkit from "gi://Polkit"; -import PolkitAgent from "gi://PolkitAgent"; +import { execAsync } from "ags/process"; +import { register } from "ags/gobject"; import { EntryPopup, EntryPopupProps } from "../widget/EntryPopup"; -import AstalAuth from "gi://AstalAuth"; import { AskPopup, AskPopupProps } from "../widget/AskPopup"; -export { Auth }; +import AstalAuth from "gi://AstalAuth"; +import Polkit from "gi://Polkit"; +import PolkitAgent from "gi://PolkitAgent"; +import Gio from "gi://Gio?version=2.0"; +import GLib from "gi://GLib?version=2.0"; + @register({ GTypeName: "AuthAgent" }) -class Auth extends PolkitAgent.Listener { +export class Auth extends PolkitAgent.Listener { private static instance: Auth; #subject: Polkit.Subject; diff --git a/src/modules/backlight.ts b/src/modules/backlight.ts index af39efd..2fa160b 100644 --- a/src/modules/backlight.ts +++ b/src/modules/backlight.ts @@ -193,6 +193,13 @@ export namespace Backlights { ): void { super.emit(signal, ...args); } + + public connect( + signal: Signal, + callback: (self: typeof this, ...args: Parameters<(typeof this.$signals)[Signal]>) => ReturnType<(typeof this.$signals)[Signal]> + ): number { + return super.connect(signal, callback); + } } export const Backlights = _Backlights; diff --git a/src/modules/notifications.ts b/src/modules/notifications.ts index d280e70..4f71c20 100644 --- a/src/modules/notifications.ts +++ b/src/modules/notifications.ts @@ -1,17 +1,13 @@ -import { timeout } from "ags/time"; import { execAsync } from "ags/process"; -import { readFile } from "ags/file"; import { generalConfig } from "../app"; import { onCleanup } from "ags"; -import GObject, { getter, property, register, signal } from "ags/gobject"; +import GObject, { getter, ParamSpec, property, register, signal } from "ags/gobject"; import AstalNotifd from "gi://AstalNotifd"; -import AstalIO from "gi://AstalIO"; -import Gio from "gi://Gio?version=2.0"; import GLib from "gi://GLib?version=2.0"; -export interface HistoryNotification { +export type HistoryNotification = { id: number; appName: string; body: string; @@ -22,28 +18,100 @@ export interface HistoryNotification { image?: string; } +export class NotificationTimeout { + #source?: GLib.Source; + #args?: Array; + #millis: number; + #lastRemained!: number; + + readonly callback: () => void; + get millis(): number { return this.#millis; } + get remaining(): number { return this.source!.get_time() } + get lastRemained(): number { return this.#lastRemained; } + get running(): boolean { return Boolean(this.source?.is_destroyed()); } + get source(): GLib.Source|undefined { return this.#source; } + + constructor(millis: number, callback: () => void, start: boolean = true, ...args: Array) { + this.#millis = millis; + this.callback = callback; + this.#args = args; + + if(!start) return; + this.start(); + } + + cancel(): void { + // use lastRemained to calculate on what time the user hold the notification, so it + // can be released by the remaining time (works like a timeout "pause") + this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000); + this.#source?.destroy(); + this.#source?.unref(); + this.#source = undefined; + } + + start(newMillis?: number): GLib.Source { + if(this.running) + throw new Error("Notifications: Can't start a new counter if it's already running!"); + + if(newMillis !== undefined) + this.#millis = newMillis; + + this.#source = setTimeout( + this.callback, + this.#millis, + this.#args + ); + + this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000); + + return this.#source; + } +}; + @register({ GTypeName: "Notifications" }) -class Notifications extends GObject.Object { +export class Notifications extends GObject.Object { private static instance: (Notifications|null) = null; - #notifications: Array = []; + declare $signals: GObject.Object.SignalSignatures & { + "history-added": (notification: HistoryNotification) => void; + "history-removed": (notificationId: number) => void; + "history-cleared": () => void; + "notification-added": (notification: AstalNotifd.Notification) => void; + "notification-removed": (notificationId: number) => void; + "notification-replaced": (notificationId: number) => void; + }; + + #notifications = new Map(); #history: Array = []; - #notificationsOnHold: Set = new Set(); #connections: Array = []; @getter(Array) - public get notifications() { return this.#notifications }; + public get notifications() { + return [...this.#notifications.values()].map(([n]) => n); + }; @getter(Array) public get history() { return this.#history }; + @getter(Array) + public get notificationsOnHold() { + return [...this.#notifications.values()].filter(([_, s]) => + typeof s === "undefined" + ).map(([n]) => n); + } + @property(Number) public historyLimit: number = 10; + /** skip notifications directly to notification history */ + @property(Boolean) + public ignoreNotifications: boolean = false; + @signal(AstalNotifd.Notification) notificationAdded(_notification: AstalNotifd.Notification) {}; @signal(Number) notificationRemoved(_id: number) {}; - @signal(Object) historyAdded(_notification: Object) {}; + @signal(Object as unknown as ParamSpec) historyAdded(_notification: Object) {}; + @signal() historyCleared() {}; @signal(Number) historyRemoved(_id: number) {}; @signal(Number) notificationReplaced(_id: number) {}; @@ -53,43 +121,13 @@ class Notifications extends GObject.Object { this.#connections.push( AstalNotifd.get_default().connect("notified", (notifd, id) => { const notification = notifd.get_notification(id); - const notifTimeout = generalConfig.getProperty( - `notifications.timeout_${this.getUrgencyString(notification.urgency).toLowerCase()}`, - "number") as number; - - if(this.getNotifd().dontDisturb) { + + if(this.getNotifd().dontDisturb || this.ignoreNotifications) { this.addHistory(notification, () => notification.dismiss()); return; } - this.addNotification(notification, () => { - if(notification.urgency !== AstalNotifd.Urgency.CRITICAL || - (notification.urgency === AstalNotifd.Urgency.CRITICAL && - notifTimeout > 0)) { - - let notifTimer: (AstalIO.Time|undefined) = undefined; - let replacedConnectionId: number; - - const removeFun = () => { // Funny name haha lmao remove fun :skull: - notifTimer = undefined; - if(this.#notificationsOnHold.has(notification.id)) return; - - this.addHistory(notification, () => { - replacedConnectionId && this.disconnect(replacedConnectionId); - this.removeNotification(id); - }); - } - - notifTimer = timeout(notifTimeout, removeFun); - - replacedConnectionId = this.connect("notification-replaced", (_, id: number) => { - if(notification.id !== id) return; - - notifTimer?.cancel(); - notifTimer = timeout(notifTimeout, removeFun); - }); - } - }); + this.addNotification(notification, this.getNotificationTimeout(notification) > 0); }), AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => { @@ -98,8 +136,6 @@ class Notifications extends GObject.Object { }) ); - this.retrieveHistoryFromFile(); - onCleanup(() => { this.#connections.map(id => AstalNotifd.get_default().disconnect(id)); @@ -113,42 +149,6 @@ class Notifications extends GObject.Object { return this.instance; } - private retrieveHistoryFromFile(): void { - const historyFile = Gio.File.new_for_path(`${GLib.get_user_state_dir()}/astal/notifd/notifications.json`); - if(!historyFile.query_exists(null)) return; - - let content: string; - console.log("Notifications: History file found! Trying to retrieve history from JSON"); - - try { - content = readFile(historyFile.get_path()!); - } catch(e: any) { - console.error(`Notifications: An error occurred while trying to read the history file. Stderr:\n${ - (e as Error).message}\n${(e as Error).stack}`); - - return; - } - - try { - const historyJSON = JSON.parse(content); - - (historyJSON["notifications"] as Array).reverse() - .forEach(n => this.addHistory(n)); - } catch(e: any) { - if(e instanceof SyntaxError) { - console.error(`Notifications: Couldn't parse history JSON because of a SyntaxError:\n${e.message - }\n${e.stack}`); - - return; - } - - console.error(`Notifications: An error occurred while parsing the history JSON file. Stderr:\n${ - e.message}\n${e.stack}`); - - return; - } - } - public async sendNotification(props: { urgency?: AstalNotifd.Urgency; appName?: string; @@ -236,7 +236,7 @@ class Notifications extends GObject.Object { this.notify("history"); this.emit("history-added", this.#history[0]); - onAdded && onAdded(notif); + onAdded?.(notif); } public async clearHistory(): Promise { @@ -245,6 +245,7 @@ class Notifications extends GObject.Object { this.emit("history-removed", notif.id); }); + this.emit("history-cleared"); this.notify("history"); } @@ -257,47 +258,82 @@ class Notifications extends GObject.Object { this.emit("history-removed", notifId); } - private addNotification(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void { - for(let i = 0; i < this.#notifications.length; i++) { - const item = this.#notifications[i]; + private addNotification( + notif: AstalNotifd.Notification, + removeOnTimeout: boolean = true, + onTimeoutEnd?: () => void + ): void { - if(item.id !== notif.id) continue; - - this.#notifications.splice(i, 1); - this.emit("notification-replaced", item.id); - break; + const replaced = this.#notifications.has(notif.id); + const notifTimeout = this.getNotificationTimeout(notif); + const onEnd = () => { + removeOnTimeout && this.removeNotification(notif); + onTimeoutEnd?.(); } - this.#notifications.unshift(notif); + // destroy timer of replaced notification(if there's any) + if(replaced) { + const data = this.#notifications.get(notif.id)!; + (data?.[1] instanceof NotificationTimeout) && + data[1].cancel(); + } + + this.#notifications.set(notif.id, [ + notif, + new NotificationTimeout(notifTimeout, onEnd, notifTimeout > 0) + ]); + + replaced && this.emit("notification-replaced", notif.id); + this.notify("notifications"); this.emit("notification-added", notif); - onAdded?.(notif); + + if(notifTimeout <= 0) onEnd?.(); } - public removeNotification(notif: (AstalNotifd.Notification|number)): void { - const notificationId = (notif instanceof AstalNotifd.Notification) ? notif.id : notif; - this.#notificationsOnHold.delete(notificationId); - - this.#notifications = this.#notifications.filter((item) => - item.id !== notificationId); - - AstalNotifd.get_default().get_notification(notificationId)?.dismiss(); - this.notify("notifications"); - this.emit("notification-removed", notificationId); + public getNotificationTimeout(notif: AstalNotifd.Notification): number { + return generalConfig.getProperty( + `notifications.timeout_${this.getUrgencyString(notif.urgency)}`, + "number" + ); } - private getNotificationById(id: number): AstalNotifd.Notification|undefined { - return this.#notifications.filter(notif => notif.id === id)?.[0]; - } - - public holdNotification(notif: (AstalNotifd.Notification|number)): void { - notif = (typeof notif === "number") ? - this.getNotificationById(notif)! + public removeNotification(notif: (AstalNotifd.Notification|number), addToHistory: boolean = true): void { + notif = typeof notif === "number" ? + this.#notifications.get(notif)?.[0]! : notif; if(!notif) return; - this.#notificationsOnHold.add(notif.id); + const timeout = this.#notifications.get(notif.id)![1]; + timeout.running && timeout.cancel(); + + this.#notifications.delete(notif.id); + addToHistory && this.addHistory(notif); + + notif.dismiss(); + this.notify("notifications"); + this.emit("notification-removed", notif.id); + } + + public holdNotification(notif: AstalNotifd.Notification|number): void { + const id = typeof notif === "number" ? notif : notif.id; + const data = this.#notifications.get(id); + + if(!data) return; + + data[1].cancel(); + this.notify("notifications-on-hold"); + } + + public releaseNotification(notif: AstalNotifd.Notification|number): void { + const id = typeof notif === "number" ? notif : notif.id; + const data = this.#notifications.get(id); + + if(!data) return; + data[1].start(data[1].lastRemained); + + this.notify("notifications-on-hold"); } public toggleDoNotDisturb(value?: boolean): boolean { @@ -308,6 +344,18 @@ class Notifications extends GObject.Object { } public getNotifd(): AstalNotifd.Notifd { return AstalNotifd.get_default(); } -} -export { Notifications }; + public emit( + signal: Signal, ...args: Parameters<(typeof this.$signals)[Signal]> + ): void { + super.emit(signal, ...args); + } + + public connect( + signal: Signal, + callback: (self: typeof this, ...params: Parameters<(typeof this.$signals)[Signal]>) => + ReturnType<(typeof this.$signals)[Signal]> + ): number { + return super.connect(signal, callback); + } +} diff --git a/src/modules/utils.ts b/src/modules/utils.ts index 7974ed0..ec3cea0 100644 --- a/src/modules/utils.ts +++ b/src/modules/utils.ts @@ -6,6 +6,7 @@ import { getSymbolicIcon } from "./apps"; import GLib from "gi://GLib?version=2.0"; import Gio from "gi://Gio?version=2.0"; +import GObject from "gi://GObject?version=2.0"; /** gnim doesn't export this, so we need to do it again */ @@ -214,3 +215,29 @@ export function addSliderMarksFromMinMax(slider: Astal.Slider, amountOfMarks: nu return slider; } + +/** initialize and sub class properties with accessors */ +export function construct(klass: object, props: Record>): Array<() => void> { + + const subs: Array<() => void> = []; + const isGObject = klass instanceof GObject.Object; + + Object.keys(props).forEach(k => { + const v = props[k as keyof typeof props]; + + if(v === undefined) return; + if(v instanceof Accessor) { + subs.push(v.subscribe(() => { + klass[k as keyof typeof klass] = v.get() as never; + if(isGObject) klass.notify(k); + })); + + klass[k as keyof typeof klass] = v.get() as never; + return; + } + + klass[k as keyof typeof klass] = v as never; + }); + + return subs; +} diff --git a/src/widget/Notification.tsx b/src/widget/Notification.tsx index cc4cd8e..5f61928 100644 --- a/src/widget/Notification.tsx +++ b/src/widget/Notification.tsx @@ -9,7 +9,6 @@ import GObject from "ags/gobject"; import AstalNotifd from "gi://AstalNotifd"; import Pango from "gi://Pango?version=1.0"; import GLib from "gi://GLib?version=2.0"; -import { timeout } from "ags/time"; function getNotificationImage(notif: AstalNotifd.Notification|HistoryNotification): (string|undefined) { @@ -42,36 +41,24 @@ export function NotificationWidget({ notification, actionClicked, holdOnHover, s const conns: Map> = new Map(); - onCleanup(() => - conns.forEach((ids, obj) => ids.forEach(id => obj.disconnect(id)))); + onCleanup(() => conns.forEach((ids, obj) => + ids.forEach(id => obj.disconnect(id)) + )); return { - const eventControllerMotion = Gtk.EventControllerMotion.new(), - gestureClick = Gtk.GestureClick.new(); - - self.add_controller(eventControllerMotion); - self.add_controller(gestureClick); - - conns.set(eventControllerMotion, [ - eventControllerMotion.connect("enter", () => - holdOnHover && Notifications.getDefault().holdNotification(notification.id)), - eventControllerMotion.connect("leave", () => - holdOnHover && notification && timeout(600, () => - Notifications.getDefault().removeNotification(notification.id) - )) - ]); - - conns.set(gestureClick, [ - gestureClick.connect("released", (gesture) => { - gesture.get_current_button() === Gdk.BUTTON_PRIMARY && - actionClicked?.(notification); - }) - ]); - }}> + }`} orientation={Gtk.Orientation.VERTICAL} spacing={5}> + holdOnHover && + Notifications.getDefault().holdNotification(notification.id) + } onLeave={() => holdOnHover && + Notifications.getDefault().releaseNotification(notification.id) + } + /> + + gesture.get_current_button() === Gdk.BUTTON_PRIMARY && + actionClicked?.(notification) + } /> { const icon = getSymbolicIcon(notification.appIcon ?? notification.appName) ?? @@ -84,7 +71,7 @@ export function NotificationWidget({ notification, actionClicked, holdOnHover, s self.set_visible(false); }} /> - } + vexpand> - - diff --git a/src/window/OSD.tsx b/src/window/OSD.tsx index fe2f0f0..ee5ca62 100644 --- a/src/window/OSD.tsx +++ b/src/window/OSD.tsx @@ -1,64 +1,106 @@ import { Astal, Gtk } from "ags/gtk4"; -import { createBinding, createState } from "ags"; +import { Accessor, createBinding, createState, With } from "ags"; import { Wireplumber } from "../modules/volume"; import { Windows } from "../windows"; -import { Time, timeout } from "ags/time"; +import { Backlights } from "../modules/backlight"; +import { construct, variableToBoolean } from "../modules/utils"; +import GObject, { ParamSpec, property, register } from "ags/gobject"; import Pango from "gi://Pango?version=1.0"; +import GLib from "gi://GLib?version=2.0"; -export enum OSDModes { - SINK, - BRIGHTNESS, - NONE +@register({ GTypeName: "OSDMode" }) +export class OSDMode extends GObject.Object { + readonly #subs: Array<() => void> = []; + @property(String) + icon: string = "image-missing"; + @property(Number) + value: number = 0; + @property(Number) + max: number = 100; + @property(String as unknown as ParamSpec) + text: string|null = null; + + constructor(props: { + icon: string | Accessor; + value: number | Accessor; + max?: number | Accessor; + text?: string | Accessor; + }) { + super(); + this.#subs = construct(this, props); + } + + vfunc_dispose(): void { + this.#subs.forEach(s => s()); + } } -const [osdMode, setOSDMode] = createState(OSDModes.NONE); -let osdTimer: (Time|undefined), osdTimeout = 3500; +export const OSDModes: Record = { + SINK: new OSDMode({ + icon: createBinding(Wireplumber.getWireplumber().defaultSpeaker, "volumeIcon"), + value: createBinding(Wireplumber.getWireplumber().defaultSpeaker, "volume"), + text: createBinding(Wireplumber.getWireplumber().defaultSpeaker, "description"), + max: Wireplumber.getDefault().getMaxSinkVolume() / 100 + }), + BRIGHTNESS: Backlights.getDefault().available ? new OSDMode({ + icon: "display-brightness-symbolic", + value: createBinding(Backlights.getDefault().default, "brightness"), + max: createBinding(Backlights.getDefault().default, "maxBrightness"), + text: createBinding(Backlights.getDefault().default, "name") + }) + : new OSDMode({ + icon: "display-brightness-symbolic", + value: 100, + max: 100, + text: "No Backlight found" + }) +} -export const OSD = (mon: number) => { - if(osdMode.get() === OSDModes.NONE) - setOSDMode(OSDModes.SINK); +const [osdMode, setOSDMode] = createState(OSDModes.SINK); +let osdTimer: (GLib.Source|undefined), osdTimeout = 3500; - return + - !Wireplumber.getDefault().isMutedSink() && - Wireplumber.getDefault().getSinkVolume() > 0 ? icon : "audio-volume-muted-symbolic")} - /> - - description ?? "Speaker")} - ellipsize={Pango.EllipsizeMode.END} - /> - - + + {(mode: OSDMode) => + + + t ?? "")} + ellipsize={Pango.EllipsizeMode.END} + visible={variableToBoolean(createBinding(mode, "text"))} + /> + + + } + - -} - -export function triggerOSD() { - if(Windows.getDefault().isOpen("control-center")) return; + ; +export function triggerOSD(mode: OSDMode) { + setOSDMode(mode); Windows.getDefault().open("osd"); if(!osdTimer) { - osdTimer = timeout(osdTimeout, () => { + osdTimer = setTimeout(() => { osdTimer = undefined; Windows.getDefault().close("osd"); - }); + }, osdTimeout); return; } - osdTimer.cancel(); - osdTimer = timeout(osdTimeout, () => { + osdTimer.destroy(); + osdTimer = setTimeout(() => { Windows.getDefault().close("osd"); osdTimer = undefined; - }); + }, osdTimeout); }