♻️ refactor: rename src/scripts -> src/modules
this is a better name
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { Gdk, Gtk } from "ags/gtk4";
|
||||
import { execAsync } from "ags/process";
|
||||
|
||||
import AstalApps from "gi://AstalApps";
|
||||
import AstalHyprland from "gi://AstalHyprland";
|
||||
|
||||
|
||||
export const uwsmIsActive: boolean = await execAsync(
|
||||
"uwsm check is-active"
|
||||
).then(() => true).catch(() => false);
|
||||
const astalApps: AstalApps.Apps = new AstalApps.Apps();
|
||||
|
||||
let appsList: Array<AstalApps.Application> = astalApps.get_list();
|
||||
|
||||
export function getApps(): Array<AstalApps.Application> {
|
||||
return appsList;
|
||||
}
|
||||
|
||||
export function updateApps(): void {
|
||||
astalApps.reload();
|
||||
appsList = astalApps.get_list();
|
||||
}
|
||||
|
||||
export function getAstalApps(): AstalApps.Apps {
|
||||
return astalApps;
|
||||
}
|
||||
|
||||
/** handles running with uwsm if it's installed */
|
||||
export function execApp(app: AstalApps.Application|string, dispatchExecArgs?: string) {
|
||||
const executable = (typeof app === "string") ? app
|
||||
: app.executable.replace(/(%f|%F|%u|%U|%i|%c|%k)/g, "");
|
||||
|
||||
AstalHyprland.get_default().dispatch("exec",
|
||||
`${dispatchExecArgs ? `${dispatchExecArgs} ` : ""}${uwsmIsActive ? "uwsm app -- " : ""}${executable}`
|
||||
);
|
||||
}
|
||||
|
||||
export function lookupIcon(name: string): boolean {
|
||||
return Gtk.IconTheme.get_for_display(Gdk.Display.get_default()!)?.has_icon(name);
|
||||
}
|
||||
|
||||
export function getAppsByName(appName: string): (Array<AstalApps.Application>|undefined) {
|
||||
let found: Array<AstalApps.Application> = [];
|
||||
|
||||
getApps().map((app: AstalApps.Application) => {
|
||||
if(app.get_name().trim().toLowerCase() === appName.trim().toLowerCase()
|
||||
|| (app?.wmClass && app.wmClass.trim().toLowerCase() === appName.trim().toLowerCase()))
|
||||
found.push(app);
|
||||
});
|
||||
|
||||
return (found.length > 0 ? found : undefined);
|
||||
}
|
||||
|
||||
export function getIconByAppName(appName: string): (string|undefined) {
|
||||
if(!appName) return undefined;
|
||||
|
||||
if(lookupIcon(appName))
|
||||
return appName;
|
||||
|
||||
if(lookupIcon(appName.toLowerCase()))
|
||||
return appName.toLowerCase();
|
||||
|
||||
const nameReverseDNS = appName.split('.');
|
||||
const lastItem = nameReverseDNS[nameReverseDNS.length - 1];
|
||||
const lastPretty = `${lastItem.charAt(0).toUpperCase()}${lastItem.substring(1, lastItem.length)}`;
|
||||
|
||||
const uppercaseRDNS = nameReverseDNS.slice(0, nameReverseDNS.length - 1)
|
||||
.concat(lastPretty).join('.');
|
||||
|
||||
if(lookupIcon(uppercaseRDNS))
|
||||
return uppercaseRDNS;
|
||||
|
||||
if(lookupIcon(nameReverseDNS[nameReverseDNS.length - 1]))
|
||||
return nameReverseDNS[nameReverseDNS.length - 1];
|
||||
|
||||
const found: (AstalApps.Application|undefined) = getAppsByName(appName)?.[0];
|
||||
if(Boolean(found))
|
||||
return found?.iconName;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getAppIcon(app: (string|AstalApps.Application)): (string|undefined) {
|
||||
if(!app) return undefined;
|
||||
|
||||
if(typeof app === "string")
|
||||
return getIconByAppName(app);
|
||||
|
||||
if(app.iconName && lookupIcon(app.iconName))
|
||||
return app.iconName;
|
||||
|
||||
if(app.wmClass)
|
||||
return getIconByAppName(app.wmClass);
|
||||
|
||||
return getIconByAppName(app.name);
|
||||
}
|
||||
|
||||
export function getSymbolicIcon(app: (string|AstalApps.Application)): (string|undefined) {
|
||||
const icon = getAppIcon(app);
|
||||
|
||||
return (icon && lookupIcon(`${icon}-symbolic`)) ?
|
||||
`${icon}-symbolic`
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Wireplumber } from "./volume";
|
||||
import { Windows } from "../windows";
|
||||
import { restartInstance } from "./reload-handler";
|
||||
import { timeout } from "ags/time";
|
||||
import { Runner } from "../runner/Runner";
|
||||
import { showWorkspaceNumber } from "../widget/bar/Workspaces";
|
||||
import { playSystemBell } from "./utils";
|
||||
import { player, setPlayer } from "./media";
|
||||
import { generalConfig, Shell } from "../app";
|
||||
|
||||
import AstalIO from "gi://AstalIO";
|
||||
import AstalMpris from "gi://AstalMpris";
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
|
||||
|
||||
let wsTimeout: AstalIO.Time|undefined;
|
||||
const help = `Manage Astal Windows and do more stuff. From retrozinndev's colorshell, \
|
||||
made using GTK4, AGS, Gnim and Astal libraries by Aylur.
|
||||
|
||||
Window Management:
|
||||
open [window]: opens the specified window.
|
||||
close [window]: closes all instances of specified window.
|
||||
toggle [window]: toggle-open/close the specified window.
|
||||
windows: list shell windows and their respective status.
|
||||
reload: quit this instance and start a new one.
|
||||
reopen: restart all open-windows.
|
||||
quit: exit the main instance of the shell.
|
||||
|
||||
Audio Controls:
|
||||
volume: speaker and microphone volume controller, see "volume help".
|
||||
|
||||
Media Controls:
|
||||
media: manage colorshell's active player, see "media help".
|
||||
|
||||
Other options:
|
||||
runner [initial_text]: open the application runner, optionally add an initial search.
|
||||
peek-workspace-num [millis]: peek the workspace numbers on bar window.
|
||||
v, version: display current colorshell version.
|
||||
h, help: shows this help message.
|
||||
|
||||
2025 (c) retrozinndev's colorshell, licensed under the MIT License.
|
||||
https://github.com/retrozinndev/colorshell
|
||||
`.split('\n').map(l => l.replace(/^ {8}/, "")).join('\n');
|
||||
|
||||
export function handleArguments(cmd: Gio.ApplicationCommandLine, args: Array<string>): number {
|
||||
switch(args[0]) {
|
||||
case "help":
|
||||
case "h":
|
||||
cmd.print_literal(help);
|
||||
return 0;
|
||||
|
||||
case "version":
|
||||
case "v":
|
||||
cmd.print_literal(`colorshell by retrozinndev, version ${COLORSHELL_VERSION
|
||||
}${DEVEL ? "(devel)" : ""}\nhttps://github.com/retrozinndev/colorshell`);
|
||||
return 0;
|
||||
|
||||
case "open":
|
||||
case "close":
|
||||
case "toggle":
|
||||
case "windows":
|
||||
case "reopen":
|
||||
return handleWindowArgs(cmd, args);
|
||||
|
||||
case "volume":
|
||||
return handleVolumeArgs(cmd, args);
|
||||
|
||||
case "media":
|
||||
return handleMediaArgs(cmd, args);
|
||||
|
||||
case "reload":
|
||||
restartInstance();
|
||||
cmd.print_literal("Restarting instance...");
|
||||
return 0;
|
||||
|
||||
case "quit":
|
||||
try {
|
||||
Shell.getDefault().quit();
|
||||
cmd.print_literal("Quitting main instance...");
|
||||
} catch(_e) {
|
||||
const e = _e as Error;
|
||||
cmd.printerr_literal(`Error: couldn't quit instance. Stderr: ${e.message}\n${e.stack}`);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
|
||||
case "runner":
|
||||
!Runner.instance ?
|
||||
Runner.openDefault(args[1] || undefined)
|
||||
: Runner.close();
|
||||
|
||||
cmd.print_literal(`Opening runner${args[1] ? ` with predefined text: "${args[1]}"` : ""}`);
|
||||
return 0;
|
||||
|
||||
case "peek-workspace-num":
|
||||
if(wsTimeout) {
|
||||
cmd.print_literal("Workspace numbers are already showing");
|
||||
return 0;
|
||||
}
|
||||
|
||||
showWorkspaceNumber(true);
|
||||
wsTimeout = timeout(Number.parseInt(args[1]) || 2200, () => {
|
||||
showWorkspaceNumber(false);
|
||||
wsTimeout = undefined;
|
||||
});
|
||||
cmd.print_literal("Toggled workspace numbers");
|
||||
return 0;
|
||||
}
|
||||
|
||||
cmd.printerr_literal("Error: command not found! try checking help");
|
||||
return 1;
|
||||
}
|
||||
|
||||
function handleMediaArgs(cmd: Gio.ApplicationCommandLine, args: Array<string>): number {
|
||||
if(/h|help/.test(args[1])) {
|
||||
const mediaHelp = `
|
||||
Manage colorshell's active player
|
||||
|
||||
Options:
|
||||
play: resume/start active player's media.
|
||||
pause: pause the active player.
|
||||
play-pause: toggle play/pause on active player.
|
||||
stop: stop the active player's media.
|
||||
previous: go back to previous media if player supports it.
|
||||
next: jump to next media if player supports it.
|
||||
bus-name: get active player's mpris bus name.
|
||||
list: show available players with their bus name.
|
||||
select bus_name: change the active player, where bus_name is
|
||||
the desired player's mpris bus name(with the mediaplayer2 prefix).
|
||||
`.trim();
|
||||
cmd.print_literal(mediaHelp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const activePlayer: AstalMpris.Player|undefined = player.get().available ?
|
||||
player.get()
|
||||
: undefined;
|
||||
const players = AstalMpris.get_default().players.filter(pl => pl.available);
|
||||
|
||||
if(!activePlayer) {
|
||||
cmd.printerr_literal(`Error: no active player found! try playing some media first`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
switch(args[1]) {
|
||||
case "play":
|
||||
activePlayer.play();
|
||||
cmd.print_literal("Playing");
|
||||
return 0;
|
||||
|
||||
case "list":
|
||||
cmd.print_literal(`Available players:\n${players.map(pl => {
|
||||
let playbackStatusStr: string;
|
||||
switch(pl.playbackStatus) {
|
||||
case AstalMpris.PlaybackStatus.PAUSED:
|
||||
playbackStatusStr = "paused";
|
||||
break;
|
||||
case AstalMpris.PlaybackStatus.PLAYING:
|
||||
playbackStatusStr = "playing";
|
||||
break;
|
||||
default:
|
||||
playbackStatusStr = "stopped";
|
||||
break;
|
||||
}
|
||||
|
||||
return ` ${pl.busName}: ${playbackStatusStr}`;
|
||||
}).join('\n')}`);
|
||||
return 0;
|
||||
|
||||
case "pause":
|
||||
activePlayer.pause();
|
||||
cmd.print_literal("Paused");
|
||||
return 0;
|
||||
|
||||
case "play-pause":
|
||||
activePlayer.play_pause();
|
||||
cmd.print_literal(
|
||||
activePlayer?.playbackStatus === AstalMpris.PlaybackStatus.PAUSED ?
|
||||
"Toggled play"
|
||||
: "Toggled pause"
|
||||
);
|
||||
return 0;
|
||||
|
||||
case "stop":
|
||||
activePlayer.stop();
|
||||
cmd.print_literal("Stopped!");
|
||||
return 0;
|
||||
|
||||
case "previous":
|
||||
activePlayer.canGoPrevious && activePlayer.previous();
|
||||
cmd.print_literal(
|
||||
activePlayer.canGoPrevious ?
|
||||
"Back to previous"
|
||||
: "Player does not support this command"
|
||||
);
|
||||
return 0;
|
||||
|
||||
case "next":
|
||||
activePlayer.canGoNext && activePlayer.next();
|
||||
cmd.print_literal(
|
||||
activePlayer.canGoNext ?
|
||||
"Jump to next"
|
||||
: "Player does not support this command"
|
||||
);
|
||||
return 0;
|
||||
|
||||
case "bus-name":
|
||||
cmd.print_literal(activePlayer.busName);
|
||||
return 0;
|
||||
|
||||
case "select":
|
||||
if(!args[2] || !players.filter(pl => pl.busName == args[2])?.[0]) {
|
||||
cmd.printerr_literal(`Error: either no player was specified or the player with \
|
||||
specified bus name does not exist/is not available!`);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
setPlayer(players.filter(pl => pl.busName === args[2])[0]);
|
||||
cmd.print_literal(`Done setting player to \`${args[2]}\`!`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cmd.printerr_literal("Error: couldn't handle media arguments, try checking `media help`");
|
||||
return 1;
|
||||
}
|
||||
|
||||
function handleWindowArgs(cmd: Gio.ApplicationCommandLine, args: Array<string>): number {
|
||||
switch(args[0]) {
|
||||
case "reopen":
|
||||
Windows.getDefault().reopen();
|
||||
cmd.print_literal("Reopening all open windows");
|
||||
return 0;
|
||||
|
||||
case "windows":
|
||||
cmd.print_literal(
|
||||
Object.keys(Windows.getDefault().windows).map(name =>
|
||||
`${name}: ${Windows.getDefault().isOpen(name) ?
|
||||
"open"
|
||||
: "closed"}`
|
||||
).join('\n')
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const specifiedWindow: string = args[1];
|
||||
|
||||
if(!specifiedWindow) {
|
||||
cmd.printerr_literal("Error: window argument not specified!");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if(!Windows.getDefault().hasWindow(specifiedWindow)) {
|
||||
cmd.printerr_literal(
|
||||
`Error: "${specifiedWindow}" not found on window list! Make sure to add new windows to the system before using them`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
switch(args[0]) {
|
||||
case "open":
|
||||
if(!Windows.getDefault().isOpen(specifiedWindow)) {
|
||||
Windows.getDefault().open(specifiedWindow);
|
||||
cmd.print_literal(`Opening window with name "${args[1]}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cmd.print_literal(`Window is already open, ignored`);
|
||||
return 0;
|
||||
|
||||
case "close":
|
||||
if(Windows.getDefault().isOpen(specifiedWindow)) {
|
||||
Windows.getDefault().close(specifiedWindow);
|
||||
cmd.print_literal(`Closing window with name "${args[1]}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cmd.print_literal(`Window is already closed, ignored`);
|
||||
return 0;
|
||||
|
||||
case "toggle":
|
||||
if(!Windows.getDefault().isOpen(specifiedWindow)) {
|
||||
Windows.getDefault().open(specifiedWindow);
|
||||
cmd.print_literal(`Toggle opening window "${args[1]}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
Windows.getDefault().close(specifiedWindow);
|
||||
cmd.print_literal(`Toggle closing window "${args[1]}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cmd.printerr_literal("Couldn't handle window management arguments");
|
||||
return 1;
|
||||
}
|
||||
|
||||
function handleVolumeArgs(cmd: Gio.ApplicationCommandLine, args: Array<string>): number {
|
||||
if(!args[1]) {
|
||||
cmd.printerr_literal(`Error: please specify what to do! see \`volume help\``);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if(/^(sink|source)[-](increase|decrease|set)$/.test(args[1]) && !args[2]) {
|
||||
cmd.printerr_literal(`Error: you forgot to set a value`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if(Number.isNaN(Number.parseFloat(args[2]))) {
|
||||
cmd.printerr_literal(`Error: argument "${args[2]} is not a valid number! Please use integers"`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const command: Array<string> = args[1].split('-');
|
||||
|
||||
if(/h|help/.test(args[1])) {
|
||||
cmd.print_literal(`
|
||||
Control speaker and microphone volumes
|
||||
Options:
|
||||
(sink|source)-set [number]: set speaker/microphone volume.
|
||||
(sink|source)-mute: toggle mute for the speaker/microphone device.
|
||||
(sink|source)-increase [number]: increases speaker/microphone volume.
|
||||
(sink|source)-decrease [number]: decreases speaker/microphone volume.
|
||||
`.trim());
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch(command[1]) {
|
||||
case "set":
|
||||
command[0] === "sink" ?
|
||||
Wireplumber.getDefault().setSinkVolume(Number.parseInt(args[2]))
|
||||
: Wireplumber.getDefault().setSourceVolume(Number.parseInt(args[2]))
|
||||
cmd.print_literal(`Done! Set ${command[0]} volume to ${args[2]}`);
|
||||
return 0;
|
||||
|
||||
case "mute":
|
||||
command[0] === "sink" ?
|
||||
Wireplumber.getDefault().toggleMuteSink()
|
||||
: Wireplumber.getDefault().toggleMuteSource()
|
||||
|
||||
cmd.print_literal(`Done toggling mute!`);
|
||||
return 0;
|
||||
|
||||
case "increase":
|
||||
command[0] === "sink" ?
|
||||
Wireplumber.getDefault().increaseSinkVolume(Number.parseInt(args[2]))
|
||||
: Wireplumber.getDefault().increaseSourceVolume(Number.parseInt(args[2]))
|
||||
|
||||
generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true &&
|
||||
playSystemBell();
|
||||
|
||||
cmd.print_literal(`Done increasing volume by ${args[2]}`);
|
||||
return 0;
|
||||
|
||||
case "decrease":
|
||||
command[0] === "sink" ?
|
||||
Wireplumber.getDefault().decreaseSinkVolume(Number.parseInt(args[2]))
|
||||
: Wireplumber.getDefault().decreaseSourceVolume(Number.parseInt(args[2]))
|
||||
|
||||
generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true &&
|
||||
playSystemBell();
|
||||
|
||||
cmd.print_literal(`Done decreasing volume to ${args[2]}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cmd.printerr_literal(`Error: couldn't resolve arguments! "${args.join(' ')
|
||||
.replace(new RegExp(`^${args[0]}`), "")}"`);
|
||||
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { execAsync, Gio, GLib, register } from "astal";
|
||||
import Polkit from "gi://Polkit";
|
||||
import PolkitAgent from "gi://PolkitAgent";
|
||||
import { EntryPopup, EntryPopupProps } from "../widget/EntryPopup";
|
||||
import AstalAuth from "gi://AstalAuth";
|
||||
import { AskPopup, AskPopupProps } from "../widget/AskPopup";
|
||||
|
||||
export { Auth };
|
||||
|
||||
@register({ GTypeName: "AuthAgent" })
|
||||
class Auth extends PolkitAgent.Listener {
|
||||
private static instance: Auth;
|
||||
#subject: Polkit.Subject;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#subject = Polkit.UnixSession.new(GLib.get_user_name());
|
||||
|
||||
this.register(PolkitAgent.RegisterFlags.NONE,
|
||||
this.#subject,
|
||||
"/io/github/retrozinndev/Colorshell/PolicyKit/AuthAgent",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
vfunc_dispose() {
|
||||
PolkitAgent.Listener.unregister();
|
||||
}
|
||||
|
||||
static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array<Polkit.Identity>, cancellable?: Gio.Cancellable, callback?: Gio.AsyncReadyCallback): void | Promise<boolean> {
|
||||
const authPopup = EntryPopup({
|
||||
title: "Authentication",
|
||||
text: message,
|
||||
isPassword: true,
|
||||
onFinish: callback,
|
||||
onCancel: () => cancellable?.cancel(),
|
||||
closeOnAccept: false,
|
||||
onAccept: (input: string) => {
|
||||
if(this.validatePasswd(input)) {
|
||||
authPopup.close();
|
||||
}
|
||||
AskPopup({
|
||||
|
||||
} as AskPopupProps)
|
||||
}
|
||||
} as EntryPopupProps);
|
||||
}
|
||||
|
||||
|
||||
public static initAgent(): Auth {
|
||||
if(!this.instance)
|
||||
this.instance = new Auth();
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private static validatePasswd(passwd: string): boolean {
|
||||
return AstalAuth.Pam.authenticate(passwd, null);
|
||||
}
|
||||
|
||||
/** @returns if successful, true, or else, false */
|
||||
public async polkitExecute(cmd: string | Array<string>): Promise<boolean> {
|
||||
let success: boolean = true;
|
||||
await execAsync([ "pkexec", "--", ...(Array.isArray(cmd) ?
|
||||
cmd as Array<string> : [ cmd as string ]) ]
|
||||
).catch((r) => {
|
||||
success = false;
|
||||
console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`);
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { exec, execAsync, GObject, monitorFile, readFileAsync, register, signal } from "astal";
|
||||
import { Connectable } from "astal/binding";
|
||||
|
||||
|
||||
/** !!TODO!! Needs more work and testing
|
||||
* I(retrozinndev) don't have a monitor that has software-controlled brightness :(
|
||||
*/
|
||||
@register({ GTypeName: "Brightness" })
|
||||
class Brightness extends GObject.Object implements Connectable {
|
||||
private readonly backlight: string|undefined;
|
||||
private max: number;
|
||||
private brightness: number;
|
||||
|
||||
@signal(Number)
|
||||
declare brightnessChanged: (value: number) => void;
|
||||
|
||||
constructor(backlightDevice?: string) {
|
||||
super();
|
||||
this.backlight = backlightDevice || "intel_backlight";
|
||||
this.max = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} max`))
|
||||
this.brightness = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} get`))
|
||||
|
||||
readFileAsync(`/sys/class/backlight/${backlightDevice}/brightness`).catch(() => {
|
||||
throw new Error(`Couldn't find backlight ${backlightDevice}`);
|
||||
});
|
||||
|
||||
monitorFile(`/sys/class/backlight/${backlightDevice}/brightness`, async () => {
|
||||
this.brightness = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} get`));
|
||||
this.max = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} max`));
|
||||
|
||||
this.emit("brightness-changed", this.brightness);
|
||||
});
|
||||
}
|
||||
|
||||
public setBrightness(newBrightness: number): void {
|
||||
execAsync(`brightnessctl -d ${this.backlight} set ${newBrightness || this.brightness}`).catch(() => {
|
||||
throw new Error(`Couldn't set brightness of backlight ${this.backlight}`);
|
||||
});
|
||||
|
||||
this.emit("brightness-changed", newBrightness);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import AstalIO from "gi://AstalIO";
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
|
||||
import GObject, { getter, register, signal } from "ags/gobject";
|
||||
import { timeout } from "ags/time";
|
||||
import { monitorFile, readFile } from "ags/file";
|
||||
import { execAsync } from "ags/process";
|
||||
|
||||
|
||||
interface ClipboardSignals extends GObject.Object.SignalSignatures {
|
||||
copied: Clipboard["copied"];
|
||||
wiped: Clipboard["wiped"];
|
||||
};
|
||||
|
||||
export enum ClipboardItemType {
|
||||
TEXT = 0,
|
||||
IMAGE = 1
|
||||
}
|
||||
|
||||
export class ClipboardItem {
|
||||
id: number;
|
||||
type: ClipboardItemType;
|
||||
preview: string;
|
||||
|
||||
constructor(id: number, type: ClipboardItemType, preview: string) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.preview = preview;
|
||||
}
|
||||
}
|
||||
|
||||
export { Clipboard };
|
||||
|
||||
/** Cliphist Manager and event listener
|
||||
* This only supports wipe and store events from cliphist */
|
||||
@register({ GTypeName: "Clipboard" })
|
||||
class Clipboard extends GObject.Object {
|
||||
private static instance: Clipboard;
|
||||
|
||||
declare $signals: ClipboardSignals;
|
||||
|
||||
#dbFile: Gio.File;
|
||||
#dbMonitor: Gio.FileMonitor;
|
||||
#updateDone: boolean = false;
|
||||
#history = new Array<ClipboardItem>;
|
||||
#changesTimeout: (AstalIO.Time|undefined);
|
||||
#ignoreChanges: boolean = false;
|
||||
|
||||
@signal(GObject.TYPE_JSOBJECT) copied(_item: object) {}
|
||||
@signal() wiped() {};
|
||||
|
||||
|
||||
@getter(Array)
|
||||
public get history() { return this.#history; }
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.#dbFile = this.getCliphistDatabase();
|
||||
|
||||
this.#dbMonitor = monitorFile(this.#dbFile.get_path()!, () => {
|
||||
if(this.#ignoreChanges || this.#changesTimeout)
|
||||
return;
|
||||
|
||||
this.#changesTimeout = timeout(300, () => this.#changesTimeout = undefined);
|
||||
|
||||
if(this.#updateDone) {
|
||||
this.updateDatabase();
|
||||
return;
|
||||
}
|
||||
|
||||
this.init();
|
||||
});
|
||||
|
||||
if(this.#dbFile.query_exists(null)) {
|
||||
this.init();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Clipboard: cliphist database not found. Try copying something first!");
|
||||
}
|
||||
|
||||
vfunc_dispose(): void {
|
||||
this.#dbMonitor.cancel();
|
||||
this.#dbMonitor.unref();
|
||||
}
|
||||
|
||||
private init() {
|
||||
console.log("Clipboard: Starting to read cliphist history...");
|
||||
|
||||
this.updateDatabase().then(() => {
|
||||
console.log("Clipboard: Done reading cliphist history!");
|
||||
}).catch((err) =>
|
||||
console.error(`Clipboard: An error occurred while reading cliphist history. Stderr: ${err}`)
|
||||
);
|
||||
}
|
||||
|
||||
public async copyAsync(content: string): Promise<void> {
|
||||
await execAsync(`wl-copy "${content}"`).catch((err: Gio.IOErrorEnum) => {
|
||||
console.error(`Clipboard: Couldn't copy text using wl-copy. Stderr:\n\t${err.message
|
||||
} | Stack:\n\t\t${err.stack}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async selectItem(itemToSelect: number|ClipboardItem): Promise<boolean> {
|
||||
const item = await this.getItemContent(itemToSelect);
|
||||
let res: boolean = true;
|
||||
|
||||
if(item)
|
||||
await this.copyAsync(item).catch(() => res = false);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Gets history item's content by its ID.
|
||||
* @returns the clipboard item's content */
|
||||
public async getItemContent(item: number|ClipboardItem): Promise<string|undefined> {
|
||||
const id = (typeof item === "number") ?
|
||||
item : item.id;
|
||||
|
||||
const cmd = Gio.Subprocess.new([ "cliphist", "decode", id.toString() ],
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
|
||||
|
||||
const [ , stdout, stderr ] = cmd.communicate_utf8(null, null);
|
||||
|
||||
if(stderr) {
|
||||
console.error(`Clipboard: An error occurred while getting item content. Stderr:\n${stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** Searches for the cliphist database file
|
||||
* Will not work if cliphist config file is not on default path */
|
||||
private getCliphistDatabase(): Gio.File {
|
||||
// Check if env variable is set
|
||||
const path = GLib.getenv("CLIPHIST_DB_PATH");
|
||||
if(path != null)
|
||||
return Gio.File.new_for_path(path);
|
||||
|
||||
// Check config file
|
||||
const confFile = Gio.File.new_for_path(`${GLib.get_user_config_dir()}/cliphist/config`);
|
||||
if(confFile.query_exists(null)) {
|
||||
const cliphistConf = readFile(confFile.get_path()!);
|
||||
for(const line of cliphistConf.split('\n').map(l => l.trim())) {
|
||||
if(line.startsWith('#'))
|
||||
continue;
|
||||
|
||||
const [ key, value ] = line.split('\s', 1);
|
||||
if(key === "db-path") {
|
||||
return Gio.File.new_for_path(value.trimStart());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return default path if none of the above matches
|
||||
return Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/cliphist/db`);
|
||||
}
|
||||
|
||||
private getContentType(preview: string): ClipboardItemType {
|
||||
return /^\[\[.*binary data.*x.*\]\]$/u.test(preview) ?
|
||||
ClipboardItemType.IMAGE
|
||||
: ClipboardItemType.TEXT;
|
||||
}
|
||||
|
||||
public async wipeHistory(noExec?: boolean): Promise<void> {
|
||||
if(noExec) {
|
||||
this.#history = [];
|
||||
this.emit("wiped");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#ignoreChanges = true;
|
||||
await execAsync("cliphist wipe").then(() => {
|
||||
this.#history = [];
|
||||
this.emit("wiped");
|
||||
}).catch((err: Gio.IOErrorEnum) =>
|
||||
console.error(`Clipboard: An error occurred on cliphist database wipe. Stderr: ${
|
||||
err.message ? `${err.message}\n` : ""}${err.stack}`)
|
||||
).finally(() => this.#ignoreChanges = false);
|
||||
}
|
||||
|
||||
public async updateDatabase(): Promise<void> {
|
||||
const proc = Gio.Subprocess.new([ "cliphist", "list" ],
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
|
||||
|
||||
proc.communicate_utf8_async(null, null, (_, asyncRes) => {
|
||||
const [ success, stdout, stderr ] = proc.communicate_utf8_finish(asyncRes);
|
||||
|
||||
if(!success || stderr) {
|
||||
console.error("Clipboard: Couldn't communicate with cliphist! Is it installed?");
|
||||
return;
|
||||
}
|
||||
|
||||
if(!stdout.trim()) {
|
||||
this.wipeHistory(true);
|
||||
this.notify("history");
|
||||
return;
|
||||
}
|
||||
|
||||
const items = stdout.split('\n');
|
||||
|
||||
if(this.#updateDone) {
|
||||
const [ id, preview ] = items[0].split('\t');
|
||||
const clipItem = {
|
||||
id: Number.parseInt(id),
|
||||
preview,
|
||||
type: this.getContentType(preview)
|
||||
} as ClipboardItem;
|
||||
|
||||
this.#history.unshift(clipItem);
|
||||
|
||||
this.emit("copied", clipItem);
|
||||
this.notify("history");
|
||||
return;
|
||||
}
|
||||
|
||||
for(const item of items) {
|
||||
if(!item) continue;
|
||||
|
||||
const [ id, preview ] = item.split('\t');
|
||||
|
||||
const clipItem = {
|
||||
id: Number.parseInt(id),
|
||||
preview,
|
||||
type: this.getContentType(preview)
|
||||
} as ClipboardItem;
|
||||
|
||||
this.#history.push(clipItem);
|
||||
|
||||
this.emit("copied", clipItem);
|
||||
this.notify("history");
|
||||
}
|
||||
|
||||
this.#updateDone = true;
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public static getDefault(): Clipboard {
|
||||
if(!this.instance)
|
||||
this.instance = new Clipboard();
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
|
||||
import GObject, { getter, property, register } from "ags/gobject";
|
||||
|
||||
|
||||
/** WIP Global implementation of a system that supports
|
||||
* a variety of Wayland Compositors */
|
||||
export namespace Compositor {
|
||||
|
||||
let instance: _Compositor;
|
||||
|
||||
@register({ GTypeName: "CompositorMonitor" })
|
||||
class _CompositorMonitor extends GObject.Object {
|
||||
public readonly width: number;
|
||||
public readonly height: number;
|
||||
|
||||
@property(Boolean)
|
||||
public readonly mirror: boolean;
|
||||
|
||||
constructor(width: number, height: number, mirror: boolean = false) {
|
||||
super();
|
||||
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.mirror = mirror;
|
||||
}
|
||||
}
|
||||
|
||||
@register({ GTypeName: "CompositorWorkspace" })
|
||||
class _CompositorWorkspace extends GObject.Object {
|
||||
public readonly id: number;
|
||||
|
||||
@getter(_CompositorMonitor)
|
||||
public readonly monitor: _CompositorMonitor;
|
||||
|
||||
constructor(monitor: _CompositorMonitor, id: number) {
|
||||
super();
|
||||
|
||||
this.monitor = monitor;
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
@register({ GTypeName: "Compositor" })
|
||||
class _Compositor extends GObject.Object {
|
||||
#workspaces: Array<_CompositorWorkspace> = [];
|
||||
|
||||
@property()
|
||||
public get workspaces() { return this.#workspaces; }
|
||||
};
|
||||
|
||||
|
||||
export function getDefault(): _Compositor {
|
||||
if(!instance)
|
||||
instance = new _Compositor();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
export const Compositor = _Compositor,
|
||||
CompositorWorkspace = _CompositorWorkspace,
|
||||
CompositorMonitor = _CompositorMonitor;
|
||||
|
||||
/** Uses the XDG_CURRENT_DESKTOP variable to detect running compositor's name.
|
||||
* ---
|
||||
* @returns running wayland compositor's name (lowercase) or `undefined` if variable's not set */
|
||||
export function getName(): string|undefined {
|
||||
return GLib.getenv("XDG_CURRENT_DESKTOP") ?? undefined;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { timeout } from "ags/time";
|
||||
import { monitorFile, readFileAsync } from "ags/file";
|
||||
import { Notifications } from "./notifications";
|
||||
import { encoder } from "./utils";
|
||||
import { Accessor } from "ags";
|
||||
import GObject, { getter, register } from "ags/gobject";
|
||||
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
import AstalIO from "gi://AstalIO";
|
||||
import AstalNotifd from "gi://AstalNotifd";
|
||||
|
||||
|
||||
export { Config };
|
||||
type ValueTypes = "string" | "boolean" | "object" | "number" | "undefined" | "any";
|
||||
|
||||
@register({ GTypeName: "Config" })
|
||||
class Config<K extends NonNullable<string|number|symbol>, V extends string|object|any> extends GObject.Object {
|
||||
declare $signals: GObject.Object.SignalSignatures & {
|
||||
"notify::entries": (entries: Record<K, V>) => void;
|
||||
};
|
||||
|
||||
/** unmodified object with default entries. User-values are stored
|
||||
* in the `entries` field */
|
||||
public readonly defaults: Record<K, V>;
|
||||
|
||||
@getter(Object)
|
||||
public get entries(): object { return this.#entries; }
|
||||
|
||||
#file: Gio.File;
|
||||
#entries: Record<K, V>;
|
||||
|
||||
private timeout: (AstalIO.Time|boolean|undefined);
|
||||
public get file() { return this.#file; };
|
||||
|
||||
constructor(filePath: Gio.File|string, defaults?: Record<K, V>) {
|
||||
super();
|
||||
|
||||
this.defaults = (defaults ?? {}) as Record<K, V>;
|
||||
this.#entries = { ...defaults } as Record<K, V>;
|
||||
|
||||
this.#file = (typeof filePath === "string") ?
|
||||
Gio.File.new_for_path(filePath)
|
||||
: filePath;
|
||||
|
||||
if(!this.#file.query_exists(null)) {
|
||||
this.#file.make_directory_with_parents(null);
|
||||
this.#file.delete(null);
|
||||
|
||||
this.#file.create_readwrite_async(
|
||||
Gio.FileCreateFlags.NONE, GLib.PRIORITY_DEFAULT,
|
||||
null, (_, asyncRes) => {
|
||||
const ioStream = this.#file.create_readwrite_finish(asyncRes);
|
||||
|
||||
ioStream.outputStream.write_bytes_async(
|
||||
GLib.Bytes.new(encoder.encode(JSON.stringify(this.entries, undefined, 4))),
|
||||
GLib.PRIORITY_DEFAULT, null,
|
||||
(_, asyncRes) => {
|
||||
const writtenBytes = ioStream.outputStream.write_bytes_finish(asyncRes);
|
||||
|
||||
if(!writtenBytes)
|
||||
Notifications.getDefault().sendNotification({
|
||||
appName: "colorshell",
|
||||
summary: "Write error",
|
||||
body: `Couldn't write default configuration file to "${this.#file.get_path()!}"`
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
monitorFile(this.#file.get_path()!,
|
||||
() => {
|
||||
if(this.timeout) return;
|
||||
this.timeout = timeout(500, () => this.timeout = undefined);
|
||||
|
||||
if(this.#file.query_exists(null)) {
|
||||
this.timeout?.cancel();
|
||||
this.timeout = true;
|
||||
|
||||
this.readFile().finally(() =>
|
||||
this.timeout = undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notifications.getDefault().sendNotification({
|
||||
appName: "colorshell",
|
||||
summary: "Config error",
|
||||
body: `Could not hot-reload configuration: config file not found in \`${this.#file.get_path()!}\`, last valid configuration is being used. Maybe it got deleted?`
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async readFile(): Promise<void> {
|
||||
await readFileAsync(this.#file.get_path()!).then((content) => {
|
||||
let config: (Record<K, V>|undefined);
|
||||
|
||||
try {
|
||||
config = JSON.parse(content) as Record<K, V>;
|
||||
} catch(e) {
|
||||
Notifications.getDefault().sendNotification({
|
||||
urgency: AstalNotifd.Urgency.NORMAL,
|
||||
appName: "colorshell",
|
||||
summary: "Config parsing error",
|
||||
body: `An error occurred while parsing colorshell's config file: \nFile: ${
|
||||
this.#file.get_path()!}\n${
|
||||
(e as SyntaxError).message}\n${(e as SyntaxError).stack}`
|
||||
});
|
||||
}
|
||||
|
||||
if(!config) return;
|
||||
|
||||
|
||||
// only change valid entries that are available in the defaults (with 1 of depth)
|
||||
for(const k of Object.keys(this.entries)) {
|
||||
if(config[k as keyof typeof config] === undefined)
|
||||
return;
|
||||
|
||||
// TODO needs more work, like object-recursive(infinite depth) entry attributions
|
||||
this.#entries[k as keyof Record<K, V>] = config[k as keyof typeof config];
|
||||
}
|
||||
|
||||
this.notify("entries");
|
||||
}).catch((e: Gio.IOErrorEnum) => {
|
||||
Notifications.getDefault().sendNotification({
|
||||
urgency: AstalNotifd.Urgency.NORMAL,
|
||||
appName: "colorshell",
|
||||
summary: "Config read error",
|
||||
body: `An error occurred while reading colorshell's config file: \nFile: ${`${
|
||||
this.#file.get_path()!}\n${e.message ? `${e.message}\n` : ""}${e.stack}`.replace(/[<>]/g, "\\&")}`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public bindProperty(propertyPath: string, expectType?: ValueTypes): Accessor<any|undefined> {
|
||||
return new Accessor<Record<K, V>>(() => this.getProperty(propertyPath, expectType), (callback: () => void) => {
|
||||
const id = this.connect("notify::entries", () => callback());
|
||||
return () => this.disconnect(id);
|
||||
});
|
||||
}
|
||||
|
||||
public getProperty(path: string, expectType?: ValueTypes): (any|undefined) {
|
||||
return this._getProperty(path, this.#entries, expectType);
|
||||
}
|
||||
|
||||
public getPropertyDefault(path: string, expectType?: ValueTypes): (any|undefined) {
|
||||
return this._getProperty(path, this.defaults, expectType);
|
||||
}
|
||||
|
||||
private _getProperty(path: string, entries: Record<K, V>, expectType?: ValueTypes): (any|undefined) {
|
||||
let property: any = entries;
|
||||
const pathArray = path.split('.').filter(str => str);
|
||||
|
||||
for(let i = 0; i < pathArray.length; i++) {
|
||||
const currentPath = pathArray[i];
|
||||
|
||||
property = property[currentPath as keyof typeof property];
|
||||
}
|
||||
|
||||
if(expectType !== "any" && typeof property !== expectType) {
|
||||
console.error(`Config: property with path \`${path
|
||||
}\` is either \`undefined\` or not in the expected value type \`${expectType
|
||||
}\`, returning default value`);
|
||||
|
||||
property = this.defaults;
|
||||
|
||||
for(let i = 0; i < pathArray.length; i++) {
|
||||
const currentPath = pathArray[i];
|
||||
|
||||
property = property[currentPath as keyof typeof property];
|
||||
}
|
||||
}
|
||||
|
||||
if(expectType !== "any" && typeof property !== expectType) {
|
||||
console.error(`Config: property with path \`${path}\` not found in defaults/user-entries, returning \`undefined\``);
|
||||
property = undefined;
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createRoot, createState, onCleanup } from "ags";
|
||||
|
||||
import GObject from "ags/gobject";
|
||||
import AstalMpris from "gi://AstalMpris";
|
||||
|
||||
|
||||
export const dummyPlayer = {
|
||||
available: false,
|
||||
busName: "dummy_player",
|
||||
bus_name: "dummy_player"
|
||||
} as AstalMpris.Player;
|
||||
|
||||
export let [player, setPlayer] = createState(dummyPlayer);
|
||||
|
||||
let disposeFun: undefined|(() => void);
|
||||
|
||||
export function initPlayer(): void {
|
||||
if(disposeFun) {
|
||||
console.error("Media: cannot initialize, there's already an instance");
|
||||
return;
|
||||
}
|
||||
|
||||
createRoot((dispose) => {
|
||||
const connections = new Map<GObject.Object, Array<number>>();
|
||||
disposeFun = dispose;
|
||||
|
||||
if(AstalMpris.get_default().players)
|
||||
setPlayer(AstalMpris.get_default().players[0]);
|
||||
|
||||
connections.set(AstalMpris.get_default(), [
|
||||
AstalMpris.get_default().connect("player-added", (_, player) =>
|
||||
player.available && setPlayer(player)),
|
||||
|
||||
AstalMpris.get_default().connect("player-closed", (_, closedPlayer) => {
|
||||
const players = AstalMpris.get_default().players.filter(pl => pl?.available &&
|
||||
pl.busName !== closedPlayer.busName);
|
||||
|
||||
if(players.length > 0 && players[0]) {
|
||||
setPlayer(players[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPlayer(dummyPlayer);
|
||||
})
|
||||
]);
|
||||
|
||||
onCleanup(() => {
|
||||
connections.forEach((ids, obj) =>
|
||||
Array.isArray(ids) ?
|
||||
ids.forEach(id => obj.disconnect(id))
|
||||
: obj.disconnect(ids)
|
||||
);
|
||||
disposeFun = undefined;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function disposePlayer(): void {
|
||||
if(disposeFun) {
|
||||
disposeFun();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Media: Couldn't dispose player, there's no instance to dispose of");
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import AstalIO from "gi://AstalIO";
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
|
||||
import GObject, { getter, register } from "ags/gobject";
|
||||
import { execAsync, exec } from "ags/process";
|
||||
import { interval } from "ags/time";
|
||||
|
||||
export { NightLight };
|
||||
|
||||
@register({ GTypeName: "NightLight" })
|
||||
class NightLight extends GObject.Object {
|
||||
private static instance: NightLight;
|
||||
|
||||
#watchInterval: (AstalIO.Time|null) = null;
|
||||
#temperature: number = 4500;
|
||||
#gamma: number = 100;
|
||||
#identity: boolean = false;
|
||||
|
||||
#prevTemperature: (number|null) = null;
|
||||
#prevGamma: (number|null) = null;
|
||||
|
||||
@getter(Number)
|
||||
public get temperature() { return this.#temperature; }
|
||||
public set temperature(newValue: number) { this.setTemperature(newValue); }
|
||||
|
||||
@getter(Number)
|
||||
public get gamma() { return this.#gamma; }
|
||||
public set gamma(newValue: number) { this.setGamma(newValue); }
|
||||
|
||||
public readonly maxTemperature = 20000;
|
||||
public readonly minTemperature = 1000;
|
||||
public readonly identityTemperature = 6000;
|
||||
public readonly maxGamma = 100;
|
||||
|
||||
@getter(Boolean)
|
||||
public get identity() { return this.#identity; }
|
||||
public set identity(newValue: boolean) {
|
||||
newValue ? this.applyIdentity() : this.filter();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.#watchInterval = interval(1000, () => {
|
||||
execAsync("hyprctl hyprsunset temperature").then(t => {
|
||||
if(t.trim() !== "" && t.trim().length <= 5) {
|
||||
const val = Number.parseInt(t.trim());
|
||||
|
||||
if(this.#temperature !== val) {
|
||||
this.#temperature = val;
|
||||
this.notify("temperature");
|
||||
}
|
||||
}
|
||||
}).catch((r) => console.error(r));
|
||||
|
||||
execAsync("hyprctl hyprsunset gamma").then(g => {
|
||||
if(g.trim() !== "" && g.trim().length <= 5) {
|
||||
const val = Number.parseInt(g.trim());
|
||||
|
||||
if(this.#gamma !== val) {
|
||||
this.#gamma = val;
|
||||
this.notify("gamma");
|
||||
}
|
||||
}
|
||||
}).catch((r) => console.error(r));
|
||||
});
|
||||
|
||||
this.vfunc_dispose = () => this.#watchInterval &&
|
||||
this.#watchInterval.cancel();
|
||||
}
|
||||
|
||||
public static getDefault(): NightLight {
|
||||
if(!this.instance)
|
||||
this.instance = new NightLight();
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private setTemperature(value: number): void {
|
||||
if(value === this.temperature) return;
|
||||
|
||||
if(value > this.maxTemperature || value < 1000) {
|
||||
console.error(`Night Light(hyprsunset): provided temperatue ${value
|
||||
} is out of bounds (min: 1000; max: ${this.maxTemperature})`);
|
||||
return;
|
||||
}
|
||||
|
||||
execAsync(`hyprctl hyprsunset temperature ${value}`).then(() => {
|
||||
this.#temperature = value;
|
||||
this.notify("temperature");
|
||||
|
||||
this.#identity = false;
|
||||
this.#prevTemperature = null;
|
||||
this.#prevGamma = null;
|
||||
}).catch((r) => console.error(
|
||||
`Night Light(hyprsunset): Couldn't set temperature. Stderr: ${r}`
|
||||
));
|
||||
}
|
||||
|
||||
private setGamma(value: number): void {
|
||||
if(value === this.gamma) return;
|
||||
|
||||
if(value > this.maxGamma || value < 0) {
|
||||
console.error(`Night Light(hyprsunset): provided gamma ${value
|
||||
} is out of bounds (min: 0; max: ${this.maxTemperature})`);
|
||||
return;
|
||||
}
|
||||
|
||||
execAsync(`hyprctl hyprsunset gamma ${value}`).then(() => {
|
||||
this.#gamma = value;
|
||||
this.notify("gamma");
|
||||
|
||||
this.#identity = false;
|
||||
this.#prevTemperature = null;
|
||||
this.#prevGamma = null;
|
||||
}).catch((r) => console.error(
|
||||
`Night Light(hyprsunset): Couldn't set gamma. Stderr: ${r}`
|
||||
));
|
||||
}
|
||||
|
||||
public applyIdentity(): void {
|
||||
if(this.#identity) return;
|
||||
|
||||
this.#prevGamma = this.#gamma;
|
||||
this.#prevTemperature = this.#temperature;
|
||||
|
||||
this.#identity = true;
|
||||
this.temperature = this.identityTemperature;
|
||||
this.gamma = this.maxGamma;
|
||||
}
|
||||
|
||||
public filter(): void {
|
||||
if(!this.#identity) return;
|
||||
|
||||
this.#identity = false;
|
||||
this.setTemperature(this.#prevTemperature ?? this.identityTemperature);
|
||||
this.setGamma(this.#prevGamma ?? this.maxGamma);
|
||||
|
||||
this.#prevTemperature = null;
|
||||
this.#prevGamma = null;
|
||||
}
|
||||
|
||||
public saveData(): void {
|
||||
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/save-hyprsunset.sh`);
|
||||
}
|
||||
|
||||
public loadData(): void {
|
||||
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/load-hyprsunset.sh`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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 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 {
|
||||
id: number;
|
||||
appName: string;
|
||||
body: string;
|
||||
summary: string;
|
||||
urgency: AstalNotifd.Urgency;
|
||||
appIcon?: string;
|
||||
time: number;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
@register({ GTypeName: "Notifications" })
|
||||
class Notifications extends GObject.Object {
|
||||
private static instance: (Notifications|null) = null;
|
||||
|
||||
#notifications: Array<AstalNotifd.Notification> = [];
|
||||
#history: Array<HistoryNotification> = [];
|
||||
#notificationsOnHold: Set<number> = new Set<number>();
|
||||
#connections: Array<number> = [];
|
||||
|
||||
@getter(Array<AstalNotifd.Notification>)
|
||||
public get notifications() { return this.#notifications };
|
||||
|
||||
@getter(Array<HistoryNotification>)
|
||||
public get history() { return this.#history };
|
||||
|
||||
@property(Number)
|
||||
public historyLimit: number = 10;
|
||||
|
||||
|
||||
@signal(AstalNotifd.Notification) notificationAdded(_notification: AstalNotifd.Notification) {};
|
||||
@signal(Number) notificationRemoved(_id: number) {};
|
||||
@signal(Object) historyAdded(_notification: Object) {};
|
||||
@signal(Number) historyRemoved(_id: number) {};
|
||||
@signal(Number) notificationReplaced(_id: number) {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
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) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => {
|
||||
this.removeNotification(id);
|
||||
this.addHistory(notifd.get_notification(id));
|
||||
})
|
||||
);
|
||||
|
||||
this.retrieveHistoryFromFile();
|
||||
|
||||
onCleanup(() => {
|
||||
this.#connections.map(id =>
|
||||
AstalNotifd.get_default().disconnect(id));
|
||||
});
|
||||
}
|
||||
|
||||
public static getDefault(): Notifications {
|
||||
if(!this.instance)
|
||||
this.instance = new Notifications();
|
||||
|
||||
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<AstalNotifd.Notification>).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;
|
||||
image?: string;
|
||||
summary: string;
|
||||
body?: string;
|
||||
replaceId?: number;
|
||||
actions?: Array<{
|
||||
id?: (string|number);
|
||||
text: string;
|
||||
onAction?: () => void
|
||||
}>
|
||||
}): Promise<{
|
||||
id?: (string|number);
|
||||
text: string;
|
||||
onAction?: () => void
|
||||
}|null|void> {
|
||||
|
||||
return await execAsync([
|
||||
"notify-send",
|
||||
...(props.urgency ? [
|
||||
"-u", this.getUrgencyString(props.urgency)
|
||||
] : []), ...(props.appName ? [
|
||||
"-a", props.appName
|
||||
] : []), ...(props.image ? [
|
||||
"-i", props.image
|
||||
] : []), ...(props.actions ? props.actions.map((action) =>
|
||||
[ "-A", action.text ]
|
||||
).flat(2) : []), ...(props.replaceId ? [
|
||||
"-r", props.replaceId.toString()
|
||||
] : []), props.summary, props.body ? props.body : ""
|
||||
]).then((stdout) => {
|
||||
stdout = stdout.trim();
|
||||
if(!stdout) {
|
||||
if(props.actions && props.actions.length > 0)
|
||||
return null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(props.actions && props.actions.length > 0) {
|
||||
const action = props.actions[Number.parseInt(stdout)];
|
||||
action?.onAction?.();
|
||||
|
||||
return action ?? undefined;
|
||||
}
|
||||
}).catch((err: Error) => {
|
||||
console.error(`Notifications: Couldn't send notification! Is the daemon running? Stderr:\n${
|
||||
err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`);
|
||||
});
|
||||
}
|
||||
|
||||
public getUrgencyString(urgency: AstalNotifd.Notification|AstalNotifd.Urgency) {
|
||||
switch((urgency instanceof AstalNotifd.Notification) ?
|
||||
urgency.urgency : urgency) {
|
||||
|
||||
case AstalNotifd.Urgency.LOW:
|
||||
return "low";
|
||||
case AstalNotifd.Urgency.CRITICAL:
|
||||
return "critical";
|
||||
}
|
||||
|
||||
return "normal";
|
||||
}
|
||||
|
||||
private addHistory(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void {
|
||||
if(!notif) return;
|
||||
|
||||
this.#history.length === this.historyLimit &&
|
||||
this.removeHistory(this.#history[this.#history.length - 1]);
|
||||
|
||||
this.#history.map((notifb, i) =>
|
||||
notifb.id === notif.id && this.#history.splice(i, 1));
|
||||
|
||||
this.#history.unshift({
|
||||
id: notif.id,
|
||||
appName: notif.app_name,
|
||||
body: notif.body,
|
||||
summary: notif.summary,
|
||||
urgency: notif.urgency,
|
||||
appIcon: notif.app_icon,
|
||||
time: notif.time,
|
||||
image: notif.image ? notif.image : undefined
|
||||
} as HistoryNotification);
|
||||
|
||||
this.notify("history");
|
||||
this.emit("history-added", this.#history[0]);
|
||||
onAdded && onAdded(notif);
|
||||
}
|
||||
|
||||
public async clearHistory(): Promise<void> {
|
||||
this.#history.reverse().map((notif) => {
|
||||
this.#history = this.history.filter((n) => n.id !== notif.id);
|
||||
this.emit("history-removed", notif.id);
|
||||
});
|
||||
|
||||
this.notify("history");
|
||||
}
|
||||
|
||||
public removeHistory(notif: (HistoryNotification|number)): void {
|
||||
const notifId = (typeof notif === "number") ? notif : notif.id;
|
||||
this.#history = this.#history.filter((item: HistoryNotification) =>
|
||||
item.id !== notifId);
|
||||
|
||||
this.notify("history");
|
||||
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];
|
||||
|
||||
if(item.id !== notif.id) continue;
|
||||
|
||||
this.#notifications.splice(i, 1);
|
||||
this.emit("notification-replaced", item.id);
|
||||
break;
|
||||
}
|
||||
|
||||
this.#notifications.unshift(notif);
|
||||
this.notify("notifications");
|
||||
this.emit("notification-added", notif);
|
||||
onAdded?.(notif);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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)!
|
||||
: notif;
|
||||
|
||||
if(!notif) return;
|
||||
|
||||
this.#notificationsOnHold.add(notif.id);
|
||||
}
|
||||
|
||||
public toggleDoNotDisturb(value?: boolean): boolean {
|
||||
value = value ?? !AstalNotifd.get_default().dontDisturb;
|
||||
AstalNotifd.get_default().dontDisturb = value;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public getNotifd(): AstalNotifd.Notifd { return AstalNotifd.get_default(); }
|
||||
}
|
||||
|
||||
export { Notifications };
|
||||
@@ -0,0 +1,158 @@
|
||||
import { execAsync } from "ags/process";
|
||||
import { getter, register, signal } from "ags/gobject";
|
||||
import { Gdk } from "ags/gtk4";
|
||||
import { makeDirectory } from "./utils";
|
||||
import { Notifications } from "./notifications";
|
||||
import { time } from "./utils";
|
||||
|
||||
import GObject from "ags/gobject";
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
|
||||
|
||||
export { Recording };
|
||||
|
||||
@register({ GTypeName: "Recording" })
|
||||
class Recording extends GObject.Object {
|
||||
private static instance: Recording;
|
||||
|
||||
@signal() started() {};
|
||||
@signal() stopped() {};
|
||||
|
||||
#recording: boolean = false;
|
||||
#path: string = "~/Recordings";
|
||||
|
||||
/** Default extension: mp4(h264) */
|
||||
#extension: string = "mp4";
|
||||
#recordAudio: boolean = false;
|
||||
#area: (Gdk.Rectangle|null) = null;
|
||||
#startedAt: number = -1;
|
||||
#process: (Gio.Subprocess|null) = null;
|
||||
#output: (string|null) = null;
|
||||
|
||||
/** GLib.DateTime of when recording started
|
||||
* its value can be `-1` if undefined(no recording is happening) */
|
||||
@getter(Number)
|
||||
public get startedAt() { return this.#startedAt; }
|
||||
|
||||
@getter(Boolean)
|
||||
public get recording() { return this.#recording; }
|
||||
private set recording(newValue: boolean) {
|
||||
(!newValue && this.#recording) ?
|
||||
this.stopRecording()
|
||||
: this.startRecording(this.#area || undefined);
|
||||
|
||||
this.#recording = newValue;
|
||||
this.notify("recording");
|
||||
}
|
||||
|
||||
@getter(String)
|
||||
public get path() { return this.#path; }
|
||||
public set path(newPath: string) {
|
||||
if(this.recording) return;
|
||||
|
||||
this.#path = newPath;
|
||||
this.notify("path");
|
||||
}
|
||||
|
||||
@getter(String)
|
||||
public get extension() { return this.#extension; }
|
||||
public set extension(newExt: string) {
|
||||
if(this.recording) return;
|
||||
|
||||
this.#extension = newExt;
|
||||
this.notify("extension");
|
||||
}
|
||||
|
||||
/** Recording output file name. %NULL if screen is not being recorded */
|
||||
public get output() { return this.#output; }
|
||||
|
||||
/** Currently unsupported property */
|
||||
public get recordAudio() { return this.#recordAudio; }
|
||||
public set recordAudio(newValue: boolean) {
|
||||
if(this.recording) return;
|
||||
|
||||
this.#recordAudio = newValue;
|
||||
this.notify("record-audio");
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const videosDir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_VIDEOS);
|
||||
if(videosDir) this.#path = `${videosDir}/Recordings`;
|
||||
}
|
||||
|
||||
public static getDefault() {
|
||||
if(!this.instance)
|
||||
this.instance = new Recording();
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
public startRecording(area?: Gdk.Rectangle) {
|
||||
if(this.recording)
|
||||
throw new Error("Screen Recording is already running!");
|
||||
|
||||
this.#output = `${time.get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension || "mp4"}`;
|
||||
this.#recording = true;
|
||||
this.notify("recording");
|
||||
this.emit("started");
|
||||
makeDirectory(this.path);
|
||||
|
||||
const cancellable = Gio.Cancellable.new();
|
||||
cancellable.cancel = () => {};
|
||||
|
||||
const areaString = `${area?.x ?? 0},${area?.y ?? 0} ${area?.width ?? 1}x${area?.height ?? 1}`;
|
||||
|
||||
this.#process = Gio.Subprocess.new([
|
||||
"wf-recorder",
|
||||
...(area ? [ `-g`, areaString ] : []),
|
||||
"-f",
|
||||
`${this.path}/${this.output!}`
|
||||
], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
|
||||
|
||||
this.#process.wait_async(cancellable, () => {
|
||||
this.stopRecording();
|
||||
});
|
||||
|
||||
this.#startedAt = time.get().to_unix();
|
||||
}
|
||||
|
||||
public stopRecording() {
|
||||
if(!this.#process) return;
|
||||
|
||||
!this.#process.get_if_exited() && execAsync([
|
||||
"kill", "-s", "SIGTERM", this.#process.get_identifier()!
|
||||
]);
|
||||
|
||||
const path = this.#path;
|
||||
const output = this.#output;
|
||||
|
||||
this.#process = null;
|
||||
this.#recording = false;
|
||||
this.#startedAt = -1;
|
||||
this.#output = null;
|
||||
this.notify("recording");
|
||||
this.emit("stopped");
|
||||
|
||||
Notifications.getDefault().sendNotification({
|
||||
actions: [
|
||||
{
|
||||
text: "View",
|
||||
onAction: () => {
|
||||
execAsync(["nautilus", "-s", output!, path]);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: "Open",
|
||||
onAction: () => {
|
||||
execAsync(["xdg-open", `${path}/${output}`]);
|
||||
}
|
||||
}
|
||||
],
|
||||
appName: "Screen Recording",
|
||||
summary: "Screen Recording saved",
|
||||
body: `Saved as ${path}/${output}`
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { uwsmIsActive } from "./apps";
|
||||
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
import { Shell } from "../app";
|
||||
|
||||
|
||||
export function restartInstance(): void {
|
||||
Gio.Subprocess.new(
|
||||
( uwsmIsActive ?
|
||||
[ "uwsm", "app", "--", "colorshell" ]
|
||||
: [ "colorshell" ]),
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
|
||||
);
|
||||
Shell.getDefault().quit();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { monitorFile, readFile, writeFileAsync } from "ags/file";
|
||||
import { decoder } from "./utils";
|
||||
import { execAsync } from "ags/process";
|
||||
import { Wallpaper } from "./wallpaper";
|
||||
import { Shell } from "../app";
|
||||
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
|
||||
|
||||
/** handles stylesheet compiling and reloading */
|
||||
export class Stylesheet {
|
||||
private static instance: Stylesheet;
|
||||
#outputPath = Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/colorshell/style`);
|
||||
#stylesPaths: Array<string>;
|
||||
readonly #sassStyles = {
|
||||
modules: ["sass:color"].map(mod => `@use "${mod}";`).join('\n'),
|
||||
colors: "",
|
||||
mixins: "",
|
||||
rules: ""
|
||||
};
|
||||
public get stylePath() { return this.#outputPath.get_path()!; }
|
||||
|
||||
|
||||
public static getDefault(): Stylesheet {
|
||||
if(!this.instance)
|
||||
this.instance = new Stylesheet();
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private bundle(): string {
|
||||
return `${this.#sassStyles.modules}\n\n${this.#sassStyles.colors
|
||||
}\n${this.#sassStyles.mixins}\n${this.#sassStyles.rules}`.trim();
|
||||
}
|
||||
|
||||
private async compile(): Promise<void> {
|
||||
const sass = this.bundle();
|
||||
await writeFileAsync(`${this.stylePath}/sass.scss`, sass).catch(_e => {
|
||||
const e = _e as Error;
|
||||
console.error(`Stylesheet: Couldn't write Sass to cache. Stderr: ${
|
||||
e.message}\n${e.stack}`);
|
||||
});
|
||||
await execAsync(
|
||||
`bash -c "sass ${this.stylePath}/sass.scss ${this.stylePath}/style.css"`
|
||||
).catch(_e => {
|
||||
const e = _e as Error;
|
||||
console.error(`Stylesheet: An error occurred on compile-time! Stderr: ${
|
||||
e.message}\n${e.stack}`);
|
||||
});
|
||||
}
|
||||
|
||||
public getStyleSheet(): string {
|
||||
return readFile(`${this.stylePath}/style.css`);
|
||||
}
|
||||
|
||||
public getColorDefinitions(): string {
|
||||
const data = Wallpaper.getDefault().getData();
|
||||
const colors = {
|
||||
...data.special,
|
||||
...data.colors
|
||||
};
|
||||
|
||||
return Object.keys(colors).map(name =>
|
||||
`$${name}: ${colors[name as keyof typeof colors]};`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
private organizeModuleImports(sass: string) {
|
||||
return sass.replaceAll(
|
||||
/[@](use|forward|import) ["'](.*)["']?[;]?\n/gi,
|
||||
(_, impType, imp) => {
|
||||
imp = (imp as string).replace(/["';]/g, "");
|
||||
|
||||
// add sass modules on top
|
||||
if(!this.#sassStyles.modules.includes(imp) && /^(sass|.*http|.*https)/.test(imp))
|
||||
this.#sassStyles.modules = this.#sassStyles.modules.concat(`\n@${impType} "${imp}";`);
|
||||
|
||||
return "";
|
||||
}
|
||||
).replace(/(colors|mixins|wal)\./g, "");
|
||||
}
|
||||
|
||||
public compileApply(): void {
|
||||
this.compile().then(() => {
|
||||
Shell.getDefault().resetStyle();
|
||||
Shell.getDefault().applyStyle(this.getStyleSheet());
|
||||
}).catch(_e => {
|
||||
const e = _e as Error;
|
||||
console.error(`Stylesheet: An error occurred at compile-time. Stderr: ${
|
||||
e.message}\n${e.stack}`);
|
||||
});
|
||||
}
|
||||
|
||||
private getStyleData(path: string): string {
|
||||
return decoder.decode(Gio.resources_lookup_data(path, null).get_data()!);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if(!this.#outputPath.query_exists(null))
|
||||
this.#outputPath.make_directory_with_parents(null);
|
||||
|
||||
this.#stylesPaths = Gio.resources_enumerate_children(
|
||||
"/io/github/retrozinndev/colorshell/styles", null
|
||||
).map(name =>
|
||||
`/io/github/retrozinndev/colorshell/styles/${name}`
|
||||
);
|
||||
|
||||
// Rules won't change at runtime in a common build,
|
||||
// so no need to worry about this.
|
||||
// But in a development build, there should be support
|
||||
// hot-reloading the gresource, this is a TODO
|
||||
this.#stylesPaths.forEach(path => {
|
||||
const name = path.split('/')[path.split('/').length - 1];
|
||||
|
||||
switch(name) {
|
||||
case "colors":
|
||||
this.#sassStyles.colors = `${this.getColorDefinitions()}\n${
|
||||
this.organizeModuleImports(this.getStyleData(path))
|
||||
}`;
|
||||
break;
|
||||
case "mixins":
|
||||
this.#sassStyles.mixins = `${this.organizeModuleImports(
|
||||
this.getStyleData(path)
|
||||
)}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
this.#sassStyles.rules = `${this.#sassStyles.rules}\n${
|
||||
this.organizeModuleImports(this.getStyleData(path))
|
||||
}`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.compileApply();
|
||||
|
||||
monitorFile(`${GLib.get_user_cache_dir()}/wal/colors`, () => {
|
||||
this.#sassStyles.colors = `${this.getColorDefinitions()}\n${
|
||||
this.organizeModuleImports(this.getStyleData(
|
||||
"/io/github/retrozinndev/colorshell/styles/colors"
|
||||
))
|
||||
}`;
|
||||
this.compileApply();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { createPoll } from "ags/time";
|
||||
import { exec, execAsync } from "ags/process";
|
||||
import { Accessor, For, With } from "ags";
|
||||
import { Astal, Gtk } from "ags/gtk4";
|
||||
import { getSymbolicIcon } from "./apps";
|
||||
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
import Xdp from "gi://Xdp?version=1.0";
|
||||
|
||||
|
||||
/** gnim doesn't export this, so we need to do it again */
|
||||
export type WidgetNodeType = Array<JSX.Element> | JSX.Element | number | string | boolean | null | undefined;
|
||||
|
||||
export const decoder = new TextDecoder("utf-8"),
|
||||
encoder = new TextEncoder();
|
||||
export const time = createPoll(GLib.DateTime.new_now_local(), 500, () =>
|
||||
GLib.DateTime.new_now_local());
|
||||
export const XdgPortal = Xdp.Portal.new();
|
||||
|
||||
export function getHyprlandInstanceSig(): (string|null) {
|
||||
return GLib.getenv("HYPRLAND_INSTANCE_SIGNATURE");
|
||||
}
|
||||
|
||||
export function getHyprlandVersion(): string {
|
||||
return exec(`${GLib.getenv("HYPRLAND_CMD") ?? "Hyprland"} --version | head -n1`).split(" ")[1];
|
||||
}
|
||||
|
||||
export function getPlayerIconFromBusName(busName: string): string {
|
||||
const splitName = busName.split('.').filter(str => str !== "" &&
|
||||
!str.toLowerCase().includes('instance'));
|
||||
|
||||
return getSymbolicIcon(splitName[splitName.length - 1]) ?
|
||||
getSymbolicIcon(splitName[splitName.length - 1])!
|
||||
: "folder-music-symbolic";
|
||||
}
|
||||
|
||||
export function escapeUnintendedMarkup(input: string): string {
|
||||
return input.replace(/<[^>]*>|[<>&"]/g, (s) => {
|
||||
if(s.startsWith('<') && s.endsWith('>'))
|
||||
return s;
|
||||
|
||||
switch(s) {
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case "&": return "&";
|
||||
case "\"": return """;
|
||||
}
|
||||
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
export function getChildren(widget: Gtk.Widget): Array<Gtk.Widget> {
|
||||
const firstChild = widget.get_first_child(),
|
||||
children: Array<Gtk.Widget> = [];
|
||||
if(!firstChild) return [];
|
||||
|
||||
let currentChild = firstChild.get_next_sibling();
|
||||
while(currentChild != null) {
|
||||
children.push(currentChild);
|
||||
currentChild = currentChild.get_next_sibling();
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function omitObjectKeys<ObjT = object>(obj: ObjT, keys: keyof ObjT|Array<keyof ObjT>): object {
|
||||
const finalObject = { ...obj };
|
||||
|
||||
for(const objKey of Object.keys(finalObject as object)) {
|
||||
if(!Array.isArray(keys)) {
|
||||
if(objKey === keys) {
|
||||
delete finalObject[keys as keyof typeof finalObject];
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
for(const omitKey of keys) {
|
||||
if(objKey === omitKey) {
|
||||
delete finalObject[objKey as keyof typeof finalObject];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalObject as object;
|
||||
}
|
||||
|
||||
export function pickObjectKeys<ObjT = object>(obj: ObjT, keys: Array<keyof ObjT>): object {
|
||||
const finalObject = {} as Record<keyof ObjT, any>;
|
||||
|
||||
for(const key of keys) {
|
||||
for(const objKey of Object.keys(obj as object)) {
|
||||
if(key === objKey) {
|
||||
finalObject[key as keyof ObjT] = obj[objKey as keyof ObjT];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalObject;
|
||||
}
|
||||
|
||||
export function variableToBoolean(variable: any|Array<any>|Accessor<Array<any>|any>): boolean|Accessor<boolean> {
|
||||
return (variable instanceof Accessor) ?
|
||||
variable.as(v => Array.isArray(v) ?
|
||||
(v as Array<any>).length > 0
|
||||
: Boolean(v))
|
||||
: Array.isArray(variable) ?
|
||||
variable.length > 0
|
||||
: Boolean(variable);
|
||||
}
|
||||
|
||||
export function pathToURI(path: string): string {
|
||||
switch(true) {
|
||||
case (/^[/]/).test(path):
|
||||
return `file://${path}`;
|
||||
|
||||
case (/^[~]/).test(path):
|
||||
case (/^file:\/\/[~]/i).test(path):
|
||||
return `file://${GLib.get_home_dir()}/${path.replace(/^(file\:\/\/|[~]|file\:\/\[~])/i, "")}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export function transform<ValueType = any|Array<any>, RType = any>(
|
||||
v: Accessor<ValueType>|ValueType, fn: (v: ValueType) => RType
|
||||
): RType|Accessor<RType> {
|
||||
|
||||
return (v instanceof Accessor) ?
|
||||
v.as(fn)
|
||||
: fn(v);
|
||||
}
|
||||
|
||||
export function transformWidget<ValueType = unknown>(
|
||||
v: Accessor<ValueType|Array<ValueType>>|ValueType|Array<ValueType>,
|
||||
fn: (v: ValueType, i?: Accessor<number>|number) => JSX.Element
|
||||
): WidgetNodeType {
|
||||
|
||||
return (v instanceof Accessor) ?
|
||||
Array.isArray(v.get()) ?
|
||||
For({
|
||||
each: v as Accessor<Array<ValueType>>,
|
||||
children: (cval, i) => fn(cval, i)
|
||||
})
|
||||
: With({
|
||||
value: v as Accessor<ValueType>,
|
||||
children: fn
|
||||
})
|
||||
: (Array.isArray(v) ?
|
||||
v.map(val => fn(val))
|
||||
: fn(v));
|
||||
}
|
||||
|
||||
export function filter<ValueType = unknown, FilterReturnType = unknown>(
|
||||
v: Accessor<Array<ValueType>>|Array<ValueType>,
|
||||
fn: (v: ValueType, i: number, array: Array<ValueType>) => FilterReturnType
|
||||
): Array<ValueType>|Accessor<Array<ValueType>> {
|
||||
return ((v instanceof Accessor) ?
|
||||
v(v => v.filter((it, i, arr) => fn(it, i, arr)))
|
||||
: v.filter((it, i, arr) => fn(it, i, arr)));
|
||||
}
|
||||
|
||||
export function makeDirectory(dir: string): void {
|
||||
execAsync([ "mkdir", "-p", dir ]);
|
||||
}
|
||||
|
||||
export function deleteFile(path: string): void {
|
||||
execAsync([ "rm", "-r", path ]);
|
||||
}
|
||||
|
||||
export function playSystemBell(): void {
|
||||
execAsync("canberra-gtk-play -i bell").catch((e: Error) => {
|
||||
console.error(`Couldn't play system bell. Stderr: ${e.message}\n${e.stack}`);
|
||||
});
|
||||
}
|
||||
|
||||
export function isInstalled(commandName: string): boolean {
|
||||
const proc = Gio.Subprocess.new(["bash", "-c", `command -v ${commandName}`],
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
|
||||
|
||||
const [ , stdout, stderr ] = proc.communicate_utf8(null, null);
|
||||
if(stdout && !stderr)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function addSliderMarksFromMinMax(slider: Astal.Slider, amountOfMarks: number = 2, markup?: (string | null)) {
|
||||
if(markup && !markup.includes("{}"))
|
||||
markup = `${markup}{}`
|
||||
|
||||
slider.add_mark(slider.min, Gtk.PositionType.BOTTOM, markup ?
|
||||
markup.replaceAll("{}", `${slider.min}`) : null);
|
||||
|
||||
const num = (amountOfMarks - 1);
|
||||
for(let i = 1; i <= num; i++) {
|
||||
const part = (slider.max / num) | 0;
|
||||
|
||||
if(i > num) {
|
||||
slider.add_mark(slider.max, Gtk.PositionType.BOTTOM, `${slider.max}K`);
|
||||
break;
|
||||
}
|
||||
|
||||
slider.add_mark(part*i, Gtk.PositionType.BOTTOM, markup ?
|
||||
markup.replaceAll("{}", `${part*i}`) : null);
|
||||
}
|
||||
|
||||
return slider;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import GObject, { register } from "ags/gobject";
|
||||
import AstalWp from "gi://AstalWp";
|
||||
|
||||
export { Wireplumber };
|
||||
|
||||
@register({ GTypeName: "Wireplumber" })
|
||||
class Wireplumber extends GObject.Object {
|
||||
private static astalWireplumber: (AstalWp.Wp|null) = AstalWp.get_default();
|
||||
private static inst: Wireplumber;
|
||||
|
||||
private defaultSink: AstalWp.Endpoint = Wireplumber.astalWireplumber!.get_default_speaker()!;
|
||||
private defaultSource: AstalWp.Endpoint = Wireplumber.astalWireplumber!.get_default_microphone()!;
|
||||
|
||||
private maxSinkVolume: number = 100;
|
||||
private maxSourceVolume: number = 100;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if(!Wireplumber.astalWireplumber)
|
||||
throw new Error("Audio features will not work correctly! Please install wireplumber first", {
|
||||
cause: "Wireplumber library not found"
|
||||
});
|
||||
}
|
||||
|
||||
public static getDefault(): Wireplumber {
|
||||
if(!Wireplumber.inst)
|
||||
Wireplumber.inst = new Wireplumber();
|
||||
|
||||
return Wireplumber.inst;
|
||||
}
|
||||
|
||||
public static getWireplumber(): AstalWp.Wp {
|
||||
return Wireplumber.astalWireplumber!;
|
||||
}
|
||||
|
||||
public getMaxSinkVolume(): number {
|
||||
return this.maxSinkVolume;
|
||||
}
|
||||
|
||||
public getMaxSourceVolume(): number {
|
||||
return this.maxSourceVolume;
|
||||
}
|
||||
|
||||
public getDefaultSink(): AstalWp.Endpoint {
|
||||
return this.defaultSink;
|
||||
}
|
||||
|
||||
public getDefaultSource(): AstalWp.Endpoint {
|
||||
return this.defaultSource;
|
||||
}
|
||||
|
||||
public getSinkVolume(): number {
|
||||
return Math.floor(this.getDefaultSink().get_volume() * 100);
|
||||
}
|
||||
|
||||
public getSourceVolume(): number {
|
||||
return Math.floor(this.getDefaultSource().get_volume() * 100);
|
||||
}
|
||||
|
||||
public setSinkVolume(newSinkVolume: number): void {
|
||||
this.defaultSink.set_volume(
|
||||
(newSinkVolume > this.maxSinkVolume ? this.maxSinkVolume : newSinkVolume) / 100
|
||||
);
|
||||
}
|
||||
|
||||
public setSourceVolume(newSourceVolume: number): void {
|
||||
this.defaultSource.set_volume(
|
||||
newSourceVolume > this.maxSourceVolume ? this.maxSourceVolume : newSourceVolume / 100
|
||||
);
|
||||
}
|
||||
|
||||
public increaseEndpointVolume(endpoint: AstalWp.Endpoint, volumeIncrease: number): void {
|
||||
volumeIncrease = Math.abs(volumeIncrease) / 100;
|
||||
|
||||
if((endpoint.get_volume() + volumeIncrease) > (this.maxSinkVolume / 100)) {
|
||||
endpoint.set_volume(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
endpoint.set_volume(endpoint.get_volume() + volumeIncrease);
|
||||
}
|
||||
|
||||
public increaseSinkVolume(volumeIncrease: number): void {
|
||||
this.increaseEndpointVolume(this.getDefaultSink(), volumeIncrease);
|
||||
}
|
||||
|
||||
public increaseSourceVolume(volumeIncrease: number): void {
|
||||
this.increaseEndpointVolume(this.getDefaultSource(), volumeIncrease);
|
||||
}
|
||||
|
||||
public decreaseEndpointVolume(endpoint: AstalWp.Endpoint, volumeDecrease: number): void {
|
||||
volumeDecrease = Math.abs(volumeDecrease) / 100;
|
||||
|
||||
if((endpoint.get_volume() - volumeDecrease) < 0) {
|
||||
endpoint.set_volume(0);
|
||||
return;
|
||||
}
|
||||
|
||||
endpoint.set_volume(endpoint.get_volume() - volumeDecrease);
|
||||
}
|
||||
|
||||
public decreaseSinkVolume(volumeDecrease: number): void {
|
||||
this.decreaseEndpointVolume(this.getDefaultSink(), volumeDecrease);
|
||||
}
|
||||
|
||||
public decreaseSourceVolume(volumeDecrease: number): void {
|
||||
this.decreaseEndpointVolume(this.getDefaultSource(), volumeDecrease);
|
||||
}
|
||||
|
||||
public muteSink(): void {
|
||||
this.getDefaultSink().set_mute(true);
|
||||
}
|
||||
|
||||
public muteSource(): void {
|
||||
this.getDefaultSource().set_mute(true);
|
||||
}
|
||||
|
||||
public unmuteSink(): void {
|
||||
this.getDefaultSink().set_mute(false);
|
||||
}
|
||||
|
||||
public unmuteSource(): void {
|
||||
this.getDefaultSource().set_mute(false);
|
||||
}
|
||||
|
||||
public isMutedSink(): boolean {
|
||||
return this.getDefaultSink().get_mute();
|
||||
}
|
||||
|
||||
public isMutedSource(): boolean {
|
||||
return this.getDefaultSource().get_mute();
|
||||
}
|
||||
|
||||
public toggleMuteSink(): void {
|
||||
if(this.isMutedSink())
|
||||
return this.unmuteSink();
|
||||
|
||||
return this.muteSink();
|
||||
}
|
||||
|
||||
public toggleMuteSource(): void {
|
||||
if(this.isMutedSource())
|
||||
return this.unmuteSource();
|
||||
|
||||
return this.muteSource();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { execAsync } from "ags/process";
|
||||
import { timeout } from "ags/time";
|
||||
import { monitorFile, readFile } from "ags/file";
|
||||
import GObject, { register, getter } from "ags/gobject";
|
||||
|
||||
import AstalIO from "gi://AstalIO";
|
||||
import Gio from "gi://Gio?version=2.0";
|
||||
import GLib from "gi://GLib?version=2.0";
|
||||
import { decoder, encoder } from "./utils";
|
||||
|
||||
|
||||
export { Wallpaper };
|
||||
|
||||
type WalData = {
|
||||
checksum: string;
|
||||
wallpaper: string;
|
||||
alpha: number;
|
||||
special: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
};
|
||||
colors: {
|
||||
color0: string;
|
||||
color1: string;
|
||||
color2: string;
|
||||
color3: string;
|
||||
color4: string;
|
||||
color5: string;
|
||||
color6: string;
|
||||
color7: string;
|
||||
color8: string;
|
||||
color9: string;
|
||||
color10: string;
|
||||
color11: string;
|
||||
color12: string;
|
||||
color13: string;
|
||||
color14: string;
|
||||
color15: string;
|
||||
};
|
||||
};
|
||||
|
||||
@register({ GTypeName: "Wallpaper" })
|
||||
class Wallpaper extends GObject.Object {
|
||||
private static instance: Wallpaper;
|
||||
#wallpaper: (string|undefined);
|
||||
#splash: boolean = true;
|
||||
#monitor: Gio.FileMonitor;
|
||||
#hyprpaperFile: Gio.File;
|
||||
#wallpapersPath: string;
|
||||
#ignoreWatch: boolean = false;
|
||||
|
||||
@getter(Boolean)
|
||||
public get splash() { return this.#splash; }
|
||||
public set splash(showSplash: boolean) {
|
||||
this.#splash = showSplash;
|
||||
this.notify("splash");
|
||||
}
|
||||
|
||||
/** current wallpaper's complete path
|
||||
* can be an empty string if undefined */
|
||||
@getter(String)
|
||||
public get wallpaper() { return this.#wallpaper ?? ""; }
|
||||
public set wallpaper(newValue: string) { this.setWallpaper(newValue); }
|
||||
|
||||
public get wallpapersPath() { return this.#wallpapersPath; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.#wallpapersPath = GLib.getenv("WALLPAPERS") ??
|
||||
`${GLib.get_home_dir()}/wallpapers`;
|
||||
|
||||
this.#hyprpaperFile = Gio.File.new_for_path(`${
|
||||
GLib.get_user_config_dir()}/hypr/hyprpaper.conf`);
|
||||
|
||||
this.getWallpaper().then((wall) => {
|
||||
if(wall?.trim()) this.#wallpaper = wall.trim();
|
||||
});
|
||||
|
||||
let tmeout: (AstalIO.Time|undefined) = undefined;
|
||||
|
||||
this.#monitor = monitorFile(this.#hyprpaperFile.get_path()!, (_, event) => {
|
||||
if(event !== Gio.FileMonitorEvent.CHANGED && event !== Gio.FileMonitorEvent.CREATED &&
|
||||
event !== Gio.FileMonitorEvent.MOVED_IN)
|
||||
return;
|
||||
|
||||
if(tmeout) return;
|
||||
else tmeout = timeout(1500, () => tmeout = undefined);
|
||||
|
||||
if(this.#ignoreWatch) {
|
||||
this.#ignoreWatch = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [ loaded, text ] = this.#hyprpaperFile.load_contents(null);
|
||||
if(!loaded)
|
||||
console.error("Wallpaper: Couldn't read changes inside the hyprpaper file!");
|
||||
|
||||
const content = decoder.decode(text);
|
||||
|
||||
if(content) {
|
||||
let setWall: boolean = true;
|
||||
|
||||
for(const line of content.split('\n')) {
|
||||
if(line.trim().startsWith('#'))
|
||||
continue;
|
||||
|
||||
const lineSplit = line.split('=');
|
||||
const key = lineSplit[0].trim(),
|
||||
value = lineSplit.filter((_, i) => i !== 0).join('=').trim();
|
||||
|
||||
switch(key) {
|
||||
case "splash":
|
||||
this.splash = (/(yes|true|on|enable|enabled|1).*/.test(value)) ? true : false;
|
||||
break;
|
||||
|
||||
case "wallpaper":
|
||||
if(this.#wallpaper !== value && setWall) {
|
||||
this.setWallpaper(value, false);
|
||||
setWall = false; // wallpaper already set
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
vfunc_dispose(): void {
|
||||
this.#monitor.cancel();
|
||||
}
|
||||
|
||||
public static getDefault(): Wallpaper {
|
||||
if(!this.instance)
|
||||
this.instance = new Wallpaper();
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private writeChanges(): void {
|
||||
this.#ignoreWatch = true; // tell monitor to ignore file replace
|
||||
this.#hyprpaperFile.replace_async(null, false,
|
||||
Gio.FileCreateFlags.REPLACE_DESTINATION,
|
||||
GLib.PRIORITY_DEFAULT, null, (_, result) => {
|
||||
const res = this.#hyprpaperFile.replace_finish(result);
|
||||
if(res) {
|
||||
// success
|
||||
this.#ignoreWatch = true; // tell monitor to ignore this change
|
||||
res.write_bytes_async(encoder.encode(`# This file was automatically generated by color-shell
|
||||
|
||||
preload = ${this.#wallpaper}
|
||||
splash = ${this.#splash}
|
||||
wallpaper = , ${this.#wallpaper}`.split('\n').map(str => str.trimStart()).join('\n')),
|
||||
GLib.PRIORITY_DEFAULT, null, (_, asyncRes) => {
|
||||
if(_!.write_finish(asyncRes)) res.flush(null);
|
||||
res.close(null);
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Wallpaper: an error occurred when trying to replace the hyprpaper file`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public getData(): WalData {
|
||||
const content = readFile(`${GLib.getenv("XDG_CACHE_HOME")}/wal/colors.json`);
|
||||
return JSON.parse(content) as WalData;
|
||||
}
|
||||
|
||||
public async getWallpaper(): Promise<string|undefined> {
|
||||
return await execAsync("sh -c \"hyprctl hyprpaper listactive | tail -n 1\"").then(stdout => {
|
||||
const loaded: (string|undefined) = stdout.split('=')[1]?.trim();
|
||||
|
||||
if(!loaded)
|
||||
console.warn(`Wallpaper: Couldn't get wallpaper. There is(are) no loaded wallpaper(s)`);
|
||||
|
||||
return loaded;
|
||||
}).catch((err: Gio.IOErrorEnum) => {
|
||||
console.error(`Wallpaper: Couldn't get wallpaper. Stderr: \n${err.message ? `${err.message} /` : ""} Stack: \n ${err.stack}`);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
public reloadColors(): void {
|
||||
execAsync(`wal -t --cols16 darken -i "${this.#wallpaper}"`).then(() => {
|
||||
console.log("Wallpaper: reloaded shell colors");
|
||||
}).catch(r => {
|
||||
console.error(`Wallpaper: Couldn't update shell colors. Stderr: ${r}`);
|
||||
});
|
||||
}
|
||||
|
||||
public setWallpaper(path: string|Gio.File, write: boolean = true): void {
|
||||
execAsync("hyprctl hyprpaper unload all").then(() =>
|
||||
execAsync(`hyprctl hyprpaper preload ${path}`).then(() =>
|
||||
execAsync(`hyprctl hyprpaper wallpaper ${path}`).then(() => {
|
||||
this.#wallpaper = (typeof path === "string") ? path : path.get_path()!;
|
||||
this.reloadColors();
|
||||
write && this.writeChanges();
|
||||
}).catch(r => {
|
||||
console.error(`Wallpaper: Couldn't set wallpaper. Stderr: ${r}`);
|
||||
})
|
||||
).catch(r => {
|
||||
console.error(`Wallpaper: Couldn't preload image. Stderr: ${r}`);
|
||||
})
|
||||
).catch(r => {
|
||||
console.error(`Wallpaper: Couldn't unload images from memory. Stderr: ${r}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async pickWallpaper(): Promise<string|undefined> {
|
||||
return (await execAsync(`zenity --file-selection`).then(wall => {
|
||||
if(!wall.trim()) return undefined;
|
||||
|
||||
this.setWallpaper(wall);
|
||||
return wall;
|
||||
}).catch(r => {
|
||||
console.error(`Wallpaper: Couldn't pick wallpaper, is \`zenity\` installed? Stderr: ${r}`);
|
||||
return undefined;
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user