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:
Jas Singh
2024-12-20 18:10:10 -08:00
committed by GitHub
parent 955eed6c60
commit 2ffd602910
605 changed files with 19543 additions and 15999 deletions

View 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());
};

View 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;
}

View 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>;
}

View 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;
}

View 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;
}

View 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>
);
};

View 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>;
}

View 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;
}

View 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[];
}

View 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>;
}

View 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[];
}

View 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>;
}

View 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>;
}

View 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;
}

View 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>;
}

View 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;
}

View 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>;
}

View 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>;
}