Feat: Custom modules can now be created through a JSON file. (#887)

* Feat: Custom modules can now be created through a JSON file.

* Added the ability to consume labels and icons.

* Add all properties but styling.

* Wrap up implementation.

* Rename custom modules to basic modules to make way for new actually custom modules.
This commit is contained in:
Jas Singh
2025-04-07 01:52:39 -07:00
committed by GitHub
parent 483facfa56
commit 93235f0fb1
31 changed files with 820 additions and 377 deletions

View File

@@ -0,0 +1,64 @@
import { Gio, readFileAsync } from 'astal';
import { CustomBarModule } from './types';
import { ModuleContainer } from './module_container';
import { WidgetContainer } from '../shared/WidgetContainer';
import { WidgetMap } from '..';
export class CustomModules {
constructor() {}
public static async build(): Promise<WidgetMap> {
const customModuleMap = await this._getCustomModules();
const customModuleComponents: WidgetMap = {};
try {
Object.entries(customModuleMap).map(([moduleName, moduleMetadata]) => {
if (!moduleName.startsWith('custom/')) {
return;
}
customModuleComponents[moduleName] = (): JSX.Element =>
WidgetContainer(ModuleContainer(moduleName, moduleMetadata));
});
return customModuleComponents;
} catch (error) {
console.log(`Failed to build custom modules in ${CONFIG_DIR}: ${error}`);
throw new Error(`Failed to build custom modules in ${CONFIG_DIR}: ${error}`);
}
}
private static async _getCustomModules(): Promise<Record<string, CustomBarModule>> {
try {
const filesInConfigDir = await this._getFilesInConfigDir();
const modulesFile = filesInConfigDir.find((file) => file.match(/^modules(\.json)?$/));
const pathToModulesFile = `${CONFIG_DIR}/${modulesFile}`;
const customModulesFileContent = await readFileAsync(pathToModulesFile);
const modulesObject = JSON.parse(customModulesFileContent);
return modulesObject;
} catch (error) {
throw new Error(`Failed to parse modules file in ${CONFIG_DIR}: ${error}`);
}
}
private static async _getFilesInConfigDir(): Promise<string[]> {
const file = Gio.File.new_for_path(CONFIG_DIR);
const enumerator = file.enumerate_children('standard::*', Gio.FileQueryInfoFlags.NONE, null);
const fileNames = [];
for (const info of enumerator) {
const fileType = info.get_file_type();
const fileName = info.get_name();
if (fileType === Gio.FileType.REGULAR) {
fileNames.push(fileName);
}
}
enumerator.close(null);
return fileNames;
}
}

View File

@@ -0,0 +1,132 @@
import { isPrimitive } from 'src/lib/utils';
import { CustomBarModuleIcon } from '../../types';
import { parseCommandOutputJson } from './utils';
const ERROR_ICON = '';
/**
* Resolves the appropriate icon for a custom bar module based on its configuration and command output
*
* @param moduleName - The name of the module requesting the icon
* @param commandOutput - The raw output string from the module's command execution
* @param moduleIcon - The module's configuration metadata containing icon settings
* @returns The resolved icon string based on the configuration, or ERROR_ICON if resolution fails
*
* @example
* // Using a static icon
* getIcon('myModule', '', { icon: '🚀' }) // returns '🚀'
*
* // Using an array of icons based on percentage
* getIcon('myModule', '{"percentage": 50}', { icon: ['😡', '😐', '😊'] })
*
* // Using an object mapping for specific states
* getIcon('myModule', '{"alt": "success"}', { icon: { success: '✅', error: '❌' } })
*/
export function getIcon(moduleName: string, commandOutput: string, moduleIcon: CustomBarModuleIcon): string {
if (Array.isArray(moduleIcon)) {
return getIconFromArray(moduleName, commandOutput, moduleIcon);
}
if (typeof moduleIcon === 'object') {
return getIconFromObject(moduleName, commandOutput, moduleIcon);
}
return moduleIcon;
}
/**
* Resolves an icon from an object configuration based on the 'alt' value in command output
*
* @param moduleName - The name of the module requesting the icon
* @param commandOutput - The raw output string from the module's command execution
* @param iconObject - Object mapping alternate text to corresponding icons
* @returns The matched icon string or ERROR_ICON if resolution fails
*
* @throws Logs error and returns ERROR_ICON if:
* - Command output cannot be parsed
* - 'alt' value is not a string
* - No matching icon is found for the alt text
* - Corresponding icon value is not a string
*/
function getIconFromObject(moduleName: string, commandOutput: string, iconObject: Record<string, unknown>): string {
try {
const commandResults: CommandResults = parseCommandOutputJson(moduleName, commandOutput);
if (!isPrimitive(commandResults?.alt) || commandResults?.alt === undefined) {
console.error(`Expected 'alt' to be a primitive for module: ${moduleName}`);
return ERROR_ICON;
}
const resultsAltText = String(commandResults?.alt);
const correspondingAltIcon = iconObject[resultsAltText];
if (correspondingAltIcon === undefined) {
console.error(`Corresponding icon ${resultsAltText} not found for module: ${moduleName}`);
return typeof iconObject.default === 'string' ? iconObject.default : ERROR_ICON;
}
if (typeof correspondingAltIcon !== 'string') {
console.error(`Corresponding icon ${resultsAltText} is not a string for module: ${moduleName}`);
return ERROR_ICON;
}
return correspondingAltIcon;
} catch {
return ERROR_ICON;
}
}
/**
* Resolves an icon from an array configuration based on the percentage value in command output
*
* @param moduleName - The name of the module requesting the icon
* @param commandOutput - The raw output string from the module's command execution
* @param iconArray - Array of icons to select from based on percentage ranges
* @returns The appropriate icon string based on the percentage or ERROR_ICON if resolution fails
*
* @example
* // With iconArray ['😡', '😐', '😊']
* // 0-33%: returns '😡'
* // 34-66%: returns '😐'
* // 67-100%: returns '😊'
*
* @throws Logs error and returns ERROR_ICON if:
* - Command output cannot be parsed
* - Percentage value is not a number
* - Percentage is NaN or exceeds 100
*/
function getIconFromArray(moduleName: string, commandOutput: string, iconArray: string[]): string {
try {
const commandResults: CommandResults = parseCommandOutputJson(moduleName, commandOutput);
const resultsPercentage = commandResults?.percentage;
if (typeof resultsPercentage !== 'number') {
console.error(`Expected percentage to be a number for module: ${moduleName}`);
return ERROR_ICON;
}
if (isNaN(resultsPercentage) || resultsPercentage > 100) {
console.error(`Expected percentage to be between 1-100 for module: ${moduleName}`);
return ERROR_ICON;
}
const step = 100 / iconArray.length;
const iconForStep = iconArray.find((_, index) => resultsPercentage <= step * (index + 1));
return iconForStep || ERROR_ICON;
} catch {
return ERROR_ICON;
}
}
/**
* Represents the expected structure of parsed command output
*/
type CommandResults = {
/** Alternate text identifier for object-based icon configuration */
alt?: string;
/** Percentage value for array-based icon configuration (0-100) */
percentage?: number;
};

View File

@@ -0,0 +1,113 @@
import { isPrimitive } from 'src/lib/utils';
/**
* Generates a label based on module command output and a template configuration.
*
* @param moduleName - The name of the module (used for error reporting)
* @param commandOutput - The raw output from a module command, expected to be a JSON string or plain text
* @param labelConfig - A template string containing variables in the format {path.to.value}
* @returns A formatted label with template variables replaced with actual values
*
* @example
* // For a JSON command output: {"user": {"name": "Jim Halpert"}}
* // And labelConfig: "Hello, {user.name}!"
* // Returns: "Hello, Jim Halpert!"
*/
export function getLabel(moduleName: string, commandOutput: string, labelConfig: string): string {
const processedCommandOutput = tryParseJson(moduleName, commandOutput);
const regexForTemplateVariables = /\{([^{}]*)\}/g;
return labelConfig.replace(regexForTemplateVariables, (_, path) => {
return getValueForTemplateVariable(path, processedCommandOutput);
});
}
/**
* Extracts a value from command output based on a template variable path.
*
* @param templatePath - The dot-notation path to extract (e.g., "user.name")
* @param commandOutput - The processed command output (either a string or object)
* @returns The extracted value as a string, or empty string if not found
*/
function getValueForTemplateVariable(templatePath: string, commandOutput: string | Record<string, unknown>): string {
if (typeof commandOutput === 'string') {
return getTemplateValueForStringOutput(templatePath, commandOutput);
}
if (typeof commandOutput === 'object' && commandOutput !== null) {
return getTemplateValueForObjectOutput(templatePath, commandOutput);
}
return '';
}
/**
* Extracts a template value from string command output.
*
* @param templatePath - The path to extract value from
* @param commandOutput - The string command output
* @returns The entire string if path is empty, otherwise empty string
*/
function getTemplateValueForStringOutput(templatePath: string, commandOutput: string): string {
if (templatePath === '') {
return commandOutput;
}
return '';
}
/**
* Extracts a template value from object command output using dot notation.
*
* @param templatePath - The dot-notation path to extract (e.g., "user.name")
* @param commandOutput - The object representing parsed command output
* @returns The extracted value as a string, or empty string if path is invalid or value is not primitive
*/
function getTemplateValueForObjectOutput(templatePath: string, commandOutput: Record<string, unknown>): string {
const pathParts = templatePath.split('.');
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && !Array.isArray(value) && typeof value === 'object';
}
try {
const result = pathParts.reduce<unknown>((acc, part) => {
if (!isRecord(acc)) {
throw new Error('Path unreachable');
}
return acc[part];
}, commandOutput);
return isPrimitive(result) && result !== undefined ? String(result) : '';
} catch {
return '';
}
}
/**
* Attempts to parse a JSON string, with fallback to the original string.
*
* @param moduleName - The name of the module (used for error reporting)
* @param commandOutput - The raw string output to parse as JSON
* @returns A parsed object if valid JSON and an object, otherwise the original string
*/
function tryParseJson(moduleName: string, commandOutput: string): string | Record<string, unknown> {
try {
if (typeof commandOutput !== 'string') {
console.error(
`Expected command output to be a string but found ${typeof commandOutput} for module: ${moduleName}`,
);
return '';
}
const parsedCommand = JSON.parse(commandOutput);
if (typeof parsedCommand === 'object' && parsedCommand !== null && !Array.isArray(parsedCommand)) {
return parsedCommand as Record<string, unknown>;
}
return commandOutput;
} catch {
return commandOutput;
}
}

