Files
colorshell/src/modules/wallpaper.ts
T
2025-12-01 06:45:17 -04:00

331 lines
12 KiB
TypeScript

import { execAsync } from "ags/process";
import { readFile } from "ags/file";
import GObject, { register, getter, gtype, property, setter } from "ags/gobject";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
import { createSubscription } from "./utils";
import { Notifications } from "./notifications";
import { generalConfig } from "../config";
import { createRoot, getScope, Scope } from "ags";
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;
};
};
export type WalMode = "darken"|"lighten";
/** wallpaper tiling mode */
export type WallpaperPositioning = "contain"|"tile"|"cover";
@register({ GTypeName: "Wallpaper" })
class Wallpaper extends GObject.Object {
private static instance: Wallpaper;
#wallpaper: (string|undefined);
#scope!: Scope;
#splash: boolean = true;
#hyprpaperFile: Gio.File;
#wallpapersPath: string;
@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)
get wallpaper() { return this.#wallpaper ?? ""; }
@setter(String)
set wallpaper(newValue: string) { this.setWallpaper(newValue); }
get wallpapersPath() { return this.#wallpapersPath; }
@property(gtype<WallpaperPositioning>(String))
positioning: WallpaperPositioning = "cover";
@property(gtype<WalMode>(String))
colorMode: WalMode = "darken";
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();
});
createRoot(() => {
this.#scope = getScope();
createSubscription(
generalConfig.bindProperty("wallpaper.color_mode", "string"),
() => {
const mode = generalConfig.getProperty("wallpaper.color_mode", "string");
if(this.colorMode === mode)
return;
if(!mode || (mode !== "darken" && mode !== "lighten")) {
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Couldn't update color mode",
body: "Invalid mode. Possible values are: \"darken\" or \"lighten\""
});
return;
};
this.colorMode = mode as WalMode;
this.reloadColors();
}
);
createSubscription(
generalConfig.bindProperty("wallpaper.positioning", "string"),
() => {
const positioning = generalConfig
.getProperty("wallpaper.positioning", "string") as WallpaperPositioning;
if(this.positioning === positioning)
return;
if(!positioning || (positioning !== "contain" &&
positioning !== "cover" &&
positioning !== "tile")) {
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Couldn't update wallpaper position",
body: "Invalid position value. Possible values are: \"cover\"(default), \"contain\" or \"tile\""
});
return;
}
this.positioning = positioning;
this.reloadWallpaper().catch((e: Error) =>
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Couldn't update wallpaper position",
body: `An error occurred while updating wallpaper's position: ${e.message}`
})
);
}
);
});
}
vfunc_dispose(): void {
this.#scope?.dispose();
}
public static getDefault(): Wallpaper {
if(!this.instance)
this.instance = new Wallpaper();
return this.instance;
}
private writeChanges(): Promise<void> {
return new Promise((resolve, reject) => {
try {
const content = `# This file was automatically generated by colorshell
preload = ${this.#wallpaper}
splash = ${this.#splash}
wallpaper = , ${this.positioning === "cover" ? "" : `${this.positioning}:`}${this.#wallpaper}
`;
// Use synchronous file writing for reliability
const filePath = this.#hyprpaperFile.get_path();
if(!filePath) {
reject(new Error("Could not get hyprpaper file path"));
return;
}
// Ensure directory exists
const parentDir = this.#hyprpaperFile.get_parent();
if(parentDir && !parentDir.query_exists(null)) {
parentDir.make_directory_with_parents(null);
}
// Write file synchronously using GLib
const success = GLib.file_set_contents(filePath, content);
if(success) {
resolve();
} else {
reject(new Error("Failed to write hyprpaper config file"));
}
} catch (e: any) {
reject(new Error(`Failed to write config file: ${e.message}`));
}
});
}
public getData(): WalData {
const cacheHome = GLib.getenv("XDG_CACHE_HOME") || `${GLib.get_home_dir()}/.cache`;
const content = readFile(`${cacheHome}/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 = stdout.split('=')[1]?.trim();
if(!loaded)
console.warn(`Wallpaper: Couldn't get wallpaper. There is(are) no loaded wallpaper(s)`);
return loaded;
}).catch((e: Error) => {
console.error(`Wallpaper: Couldn't get wallpaper. Stderr: \n${e.message}`);
return undefined;
});
}
public reloadColors(): void {
const cacheHome = GLib.getenv("XDG_CACHE_HOME") || `${GLib.get_home_dir()}/.cache`;
const colorsKittyPath = `${cacheHome}/wal/colors-kitty.conf`;
const kittyConfigPath = `${GLib.get_user_config_dir()}/kitty/kitty.conf`;
const runWal = (extraArgs: string = "") =>
execAsync(`wal -t --cols16 ${this.colorMode} ${extraArgs} -i "${this.#wallpaper}"`);
// First try default backend; if it fails (e.g. some images on aarch64),
// fall back to a more forgiving backend like "colorz".
runWal().catch((e: Error) => {
console.error(`Wallpaper: Couldn't update shell colors with default backend. Stderr: ${e.message}`);
console.log("Wallpaper: Falling back to pywal backend 'colorz'");
return runWal("--backend colorz");
}).then(() => {
console.log("Wallpaper: reloaded shell colors");
// First, try to set colors on all existing kitty instances
execAsync(`kitty @ set-colors --all ${colorsKittyPath}`).then(() => {
console.log("Wallpaper: reloaded colors in existing kitty instances");
}).catch((e: Error) => {
// It's okay if this fails (e.g., no kitty instances running)
console.log(`Wallpaper: Couldn't reload kitty colors in existing instances: ${e.message}`);
});
// Then, update the configured colors for future kitty instances
// This is critical - it tells kitty to use these colors for new windows
execAsync(`kitty @ set-colors --configured ${colorsKittyPath}`).then(() => {
console.log("Wallpaper: configured colors for future kitty instances");
}).catch((e: Error) => {
// If no kitty instances are running, we can't set configured colors
// In this case, new instances should still pick up colors from the include directive
console.log(`Wallpaper: Couldn't set configured colors (new instances will use include directive): ${e.message}`);
});
}).catch((e: Error) => {
console.error(`Wallpaper: Couldn't update shell colors even with fallback backend. Stderr: ${e.message}`);
});
}
public async reloadWallpaper(write: boolean = true): Promise<void> {
if(this.wallpaper.trim() === "")
return;
const wallpaperPath = this.#wallpaper.trim();
try {
// Write config file first if needed
if(write) {
await this.writeChanges();
}
// Unload all current wallpapers
await execAsync(`hyprctl hyprpaper unload all`).catch(() => {
// Ignore errors - this is usually fine
});
// Preload the new wallpaper
await execAsync(`hyprctl hyprpaper preload "${wallpaperPath}"`);
// Set wallpaper on all monitors
await execAsync(`hyprctl hyprpaper wallpaper ", ${wallpaperPath}"`);
// Note: We don't need to reload or restart hyprpaper here
// The preload and wallpaper commands should apply the change immediately
// The config file is written for persistence across hyprpaper restarts
} catch (e: any) {
console.error(`Wallpaper: Error reloading wallpaper: ${e.message}`);
console.error(`Wallpaper: Stack trace: ${e.stack}`);
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Failed to set wallpaper",
body: `Error: ${e.message}`
});
throw e;
}
}
public setWallpaper(path: string|Gio.File, write: boolean = true): void {
path = typeof path === "string" ? path : path.peek_path()!;
if(!GLib.file_test(path, GLib.FileTest.EXISTS)) {
console.error("Wallpaper: file does not exist, skipped");
return;
}
this.#wallpaper = path;
this.reloadWallpaper(write).catch((e: Error) => {
console.error(`Wallpaper: Couldn't set wallpaper. Stderr: ${e.message}`);
});
this.reloadColors();
}
public async pickWallpaper(): Promise<string|undefined> {
return (await execAsync(`zenity --file-selection`).then(wall => {
const trimmedWall = wall.trim();
if(!trimmedWall) return undefined;
// Ensure path is absolute
const absolutePath = GLib.path_is_absolute(trimmedWall)
? trimmedWall
: GLib.build_filenamev([GLib.get_current_dir(), trimmedWall]);
this.setWallpaper(absolutePath);
return absolutePath;
}).catch((e: Error) => {
console.error(`Wallpaper: Couldn't pick wallpaper, is \`zenity\` installed? Stderr: ${e.message}`);
return undefined;
}));
}
}