ags: add ask popup, make notifications work(finally :3) and more improvements

This commit is contained in:
retrozinndev
2025-03-09 13:45:07 -03:00
parent 161c811841
commit 59ef5e4aa7
67 changed files with 2005 additions and 731 deletions
+4
View File
@@ -12,6 +12,10 @@ export function updateApps(): void {
appsList = astalApps.get_list();
}
export function getAstalApps(): AstalApps.Apps {
return astalApps;
}
export function getAppsByName(appName: string): (Array<AstalApps.Application>|undefined) {
let found: Array<AstalApps.Application> = [];
+16 -1
View File
@@ -2,6 +2,9 @@ import { Gtk } from "astal/gtk3";
import { Windows } from "../windows";
import { restartInstance } from "./reload-handler";
import { Wireplumber } from "./volume";
import { startRunnerDefault } from "../window/Runner";
import { AskPopup } from "../widget/AskPopup";
import { execAsync } from "astal";
export function handleArguments(request: string): any {
const args: Array<string> = request.split(" ");
@@ -20,7 +23,18 @@ export function handleArguments(request: string): any {
case "reload":
restartInstance();
return "Reloading instance..."
return "Restarting instance..."
case "runner":
startRunnerDefault();
return "Opening runner..."
case "test":
return AskPopup({
onAccept: () => execAsync("notify-send -u normal haha dumb"),
text: "Would you accept?",
title: "Dumb Question"
});
default:
return "command not found! try checking help";
@@ -143,6 +157,7 @@ Options:
toggle [window_name]: toggles visibility of specified window.
reload: creates a new astal instance and removes this one.
volume: wireplumber volume controller, see "volume help".
runner: open the application runner.
help, -h, --help: shows this help message.
2025 (c) retrozinndev's Hyprland-Dots, licensed under the MIT License.
+1 -1
View File
@@ -1,4 +1,4 @@
import { exec, execAsync, GObject, monitorFile, Process, readFileAsync, register, signal } from "astal";
import { exec, execAsync, GObject, monitorFile, readFileAsync, register, signal } from "astal";
import { Connectable } from "astal/binding";
-172
View File
@@ -1,172 +0,0 @@
import AstalNotifd from "gi://AstalNotifd";
import { timeout } from "astal/time";
import { Subscribable } from "astal/binding";
import { GObject, property, register, Variable } from "astal";
import { Windows } from "../windows";
import { FloatingNotifications } from "../window/FloatingNotifications";
import { Gtk, Widget } from "astal/gtk3";
@register({ GTypeName: "Notifications" })
class NotificationsClass extends GObject.Object implements Subscribable {
private static instance: NotificationsClass;
@property(AstalNotifd.Notifd)
private notifd: AstalNotifd.Notifd;
@property(Boolean)
private doNotDisturb: boolean = false;
@property()
public notificationHistory: Array<AstalNotifd.Notification> = [];
@property()
public notifications: Variable<Array<AstalNotifd.Notification>> = new Variable<Array<AstalNotifd.Notification>>([]);
public static getDefault(): NotificationsClass {
if(!NotificationsClass.instance) {
NotificationsClass.instance = new NotificationsClass();
}
return NotificationsClass.instance;
}
constructor() {
super();
this.notifd = new AstalNotifd.Notifd({
ignoreTimeout: true,
dontDisturb: false
} as AstalNotifd.Notifd.ConstructorProps);
this.getNotifd().connect("notified", (daemon: AstalNotifd.Notifd, id: number) => {
const notification: (AstalNotifd.Notification|null) = daemon.get_notification(id);
if(!notification) {
console.log("[LOG] Notification is null, ignoring");
return;
}
if(!this.doNotDisturb) {
this.handleNotification(notification);
return;
}
this.addHistory(notification);
});
}
public handleNotification(notification: AstalNotifd.Notification): void {
Windows.open(FloatingNotifications);
let tmpArray = this.notifications.get().reverse();
tmpArray.push(notification);
this.notifications.set(tmpArray.reverse());
// default timeout if undefined
let notificationTimeout = 4000;
switch(notification.urgency) {
case AstalNotifd.Urgency.LOW:
notificationTimeout = 2000;
break;
case AstalNotifd.Urgency.NORMAL:
notificationTimeout = 4000;
break;
}
notification.urgency !== AstalNotifd.Urgency.CRITICAL &&
timeout(notificationTimeout, () => {
this.notifications.set(this.notifications.get().filter((item) => item.id !== notification.id));
this.addHistory(notification);
});
}
public addHistory(notification: AstalNotifd.Notification): void {
let tmpArray: Array<AstalNotifd.Notification> = this.notificationHistory.reverse()
.filter((item: AstalNotifd.Notification) => item.id !== notification.id);
tmpArray.push(notification);
this.notificationHistory = tmpArray.reverse();
}
public removeHistory(notification: AstalNotifd.Notification) {
this.notificationHistory = this.notificationHistory.filter((curNotification: AstalNotifd.Notification) =>
curNotification.id !== notification.id);
}
public getNotifd(): AstalNotifd.Notifd {
return this.notifd;
}
get() {
return this.notifications.get();
}
subscribe(callback: (list: Array<AstalNotifd.Notification>) => void) {
return this.notifications.subscribe(callback);
}
}
function NotificationWidget(notification: AstalNotifd.Notification): Gtk.Widget {
return new Widget.Box({
className: "notification",
homogeneous: false,
expand: false,
orientation: Gtk.Orientation.VERTICAL,
children: [
new Widget.Box({
className: "top",
orientation: Gtk.Orientation.HORIZONTAL,
hexpand: true,
vexpand: false,
children: [
new Widget.Icon({
className: "icon",
visible: notification.appIcon !== "",
icon: notification.appIcon || "image-missing",
iconSize: Gtk.IconSize.DND,
css: ".icon { font-size: 24px; }"
}),
new Widget.Label({
className: "app-name",
halign: Gtk.Align.START,
label: notification.appName || "Unknown Application"
} as Widget.LabelProps),
new Widget.Button({
className: "close nf",
onClick: () => notification.dismiss(),
label: "󰅖"
} as Widget.ButtonProps)
]
} as Widget.BoxProps),
new Widget.Box({
className: "content",
orientation: Gtk.Orientation.HORIZONTAL,
children: [
new Widget.Box({
className: "image",
visible: notification.image !== "",
css: `box.image { background-image: url('${notification.image}'); }`
} as Widget.BoxProps),
new Widget.Box({
className: "text",
orientation: Gtk.Orientation.VERTICAL,
children: [
new Widget.Label({
className: "summary",
useMarkup: true,
label: notification.summary
}),
new Widget.Label({
className: "body",
useMarkup: true,
label: notification.body
} as Widget.LabelProps)
]
} as Widget.BoxProps)
]
} as Widget.BoxProps)
]
} as Widget.BoxProps);
}
export const Notifications = new NotificationsClass();
+103
View File
@@ -0,0 +1,103 @@
import { GObject, property, register, signal, timeout } from "astal";
import AstalNotifd from "gi://AstalNotifd";
@register({ GTypeName: "Notifications" })
class Notifications extends GObject.Object {
private static instance: (Notifications|null) = null;
#notifications: Array<AstalNotifd.Notification> = [];
#history: Array<AstalNotifd.Notification> = [];
#connections: Array<number>;
@property()
public get notifications() { return this.#notifications };
@property()
public get history() { return this.#history };
@signal(AstalNotifd.Notification)
declare notificationAdded: (notification: AstalNotifd.Notification) => void;
@signal(Number)
declare notificationRemoved: (id: number) => void;
@signal(AstalNotifd.Notification)
declare historyAdded: (notification: AstalNotifd.Notification) => void;
@signal(Number)
declare historyRemoved: (id: number) => void;
constructor() {
super();
this.#connections = [
AstalNotifd.get_default().connect("notified", (notifd, id, _replaced) => {
const notification = notifd.get_notification(id);
const notifTimeout = 4000;
this.addNotification(notification, () => {
if(notification.urgency !== AstalNotifd.Urgency.CRITICAL)
timeout(notifTimeout, () => {
this.removeNotification(id);
});
});
}),
AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => {
this.removeNotification(id);
this.addHistory(notifd.get_notification(id));
})
];
this.vfunc_dispose = () => {
this.#connections.map((id: number) =>
AstalNotifd.get_default().disconnect(id));
};
}
public static getDefault(): Notifications {
if(!this.instance)
this.instance = new Notifications();
return this.instance;
}
private addHistory(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void {
const newArray = this.#history.reverse().filter((item) => item.id !== notif.id);
newArray.push(notif);
this.#history = newArray.reverse();
this.notify("history");
this.emit("history-added", notif);
onAdded && onAdded(notif);
}
public removeHistory(notif: (AstalNotifd.Notification|number)): void {
const notifId = (notif instanceof AstalNotifd.Notification) ? notif.id : notif;
this.#history = this.#history.filter((item: AstalNotifd.Notification) =>
item.id !== notifId);
this.notify("history");
this.emit("history-removed", notifId);
}
private addNotification(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void {
const newArray = this.#notifications.reverse().filter((item) => item.id !== notif.id);
newArray.push(notif);
this.#notifications = newArray.reverse();
this.notify("notifications");
this.emit("notification-added", notif);
onAdded && onAdded(notif);
}
public removeNotification(notif: (AstalNotifd.Notification|number)): void {
const notifId = (notif instanceof AstalNotifd.Notification) ? notif.id : notif;
this.#notifications = this.#notifications.filter((item: AstalNotifd.Notification) =>
item.id !== notifId);
this.notify("notifications");
this.emit("notification-removed", notifId);
}
}
export { Notifications };
+88
View File
@@ -0,0 +1,88 @@
import { execAsync, GLib, GObject, register, signal, writeFile } from "astal";
import { Subscribable } from "astal/binding";
import { Gdk } from "astal/gtk3";
import { getDateTime } from "./time";
import AstalWp from "gi://AstalWp";
@register({ GTypeName: "ScreenRecording" })
class Recording extends GObject.Object implements Subscribable {
private static instance: Recording;
@signal()
declare started: () => void;
@signal(String)
declare stopped: (outputFile: string) => void;
@signal(String)
declare outputChanged: (newPath: string) => void;
#recording: boolean = false;
#subs = new Set<(isRec: boolean) => void>();
#path: string = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_VIDEOS) || `${GLib.get_home_dir()}/Recordings`;
/** Default extension: mp4(h264) */
#extension: string = "mp4";
#recordAudio: boolean|AstalWp.Endpoint = false; // TODO
private notifySub() {
const subs = this.#subs;
for(const sub of subs) {
sub(this.recording);
}
}
public get recording() { return this.#recording; }
private set recording(newValue: boolean) { this.#recording = newValue; }
public get path() { return this.#path; }
public set path(newPath: string) { this.#path = newPath; }
public get extension() { return this.#extension; }
public set extension(newExt: string) { this.#extension = newExt; }
constructor() {
super();
}
public static getDefault() {
if(!this.instance)
this.instance = new Recording();
return this.instance;
}
public get() {
return this.recording;
}
private emit(id: string, ...args: any[]) {
super.emit(id, ...args);
this.notifySub();
}
public startRecording(area?: Gdk.Rectangle) {
const output = `${getDateTime().get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension}`;
execAsync([ "wf-recorder",
`${Boolean(area) ?
`-g ${area?.x || 0},${area?.y || 0} ${area?.width || 1}x${area?.height || 1}`
: ""}`,
"-f", output ]
).then(() => {
this.emit("stopped", `${this.path}/${output}`);
});
writeFile("", "");
this.emit("started");
this.notifySub();
}
public stopRecording() {
}
public subscribe(callback: (isRec: boolean) => void) {
this.#subs.add(callback);
return () => this.#subs.delete(callback);
}
}
export { Recording };
View File
+15
View File
@@ -0,0 +1,15 @@
import AstalHyprland from "gi://AstalHyprland";
import { getAstalApps } from "../apps";
import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget";
import AstalApps from "gi://AstalApps";
export function handleApplications(search: string): (Array<ResultWidget>|null) {
return getAstalApps().fuzzy_query(search).map((app: AstalApps.Application) =>
new ResultWidget({
title: app.get_name(),
description: app.get_description(),
icon: app.iconName,
onClick: () => AstalHyprland.get_default().dispatch("exec", app.get_executable())
} as ResultWidgetProps)
) || null;
}
View File
+14
View File
@@ -0,0 +1,14 @@
import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget";
import AstalHyprland from "gi://AstalHyprland";
import { GLib } from "astal";
export function handleShell(command: string): ResultWidget {
const userShell = GLib.getenv("SHELL") || "/usr/bin/env bash";
return new ResultWidget({
onClick: () => AstalHyprland.get_default().dispatch("exec", `${userShell} -c "${command}"`),
title: `Run: \`${command}\``,
description: userShell,
icon: "utilities-terminal-symbolic"
} as ResultWidgetProps);
}
+42
View File
@@ -0,0 +1,42 @@
import AstalHyprland from "gi://AstalHyprland";
import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget";
export enum SearchEngine {
GOOGLE,
DUCKDUCKGO,
YAHOO
}
export const SearchEngineMap: Map<SearchEngine, string> = new Map([
[ SearchEngine.DUCKDUCKGO, "https://duckduckgo.com/?q=" ],
[ SearchEngine.GOOGLE, "https://google.com/search?q=" ],
[ SearchEngine.YAHOO, "https://search.yahoo.com/search?p=" ]
]);
let searchEngine: SearchEngine = SearchEngine.GOOGLE;
export function handleWebSearch(search: string): ResultWidget {
let engineString: string;
switch(searchEngine as SearchEngine) {
case SearchEngine.GOOGLE:
engineString = "Google";
case SearchEngine.YAHOO:
engineString = "Yahoo";
case SearchEngine.DUCKDUCKGO:
engineString = "DuckDuckGo";
default: engineString = "Web";
}
return new ResultWidget({
icon: "system-search-symbolic",
title: search || "",
description: `Search with ${engineString}`,
onClick: () => AstalHyprland.get_default().dispatch(
"exec",
`xdg-open "${SearchEngineMap.get(searchEngine)! + search.replaceAll(" ", "%20")}"`
)
} as ResultWidgetProps);
}
+10 -7
View File
@@ -1,8 +1,10 @@
// handles reloading stylesheet and pywal colors
import { readFile, monitorFile, Process } from "astal";
import { readFile, monitorFile, AstalIO, exec, timeout } from "astal";
import { App } from "astal/gtk3";
import { getUserDirs } from "./user";
import { getUserDirs } from "./utils";
let watchDelay: (AstalIO.Time|null);
const stylePath = `${getUserDirs().state}/ags/style`
const watchPaths = [
@@ -22,8 +24,8 @@ export function reloadStyle(): void {
export function compileStyle(): void {
console.log("[LOG] Compiling sass (stylesheet)");
Process.exec(`mkdir -p ${stylePath}`);
Process.exec(`sh -c "sass -I ./style ./style.scss ${stylePath}/style.css"`);
exec(`mkdir -p ${stylePath}`);
exec(`sh -c "sass -I ./style ./style.scss ${stylePath}/style.css"`);
}
export function applyStyle(): void {
@@ -34,14 +36,15 @@ export function applyStyle(): void {
);
}
/** Monitor changes on stylesheet at runtime */
function watch(): void {
// Monitor changes on stylesheet at runtime
watchPaths.map((path: string) =>
monitorFile(
`${path}`,
(file: string) => {
// Ignore tmp files
if(!file.endsWith('~') && !Number.isNaN(file)) {
if(!watchDelay && !file.endsWith('~') && !Number.isNaN(file)) {
watchDelay = timeout(250, () => watchDelay = null);
console.log(`[LOG] Stylesheet ${file} file updated`)
compileStyle();
applyStyle();
@@ -54,7 +57,7 @@ function watch(): void {
monitorFile(
`${getUserDirs().cache}/wal/colors.scss`,
(file: string) => {
Process.exec(`bash -c "cp -f ${file} ./style/_wal.scss"`)
exec(`bash -c "cp -f ${file} ./style/_wal.scss"`)
}
);
}
+10 -2
View File
@@ -1,4 +1,4 @@
import { GLib } from "astal";
import { execAsync, GLib } from "astal";
export function getUserDirs() {
return {
@@ -7,5 +7,13 @@ export function getUserDirs() {
cache: GLib.getenv("XDG_CACHE_HOME"),
config: GLib.getenv("XDG_CONFIG_HOME"),
data: GLib.getenv("XDG_DATA_HOME")
} as const;
};
}
export function makeDirectory(dir: string): void {
execAsync([ "mkdir", "-p", dir ]);
}
export function deleteFile(path: string): void {
execAsync([ "rm", "-r", path ]);
}
+89
View File
@@ -0,0 +1,89 @@
import { Subscribable } from "astal/binding";
export class VarMap<K, V> implements Subscribable {
#subs = new Set<(v: Map<K, V>) => void>();
#map: Map<K, V>;
constructor(initial?: Map<K, V>) {
this.#map = initial || new Map<K, V>();
}
private notifyMap() {
const subs = this.#subs;
for(const sub of subs) {
sub(this.#map);
}
}
public get(): Map<K, V> {
return this.#map;
}
public get size(): number {
return this.#map.size;
}
public getValue(key: K): (V|undefined) {
return this.#map.get(key);
}
public getKeyAt(index: number): (K|undefined) {
return [...this.#map.keys()][index];
}
public getValueAt(index: number): (V|undefined) {
return [...this.#map.values()][index];
}
public set(key: K, value: V): Map<K, V> {
const newMap: Map<K, V> = this.#map.set(key, value);
this.notifyMap();
return newMap;
}
public delete(key: K): boolean {
const deleted: boolean = this.#map.delete(key);
this.notifyMap();
return deleted;
}
public has(key: K): boolean {
return this.#map.has(key);
}
public clear(): void {
this.#map.clear();
this.notifyMap();
}
public entries(): MapIterator<[K, V]> {
return this.#map.entries();
}
public keys(): MapIterator<K> {
return this.#map.keys();
}
public values(): MapIterator<V> {
return this.#map.values();
}
public forEach<ReturnType = any> (callback: (value: V, key: K, map: Map<K, V>) => ReturnType): ReturnType[] {
const result: Array<ReturnType> = [];
for(const entry of this.#map.entries()) {
result.push(callback(entry[1], entry[0], this.#map));
}
return result;
}
public subscribe(callback: (v: Map<K, V>) => void): () => void {
this.#subs.add(callback);
return () => {
this.#subs.delete(callback);
}
}
}