diff --git a/src/components/settings/shared/FileChooser.ts b/src/components/settings/shared/FileChooser.ts index 0bfdf6c..bdf6217 100644 --- a/src/components/settings/shared/FileChooser.ts +++ b/src/components/settings/shared/FileChooser.ts @@ -272,32 +272,23 @@ export const importFiles = (themeOnly: boolean = false): void => { iconName: icons.ui.info, }); - const tmpConfigFile = Gio.File.new_for_path(`${TMP}/config.json`); const optionsConfigFile = Gio.File.new_for_path(CONFIG); - const [tmpSuccess, tmpContent] = tmpConfigFile.load_contents(null); const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null); - if (!tmpSuccess || !optionsSuccess) { - console.error('Failed to read existing configuration files.'); + if (!optionsSuccess) { + console.error('Failed to read existing configuration file.'); dialog.destroy(); return; } - let tmpConfig = JSON.parse(new TextDecoder('utf-8').decode(tmpContent)); let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent)); - if (themeOnly) { - const filteredConfig = filterConfigForThemeOnly(importedConfig); - tmpConfig = { ...tmpConfig, ...filteredConfig }; - optionsConfig = { ...optionsConfig, ...filteredConfig }; - } else { - const filteredConfig = filterConfigForNonTheme(importedConfig); - tmpConfig = { ...tmpConfig, ...filteredConfig }; - optionsConfig = { ...optionsConfig, ...filteredConfig }; - } + const filteredConfig = themeOnly + ? filterConfigForThemeOnly(importedConfig) + : filterConfigForNonTheme(importedConfig); + optionsConfig = { ...optionsConfig, ...filteredConfig }; - saveConfigToFile(tmpConfig, `${TMP}/config.json`); saveConfigToFile(optionsConfig, CONFIG); } dialog.destroy(); diff --git a/src/components/settings/shared/inputs/boolean.tsx b/src/components/settings/shared/inputs/boolean.tsx index 76ed482..28749c3 100644 --- a/src/components/settings/shared/inputs/boolean.tsx +++ b/src/components/settings/shared/inputs/boolean.tsx @@ -31,7 +31,6 @@ export const BooleanInputter = ({ interface BooleanInputterProps { opt: Opt; - isUnsaved?: Variable; disabledBinding?: Variable; dependencies?: string[]; } diff --git a/src/components/settings/shared/inputs/number.tsx b/src/components/settings/shared/inputs/number.tsx index 194b0c5..bc47831 100644 --- a/src/components/settings/shared/inputs/number.tsx +++ b/src/components/settings/shared/inputs/number.tsx @@ -29,14 +29,18 @@ export const NumberInputter = ({ })} { + const currentText = self.value; + const optValue = opt.get(); + isUnsaved.set(currentText !== optValue); + }} + onActivate={(self) => { + opt.set(self.value as T); + }} setup={(self) => { self.set_range(min, max); self.set_increments(1 * increment, 5 * increment); - self.connect('value-changed', () => { - opt.set(self.value as T); - }); - useHook(self, opt, () => { self.set_value(opt.get() as number); isUnsaved.set(Number(self.get_text()) !== opt.get()); diff --git a/src/globals/useTheme.ts b/src/globals/useTheme.ts index 456b809..cb64857 100644 --- a/src/globals/useTheme.ts +++ b/src/globals/useTheme.ts @@ -14,24 +14,19 @@ globalThis.useTheme = (filePath: string): void => { return; } - const tmpConfigFile = Gio.File.new_for_path(`${TMP}/config.json`); const optionsConfigFile = Gio.File.new_for_path(CONFIG); - const [tmpSuccess, tmpContent] = tmpConfigFile.load_contents(null); const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null); - if (!tmpSuccess || !optionsSuccess) { + if (!optionsSuccess) { throw new Error('Failed to load theme file.'); } - let tmpConfig = JSON.parse(new TextDecoder('utf-8').decode(tmpContent)); let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent)); const filteredConfig = filterConfigForThemeOnly(importedConfig); - tmpConfig = { ...tmpConfig, ...filteredConfig }; optionsConfig = { ...optionsConfig, ...filteredConfig }; - saveConfigToFile(tmpConfig, `${TMP}/config.json`); saveConfigToFile(optionsConfig, CONFIG); bash(restartCommand.get()); } catch (error) { diff --git a/src/lib/option.ts b/src/lib/option.ts index 275d35c..1cd7dd2 100644 --- a/src/lib/option.ts +++ b/src/lib/option.ts @@ -1,20 +1,17 @@ -import { isHexColor } from '../globals/variables'; import { MkOptionsResult } from './types/options'; -import { ensureDirectory } from './session'; import Variable from 'astal/variable'; import { monitorFile, readFile, writeFile } from 'astal/file'; -import GLib from 'gi://GLib?version=2.0'; -import { errorHandler } from './utils'; +import { errorHandler, Notify } from './utils'; +import { ensureDirectory } from './session'; +import icons from './icons/icons'; type OptProps = { persistent?: boolean; }; -/** - * A file to store default configurations. Placed inside the cache directory. - * NOTE: We need to move this out into the .config directory instead. - */ -export const defaultFile = `${GLib.get_tmp_dir()}/ags/hyprpanel/default.json`; +type WriteDiskProps = { + writeDisk?: boolean; +}; export class Opt extends Variable { /** @@ -76,118 +73,109 @@ export class Opt extends Variable { } /** - * Initializes this option by attempting to read its value from a cache file. - * If found, sets the current value. Also sets up a subscription to write updates back. + * Initializes this option based on the provided configuration, if available. * - * @param cacheFile - The path to the cache file. + * @param config - The configuration. */ - public init(cacheFile: string): void { - const rawData = readFile(cacheFile); + public init(config: Record): void { + const value = _findVal(config, this._id.split('.')); - let cacheData: Record = {}; - - if (rawData && rawData.trim() !== '') { - try { - cacheData = JSON.parse(rawData) as Record; - } catch (error) { - errorHandler(error); - } + if (value !== undefined) { + this.set(value as T, { writeDisk: false }); } - - const cachedVariable = this._findKey(cacheData, this._id.split('.')); - - if (cachedVariable !== undefined) { - this.set(cachedVariable as T); - } - - this.subscribe((newVal) => { - const reRaw = readFile(cacheFile); - let currentCache: Record = {}; - if (reRaw && reRaw.trim() !== '') { - try { - currentCache = JSON.parse(reRaw) as Record; - } catch { - // Do nuffin - } - } - currentCache[this._id] = newVal; - writeFile(cacheFile, JSON.stringify(currentCache, null, 2)); - }); } /** - * Initializes this option by attempting to read its default value from the default file. - * If found, sets the current value. + * 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 createDefault(): void { - const rawData = readFile(defaultFile); + public set = (value: T, { writeDisk = true }: WriteDiskProps = {}): void => { + if (value === this.get()) { + // If nothing actually changed, exit quick + return; + } - let defaultData: Record = {}; + super.set(value); - if (rawData && rawData.trim() !== '') { - try { - defaultData = JSON.parse(rawData) as Record; - } catch { - // do nuffin + 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)); } - - const defaultVal = defaultData[this._id]; - - if (defaultVal !== undefined) { - this.set(defaultVal as T); - } - } + }; /** * 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(): string | undefined { + public reset(writeDiskProps: WriteDiskProps = {}): string | undefined { if (this.persistent) { return undefined; } - const current = this.get(); + 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 (JSON.stringify(current) !== JSON.stringify(this.initial)) { - this.set(this.initial); + if (currentValue !== initialValue) { + this.set(this.initial, writeDiskProps); return this._id; } return undefined; } +} - private _findKey(obj: Record, path: string[]): T | undefined { - const top = path.shift(); +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 as T; - } + 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] as T; - } - - if (top in obj) { - // The value exists but we are not there yet, so we recurse. - return this._findKey(obj[top] as Record, path); - } - - // Key does not exist :( + 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; } /** @@ -203,15 +191,15 @@ export function opt(initial: T, props?: OptProps): Opt { /** * Recursively traverses the provided object to extract all `Opt` instances, assigning IDs to each. * - * @param object - The object containing `Opt` instances. + * @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(object: Record, path = '', arr: Opt[] = []): Opt[] { +function getOptions(optionsObj: Record, path = '', arr: Opt[] = []): Opt[] { try { - for (const key in object) { - const value = object[key]; + for (const key in optionsObj) { + const value = optionsObj[key]; const id = path ? `${path}.${key}` : key; if (value instanceof Variable) { @@ -233,66 +221,78 @@ function getOptions(object: Record, path = '', arr: Opt[] = []) * includes methods to reset values, reset theme colors, and handle dependencies. * * @template T extends object - * @param cacheFile - The file path to store cached values. - * @param object - The object containing nested `Opt` instances. - * @param [confFile='config.json'] - The configuration file name stored in TMP. + * @param optionsObj - The object containing nested `Opt` instances. * @returns The original object extended with additional methods for handling options. */ -export function mkOptions( - cacheFile: string, - object: T, - confFile: string = 'config.json', -): T & MkOptionsResult { - const allOptions = getOptions(object as Record); +export function mkOptions(optionsObj: T): T & MkOptionsResult { + ensureDirectory(CONFIG.split('/').slice(0, -1).join('/')); - for (let i = 0; i < allOptions.length; i++) { - allOptions[i].init(cacheFile); - } + const rawConfig = readFile(CONFIG); - ensureDirectory(cacheFile.split('/').slice(0, -1).join('/')); - ensureDirectory(defaultFile.split('/').slice(0, -1).join('/')); - - const configFile = `${TMP}/${confFile}`; - - const values: Record = {}; - const defaultValues: Record = {}; - - for (let i = 0; i < allOptions.length; i++) { - const option = allOptions[i]; - const val = option.value; - - values[option.id] = val; - - if (isHexColor(val as string)) { - defaultValues[option.id] = option.initial; - } else { - defaultValues[option.id] = val; + 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 } } - writeFile(defaultFile, JSON.stringify(defaultValues, null, 2)); - writeFile(configFile, JSON.stringify(values, null, 2)); + // Initialize the config options + const allOptions = getOptions(optionsObj as Record); + for (let i = 0; i < allOptions.length; i++) { + allOptions[i].init(config); + } - monitorFile(configFile, () => { - const raw = readFile(configFile); - - if (!raw || raw.trim() === '') return; - - let cache: Record; - - try { - cache = JSON.parse(raw) as Record; - } catch { + // 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 = cache[opt.id]; - const oldVal = opt.get(); + const newVal = _findVal(newConfig, opt.id.split('.')); - if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) { - opt.set(newVal as T); + 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 }); } } }); @@ -325,8 +325,7 @@ export function mkOptions( return results; } - return Object.assign(object, { - configFile, + return Object.assign(optionsObj, { array: (): Opt[] => allOptions, async reset(): Promise { const ids = await resetAll(allOptions); diff --git a/src/lib/types/options.d.ts b/src/lib/types/options.d.ts index 863c746..f54f45d 100644 --- a/src/lib/types/options.d.ts +++ b/src/lib/types/options.d.ts @@ -5,7 +5,6 @@ import { Astal } from 'astal/gtk3'; import { dropdownMenuList } from '../constants/options'; export type MkOptionsResult = { - configFile: string; array: () => Opt[]; reset: () => Promise; handler: (deps: string[], callback: () => void) => void; diff --git a/src/options.ts b/src/options.ts index 96f70af..4dbe258 100644 --- a/src/options.ts +++ b/src/options.ts @@ -90,7 +90,7 @@ const tertiary_colors = { surface2: '#585b71', }; -const options = mkOptions(CONFIG, { +const options = mkOptions({ theme: { tooltip: { scaling: opt(100), diff --git a/src/scss/style.ts b/src/scss/style.ts index 4ee135e..6248517 100644 --- a/src/scss/style.ts +++ b/src/scss/style.ts @@ -1,13 +1,12 @@ import options from '../options'; import { bash, dependencies } from '../lib/utils'; -import { MatugenColors, RecursiveOptionsObject } from '../lib/types/options'; +import { HexColor, MatugenColors, RecursiveOptionsObject } from '../lib/types/options'; import { initializeTrackers } from './optionsTrackers'; import { generateMatugenColors, getMatugenHex, replaceHexValues } from '../services/matugen/index'; import { isHexColor } from '../globals/variables'; import { readFile, writeFile } from 'astal/file'; import { App } from 'astal/gtk3'; import { initializeHotReload } from './utils/hotReload'; -import { defaultFile } from 'src/lib/option'; const deps = ['font', 'theme', 'bar.flatButtons', 'bar.position', 'bar.battery.charging', 'bar.battery.blocks']; @@ -46,23 +45,26 @@ function extractVariables(theme: RecursiveOptionsObject, prefix = '', matugenCol async function extractMatugenizedVariables(matugenColors: MatugenColors): Promise { try { const result = [] as string[]; + const optArray = options.array(); - const defaultFileContent = JSON.parse(readFile(defaultFile) || '{}'); + for (let i = 0; i < optArray.length; i++) { + const opt = optArray[i]; + const name = opt.id; - for (const key in defaultFileContent) { - if (key.startsWith('theme.') === false) { - continue; - } - const configValue = defaultFileContent[key]; - - if (!isHexColor(configValue) && matugenColors !== undefined) { - result.push(`$${key.replace('theme.', '').split('.').join('-')}: ${configValue};`); + if (name.startsWith('theme.') === false) { continue; } - const matugenColor = getMatugenHex(configValue, matugenColors); + const initialValue = opt.initial; - result.push(`$${key.replace('theme.', '').split('.').join('-')}: ${matugenColor};`); + if (!isHexColor(initialValue) && matugenColors !== undefined) { + result.push(`$${name.replace('theme.', '').split('.').join('-')}: ${initialValue};`); + continue; + } + + const matugenColor = getMatugenHex(initialValue as HexColor, matugenColors); + + result.push(`$${name.replace('theme.', '').split('.').join('-')}: ${matugenColor};`); } return result;