View File

@@ -0,0 +1,11 @@
export function parseCommandOutputJson(moduleName: string, cmdOutput: unknown): Record<string, unknown> {
try {
if (typeof cmdOutput !== 'string') {
throw new Error('Input must be a string');
}
return JSON.parse(cmdOutput);
} catch {
throw new Error(`The command output for the following module is not valid JSON: ${moduleName}`);
}
}

View File

@@ -0,0 +1,48 @@
import { BarBoxChild } from 'src/lib/types/bar.js';
import { CustomBarModule } from '../types';
import { Module } from '../../shared/Module';
import { Astal } from 'astal/gtk3';
import { bind, Variable } from 'astal';
import { getIcon } from './helpers/icon';
import { getLabel } from './helpers/label';
import { initActionListener, initCommandPoller, setupModuleInteractions } from './setup';
export const ModuleContainer = (moduleName: string, moduleMetadata: CustomBarModule): BarBoxChild => {
const {
icon: moduleIcon = '',
label: moduleLabel = '',
tooltip: moduleTooltip = '',
truncationSize: moduleTruncation = -1,
execute: moduleExecute = '',
executeOnAction: moduleExecuteOnAction = '',
interval: moduleInterval = -1,
hideOnEmpty: moduleHideOnEmpty = false,
scrollThreshold: moduleScrollThreshold = 4,
actions: moduleActions = {},
} = moduleMetadata;
const pollingInterval: Variable<number> = Variable(moduleInterval);
const actionExecutionListener: Variable<boolean> = Variable(true);
const commandOutput: Variable<string> = Variable('');
const commandPoller = initCommandPoller(commandOutput, pollingInterval, moduleExecute, moduleInterval);
initActionListener(actionExecutionListener, moduleExecuteOnAction, commandOutput);
const module = Module({
textIcon: bind(commandOutput).as((cmdOutput) => getIcon(moduleName, cmdOutput, moduleIcon)),
tooltipText: bind(commandOutput).as((cmdOutput) => getLabel(moduleName, cmdOutput, moduleTooltip)),
boxClass: `cmodule-${moduleName.replace(/custom\//, '')}`,
label: bind(commandOutput).as((cmdOutput) => getLabel(moduleName, cmdOutput, moduleLabel)),
truncationSize: bind(Variable(typeof moduleTruncation === 'number' ? moduleTruncation : -1)),
props: {
setup: (self: Astal.Button) =>
setupModuleInteractions(self, moduleActions, actionExecutionListener, moduleScrollThreshold),
onDestroy: () => {
commandPoller.stop();
},
},
isVis: bind(commandOutput).as((cmdOutput) => (moduleHideOnEmpty ? cmdOutput.length > 0 : true)),
});
return module;
};

