refactor: make the whole thing more generic

This commit is contained in:
2024-04-02 16:28:57 +02:00
parent 7b648e1955
commit 651f3ad808
193 changed files with 763 additions and 521 deletions

View File

@@ -0,0 +1,297 @@
import * as Utils from "resource:///com/github/Aylur/ags/utils.js";
import App from "resource:///com/github/Aylur/ags/app.js";
import Widget from "resource:///com/github/Aylur/ags/widget.js";
import RegularWindow from "../misc/RegularWindow.js";
import Variable from "resource:///com/github/Aylur/ags/variable.js";
import icons from "../icons.js";
import { getOptions, getValues } from "./option.js";
import options from "../options.js";
const optionsList = getOptions();
const categories = Array.from(
new Set(optionsList.map((opt) => opt.category)),
).filter((category) => category !== "exclude");
const currentPage = Variable(categories[0]);
const search = Variable("");
const showSearch = Variable(false);
showSearch.connect("changed", ({ value }) => {
if (!value) search.value = "";
});
/** @param {import('./option.js').Opt<string>} opt */
const EnumSetter = (opt) => {
const lbl = Widget.Label().bind("label", opt);
const step = (dir = 1) => {
const i = opt.enums.findIndex((i) => i === lbl.label);
opt.setValue(
dir > 0
? i + dir > opt.enums.length - 1
? opt.enums[0]
: opt.enums[i + dir]
: i + dir < 0
? opt.enums[opt.enums.length - 1]
: opt.enums[i + dir],
true,
);
};
const next = Widget.Button({
child: Widget.Icon(icons.ui.arrow.right),
on_clicked: () => step(+1),
});
const prev = Widget.Button({
child: Widget.Icon(icons.ui.arrow.left),
on_clicked: () => step(-1),
});
return Widget.Box({
class_name: "enum-setter",
children: [prev, lbl, next],
});
};
/** @param {import('./option.js').Opt} opt */
const Setter = (opt) => {
switch (opt.type) {
case "number":
return Widget.SpinButton({
setup(self) {
self.set_range(0, 1000);
self.set_increments(1, 5);
self.on("value-changed", () => opt.setValue(self.value, true));
self.hook(opt, () => (self.value = opt.value));
},
});
case "float":
case "object":
return Widget.Entry({
on_accept: (self) => opt.setValue(JSON.parse(self.text || ""), true),
setup: (self) =>
self.hook(opt, () => (self.text = JSON.stringify(opt.value))),
});
case "string":
return Widget.Entry({
on_accept: (self) => opt.setValue(self.text, true),
setup: (self) => self.hook(opt, () => (self.text = opt.value)),
});
case "enum":
return EnumSetter(opt);
case "boolean":
return Widget.Switch()
.on("notify::active", (self) => opt.setValue(self.active, true))
.hook(opt, (self) => (self.active = opt.value));
case "img":
return Widget.FileChooserButton().on("selection-changed", (self) => {
opt.setValue(self.get_uri()?.replace("file://", ""), true);
});
case "font":
return Widget.FontButton({
show_size: false,
use_size: false,
setup: (self) =>
self
.on("notify::font", ({ font }) => opt.setValue(font, true))
.hook(opt, () => (self.font = opt.value)),
});
default:
return Widget.Label({
label: "no setter with type " + opt.type,
});
}
};
/** @param {import('./option.js').Opt} opt */
const Row = (opt) =>
Widget.Box({
class_name: "row",
attribute: opt,
children: [
Widget.Box({
vertical: true,
vpack: "center",
children: [
opt.title &&
Widget.Label({
xalign: 0,
class_name: "summary",
label: opt.title,
}),
Widget.Label({
xalign: 0,
class_name: "id",
label: `id: "${opt.id}"`,
}),
],
}),
Widget.Box({ hexpand: true }),
Widget.Box({
vpack: "center",
vertical: true,
children: [
Widget.Box({
hpack: "end",
child: Setter(opt),
}),
opt.note &&
Widget.Label({
xalign: 1,
class_name: "note",
label: opt.note,
}),
],
}),
],
});
/** @param {string} category */
const Page = (category) =>
Widget.Scrollable({
vexpand: true,
class_name: "page",
child: Widget.Box({
class_name: "page-content vertical",
vertical: true,
setup: (self) =>
self.hook(search, () => {
for (const child of self.children) {
child.visible =
child.attribute.id.includes(search.value) ||
child.attribute.title.includes(search.value) ||
child.attribute.note.includes(search.value);
}
}),
children: optionsList
.filter((opt) => opt.category.includes(category))
.map(Row),
}),
});
const sidebar = Widget.Revealer({
reveal_child: search.bind().transform((v) => !v),
transition: "slide_right",
child: Widget.Box({
hexpand: false,
vertical: true,
children: [
Widget.Box({
class_name: "sidebar-header",
children: [
Widget.Button({
hexpand: true,
label: icons.dialog.Search + " Search",
on_clicked: () => (showSearch.value = !showSearch.value),
}),
Widget.Button({
hpack: "end",
child: Widget.Icon(icons.ui.info),
on_clicked: () => App.toggleWindow("about"),
}),
],
}),
Widget.Scrollable({
vexpand: true,
hscroll: "never",
child: Widget.Box({
class_name: "sidebar-box vertical",
vertical: true,
children: [
...categories.map((name) =>
Widget.Button({
label: (icons.dialog[name] || "") + " " + name,
xalign: 0,
class_name: currentPage
.bind()
.transform((v) => `${v === name ? "active" : ""}`),
on_clicked: () => currentPage.setValue(name),
}),
),
],
}),
}),
Widget.Box({
class_name: "sidebar-footer",
child: Widget.Button({
class_name: "copy",
child: Widget.Label({
label: " Save",
xalign: 0,
}),
hexpand: true,
on_clicked: () => {
Utils.execAsync(["wl-copy", getValues()]);
Utils.execAsync([
"notify-send",
"-i",
"preferences-desktop-theme-symbolic",
"Theme copied to clipboard",
'To save it permanently, make a new theme in <span weight="bold">themes.js</span>',
]);
},
}),
}),
],
}),
});
const searchEntry = Widget.Revealer({
transition: "slide_down",
reveal_child: showSearch.bind(),
transition_duration: options.transition.bind("value"),
child: Widget.Entry({
setup: (self) =>
self.hook(showSearch, () => {
if (!showSearch.value) self.text = "";
if (showSearch.value) self.grab_focus();
}),
hexpand: true,
class_name: "search",
placeholder_text: "Search Options",
secondary_icon_name: icons.apps.search,
on_change: ({ text }) => (search.value = text || ""),
}),
});
const categoriesStack = Widget.Stack({
transition: "slide_left_right",
children: categories.reduce((obj, name) => {
obj[name] = Page(name);
return obj;
}, {}),
shown: currentPage.bind(),
visible: search.bind().transform((v) => !v),
});
const searchPage = Widget.Box({
visible: search.bind().transform((v) => !!v),
child: Page(""),
});
export default RegularWindow({
name: "settings-dialog",
title: "Settings",
setup: (win) =>
win
.on("delete-event", () => {
win.hide();
return true;
})
.on("key-press-event", (_, event) => {
if (event.get_keyval()[1] === imports.gi.Gdk.KEY_Escape) {
showSearch.setValue(false);
search.setValue("");
}
})
.set_default_size(800, 500),
child: Widget.Box({
children: [
sidebar,
Widget.Box({
vertical: true,
children: [searchEntry, categoriesStack, searchPage],
}),
],
}),
});

