diff --git a/.prettierrc b/.prettierrc index 6e88f12..255816c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,13 @@ "trailingComma": "all", "printWidth": 120, "tabWidth": 4, - "useTabs": false + "useTabs": false, + "overrides": [ + { + "files": ["**/*.jsonc"], + "options": { + "parser": "json" + } + } + ] } diff --git a/app.ts b/app.ts index 4457e61..f5d48d7 100644 --- a/app.ts +++ b/app.ts @@ -53,12 +53,15 @@ App.start({ requestHandler(request: string, res: (response: unknown) => void) { runCLI(request, res); }, - main() { + async main() { initializeStartupScripts(); Notifications(); OSD(); - forMonitors(Bar).forEach((bar: JSX.Element) => bar); + + const barsForMonitors = await forMonitors(Bar); + barsForMonitors.forEach((bar: JSX.Element) => bar); + SettingsDialog(); initializeMenus(); diff --git a/package-lock.json b/package-lock.json index 7d53acc..fc70eb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,13 +24,8 @@ "typescript": "^5.6.2" } }, - "../../../../../usr/share/astal/gjs": { - "name": "astal", - "license": "LGPL-2.1" - }, "../../../../usr/share/astal/gjs": { "name": "astal", - "extraneous": true, "license": "LGPL-2.1" }, "node_modules/@eslint-community/eslint-utils": { @@ -606,7 +601,7 @@ } }, "node_modules/astal": { - "resolved": "../../../../../usr/share/astal/gjs", + "resolved": "../../../../usr/share/astal/gjs", "link": true }, "node_modules/available-typed-arrays": { diff --git a/src/cli/commander/commands/utility/index.ts b/src/cli/commander/commands/utility/index.ts index 7398725..66e677a 100644 --- a/src/cli/commander/commands/utility/index.ts +++ b/src/cli/commander/commands/utility/index.ts @@ -120,11 +120,11 @@ export const utilityCommands: Command[] = [ try { const oldFile = Gio.File.new_for_path(oldPath); - const newFile = Gio.File.new_for_path(CONFIG); + const newFile = Gio.File.new_for_path(CONFIG_FILE); if (oldFile.query_exists(null)) { oldFile.move(newFile, Gio.FileCopyFlags.OVERWRITE, null, null); - return `Configuration file moved to ${CONFIG}`; + return `Configuration file moved to ${CONFIG_FILE}`; } else { return `Old configuration file does not exist at ${oldPath}`; } diff --git a/src/components/bar/custom_modules/CustomModules.ts b/src/components/bar/custom_modules/CustomModules.ts new file mode 100644 index 0000000..ef79de8 --- /dev/null +++ b/src/components/bar/custom_modules/CustomModules.ts @@ -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 { + 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> { + 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 { + 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; + } +} diff --git a/src/components/bar/custom_modules/module_container/helpers/icon.ts b/src/components/bar/custom_modules/module_container/helpers/icon.ts new file mode 100644 index 0000000..1c664e6 --- /dev/null +++ b/src/components/bar/custom_modules/module_container/helpers/icon.ts @@ -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 { + 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; +}; diff --git a/src/components/bar/custom_modules/module_container/helpers/label.ts b/src/components/bar/custom_modules/module_container/helpers/label.ts new file mode 100644 index 0000000..b58f262 --- /dev/null +++ b/src/components/bar/custom_modules/module_container/helpers/label.ts @@ -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 { + 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 { + const pathParts = templatePath.split('.'); + + function isRecord(value: unknown): value is Record { + return value !== null && !Array.isArray(value) && typeof value === 'object'; + } + + try { + const result = pathParts.reduce((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 { + 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; + } + + return commandOutput; + } catch { + return commandOutput; + } +} diff --git a/src/components/bar/custom_modules/module_container/helpers/utils.ts b/src/components/bar/custom_modules/module_container/helpers/utils.ts new file mode 100644 index 0000000..4ac8d12 --- /dev/null +++ b/src/components/bar/custom_modules/module_container/helpers/utils.ts @@ -0,0 +1,11 @@ +export function parseCommandOutputJson(moduleName: string, cmdOutput: unknown): Record { + 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}`); + } +} diff --git a/src/components/bar/custom_modules/module_container/index.tsx b/src/components/bar/custom_modules/module_container/index.tsx new file mode 100644 index 0000000..a8f45f7 --- /dev/null +++ b/src/components/bar/custom_modules/module_container/index.tsx @@ -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 = Variable(moduleInterval); + const actionExecutionListener: Variable = Variable(true); + const commandOutput: Variable = 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; +}; diff --git a/src/components/bar/custom_modules/module_container/setup.ts b/src/components/bar/custom_modules/module_container/setup.ts new file mode 100644 index 0000000..dd80f2e --- /dev/null +++ b/src/components/bar/custom_modules/module_container/setup.ts @@ -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, + pollingInterval: Variable, + moduleExecute: string, + moduleInterval: number, +): BashPoller { + const commandPoller = new BashPoller( + commandOutput, + [], + bind(pollingInterval), + moduleExecute || '', + (commandResult: string) => commandResult, + ); + + if (moduleInterval >= 0) { + commandPoller.initialize(); + } + + return commandPoller; +} + +export function initActionListener( + actionExecutionListener: Variable, + moduleExecuteOnAction: string, + commandOutput: Variable, +): 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, + 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, + ); +} diff --git a/src/components/bar/custom_modules/types.ts b/src/components/bar/custom_modules/types.ts new file mode 100644 index 0000000..07d112d --- /dev/null +++ b/src/components/bar/custom_modules/types.ts @@ -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; diff --git a/src/components/bar/exports.ts b/src/components/bar/exports.ts index b4bdc32..f57dcca 100644 --- a/src/components/bar/exports.ts +++ b/src/components/bar/exports.ts @@ -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, diff --git a/src/components/bar/index.tsx b/src/components/bar/index.tsx index 5025de9..233ee75 100644 --- a/src/components/bar/index.tsx +++ b/src/components/bar/index.tsx @@ -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 => { + 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 => { ); }; + +export type WidgetMap = { + [K in string]: (monitor: number) => JSX.Element; +}; diff --git a/src/components/bar/settings/config.tsx b/src/components/bar/settings/config.tsx index 8202f52..cf9e246 100644 --- a/src/components/bar/settings/config.tsx +++ b/src/components/bar/settings/config.tsx @@ -6,7 +6,7 @@ import { Gtk } from 'astal/gtk3'; export const CustomModuleSettings = (): JSX.Element => { return ( { return ( { 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 = ; - } else if (textIcon !== undefined && textIcon.get() != '') { - iconWidget =