View File

@@ -0,0 +1,76 @@
import { Variable, bind, execAsync } from 'astal';
import { Astal } from 'astal/gtk3';
import { BashPoller } from 'src/lib/poller/BashPoller';
import { CustomBarModule } from '../types';
import { inputHandler } from '../../utils/helpers';
export function initCommandPoller(
commandOutput: Variable<string>,
pollingInterval: Variable<number>,
moduleExecute: string,
moduleInterval: number,
): BashPoller<string, []> {
const commandPoller = new BashPoller<string, []>(
commandOutput,
[],
bind(pollingInterval),
moduleExecute || '',
(commandResult: string) => commandResult,
);
if (moduleInterval >= 0) {
commandPoller.initialize();
}
return commandPoller;
}
export function initActionListener(
actionExecutionListener: Variable<boolean>,
moduleExecuteOnAction: string,
commandOutput: Variable<string>,
): void {
actionExecutionListener.subscribe(() => {
if (typeof moduleExecuteOnAction !== 'string' || !moduleExecuteOnAction.length) {
return;
}
execAsync(moduleExecuteOnAction).then((cmdOutput) => {
commandOutput.set(cmdOutput);
});
});
}
/**
* Sets up user interaction handlers for the module
*/
export function setupModuleInteractions(
element: Astal.Button,
moduleActions: CustomBarModule['actions'],
actionListener: Variable<boolean>,
moduleScrollThreshold: number,
): void {
const scrollThreshold = moduleScrollThreshold >= 0 ? moduleScrollThreshold : 1;
inputHandler(
element,
{
onPrimaryClick: {
cmd: Variable(moduleActions?.onLeftClick ?? ''),
},
onSecondaryClick: {
cmd: Variable(moduleActions?.onRightClick ?? ''),
},
onMiddleClick: {
cmd: Variable(moduleActions?.onMiddleClick ?? ''),
},
onScrollUp: {
cmd: Variable(moduleActions?.onScrollUp ?? ''),
},
onScrollDown: {
cmd: Variable(moduleActions?.onScrollDown ?? ''),
},
},
actionListener,
scrollThreshold,
);
}