View File

@@ -0,0 +1,40 @@
import Mpris from "resource:///com/github/Aylur/ags/service/mpris.js";
export async function globals() {
try {
globalThis.options = (await import("../options.js")).default;
globalThis.iconBrowser = (await import("../misc/IconBrowser.js")).default;
globalThis.app = (
await import("resource:///com/github/Aylur/ags/app.js")
).default;
globalThis.audio = (
await import("resource:///com/github/Aylur/ags/service/audio.js")
).default;
globalThis.recorder = (await import("../services/screenrecord.js")).default;
globalThis.brightness = (await import("../services/brightness.js")).default;
globalThis.indicator = (
await import("../services/onScreenIndicator.js")
).default;
globalThis.app = (
await import("resource:///com/github/Aylur/ags/app.js")
).default;
Mpris.players.forEach((player) => {
player.connect("changed", (player) => {
globalThis.mpris = player || Mpris.players[0];
});
});
Mpris.connect("player-added", (mpris, bus) => {
mpris.getPlayer(bus)?.connect("changed", (player) => {
globalThis.mpris = player || Mpris.players[0];
});
});
Mpris.connect("player-closed", () => {
globalThis.mpris = Mpris.players[0];
});
} catch (error) {
logError(error);
}
}

View File

@@ -0,0 +1,69 @@
import App from "resource:///com/github/Aylur/ags/app.js";
import Hyprland from "resource:///com/github/Aylur/ags/service/hyprland.js";
import options from "../options.js";
import * as Utils from "resource:///com/github/Aylur/ags/utils.js";
const noIgnorealpha = ["verification", "powermenu", "lockscreen"];
/** @param {Array<string>} batch */
function sendBatch(batch) {
const cmd = batch
.filter((x) => !!x)
.map((x) => `keyword ${x}`)
.join("; ");
Hyprland.sendMessage(`[[BATCH]]/${cmd}`);
}
/** @param {string} scss */
function getColor(scss) {
if (scss.includes("#")) return scss.replace("#", "");
if (scss.includes("$")) {
const opt = options
.list()
.find((opt) => opt.scss === scss.replace("$", ""));
return opt?.value.replace("#", "") || "ff0000";
}
}
export function hyprlandInit() {
sendBatch(
App.windows.flatMap(({ name }) => [
`layerrule blur, ${name}`,
noIgnorealpha.some((skip) => name?.includes(skip))
? ""
: `layerrule ignorealpha 0.3, ${name}`,
]),
);
}
export async function setupHyprland() {
/*Hyprland.event("activewindowv2", async (addr) => {
const client = Hyprland.getClient(addr);
if (!client.pinned || !client.floating) return;
const x = client.at[0];
console.log(
await Utils.execAsync(`hyprctl dispatch moveactive exact ${x} 80`),
);
});*/
const wm_gaps = Math.floor(
options.hypr.wm_gaps_multiplier.value * options.spacing.value,
);
const border_width = options.border.width.value;
const radii = options.radii.value;
const drop_shadow = options.desktop.drop_shadow.value;
const inactive_border = options.hypr.inactive_border.value;
const accent = getColor(options.theme.accent.accent.value);
sendBatch([
`general:border_size ${border_width}`,
`general:gaps_out ${wm_gaps}`,
`general:gaps_in ${Math.floor(wm_gaps / 2)}`,
`general:col.active_border rgba(${accent}ff)`,
`general:col.inactive_border ${inactive_border}`,
`decoration:rounding ${radii}`,
`decoration:drop_shadow ${drop_shadow ? "yes" : "no"}`,
]);
}

