import { MkOptionsResult } from './types/options'; import Variable from 'astal/variable'; import { monitorFile, readFile, writeFile } from 'astal/file'; import { errorHandler, Notify } from './utils'; import { ensureDirectory } from './session'; import icons from './icons/icons'; type OptProps = { persistent?: boolean; }; type WriteDiskProps = { writeDisk?: boolean; }; export class Opt extends Variable { /** * The initial value set when the `Opt` is created. */ public readonly initial: T; /** * Indicates whether this option should remain unchanged even when reset operations occur. */ public readonly persistent: boolean; private _id = ''; /** * Creates an instance of `Opt`. * * @param {T} initial - The initial value of the option. * @param {OptProps} [props={}] - Additional properties for the option. */ constructor(initial: T, { persistent = false }: OptProps = {}) { super(initial); this.initial = initial; this.persistent = persistent; } /** * Converts the current value to a JSON-compatible string. * * @returns {string} */ toJSON(): string { return `opt:${JSON.stringify(this.get())}`; } public get value(): T { return this.get(); } /** * Setter for the current value of the option. */ public set value(val: T) { this.set(val); } /** * Getter for the unique ID of the option. */ public get id(): string { return this._id; } /** * Setter for the unique ID of the option. */ public set id(newId: string) { this._id = newId; } /** * Initializes this option based on the provided configuration, if available. * * @param config - The configuration. */ public init(config: Record): void { const value = _findVal(config, this._id.split('.')); if (value !== undefined) { this.set(value as T, { writeDisk: false }); } } /** * Set the given configuration value and write it to disk, if specified. * * @param value - The new value. * @param writeDisk - Whether to write the changes to disk. Defaults to true. */ public set = (value: T, { writeDisk = true }: WriteDiskProps = {}): void => { if (value === this.get()) { // If nothing actually changed, exit quick return; } super.set(value); if (writeDisk) { const raw = readFile(CONFIG); let config: Record = {}; if (raw && raw.trim() !== '') { try { config = JSON.parse(raw) as Record; } catch (error) { // Last thing we want is to reset someones entire config // so notify them instead console.error(`Failed to load config file: ${error}`); Notify({ summary: 'Failed to load config file', body: `${error}`, iconName: icons.ui.warning, }); errorHandler(error); } } config[this._id] = value; writeFile(CONFIG, JSON.stringify(config, null, 2)); } }; /** * Resets the value of this option to its initial value if not persistent and if it differs from the current value. * * @param writeDisk - Whether to write the changes to disk. Defaults to true. * @returns Returns the option's ID if reset occurred, otherwise undefined. */ public reset(writeDiskProps: WriteDiskProps = {}): string | undefined { if (this.persistent) { return undefined; } let currentValue: string | T = this.get(); currentValue = typeof currentValue === 'object' ? JSON.stringify(currentValue) : currentValue; let initialValue: string | T = this.initial; initialValue = typeof initialValue === 'object' ? JSON.stringify(initialValue) : initialValue; if (currentValue !== initialValue) { this.set(this.initial, writeDiskProps); return this._id; } return undefined; } } function _findVal(obj: Record, path: string[]): unknown | undefined { const top = path.shift(); if (!top) { // The path is empty, so this is our value. return obj; } if (typeof obj !== 'object') { // Not an array, not an object, but we need to go deeper. // This is invalid, so return. return undefined; } const mergedPath = [top, ...path].join('.'); if (mergedPath in obj) { // The key exists on this level with dot-notation, so we return that. return obj[mergedPath]; } if (top in obj) { // The value exists but we are not there yet, so we recurse. return _findVal(obj[top] as Record, path); } // Key does not exist :( return undefined; } /** * Creates an `Opt` instance with the given initial value and properties. * @template T * @param initial - The initial value. * @param [props] - Additional properties. */ export function opt(initial: T, props?: OptProps): Opt { return new Opt(initial, props); } /** * Recursively traverses the provided object to extract all `Opt` instances, assigning IDs to each. * * @param optionsObj - The object containing `Opt` instances. * @param [path=''] - The current path (used internally). * @param [arr=[]] - The accumulator array for found `Opt` instances. * @returns An array of all found `Opt` instances. */ function getOptions(optionsObj: Record, path = '', arr: Opt[] = []): Opt[] { try { for (const key in optionsObj) { const value = optionsObj[key]; const id = path ? `${path}.${key}` : key; if (value instanceof Variable) { const optValue = value as Opt; optValue.id = id; arr.push(optValue); } else if (typeof value === 'object' && value !== null) { getOptions(value as Record, id, arr); } } return arr; } catch (error) { errorHandler(error); } } /** * Creates and initializes options from a given object structure. The returned object * includes methods to reset values, reset theme colors, and handle dependencies. * * @template T extends object * @param optionsObj - The object containing nested `Opt` instances. * @returns The original object extended with additional methods for handling options. */ export function mkOptions(optionsObj: T): T & MkOptionsResult { ensureDirectory(CONFIG.split('/').slice(0, -1).join('/')); const rawConfig = readFile(CONFIG); let config: Record = {}; if (rawConfig && rawConfig.trim() !== '') { try { config = JSON.parse(rawConfig) as Record; } catch (error) { Notify({ summary: 'Failed to load config file', body: `${error}`, iconName: icons.ui.warning, }); // Continue with a broken config, the user has // been warned } } // Initialize the config options const allOptions = getOptions(optionsObj as Record); for (let i = 0; i < allOptions.length; i++) { allOptions[i].init(config); } // Setup a file monitor to allow live config edit preview from outside // the config menu const debounceTimeMs = 200; let lastEventTime = Date.now(); monitorFile(CONFIG, () => { if (Date.now() - lastEventTime < debounceTimeMs) { return; } lastEventTime = Date.now(); let newConfig: Record = {}; const rawConfig = readFile(CONFIG); if (rawConfig && rawConfig.trim() !== '') { try { newConfig = JSON.parse(rawConfig) as Record; } catch (error) { console.error(`Error loading configuration file: ${error}`); Notify({ summary: 'Loading configuration file failed', body: `${error}`, iconName: icons.ui.warning, }); return; } } for (let i = 0; i < allOptions.length; i++) { const opt = allOptions[i]; const newVal = _findVal(newConfig, opt.id.split('.')); if (newVal === undefined) { // Set the variable but don't write it back to the file, // as we are getting it from there opt.reset({ writeDisk: false }); continue; } const oldVal = opt.get(); if (newVal !== oldVal) { // Set the variable but don't write it back to the file, // as we are getting it from there opt.set(newVal, { writeDisk: false }); } } }); /** * A simple sleep utility. * * @param [ms=0] - Milliseconds to sleep. */ function sleep(ms = 0): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Resets all options to their initial values if possible. * * @param opts - Array of all option instances. * @returns IDs of all reset options. */ async function resetAll(opts: Opt[]): Promise { const results: string[] = []; for (let i = 0; i < opts.length; i++) { const id = opts[i].reset(); if (id) { results.push(id); await sleep(50); } } return results; } return Object.assign(optionsObj, { array: (): Opt[] => allOptions, async reset(): Promise { const ids = await resetAll(allOptions); return ids.join('\n'); }, /** * Registers a callback that fires when any option whose ID starts with any of the given dependencies changes. * * @param deps - An array of dependency prefixes. * @param callback - The callback function to execute on changes. */ handler(deps: string[], callback: () => void): void { for (let i = 0; i < allOptions.length; i++) { const opt = allOptions[i]; for (let j = 0; j < deps.length; j++) { if (opt.id.startsWith(deps[j])) { opt.subscribe(callback); break; } } } }, }); }