View File

@@ -0,0 +1,20 @@
export type CustomBarModuleActions = {
onLeftClick?: string;
onRightClick?: string;
onMiddleClick?: string;
onScrollUp?: string;
onScrollDown?: string;
};
export type CustomBarModule = {
icon?: CustomBarModuleIcon;
label?: string;
tooltip?: string;
truncationSize?: number;
execute?: string;
executeOnAction?: string;
interval?: number;
hideOnEmpty?: boolean;
scrollThreshold?: number;
actions?: CustomBarModuleActions;
};
export type CustomBarModuleIcon = string | string[] | Record<string, string>;

View File

@@ -10,7 +10,7 @@ import { BatteryLabel } from '../../components/bar/modules/battery/index';
import { Clock } from '../../components/bar/modules/clock/index';
import { SysTray } from '../../components/bar/modules/systray/index';
// Custom Modules
// Basic Modules
import { Microphone } from '../../components/bar/modules/microphone/index';
import { Ram } from '../../components/bar/modules/ram/index';
import { Cpu } from '../../components/bar/modules/cpu/index';
@@ -40,7 +40,7 @@ export {
Clock,
SysTray,
// Custom Modules
// Basic Modules
Microphone,
Ram,
Cpu,

View File

@@ -10,8 +10,6 @@ import {
BatteryLabel,
Clock,
SysTray,
// Custom Modules
Microphone,
Ram,
Cpu,
@@ -37,12 +35,13 @@ import Astal from 'gi://Astal?version=3.0';
import { bind, Variable } from 'astal';
import { getLayoutForMonitor, isLayoutEmpty } from './utils/monitors';
import { GdkMonitorMapper } from './utils/GdkMonitorMapper';
import { CustomModules } from './custom_modules/CustomModules';
const { layouts } = options.bar;
const { location } = options.theme.bar;
const { location: borderLocation } = options.theme.bar.border;
const widget = {
let widgets: WidgetMap = {
battery: (): JSX.Element => WidgetContainer(BatteryLabel()),
dashboard: (): JSX.Element => WidgetContainer(Menu()),
workspaces: (monitor: number): JSX.Element => WidgetContainer(Workspaces(monitor)),
@@ -73,7 +72,16 @@ const widget = {
const gdkMonitorMapper = new GdkMonitorMapper();
export const Bar = (monitor: number): JSX.Element => {
export const Bar = async (monitor: number): Promise<JSX.Element> => {
try {
const customWidgets = await CustomModules.build();
widgets = {
...widgets,
...customWidgets,
};
} catch (error) {
console.log(error);
}
const hyprlandMonitor = gdkMonitorMapper.mapGdkToHyprland(monitor);
const computeVisibility = bind(layouts).as(() => {
@@ -116,22 +124,22 @@ export const Bar = (monitor: number): JSX.Element => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, currentLayouts);
return foundLayout.left
.filter((mod) => Object.keys(widget).includes(mod))
.map((w) => widget[w](hyprlandMonitor));
.filter((mod) => Object.keys(widgets).includes(mod))
.map((w) => widgets[w](hyprlandMonitor));
});
const middleBinding = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, currentLayouts);
return foundLayout.middle
.filter((mod) => Object.keys(widget).includes(mod))
.map((w) => widget[w](hyprlandMonitor));
.filter((mod) => Object.keys(widgets).includes(mod))
.map((w) => widgets[w](hyprlandMonitor));
});
const rightBinding = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, currentLayouts);
return foundLayout.right
.filter((mod) => Object.keys(widget).includes(mod))
.map((w) => widget[w](hyprlandMonitor));
.filter((mod) => Object.keys(widgets).includes(mod))
.map((w) => widgets[w](hyprlandMonitor));
});
return (
@@ -178,3 +186,7 @@ export const Bar = (monitor: number): JSX.Element => {
</window>
);
};
export type WidgetMap = {
[K in string]: (monitor: number) => JSX.Element;
};