View File

@@ -0,0 +1,198 @@
import {
CACHE_DIR,
readFile,
writeFile,
} from "resource:///com/github/Aylur/ags/utils.js";
import { exec } from "resource:///com/github/Aylur/ags/utils.js";
import options from "../options.js";
import Service from "resource:///com/github/Aylur/ags/service.js";
import { reloadScss } from "./scss.js";
import { setupHyprland } from "./hyprland.js";
const CACHE_FILE = CACHE_DIR + "/options.json";
/** object that holds the overriedden values */
let cacheObj = JSON.parse(readFile(CACHE_FILE) || "{}");
/**
* @template T
* @typedef {Object} OptionConfig
* @property {string=} scss - name of scss variable set to "exclude" to not include it in the generated scss file
* @property {string=} unit - scss unit on numbers, default is "px"
* @property {string=} title
* @property {string=} note
* @property {string=} category
* @property {boolean=} noReload - don't reload css & hyprland on change
* @property {boolean=} persist - ignore reset call
* @property {'object' | 'string' | 'img' | 'number' | 'float' | 'font' | 'enum' =} type
* @property {Array<string> =} enums
* @property {(value: T) => any=} format
* @property {(value: T) => any=} scssFormat
*/
/** @template T */
export class Opt extends Service {
static {
Service.register(
this,
{},
{
value: ["jsobject"],
},
);
}
#value;
#scss = "";
unit = "px";
noReload = false;
persist = false;
id = "";
title = "";
note = "";
type = "";
category = "";
/** @type {Array<string>} */
enums = [];
/** @type {(v: T) => any} */
format = (v) => v;
/** @type {(v: T) => any} */
scssFormat = (v) => v;
/**
* @param {T} value
* @param {OptionConfig<T> =} config
*/
constructor(value, config) {
super();
this.#value = value;
this.defaultValue = value;
this.type = typeof value;
if (config) Object.keys(config).forEach((c) => (this[c] = config[c]));
import("../options.js").then(this.#init.bind(this));
}
set scss(scss) {
this.#scss = scss;
}
get scss() {
return this.#scss || this.id.split(".").join("-").split("_").join("-");
}
#init() {
getOptions(); // sets the ids as a side effect
if (cacheObj[this.id] !== undefined) this.setValue(cacheObj[this.id]);
const words = this.id
.split(".")
.flatMap((w) => w.split("_"))
.map((word) => word.charAt(0).toUpperCase() + word.slice(1));
this.title ||= words.join(" ");
this.category ||= words.length === 1 ? "General" : words.at(0) || "General";
this.connect("changed", () => {
cacheObj[this.id] = this.value;
writeFile(JSON.stringify(cacheObj, null, 2), CACHE_FILE);
});
}
get value() {
return this.#value;
}
set value(value) {
this.setValue(value);
}
/** @param {T} value */
setValue(value, reload = false) {
if (typeof value !== typeof this.defaultValue) {
console.error(
Error(
`WrongType: Option "${this.id}" can't be set to ${value}, ` +
`expected "${typeof this.defaultValue}", but got "${typeof value}"`,
),
);
return;
}
if (this.value !== value) {
this.#value = this.format(value);
this.changed("value");
if (reload && !this.noReload) {
reloadScss();
setupHyprland();
}
}
}
reset(reload = false) {
if (!this.persist) this.setValue(this.defaultValue, reload);
}
}
/**
* @template T
* @param {T} value
* @param {OptionConfig<T> =} config
* @returns {Opt<T>}
*/
export function Option(value, config) {
return new Opt(value, config);
}
/** @returns {Array<Opt<any>>} */
export function getOptions(object = options, path = "") {
return Object.keys(object).flatMap((key) => {
/** @type Option<any> */
const obj = object[key];
const id = path ? path + "." + key : key;
if (obj instanceof Opt) {
obj.id = id;
return obj;
}
if (typeof obj === "object") return getOptions(obj, id);
return [];
});
}
export function resetOptions() {
exec(`rm -rf ${CACHE_FILE}`);
cacheObj = {};
getOptions().forEach((opt) => opt.reset());
}
export function getValues() {
const obj = {};
for (const opt of getOptions()) {
if (opt.category !== "exclude") obj[opt.id] = opt.value;
}
return JSON.stringify(obj, null, 2);
}
/** @param {string | object} config */
export function apply(config) {
const options = getOptions();
const settings = typeof config === "string" ? JSON.parse(config) : config;
for (const id of Object.keys(settings)) {
const opt = options.find((opt) => opt.id === id);
if (!opt) {
print(`No option with id: "${id}"`);
continue;
}
opt.setValue(settings[id]);
}
}

