Fix: An issue that would cause Matugen colors to not apply. (#929)

* Eslint updates

* linter fixes

* Type fixes

* More type fixes

* Fix isvis

* More type fixes

* Type Fixes

* Consolidate logic to manage options

* Linter fixes

* Package lock update

* Update configs

* Version checker

* Debug pipeline

* Package lock update

* Update ci

* Strict check

* Revert ci

* Eslint

* Remove rule since it causes issues in CI

* Actual matugen fix
This commit is contained in:
Jas Singh
2025-05-11 23:01:55 -07:00
committed by GitHub
parent 0c82ce9704
commit 2bb1449fb6
275 changed files with 4363 additions and 2505 deletions

View File

@@ -0,0 +1,169 @@
import { readFile, writeFile, monitorFile } from 'astal/file';
import { errorHandler, Notify } from '../utils';
import { ensureDirectory } from '../session';
import icons from '../icons/icons';
/**
* Manages configuration file operations including reading, writing, and change monitoring
*
* The ConfigManager centralizes all configuration persistence operations and provides
* utilities for working with nested configuration structures.
*/
export class ConfigManager {
private _configPath: string;
private _changeCallbacks: Array<() => void> = [];
/**
* Creates a new configuration manager for a specific config file
*
* @param configPath - Path to the configuration file to manage
*/
constructor(configPath: string) {
this._configPath = configPath;
this._ensureConfigDirectory();
this._setupConfigMonitor();
}
/**
* Updates a single option in the configuration file
*
* @param id - Dot-notation path of the option to update
* @param value - New value to store for the option
*/
public updateOption(id: string, value: unknown): void {
const config = this.readConfig();
config[id] = value;
this.writeConfig(config);
}
/**
* Retrieves a value from a nested object using a path
*
* @param dataObject - The object to search within
* @param path - Dot-notation path or array of path segments
* @returns The value at the specified path or undefined if not found
*/
public getNestedValue(dataObject: Record<string, unknown>, path: string | string[]): unknown {
const pathArray = typeof path === 'string' ? path.split('.') : path;
return this._findValueByPath(dataObject, pathArray);
}
/**
* Reads the current configuration from disk
*
* @returns The parsed configuration object or an empty object if the file doesn't exist
*/
public readConfig(): Record<string, unknown> {
const raw = readFile(this._configPath);
if (!raw || raw.trim() === '') {
return {};
}
try {
return JSON.parse(raw);
} catch (error) {
this._handleConfigError(error);
return {};
}
}
/**
* Writes configuration to disk
*
* @param config - The configuration object to serialize and save
*/
public writeConfig(config: Record<string, unknown>): void {
writeFile(this._configPath, JSON.stringify(config, null, 2));
}
/**
* Registers a callback to be called when the config file changes
*
* @param callback - Function to execute when config file changes are detected
*/
public onConfigChanged(callback: () => void): void {
this._changeCallbacks.push(callback);
}
/**
* Recursively navigates an object to find a value at the specified path
*
* @param currentObject - The object currently being traversed
* @param pathKeys - Remaining path segments to navigate
* @returns The value at the path or undefined if not found
*/
private _findValueByPath(currentObject: Record<string, unknown>, pathKeys: string[]): unknown {
const currentKey = pathKeys.shift();
if (currentKey === undefined) {
return currentObject;
}
if (!this._isObject(currentObject)) {
return;
}
const propertyPath = [currentKey, ...pathKeys].join('.');
if (propertyPath in currentObject) {
return currentObject[propertyPath];
}
if (!(currentKey in currentObject)) {
return;
}
const currentKeyValue = currentObject[currentKey];
if (!this._isObject(currentKeyValue)) {
return;
}
return this._findValueByPath(currentKeyValue, pathKeys);
}
/**
* Ensures the directory for the config file exists
*/
private _ensureConfigDirectory(): void {
ensureDirectory(this._configPath.split('/').slice(0, -1).join('/'));
}
/**
* Sets up file monitoring to detect external changes to the config file
*/
private _setupConfigMonitor(): void {
const debounceTimeMs = 200;
let lastEventTime = Date.now();
monitorFile(this._configPath, () => {
if (Date.now() - lastEventTime < debounceTimeMs) {
return;
}
lastEventTime = Date.now();
this._notifyConfigChanged();
});
}
/**
* Notifies all registered callbacks about config file changes
*/
private _notifyConfigChanged(): void {
this._changeCallbacks.forEach((callback) => callback());
}
/**
* Handles configuration parsing errors with appropriate logging and notification
*
* @param error - The error that occurred during config parsing
*/
private _handleConfigError(error: unknown): void {
console.error(`Failed to load config file: ${error}`);
Notify({
summary: 'Failed to load config file',
body: `${error}`,
iconName: icons.ui.warning,
});
errorHandler(error);
}
/**
* Type guard that checks if a value is a non-null object
*
* @param value - The value to check
* @returns True if the value is a non-null object
*/
private _isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
}

98
src/lib/options/Opt.ts Normal file
View File

@@ -0,0 +1,98 @@
import Variable from 'astal/variable';
import { ConfigManager } from './ConfigManager';
/**
* Properties that can be passed when creating an option
*/
export interface OptProps {
persistent?: boolean;
}
/**
* Options for set operations
*/
export interface WriteOptions {
writeDisk?: boolean;
}
/**
* A managed application option with persistence capabilities
*/
export class Opt<T = unknown> extends Variable<T> {
public readonly initial: T;
public readonly persistent: boolean;
private _id = '';
private _configManager: ConfigManager;
constructor(initial: T, configManager: ConfigManager, { persistent = false }: OptProps = {}) {
super(initial);
this.initial = initial;
this.persistent = persistent;
this._configManager = configManager;
}
public toJSON(): string {
return `opt:${JSON.stringify(this.get())}`;
}
public get value(): T {
return this.get();
}
public set value(val: T) {
this.set(val);
}
public get id(): string {
return this._id;
}
public set id(newId: string) {
this._id = newId;
}
public init(config: Record<string, unknown>): void {
const value = this._configManager.getNestedValue(config, this._id);
if (value !== undefined) {
this.set(value as T, { writeDisk: false });
}
}
public set = (value: T, { writeDisk = true }: WriteOptions = {}): void => {
if (value === this.get()) {
return;
}
super.set(value);
if (writeDisk) {
this._configManager.updateOption(this._id, value);
}
};
public reset(writeOptions: WriteOptions = {}): string | undefined {
if (this.persistent) {
return;
}
const hasChanged = this._hasChangedFromInitial();
if (hasChanged) {
this.set(this.initial, writeOptions);
return this._id;
}
return;
}
private _hasChangedFromInitial(): boolean {
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;
return currentValue !== initialValue;
}
}

View File

@@ -0,0 +1,186 @@
import { Opt } from './Opt';
import { ConfigManager } from './ConfigManager';
import { MkOptionsResult, OptionsObject } from './options.types';
import { errorHandler } from '../utils';
/**
* Creates and manages a registry of application options
*
* Provides functionality to collect, initialize, reset, and track options throughout
* the application. Handles configuration synchronization and dependency-based subscriptions.
*/
export class OptionRegistry<T extends OptionsObject> {
private _options: Opt[] = [];
private _optionsObj: T;
private _configManager: ConfigManager;
/**
* Creates a new option registry
*
* @param optionsObj - The object containing option definitions
* @param configManager - The configuration manager to handle persistence
*/
constructor(optionsObj: T, configManager: ConfigManager) {
this._optionsObj = optionsObj;
this._configManager = configManager;
this._initializeOptions();
}
/**
* Returns all registered options as an array
*/
public toArray(): Opt[] {
return this._options;
}
/**
* Resets all options to their initial values
*
* @returns Newline-separated list of IDs for options that were reset
*/
public async reset(): Promise<string> {
const results = await this._resetAllOptions(this._options);
return results.join('\n');
}
/**
* Registers a callback for options matching the provided dependency prefixes
*
* @param optionsToWatch - Array of option ID prefixes to watch
* @param callback - Function to call when matching options change
*/
public handler(optionsToWatch: string[], callback: () => void): void {
optionsToWatch.forEach((prefix) => {
const matchingOptions = this._options.filter((opt) => opt.id.startsWith(prefix));
matchingOptions.forEach((opt) => opt.subscribe(callback));
});
}
/**
* Updates options based on changes to the config file
*
* Synchronizes in-memory option values with the current state of the config file
*/
public handleConfigFileChange(): void {
const newConfig = this._configManager.readConfig();
for (const opt of this._options) {
const newVal = this._configManager.getNestedValue(newConfig, opt.id);
if (newVal === undefined) {
opt.reset({ writeDisk: false });
continue;
}
const oldVal = opt.get();
if (newVal !== oldVal) {
opt.set(newVal, { writeDisk: false });
}
}
}
/**
* Creates the enhanced options object with additional methods
*
* @returns The original options object enhanced with registry methods
*/
public createEnhancedOptions(): T & MkOptionsResult {
return Object.assign(this._optionsObj, {
toArray: this.toArray.bind(this),
reset: this.reset.bind(this),
handler: this.handler.bind(this),
});
}
/**
* Initializes the option registry by collecting options and setting up monitoring
*/
private _initializeOptions(): void {
this._options = this._collectOptions(this._optionsObj);
this._initializeFromConfig();
this._configManager.onConfigChanged(() => {
this.handleConfigFileChange();
});
}
/**
* Initializes option values from the saved configuration
*/
private _initializeFromConfig(): void {
const config = this._configManager.readConfig();
for (const opt of this._options) {
opt.init(config);
}
}
/**
* Recursively collects all option instances from an object structure
*
* @param sourceObject - The object to search for options
* @param path - Current path in the object hierarchy
* @returns Array of found option instances
*/
private _collectOptions(sourceObject: Record<string, unknown>, path = ''): Opt[] {
const result: Opt[] = [];
try {
for (const key in sourceObject) {
const value = sourceObject[key];
const id = path ? `${path}.${key}` : key;
if (value instanceof Opt) {
value.id = id;
result.push(value);
} else if (this._isNestedObject(value)) {
result.push(...this._collectOptions(value, id));
}
}
} catch (error) {
errorHandler(error);
}
return result;
}
/**
* Resets all options to their initial values with a delay between operations
*
* @param opts - Array of options to reset
* @returns Array of IDs for options that were reset
*/
private async _resetAllOptions(opts: Opt[]): Promise<string[]> {
const results: string[] = [];
for (const opt of opts) {
const id = opt.reset();
if (id !== undefined) {
results.push(id);
await this._sleep(50);
}
}
return results;
}
/**
* Simple promise-based sleep function
*
* @param ms - Milliseconds to sleep
*/
private _sleep(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Type guard to check if a value is a non-null object that can be traversed
*
* @param value - The value to check
*/
private _isNestedObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
}

25
src/lib/options/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { ConfigManager } from './ConfigManager';
import { Opt, OptProps } from './Opt';
import { OptionRegistry } from './OptionRegistry';
import { MkOptionsResult, OptionsObject } from './options.types';
const CONFIG_PATH = CONFIG_FILE;
const configManager = new ConfigManager(CONFIG_PATH);
/**
* Creates an option with the specified initial value
*/
export function opt<T>(initial: T, props?: OptProps): Opt<T> {
return new Opt(initial, configManager, props);
}
/**
* Creates and initializes an options management system
*/
export function mkOptions<T extends OptionsObject>(optionsObj: T): T & MkOptionsResult {
const registry = new OptionRegistry(optionsObj, configManager);
return registry.createEnhancedOptions();
}
export { Opt, OptProps, ConfigManager, OptionRegistry };

View File

@@ -0,0 +1,287 @@
import { Astal } from 'astal/gtk3';
import { dropdownMenuList } from '../constants/options';
import { FontStyle } from 'src/components/settings/shared/inputs/font/utils';
import { Variable } from 'astal';
import { defaultColorMap } from '../types/defaults/options.types';
import { LabelSettingProps } from 'src/components/settings/shared/Label';
import { Opt } from './Opt';
export interface MkOptionsResult {
toArray: () => Opt[];
reset: () => Promise<string>;
handler: (optionsToWatch: string[], callback: () => void) => void;
}
export type RecursiveOptionsObject = {
[key: string]:
| RecursiveOptionsObject
| Opt<string>
| Opt<number>
| Opt<boolean>
| Variable<string>
| Variable<number>
| Variable<boolean>;
};
export type OptionsObject = Record<string, unknown>;
export type BarLocation = 'top' | 'bottom';
export type AutoHide = 'never' | 'fullscreen' | 'single-window';
export type BarModule =
| 'battery'
| 'dashboard'
| 'workspaces'
| 'windowtitle'
| 'media'
| 'notifications'
| 'volume'
| 'network'
| 'bluetooth'
| 'clock'
| 'ram'
| 'cpu'
| 'cputemp'
| 'storage'
| 'netstat'
| 'kbinput'
| 'updates'
| 'submap'
| 'weather'
| 'power'
| 'systray'
| 'hypridle'
| 'hyprsunset'
| 'cava';
export type BarLayout = {
left: BarModule[];
middle: BarModule[];
right: BarModule[];
};
export type BarLayouts = {
[key: string]: BarLayout;
};
export type Unit = 'imperial' | 'metric';
export type PowerOptions = 'sleep' | 'reboot' | 'logout' | 'shutdown';
export type NotificationAnchor =
| 'top'
| 'top right'
| 'top left'
| 'bottom'
| 'bottom right'
| 'bottom left'
| 'left'
| 'right';
export type OSDAnchor =
| 'top left'
| 'top'
| 'top right'
| 'right'
| 'bottom right'
| 'bottom'
| 'bottom left'
| 'left';
export type BarButtonStyles = 'default' | 'split' | 'wave' | 'wave2';
export type ThemeExportData = {
filePath: string;
themeOnly: boolean;
};
export type InputType =
| 'number'
| 'color'
| 'float'
| 'object'
| 'string'
| 'enum'
| 'boolean'
| 'img'
| 'wallpaper'
| 'export'
| 'import'
| 'config_import'
| 'font';
export interface RowProps<T> {
opt: Opt<T>;
note?: string;
type?: InputType;
enums?: T[];
max?: number;
min?: number;
disabledBinding?: Variable<boolean>;
exportData?: ThemeExportData;
subtitle?: LabelSettingProps['subtitle'];
subtitleLink?: string;
dependencies?: string[];
increment?: number;
fontStyle?: Opt<FontStyle>;
fontLabel?: Opt<string>;
}
export type OSDOrientation = 'horizontal' | 'vertical';
export type HexColor = `#${string}`;
export type WindowLayer = 'top' | 'bottom' | 'overlay' | 'background';
export type ActiveWsIndicator = 'underline' | 'highlight' | 'color';
export type MatugenColors = {
background: HexColor;
error: HexColor;
error_container: HexColor;
inverse_on_surface: HexColor;
inverse_primary: HexColor;
inverse_surface: HexColor;
on_background: HexColor;
on_error: HexColor;
on_error_container: HexColor;
on_primary: HexColor;
on_primary_container: HexColor;
on_primary_fixed: HexColor;
on_primary_fixed_variant: HexColor;
on_secondary: HexColor;
on_secondary_container: HexColor;
on_secondary_fixed: HexColor;
on_secondary_fixed_variant: HexColor;
on_surface: HexColor;
on_surface_variant: HexColor;
on_tertiary: HexColor;
on_tertiary_container: HexColor;
on_tertiary_fixed: HexColor;
on_tertiary_fixed_variant: HexColor;
outline: HexColor;
outline_variant: HexColor;
primary: HexColor;
primary_container: HexColor;
primary_fixed: HexColor;
primary_fixed_dim: HexColor;
scrim: HexColor;
secondary: HexColor;
secondary_container: HexColor;
secondary_fixed: HexColor;
secondary_fixed_dim: HexColor;
shadow: HexColor;
surface: HexColor;
surface_bright: HexColor;
surface_container: HexColor;
surface_container_high: HexColor;
surface_container_highest: HexColor;
surface_container_low: HexColor;
surface_container_lowest: HexColor;
surface_dim: HexColor;
surface_variant: HexColor;
tertiary: HexColor;
tertiary_container: HexColor;
tertiary_fixed: HexColor;
tertiary_fixed_dim: HexColor;
};
export type MatugenVariation = {
rosewater: HexColor;
flamingo: HexColor;
pink: HexColor;
mauve: HexColor;
red: HexColor;
maroon: HexColor;
peach: HexColor;
yellow: HexColor;
green: HexColor;
teal: HexColor;
sky: HexColor;
sapphire: HexColor;
blue: HexColor;
lavender: HexColor;
text: HexColor;
subtext1: HexColor;
subtext2: HexColor;
overlay2: HexColor;
overlay1: HexColor;
overlay0: HexColor;
surface2: HexColor;
surface1: HexColor;
surface0: HexColor;
base2: HexColor;
base: HexColor;
mantle: HexColor;
crust: HexColor;
notifications_closer?: HexColor;
notifications_background?: HexColor;
dashboard_btn_text?: HexColor;
red2: HexColor;
peach2: HexColor;
pink2: HexColor;
mantle2: HexColor;
surface1_2: HexColor;
surface0_2: HexColor;
overlay1_2: HexColor;
text2: HexColor;
lavender2: HexColor;
crust2: HexColor;
maroon2: HexColor;
mauve2: HexColor;
green2: HexColor;
surface2_2: HexColor;
sky2: HexColor;
teal2: HexColor;
yellow2: HexColor;
pink3: HexColor;
red3: HexColor;
mantle3: HexColor;
surface0_3: HexColor;
surface2_3: HexColor;
overlay1_3: HexColor;
lavender3: HexColor;
mauve3: HexColor;
green3: HexColor;
sky3: HexColor;
teal3: HexColor;
yellow3: HexColor;
maroon3: HexColor;
crust3: HexColor;
};
export type MatugenScheme =
| 'content'
| 'expressive'
| 'fidelity'
| 'fruit-salad'
| 'monochrome'
| 'neutral'
| 'rainbow'
| 'tonal-spot';
export type MatugenTheme = 'light' | 'dark';
export type MatugenVariations =
| 'standard_1'
| 'standard_2'
| 'standard_3'
| 'monochrome_1'
| 'monochrome_2'
| 'monochrome_3'
| 'vivid_1'
| 'vivid_2'
| 'vivid_3';
export type ColorMapKey = keyof typeof defaultColorMap;
export type ColorMapValue = (typeof defaultColorMap)[ColorMapKey];
export type ScalingPriority = 'gdk' | 'hyprland' | 'both';
export type BluetoothBatteryState = 'paired' | 'connected' | 'always';
export type BorderLocation =
| 'none'
| 'top'
| 'right'
| 'bottom'
| 'left'
| 'horizontal'
| 'vertical'
| 'full';
export type PositionAnchor = { [key: string]: Astal.WindowAnchor };
export type DropdownMenuList = (typeof dropdownMenuList)[number];