View File

@@ -6,7 +6,7 @@ import { Gtk } from 'astal/gtk3';
export const CustomModuleSettings = (): JSX.Element => {
return (
<scrollable
name={'Custom Modules'}
name={'Basic Modules'}
className="menu-theme-page customModules paged-container"
vscroll={Gtk.PolicyType.AUTOMATIC}
hscroll={Gtk.PolicyType.AUTOMATIC}

View File

@@ -7,7 +7,7 @@ import { Gtk } from 'astal/gtk3';
export const CustomModuleTheme = (): JSX.Element => {
return (
<scrollable
name={'Custom Modules'}
name={'Basic Modules'}
className="menu-theme-page customModules paged-container"
vscroll={Gtk.PolicyType.AUTOMATIC}
hscroll={Gtk.PolicyType.AUTOMATIC}

View File

@@ -10,6 +10,7 @@ export const Module = ({
textIcon,
useTextIcon = bind(Variable(false)),
label,
truncationSize = bind(Variable(-1)),
tooltipText = '',
boxClass,
isVis,
@@ -21,15 +22,17 @@ export const Module = ({
hook,
}: BarModule): BarBoxChild => {
const getIconWidget = (useTxtIcn: boolean): JSX.Element | undefined => {
let iconWidget: JSX.Element | undefined;
const className = `txt-icon bar-button-icon module-icon ${boxClass}`;
if (icon !== undefined && icon.get() != '' && !useTxtIcn) {
iconWidget = <icon className={`txt-icon bar-button-icon module-icon ${boxClass}`} icon={icon} />;
} else if (textIcon !== undefined && textIcon.get() != '') {
iconWidget = <label className={`txt-icon bar-button-icon module-icon ${boxClass}`} label={textIcon} />;
const icn = typeof icon === 'string' ? icon : icon?.get();
if (!useTxtIcn && icn?.length) {
return <icon className={className} icon={icon} />;
}
return iconWidget;
const textIcn = typeof textIcon === 'string' ? textIcon : textIcon?.get();
if (textIcn?.length) {
return <label className={className} label={textIcon} />;
}
};
const componentClass = Variable.derive(
@@ -60,6 +63,8 @@ export const Module = ({
childrenArray.push(
<label
className={`bar-button-label module-label ${boxClass}`}
truncate={truncationSize.as((truncSize) => truncSize > 0)}
maxWidthChars={truncationSize.as((truncSize) => truncSize)}
label={label ?? ''}
setup={labelHook}
/>,

View File

@@ -42,7 +42,7 @@ export const runAsyncCommand: RunAsyncCommand = (cmd, events, fn, postInputUpdat
if (cmd.startsWith('menu:')) {
const menuName = cmd.split(':')[1].trim().toLowerCase();
openMenu(events.clicked, events.event, `${menuName}menu`);
handlePostInputUpdater(postInputUpdater);
return;
}
@@ -122,8 +122,9 @@ export const inputHandler = (
onScrollDown: onScrollDownInput,
}: InputHandlerEvents,
postInputUpdater?: Variable<boolean>,
customScrollThreshold?: number,
): void => {
const sanitizeInput = (input?: Variable<string> | Variable<string>): string => {
const sanitizeInput = (input?: Variable<string>): string => {
if (input === undefined) {
return '';
}
@@ -131,7 +132,7 @@ export const inputHandler = (
};
const updateHandlers = (): UpdateHandlers => {
const interval = scrollSpeed.get();
const interval = customScrollThreshold ?? scrollSpeed.get();
const throttledHandler = throttledScrollHandler(interval);
const disconnectPrimaryClick = onPrimaryClick(self, (clicked: GtkWidget, event: Gdk.Event) => {

View File

@@ -16,7 +16,7 @@ export const themePages = [
'System Tray',
'Volume Menu',
'Power Menu',
'Custom Modules',
'Basic Modules',
] as const;
export const configPages = [
@@ -28,7 +28,7 @@ export const configPages = [
'Volume',
'Clock Menu',
'Dashboard Menu',
'Custom Modules',
'Basic Modules',
'Power Menu',
] as const;

View File

@@ -30,7 +30,7 @@ export const BarGeneral = (): JSX.Element => {
title="Config"
subtitle="WARNING: Importing a configuration will replace your current configuration settings."
type="config_import"
exportData={{ filePath: CONFIG, themeOnly: false }}
exportData={{ filePath: CONFIG_FILE, themeOnly: false }}
/>
<Option
opt={options.hyprpanel.restartAgs}

View File

@@ -22,7 +22,7 @@ export const MenuTheme = (): JSX.Element => {
title="Theme"
subtitle="WARNING: Importing a theme will replace your current theme color settings."
type="config_import"
exportData={{ filePath: CONFIG, themeOnly: true }}
exportData={{ filePath: CONFIG_FILE, themeOnly: true }}
/>
<Option
opt={options.theme.bar.menus.monochrome}

View File

@@ -272,7 +272,7 @@ export const importFiles = (themeOnly: boolean = false): void => {
iconName: icons.ui.info,
});
const optionsConfigFile = Gio.File.new_for_path(CONFIG);
const optionsConfigFile = Gio.File.new_for_path(CONFIG_FILE);
const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null);
@@ -289,7 +289,7 @@ export const importFiles = (themeOnly: boolean = false): void => {
: filterConfigForNonTheme(importedConfig);
optionsConfig = { ...optionsConfig, ...filteredConfig };
saveConfigToFile(optionsConfig, CONFIG);
saveConfigToFile(optionsConfig, CONFIG_FILE);
}
dialog.destroy();
bash(restartCommand.get());