View File

@@ -0,0 +1,62 @@
import App from "resource:///com/github/Aylur/ags/app.js";
import * as Utils from "resource:///com/github/Aylur/ags/utils.js";
import { getOptions } from "./option.js";
export function scssWatcher() {
return Utils.subprocess(
[
"inotifywait",
"--recursive",
"--event",
"create,modify",
"-m",
App.configDir + "/scss",
],
reloadScss,
() => print("missing dependancy for css hotreload: inotify-tools"),
);
}
/**
* generate an scss file that makes every option available as a variable
* based on the passed scss parameter or the path in the object
*
* e.g
* options.bar.style.value => $bar-style
*/
export async function reloadScss() {
const opts = getOptions();
const vars = opts.map((opt) => {
if (opt.scss === "exclude") return "";
const unit = typeof opt.value === "number" ? opt.unit : "";
const value = opt.scssFormat ? opt.scssFormat(opt.value) : opt.value;
return `$${opt.scss}: ${value}${unit};`;
});
const bar_style = opts.find((opt) => opt.id === "bar.style")?.value || "";
const additional =
bar_style === "normal"
? "//"
: `
window#quicksettings .window-content {
margin-right: $wm-gaps;
}
`;
try {
const tmp = "/tmp/ags/scss";
Utils.ensureDirectory(tmp);
await Utils.writeFile(vars.join("\n"), `${tmp}/options.scss`);
await Utils.writeFile(additional, `${tmp}/additional.scss`);
await Utils.execAsync(
`sassc ${App.configDir}/scss/main.scss ${tmp}/style.css`,
);
App.resetCss();
App.applyCss(`${tmp}/style.css`);
} catch (error) {
if (error instanceof Error) console.error(error.message);
if (typeof error === "string") console.error(error);
}
}

