Upgrade to Agsv2 + Astal (#533)
* migrate to astal * Reorganize project structure. * progress * Migrate Dashboard and Window Title modules. * Migrate clock and notification bar modules. * Remove unused code * Media menu * Rework network and volume modules * Finish custom modules. * Migrate battery bar module. * Update battery module and organize helpers. * Migrate workspace module. * Wrap up bar modules. * Checkpoint before I inevitbly blow something up. * Updates * Fix event propagation logic. * Type fixes * More type fixes * Fix padding for event boxes. * Migrate volume menu and refactor scroll event handlers. * network module WIP * Migrate network service. * Migrate bluetooth menu * Updates * Migrate notifications * Update scrolling behavior for custom modules. * Improve popup notifications and add timer functionality. * Migration notifications menu header/controls. * Migrate notifications menu and consolidate notifications menu code. * Migrate power menu. * Dashboard progress * Migrate dashboard * Migrate media menu. * Reduce media menu nesting. * Finish updating media menu bindings to navigate active player. * Migrate battery menu * Consolidate code * Migrate calendar menu * Fix workspace logic to update on client add/change/remove and consolidate code. * Migrate osd * Consolidate hyprland service connections. * Implement startup dropdown menu position allocation. * Migrate settings menu (WIP) * Settings dialo menu fixes * Finish Dashboard menu * Type updates * update submoldule for types * update github ci * ci * Submodule update * Ci updates * Remove type checking for now. * ci fix * Fix a bunch of stuff, losing track... need rest. Brb coffee * Validate dropdown menu before render. * Consolidate code and add auto-hide functionality. * Improve auto-hide behavior. * Consolidate audio menu code * Organize bluetooth code * Improve active player logic * Properly dismiss a notification on action button resolution. * Implement CLI command engine and migrate CLI commands. * Handle variable disposal * Bar component fixes and add hyprland startup rules. * Handle potentially null bindings network and bluetooth bindings. * Handle potentially null wired adapter. * Fix GPU stats * Handle poller for GPU * Fix gpu bar logic. * Clean up logic for stat bars. * Handle wifi and wired bar icon bindings. * Fix battery percentages * Fix switch behavior * Wifi staging fixes * Reduce redundant hyprland service calls. * Code cleanup * Document the option code and reduce redundant calls to optimize performance. * Remove outdated comment. * Add JSDocs * Add meson to build hyprpanel * Consistency updates * Organize commands * Fix images not showing up on notifications. * Remove todo * Move hyprpanel configuration to the ~/.config/hyprpanel directory and add utility commands. * Handle SRC directory for the bundled/built hyprpanel. * Add namespaces to all windows * Migrate systray * systray updates * Update meson to include ts, tsx and scss files. * Remove log from meson * Fix file choose path and make it float. * Added a command to check the dependency status * Update dep names. * Get scale directly from env * Add todo
This commit is contained in:
296
src/components/settings/shared/FileChooser.ts
Normal file
296
src/components/settings/shared/FileChooser.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import options from '../../../options';
|
||||
import Gtk from 'gi://Gtk?version=3.0';
|
||||
import Gio from 'gi://Gio';
|
||||
import { bash, Notify } from '../../../lib/utils';
|
||||
import icons from '../../../lib/icons/icons';
|
||||
import { Config } from '../../../lib/types/filechooser';
|
||||
import { hexColorPattern } from '../../../globals/useTheme';
|
||||
import { isHexColor } from '../../../globals/variables';
|
||||
|
||||
const { restartCommand } = options.hyprpanel;
|
||||
const whiteListedThemeProp = ['theme.bar.buttons.style'];
|
||||
|
||||
/**
|
||||
* Loads a JSON file from the specified file path and parses it.
|
||||
* If the file cannot be loaded or parsed, it logs an error and returns null.
|
||||
*
|
||||
* @param filePath - The path to the JSON file to be loaded.
|
||||
* @returns The parsed JavaScript object or null if the file could not be loaded or parsed.
|
||||
*/
|
||||
export const loadJsonFile = (filePath: string): Config | null => {
|
||||
const file = Gio.File.new_for_path(filePath as string);
|
||||
const [success, content] = file.load_contents(null);
|
||||
|
||||
if (!success) {
|
||||
console.error(`Failed to import: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const jsonString = new TextDecoder('utf-8').decode(content);
|
||||
return JSON.parse(jsonString);
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves an object as a JSON file to the specified file path.
|
||||
* If the file cannot be saved, it logs an error.
|
||||
*
|
||||
* @param config - The JavaScript object to be saved as a JSON file.
|
||||
* @param filePath - The path where the JSON file will be saved.
|
||||
*/
|
||||
export const saveConfigToFile = (config: object, filePath: string): void => {
|
||||
const file = Gio.File.new_for_path(filePath);
|
||||
const outputStream = file.replace(null, false, Gio.FileCreateFlags.NONE, null);
|
||||
const dataOutputStream = new Gio.DataOutputStream({ base_stream: outputStream });
|
||||
|
||||
const jsonString = JSON.stringify(config, null, 2);
|
||||
dataOutputStream.put_string(jsonString, null);
|
||||
dataOutputStream.close(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters the given configuration object to include only theme-related properties.
|
||||
* Theme-related properties are identified by their keys matching a hex color pattern or being in the whitelist.
|
||||
*
|
||||
* @param config - The configuration object to be filtered.
|
||||
* @returns A new configuration object containing only theme-related properties.
|
||||
*/
|
||||
export const filterConfigForThemeOnly = (config: Config): Config => {
|
||||
const filteredConfig: Config = {};
|
||||
|
||||
for (const key in config) {
|
||||
const value = config[key];
|
||||
if (typeof value === 'string' && hexColorPattern.test(value)) {
|
||||
filteredConfig[key] = config[key];
|
||||
} else if (whiteListedThemeProp.includes(key)) {
|
||||
filteredConfig[key] = config[key];
|
||||
}
|
||||
}
|
||||
return filteredConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters the given configuration object to exclude theme-related properties.
|
||||
* Theme-related properties are identified by their keys matching a hex color pattern or being in the whitelist.
|
||||
*
|
||||
* @param config - The configuration object to be filtered.
|
||||
* @returns A new configuration object excluding theme-related properties.
|
||||
*/
|
||||
export const filterConfigForNonTheme = (config: Config): Config => {
|
||||
const filteredConfig: Config = {};
|
||||
for (const key in config) {
|
||||
if (whiteListedThemeProp.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = config[key];
|
||||
if (!(typeof value === 'string' && hexColorPattern.test(value))) {
|
||||
filteredConfig[key] = config[key];
|
||||
}
|
||||
}
|
||||
return filteredConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a file save dialog to save the current configuration to a specified file path.
|
||||
* The configuration can be filtered to include only theme-related properties if the themeOnly flag is set.
|
||||
* If the file already exists, it increments the file name to avoid overwriting.
|
||||
* Displays a notification upon successful save or logs an error if the save fails.
|
||||
*
|
||||
* @param filePath - The original file path where the configuration is to be saved.
|
||||
* @param themeOnly - A flag indicating whether to save only theme-related properties.
|
||||
*/
|
||||
export const saveFileDialog = (filePath: string, themeOnly: boolean): void => {
|
||||
const original_file_path = filePath;
|
||||
|
||||
const file = Gio.File.new_for_path(original_file_path);
|
||||
const [success, content] = file.load_contents(null);
|
||||
|
||||
if (!success) {
|
||||
console.error(`Could not find 'config.json' at ${TMP}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonString = new TextDecoder('utf-8').decode(content);
|
||||
const jsonObject = JSON.parse(jsonString);
|
||||
|
||||
const filterHexColorPairs = (jsonObject: Config): Config => {
|
||||
const filteredObject: Config = {};
|
||||
|
||||
for (const key in jsonObject) {
|
||||
const value = jsonObject[key];
|
||||
if (typeof value === 'string' && isHexColor(value)) {
|
||||
filteredObject[key] = jsonObject[key];
|
||||
} else if (whiteListedThemeProp.includes(key)) {
|
||||
filteredObject[key] = jsonObject[key];
|
||||
}
|
||||
}
|
||||
|
||||
return filteredObject;
|
||||
};
|
||||
|
||||
const filterOutHexColorPairs = (jsonObject: Config): Config => {
|
||||
const filteredObject: Config = {};
|
||||
|
||||
for (const key in jsonObject) {
|
||||
if (whiteListedThemeProp.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = jsonObject[key];
|
||||
if (!(typeof value === 'string' && isHexColor(value))) {
|
||||
filteredObject[key] = jsonObject[key];
|
||||
}
|
||||
}
|
||||
|
||||
return filteredObject;
|
||||
};
|
||||
|
||||
const filteredJsonObject = themeOnly ? filterHexColorPairs(jsonObject) : filterOutHexColorPairs(jsonObject);
|
||||
const filteredContent = JSON.stringify(filteredJsonObject, null, 2);
|
||||
|
||||
const dialog = new Gtk.FileChooserDialog({
|
||||
title: `Save Hyprpanel ${themeOnly ? 'Theme' : 'Config'}`,
|
||||
action: Gtk.FileChooserAction.SAVE,
|
||||
});
|
||||
|
||||
dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL);
|
||||
dialog.add_button(Gtk.STOCK_SAVE, Gtk.ResponseType.ACCEPT);
|
||||
dialog.set_current_name(themeOnly ? 'hyprpanel_theme.json' : 'hyprpanel_config.json');
|
||||
dialog.get_style_context().add_class('hyprpanel-file-chooser');
|
||||
|
||||
const response = dialog.run();
|
||||
|
||||
if (response === Gtk.ResponseType.ACCEPT) {
|
||||
const file_path = dialog.get_filename();
|
||||
console.info(`Original file path: ${file_path}`);
|
||||
|
||||
const getIncrementedFilePath = (filePath: string): string => {
|
||||
let increment = 1;
|
||||
const baseName = filePath.replace(/(\.\w+)$/, '');
|
||||
const match = filePath.match(/(\.\w+)$/);
|
||||
const extension = match ? match[0] : '';
|
||||
|
||||
let newFilePath = filePath;
|
||||
let file = Gio.File.new_for_path(newFilePath);
|
||||
|
||||
while (file.query_exists(null)) {
|
||||
newFilePath = `${baseName}_${increment}${extension}`;
|
||||
file = Gio.File.new_for_path(newFilePath);
|
||||
increment++;
|
||||
}
|
||||
|
||||
return newFilePath;
|
||||
};
|
||||
|
||||
const finalFilePath = getIncrementedFilePath(file_path as string);
|
||||
console.info(`File will be saved at: ${finalFilePath}`);
|
||||
|
||||
try {
|
||||
const save_file = Gio.File.new_for_path(finalFilePath);
|
||||
const outputStream = save_file.replace(null, false, Gio.FileCreateFlags.NONE, null);
|
||||
const dataOutputStream = new Gio.DataOutputStream({
|
||||
base_stream: outputStream,
|
||||
});
|
||||
|
||||
dataOutputStream.put_string(filteredContent, null);
|
||||
|
||||
dataOutputStream.close(null);
|
||||
|
||||
Notify({
|
||||
summary: 'File Saved Successfully',
|
||||
body: `At ${finalFilePath}.`,
|
||||
iconName: icons.ui.info,
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error('Failed to write to file:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.destroy();
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a file chooser dialog to import a configuration file.
|
||||
* The imported configuration can be filtered to include only theme-related properties if the themeOnly flag is set.
|
||||
* Merges the imported configuration with the existing configuration and saves the result.
|
||||
* Displays a notification upon successful import or logs an error if the import fails.
|
||||
*
|
||||
* @param themeOnly - A flag indicating whether to import only theme-related properties.
|
||||
*/
|
||||
export const importFiles = (themeOnly: boolean = false): void => {
|
||||
const dialog = new Gtk.FileChooserDialog({
|
||||
title: `Import Hyprpanel ${themeOnly ? 'Theme' : 'Config'}`,
|
||||
action: Gtk.FileChooserAction.OPEN,
|
||||
});
|
||||
dialog.set_current_folder(`${SRC_DIR}/themes`);
|
||||
dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL);
|
||||
dialog.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.ACCEPT);
|
||||
dialog.get_style_context().add_class('hyprpanel-file-chooser');
|
||||
|
||||
const response = dialog.run();
|
||||
|
||||
if (response === Gtk.ResponseType.CANCEL) {
|
||||
dialog.destroy();
|
||||
return;
|
||||
}
|
||||
if (response === Gtk.ResponseType.ACCEPT) {
|
||||
const filePath: string | null = dialog.get_filename();
|
||||
|
||||
if (filePath === null) {
|
||||
Notify({
|
||||
summary: 'Failed to import',
|
||||
body: 'No file selected.',
|
||||
iconName: icons.ui.warning,
|
||||
timeout: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const importedConfig = loadJsonFile(filePath);
|
||||
|
||||
if (!importedConfig) {
|
||||
dialog.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
Notify({
|
||||
summary: `Importing ${themeOnly ? 'Theme' : 'Config'}`,
|
||||
body: `Importing: ${filePath}`,
|
||||
iconName: icons.ui.info,
|
||||
timeout: 7000,
|
||||
});
|
||||
|
||||
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.');
|
||||
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 };
|
||||
}
|
||||
|
||||
saveConfigToFile(tmpConfig, `${TMP}/config.json`);
|
||||
saveConfigToFile(optionsConfig, CONFIG);
|
||||
}
|
||||
dialog.destroy();
|
||||
bash(restartCommand.get());
|
||||
};
|
||||
15
src/components/settings/shared/Header.tsx
Normal file
15
src/components/settings/shared/Header.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Gtk } from 'astal/gtk3';
|
||||
import Separator from 'src/components/shared/Separator';
|
||||
|
||||
export const Header = ({ title }: HeaderProps): JSX.Element => {
|
||||
return (
|
||||
<box className="options-header">
|
||||
<label className="label-name" label={title} />
|
||||
<Separator className="menu-separator" valign={Gtk.Align.CENTER} hexpand />
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
}
|
||||
96
src/components/settings/shared/Inputter.tsx
Normal file
96
src/components/settings/shared/Inputter.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { RowProps } from 'src/lib/types/options';
|
||||
import { NumberInputter } from './inputs/number';
|
||||
import { ObjectInputter } from './inputs/object';
|
||||
import { StringInputter } from './inputs/string';
|
||||
import { BooleanInputter } from './inputs/boolean';
|
||||
import { ImageInputter } from './inputs/image';
|
||||
import { ImportInputter } from './inputs/import';
|
||||
import { WallpaperInputter } from './inputs/wallpaper';
|
||||
import { ColorInputter } from './inputs/color';
|
||||
import { EnumInputter } from './inputs/enum';
|
||||
import { FontInputter } from './inputs/font';
|
||||
import { Variable } from 'astal';
|
||||
import { Gtk } from 'astal/gtk3';
|
||||
|
||||
const InputField = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
type = typeof opt.get() as RowProps<T>['type'],
|
||||
enums = [],
|
||||
disabledBinding,
|
||||
dependencies,
|
||||
exportData,
|
||||
min = 0,
|
||||
max = 1000000,
|
||||
increment = 1,
|
||||
className = '',
|
||||
isUnsaved,
|
||||
}: InputFieldProps<T>): JSX.Element => {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return <NumberInputter opt={opt} min={min} max={max} increment={increment} isUnsaved={isUnsaved} />;
|
||||
case 'float':
|
||||
case 'object':
|
||||
return <ObjectInputter opt={opt} isUnsaved={isUnsaved} className={className} />;
|
||||
case 'string':
|
||||
return <StringInputter opt={opt} isUnsaved={isUnsaved} />;
|
||||
case 'enum':
|
||||
return <EnumInputter opt={opt} values={enums} />;
|
||||
case 'boolean':
|
||||
return <BooleanInputter opt={opt} disabledBinding={disabledBinding} dependencies={dependencies} />;
|
||||
case 'img':
|
||||
return <ImageInputter opt={opt} />;
|
||||
case 'config_import':
|
||||
return <ImportInputter exportData={exportData} />;
|
||||
case 'wallpaper':
|
||||
return <WallpaperInputter opt={opt} />;
|
||||
case 'font':
|
||||
return <FontInputter opt={opt} />;
|
||||
case 'color':
|
||||
return <ColorInputter opt={opt} />;
|
||||
|
||||
default:
|
||||
return <label label={`No setter with type ${type}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const Inputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
type = typeof opt.get() as RowProps<T>['type'],
|
||||
enums,
|
||||
disabledBinding,
|
||||
dependencies,
|
||||
exportData,
|
||||
min,
|
||||
max,
|
||||
increment,
|
||||
className,
|
||||
isUnsaved,
|
||||
}: InputterProps<T>): JSX.Element => {
|
||||
return (
|
||||
<box className={/export|import/.test(type || '') ? '' : 'inputter-container'} valign={Gtk.Align.CENTER}>
|
||||
<InputField
|
||||
type={type}
|
||||
opt={opt}
|
||||
enums={enums}
|
||||
disabledBinding={disabledBinding}
|
||||
dependencies={dependencies}
|
||||
exportData={exportData}
|
||||
min={min}
|
||||
max={max}
|
||||
increment={increment}
|
||||
className={className}
|
||||
isUnsaved={isUnsaved}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface InputterProps<T> extends RowProps<T> {
|
||||
className?: string;
|
||||
isUnsaved: Variable<boolean>;
|
||||
}
|
||||
|
||||
interface InputFieldProps<T> extends RowProps<T> {
|
||||
className?: string;
|
||||
isUnsaved: Variable<boolean>;
|
||||
}
|
||||
33
src/components/settings/shared/Label.tsx
Normal file
33
src/components/settings/shared/Label.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { execAsync } from 'astal';
|
||||
import { Gtk } from 'astal/gtk3';
|
||||
|
||||
export const Label = ({ title: name, subtitle: sub = '', subtitleLink = '' }: LabelProps): JSX.Element => {
|
||||
const Subtitle = (): JSX.Element => {
|
||||
if (subtitleLink.length) {
|
||||
return (
|
||||
<button
|
||||
className="options-sublabel-link"
|
||||
onClick={() => execAsync(`bash -c 'xdg-open ${subtitleLink}'`)}
|
||||
halign={Gtk.Align.START}
|
||||
valign={Gtk.Align.CENTER}
|
||||
>
|
||||
<label label={sub} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <label className="options-sublabel" label={sub} halign={Gtk.Align.START} valign={Gtk.Align.CENTER} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<box halign={Gtk.Align.START} vertical>
|
||||
<label className="options-label" label={name} halign={Gtk.Align.START} valign={Gtk.Align.CENTER} />
|
||||
<Subtitle />
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface LabelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subtitleLink?: string;
|
||||
}
|
||||
16
src/components/settings/shared/Option/PropertyLabel.tsx
Normal file
16
src/components/settings/shared/Option/PropertyLabel.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Gtk } from 'astal/gtk3';
|
||||
import { Label } from '../Label';
|
||||
|
||||
export const PropertyLabel = ({ title, subtitle, subtitleLink }: PropertyLabelProps): JSX.Element => {
|
||||
return (
|
||||
<box halign={Gtk.Align.START} valign={Gtk.Align.CENTER} hexpand>
|
||||
<Label title={title} subtitle={subtitle} subtitleLink={subtitleLink} />
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface PropertyLabelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subtitleLink?: string;
|
||||
}
|
||||
22
src/components/settings/shared/Option/ResetButton.tsx
Normal file
22
src/components/settings/shared/Option/ResetButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { bind } from 'astal';
|
||||
import { Gtk } from 'astal/gtk3';
|
||||
import icons from 'src/lib/icons/icons';
|
||||
import { RowProps } from 'src/lib/types/options';
|
||||
import { isPrimaryClick } from 'src/lib/utils';
|
||||
|
||||
export const ResetButton = <T extends string | number | boolean | object>({ ...props }: RowProps<T>): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
className={'reset-options'}
|
||||
onClick={(_, event) => {
|
||||
if (isPrimaryClick(event)) {
|
||||
props.opt.reset();
|
||||
}
|
||||
}}
|
||||
sensitive={bind(props.opt).as((v) => v !== props.opt.initial)}
|
||||
valign={Gtk.Align.CENTER}
|
||||
>
|
||||
<icon icon={icons.ui.refresh} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
30
src/components/settings/shared/Option/SettingInput.tsx
Normal file
30
src/components/settings/shared/Option/SettingInput.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Variable } from 'astal';
|
||||
import { RowProps } from 'src/lib/types/options';
|
||||
import { Inputter } from '../Inputter';
|
||||
|
||||
export const SettingInput = <T extends string | number | boolean | object>({
|
||||
className,
|
||||
isUnsaved,
|
||||
...props
|
||||
}: SettingInputProps<T>): JSX.Element => {
|
||||
return (
|
||||
<Inputter
|
||||
opt={props.opt}
|
||||
type={props.type}
|
||||
enums={props.enums}
|
||||
disabledBinding={props.disabledBinding}
|
||||
dependencies={props.dependencies}
|
||||
exportData={props.exportData}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
increment={props.increment}
|
||||
className={className}
|
||||
isUnsaved={isUnsaved}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingInputProps<T> extends RowProps<T> {
|
||||
className?: string;
|
||||
isUnsaved: Variable<boolean>;
|
||||
}
|
||||
30
src/components/settings/shared/Option/index.tsx
Normal file
30
src/components/settings/shared/Option/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { RowProps } from 'src/lib/types/options';
|
||||
import { Variable } from 'astal';
|
||||
import { PropertyLabel } from './PropertyLabel';
|
||||
import { ResetButton } from './ResetButton';
|
||||
import { SettingInput } from './SettingInput';
|
||||
|
||||
export const Option = <T extends string | number | boolean | object>({
|
||||
className,
|
||||
...props
|
||||
}: OptionProps<T>): JSX.Element => {
|
||||
const isUnsaved = Variable(false);
|
||||
return (
|
||||
<box
|
||||
className={'option-item'}
|
||||
hexpand
|
||||
onDestroy={() => {
|
||||
isUnsaved.drop();
|
||||
}}
|
||||
>
|
||||
<PropertyLabel title={props.title} subtitle={props.subtitle} subtitleLink={props.subtitleLink} />
|
||||
<SettingInput isUnsaved={isUnsaved} className={className} {...props} />
|
||||
<ResetButton {...props} />
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface OptionProps<T> extends RowProps<T> {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
37
src/components/settings/shared/inputs/boolean.tsx
Normal file
37
src/components/settings/shared/inputs/boolean.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Binding } from 'astal';
|
||||
import { bind, Variable } from 'astal';
|
||||
import { Opt } from 'src/lib/option';
|
||||
|
||||
import { dependencies as checkDependencies } from 'src/lib/utils';
|
||||
|
||||
export const BooleanInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
disabledBinding,
|
||||
dependencies,
|
||||
}: BooleanInputterProps<T>): JSX.Element => (
|
||||
<switch
|
||||
sensitive={disabledBinding !== undefined ? bind(disabledBinding).as((disabled) => !disabled) : true}
|
||||
active={bind(opt) as Binding<boolean>}
|
||||
setup={(self) => {
|
||||
self.connect('notify::active', () => {
|
||||
if (disabledBinding !== undefined && disabledBinding.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.active && dependencies !== undefined && !dependencies.every((dep) => checkDependencies(dep))) {
|
||||
self.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
opt.set(self.active as T);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
interface BooleanInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
isUnsaved?: Variable<boolean>;
|
||||
disabledBinding?: Variable<boolean>;
|
||||
dependencies?: string[];
|
||||
}
|
||||
33
src/components/settings/shared/inputs/color.tsx
Normal file
33
src/components/settings/shared/inputs/color.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Gdk } from 'astal/gtk3';
|
||||
import ColorButton from 'src/components/shared/ColorButton';
|
||||
import { Opt } from 'src/lib/option';
|
||||
import { useHook } from 'src/lib/shared/hookHandler';
|
||||
|
||||
export const ColorInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
}: ColorInputterProps<T>): JSX.Element => {
|
||||
return (
|
||||
<ColorButton
|
||||
setup={(self) => {
|
||||
useHook(self, opt, () => {
|
||||
const rgba = new Gdk.RGBA();
|
||||
rgba.parse(opt.get() as string);
|
||||
self.rgba = rgba;
|
||||
});
|
||||
|
||||
self.connect('color-set', ({ rgba: { red, green, blue } }) => {
|
||||
const hex = (n: number): string => {
|
||||
const c = Math.floor(255 * n).toString(16);
|
||||
return c.length === 1 ? `0${c}` : c;
|
||||
};
|
||||
|
||||
opt.set(`#${hex(red)}${hex(green)}${hex(blue)}` as T);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ColorInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
}
|
||||
51
src/components/settings/shared/inputs/enum.tsx
Normal file
51
src/components/settings/shared/inputs/enum.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Opt } from 'src/lib/option';
|
||||
import icons from 'src/lib/icons/icons';
|
||||
import { bind } from 'astal';
|
||||
import { isPrimaryClick } from 'src/lib/utils';
|
||||
|
||||
export const EnumInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
values,
|
||||
}: EnumInputterProps<T>): JSX.Element => {
|
||||
const step = (dir: 1 | -1): void => {
|
||||
const indexOfCurrentValue = values.findIndex((index) => index === opt.get());
|
||||
|
||||
opt.set(
|
||||
dir > 0
|
||||
? indexOfCurrentValue + dir > values.length - 1
|
||||
? values[0]
|
||||
: values[indexOfCurrentValue + dir]
|
||||
: indexOfCurrentValue + dir < 0
|
||||
? values[values.length - 1]
|
||||
: values[indexOfCurrentValue + dir],
|
||||
);
|
||||
};
|
||||
return (
|
||||
<box className={'enum-setter'}>
|
||||
<label label={bind(opt).as((option) => `${option}`)} />
|
||||
<button
|
||||
onClick={(_, event) => {
|
||||
if (isPrimaryClick(event)) {
|
||||
step(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<icon icon={icons.ui.arrow.left} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(_, event) => {
|
||||
if (isPrimaryClick(event)) {
|
||||
step(+1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<icon icon={icons.ui.arrow.right} />
|
||||
</button>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface EnumInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
values: T[];
|
||||
}
|
||||
23
src/components/settings/shared/inputs/font.tsx
Normal file
23
src/components/settings/shared/inputs/font.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import FontButton from 'src/components/shared/FontButton';
|
||||
import { Opt } from 'src/lib/option';
|
||||
|
||||
export const FontInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
}: FontInputterProps<T>): JSX.Element => {
|
||||
return (
|
||||
<FontButton
|
||||
showSize={false}
|
||||
useSize={false}
|
||||
setup={(self) => {
|
||||
self.font = opt.get() as string;
|
||||
|
||||
self.hook(opt, () => (self.font = opt.get() as string));
|
||||
self.connect('font-set', ({ font }) => opt.set(font!.split(' ').slice(0, -1).join(' ') as T));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface FontInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
}
|
||||
35
src/components/settings/shared/inputs/image.tsx
Normal file
35
src/components/settings/shared/inputs/image.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Gtk } from 'astal/gtk3';
|
||||
import FileChooserButton from 'src/components/shared/FileChooseButton';
|
||||
import { Opt } from 'src/lib/option';
|
||||
|
||||
const handleFileSet =
|
||||
<T,>(opt: Opt<T>) =>
|
||||
(self: Gtk.FileChooserButton): void => {
|
||||
const uri = self.get_uri();
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedPath = decodeURIComponent(uri.replace('file://', ''));
|
||||
opt.set(decodedPath as unknown as T);
|
||||
} catch (error) {
|
||||
console.error('Failed to decode URI:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const ImageInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
}: ImageInputterProps<T>): JSX.Element => {
|
||||
return (
|
||||
<FileChooserButton
|
||||
on_file_set={(self) => {
|
||||
return handleFileSet(opt)(self);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImageInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
}
|
||||
34
src/components/settings/shared/inputs/import.tsx
Normal file
34
src/components/settings/shared/inputs/import.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ThemeExportData } from 'src/lib/types/options';
|
||||
import { importFiles, saveFileDialog } from '../FileChooser';
|
||||
import { isPrimaryClick } from 'src/lib/utils';
|
||||
|
||||
export const ImportInputter = ({ exportData }: ImportInputterProps): JSX.Element => {
|
||||
return (
|
||||
<box>
|
||||
<button
|
||||
className="options-import"
|
||||
onClick={(_, event) => {
|
||||
if (isPrimaryClick(event)) {
|
||||
importFiles(exportData?.themeOnly as boolean);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label label="import" />
|
||||
</button>
|
||||
<button
|
||||
className="options-export"
|
||||
onClick={(_, event) => {
|
||||
if (isPrimaryClick(event)) {
|
||||
saveFileDialog(exportData?.filePath as string, exportData?.themeOnly as boolean);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label label="export" />
|
||||
</button>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportInputterProps {
|
||||
exportData?: ThemeExportData;
|
||||
}
|
||||
60
src/components/settings/shared/inputs/number.tsx
Normal file
60
src/components/settings/shared/inputs/number.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { bind, Variable } from 'astal';
|
||||
import { Gtk } from 'astal/gtk3';
|
||||
import SpinButton from 'src/components/shared/SpinButton';
|
||||
import icons from 'src/lib/icons/icons';
|
||||
import { Opt } from 'src/lib/option';
|
||||
import { useHook } from 'src/lib/shared/hookHandler';
|
||||
|
||||
export const NumberInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
min,
|
||||
max,
|
||||
increment = 1,
|
||||
isUnsaved,
|
||||
}: NumberInputterProps<T>): JSX.Element => {
|
||||
return (
|
||||
<box>
|
||||
<box className="unsaved-icon-container" halign={Gtk.Align.START}>
|
||||
{bind(isUnsaved).as((unsaved) => {
|
||||
if (unsaved) {
|
||||
return (
|
||||
<icon
|
||||
className="unsaved-icon"
|
||||
icon={icons.ui.warning}
|
||||
tooltipText="Press 'Enter' to apply your changes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <box />;
|
||||
})}
|
||||
</box>
|
||||
<SpinButton
|
||||
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());
|
||||
});
|
||||
|
||||
self.connect('key-release-event', () => {
|
||||
isUnsaved.set(Number(self.get_text()) !== opt.get());
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface NumberInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
min: number;
|
||||
max: number;
|
||||
increment?: number;
|
||||
isUnsaved: Variable<boolean>;
|
||||
}
|
||||
61
src/components/settings/shared/inputs/object.tsx
Normal file
61
src/components/settings/shared/inputs/object.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { bind, Variable } from 'astal';
|
||||
import icons from 'src/lib/icons/icons';
|
||||
import { Opt } from 'src/lib/option';
|
||||
|
||||
export const ObjectInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
isUnsaved,
|
||||
className,
|
||||
}: ObjectInputterProps<T>): JSX.Element => {
|
||||
return (
|
||||
<box>
|
||||
<box className="unsaved-icon-container">
|
||||
{bind(isUnsaved).as((unsaved) => {
|
||||
if (unsaved) {
|
||||
return (
|
||||
<icon
|
||||
className="unsaved-icon"
|
||||
icon={icons.ui.warning}
|
||||
tooltipText="Press 'Enter' to apply your changes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <box />;
|
||||
})}
|
||||
</box>
|
||||
|
||||
<entry
|
||||
className={className}
|
||||
onChanged={(self) => {
|
||||
const currentText = self.text;
|
||||
const serializedOpt = JSON.stringify(opt.get());
|
||||
isUnsaved.set(currentText !== serializedOpt);
|
||||
}}
|
||||
onActivate={(self) => {
|
||||
try {
|
||||
const parsedValue = JSON.parse(self.text || '{}');
|
||||
opt.set(parsedValue);
|
||||
isUnsaved.set(false);
|
||||
} catch (error) {
|
||||
console.error('Invalid JSON input:', error);
|
||||
}
|
||||
}}
|
||||
setup={(self) => {
|
||||
self.text = JSON.stringify(opt.get());
|
||||
isUnsaved.set(self.text !== JSON.stringify(opt.get()));
|
||||
|
||||
self.hook(opt, () => {
|
||||
self.text = JSON.stringify(opt.get());
|
||||
isUnsaved.set(self.text !== JSON.stringify(opt.get()));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
interface ObjectInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
isUnsaved: Variable<boolean>;
|
||||
className: string;
|
||||
}
|
||||
51
src/components/settings/shared/inputs/string.tsx
Normal file
51
src/components/settings/shared/inputs/string.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { bind, Variable } from 'astal';
|
||||
import icons from 'src/lib/icons/icons';
|
||||
import { Opt } from 'src/lib/option';
|
||||
|
||||
export const StringInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
isUnsaved,
|
||||
}: StringInputterProps<T>): JSX.Element => {
|
||||
return (
|
||||
<box>
|
||||
<box className="unsaved-icon-container">
|
||||
{bind(isUnsaved).as((unsaved) => {
|
||||
if (unsaved) {
|
||||
return (
|
||||
<icon
|
||||
className="unsaved-icon"
|
||||
icon={icons.ui.warning}
|
||||
tooltipText="Press 'Enter' to apply your changes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <box />;
|
||||
})}
|
||||
</box>
|
||||
<entry
|
||||
className={bind(isUnsaved).as((unsaved) => (unsaved ? 'unsaved' : ''))}
|
||||
onChanged={(self) => {
|
||||
const currentText = self.text;
|
||||
const optValue = opt.get();
|
||||
isUnsaved.set(currentText !== optValue);
|
||||
}}
|
||||
onActivate={(self) => {
|
||||
opt.set(self.text as T);
|
||||
}}
|
||||
setup={(self) => {
|
||||
self.text = opt.get() as string;
|
||||
isUnsaved.set(self.text !== opt.get());
|
||||
|
||||
self.hook(opt, () => {
|
||||
isUnsaved.set(self.text !== opt.get());
|
||||
self.text = opt.get() as string;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
interface StringInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
isUnsaved: Variable<boolean>;
|
||||
}
|
||||
27
src/components/settings/shared/inputs/wallpaper.tsx
Normal file
27
src/components/settings/shared/inputs/wallpaper.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import FileChooserButton from 'src/components/shared/FileChooseButton';
|
||||
import { Opt } from 'src/lib/option';
|
||||
import Wallpaper from 'src/services/Wallpaper';
|
||||
|
||||
export const WallpaperInputter = <T extends string | number | boolean | object>({
|
||||
opt,
|
||||
}: WallpaperInputterProps<T>): JSX.Element => {
|
||||
if (typeof opt.get() === 'string') {
|
||||
return (
|
||||
<FileChooserButton
|
||||
onFileSet={(self) => {
|
||||
const newValue: string = self.get_uri()!.replace('file://', '');
|
||||
opt.set(newValue as T);
|
||||
if (options.wallpaper.enable.get()) {
|
||||
Wallpaper.setWallpaper(newValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <box />;
|
||||
};
|
||||
|
||||
interface WallpaperInputterProps<T> {
|
||||
opt: Opt<T>;
|
||||
}
|
||||
Reference in New Issue
Block a user