View File

@@ -0,0 +1,126 @@
import * as Utils from "resource:///com/github/Aylur/ags/utils.js";
import Battery from "resource:///com/github/Aylur/ags/service/battery.js";
import Notifications from "resource:///com/github/Aylur/ags/service/notifications.js";
import options from "../options.js";
import icons from "../icons.js";
import { reloadScss } from "./scss.js";
import { wallpaper } from "./wallpaper.js";
import { hyprlandInit, setupHyprland } from "./hyprland.js";
import { globals } from "./globals.js";
import { showAbout } from "../about/about.js";
import Gtk from "gi://Gtk?version=3.0";
export function init() {
notificationBlacklist();
warnOnLowBattery();
globals();
tmux();
kitty();
gsettigsColorScheme();
gtkFontSettings();
dependandOptions();
reloadScss();
hyprlandInit();
setupHyprland();
wallpaper();
showAbout();
}
function dependandOptions() {
options.bar.style.connect("changed", ({ value }) => {
if (value !== "normal")
options.desktop.screen_corners.setValue(false, true);
});
}
function kitty() {
if (!Utils.exec("which kitty")) return;
console.log("kitty");
options.theme.scheme.connect("changed", ({ value }) =>
Utils.execAsync(
`kitty +kitten themes --reload-in=all --config-file-name /home/theaninova/.config/kitty/current-colors.conf Catppuccin-${
value === "light" ? "Latte" : "Frappe"
}`,
),
);
}
function tmux() {
if (!Utils.exec("which tmux")) return;
/** @param {string} scss */
function getColor(scss) {
if (scss.includes("#")) return scss;
if (scss.includes("$")) {
const opt = options
.list()
.find((opt) => opt.scss === scss.replace("$", ""));
return opt?.value;
}
}
options.theme.accent.accent.connect("changed", ({ value }) =>
Utils.execAsync(`tmux set @main_accent ${getColor(value)}`).catch((err) =>
console.error(err.message),
),
);
}
function gsettigsColorScheme() {
if (!Utils.exec("which gsettings")) return;
options.theme.scheme.connect("changed", ({ value }) => {
const gsettings = "gsettings set org.gnome.desktop.interface color-scheme";
Utils.execAsync(`${gsettings} "prefer-${value}"`).catch((err) =>
console.error(err.message),
);
});
}
function gtkFontSettings() {
const settings = Gtk.Settings.get_default();
if (!settings) {
console.error(Error("Gtk.Settings unavailable"));
return;
}
const callback = () => {
const { size, font } = options.font;
settings.gtk_font_name = `${font.value} ${size.value}`;
};
options.font.font.connect("notify::value", callback);
options.font.size.connect("notify::value", callback);
}
function notificationBlacklist() {
Notifications.connect("notified", (_, id) => {
const n = Notifications.getNotification(id);
options.notifications.black_list.value.forEach((item) => {
if (n?.app_name.includes(item) || n?.app_entry?.includes(item)) n.close();
});
});
}
function warnOnLowBattery() {
Battery.connect("notify::percent", () => {
const low = options.battery.low.value;
if (
Battery.percent !== low ||
Battery.percent !== low / 2 ||
!Battery.charging
)
return;
Utils.execAsync([
"notify-send",
`${Battery.percent}% Battery Percentage`,
"-i",
icons.battery.warning,
"-u",
"critical",
]);
});
}

View File

@@ -0,0 +1,68 @@
import App from "resource:///com/github/Aylur/ags/app.js";
import options from "../options.js";
import themes from "../themes.js";
import { reloadScss } from "./scss.js";
import { setupHyprland } from "./hyprland.js";
import { wallpaper } from "./wallpaper.js";
/** @param {string} name */
export function setTheme(name) {
options.reset();
const theme = themes.find((t) => t.name === name);
if (!theme) return print("No theme named " + name);
options.apply(theme.options);
reloadScss();
setupHyprland();
wallpaper();
}
export const WP = App.configDir + "/assets/";
export const lightColors = {
"theme.scheme": "light",
"color.red": "#d20f39",
"color.green": "#40a02b",
"color.yellow": "#df8e1d",
"color.blue": "#1e66f5",
"color.magenta": "#8839ef",
"color.teal": "#179299",
"color.orange": "#fe640b",
"theme.bg": "transparentize(#eff1f5, 0.3)",
"theme.fg": "#4c4f69",
};
export const darkColors = {
"theme.scheme": "dark",
"color.red": "#e78284",
"color.green": "#a6d189",
"color.yellow": "#e5c890",
"color.blue": "#8caaee",
"color.magenta": "#ca9ee6",
"color.teal": "#81c8be",
"color.orange": "#ef9f76",
"theme.bg": "transparentize(#303446, 0.3)",
"theme.fg": "#c6d0f5",
};
export const Theme = ({ name, icon = " ", ...options }) => ({
name,
icon,
options: {
"theme.name": name,
"theme.icon": icon,
...options,
},
});
let settingsDialog;
export async function openSettings() {
if (settingsDialog) return settingsDialog.present();
try {
settingsDialog = (await import("./SettingsDialog.js")).default;
settingsDialog.present();
} catch (error) {
if (error instanceof Error) console.error(error.message);
}
}

View File

@@ -0,0 +1,19 @@
import options from "../options.js";
import { exec, execAsync } from "resource:///com/github/Aylur/ags/utils.js";
import { dependencies } from "../utils.js";
export function initWallpaper() {
if (dependencies(["swww"])) {
exec("swww init");
options.desktop.wallpaper.img.connect("changed", wallpaper);
}
}
export function wallpaper() {
if (!dependencies(["swww"])) return;
execAsync(["swww", "img", options.desktop.wallpaper.img.value]).catch((err) =>
console.error(err),
);
}