Minor: Refactor the code-base for better organization and compartmentalization. (#934)

* Clean up unused code

* Fix media player formatting issue for labels with new line characteres.

* Refactor the media player handlers into a class.

* More code cleanup and organize shared weather utils into distinct classes.

* Flatten some nesting.

* Move weather manager in dedicated class and build HTTP Utility class for Rest API calling.

* Remove logs

* Rebase master merge

* Reorg code (WIP)

* More reorg

* Delete utility scripts

* Reorg options

* Finish moving all options over

* Fix typescript issues

* Update options imports to default

* missed update

* Screw barrel files honestly, work of the devil.

* Only initialize power profiles if power-profiles-daemon is running.

* Fix window positioning and weather service naming

* style dir

* More organization

* Restructure types to be closer to their source

* Remove lib types and constants

* Update basic weather object to be saner with extensibility.

* Service updates

* Fix initialization strategy for services.

* Fix Config Manager to only emit changed objects and added missing temp converters.

* Update storage service to handle unit changes.

* Added cpu temp sensor auto-discovery

* Added missing JSDocs to services

* remove unused

* Migrate to network service.

* Fix network password issue.

* Move out password input into helper

* Rename password mask constant to be less double-negativey.

* Dropdown menu rename

* Added a component to edit JSON in the settings dialog (rough/WIP)

* Align settings

* Add and style JSON Editor.

* Adjust padding

* perf(shortcuts):  avoid unnecessary polling when shortcuts are disabled

Stops the recording poller when shortcuts are disabled, preventing redundant polling and reducing resource usage.

* Fix types and return value if shortcut not enabled.

* Move the swww daemon checking process outside of the wallpaper service into a dedicated deamon lifecyle processor.

* Add more string formatters and use title case for weather status (as it was).

* Fix startup errors.

* Rgba fix

* Remove zod from dependencies

---------

Co-authored-by: KernelDiego <gonzalezdiego.contact@gmail.com>
This commit is contained in:
Jas Singh
2025-05-26 19:45:11 -07:00
committed by GitHub
parent 436dcbfcf2
commit 8cf5806766
532 changed files with 13134 additions and 8669 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
.weather.json
node_modules
prepare
@girs
**/.claude/settings.local.json

59
app.ts
View File

@@ -1,31 +1,27 @@
import './src/lib/session';
import './src/scss/style';
import './src/shared/useTheme';
import './src/shared/wallpaper';
import './src/shared/systray';
import './src/shared/dropdown';
import './src/shared/utilities';
import './src/components/bar/utils/sideEffects';
import './src/style';
import 'src/core/behaviors/bar';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
const hyprland = AstalHyprland.get_default();
import { Bar } from './src/components/bar';
import { DropdownMenus, StandardWindows } from './src/components/menus/exports';
import Notifications from './src/components/notifications';
import SettingsDialog from './src/components/settings/index';
import { bash, forMonitors } from 'src/lib/utils';
import options from 'src/options';
import OSD from 'src/components/osd/index';
import { App } from 'astal/gtk3';
import { execAsync } from 'astal';
import { handleRealization } from 'src/components/menus/shared/dropdown/helpers';
import { isDropdownMenu } from 'src/lib/constants/options.js';
import { initializeSystemBehaviors } from 'src/lib/behaviors';
import { runCLI } from 'src/cli/commander';
import { handleRealization } from 'src/components/menus/shared/dropdown/helpers/helpers';
import { isDropdownMenu } from 'src/components/settings/constants.js';
import { initializeSystemBehaviors } from 'src/core/behaviors';
import { runCLI } from 'src/services/cli/commander';
import { DropdownMenus, StandardWindows } from 'src/components/menus';
import { forMonitors } from 'src/components/bar/utils/monitors';
import options from 'src/configuration';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
const hyprland = AstalHyprland.get_default();
const initializeStartupScripts = (): void => {
execAsync(`python3 ${SRC_DIR}/scripts/bluetooth.py`).catch((err) => console.error(err));
execAsync(`python3 ${SRC_DIR}/scripts/bluetooth.py`).catch((err) =>
console.error('Failed to initialize bluetooth script:', err),
);
};
const initializeMenus = (): void => {
@@ -38,7 +34,10 @@ const initializeMenus = (): void => {
});
DropdownMenus.forEach((window) => {
const windowName = window.name.replace('_default', '').concat('menu').toLowerCase();
const windowName = window.name
.replace(/_default.*/, '')
.concat('menu')
.toLowerCase();
if (!isDropdownMenu(windowName)) {
return;
@@ -54,18 +53,22 @@ App.start({
runCLI(request, res);
},
async main() {
initializeStartupScripts();
try {
initializeStartupScripts();
Notifications();
OSD();
Notifications();
OSD();
const barsForMonitors = await forMonitors(Bar);
barsForMonitors.forEach((bar: JSX.Element) => bar);
const barsForMonitors = await forMonitors(Bar);
barsForMonitors.forEach((bar: JSX.Element) => bar);
SettingsDialog();
initializeMenus();
SettingsDialog();
initializeMenus();
initializeSystemBehaviors();
initializeSystemBehaviors();
} catch (error) {
console.error('Error during application initialization:', error);
}
},
});
@@ -73,6 +76,6 @@ hyprland.connect('monitor-added', () => {
const { restartCommand } = options.hyprpanel;
if (options.hyprpanel.restartAgs.get()) {
bash(restartCommand.get());
SystemUtilities.bash(restartCommand.get());
}
});

139
assets/tokyo-night.xml Normal file
View File

@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Tokyo Night color scheme for GtkSourceView
Ported for HyprPanel
-->
<style-scheme id="tokyo-night" name="Tokyo Night" version="1.0">
<author>HyprPanel - Jas Singh</author>
<description>Tokyo Night color scheme for GtkSourceView</description>
<!-- Global Settings -->
<style name="text" foreground="#c0caf5" background="#1a1b26"/>
<style name="selection" foreground="#c0caf5" background="#283457"/>
<style name="cursor" foreground="#c0caf5"/>
<style name="secondary-cursor" foreground="#c0caf5"/>
<style name="current-line" background="#292e42"/>
<style name="line-numbers" foreground="#565f89" background="#16161e"/>
<style name="draw-spaces" foreground="#565f89"/>
<style name="background-pattern" background="#292e42"/>
<style name="bracket-match" foreground="#c0caf5" background="#3d59a1" bold="true"/>
<style name="bracket-mismatch" foreground="#1a1b26" background="#f7768e" bold="true"/>
<style name="right-margin" foreground="#565f89" background="#16161e"/>
<style name="search-match" foreground="#1a1b26" background="#ff9e64"/>
<!-- Syntax Highlighting -->
<!-- Comments -->
<style name="def:comment" foreground="#565f89" italic="true"/>
<style name="def:shebang" foreground="#565f89" bold="true"/>
<style name="def:doc-comment-element" foreground="#565f89" italic="true"/>
<!-- Constants -->
<style name="def:constant" foreground="#ff9e64"/>
<style name="def:string" foreground="#9ece6a"/>
<style name="def:special-char" foreground="#7dcfff"/>
<style name="def:special-constant" foreground="#bb9af7"/>
<style name="def:number" foreground="#ff9e64"/>
<style name="def:floating-point" foreground="#ff9e64"/>
<style name="def:decimal" foreground="#ff9e64"/>
<style name="def:base-n-integer" foreground="#ff9e64"/>
<style name="def:boolean" foreground="#bb9af7"/>
<style name="def:character" foreground="#9ece6a"/>
<!-- Identifiers -->
<style name="def:identifier" foreground="#c0caf5"/>
<style name="def:function" foreground="#7aa2f7"/>
<style name="def:builtin" foreground="#7dcfff"/>
<!-- Statements -->
<style name="def:statement" foreground="#bb9af7"/>
<style name="def:operator" foreground="#89ddff"/>
<style name="def:keyword" foreground="#bb9af7" bold="true"/>
<style name="def:type" foreground="#7dcfff"/>
<style name="def:reserved" foreground="#bb9af7"/>
<!-- Types -->
<style name="def:type" foreground="#7dcfff"/>
<!-- Others -->
<style name="def:preprocessor" foreground="#bb9af7"/>
<style name="def:error" foreground="#f7768e" underline="error"/>
<style name="def:warning" foreground="#e0af68" underline="error"/>
<style name="def:note" foreground="#7aa2f7" underline="error"/>
<style name="def:net-address-in-comment" foreground="#7dcfff" underline="true"/>
<style name="def:underlined" underline="single"/>
<!-- Language specific -->
<!-- XML & HTML -->
<style name="xml:attribute-name" foreground="#7aa2f7"/>
<style name="xml:element-name" foreground="#f7768e"/>
<style name="xml:entity" foreground="#bb9af7"/>
<style name="xml:namespace" foreground="#f7768e" underline="true"/>
<style name="xml:tag" foreground="#f7768e"/>
<style name="xml:doctype" foreground="#bb9af7"/>
<style name="xml:cdata-delim" foreground="#8c8c8c" bold="true"/>
<style name="html:dtd" foreground="#bb9af7"/>
<style name="html:tag" foreground="#f7768e"/>
<!-- CSS -->
<style name="css:keyword" foreground="#7aa2f7"/>
<style name="css:at-rules" foreground="#bb9af7"/>
<style name="css:color" foreground="#ff9e64"/>
<style name="css:string" foreground="#9ece6a"/>
<!-- Diff -->
<style name="diff:added-line" foreground="#9ece6a"/>
<style name="diff:removed-line" foreground="#f7768e"/>
<style name="diff:changed-line" foreground="#e0af68"/>
<style name="diff:special-case" foreground="#bb9af7"/>
<style name="diff:location" foreground="#7aa2f7" bold="true"/>
<style name="diff:diff-file" foreground="#e0af68" bold="true"/>
<!-- JSON specific -->
<style name="json:keyname" foreground="#7aa2f7"/>
<style name="json:special-char" foreground="#89ddff"/>
<style name="json:string" foreground="#9ece6a"/>
<style name="json:boolean" foreground="#ff9e64"/>
<style name="json:null-value" foreground="#bb9af7"/>
<style name="json:float" foreground="#ff9e64"/>
<style name="json:decimal" foreground="#ff9e64"/>
<style name="json:error" foreground="#f7768e" underline="error"/>
<!-- JavaScript -->
<style name="js:function" foreground="#7aa2f7"/>
<style name="js:string" foreground="#9ece6a"/>
<style name="js:regex" foreground="#7dcfff"/>
<!-- Python -->
<style name="python:builtin-constant" foreground="#bb9af7"/>
<style name="python:builtin-function" foreground="#7aa2f7"/>
<style name="python:module-handler" foreground="#bb9af7"/>
<style name="python:special-variable" foreground="#bb9af7"/>
<style name="python:string-conversion" foreground="#7dcfff"/>
<style name="python:format" foreground="#7dcfff"/>
<style name="python:decorator" foreground="#bb9af7"/>
<!-- C/C++ -->
<style name="c:preprocessor" foreground="#bb9af7"/>
<style name="c:common-defines" foreground="#bb9af7"/>
<style name="c:included-file" foreground="#9ece6a"/>
<style name="c:char" foreground="#9ece6a"/>
<!-- Markdown -->
<style name="markdown:header" foreground="#f7768e" bold="true"/>
<style name="markdown:list-marker" foreground="#ff9e64" bold="true"/>
<style name="markdown:code" foreground="#9ece6a"/>
<style name="markdown:emphasis" foreground="#e0af68" italic="true"/>
<style name="markdown:strong-emphasis" foreground="#e0af68" bold="true"/>
<style name="markdown:url" foreground="#7aa2f7" underline="true"/>
<style name="markdown:link-text" foreground="#bb9af7"/>
<style name="markdown:backslash-escape" foreground="#ff9e64"/>
<style name="markdown:line-break" foreground="#565f89"/>
<!-- Others -->
<style name="def:variable" foreground="#c0caf5"/>
<style name="def:class" foreground="#7aa2f7" bold="true"/>
<style name="def:interface" foreground="#7aa2f7" italic="true"/>
<style name="def:method" foreground="#7aa2f7"/>
<style name="def:namespace" foreground="#bb9af7" underline="true"/>
</style-scheme>

View File

@@ -41,4 +41,4 @@ configure_file(
install_subdir('scripts', install_dir: datadir)
install_subdir('themes', install_dir: datadir)
install_subdir('assets', install_dir: datadir)
install_subdir('src/scss', install_dir: datadir / 'src')
install_subdir('src/style', install_dir: datadir / 'src')

916
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": {
"lint": "eslint --config .eslintrc.json .",
"lint:fix": "eslint --config .eslintrc.json . --fix",
"format": "prettier --write 'modules/**/*.ts'"
"format": "prettier --write 'modules/**/*.ts'",
"knip": "knip"
},
"keywords": [],
"author": "",
@@ -15,13 +16,14 @@
"astal": "/usr/share/astal/gjs"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/node": "^22.15.17",
"@typescript-eslint/eslint-plugin": "^8.5.0",
"@typescript-eslint/parser": "^8.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-prettier": "^5.2.1",
"knip": "^5.55.1",
"prettier": "^3.3.3",
"tsconfig-paths": "^4.2.0",
"typescript": "5.7.3"

View File

@@ -1,13 +0,0 @@
import { BarToggleStates } from 'src/lib/types/cli.types';
export class BarVisibility {
private static _toggleStates: BarToggleStates = {};
public static get(barName: string): boolean {
return this._toggleStates[barName] ?? true;
}
public static set(barName: string, isVisible: boolean): void {
this._toggleStates[barName] = isVisible;
}
}

View File

@@ -1,8 +1,7 @@
import { Gio, readFileAsync } from 'astal';
import { CustomBarModule } from './types';
import { CustomBarModule, WidgetMap } from './types';
import { ModuleContainer } from './module_container';
import { WidgetContainer } from '../shared/WidgetContainer';
import { WidgetMap } from '..';
import { WidgetContainer } from '../shared/widgetContainer';
export class CustomModules {
constructor() {}

View File

@@ -1,4 +1,4 @@
import { isPrimitive } from 'src/lib/utils';
import { isPrimitive } from 'src/lib/validation/types';
import { CustomBarModuleIcon } from '../../types';
import { parseCommandOutputJson } from './utils';

View File

@@ -1,4 +1,4 @@
import { isPrimitive } from 'src/lib/utils';
import { isPrimitive } from 'src/lib/validation/types';
/**
* Generates a label based on module command output and a template configuration.

View File

@@ -1,11 +1,11 @@
import { CustomBarModule } from '../types';
import { Module } from '../../shared/Module';
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';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
export const ModuleContainer = (moduleName: string, moduleMetadata: CustomBarModule): BarBoxChild => {
const {

View File

@@ -2,7 +2,9 @@ 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';
import { InputHandlerService } from '../../utils/input/inputHandler';
const inputHandler = InputHandlerService.getInstance();
export function initCommandPoller(
commandOutput: Variable<string>,
@@ -51,7 +53,7 @@ export function setupModuleInteractions(
moduleScrollThreshold: number,
): void {
const scrollThreshold = moduleScrollThreshold >= 0 ? moduleScrollThreshold : 1;
inputHandler(
inputHandler.attachHandlers(
element,
{
onPrimaryClick: {

View File

@@ -1,4 +1,4 @@
export type CustomBarModuleActions = {
type CustomBarModuleActions = {
onLeftClick?: string;
onRightClick?: string;
onMiddleClick?: string;
@@ -18,3 +18,7 @@ export type CustomBarModule = {
actions?: CustomBarModuleActions;
};
export type CustomBarModuleIcon = string | string[] | Record<string, string>;
export type WidgetMap = {
[key in string]: (monitor: number) => JSX.Element;
};

View File

@@ -1,62 +0,0 @@
import { Menu } from './modules/menu';
import { Workspaces } from '../../components/bar/modules/workspaces/index';
import { ClientTitle } from '../../components/bar/modules/window_title/index';
import { Media } from '../../components/bar/modules/media/index';
import { Notifications } from '../../components/bar/modules/notifications/index';
import { Volume } from '../../components/bar/modules/volume/index';
import { Network } from '../../components/bar/modules/network/index';
import { Bluetooth } from '../../components/bar/modules/bluetooth/index';
import { BatteryLabel } from '../../components/bar/modules/battery/index';
import { Clock } from '../../components/bar/modules/clock/index';
import { SysTray } from '../../components/bar/modules/systray/index';
// 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';
import { CpuTemp } from '../../components/bar/modules/cputemp/index';
import { Storage } from '../../components/bar/modules/storage/index';
import { Netstat } from '../../components/bar/modules/netstat/index';
import { KbInput } from '../../components/bar/modules/kblayout/index';
import { Updates } from '../../components/bar/modules/updates/index';
import { Submap } from '../../components/bar/modules/submap/index';
import { Weather } from '../../components/bar/modules/weather/index';
import { Power } from '../../components/bar/modules/power/index';
import { Hyprsunset } from '../../components/bar/modules/hyprsunset/index';
import { Hypridle } from '../../components/bar/modules/hypridle/index';
import { Cava } from '../../components/bar/modules/cava/index';
import { WorldClock } from '../../components/bar/modules/worldclock/index';
import { ModuleSeparator } from './modules/separator';
export {
Menu,
Workspaces,
ClientTitle,
Media,
Notifications,
Volume,
Network,
Bluetooth,
BatteryLabel,
Clock,
SysTray,
// Basic Modules
Microphone,
Ram,
Cpu,
CpuTemp,
Storage,
Netstat,
KbInput,
Updates,
Submap,
Weather,
Power,
Hyprsunset,
Hypridle,
Cava,
WorldClock,
ModuleSeparator,
};

View File

@@ -1,198 +1,19 @@
import {
Menu,
Workspaces,
ClientTitle,
Media,
Notifications,
Volume,
Network,
Bluetooth,
BatteryLabel,
Clock,
SysTray,
Microphone,
Ram,
Cpu,
CpuTemp,
Storage,
Netstat,
KbInput,
Updates,
Submap,
Weather,
Power,
Hyprsunset,
Hypridle,
Cava,
WorldClock,
ModuleSeparator,
} from './exports';
import { GdkMonitorService } from 'src/services/display/monitor';
import { BarLayout } from './layout/BarLayout';
import { getCoreWidgets } from './layout/coreWidgets';
import { WidgetRegistry } from './layout/WidgetRegistry';
import { WidgetContainer } from './shared/WidgetContainer';
import options from 'src/options';
import { App, Gtk } from 'astal/gtk3';
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';
import { idleInhibit } from 'src/shared/utilities';
const { layouts } = options.bar;
const { location } = options.theme.bar;
const { location: borderLocation } = options.theme.bar.border;
let widgets: WidgetMap = {
battery: () => WidgetContainer(BatteryLabel()),
dashboard: () => WidgetContainer(Menu()),
workspaces: (monitor: number) => WidgetContainer(Workspaces(monitor)),
windowtitle: () => WidgetContainer(ClientTitle()),
media: () => WidgetContainer(Media()),
notifications: () => WidgetContainer(Notifications()),
volume: () => WidgetContainer(Volume()),
network: () => WidgetContainer(Network()),
bluetooth: () => WidgetContainer(Bluetooth()),
clock: () => WidgetContainer(Clock()),
systray: () => WidgetContainer(SysTray()),
microphone: () => WidgetContainer(Microphone()),
ram: () => WidgetContainer(Ram()),
cpu: () => WidgetContainer(Cpu()),
cputemp: () => WidgetContainer(CpuTemp()),
storage: () => WidgetContainer(Storage()),
netstat: () => WidgetContainer(Netstat()),
kbinput: () => WidgetContainer(KbInput()),
updates: () => WidgetContainer(Updates()),
submap: () => WidgetContainer(Submap()),
weather: () => WidgetContainer(Weather()),
power: () => WidgetContainer(Power()),
hyprsunset: () => WidgetContainer(Hyprsunset()),
hypridle: () => WidgetContainer(Hypridle()),
cava: () => WidgetContainer(Cava()),
worldclock: () => WidgetContainer(WorldClock()),
separator: () => ModuleSeparator(),
};
const gdkMonitorMapper = new GdkMonitorMapper();
const gdkMonitorService = new GdkMonitorService();
const widgetRegistry = new WidgetRegistry(getCoreWidgets());
/**
* Factory function to create a Bar for a specific monitor
*/
export const Bar = async (monitor: number): Promise<JSX.Element> => {
try {
const customWidgets = await CustomModules.build();
widgets = {
...widgets,
...customWidgets,
};
} catch (error) {
console.error(error);
}
const hyprlandMonitor = gdkMonitorMapper.mapGdkToHyprland(monitor);
await widgetRegistry.initialize();
const computeVisibility = bind(layouts).as(() => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, layouts.get());
return !isLayoutEmpty(foundLayout);
});
const hyprlandMonitor = gdkMonitorService.mapGdkToHyprland(monitor);
const barLayout = new BarLayout(monitor, hyprlandMonitor, widgetRegistry);
const computeClassName = bind(layouts).as(() => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, layouts.get());
return !isLayoutEmpty(foundLayout) ? 'bar' : '';
});
const computeAnchor = bind(location).as((loc) => {
if (loc === 'bottom') {
return Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT;
}
return Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT;
});
const computeLayer = Variable.derive(
[bind(options.theme.bar.layer), bind(options.tear)],
(barLayer, tear) => {
if (tear && barLayer === 'overlay') {
return Astal.Layer.TOP;
}
const layerMap = {
overlay: Astal.Layer.OVERLAY,
top: Astal.Layer.TOP,
bottom: Astal.Layer.BOTTOM,
background: Astal.Layer.BACKGROUND,
};
return layerMap[barLayer];
},
);
const computeBorderLocation = bind(borderLocation).as((brdrLcn) =>
brdrLcn !== 'none' ? 'bar-panel withBorder' : 'bar-panel',
);
const leftBinding = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, currentLayouts);
return foundLayout.left
.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(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(widgets).includes(mod))
.map((w) => widgets[w](hyprlandMonitor));
});
return (
<window
inhibit={bind(idleInhibit)}
name={`bar-${hyprlandMonitor}`}
namespace={`bar-${hyprlandMonitor}`}
className={computeClassName}
application={App}
monitor={monitor}
visible={computeVisibility}
anchor={computeAnchor}
layer={computeLayer()}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
onDestroy={() => {
computeLayer.drop();
leftBinding.drop();
middleBinding.drop();
rightBinding.drop();
}}
>
<box className={'bar-panel-container'}>
<centerbox
css={'padding: 1px;'}
hexpand
className={computeBorderLocation}
startWidget={
<box className={'box-left'} hexpand>
{leftBinding()}
</box>
}
centerWidget={
<box className={'box-center'} halign={Gtk.Align.CENTER}>
{middleBinding()}
</box>
}
endWidget={
<box className={'box-right'} halign={Gtk.Align.END}>
{rightBinding()}
</box>
}
/>
</box>
</window>
);
};
export type WidgetMap = {
[K in string]: (monitor: number) => JSX.Element;
return barLayout.render();
};

View File

@@ -0,0 +1,185 @@
import { App, Gtk } from 'astal/gtk3';
import Astal from 'gi://Astal?version=3.0';
import { bind, Binding, Variable } from 'astal';
import { idleInhibit } from 'src/lib/window/visibility';
import { WidgetRegistry } from './WidgetRegistry';
import { getLayoutForMonitor, isLayoutEmpty } from '../utils/monitors';
import options from 'src/configuration';
/**
* Responsible for the bar UI layout and positioning
*/
export class BarLayout {
private _hyprlandMonitor: number;
private _gdkMonitor: number;
private _widgetRegistry: WidgetRegistry;
private _visibilityVar: Variable<boolean>;
private _classNameVar: Variable<string>;
private _anchorVar: Variable<Astal.WindowAnchor>;
private _layerVar: Variable<Astal.Layer>;
private _borderLocationVar: Binding<string>;
private _barSectionsVar: {
left: Variable<JSX.Element[]>;
middle: Variable<JSX.Element[]>;
right: Variable<JSX.Element[]>;
};
constructor(gdkMonitor: number, hyprlandMonitor: number, widgetRegistry: WidgetRegistry) {
this._gdkMonitor = gdkMonitor;
this._hyprlandMonitor = hyprlandMonitor;
this._widgetRegistry = widgetRegistry;
this._visibilityVar = Variable(true);
this._classNameVar = Variable('bar');
this._anchorVar = Variable(
Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT,
);
this._layerVar = Variable(Astal.Layer.TOP);
this._borderLocationVar = Variable('bar-panel')();
this._barSectionsVar = {
left: Variable([]),
middle: Variable([]),
right: Variable([]),
};
this._initializeReactiveVariables();
}
public render(): JSX.Element {
return (
<window
inhibit={bind(idleInhibit)}
name={`bar-${this._hyprlandMonitor}`}
namespace={`bar-${this._hyprlandMonitor}`}
className={this._classNameVar()}
application={App}
monitor={this._gdkMonitor}
visible={this._visibilityVar()}
anchor={this._anchorVar()}
layer={this._layerVar()}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
onDestroy={() => this._cleanup()}
>
<box className="bar-panel-container">
<centerbox
css="padding: 1px;"
hexpand
className={this._borderLocationVar}
startWidget={
<box className="box-left" hexpand>
{this._barSectionsVar.left()}
</box>
}
centerWidget={
<box className="box-center" halign={Gtk.Align.CENTER}>
{this._barSectionsVar.middle()}
</box>
}
endWidget={
<box className="box-right" halign={Gtk.Align.END}>
{this._barSectionsVar.right()}
</box>
}
/>
</box>
</window>
);
}
private _initializeReactiveVariables(): void {
this._initializeVisibilityVariables();
this._initializePositionVariables();
this._initializeAppearanceVariables();
this._initializeSectionVariables();
}
private _initializeVisibilityVariables(): void {
const { layouts } = options.bar;
this._visibilityVar = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(this._hyprlandMonitor, currentLayouts);
return !isLayoutEmpty(foundLayout);
});
this._classNameVar = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(this._hyprlandMonitor, currentLayouts);
return !isLayoutEmpty(foundLayout) ? 'bar' : '';
});
}
/**
* Initialize variables related to bar positioning
*/
private _initializePositionVariables(): void {
const { location } = options.theme.bar;
this._anchorVar = Variable.derive([bind(location)], (loc) => {
if (loc === 'bottom') {
return Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT;
}
return Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT;
});
}
private _initializeAppearanceVariables(): void {
const { location: borderLocation } = options.theme.bar.border;
this._layerVar = this._createLayerVariable();
this._borderLocationVar = bind(borderLocation).as((brdrLcn) =>
brdrLcn !== 'none' ? 'bar-panel withBorder' : 'bar-panel',
);
}
private _createLayerVariable(): Variable<Astal.Layer> {
return Variable.derive([bind(options.theme.bar.layer), bind(options.tear)], (barLayer, tear) => {
if (tear && barLayer === 'overlay') {
return Astal.Layer.TOP;
}
return this._getLayerFromConfig(barLayer);
});
}
private _getLayerFromConfig(barLayer: string): Astal.Layer {
const layerMap: Record<string, Astal.Layer> = {
overlay: Astal.Layer.OVERLAY,
top: Astal.Layer.TOP,
bottom: Astal.Layer.BOTTOM,
background: Astal.Layer.BACKGROUND,
};
return layerMap[barLayer] ?? Astal.Layer.TOP;
}
private _initializeSectionVariables(): void {
this._barSectionsVar = {
left: this._createSectionBinding('left'),
middle: this._createSectionBinding('middle'),
right: this._createSectionBinding('right'),
};
}
private _createSectionBinding(section: 'left' | 'middle' | 'right'): Variable<JSX.Element[]> {
const { layouts } = options.bar;
return Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(this._hyprlandMonitor, currentLayouts);
return foundLayout[section]
.filter((mod) => this._widgetRegistry.hasWidget(mod))
.map((widget) => this._widgetRegistry.createWidget(widget, this._hyprlandMonitor));
});
}
private _cleanup(): void {
this._visibilityVar.drop();
this._classNameVar.drop();
this._anchorVar.drop();
this._layerVar.drop();
this._barSectionsVar.left.drop();
this._barSectionsVar.middle.drop();
this._barSectionsVar.right.drop();
}
}

View File

@@ -0,0 +1,55 @@
import { CustomModules } from '../customModules';
export type WidgetFactory = (monitor: number) => JSX.Element;
/**
* Manages registration and creation of widgets
*/
export class WidgetRegistry {
private _widgets: Record<string, WidgetFactory> = {};
private _initialized = false;
constructor(coreWidgets: Record<string, WidgetFactory>) {
this._widgets = { ...coreWidgets };
}
/**
* Initialize the registry with core and custom widgets
*/
public async initialize(): Promise<void> {
if (this._initialized) return;
try {
const customWidgets = await CustomModules.build();
this._widgets = {
...this._widgets,
...customWidgets,
};
this._initialized = true;
} catch (error) {
console.error('Failed to initialize widget registry:', error);
throw error;
}
}
/**
* Check if a widget is registered
*/
public hasWidget(name: string): boolean {
return Object.keys(this._widgets).includes(name);
}
/**
* Create an instance of a widget
*/
public createWidget(name: string, monitor: number): JSX.Element {
if (!this.hasWidget(name)) {
console.error(`Widget "${name}" not found`);
return <box />;
}
return this._widgets[name](monitor);
}
}

View File

@@ -0,0 +1,61 @@
import { BatteryLabel } from '../modules/battery';
import { Bluetooth } from '../modules/bluetooth';
import { Cava } from '../modules/cava';
import { Clock } from '../modules/clock';
import { Cpu } from '../modules/cpu';
import { CpuTemp } from '../modules/cputemp';
import { Hypridle } from '../modules/hypridle';
import { Hyprsunset } from '../modules/hyprsunset';
import { KbInput } from '../modules/kblayout';
import { Media } from '../modules/media';
import { Menu } from '../modules/menu';
import { Microphone } from '../modules/microphone';
import { Netstat } from '../modules/netstat';
import { Network } from '../modules/network';
import { Notifications } from '../modules/notifications';
import { Power } from '../modules/power';
import { Ram } from '../modules/ram';
import { ModuleSeparator } from '../modules/separator';
import { Storage } from '../modules/storage';
import { Submap } from '../modules/submap';
import { SysTray } from '../modules/systray';
import { Updates } from '../modules/updates';
import { Volume } from '../modules/volume';
import { Weather } from '../modules/weather';
import { ClientTitle } from '../modules/window_title';
import { Workspaces } from '../modules/workspaces';
import { WorldClock } from '../modules/worldclock';
import { WidgetContainer } from '../shared/widgetContainer';
import { WidgetFactory } from './WidgetRegistry';
export function getCoreWidgets(): Record<string, WidgetFactory> {
return {
battery: () => WidgetContainer(BatteryLabel()),
dashboard: () => WidgetContainer(Menu()),
workspaces: (monitor: number) => WidgetContainer(Workspaces(monitor)),
windowtitle: () => WidgetContainer(ClientTitle()),
media: () => WidgetContainer(Media()),
notifications: () => WidgetContainer(Notifications()),
volume: () => WidgetContainer(Volume()),
network: () => WidgetContainer(Network()),
bluetooth: () => WidgetContainer(Bluetooth()),
clock: () => WidgetContainer(Clock()),
systray: () => WidgetContainer(SysTray()),
microphone: () => WidgetContainer(Microphone()),
ram: () => WidgetContainer(Ram()),
cpu: () => WidgetContainer(Cpu()),
cputemp: () => WidgetContainer(CpuTemp()),
storage: () => WidgetContainer(Storage()),
netstat: () => WidgetContainer(Netstat()),
kbinput: () => WidgetContainer(KbInput()),
updates: () => WidgetContainer(Updates()),
submap: () => WidgetContainer(Submap()),
weather: () => WidgetContainer(Weather()),
power: () => WidgetContainer(Power()),
hyprsunset: () => WidgetContainer(Hyprsunset()),
hypridle: () => WidgetContainer(Hypridle()),
cava: () => WidgetContainer(Cava()),
worldclock: () => WidgetContainer(WorldClock()),
separator: () => ModuleSeparator(),
};
}

View File

@@ -1,4 +1,4 @@
import { BatteryIconKeys, BatteryIcons } from 'src/lib/types/battery.types';
import { BatteryIcons, BatteryIconKeys } from './types';
const batteryIcons: BatteryIcons = {
0: '󰂎',

View File

@@ -1,15 +1,17 @@
import AstalBattery from 'gi://AstalBattery?version=0.1';
import { Astal } from 'astal/gtk3';
import { openMenu } from '../../utils/menu';
import options from 'src/options';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { openDropdownMenu } from '../../utils/menu';
import Variable from 'astal/variable';
import { bind } from 'astal';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { getBatteryIcon } from './helpers';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { throttledScrollHandler } from '../../utils/input/throttle';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
const batteryService = AstalBattery.get_default();
const {
label: show_label,
rightClick,
@@ -136,7 +138,7 @@ const BatteryLabel = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'energymenu');
openDropdownMenu(clicked, event, 'energymenu');
}),
);

View File

@@ -1,11 +1,12 @@
import options from 'src/options.js';
import { openMenu } from '../../utils/menu.js';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { Variable, bind } from 'astal';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types.js';
import { BarBoxChild } from 'src/components/bar/types.js';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
import { openDropdownMenu } from '../../utils/menu';
import { onPrimaryClick, onSecondaryClick, onMiddleClick, onScroll } from 'src/lib/shared/eventHandlers';
const bluetoothService = AstalBluetooth.get_default();
@@ -90,7 +91,7 @@ const Bluetooth = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'bluetoothmenu');
openDropdownMenu(clicked, event, 'bluetoothmenu');
}),
);

View File

@@ -1,9 +1,8 @@
import { bind, Variable } from 'astal';
import AstalCava from 'gi://AstalCava?version=0.1';
import AstalMpris from 'gi://AstalMpris?version=0.1';
import options from 'src/options';
import options from 'src/configuration';
const mprisService = AstalMpris.get_default();
const {
showActiveOnly,
bars,
@@ -24,6 +23,7 @@ const {
*/
export function initVisibilityTracker(isVis: Variable<boolean>): Variable<void> {
const cavaService = AstalCava.get_default();
const mprisService = AstalMpris.get_default();
return Variable.derive([bind(showActiveOnly), bind(mprisService, 'players')], (showActive, players) => {
isVis.set(cavaService !== null && (!showActive || players?.length > 0));

View File

@@ -1,11 +1,13 @@
import { Variable, bind } from 'astal';
import { Astal } from 'astal/gtk3';
import { Module } from '../../shared/Module';
import { inputHandler } from '../../utils/helpers';
import options from 'src/options';
import { Module } from '../../shared/module';
import { initSettingsTracker, initVisibilityTracker } from './helpers';
import AstalCava from 'gi://AstalCava?version=0.1';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { InputHandlerService } from '../../utils/input/inputHandler';
const inputHandler = InputHandlerService.getInstance();
const {
icon,
@@ -45,6 +47,8 @@ export const Cava = (): BarBoxChild => {
);
}
let inputHandlerBindings: Variable<void>;
return Module({
isVis: bind(isVis),
label: labelBinding(),
@@ -53,7 +57,7 @@ export const Cava = (): BarBoxChild => {
boxClass: 'cava',
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -72,9 +76,10 @@ export const Cava = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
settingsTracker?.drop();
labelBinding.drop();
visTracker.drop();
settingsTracker?.drop();
},
},
});

View File

@@ -1,11 +1,12 @@
import { openMenu } from '../../utils/menu';
import options from 'src/options';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { openDropdownMenu } from '../../utils/menu';
import { bind, Variable } from 'astal';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { Astal } from 'astal/gtk3';
import { systemTime } from 'src/shared/time';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { systemTime } from 'src/lib/units/time';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
const { format, icon, showIcon, showTime, rightClick, middleClick, scrollUp, scrollDown } = options.bar.clock;
const { style } = options.theme.bar.buttons;
@@ -83,7 +84,7 @@ const Clock = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'calendarmenu');
openDropdownMenu(clicked, event, 'calendarmenu');
}),
);

View File

@@ -1,26 +0,0 @@
import GTop from 'gi://GTop';
let previousCpuData = new GTop.glibtop_cpu();
GTop.glibtop_get_cpu(previousCpuData);
/**
* Computes the CPU usage percentage.
*
* This function calculates the CPU usage percentage by comparing the current CPU data with the previous CPU data.
* It calculates the differences in total and idle CPU times and uses these differences to compute the usage percentage.
*
* @returns The CPU usage percentage as a number.
*/
export const computeCPU = (): number => {
const currentCpuData = new GTop.glibtop_cpu();
GTop.glibtop_get_cpu(currentCpuData);
const totalDiff = currentCpuData.total - previousCpuData.total;
const idleDiff = currentCpuData.idle - previousCpuData.idle;
const cpuUsagePercentage = totalDiff > 0 ? ((totalDiff - idleDiff) / totalDiff) * 100 : 0;
previousCpuData = currentCpuData;
return cpuUsagePercentage;
};

View File

@@ -1,25 +1,29 @@
import { Module } from '../../shared/Module';
import options from 'src/options';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { computeCPU } from './helpers';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { Module } from '../../shared/module';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { InputHandlerService } from '../../utils/input/inputHandler';
import CpuUsageService from 'src/services/system/cpuUsage';
const inputHandler = InputHandlerService.getInstance();
const { label, round, leftClick, rightClick, middleClick, scrollUp, scrollDown, pollingInterval, icon } =
options.bar.customModules.cpu;
export const cpuUsage = Variable(0);
const cpuPoller = new FunctionPoller<number, []>(cpuUsage, [bind(round)], bind(pollingInterval), computeCPU);
cpuPoller.initialize('cpu');
const cpuService = new CpuUsageService({ frequency: pollingInterval });
export const Cpu = (): BarBoxChild => {
const labelBinding = Variable.derive([bind(cpuUsage), bind(round)], (cpuUsg: number, round: boolean) => {
return round ? `${Math.round(cpuUsg)}%` : `${cpuUsg.toFixed(2)}%`;
});
cpuService.initialize();
const labelBinding = Variable.derive(
[bind(cpuService.cpu), bind(round)],
(cpuUsg: number, round: boolean) => {
return round ? `${Math.round(cpuUsg)}%` : `${cpuUsg.toFixed(2)}%`;
},
);
let inputHandlerBindings: Variable<void>;
const cpuModule = Module({
textIcon: bind(icon),
@@ -29,7 +33,7 @@ export const Cpu = (): BarBoxChild => {
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -48,7 +52,9 @@ export const Cpu = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
labelBinding.drop();
cpuService.destroy();
},
},
});

View File

@@ -1,44 +1,108 @@
import { Variable } from 'astal';
import { bind, Binding } from 'astal';
import CpuTempService from 'src/services/system/cputemp';
import { TemperatureConverter } from 'src/lib/units/temperature';
import { CpuTempSensorDiscovery } from 'src/services/system/cputemp/sensorDiscovery';
import options from 'src/configuration';
import GLib from 'gi://GLib?version=2.0';
import { convertCelsiusToFahrenheit } from 'src/shared/weather';
import options from 'src/options';
import { UnitType } from 'src/lib/types/weather.types';
const { sensor } = options.bar.customModules.cpuTemp;
const { pollingInterval, sensor } = options.bar.customModules.cpuTemp;
/**
* Retrieves the current CPU temperature.
*
* This function reads the CPU temperature from the specified sensor file and converts it to the desired unit (Celsius or Fahrenheit).
* It also handles rounding the temperature value based on the provided `round` variable.
*
* @param round A Variable<boolean> indicating whether to round the temperature value.
* @param unit A Variable<UnitType> indicating the desired unit for the temperature (Celsius or Fahrenheit).
*
* @returns The current CPU temperature as a number. Returns 0 if an error occurs or the sensor file is empty.
* Creates a tooltip for the CPU temperature module showing sensor details
*/
export const getCPUTemperature = (round: Variable<boolean>, unit: Variable<UnitType>): number => {
try {
if (sensor.get().length === 0) {
return 0;
export function getCpuTempTooltip(cpuTempService: CpuTempService): Binding<string> {
return bind(cpuTempService.temperature).as((temp) => {
const currentPath = cpuTempService.currentSensorPath;
const configuredSensor = sensor.get();
const isAuto = configuredSensor === 'auto' || configuredSensor === '';
const tempC = TemperatureConverter.fromCelsius(temp).formatCelsius();
const tempF = TemperatureConverter.fromCelsius(temp).formatFahrenheit();
const lines = [
'CPU Temperature',
'─────────────────────────',
`Current: ${tempC} (${tempF})`,
'',
'Sensor Information',
'─────────────────────────',
];
if (currentPath) {
const sensorType = getSensorType(currentPath);
const sensorName = getSensorName(currentPath);
const chipName = getChipName(currentPath);
lines.push(`Mode: ${isAuto ? 'Auto-discovered' : 'User-configured'}`, `Type: ${sensorType}`);
if (chipName) {
lines.push(`Chip: ${chipName}`);
}
lines.push(`Device: ${sensorName}`, `Path: ${currentPath}`);
} else {
lines.push('Status: No sensor found', 'Try setting a manual sensor path');
}
const [success, tempInfoBytes] = GLib.file_get_contents(sensor.get());
const tempInfo = new TextDecoder('utf-8').decode(tempInfoBytes);
const interval = pollingInterval.get();
lines.push('', `Update interval: ${interval}ms`);
if (!success || tempInfoBytes === null) {
console.error(`Failed to read ${sensor.get()} or file content is null.`);
return 0;
const allSensors = CpuTempSensorDiscovery.getAllSensors();
if (allSensors.length > 1) {
lines.push('', `Available sensors: ${allSensors.length}`);
}
let decimalTemp = parseInt(tempInfo, 10) / 1000;
return lines.join('\n');
});
}
if (unit.get() === 'imperial') {
decimalTemp = convertCelsiusToFahrenheit(decimalTemp);
}
/**
* Determines sensor type from path
*/
function getSensorType(path: string): string {
if (path.includes('/sys/class/hwmon/')) return 'Hardware Monitor';
if (path.includes('/sys/class/thermal/')) return 'Thermal Zone';
return 'Unknown';
}
return round.get() ? Math.round(decimalTemp) : parseFloat(decimalTemp.toFixed(2));
} catch (error) {
console.error('Error calculating CPU Temp:', error);
return 0;
/**
* Extracts sensor name from path
*/
function getSensorName(path: string): string {
if (path.includes('/sys/class/hwmon/')) {
const match = path.match(/hwmon(\d+)/);
return match ? `hwmon${match[1]}` : 'Unknown';
}
};
if (path.includes('/sys/class/thermal/')) {
const match = path.match(/thermal_zone(\d+)/);
return match ? `thermal_zone${match[1]}` : 'Unknown';
}
return 'Unknown';
}
/**
* Gets the actual chip name for hwmon sensors
*/
function getChipName(path: string): string | undefined {
if (!path.includes('/sys/class/hwmon/')) return undefined;
try {
const match = path.match(/\/sys\/class\/hwmon\/hwmon\d+/);
if (!match) return undefined;
const nameFile = `${match[0]}/name`;
const [success, bytes] = GLib.file_get_contents(nameFile);
if (success && bytes) {
return new TextDecoder('utf-8').decode(bytes).trim();
}
} catch (error) {
if (error instanceof Error) {
console.debug(`Failed to get chip name: ${error.message}`);
}
}
return undefined;
}

View File

@@ -1,12 +1,14 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { getCPUTemperature } from './helpers';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { Module } from '../../shared/module';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { UnitType } from 'src/lib/types/weather.types';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import CpuTempService from 'src/services/system/cputemp';
import options from 'src/configuration';
import { TemperatureConverter } from 'src/lib/units/temperature';
import { getCpuTempTooltip } from './helpers';
const inputHandler = InputHandlerService.getInstance();
const {
label,
@@ -23,37 +25,51 @@ const {
icon,
} = options.bar.customModules.cpuTemp;
export const cpuTemp = Variable(0);
const cpuTempPoller = new FunctionPoller<number, [Variable<boolean>, Variable<UnitType>]>(
cpuTemp,
[bind(sensor), bind(round), bind(unit)],
bind(pollingInterval),
getCPUTemperature,
round,
unit,
);
cpuTempPoller.initialize('cputemp');
const cpuTempService = new CpuTempService({ frequency: pollingInterval, sensor });
export const CpuTemp = (): BarBoxChild => {
cpuTempService.initialize();
const bindings = Variable.derive([bind(sensor), bind(round), bind(unit)], (sensorName) => {
cpuTempService.refresh();
if (cpuTempService.sensor.get() !== sensorName) {
cpuTempService.updateSensor(sensorName);
}
});
const labelBinding = Variable.derive(
[bind(cpuTemp), bind(unit), bind(showUnit), bind(round)],
(cpuTmp, tempUnit, shwUnit) => {
const unitLabel = tempUnit === 'imperial' ? 'F' : 'C';
const unit = shwUnit ? ` ${unitLabel}` : '';
return `${cpuTmp.toString()}°${unit}`;
[bind(cpuTempService.temperature), bind(unit), bind(showUnit), bind(round)],
(cpuTemp, tempUnit, showUnit, roundValue) => {
const tempConverter = TemperatureConverter.fromCelsius(cpuTemp);
const isImperial = tempUnit === 'imperial';
const precision = roundValue ? 0 : 2;
if (showUnit) {
return isImperial
? tempConverter.formatFahrenheit(precision)
: tempConverter.formatCelsius(precision);
}
const temp = isImperial
? tempConverter.toFahrenheit(precision)
: tempConverter.toCelsius(precision);
return temp.toString();
},
);
let inputHandlerBindings: Variable<void>;
const cpuTempModule = Module({
textIcon: bind(icon),
label: labelBinding(),
tooltipText: 'CPU Temperature',
tooltipText: getCpuTempTooltip(cpuTempService),
boxClass: 'cpu-temp',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -72,7 +88,10 @@ export const CpuTemp = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
cpuTempService.destroy();
labelBinding.drop();
bindings.drop();
},
},
});

View File

@@ -1,11 +1,13 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from '../../utils/helpers';
import { Module } from '../../shared/module';
import Variable from 'astal/variable';
import { bind } from 'astal';
import { Astal } from 'astal/gtk3';
import { idleInhibit } from 'src/shared/utilities';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { idleInhibit } from 'src/lib/window/visibility';
import { BarBoxChild } from 'src/components/bar/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import options from 'src/configuration';
const inputHandler = InputHandlerService.getInstance();
const { label, onIcon, offIcon, onLabel, offLabel, rightClick, middleClick, scrollUp, scrollDown } =
options.bar.customModules.hypridle;
@@ -29,6 +31,8 @@ export const Hypridle = (): BarBoxChild => {
},
);
let inputHandlerBindings: Variable<void>;
const hypridleModule = Module({
textIcon: iconBinding(),
tooltipText: bind(idleInhibit).as(
@@ -39,7 +43,7 @@ export const Hypridle = (): BarBoxChild => {
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
fn: () => {
toggleInhibit();
@@ -60,6 +64,7 @@ export const Hypridle = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
iconBinding.drop();
labelBinding.drop();
},

View File

@@ -1,5 +1,5 @@
import { execAsync, Variable } from 'astal';
import options from 'src/options';
import options from 'src/configuration';
const { temperature } = options.bar.customModules.hyprsunset;
@@ -9,7 +9,7 @@ const { temperature } = options.bar.customModules.hyprsunset;
* This command checks if the hyprsunset process is currently running by using the `pgrep` command.
* It returns 'yes' if the process is found and 'no' otherwise.
*/
export const isActiveCommand = "bash -c \"pgrep -x 'hyprsunset' > /dev/null && echo 'yes' || echo 'no'\"";
const isActiveCommand = "bash -c \"pgrep -x 'hyprsunset' > /dev/null && echo 'yes' || echo 'no'\"";
/**
* A variable to track the active state of the hyprsunset process.

View File

@@ -1,11 +1,14 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler, throttleInput } from 'src/components/bar/utils/helpers';
import { Module } from '../../shared/module';
import { checkSunsetStatus, isActive, toggleSunset } from './helpers';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import options from 'src/configuration';
import { throttleInput } from '../../utils/input/throttle';
const inputHandler = InputHandlerService.getInstance();
const {
label,
@@ -55,6 +58,8 @@ export const Hyprsunset = (): BarBoxChild => {
},
);
let inputHandlerBindings: Variable<void>;
const hyprsunsetModule = Module({
textIcon: iconBinding(),
tooltipText: tooltipBinding(),
@@ -63,7 +68,7 @@ export const Hyprsunset = (): BarBoxChild => {
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
fn: () => {
throttledToggleSunset();
@@ -84,6 +89,7 @@ export const Hyprsunset = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
iconBinding.drop();
tooltipBinding.drop();
labelBinding.drop();

View File

@@ -1,9 +1,5 @@
import {
HyprctlDeviceLayout,
HyprctlKeyboard,
KbLabelType,
} from 'src/lib/types/customModules/kbLayout.types.js';
import { LayoutKeys, layoutMap, LayoutValues } from './layouts';
import { KbLabelType, HyprctlDeviceLayout, HyprctlKeyboard } from './types';
/**
* Retrieves the keyboard layout from a given JSON string and format.

View File

@@ -1,4 +1,3 @@
// Create a const object with all layouts
const layoutMapObj = {
'Abkhazian (Russia)': 'RU (Ab)',
Akan: 'GH (Akan)',

View File

@@ -12,7 +12,7 @@ export type HyprctlKeyboard = {
main: boolean;
};
export type HyprctlMouse = {
type HyprctlMouse = {
address: string;
name: string;
defaultSpeed: number;

View File

@@ -1,12 +1,14 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { Module } from '../../shared/module';
import { getKeyboardLayout } from './helpers';
import { bind } from 'astal';
import { bind, Variable } from 'astal';
import { useHook } from 'src/lib/shared/hookHandler';
import { Astal } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import options from 'src/configuration';
const inputHandler = InputHandlerService.getInstance();
const hyprlandService = AstalHyprland.get_default();
const { label, labelType, icon, leftClick, rightClick, middleClick, scrollUp, scrollDown } =
@@ -22,6 +24,8 @@ function setLabel(self: Astal.Label): void {
}
export const KbInput = (): BarBoxChild => {
let inputHandlerBindings: Variable<void>;
const keyboardModule = Module({
textIcon: bind(icon),
tooltipText: '',
@@ -43,7 +47,7 @@ export const KbInput = (): BarBoxChild => {
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -61,6 +65,9 @@ export const KbInput = (): BarBoxChild => {
},
});
},
onDestroy: () => {
inputHandlerBindings.drop();
},
},
});

View File

@@ -1,7 +1,7 @@
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { Variable } from 'astal';
import { MediaTags } from 'src/lib/types/audio.types';
import { Opt } from 'src/lib/options';
import { MediaTags } from './types';
/**
* Retrieves the icon for a given media player.
@@ -106,7 +106,10 @@ export const generateMediaLabel = (
if (!isValidMediaTag(p1)) {
return '';
}
const value = p1 !== undefined ? mediaTags[p1] : '';
let value = p1 !== undefined ? mediaTags[p1] : '';
value = value?.replace(/\r?\n/g, ' ') ?? '';
const suffix = p2 !== undefined && p2.length > 0 ? p2.slice(1) : '';
return value ? value + suffix : '';
},

View File

@@ -1,13 +1,14 @@
import { openMenu } from '../../utils/menu.js';
import options from 'src/options.js';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { generateMediaLabel } from './helpers/index.js';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import { onPrimaryClick, onSecondaryClick, onMiddleClick, onScroll } from 'src/lib/shared/eventHandlers';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { activePlayer, mediaAlbum, mediaArtist, mediaTitle } from 'src/shared/media.js';
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { BarBoxChild } from 'src/lib/types/bar.types.js';
import { BarBoxChild } from 'src/components/bar/types.js';
import { activePlayer, mediaTitle, mediaAlbum, mediaArtist } from 'src/services/media';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
import { openDropdownMenu } from '../../utils/menu';
const mprisService = AstalMpris.get_default();
const {
@@ -103,7 +104,7 @@ const Media = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'mediamenu');
openDropdownMenu(clicked, event, 'mediamenu');
}),
);

View File

@@ -1,18 +1,20 @@
import { runAsyncCommand, throttledScrollHandler } from '../../utils/helpers.js';
import options from '../../../../options.js';
import { openMenu } from '../../utils/menu.js';
import { getDistroIcon } from '../../../../lib/utils.js';
import { Variable, bind } from 'astal';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import { onPrimaryClick, onSecondaryClick, onMiddleClick, onScroll } from 'src/lib/shared/eventHandlers';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types.js';
import { BarBoxChild } from 'src/components/bar/types.js';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
import { openDropdownMenu } from '../../utils/menu';
const { rightClick, middleClick, scrollUp, scrollDown, autoDetectIcon, icon } = options.bar.launcher;
const Menu = (): BarBoxChild => {
const iconBinding = Variable.derive(
[autoDetectIcon, icon],
(autoDetect: boolean, iconValue: string): string => (autoDetect ? getDistroIcon() : iconValue),
(autoDetect: boolean, iconValue: string): string =>
autoDetect ? SystemUtilities.getDistroIcon() : iconValue,
);
const componentClassName = bind(options.theme.bar.buttons.style).as((style: string) => {
@@ -60,7 +62,7 @@ const Menu = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'dashboardmenu');
openDropdownMenu(clicked, event, 'dashboardmenu');
}),
);

View File

@@ -1,10 +1,12 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { Module } from '../../shared/module';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { inputHandler } from '../../utils/helpers';
import AstalWp from 'gi://AstalWp?version=0.1';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import options from 'src/configuration';
const inputHandler = InputHandlerService.getInstance();
const wireplumber = AstalWp.get_default() as AstalWp.Wp;
const audioService = wireplumber.audio;
@@ -43,6 +45,9 @@ export const Microphone = (): BarBoxChild => {
return `${icon} ${description}`;
},
);
let inputHandlerBindings: Variable<void>;
const microphoneModule = Module({
textIcon: iconBinding(),
label: bind(audioService.defaultMicrophone, 'volume').as((vol) => `${Math.round(vol * 100)}%`),
@@ -51,7 +56,7 @@ export const Microphone = (): BarBoxChild => {
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -69,6 +74,9 @@ export const Microphone = (): BarBoxChild => {
},
});
},
onDestroy: () => {
inputHandlerBindings.drop();
},
},
});

View File

@@ -0,0 +1,34 @@
import NetworkUsageService from 'src/services/system/networkUsage';
import { bind, Variable } from 'astal';
import options from 'src/configuration';
const { networkInterface, rateUnit, round, pollingInterval } = options.bar.customModules.netstat;
export const setupNetworkServiceBindings = (): void => {
const networkService = new NetworkUsageService();
Variable.derive([bind(pollingInterval)], (interval) => {
networkService.updateTimer(interval);
})();
Variable.derive([bind(networkInterface)], (interfaceName) => {
networkService.setInterface(interfaceName);
})();
Variable.derive([bind(rateUnit)], (unit) => {
networkService.setRateUnit(unit);
})();
Variable.derive([bind(round)], (shouldRound) => {
networkService.setShouldRound(shouldRound);
})();
};
export const cycleArray = <T>(array: T[], current: T, direction: 'next' | 'prev'): T => {
const currentIndex = array.indexOf(current);
const nextIndex =
direction === 'next'
? (currentIndex + 1) % array.length
: (currentIndex - 1 + array.length) % array.length;
return array[nextIndex];
};

View File

@@ -1,168 +0,0 @@
import GLib from 'gi://GLib';
import { Variable } from 'astal';
import { RateUnit } from 'src/lib/types/bar.types';
import { NetworkResourceData } from 'src/lib/types/customModules/network.types';
import { getDefaultNetstatData } from 'src/lib/types/defaults/netstat.types';
let previousNetUsage = { rx: 0, tx: 0, time: 0 };
interface NetworkUsage {
name: string;
rx: number;
tx: number;
}
/**
* Formats the network rate based on the provided rate, type, and rounding option.
*
* This function converts the network rate to the appropriate unit (KiB/s, MiB/s, GiB/s, or bytes/s) based on the provided type.
* It also rounds the rate to the specified number of decimal places.
*
* @param rate The network rate to format.
* @param type The unit type for the rate (KiB, MiB, GiB).
* @param round A boolean indicating whether to round the rate.
*
* @returns The formatted network rate as a string.
*/
const formatRate = (rate: number, type: string, round: boolean): string => {
const fixed = round ? 0 : 2;
switch (true) {
case type === 'KiB':
return `${(rate / 1e3).toFixed(fixed)} KiB/s`;
case type === 'MiB':
return `${(rate / 1e6).toFixed(fixed)} MiB/s`;
case type === 'GiB':
return `${(rate / 1e9).toFixed(fixed)} GiB/s`;
case rate >= 1e9:
return `${(rate / 1e9).toFixed(fixed)} GiB/s`;
case rate >= 1e6:
return `${(rate / 1e6).toFixed(fixed)} MiB/s`;
case rate >= 1e3:
return `${(rate / 1e3).toFixed(fixed)} KiB/s`;
default:
return `${rate.toFixed(fixed)} bytes/s`;
}
};
/**
* Parses a line of network interface data.
*
* This function parses a line of network interface data from the /proc/net/dev file.
* It extracts the interface name, received bytes, and transmitted bytes.
*
* @param line The line of network interface data to parse.
*
* @returns An object containing the interface name, received bytes, and transmitted bytes, or null if the line is invalid.
*/
const parseInterfaceData = (line: string): NetworkUsage | null => {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('Inter-') || trimmedLine.startsWith('face')) {
return null;
}
const [iface, rx, , , , , , , , tx] = trimmedLine.split(/\s+/);
const rxValue = parseInt(rx, 10);
const txValue = parseInt(tx, 10);
const cleanedIface = iface.replace(':', '');
return { name: cleanedIface, rx: rxValue, tx: txValue };
};
/**
* Validates a network interface.
*
* This function checks if the provided network interface is valid based on the interface name and received/transmitted bytes.
*
* @param iface The network interface to validate.
* @param interfaceName The name of the interface to check.
*
* @returns True if the interface is valid, false otherwise.
*/
const isValidInterface = (iface: NetworkUsage | null, interfaceName: string): boolean => {
if (!iface) return false;
if (interfaceName) return iface.name === interfaceName;
return iface.name !== 'lo' && iface.rx > 0 && iface.tx > 0;
};
/**
* Retrieves the network usage for a specified interface.
*
* This function reads the /proc/net/dev file to get the network usage data for the specified interface.
* If no interface name is provided, it returns the usage data for the first valid interface found.
*
* @param interfaceName The name of the interface to get the usage data for. Defaults to an empty string.
*
* @returns An object containing the interface name, received bytes, and transmitted bytes.
*/
const getNetworkUsage = (interfaceName: string = ''): NetworkUsage => {
const [success, data] = GLib.file_get_contents('/proc/net/dev');
const defaultStats = { name: '', rx: 0, tx: 0 };
if (!success) {
console.error('Failed to read /proc/net/dev');
return defaultStats;
}
const lines = new TextDecoder('utf-8').decode(data).split('\n');
for (const line of lines) {
const iface = parseInterfaceData(line);
if (isValidInterface(iface, interfaceName)) {
return iface ?? defaultStats;
}
}
return { name: '', rx: 0, tx: 0 };
};
/**
* Computes the network usage data.
*
* This function calculates the network usage data based on the provided rounding option, interface name, and data type.
* It returns an object containing the formatted received and transmitted rates.
*
* @param round A Variable<boolean> indicating whether to round the rates.
* @param interfaceNameVar A Variable<string> containing the name of the interface to get the usage data for.
* @param dataType A Variable<RateUnit> containing the unit type for the rates.
*
* @returns An object containing the formatted received and transmitted rates.
*/
export const computeNetwork = (
round: Variable<boolean>,
interfaceNameVar: Variable<string>,
dataType: Variable<RateUnit>,
): NetworkResourceData => {
const rateUnit = dataType.get();
const interfaceName = interfaceNameVar.get();
const DEFAULT_NETSTAT_DATA = getDefaultNetstatData(rateUnit);
try {
const { rx, tx, name } = getNetworkUsage(interfaceName);
const currentTime = Date.now();
if (!name) {
return DEFAULT_NETSTAT_DATA;
}
if (previousNetUsage.time === 0) {
previousNetUsage = { rx, tx, time: currentTime };
return DEFAULT_NETSTAT_DATA;
}
const timeDiff = Math.max((currentTime - previousNetUsage.time) / 1000, 1);
const rxRate = (rx - previousNetUsage.rx) / timeDiff;
const txRate = (tx - previousNetUsage.tx) / timeDiff;
previousNetUsage = { rx, tx, time: currentTime };
return {
in: formatRate(rxRate, rateUnit, round.get()),
out: formatRate(txRate, rateUnit, round.get()),
};
} catch (error) {
console.error('Error calculating network usage:', error);
return DEFAULT_NETSTAT_DATA;
}
};

View File

@@ -1,91 +1,87 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { computeNetwork } from './helpers';
import { NETWORK_LABEL_TYPES } from 'src/lib/types/defaults/bar.types';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { Module } from '../../shared/module';
import NetworkUsageService from 'src/services/system/networkUsage';
import { bind, Variable } from 'astal';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { Astal } from 'astal/gtk3';
import { RateUnit, BarBoxChild, NetstatLabelType } from 'src/lib/types/bar.types';
import { NetworkResourceData } from 'src/lib/types/customModules/network.types';
import { getDefaultNetstatData } from 'src/lib/types/defaults/netstat.types';
import { BarBoxChild } from '../../types';
import { NetstatLabelType } from 'src/services/system/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import options from 'src/configuration';
import { cycleArray, setupNetworkServiceBindings } from './helpers';
const inputHandler = InputHandlerService.getInstance();
const astalNetworkService = AstalNetwork.get_default();
const NETWORK_LABEL_TYPES: NetstatLabelType[] = ['full', 'in', 'out'];
const networkService = AstalNetwork.get_default();
const {
label,
labelType,
networkInterface,
rateUnit,
dynamicIcon,
icon,
networkInLabel,
networkOutLabel,
round,
leftClick,
rightClick,
middleClick,
pollingInterval,
} = options.bar.customModules.netstat;
export const networkUsage = Variable<NetworkResourceData>(getDefaultNetstatData(rateUnit.get()));
setupNetworkServiceBindings();
const netstatPoller = new FunctionPoller<
NetworkResourceData,
[round: Variable<boolean>, interfaceNameVar: Variable<string>, dataType: Variable<RateUnit>]
>(
networkUsage,
[bind(rateUnit), bind(networkInterface), bind(round)],
bind(pollingInterval),
computeNetwork,
round,
networkInterface,
rateUnit,
);
netstatPoller.initialize('netstat');
const networkService = new NetworkUsageService({ frequency: pollingInterval });
export const Netstat = (): BarBoxChild => {
const renderNetworkLabel = (lblType: NetstatLabelType, networkService: NetworkResourceData): string => {
networkService.initialize();
const renderNetworkLabel = (
lblType: NetstatLabelType,
networkData: { in: string; out: string },
): string => {
switch (lblType) {
case 'in':
return `${networkInLabel.get()} ${networkService.in}`;
return `${networkInLabel.get()} ${networkData.in}`;
case 'out':
return `${networkOutLabel.get()} ${networkService.out}`;
return `${networkOutLabel.get()} ${networkData.out}`;
default:
return `${networkInLabel.get()} ${networkService.in} ${networkOutLabel.get()} ${networkService.out}`;
return `${networkInLabel.get()} ${networkData.in} ${networkOutLabel.get()} ${networkData.out}`;
}
};
const iconBinding = Variable.derive(
[bind(networkService, 'primary'), bind(networkService, 'wifi'), bind(networkService, 'wired')],
(pmry, wfi, wrd) => {
if (pmry === AstalNetwork.Primary.WIRED) {
return wrd?.icon_name;
[
bind(astalNetworkService, 'primary'),
bind(astalNetworkService, 'wifi'),
bind(astalNetworkService, 'wired'),
],
(primary, wifi, wired) => {
if (primary === AstalNetwork.Primary.WIRED) {
return wired?.icon_name;
}
return wfi?.icon_name;
return wifi?.icon_name;
},
);
const labelBinding = Variable.derive(
[bind(networkUsage), bind(labelType)],
(networkService: NetworkResourceData, lblTyp: NetstatLabelType) =>
renderNetworkLabel(lblTyp, networkService),
[bind(networkService.network), bind(labelType)],
(networkData, lblType: NetstatLabelType) => renderNetworkLabel(lblType, networkData),
);
let inputHandlerBindings: Variable<void>;
const netstatModule = Module({
useTextIcon: bind(dynamicIcon).as((useDynamicIcon) => !useDynamicIcon),
icon: iconBinding(),
textIcon: bind(icon),
label: labelBinding(),
tooltipText: bind(labelType).as((lblTyp) => {
return lblTyp === 'full' ? 'Ingress / Egress' : lblTyp === 'in' ? 'Ingress' : 'Egress';
tooltipText: bind(labelType).as((lblType) => {
return lblType === 'full' ? 'Ingress / Egress' : lblType === 'in' ? 'Ingress' : 'Egress';
}),
boxClass: 'netstat',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -97,31 +93,23 @@ export const Netstat = (): BarBoxChild => {
},
onScrollUp: {
fn: () => {
labelType.set(
NETWORK_LABEL_TYPES[
(NETWORK_LABEL_TYPES.indexOf(labelType.get()) + 1) %
NETWORK_LABEL_TYPES.length
] as NetstatLabelType,
);
const nextLabelType = cycleArray(NETWORK_LABEL_TYPES, labelType.get(), 'next');
labelType.set(nextLabelType);
},
},
onScrollDown: {
fn: () => {
labelType.set(
NETWORK_LABEL_TYPES[
(NETWORK_LABEL_TYPES.indexOf(labelType.get()) -
1 +
NETWORK_LABEL_TYPES.length) %
NETWORK_LABEL_TYPES.length
] as NetstatLabelType,
);
const prevLabelType = cycleArray(NETWORK_LABEL_TYPES, labelType.get(), 'prev');
labelType.set(prevLabelType);
},
},
});
},
onDestroy: () => {
inputHandlerBindings.drop();
labelBinding.drop();
iconBinding.drop();
networkService.destroy();
},
},
});

View File

@@ -1,12 +1,13 @@
import options from 'src/options';
import { openMenu } from '../../utils/menu';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { openDropdownMenu } from '../../utils/menu';
import { bind, Variable } from 'astal';
import { onPrimaryClick, onSecondaryClick, onMiddleClick, onScroll } from 'src/lib/shared/eventHandlers';
import { Astal, Gtk } from 'astal/gtk3';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { formatWifiInfo, wiredIcon, wirelessIcon } from './helpers';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
const networkService = AstalNetwork.get_default();
const { label, truncation, truncation_size, rightClick, middleClick, scrollDown, scrollUp, showWifiInfo } =
@@ -46,10 +47,7 @@ const Network = (): BarBoxChild => {
);
}
const networkWifi = networkService.wifi;
if (networkWifi != null) {
// Astal doesn't reset the wifi attributes on disconnect, only on a valid connection
// so we need to check if both the WiFi is enabled and if there is an active access
// point
if (networkWifi !== null) {
if (!networkWifi.enabled) {
return <label className={'bar-button-label network-label'} label="Off" />;
}
@@ -127,7 +125,7 @@ const Network = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'networkmenu');
openDropdownMenu(clicked, event, 'networkmenu');
}),
);

View File

@@ -1,12 +1,13 @@
import AstalNotifd from 'gi://AstalNotifd?version=0.1';
import { Astal, Gtk } from 'astal/gtk3';
import { openMenu } from '../../utils/menu';
import options from 'src/options';
import { filterNotifications } from 'src/lib/shared/notifications.js';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { openDropdownMenu } from '../../utils/menu';
import { bind, Variable } from 'astal';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
import { filterNotifications } from 'src/lib/shared/notifications';
const notifdService = AstalNotifd.get_default();
const { show_total, rightClick, middleClick, scrollUp, scrollDown, hideCountWhenZero } =
@@ -107,7 +108,7 @@ export const Notifications = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'notificationsmenu');
openDropdownMenu(clicked, event, 'notificationsmenu');
}),
);

View File

@@ -1,13 +1,17 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { Module } from '../../shared/module';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import options from 'src/configuration';
const inputHandler = InputHandlerService.getInstance();
const { icon, leftClick, rightClick, middleClick, scrollUp, scrollDown } = options.bar.customModules.power;
export const Power = (): BarBoxChild => {
let inputHandlerBindings: Variable<void>;
const powerModule = Module({
tooltipText: 'Power Menu',
textIcon: bind(icon),
@@ -15,7 +19,7 @@ export const Power = (): BarBoxChild => {
boxClass: 'powermodule',
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -33,6 +37,9 @@ export const Power = (): BarBoxChild => {
},
});
},
onDestroy: () => {
inputHandlerBindings.drop();
},
},
});

View File

@@ -1,48 +0,0 @@
import { divide } from 'src/components/bar/utils/helpers';
import { GLib, Variable } from 'astal';
import { GenericResourceData } from 'src/lib/types/customModules/generic.types';
/**
* Calculates the RAM usage.
*
* This function reads the memory information from the /proc/meminfo file and calculates the total, used, and available RAM.
* It returns an object containing these values along with the percentage of used RAM.
*
* @param round A Variable<boolean> indicating whether to round the percentage value.
*
* @returns An object containing the total, used, free RAM in bytes, and the percentage of used RAM.
*/
export const calculateRamUsage = (round: Variable<boolean>): GenericResourceData => {
try {
const [success, meminfoBytes] = GLib.file_get_contents('/proc/meminfo');
if (!success || meminfoBytes === null) {
throw new Error('Failed to read /proc/meminfo or file content is null.');
}
const meminfo = new TextDecoder('utf-8').decode(meminfoBytes);
const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/);
const availableMatch = meminfo.match(/MemAvailable:\s+(\d+)/);
if (!totalMatch || !availableMatch) {
throw new Error('Failed to parse /proc/meminfo for memory values.');
}
const totalRamInBytes = parseInt(totalMatch[1], 10) * 1024;
const availableRamInBytes = parseInt(availableMatch[1], 10) * 1024;
let usedRam = totalRamInBytes - availableRamInBytes;
usedRam = isNaN(usedRam) || usedRam < 0 ? 0 : usedRam;
return {
percentage: divide([totalRamInBytes, usedRam], round.get()),
total: totalRamInBytes,
used: usedRam,
free: availableRamInBytes,
};
} catch (error) {
console.error('Error calculating RAM usage:', error);
return { total: 0, used: 0, percentage: 0, free: 0 };
}
};

View File

@@ -1,33 +1,25 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { calculateRamUsage } from './helpers';
import { formatTooltip, inputHandler, renderResourceLabel } from 'src/components/bar/utils/helpers';
import { LABEL_TYPES } from 'src/lib/types/defaults/bar.types';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { Module } from '../../shared/module';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { BarBoxChild, ResourceLabelType } from 'src/lib/types/bar.types';
import { GenericResourceData } from 'src/lib/types/customModules/generic.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { renderResourceLabel, formatTooltip } from '../../utils/systemResource';
import { InputHandlerService } from '../../utils/input/inputHandler';
import { GenericResourceData, ResourceLabelType, LABEL_TYPES } from 'src/services/system/types';
import RamUsageService from 'src/services/system/ramUsage';
const inputHandler = InputHandlerService.getInstance();
const { label, labelType, round, leftClick, rightClick, middleClick, pollingInterval, icon } =
options.bar.customModules.ram;
const defaultRamData: GenericResourceData = { total: 0, used: 0, percentage: 0, free: 0 };
const ramUsage = Variable<GenericResourceData>(defaultRamData);
const ramPoller = new FunctionPoller<GenericResourceData, [Variable<boolean>]>(
ramUsage,
[bind(round)],
bind(pollingInterval),
calculateRamUsage,
round,
);
ramPoller.initialize('ram');
const ramService = new RamUsageService({ frequency: pollingInterval });
export const Ram = (): BarBoxChild => {
ramService.initialize();
const labelBinding = Variable.derive(
[bind(ramUsage), bind(labelType), bind(round)],
[bind(ramService.ram), bind(labelType), bind(round)],
(rmUsg: GenericResourceData, lblTyp: ResourceLabelType, round: boolean) => {
const returnValue = renderResourceLabel(lblTyp, rmUsg, round);
@@ -35,6 +27,8 @@ export const Ram = (): BarBoxChild => {
},
);
let inputHandlerBindings: Variable<void>;
const ramModule = Module({
textIcon: bind(icon),
label: labelBinding(),
@@ -45,7 +39,7 @@ export const Ram = (): BarBoxChild => {
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -77,7 +71,9 @@ export const Ram = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
labelBinding.drop();
ramService.destroy();
},
},
});

View File

@@ -1,39 +0,0 @@
import GTop from 'gi://GTop';
import { divide } from 'src/components/bar/utils/helpers';
import { Variable } from 'astal';
import { GenericResourceData } from 'src/lib/types/customModules/generic.types';
/**
* Computes the storage usage for the root filesystem.
*
* This function calculates the total, used, and available storage for the root filesystem.
* It returns an object containing these values along with the percentage of used storage.
*
* @param round A Variable<boolean> indicating whether to round the percentage value.
*
* @returns An object containing the total, used, free storage in bytes, and the percentage of used storage.
*
* FIX: Consolidate with Storage service class
*/
export const computeStorage = (round: Variable<boolean>): GenericResourceData => {
try {
const currentFsUsage = new GTop.glibtop_fsusage();
GTop.glibtop_get_fsusage(currentFsUsage, '/');
const total = currentFsUsage.blocks * currentFsUsage.block_size;
const available = currentFsUsage.bavail * currentFsUsage.block_size;
const used = total - available;
return {
total,
used,
free: available,
percentage: divide([total, used], round.get()),
};
} catch (error) {
console.error('Error calculating RAM usage:', error);
return { total: 0, used: 0, percentage: 0, free: 0 };
}
};

View File

@@ -0,0 +1,90 @@
import { DriveStorageData } from 'src/services/system/storage/types';
import StorageService from 'src/services/system/storage';
import { renderResourceLabel } from 'src/components/bar/utils/systemResource';
import { SizeUnit } from 'src/lib/units/size/types';
export type TooltipStyle = 'percentage-bar' | 'tree' | 'simple';
/**
* Formats storage tooltip information based on the selected style
* @param paths - Array of mount paths to display
* @param storageService - The storage service instance
* @param style - The tooltip formatting style
* @param lblTyp - The label type for resource display
* @param round - Whether to round values
* @param sizeUnits - The size unit to use
*/
export function formatStorageTooltip(
paths: string[],
storageService: StorageService,
style: TooltipStyle,
round: boolean,
sizeUnits?: SizeUnit,
): string {
const driveData = paths
.map((path) => storageService.getDriveInfo(path))
.filter((usage): usage is DriveStorageData => usage !== undefined);
switch (style) {
case 'percentage-bar':
return formatPercentageBarStyle(driveData, round, sizeUnits);
case 'tree':
return formatTreeStyle(driveData, round, sizeUnits);
case 'simple':
default:
return formatSimpleStyle(driveData, round, sizeUnits);
}
}
/**
* Creates a visual percentage bar using Unicode characters
* @param percentage - The percentage value (0-100)
*/
function generatePercentBar(percentage: number): string {
const filledBlocks = Math.round(percentage / 10);
const emptyBlocks = 10 - filledBlocks;
return '▰'.repeat(filledBlocks) + '▱'.repeat(emptyBlocks);
}
/**
* Formats tooltip with visual percentage bars
*/
function formatPercentageBarStyle(drives: DriveStorageData[], round: boolean, sizeUnits?: SizeUnit): string {
return drives
.map((usage) => {
const lbl = renderResourceLabel('used/total', usage, round, sizeUnits);
const percentBar = generatePercentBar(usage.percentage);
const displayName = usage.path === '/' ? '◉ System' : `${usage.name}`;
return `${displayName}\n ${percentBar} ${usage.percentage.toFixed(1)}%\n ${lbl}`;
})
.join('\n\n');
}
/**
* Formats tooltip with tree-like structure
*/
function formatTreeStyle(drives: DriveStorageData[], round: boolean, sizeUnits?: SizeUnit): string {
return drives
.map((usage) => {
const lbl = renderResourceLabel('used/total', usage, round, sizeUnits);
const displayName = usage.path === '/' ? 'System' : usage.name;
return `${displayName}: ${usage.percentage.toFixed(1)}%\n └─ ${lbl}`;
})
.join('\n');
}
/**
* Formats tooltip with simple text layout
*/
function formatSimpleStyle(drives: DriveStorageData[], round: boolean, sizeUnits?: SizeUnit): string {
return drives
.map((usage) => {
const lbl = renderResourceLabel('used/total', usage, round, sizeUnits);
const displayName = usage.path === '/' ? 'System' : usage.name;
return `[${displayName}]: ${lbl}`;
})
.join('\n');
}

View File

@@ -1,49 +1,68 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { formatTooltip, inputHandler, renderResourceLabel } from 'src/components/bar/utils/helpers';
import { computeStorage } from './helpers';
import { LABEL_TYPES } from 'src/lib/types/defaults/bar.types';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { Module } from '../../shared/module';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { BarBoxChild, ResourceLabelType } from 'src/lib/types/bar.types';
import { GenericResourceData } from 'src/lib/types/customModules/generic.types';
import options from 'src/configuration';
import { renderResourceLabel } from '../../utils/systemResource';
import { LABEL_TYPES, ResourceLabelType } from 'src/services/system/types';
import { BarBoxChild } from '../../types';
import { InputHandlerService } from '../../utils/input/inputHandler';
import StorageService from 'src/services/system/storage';
import { formatStorageTooltip } from './helpers/tooltipFormatters';
const { label, labelType, icon, round, leftClick, rightClick, middleClick, pollingInterval } =
options.bar.customModules.storage;
const inputHandler = InputHandlerService.getInstance();
const defaultStorageData = { total: 0, used: 0, percentage: 0, free: 0 };
const storageUsage = Variable<GenericResourceData>(defaultStorageData);
const storagePoller = new FunctionPoller<GenericResourceData, [Variable<boolean>]>(
storageUsage,
[bind(round)],
bind(pollingInterval),
computeStorage,
const {
label,
labelType,
icon,
round,
);
leftClick,
rightClick,
middleClick,
pollingInterval,
units,
tooltipStyle,
paths,
} = options.bar.customModules.storage;
storagePoller.initialize('storage');
const storageService = new StorageService({ frequency: pollingInterval, round, pathsToMonitor: paths });
export const Storage = (): BarBoxChild => {
const tooltipText = Variable('');
storageService.initialize();
const labelBinding = Variable.derive(
[bind(storageUsage), bind(labelType), bind(round)],
(storage, lblTyp, round) => {
return renderResourceLabel(lblTyp, storage, round);
[bind(storageService.storage), bind(labelType), bind(paths), bind(tooltipStyle)],
(storage, lblTyp, filePaths) => {
const storageUnitToUse = units.get();
const sizeUnits = storageUnitToUse !== 'auto' ? storageUnitToUse : undefined;
const tooltipFormatted = formatStorageTooltip(
filePaths,
storageService,
tooltipStyle.get(),
round.get(),
sizeUnits,
);
tooltipText.set(tooltipFormatted);
return renderResourceLabel(lblTyp, storage, round.get(), sizeUnits);
},
);
let inputHandlerBindings: Variable<void>;
const storageModule = Module({
textIcon: bind(icon),
label: labelBinding(),
tooltipText: bind(labelType).as((lblTyp) => {
return formatTooltip('Storage', lblTyp);
}),
tooltipText: bind(tooltipText),
boxClass: 'storage',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -75,6 +94,7 @@ export const Storage = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
labelBinding.drop();
},
},

View File

@@ -1,12 +1,14 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { capitalizeFirstLetter } from 'src/lib/utils';
import { Module } from '../../shared/module';
import { getInitialSubmap, isSubmapEnabled } from './helpers';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { BarBoxChild } from 'src/lib/types/bar.types';
import options from 'src/configuration';
import { capitalizeFirstLetter } from 'src/lib/string/formatters';
import { BarBoxChild } from 'src/components/bar/types';
import { InputHandlerService } from '../../utils/input/inputHandler';
const inputHandler = InputHandlerService.getInstance();
const hyprlandService = AstalHyprland.get_default();
const {
@@ -52,6 +54,8 @@ export const Submap = (): BarBoxChild => {
},
);
let inputHandlerBindings: Variable<void>;
const submapModule = Module({
textIcon: submapIcon(),
tooltipText: submapLabel(),
@@ -60,7 +64,7 @@ export const Submap = (): BarBoxChild => {
boxClass: 'submap',
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -79,6 +83,7 @@ export const Submap = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
submapLabel.drop();
submapIcon.drop();
},

View File

@@ -1,14 +1,14 @@
import { isMiddleClick, isPrimaryClick, isSecondaryClick, Notify } from '../../../../lib/utils';
import options from '../../../../options';
import AstalTray from 'gi://AstalTray?version=0.1';
import { bind, Gio, Variable } from 'astal';
import { Gdk, Gtk } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { isPrimaryClick, isSecondaryClick, isMiddleClick } from 'src/lib/events/mouse';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
const systemtray = AstalTray.get_default();
const { ignore, customIcons } = options.bar.systray;
//TODO: Connect to `notify::menu-model` and `notify::action-group` to have up to date menu and action group
const createMenu = (menuModel: Gio.MenuModel, actionGroup: Gio.ActionGroup | null): Gtk.Menu => {
const menu = Gtk.Menu.new_from_model(menuModel);
menu.insert_action_group('dbusmenu', actionGroup);
@@ -31,7 +31,7 @@ const MenuDefaultIcon = ({ item }: MenuEntryProps): JSX.Element => {
return (
<icon
className={'systray-icon'}
gIcon={bind(item, 'gicon')}
gicon={bind(item, 'gicon')}
tooltipMarkup={bind(item, 'tooltipMarkup')}
/>
);
@@ -67,7 +67,7 @@ const MenuEntry = ({ item, child }: MenuEntryProps): JSX.Element => {
}
if (isMiddleClick(event)) {
Notify({ summary: 'App Name', body: item.id });
SystemUtilities.notify({ summary: 'App Name', body: item.id });
}
}}
onDestroy={() => {

View File

@@ -1,10 +1,12 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { Module } from '../../shared/module';
import { BashPoller } from 'src/lib/poller/BashPoller';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { InputHandlerService } from '../../utils/input/inputHandler';
const inputHandler = InputHandlerService.getInstance();
const {
updateCommand,
@@ -71,6 +73,8 @@ const updatesIcon = Variable.derive(
);
export const Updates = (): BarBoxChild => {
let inputHandlerBindings: Variable<void>;
const updatesModule = Module({
textIcon: updatesIcon(),
tooltipText: bind(pendingUpdatesTooltip),
@@ -80,7 +84,7 @@ export const Updates = (): BarBoxChild => {
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(
inputHandlerBindings = inputHandler.attachHandlers(
self,
{
onPrimaryClick: {
@@ -102,6 +106,9 @@ export const Updates = (): BarBoxChild => {
postInputUpdater,
);
},
onDestroy: () => {
inputHandlerBindings.drop();
},
},
});

View File

@@ -1,12 +1,13 @@
import { openMenu } from '../../utils/menu.js';
import options from 'src/options';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { bind, Variable } from 'astal';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import { onPrimaryClick, onSecondaryClick, onMiddleClick, onScroll } from 'src/lib/shared/eventHandlers';
import { getIcon } from './helpers/index.js';
import { Astal } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
import { BarBoxChild } from 'src/lib/types/bar.types.js';
import { BarBoxChild } from 'src/components/bar/types.js';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
import { openDropdownMenu } from '../../utils/menu';
const wireplumber = AstalWp.get_default() as AstalWp.Wp;
const audioService = wireplumber?.audio;
@@ -102,7 +103,7 @@ const Volume = (): BarBoxChild => {
disconnectFunctions.push(
onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'audiomenu');
openDropdownMenu(clicked, event, 'audiomenu');
}),
);

View File

@@ -1,37 +1,41 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { getWeatherStatusTextIcon, globalWeatherVar } from 'src/shared/weather';
import { Module } from '../../shared/module';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import WeatherService from 'src/services/weather';
import { InputHandlerService } from '../../utils/input/inputHandler';
import options from 'src/configuration';
import { toTitleCase } from 'src/lib/string/formatters';
const inputHandler = InputHandlerService.getInstance();
const weatherService = WeatherService.getInstance();
const { label, unit, leftClick, rightClick, middleClick, scrollUp, scrollDown } =
options.bar.customModules.weather;
export const Weather = (): BarBoxChild => {
const iconBinding = Variable.derive([bind(globalWeatherVar)], (wthr) => {
const weatherStatusIcon = getWeatherStatusTextIcon(wthr);
return weatherStatusIcon;
const iconBinding = Variable.derive([bind(weatherService.statusIcon)], (icon) => {
return icon;
});
const labelBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], (wthr, unt) => {
if (unt === 'imperial') {
return `${Math.ceil(wthr.current.temp_f)}° F`;
} else {
return `${Math.ceil(wthr.current.temp_c)}° C`;
}
const labelBinding = Variable.derive([bind(weatherService.temperature), bind(unit)], (temp) => {
return temp;
});
let inputHandlerBindings: Variable<void>;
const weatherModule = Module({
textIcon: iconBinding(),
tooltipText: bind(globalWeatherVar).as((v) => `Weather Status: ${v.current.condition.text}`),
tooltipText: bind(weatherService.weatherData).as(
(wthr) => `Weather Status: ${toTitleCase(wthr.current.condition.text)}`,
),
boxClass: 'weather-custom',
label: labelBinding(),
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -50,6 +54,7 @@ export const Weather = (): BarBoxChild => {
});
},
onDestroy: () => {
inputHandlerBindings.drop();
iconBinding.drop();
labelBinding.drop();
},

View File

@@ -1,8 +1,8 @@
import options from 'src/options';
import { capitalizeFirstLetter } from 'src/lib/utils';
import { defaultWindowTitleMap } from 'src/lib/constants/appIcons';
import { defaultWindowTitleMap } from 'src/components/bar/modules/window_title/helpers/appIcons';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { bind, Variable } from 'astal';
import options from 'src/configuration';
import { capitalizeFirstLetter } from 'src/lib/string/formatters';
const { title_map: userDefinedTitles } = options.bar.windowtitle;

View File

@@ -1,11 +1,12 @@
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers';
import options from 'src/options';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { bind, Variable } from 'astal';
import { clientTitle, getTitle, getWindowMatch, truncateTitle } from './helpers/title';
import { Astal } from 'astal/gtk3';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { runAsyncCommand } from '../../utils/input/commandExecutor';
import { throttledScrollHandler } from '../../utils/input/throttle';
const hyprlandService = AstalHyprland.get_default();
const { leftClick, rightClick, middleClick, scrollDown, scrollUp } = options.bar.windowtitle;

View File

@@ -1,406 +1,278 @@
import { Variable } from 'astal';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { MonitorMap, WorkspaceMonitorMap, WorkspaceRule } from 'src/lib/types/workspace.types';
import { range } from 'src/lib/utils';
import options from 'src/options';
import options from 'src/configuration';
import { defaultApplicationIconMap } from 'src/components/bar/modules/window_title/helpers/appIcons';
import { isValidGjsColor } from 'src/lib/validation/colors';
import { AppIconOptions } from './types';
import { WorkspaceIconMap } from '../types';
import { unique } from 'src/lib/array/helpers';
const hyprlandService = AstalHyprland.get_default();
const { workspaces, reverse_scroll, ignored } = options.bar.workspaces;
const { monochrome, background } = options.theme.bar.buttons;
const { background: wsBackground, active } = options.theme.bar.buttons.workspaces;
const { showWsIcons, showAllActive, numbered_active_indicator: wsActiveIndicator } = options.bar.workspaces;
/**
* A Variable that holds the current map of monitors to the workspace numbers assigned to them.
* Determines if a workspace is active on a given monitor.
*
* This function checks if the workspace with the specified index is currently active on the given monitor.
* It uses the `showAllActive` setting and the `hyprlandService` to determine the active workspace on the monitor.
*
* @param monitor The index of the monitor to check.
* @param i The index of the workspace to check.
*
* @returns True if the workspace is active on the monitor, false otherwise.
*/
export const workspaceRules = Variable(getWorkspaceMonitorMap());
/**
* A Variable used to force UI or other updates when relevant workspace events occur.
*/
export const forceUpdater = Variable(true);
/**
* Retrieves the workspace numbers associated with a specific monitor.
*
* If only one monitor exists, this will simply return a list of all possible workspaces.
* Otherwise, it will consult the workspace rules to determine which workspace numbers
* belong to the specified monitor.
*
* @param monitorId - The numeric identifier of the monitor.
*
* @returns An array of workspace numbers belonging to the specified monitor.
*/
export function getWorkspacesForMonitor(monitorId: number): number[] {
const allMonitors = hyprlandService.get_monitors();
if (allMonitors.length === 1) {
return Array.from({ length: workspaces.get() }, (_, index) => index + 1);
}
const workspaceMonitorRules = getWorkspaceMonitorMap();
const monitorNameMap: MonitorMap = {};
allMonitors.forEach((monitorInstance) => {
monitorNameMap[monitorInstance.id] = monitorInstance.name;
});
const currentMonitorName = monitorNameMap[monitorId];
return workspaceMonitorRules[currentMonitorName];
}
/**
* Checks whether a given workspace is valid (assigned) for the specified monitor.
*
* This function inspects the workspace rules object to determine if the current workspace belongs
* to the target monitor. If no workspace rules exist, the function defaults to returning `true`.
*
* @param workspaceId - The number representing the current workspace.
* @param workspaceMonitorRules - The map of monitor names to assigned workspace numbers.
* @param monitorId - The numeric identifier for the monitor.
* @param workspaceList - A list of Hyprland workspace objects.
* @param monitorList - A list of Hyprland monitor objects.
*
* @returns `true` if the workspace is assigned to the monitor or if no rules exist. Otherwise, `false`.
*/
function isWorkspaceValidForMonitor(
workspaceId: number,
workspaceMonitorRules: WorkspaceMonitorMap,
monitorId: number,
workspaceList: AstalHyprland.Workspace[],
monitorList: AstalHyprland.Monitor[],
): boolean {
const monitorNameMap: MonitorMap = {};
const allWorkspaceInstances = workspaceList ?? [];
const workspaceMonitorReferences = allWorkspaceInstances
.filter((workspaceInstance) => workspaceInstance !== null)
.map((workspaceInstance) => {
return {
id: workspaceInstance.monitor?.id,
name: workspaceInstance.monitor?.name,
};
});
const mergedMonitorInstances = [
...new Map(
[...workspaceMonitorReferences, ...monitorList].map((monitorCandidate) => [
monitorCandidate.id,
monitorCandidate,
]),
).values(),
];
mergedMonitorInstances.forEach((monitorInstance) => {
monitorNameMap[monitorInstance.id] = monitorInstance.name;
});
const currentMonitorName = monitorNameMap[monitorId];
const currentMonitorWorkspaceRules = workspaceMonitorRules[currentMonitorName] ?? [];
const activeWorkspaceIds = new Set(allWorkspaceInstances.map((ws) => ws.id));
const filteredWorkspaceRules = currentMonitorWorkspaceRules.filter((ws) => !activeWorkspaceIds.has(ws));
if (filteredWorkspaceRules === undefined) {
return false;
}
return filteredWorkspaceRules.includes(workspaceId);
}
/**
* Fetches a map of monitors to the workspace numbers that belong to them.
*
* This function communicates with the Hyprland service to retrieve workspace rules in JSON format.
* Those rules are parsed, and a map of monitor names to lists of assigned workspace numbers is constructed.
*
* @returns An object where each key is a monitor name, and each value is an array of workspace numbers.
*/
function getWorkspaceMonitorMap(): WorkspaceMonitorMap {
try {
const rulesResponse = hyprlandService.message('j/workspacerules');
const workspaceMonitorRules: WorkspaceMonitorMap = {};
const parsedWorkspaceRules = JSON.parse(rulesResponse);
parsedWorkspaceRules.forEach((rule: WorkspaceRule) => {
const workspaceNumber = parseInt(rule.workspaceString, 10);
if (rule.monitor === undefined || isNaN(workspaceNumber)) {
return;
}
const doesMonitorExistInRules = Object.hasOwnProperty.call(workspaceMonitorRules, rule.monitor);
if (doesMonitorExistInRules) {
workspaceMonitorRules[rule.monitor].push(workspaceNumber);
} else {
workspaceMonitorRules[rule.monitor] = [workspaceNumber];
}
});
return workspaceMonitorRules;
} catch (error) {
console.error(error);
return {};
}
}
/**
* Checks if a workspace number should be ignored based on a regular expression.
*
* @param ignoredWorkspacesVariable - A Variable object containing a string pattern of ignored workspaces.
* @param workspaceNumber - The numeric representation of the workspace to check.
*
* @returns `true` if the workspace should be ignored, otherwise `false`.
*/
function isWorkspaceIgnored(ignoredWorkspacesVariable: Variable<string>, workspaceNumber: number): boolean {
if (ignoredWorkspacesVariable.get() === '') {
return false;
}
const ignoredWorkspacesRegex = new RegExp(ignoredWorkspacesVariable.get());
return ignoredWorkspacesRegex.test(workspaceNumber.toString());
}
/**
* Changes the active workspace in the specified direction ('next' or 'prev').
*
* This function uses the current monitor's set of active or assigned workspaces and
* cycles through them in the chosen direction. It also respects the list of ignored
* workspaces, skipping any that match the ignored pattern.
*
* @param direction - The direction to navigate ('next' or 'prev').
* @param currentMonitorWorkspacesVariable - A Variable containing an array of workspace numbers for the current monitor.
* @param onlyActiveWorkspaces - Whether to only include active (occupied) workspaces when navigating.
* @param ignoredWorkspacesVariable - A Variable that contains the ignored workspaces pattern.
*/
function navigateWorkspace(direction: 'next' | 'prev', ignoredWorkspacesVariable: Variable<string>): void {
const allHyprlandWorkspaces = hyprlandService.get_workspaces() ?? [];
const activeWorkspaceIds = allHyprlandWorkspaces
.filter((workspaceInstance) => hyprlandService.focusedMonitor.id === workspaceInstance.monitor?.id)
.map((workspaceInstance) => workspaceInstance.id);
const assignedOrOccupiedWorkspaces = activeWorkspaceIds.sort((a, b) => a - b);
if (assignedOrOccupiedWorkspaces.length === 0) {
return;
}
const workspaceIndex = assignedOrOccupiedWorkspaces.indexOf(hyprlandService.focusedWorkspace?.id);
const step = direction === 'next' ? 1 : -1;
let newIndex =
(workspaceIndex + step + assignedOrOccupiedWorkspaces.length) % assignedOrOccupiedWorkspaces.length;
let attempts = 0;
while (attempts < assignedOrOccupiedWorkspaces.length) {
const targetWorkspaceNumber = assignedOrOccupiedWorkspaces[newIndex];
if (!isWorkspaceIgnored(ignoredWorkspacesVariable, targetWorkspaceNumber)) {
hyprlandService.dispatch('workspace', targetWorkspaceNumber.toString());
return;
}
newIndex =
(newIndex + step + assignedOrOccupiedWorkspaces.length) % assignedOrOccupiedWorkspaces.length;
attempts++;
}
}
/**
* Navigates to the next workspace in the current monitor.
*
* @param currentMonitorWorkspacesVariable - A Variable containing workspace numbers for the current monitor.
* @param onlyActiveWorkspaces - Whether to only navigate among active (occupied) workspaces.
* @param ignoredWorkspacesVariable - A Variable that contains the ignored workspaces pattern.
*/
export function goToNextWorkspace(ignoredWorkspacesVariable: Variable<string>): void {
navigateWorkspace('next', ignoredWorkspacesVariable);
}
/**
* Navigates to the previous workspace in the current monitor.
*
* @param currentMonitorWorkspacesVariable - A Variable containing workspace numbers for the current monitor.
* @param onlyActiveWorkspaces - Whether to only navigate among active (occupied) workspaces.
* @param ignoredWorkspacesVariable - A Variable that contains the ignored workspaces pattern.
*/
export function goToPreviousWorkspace(ignoredWorkspacesVariable: Variable<string>): void {
navigateWorkspace('prev', ignoredWorkspacesVariable);
}
/**
* Limits the execution rate of a given function to prevent it from being called too often.
*
* @param func - The function to be throttled.
* @param limit - The time limit (in milliseconds) during which calls to `func` are disallowed after the first call.
*
* @returns The throttled version of the input function.
*/
export function throttle<T extends (...args: unknown[]) => void>(func: T, limit: number): T {
let isThrottleActive: boolean;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (!isThrottleActive) {
func.apply(this, args);
isThrottleActive = true;
setTimeout(() => {
isThrottleActive = false;
}, limit);
}
} as T;
}
/**
* Creates throttled scroll handlers that navigate workspaces upon scrolling, respecting the configured scroll speed.
*
* @param scrollSpeed - The factor by which the scroll navigation is throttled.
* @param onlyActiveWorkspaces - Whether to only navigate among active (occupied) workspaces.
*
* @returns An object containing two functions (`throttledScrollUp` and `throttledScrollDown`), both throttled.
*/
export function initThrottledScrollHandlers(scrollSpeed: number): ThrottledScrollHandlers {
const throttledScrollUp = throttle(() => {
if (reverse_scroll.get()) {
goToPreviousWorkspace(ignored);
} else {
goToNextWorkspace(ignored);
}
}, 200 / scrollSpeed);
const throttledScrollDown = throttle(() => {
if (reverse_scroll.get()) {
goToNextWorkspace(ignored);
} else {
goToPreviousWorkspace(ignored);
}
}, 200 / scrollSpeed);
return { throttledScrollUp, throttledScrollDown };
}
/**
* Computes which workspace numbers should be rendered for a given monitor.
*
* This function consolidates both active and all possible workspaces (based on rules),
* then filters them by the selected monitor if `isMonitorSpecific` is set to `true`.
*
* @param totalWorkspaces - The total number of workspaces (a fallback if workspace rules are not enforced).
* @param workspaceInstances - A list of Hyprland workspace objects.
* @param workspaceMonitorRules - The map of monitor names to assigned workspace numbers.
* @param monitorId - The numeric identifier of the monitor.
* @param isMonitorSpecific - If `true`, only include the workspaces that match this monitor.
* @param hyprlandMonitorInstances - A list of Hyprland monitor objects.
*
* @returns An array of workspace numbers that should be shown.
*/
export function getWorkspacesToRender(
totalWorkspaces: number,
workspaceInstances: AstalHyprland.Workspace[],
workspaceMonitorRules: WorkspaceMonitorMap,
monitorId: number,
isMonitorSpecific: boolean,
hyprlandMonitorInstances: AstalHyprland.Monitor[],
): number[] {
let allPotentialWorkspaces = range(totalWorkspaces || 8);
const allWorkspaceInstances = workspaceInstances ?? [];
const activeWorkspaceIds = allWorkspaceInstances.map((workspaceInstance) => workspaceInstance.id);
const monitorReferencesForActiveWorkspaces = allWorkspaceInstances.map((workspaceInstance) => {
return {
id: workspaceInstance.monitor?.id ?? -1,
name: workspaceInstance.monitor?.name ?? '',
};
});
const currentMonitorInstance =
hyprlandMonitorInstances.find((monitorObj) => monitorObj.id === monitorId) ||
monitorReferencesForActiveWorkspaces.find((monitorObj) => monitorObj.id === monitorId);
const allWorkspacesWithRules = Object.keys(workspaceMonitorRules).reduce(
(accumulator: number[], monitorName: string) => {
return [...accumulator, ...workspaceMonitorRules[monitorName]];
},
[],
);
const activeWorkspacesForCurrentMonitor = activeWorkspaceIds.filter((workspaceId) => {
const metadataForWorkspace = allWorkspaceInstances.find(
(workspaceObj) => workspaceObj.id === workspaceId,
);
if (metadataForWorkspace) {
return metadataForWorkspace?.monitor?.id === monitorId;
}
if (
currentMonitorInstance &&
Object.hasOwnProperty.call(workspaceMonitorRules, currentMonitorInstance.name) &&
allWorkspacesWithRules.includes(workspaceId)
) {
return workspaceMonitorRules[currentMonitorInstance.name].includes(workspaceId);
}
return false;
});
if (isMonitorSpecific) {
const validWorkspaceNumbers = range(totalWorkspaces).filter((workspaceNumber) => {
return isWorkspaceValidForMonitor(
workspaceNumber,
workspaceMonitorRules,
monitorId,
allWorkspaceInstances,
hyprlandMonitorInstances,
);
});
allPotentialWorkspaces = [
...new Set([...activeWorkspacesForCurrentMonitor, ...validWorkspaceNumbers]),
];
} else {
allPotentialWorkspaces = [...new Set([...allPotentialWorkspaces, ...activeWorkspaceIds])];
}
return allPotentialWorkspaces
.filter((workspace) => !isWorkspaceIgnored(ignored, workspace))
.sort((a, b) => a - b);
}
/**
* Subscribes to Hyprland service events related to workspaces to keep the local state updated.
*
* When certain events occur (like a configuration reload or a client being moved/added/removed),
* this function updates the workspace rules or toggles the `forceUpdater` variable to ensure
* that any dependent UI or logic is re-rendered or re-run.
*/
export function initWorkspaceEvents(): void {
hyprlandService.connect('config-reloaded', () => {
workspaceRules.set(getWorkspaceMonitorMap());
});
hyprlandService.connect('client-moved', () => {
forceUpdater.set(!forceUpdater.get());
});
hyprlandService.connect('client-added', () => {
forceUpdater.set(!forceUpdater.get());
});
hyprlandService.connect('client-removed', () => {
forceUpdater.set(!forceUpdater.get());
});
}
/**
* Throttled scroll handler functions for navigating workspaces.
*/
type ThrottledScrollHandlers = {
/**
* Scroll up throttled handler.
*/
throttledScrollUp: () => void;
/**
* Scroll down throttled handler.
*/
throttledScrollDown: () => void;
const isWorkspaceActiveOnMonitor = (monitor: number, i: number): boolean => {
return showAllActive.get() && hyprlandService.get_monitor(monitor)?.activeWorkspace?.id === i;
};
/**
* Retrieves the icon for a given workspace.
*
* This function returns the icon associated with a workspace from the provided workspace icon map.
* If no icon is found, it returns the workspace index as a string.
*
* @param wsIconMap The map of workspace icons where keys are workspace indices and values are icons or icon objects.
* @param i The index of the workspace for which to retrieve the icon.
*
* @returns The icon for the workspace as a string. If no icon is found, returns the workspace index as a string.
*/
const getWsIcon = (wsIconMap: WorkspaceIconMap, i: number): string => {
const iconEntry = wsIconMap[i];
const defaultIcon = `${i}`;
if (iconEntry === undefined) {
return defaultIcon;
}
if (typeof iconEntry === 'string' && iconEntry !== '') {
return iconEntry;
}
const hasIcon = typeof iconEntry === 'object' && 'icon' in iconEntry && iconEntry.icon !== '';
if (hasIcon) {
return iconEntry.icon;
}
return defaultIcon;
};
/**
* Retrieves the color for a given workspace.
*
* This function determines the color styling for a workspace based on the provided workspace icon map,
* smart highlighting settings, and the monitor index. It returns a CSS string for the color and background.
*
* @param wsIconMap The map of workspace icons where keys are workspace indices and values are icon objects.
* @param i The index of the workspace for which to retrieve the color.
* @param smartHighlight A boolean indicating whether smart highlighting is enabled.
* @param monitor The index of the monitor to check for active workspaces.
*
* @returns A CSS string representing the color and background for the workspace. If no color is found, returns an empty string.
*/
export const getWsColor = (
wsIconMap: WorkspaceIconMap,
i: number,
smartHighlight: boolean,
monitor: number,
): string => {
const iconEntry = wsIconMap[i];
const hasColor =
typeof iconEntry === 'object' && 'color' in iconEntry && isValidGjsColor(iconEntry.color);
if (iconEntry === undefined) {
return '';
}
if (
showWsIcons.get() &&
smartHighlight &&
wsActiveIndicator.get() === 'highlight' &&
(hyprlandService.focusedWorkspace?.id === i || isWorkspaceActiveOnMonitor(monitor, i))
) {
const iconColor = monochrome.get() ? background.get() : wsBackground.get();
const iconBackground = hasColor && isValidGjsColor(iconEntry.color) ? iconEntry.color : active.get();
const colorCss = `color: ${iconColor};`;
const backgroundCss = `background: ${iconBackground};`;
return colorCss + backgroundCss;
}
if (hasColor && isValidGjsColor(iconEntry.color)) {
return `color: ${iconEntry.color}; border-bottom-color: ${iconEntry.color};`;
}
return '';
};
/**
* Retrieves the application icon for a given workspace.
*
* This function returns the appropriate application icon for the specified workspace index.
* It considers user-defined icons, default icons, and the option to remove duplicate icons.
*
* @param workspaceIndex The index of the workspace for which to retrieve the application icon.
* @param removeDuplicateIcons A boolean indicating whether to remove duplicate icons.
* @param options An object containing user-defined icon map, default icon, and empty icon.
*
* @returns The application icon for the workspace as a string. If no icons are found, returns the default or empty icon.
*/
export const getAppIcon = (
workspaceIndex: number,
removeDuplicateIcons: boolean,
{ iconMap: userDefinedIconMap, defaultIcon, emptyIcon }: AppIconOptions,
): string => {
const workspaceClients = hyprlandService
.get_clients()
.filter((client) => client?.workspace?.id === workspaceIndex)
.map((client) => [client.class, client.title]);
if (!workspaceClients.length) {
return emptyIcon;
}
const findIconForClient = (clientClass: string, clientTitle: string): string | undefined => {
const appIconMap = { ...userDefinedIconMap, ...defaultApplicationIconMap };
const iconEntry = Object.entries(appIconMap).find(([matcher]) => {
if (matcher.startsWith('class:')) {
return new RegExp(matcher.substring(6)).test(clientClass);
}
if (matcher.startsWith('title:')) {
return new RegExp(matcher.substring(6)).test(clientTitle);
}
return new RegExp(matcher, 'i').test(clientClass);
});
return iconEntry?.[1] ?? defaultIcon;
};
let icons = workspaceClients.reduce((iconAccumulator, [clientClass, clientTitle]) => {
const icon = findIconForClient(clientClass, clientTitle);
if (icon !== undefined) {
iconAccumulator.push(icon);
}
return iconAccumulator;
}, []);
if (icons.length) {
if (removeDuplicateIcons) {
icons = unique(icons);
}
return icons.join(' ');
}
return defaultIcon;
};
/**
* Renders the class names for a workspace.
*
* This function generates the appropriate class names for a workspace based on various settings such as
* whether to show icons, numbered workspaces, workspace icons, and smart highlighting.
*
* @param showIcons A boolean indicating whether to show icons.
* @param showNumbered A boolean indicating whether to show numbered workspaces.
* @param numberedActiveIndicator The indicator for active numbered workspaces.
* @param showWsIcons A boolean indicating whether to show workspace icons.
* @param smartHighlight A boolean indicating whether smart highlighting is enabled.
* @param monitor The index of the monitor to check for active workspaces.
* @param i The index of the workspace for which to render class names.
*
* @returns The class names for the workspace as a string.
*/
export const renderClassnames = (
showIcons: boolean,
showNumbered: boolean,
numberedActiveIndicator: string,
showWsIcons: boolean,
smartHighlight: boolean,
monitor: number,
i: number,
): string => {
const isWorkspaceActive =
hyprlandService.focusedWorkspace?.id === i || isWorkspaceActiveOnMonitor(monitor, i);
const isActive = isWorkspaceActive ? 'active' : '';
if (showIcons) {
return `workspace-icon txt-icon bar ${isActive}`;
}
if (showNumbered || showWsIcons) {
const numActiveInd = isWorkspaceActive ? numberedActiveIndicator : '';
const wsIconClass = showWsIcons ? 'txt-icon' : '';
const smartHighlightClass = smartHighlight ? 'smart-highlight' : '';
const className = `workspace-number can_${numberedActiveIndicator} ${numActiveInd} ${wsIconClass} ${smartHighlightClass} ${isActive}`;
return className.trim();
}
return `default ${isActive}`;
};
/**
* Renders the label for a workspace.
*
* This function generates the appropriate label for a workspace based on various settings such as
* whether to show icons, application icons, workspace icons, and workspace indicators.
*
* @param showIcons A boolean indicating whether to show icons.
* @param availableIndicator The indicator for available workspaces.
* @param activeIndicator The indicator for active workspaces.
* @param occupiedIndicator The indicator for occupied workspaces.
* @param showAppIcons A boolean indicating whether to show application icons.
* @param appIcons The application icons as a string.
* @param workspaceMask A boolean indicating whether to mask the workspace.
* @param showWorkspaceIcons A boolean indicating whether to show workspace icons.
* @param wsIconMap The map of workspace icons where keys are workspace indices and values are icons or icon objects.
* @param i The index of the workspace for which to render the label.
* @param index The index of the workspace in the list.
* @param monitor The index of the monitor to check for active workspaces.
*
* @returns The label for the workspace as a string.
*/
export const renderLabel = (
showIcons: boolean,
availableIndicator: string,
activeIndicator: string,
occupiedIndicator: string,
showAppIcons: boolean,
appIcons: string,
workspaceMask: boolean,
showWorkspaceIcons: boolean,
wsIconMap: WorkspaceIconMap,
i: number,
index: number,
monitor: number,
): string => {
if (showAppIcons) {
return appIcons;
}
if (showIcons) {
if (hyprlandService.focusedWorkspace?.id === i || isWorkspaceActiveOnMonitor(monitor, i)) {
return activeIndicator;
}
if ((hyprlandService.get_workspace(i)?.get_clients().length || 0) > 0) {
return occupiedIndicator;
}
if (monitor !== -1) {
return availableIndicator;
}
}
if (showWorkspaceIcons) {
return getWsIcon(wsIconMap, i);
}
return workspaceMask ? `${index + 1}` : `${i}`;
};

View File

@@ -0,0 +1,7 @@
import { ApplicationIcons } from '../types';
export type AppIconOptions = {
iconMap: ApplicationIcons;
defaultIcon: string;
emptyIcon: string;
};

View File

@@ -1,276 +1,99 @@
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { defaultApplicationIconMap } from 'src/lib/constants/appIcons';
import { WorkspaceIconMap, AppIconOptions } from 'src/lib/types/workspace.types';
import { isValidGjsColor } from 'src/lib/utils';
import options from 'src/options';
import options from 'src/configuration';
import { WorkspaceService } from 'src/services/workspace';
const workspaceService = WorkspaceService.getInstance();
const hyprlandService = AstalHyprland.get_default();
const { monochrome, background } = options.theme.bar.buttons;
const { background: wsBackground, active } = options.theme.bar.buttons.workspaces;
const { showWsIcons, showAllActive, numbered_active_indicator: wsActiveIndicator } = options.bar.workspaces;
const { reverse_scroll } = options.bar.workspaces;
/**
* Determines if a workspace is active on a given monitor.
* Limits the execution rate of a given function to prevent it from being called too often.
*
* This function checks if the workspace with the specified index is currently active on the given monitor.
* It uses the `showAllActive` setting and the `hyprlandService` to determine the active workspace on the monitor.
* @param func - The function to be throttled.
* @param limit - The time limit (in milliseconds) during which calls to `func` are disallowed after the first call.
*
* @param monitor The index of the monitor to check.
* @param i The index of the workspace to check.
*
* @returns True if the workspace is active on the monitor, false otherwise.
* @returns The throttled version of the input function.
*/
const isWorkspaceActiveOnMonitor = (monitor: number, i: number): boolean => {
return showAllActive.get() && hyprlandService.get_monitor(monitor)?.activeWorkspace?.id === i;
};
function throttle<T extends (...args: unknown[]) => void>(func: T, limit: number): T {
let isThrottleActive: boolean;
/**
* Retrieves the icon for a given workspace.
*
* This function returns the icon associated with a workspace from the provided workspace icon map.
* If no icon is found, it returns the workspace index as a string.
*
* @param wsIconMap The map of workspace icons where keys are workspace indices and values are icons or icon objects.
* @param i The index of the workspace for which to retrieve the icon.
*
* @returns The icon for the workspace as a string. If no icon is found, returns the workspace index as a string.
*/
const getWsIcon = (wsIconMap: WorkspaceIconMap, i: number): string => {
const iconEntry = wsIconMap[i];
const defaultIcon = `${i}`;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (!isThrottleActive) {
func.apply(this, args);
isThrottleActive = true;
if (iconEntry === undefined) {
return defaultIcon;
}
if (typeof iconEntry === 'string' && iconEntry !== '') {
return iconEntry;
}
const hasIcon = typeof iconEntry === 'object' && 'icon' in iconEntry && iconEntry.icon !== '';
if (hasIcon) {
return iconEntry.icon;
}
return defaultIcon;
};
/**
* Retrieves the color for a given workspace.
*
* This function determines the color styling for a workspace based on the provided workspace icon map,
* smart highlighting settings, and the monitor index. It returns a CSS string for the color and background.
*
* @param wsIconMap The map of workspace icons where keys are workspace indices and values are icon objects.
* @param i The index of the workspace for which to retrieve the color.
* @param smartHighlight A boolean indicating whether smart highlighting is enabled.
* @param monitor The index of the monitor to check for active workspaces.
*
* @returns A CSS string representing the color and background for the workspace. If no color is found, returns an empty string.
*/
export const getWsColor = (
wsIconMap: WorkspaceIconMap,
i: number,
smartHighlight: boolean,
monitor: number,
): string => {
const iconEntry = wsIconMap[i];
const hasColor =
typeof iconEntry === 'object' && 'color' in iconEntry && isValidGjsColor(iconEntry.color);
if (iconEntry === undefined) {
return '';
}
if (
showWsIcons.get() &&
smartHighlight &&
wsActiveIndicator.get() === 'highlight' &&
(hyprlandService.focusedWorkspace?.id === i || isWorkspaceActiveOnMonitor(monitor, i))
) {
const iconColor = monochrome.get() ? background.get() : wsBackground.get();
const iconBackground = hasColor && isValidGjsColor(iconEntry.color) ? iconEntry.color : active.get();
const colorCss = `color: ${iconColor};`;
const backgroundCss = `background: ${iconBackground};`;
return colorCss + backgroundCss;
}
if (hasColor && isValidGjsColor(iconEntry.color)) {
return `color: ${iconEntry.color}; border-bottom-color: ${iconEntry.color};`;
}
return '';
};
/**
* Retrieves the application icon for a given workspace.
*
* This function returns the appropriate application icon for the specified workspace index.
* It considers user-defined icons, default icons, and the option to remove duplicate icons.
*
* @param workspaceIndex The index of the workspace for which to retrieve the application icon.
* @param removeDuplicateIcons A boolean indicating whether to remove duplicate icons.
* @param options An object containing user-defined icon map, default icon, and empty icon.
*
* @returns The application icon for the workspace as a string. If no icons are found, returns the default or empty icon.
*/
export const getAppIcon = (
workspaceIndex: number,
removeDuplicateIcons: boolean,
{ iconMap: userDefinedIconMap, defaultIcon, emptyIcon }: AppIconOptions,
): string => {
const workspaceClients = hyprlandService
.get_clients()
.filter((client) => client?.workspace?.id === workspaceIndex)
.map((client) => [client.class, client.title]);
if (!workspaceClients.length) {
return emptyIcon;
}
const findIconForClient = (clientClass: string, clientTitle: string): string | undefined => {
const appIconMap = { ...userDefinedIconMap, ...defaultApplicationIconMap };
const iconEntry = Object.entries(appIconMap).find(([matcher]) => {
if (matcher.startsWith('class:')) {
return new RegExp(matcher.substring(6)).test(clientClass);
}
if (matcher.startsWith('title:')) {
return new RegExp(matcher.substring(6)).test(clientTitle);
}
return new RegExp(matcher, 'i').test(clientClass);
});
return iconEntry?.[1] ?? defaultIcon;
};
let icons = workspaceClients.reduce((iconAccumulator, [clientClass, clientTitle]) => {
const icon = findIconForClient(clientClass, clientTitle);
if (icon !== undefined) {
iconAccumulator.push(icon);
setTimeout(() => {
isThrottleActive = false;
}, limit);
}
return iconAccumulator;
}, []);
if (icons.length) {
if (removeDuplicateIcons) {
icons = [...new Set(icons)];
}
return icons.join(' ');
}
return defaultIcon;
};
} as T;
}
/**
* Renders the class names for a workspace.
* Creates throttled scroll handlers that navigate workspaces upon scrolling, respecting the configured scroll speed.
*
* This function generates the appropriate class names for a workspace based on various settings such as
* whether to show icons, numbered workspaces, workspace icons, and smart highlighting.
* @param scrollSpeed - The factor by which the scroll navigation is throttled.
* @param onlyActiveWorkspaces - Whether to only navigate among active (occupied) workspaces.
*
* @param showIcons A boolean indicating whether to show icons.
* @param showNumbered A boolean indicating whether to show numbered workspaces.
* @param numberedActiveIndicator The indicator for active numbered workspaces.
* @param showWsIcons A boolean indicating whether to show workspace icons.
* @param smartHighlight A boolean indicating whether smart highlighting is enabled.
* @param monitor The index of the monitor to check for active workspaces.
* @param i The index of the workspace for which to render class names.
*
* @returns The class names for the workspace as a string.
* @returns An object containing two functions (`throttledScrollUp` and `throttledScrollDown`), both throttled.
*/
export const renderClassnames = (
showIcons: boolean,
showNumbered: boolean,
numberedActiveIndicator: string,
showWsIcons: boolean,
smartHighlight: boolean,
monitor: number,
i: number,
): string => {
const isWorkspaceActive =
hyprlandService.focusedWorkspace?.id === i || isWorkspaceActiveOnMonitor(monitor, i);
const isActive = isWorkspaceActive ? 'active' : '';
export function initThrottledScrollHandlers(scrollSpeed: number): ThrottledScrollHandlers {
const throttledScrollUp = throttle(() => {
if (reverse_scroll.get()) {
workspaceService.goToPreviousWorkspace();
} else {
workspaceService.goToNextWorkspace();
}
}, 200 / scrollSpeed);
if (showIcons) {
return `workspace-icon txt-icon bar ${isActive}`;
}
const throttledScrollDown = throttle(() => {
if (reverse_scroll.get()) {
workspaceService.goToNextWorkspace();
} else {
workspaceService.goToPreviousWorkspace();
}
}, 200 / scrollSpeed);
if (showNumbered || showWsIcons) {
const numActiveInd = isWorkspaceActive ? numberedActiveIndicator : '';
const wsIconClass = showWsIcons ? 'txt-icon' : '';
const smartHighlightClass = smartHighlight ? 'smart-highlight' : '';
const className = `workspace-number can_${numberedActiveIndicator} ${numActiveInd} ${wsIconClass} ${smartHighlightClass} ${isActive}`;
return className.trim();
}
return `default ${isActive}`;
};
return { throttledScrollUp, throttledScrollDown };
}
/**
* Renders the label for a workspace.
* Subscribes to Hyprland service events related to workspaces to keep the local state updated.
*
* This function generates the appropriate label for a workspace based on various settings such as
* whether to show icons, application icons, workspace icons, and workspace indicators.
*
* @param showIcons A boolean indicating whether to show icons.
* @param availableIndicator The indicator for available workspaces.
* @param activeIndicator The indicator for active workspaces.
* @param occupiedIndicator The indicator for occupied workspaces.
* @param showAppIcons A boolean indicating whether to show application icons.
* @param appIcons The application icons as a string.
* @param workspaceMask A boolean indicating whether to mask the workspace.
* @param showWorkspaceIcons A boolean indicating whether to show workspace icons.
* @param wsIconMap The map of workspace icons where keys are workspace indices and values are icons or icon objects.
* @param i The index of the workspace for which to render the label.
* @param index The index of the workspace in the list.
* @param monitor The index of the monitor to check for active workspaces.
*
* @returns The label for the workspace as a string.
* When certain events occur (like a configuration reload or a client being moved/added/removed),
* this function updates the workspace rules or toggles the `forceUpdater` variable to ensure
* that any dependent UI or logic is re-rendered or re-run.
*/
export const renderLabel = (
showIcons: boolean,
availableIndicator: string,
activeIndicator: string,
occupiedIndicator: string,
showAppIcons: boolean,
appIcons: string,
workspaceMask: boolean,
showWorkspaceIcons: boolean,
wsIconMap: WorkspaceIconMap,
i: number,
index: number,
monitor: number,
): string => {
if (showAppIcons) {
return appIcons;
}
export function initWorkspaceEvents(): void {
hyprlandService.connect('config-reloaded', () => {
workspaceService.refreshWorkspaceRules();
});
if (showIcons) {
if (hyprlandService.focusedWorkspace?.id === i || isWorkspaceActiveOnMonitor(monitor, i)) {
return activeIndicator;
}
if ((hyprlandService.get_workspace(i)?.get_clients().length || 0) > 0) {
return occupiedIndicator;
}
if (monitor !== -1) {
return availableIndicator;
}
}
hyprlandService.connect('client-moved', () => {
workspaceService.forceAnUpdate();
});
if (showWorkspaceIcons) {
return getWsIcon(wsIconMap, i);
}
hyprlandService.connect('client-added', () => {
workspaceService.forceAnUpdate();
});
return workspaceMask ? `${index + 1}` : `${i}`;
hyprlandService.connect('client-removed', () => {
workspaceService.forceAnUpdate();
});
}
/**
* Throttled scroll handler functions for navigating workspaces.
*/
type ThrottledScrollHandlers = {
/**
* Scroll up throttled handler.
*/
throttledScrollUp: () => void;
/**
* Scroll down throttled handler.
*/
throttledScrollDown: () => void;
};

View File

@@ -1,11 +1,10 @@
import options from 'src/options';
import { initThrottledScrollHandlers } from './helpers';
import { initThrottledScrollHandlers } from './helpers/utils';
import { WorkspaceModule } from './workspaces';
import { bind, Variable } from 'astal';
import { Astal, Gdk } from 'astal/gtk3';
import { isScrollDown, isScrollUp } from 'src/lib/utils';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { GtkWidget } from 'src/lib/types/widget.types';
import options from 'src/configuration';
import { isScrollUp, isScrollDown } from 'src/lib/events/mouse';
import { BarBoxChild, GtkWidget } from 'src/components/bar/types';
const { scroll_speed } = options.bar.workspaces;

View File

@@ -0,0 +1,15 @@
export type WorkspaceIcons = {
[key: string]: string;
};
export type WorkspaceIconsColored = {
[key: string]: {
color: string;
icon: string;
};
};
export type ApplicationIcons = {
[key: string]: string;
};
export type WorkspaceIconMap = WorkspaceIcons | WorkspaceIconsColored;

View File

@@ -1,11 +1,14 @@
import options from 'src/options';
import { forceUpdater, getWorkspacesToRender, initWorkspaceEvents, workspaceRules } from './helpers';
import { getAppIcon, getWsColor, renderClassnames, renderLabel } from './helpers/utils';
import { initWorkspaceEvents } from './helpers/utils';
import { getAppIcon, getWsColor, renderClassnames, renderLabel } from './helpers';
import { bind, Variable } from 'astal';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { Gtk } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { WorkspaceIconMap, ApplicationIcons } from 'src/lib/types/workspace.types';
import { WorkspaceService } from 'src/services/workspace';
import options from 'src/configuration';
import { isPrimaryClick } from 'src/lib/events/mouse';
import { WorkspaceIconMap, ApplicationIcons } from './types';
const workspaceService = WorkspaceService.getInstance();
const hyprlandService = AstalHyprland.get_default();
const {
@@ -61,8 +64,8 @@ export const WorkspaceModule = ({ monitor }: WorkspaceModuleProps): JSX.Element
bind(ignored),
bind(showAllActive),
bind(hyprlandService, 'focusedWorkspace'),
bind(workspaceRules),
bind(forceUpdater),
bind(workspaceService.workspaceRules),
bind(workspaceService.forceUpdater),
],
(
isMonitorSpecific: boolean,
@@ -88,10 +91,11 @@ export const WorkspaceModule = ({ monitor }: WorkspaceModuleProps): JSX.Element
clients: AstalHyprland.Client[],
monitorList: AstalHyprland.Monitor[],
) => {
const workspacesToRender = getWorkspacesToRender(
const wsRules = workspaceService.workspaceRules.get();
const workspacesToRender = workspaceService.getWorkspaces(
totalWorkspaces,
workspaceList,
workspaceRules.get(),
wsRules,
monitor,
isMonitorSpecific,
monitorList,

View File

@@ -1,11 +1,13 @@
import options from 'src/options';
import { inputHandler } from 'src/components/bar/utils/helpers.js';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import { systemTime } from 'src/shared/time';
import { systemTime } from 'src/lib/units/time';
import { GLib } from 'astal';
import { Module } from '../../shared/Module';
import { BarBoxChild } from 'src/lib/types/bar.types';
import { Module } from '../../shared/module';
import { BarBoxChild } from 'src/components/bar/types';
import options from 'src/configuration';
import { InputHandlerService } from '../../utils/input/inputHandler';
const inputHandler = InputHandlerService.getInstance();
const {
format,
@@ -51,13 +53,15 @@ export const WorldClock = (): BarBoxChild => {
.join(timeDivider),
);
let inputHandlerBindings: Variable<void>;
const microphoneModule = Module({
textIcon: iconBinding(),
label: timeBinding(),
boxClass: 'worldclock',
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
inputHandlerBindings = inputHandler.attachHandlers(self, {
onPrimaryClick: {
cmd: leftClick,
},
@@ -75,6 +79,11 @@ export const WorldClock = (): BarBoxChild => {
},
});
},
onDestroy: () => {
inputHandlerBindings.drop();
timeBinding.drop();
iconBinding.drop();
},
},
});

View File

@@ -1,7 +1,7 @@
import { Option } from 'src/components/settings/shared/Option';
import { Header } from 'src/components/settings/shared/Header';
import options from 'src/options';
import { Gtk } from 'astal/gtk3';
import options from 'src/configuration';
export const CustomModuleSettings = (): JSX.Element => {
return (
@@ -176,6 +176,12 @@ export const CustomModuleSettings = (): JSX.Element => {
{/* Storage Section */}
<Header title="Storage" />
<Option
opt={options.bar.customModules.storage.paths}
title="Paths to Monitor"
subtitle="Paths must be absolute paths"
type="object"
/>
<Option
opt={options.theme.bar.buttons.modules.storage.enableBorder}
title="Button Border"
@@ -194,6 +200,19 @@ export const CustomModuleSettings = (): JSX.Element => {
type="enum"
enums={['used/total', 'used', 'free', 'percentage']}
/>
<Option
opt={options.bar.customModules.storage.units}
title="Unit of measurement"
type="enum"
enums={['auto', 'bytes', 'kibibytes', 'mebibytes', 'gibibytes', 'tebibytes']}
/>
<Option
opt={options.bar.customModules.storage.tooltipStyle}
title="Tooltip Style"
subtitle="Choose how drive information is displayed in the tooltip"
type="enum"
enums={['percentage-bar', 'tree', 'simple']}
/>
<Option opt={options.bar.customModules.storage.round} title="Round" type="boolean" />
<Option
opt={options.bar.customModules.storage.pollingInterval}

View File

@@ -1,8 +1,7 @@
import { Option } from 'src/components/settings/shared/Option';
import { Header } from 'src/components/settings/shared/Header';
import options from 'src/options';
import { Gtk } from 'astal/gtk3';
import options from 'src/configuration';
export const CustomModuleTheme = (): JSX.Element => {
return (

View File

@@ -1,7 +1,7 @@
import { bind, Variable } from 'astal';
import { BarBoxChild, BarModuleProps } from 'src/lib/types/bar.types';
import { BarButtonStyles } from 'src/lib/options/options.types';
import options from 'src/options';
import { BarBoxChild, BarModuleProps } from 'src/components/bar/types';
import { BarButtonStyles } from 'src/lib/options/types';
import options from 'src/configuration';
const { style } = options.theme.bar.buttons;

View File

@@ -1,6 +1,6 @@
import { BarBoxChild } from 'src/lib/types/bar.types';
import options from '../../../options';
import { BarBoxChild } from 'src/components/bar/types';
import { bind, Binding } from 'astal';
import options from 'src/configuration';
const computeVisible = (child: BarBoxChild): Binding<boolean> | boolean => {
if (child.isVis !== undefined) {

View File

@@ -1,7 +1,6 @@
import { Widget } from 'astal/gtk3';
import { Binding, Variable } from 'astal';
import { Astal, Gdk, Gtk, Widget } from 'astal/gtk3';
import { Binding } from 'astal';
import { Connectable } from 'astal/binding';
import { BoxWidget } from './widget.types';
import { Label } from 'astal/gtk3/widget';
export type BarBoxChild = {
@@ -13,8 +12,8 @@ export type BarBoxChild = {
tooltip_text?: string | Binding<string>;
} & ({ isBox: true; props: Widget.EventBoxProps } | { isBox?: false; props: Widget.ButtonProps });
export type BoxHook = (self: BoxWidget) => void;
export type LabelHook = (self: Label) => void;
type BoxHook = (self: Gtk.Box) => void;
type LabelHook = (self: Label) => void;
export type BarModuleProps = {
icon?: string | Binding<string>;
@@ -35,7 +34,24 @@ export type BarModuleProps = {
connection?: Binding<Connectable>;
};
export type ResourceLabelType = 'used/total' | 'used' | 'percentage' | 'free';
interface WidgetProps {
onPrimaryClick?: (clicked: GtkWidget, event: Gdk.EventButton) => void;
onSecondaryClick?: (clicked: GtkWidget, event: Gdk.EventButton) => void;
onMiddleClick?: (clicked: GtkWidget, event: Gdk.EventButton) => void;
onScrollUp?: (clicked: GtkWidget, event: Gdk.EventScroll) => void;
onScrollDown?: (clicked: GtkWidget, event: Gdk.EventScroll) => void;
setup?: (self: GtkWidget) => void;
}
export type NetstatLabelType = 'full' | 'in' | 'out';
export type RateUnit = 'GiB' | 'MiB' | 'KiB' | 'auto';
interface GtkWidgetExtended extends Gtk.Widget {
props?: WidgetProps;
component?: JSX.Element;
primaryClick?: (clicked: GtkWidget, event: Astal.ClickEvent) => void;
isVisible?: boolean;
boxClass?: string;
isVis?: {
bind: (key: string) => Binding<boolean>;
};
}
export type GtkWidget = GtkWidgetExtended;

View File

@@ -1,422 +0,0 @@
import { bind, Binding, execAsync, Variable } from 'astal';
import { openMenu } from 'src/components/bar/utils/menu';
import options from 'src/options';
import { Gdk } from 'astal/gtk3';
import { onMiddleClick, onPrimaryClick, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { isScrollDown, isScrollUp } from 'src/lib/utils';
import { ResourceLabelType } from 'src/lib/types/bar.types';
import { UpdateHandlers, Postfix, GenericResourceData } from 'src/lib/types/customModules/generic.types';
import {
RunAsyncCommand,
InputHandlerEvents,
InputHandlerEventArgs,
} from 'src/lib/types/customModules/utils.types';
import { ThrottleFn } from 'src/lib/types/utils.types';
import { GtkWidget } from 'src/lib/types/widget.types';
const { scrollSpeed } = options.bar.customModules;
const dummyVar = Variable('');
/**
* Handles the post input updater by toggling its value.
*
* This function checks if the `postInputUpdater` variable is defined. If it is, it toggles its value.
*
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
*/
const handlePostInputUpdater = (postInputUpdater?: Variable<boolean>): void => {
if (postInputUpdater !== undefined) {
postInputUpdater.set(!postInputUpdater.get());
}
};
/**
* Executes an asynchronous command and handles the result.
*
* This function runs a given command asynchronously using `execAsync`. If the command starts with 'menu:', it opens the specified menu.
* Otherwise, it executes the command in a bash shell. After execution, it handles the post input updater and calls the provided callback function with the command output.
*
* @param cmd The command to execute.
* @param events An object containing the clicked widget and event information.
* @param fn An optional callback function to handle the command output.
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
*/
export const runAsyncCommand: RunAsyncCommand = (
cmd,
events,
fn,
postInputUpdater?: Variable<boolean>,
): void => {
if (cmd.startsWith('menu:')) {
const menuName = cmd.split(':')[1].trim().toLowerCase();
openMenu(events.clicked, events.event, `${menuName}menu`);
handlePostInputUpdater(postInputUpdater);
return;
}
execAsync(['bash', '-c', cmd])
.then((output) => {
handlePostInputUpdater(postInputUpdater);
if (fn !== undefined) {
fn(output);
}
})
.catch((err) => console.error(`Error running command "${cmd}": ${err})`));
};
/*
* NOTE: Added a throttle since spamming a button yields duplicate events
* which undo the toggle.
*/
const throttledAsyncCommand = throttleInput(
(cmd, events, fn, postInputUpdater?: Variable<boolean>) =>
runAsyncCommand(cmd, events, fn, postInputUpdater),
50,
);
/**
* Generic throttle function to limit the rate at which a function can be called.
*
* This function creates a throttled version of the provided function that can only be called once within the specified limit.
*
* @param func The function to throttle.
* @param limit The time limit in milliseconds.
*
* @returns The throttled function.
*/
export function throttleInput<T extends ThrottleFn>(func: T, limit: number): T {
let inThrottle = false;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
} as T;
}
/**
* Creates a throttled scroll handler with the given interval.
*
* This function returns a throttled version of the `runAsyncCommand` function that can be called with the specified interval.
*
* @param interval The interval in milliseconds.
*
* @returns The throttled scroll handler function.
*/
export const throttledScrollHandler = (interval: number): ThrottleFn =>
throttleInput((cmd: string, args, fn, postInputUpdater) => {
throttledAsyncCommand(cmd, args, fn, postInputUpdater);
}, 200 / interval);
/**
* Handles input events for a GtkWidget.
*
* This function sets up event handlers for primary, secondary, and middle clicks, as well as scroll events.
* It uses the provided input handler events and post input updater to manage the input state.
*
* @param self The GtkWidget instance to handle input events for.
* @param inputHandlerEvents An object containing the input handler events for primary, secondary, and middle clicks, as well as scroll up and down.
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
*/
export const inputHandler = (
self: GtkWidget,
{
onPrimaryClick: onPrimaryClickInput,
onSecondaryClick: onSecondaryClickInput,
onMiddleClick: onMiddleClickInput,
onScrollUp: onScrollUpInput,
onScrollDown: onScrollDownInput,
}: InputHandlerEvents,
postInputUpdater?: Variable<boolean>,
customScrollThreshold?: number,
): void => {
const sanitizeInput = (input?: Variable<string>): string => {
if (input === undefined) {
return '';
}
return input.get();
};
const updateHandlers = (): UpdateHandlers => {
const interval = customScrollThreshold ?? scrollSpeed.get();
const throttledHandler = throttledScrollHandler(interval);
const disconnectPrimaryClick = onPrimaryClick(self, (clicked: GtkWidget, event: Gdk.Event) => {
throttledAsyncCommand(
sanitizeInput(onPrimaryClickInput?.cmd || dummyVar),
{ clicked, event },
onPrimaryClickInput?.fn,
postInputUpdater,
);
});
const disconnectSecondaryClick = onSecondaryClick(self, (clicked: GtkWidget, event: Gdk.Event) => {
throttledAsyncCommand(
sanitizeInput(onSecondaryClickInput?.cmd || dummyVar),
{ clicked, event },
onSecondaryClickInput?.fn,
postInputUpdater,
);
});
const disconnectMiddleClick = onMiddleClick(self, (clicked: GtkWidget, event: Gdk.Event) => {
throttledAsyncCommand(
sanitizeInput(onMiddleClickInput?.cmd || dummyVar),
{ clicked, event },
onMiddleClickInput?.fn,
postInputUpdater,
);
});
const id = self.connect('scroll-event', (self: GtkWidget, event: Gdk.Event) => {
const handleScroll = (input?: InputHandlerEventArgs): void => {
if (input) {
throttledHandler(
sanitizeInput(input.cmd),
{ clicked: self, event },
input.fn,
postInputUpdater,
);
}
};
if (isScrollUp(event)) {
handleScroll(onScrollUpInput);
}
if (isScrollDown(event)) {
handleScroll(onScrollDownInput);
}
});
return {
disconnectPrimary: disconnectPrimaryClick,
disconnectSecondary: disconnectSecondaryClick,
disconnectMiddle: disconnectMiddleClick,
disconnectScroll: () => self.disconnect(id),
};
};
updateHandlers();
const sanitizeVariable = (someVar?: Variable<string>): Binding<string> => {
if (someVar === undefined) {
return bind(dummyVar);
}
return bind(someVar);
};
Variable.derive(
[
bind(scrollSpeed),
sanitizeVariable(onPrimaryClickInput?.cmd),
sanitizeVariable(onSecondaryClickInput?.cmd),
sanitizeVariable(onMiddleClickInput?.cmd),
sanitizeVariable(onScrollUpInput?.cmd),
sanitizeVariable(onScrollDownInput?.cmd),
],
() => {
const handlers = updateHandlers();
handlers.disconnectPrimary();
handlers.disconnectSecondary();
handlers.disconnectMiddle();
handlers.disconnectScroll();
},
)();
};
/**
* Calculates the percentage of used resources.
*
* This function calculates the percentage of used resources based on the total and used values.
* It can optionally round the result to the nearest integer.
*
* @param totalUsed An array containing the total and used values.
* @param round A boolean indicating whether to round the result.
*
* @returns The percentage of used resources as a number.
*/
export const divide = ([total, used]: number[], round: boolean): number => {
const percentageTotal = (used / total) * 100;
if (round) {
return total > 0 ? Math.round(percentageTotal) : 0;
}
return total > 0 ? parseFloat(percentageTotal.toFixed(2)) : 0;
};
/**
* Formats a size in bytes to KiB.
*
* This function converts a size in bytes to kibibytes (KiB) and optionally rounds the result.
*
* @param sizeInBytes The size in bytes to format.
* @param round A boolean indicating whether to round the result.
*
* @returns The size in KiB as a number.
*/
export const formatSizeInKiB = (sizeInBytes: number, round: boolean): number => {
const sizeInGiB = sizeInBytes / 1024 ** 1;
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
};
/**
* Formats a size in bytes to MiB.
*
* This function converts a size in bytes to mebibytes (MiB) and optionally rounds the result.
*
* @param sizeInBytes The size in bytes to format.
* @param round A boolean indicating whether to round the result.
*
* @returns The size in MiB as a number.
*/
export const formatSizeInMiB = (sizeInBytes: number, round: boolean): number => {
const sizeInGiB = sizeInBytes / 1024 ** 2;
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
};
/**
* Formats a size in bytes to GiB.
*
* This function converts a size in bytes to gibibytes (GiB) and optionally rounds the result.
*
* @param sizeInBytes The size in bytes to format.
* @param round A boolean indicating whether to round the result.
*
* @returns The size in GiB as a number.
*/
export const formatSizeInGiB = (sizeInBytes: number, round: boolean): number => {
const sizeInGiB = sizeInBytes / 1024 ** 3;
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
};
/**
* Formats a size in bytes to TiB.
*
* This function converts a size in bytes to tebibytes (TiB) and optionally rounds the result.
*
* @param sizeInBytes The size in bytes to format.
* @param round A boolean indicating whether to round the result.
*
* @returns The size in TiB as a number.
*/
export const formatSizeInTiB = (sizeInBytes: number, round: boolean): number => {
const sizeInGiB = sizeInBytes / 1024 ** 4;
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
};
/**
* Automatically formats a size in bytes to the appropriate unit.
*
* This function converts a size in bytes to the most appropriate unit (TiB, GiB, MiB, KiB, or bytes) and optionally rounds the result.
*
* @param sizeInBytes The size in bytes to format.
* @param round A boolean indicating whether to round the result.
*
* @returns The formatted size as a number.
*/
export const autoFormatSize = (sizeInBytes: number, round: boolean): number => {
// auto convert to GiB, MiB, KiB, TiB, or bytes
if (sizeInBytes >= 1024 ** 4) return formatSizeInTiB(sizeInBytes, round);
if (sizeInBytes >= 1024 ** 3) return formatSizeInGiB(sizeInBytes, round);
if (sizeInBytes >= 1024 ** 2) return formatSizeInMiB(sizeInBytes, round);
if (sizeInBytes >= 1024 ** 1) return formatSizeInKiB(sizeInBytes, round);
return sizeInBytes;
};
/**
* Retrieves the appropriate postfix for a size in bytes.
*
* This function returns the appropriate postfix (TiB, GiB, MiB, KiB, or B) for a given size in bytes.
*
* @param sizeInBytes The size in bytes to determine the postfix for.
*
* @returns The postfix as a string.
*/
export const getPostfix = (sizeInBytes: number): Postfix => {
if (sizeInBytes >= 1024 ** 4) return 'TiB';
if (sizeInBytes >= 1024 ** 3) return 'GiB';
if (sizeInBytes >= 1024 ** 2) return 'MiB';
if (sizeInBytes >= 1024 ** 1) return 'KiB';
return 'B';
};
/**
* Renders a resource label based on the label type and resource data.
*
* This function generates a resource label string based on the provided label type, resource data, and rounding option.
* It formats the used, total, and free resource values and calculates the percentage if needed.
*
* @param lblType The type of label to render (used/total, used, free, or percentage).
* @param rmUsg An object containing the resource usage data (used, total, percentage, and free).
* @param round A boolean indicating whether to round the values.
*
* @returns The rendered resource label as a string.
*/
export const renderResourceLabel = (
lblType: ResourceLabelType,
rmUsg: GenericResourceData,
round: boolean,
): string => {
const { used, total, percentage, free } = rmUsg;
const formatFunctions = {
TiB: formatSizeInTiB,
GiB: formatSizeInGiB,
MiB: formatSizeInMiB,
KiB: formatSizeInKiB,
B: (size: number): number => size,
};
// Get the data in proper GiB, MiB, KiB, TiB, or bytes
const totalSizeFormatted = autoFormatSize(total, round);
// get the postfix: one of [TiB, GiB, MiB, KiB, B]
const postfix = getPostfix(total);
// Determine which format function to use
const formatUsed = formatFunctions[postfix] ?? formatFunctions['B'];
const usedSizeFormatted = formatUsed(used, round);
if (lblType === 'used/total') {
return `${usedSizeFormatted}/${totalSizeFormatted} ${postfix}`;
}
if (lblType === 'used') {
return `${autoFormatSize(used, round)} ${getPostfix(used)}`;
}
if (lblType === 'free') {
return `${autoFormatSize(free, round)} ${getPostfix(free)}`;
}
return `${percentage}%`;
};
/**
* Formats a tooltip based on the data type and label type.
*
* This function generates a tooltip string based on the provided data type and label type.
*
* @param dataType The type of data to include in the tooltip.
* @param lblTyp The type of label to format the tooltip for (used, free, used/total, or percentage).
*
* @returns The formatted tooltip as a string.
*/
export const formatTooltip = (dataType: string, lblTyp: ResourceLabelType): string => {
switch (lblTyp) {
case 'used':
return `Used ${dataType}`;
case 'free':
return `Free ${dataType}`;
case 'used/total':
return `Used/Total ${dataType}`;
case 'percentage':
return `Percentage ${dataType} Usage`;
default:
return '';
}
};

View File

@@ -0,0 +1,53 @@
import { execAsync, Variable } from 'astal';
import { openDropdownMenu } from '../menu';
import { EventArgs } from './types';
/**
* Executes an asynchronous command and handles the result.
*
* This function runs a given command asynchronously using `execAsync`. If the command starts with 'menu:', it opens the specified menu.
* Otherwise, it executes the command in a bash shell. After execution, it handles the post input updater and calls the provided callback function with the command output.
*
* @param cmd The command to execute.
* @param events An object containing the clicked widget and event information.
* @param fn An optional callback function to handle the command output.
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
*/
export function runAsyncCommand(
cmd: string,
events: EventArgs,
fn?: (output: string) => void,
postInputUpdater?: Variable<boolean>,
): void {
if (cmd.startsWith('menu:')) {
const menuName = cmd.split(':')[1].trim().toLowerCase();
openDropdownMenu(events.clicked, events.event, `${menuName}menu`);
handlePostInputUpdater(postInputUpdater);
return;
}
execAsync(['bash', '-c', cmd])
.then((output) => {
handlePostInputUpdater(postInputUpdater);
if (fn !== undefined) {
fn(output);
}
})
.catch((err) => console.error(`Error running command "${cmd}": ${err})`));
}
/**
* Handles the post input updater by toggling its value.
*
* This function checks if the `postInputUpdater` variable is defined. If it is, it toggles its value.
*
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
*/
function handlePostInputUpdater(postInputUpdater?: Variable<boolean>): void {
if (postInputUpdater !== undefined) {
postInputUpdater.set(!postInputUpdater.get());
}
}

View File

@@ -0,0 +1,228 @@
import { bind, Binding, Variable } from 'astal';
import { onMiddleClick, onPrimaryClick, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { Gdk } from 'astal/gtk3';
import { isScrollDown, isScrollUp } from 'src/lib/events/mouse';
import { throttledAsyncCommand, throttledScrollHandler } from './throttle';
import options from 'src/configuration';
import { InputHandlerEventArgs, InputHandlerEvents, UpdateHandlers } from './types';
import { GtkWidget } from '../../types';
type EventType = 'primary' | 'secondary' | 'middle';
type ClickHandler = typeof onPrimaryClick | typeof onSecondaryClick | typeof onMiddleClick;
interface EventConfig {
event?: InputHandlerEventArgs;
handler: ClickHandler;
}
/**
* Service responsible for managing input userDefinedActions for widgets
*/
export class InputHandlerService {
private static _instance: InputHandlerService;
private readonly _EMPTY_CMD = Variable('');
private readonly _scrollSpeed = options.bar.customModules.scrollSpeed;
private constructor() {}
public static getInstance(): InputHandlerService {
if (this._instance === undefined) {
this._instance = new InputHandlerService();
}
return this._instance;
}
/**
* Attaches input handlers to a widget and manages their lifecycle
*/
public attachHandlers(
widget: GtkWidget,
userDefinedActions: InputHandlerEvents,
postInputUpdater?: Variable<boolean>,
customScrollThreshold?: number,
): Variable<void> {
const eventHandlers = this._createEventHandlers(
widget,
userDefinedActions,
postInputUpdater,
customScrollThreshold,
);
return this._setupBindings(
widget,
userDefinedActions,
eventHandlers,
postInputUpdater,
customScrollThreshold,
);
}
/**
* Creates event handlers for the widget
*/
private _createEventHandlers(
widget: GtkWidget,
userDefinedActions: InputHandlerEvents,
postInputUpdater?: Variable<boolean>,
customScrollThreshold?: number,
): UpdateHandlers {
const clickHandlers = this._createClickHandlers(widget, userDefinedActions, postInputUpdater);
const scrollHandler = this._createScrollHandler(
widget,
userDefinedActions,
postInputUpdater,
customScrollThreshold,
);
return {
...clickHandlers,
...scrollHandler,
};
}
/**
* Creates click event handlers (primary, secondary, middle)
*/
private _createClickHandlers(
widget: GtkWidget,
userDefinedActions: InputHandlerEvents,
postInputUpdater?: Variable<boolean>,
): Pick<UpdateHandlers, 'disconnectPrimary' | 'disconnectSecondary' | 'disconnectMiddle'> {
const eventConfigs: Record<EventType, EventConfig> = {
primary: { event: userDefinedActions.onPrimaryClick, handler: onPrimaryClick },
secondary: { event: userDefinedActions.onSecondaryClick, handler: onSecondaryClick },
middle: { event: userDefinedActions.onMiddleClick, handler: onMiddleClick },
};
return {
disconnectPrimary: this._createClickHandler(widget, eventConfigs.primary, postInputUpdater),
disconnectSecondary: this._createClickHandler(widget, eventConfigs.secondary, postInputUpdater),
disconnectMiddle: this._createClickHandler(widget, eventConfigs.middle, postInputUpdater),
};
}
/**
* Creates a single click handler
*/
private _createClickHandler(
widget: GtkWidget,
config: EventConfig,
postInputUpdater?: Variable<boolean>,
): () => void {
return config.handler(widget, (clicked: GtkWidget, event: Gdk.Event) => {
throttledAsyncCommand(
this._sanitizeInput(config.event?.cmd),
{ clicked, event },
config.event?.fn,
postInputUpdater,
);
});
}
/**
* Creates scroll event handler
*/
private _createScrollHandler(
widget: GtkWidget,
userDefinedActions: InputHandlerEvents,
postInputUpdater?: Variable<boolean>,
customScrollThreshold?: number,
): Pick<UpdateHandlers, 'disconnectScroll'> {
const interval = customScrollThreshold ?? this._scrollSpeed.get();
const throttledHandler = throttledScrollHandler(interval);
const id = widget.connect('scroll-event', (self: GtkWidget, event: Gdk.Event) => {
const scrollAction = this._getScrollAction(event, userDefinedActions);
if (scrollAction) {
throttledHandler(
this._sanitizeInput(scrollAction.cmd),
{ clicked: self, event },
scrollAction.fn,
postInputUpdater,
);
}
});
return {
disconnectScroll: () => widget.disconnect(id),
};
}
/**
* Determines which scroll configuration to use based on event
*/
private _getScrollAction(
event: Gdk.Event,
userDefinedActions: InputHandlerEvents,
): InputHandlerEventArgs | undefined {
if (isScrollUp(event)) {
return userDefinedActions.onScrollUp;
}
if (isScrollDown(event)) {
return userDefinedActions.onScrollDown;
}
}
/**
* Sets up reactive bindings that recreate handlers when dependencies change
*/
private _setupBindings(
widget: GtkWidget,
userDefinedActions: InputHandlerEvents,
handlers: UpdateHandlers,
postInputUpdater?: Variable<boolean>,
customScrollThreshold?: number,
): Variable<void> {
const eventCommands = [
userDefinedActions.onPrimaryClick?.cmd,
userDefinedActions.onSecondaryClick?.cmd,
userDefinedActions.onMiddleClick?.cmd,
userDefinedActions.onScrollUp?.cmd,
userDefinedActions.onScrollDown?.cmd,
];
const eventCommandBindings = eventCommands.map((cmd) => this._sanitizeVariable(cmd));
return Variable.derive([bind(this._scrollSpeed), ...eventCommandBindings], () => {
this._disconnectHandlers(handlers);
const newHandlers = this._createEventHandlers(
widget,
userDefinedActions,
postInputUpdater,
customScrollThreshold,
);
Object.assign(handlers, newHandlers);
});
}
/**
* Disconnects all event handlers
*/
private _disconnectHandlers(handlers: UpdateHandlers): void {
handlers.disconnectPrimary();
handlers.disconnectSecondary();
handlers.disconnectMiddle();
handlers.disconnectScroll();
}
/**
* Sanitizes a variable input to a string
*/
private _sanitizeInput(input?: Variable<string> | undefined): string {
if (!input) return '';
return input.get();
}
/**
* Sanitizes a variable for binding
*/
private _sanitizeVariable(variable?: Variable<string> | undefined): Binding<string> {
return bind(variable ?? this._EMPTY_CMD);
}
}

View File

@@ -0,0 +1,50 @@
import { Variable } from 'astal';
import { runAsyncCommand } from './commandExecutor';
import { ThrottleFn } from 'src/lib/shared/eventHandlers/types';
/**
* Generic throttle function to limit the rate at which a function can be called.
*
* This function creates a throttled version of the provided function that can only be called once within the specified limit.
*
* @param func The function to throttle.
* @param limit The time limit in milliseconds.
*
* @returns The throttled function.
*/
export function throttleInput<T extends ThrottleFn>(func: T, limit: number): T {
let inThrottle = false;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
} as T;
}
/**
* Creates a throttled scroll handler with the given interval.
*
* This function returns a throttled version of the `runAsyncCommand` function that can be called with the specified interval.
*
* @param interval The interval in milliseconds.
*
* @returns The throttled scroll handler function.
*/
export const throttledScrollHandler = (interval: number): ThrottleFn =>
throttleInput((cmd: string, args, fn, postInputUpdater) => {
throttledAsyncCommand(cmd, args, fn, postInputUpdater);
}, 200 / interval);
/*
* NOTE: Added a throttle since spamming a button yields duplicate events
* which undo the toggle.
*/
export const throttledAsyncCommand = throttleInput(
(cmd, events, fn, postInputUpdater?: Variable<boolean>) =>
runAsyncCommand(cmd, events, fn, postInputUpdater),
50,
);

View File

@@ -1,6 +1,19 @@
import { Variable } from 'astal';
import { EventArgs } from '../widget.types';
import { Gdk } from 'astal/gtk3';
import { Opt } from 'src/lib/options';
import { GtkWidget } from '../../types';
export type EventArgs = {
clicked: GtkWidget;
event: Gdk.Event;
};
export type UpdateHandlers = {
disconnectPrimary: () => void;
disconnectSecondary: () => void;
disconnectMiddle: () => void;
disconnectScroll: () => void;
};
export type InputHandlerEventArgs = {
cmd?: Opt<string> | Variable<string>;
@@ -13,10 +26,3 @@ export type InputHandlerEvents = {
onScrollUp?: InputHandlerEventArgs;
onScrollDown?: InputHandlerEventArgs;
};
export type RunAsyncCommand = (
cmd: string,
args: EventArgs,
fn?: (output: string) => void,
postInputUpdater?: Variable<boolean>,
) => void;

View File

@@ -1,41 +1,24 @@
import { App, Gdk } from 'astal/gtk3';
import { GtkWidget } from 'src/lib/types/widget.types';
import { calculateMenuPosition } from 'src/components/menus/shared/dropdown/locationHandler';
export const closeAllMenus = (): void => {
const menuWindows = App.get_windows()
.filter((w) => {
if (w.name) {
return /.*menu/.test(w.name);
}
return false;
})
.map((window) => window.name);
menuWindows.forEach((window) => {
if (window) {
App.get_window(window)?.set_visible(false);
}
});
};
export const openMenu = async (clicked: GtkWidget, event: Gdk.Event, window: string): Promise<void> => {
/*
* NOTE: We have to make some adjustments so the menu pops up relatively
* to the center of the button clicked. We don't want the menu to spawn
* offcenter depending on which edge of the button you click on.
* -------------
* To fix this, we take the x coordinate of the click within the button's bounds.
* If you click the left edge of a 100 width button, then the x axis will be 0
* and if you click the right edge then the x axis will be 100.
* -------------
* Then we divide the width of the button by 2 to get the center of the button and then get
* the offset by subtracting the clicked x coordinate. Then we can apply that offset
* to the x coordinate of the click relative to the screen to get the center of the
* icon click.
*/
import { calculateMenuPosition } from 'src/components/menus/shared/dropdown/helpers/locationHandler';
import { GtkWidget } from '../../types';
/**
* Opens a dropdown menu centered relative to the clicked button
*
* This function handles the positioning logic to ensure menus appear centered
* relative to the button that was clicked, regardless of where on the button
* the click occurred. It calculates the offset needed to center the menu
* based on the click position within the button's bounds.
*
* @param clicked - The widget that was clicked to trigger the menu
* @param event - The click event containing position information
* @param window - The name of the menu window to open
*/
export const openDropdownMenu = async (
clicked: GtkWidget,
event: Gdk.Event,
window: string,
): Promise<void> => {
try {
const middleOfButton = Math.floor(clicked.get_allocated_width() / 2);
const xAxisOfButtonClick = clicked.get_pointer()[0];
@@ -57,3 +40,28 @@ export const openMenu = async (clicked: GtkWidget, event: Gdk.Event, window: str
}
}
};
/**
* Closes all currently open menu windows
*
* This function finds all windows whose names contain "menu" and
* hides them. It's used to ensure only one menu is open at a time
* when opening a new dropdown menu.
*/
function closeAllMenus(): void {
const menuWindows = App.get_windows()
.filter((w) => {
if (w.name) {
return /.*menu/.test(w.name);
}
return false;
})
.map((window) => window.name);
menuWindows.forEach((window) => {
if (window) {
App.get_window(window)?.set_visible(false);
}
});
}

View File

@@ -1,4 +1,6 @@
import { BarLayout, BarLayouts } from 'src/lib/options/options.types';
import { Gdk } from 'astal/gtk3';
import { range } from 'src/lib/array/helpers';
import { BarLayout, BarLayouts } from 'src/lib/options/types';
/**
* Returns the bar layout configuration for a specific monitor
@@ -39,3 +41,19 @@ export const isLayoutEmpty = (layout: BarLayout): boolean => {
return isLeftSectionEmpty && isRightSectionEmpty && isMiddleSectionEmpty;
};
/**
* Generates an array of JSX elements for each monitor.
*
* This function creates an array of JSX elements by calling the provided widget function for each monitor.
* It uses the number of monitors available in the default Gdk display.
*
* @param widget A function that takes a monitor index and returns a JSX element.
*
* @returns An array of JSX elements, one for each monitor.
*/
export async function forMonitors(widget: (monitor: number) => Promise<JSX.Element>): Promise<JSX.Element[]> {
const n = Gdk.Display.get_default()?.get_n_monitors() ?? 1;
return Promise.all(range(n, 0).map(widget));
}

View File

@@ -1,29 +0,0 @@
import options from '../../../options';
const { showIcon, showTime } = options.bar.clock;
showIcon.subscribe(() => {
if (!showTime.get() && !showIcon.get()) {
showTime.set(true);
}
});
showTime.subscribe(() => {
if (!showTime.get() && !showIcon.get()) {
showIcon.set(true);
}
});
const { label, icon } = options.bar.windowtitle;
label.subscribe(() => {
if (!label.get() && !icon.get()) {
icon.set(true);
}
});
icon.subscribe(() => {
if (!label.get() && !icon.get()) {
label.set(true);
}
});

View File

@@ -0,0 +1,93 @@
import { SizeConverter } from 'src/lib/units/size';
import { SizeUnit } from 'src/lib/units/size/types';
import { ResourceLabelType, GenericResourceData } from 'src/services/system/types';
/**
* Renders a resource label based on the label type and resource data.
*
* This function generates a resource label string based on the provided label type, resource data, and rounding option.
* It formats the used, total, and free resource values and calculates the percentage if needed.
*
* @param lblType The type of label to render (used/total, used, free, or percentage).
* @param resourceUsage An object containing the resource usage data (used, total, percentage, and free).
* @param round A boolean indicating whether to round the values.
*
* @returns The rendered resource label as a string.
*/
export const renderResourceLabel = (
lblType: ResourceLabelType,
resourceUsage: GenericResourceData,
round: boolean,
unitType?: SizeUnit,
): string => {
const { used, total, percentage, free } = resourceUsage;
const precision = round ? 0 : 2;
if (lblType === 'used/total') {
const totalConverter = SizeConverter.fromBytes(total);
const usedConverter = SizeConverter.fromBytes(used);
const { unit } = totalConverter.toAuto();
const sizeUnit: SizeUnit = unitType ?? unit;
let usedValue: number;
let totalValue: string;
switch (sizeUnit) {
case 'tebibytes':
usedValue = usedConverter.toTiB(precision);
totalValue = totalConverter.formatTiB(precision);
return `${usedValue}/${totalValue}`;
case 'gibibytes':
usedValue = usedConverter.toGiB(precision);
totalValue = totalConverter.formatGiB(precision);
return `${usedValue}/${totalValue}`;
case 'mebibytes':
usedValue = usedConverter.toMiB(precision);
totalValue = totalConverter.formatMiB(precision);
return `${usedValue}/${totalValue}`;
case 'kibibytes':
usedValue = usedConverter.toKiB(precision);
totalValue = totalConverter.formatKiB(precision);
return `${usedValue}/${totalValue}`;
default:
usedValue = usedConverter.toBytes(precision);
totalValue = totalConverter.formatBytes(precision);
return `${usedValue}/${totalValue}`;
}
}
if (lblType === 'used') {
return SizeConverter.fromBytes(used).formatAuto(precision);
}
if (lblType === 'free') {
return SizeConverter.fromBytes(free).formatAuto(precision);
}
return `${percentage}%`;
};
/**
* Formats a tooltip based on the data type and label type.
*
* This function generates a tooltip string based on the provided data type and label type.
*
* @param dataType The type of data to include in the tooltip.
* @param lblTyp The type of label to format the tooltip for (used, free, used/total, or percentage).
*
* @returns The formatted tooltip as a string.
*/
export const formatTooltip = (dataType: string, lblTyp: ResourceLabelType): string => {
switch (lblTyp) {
case 'used':
return `Used ${dataType}`;
case 'free':
return `Free ${dataType}`;
case 'used/total':
return `Used/Total ${dataType}`;
case 'percentage':
return `Percentage ${dataType} Usage`;
default:
return '';
}
};

View File

@@ -2,7 +2,7 @@ import { Gtk } from 'astal/gtk3';
import { ActiveDevices } from './devices/index.js';
import { ActivePlaybacks } from './playbacks/index.js';
import { bind, Variable } from 'astal';
import { isPrimaryClick } from 'src/lib/utils.js';
import { isPrimaryClick } from 'src/lib/events/mouse';
export enum ActiveDeviceMenu {
DEVICES = 'devices',

View File

@@ -1,8 +1,9 @@
import { bind } from 'astal';
import { Gdk, Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
import { capitalizeFirstLetter, isScrollDown, isScrollUp } from 'src/lib/utils';
import options from 'src/options';
import options from 'src/configuration';
import { isScrollUp, isScrollDown } from 'src/lib/events/mouse';
import { capitalizeFirstLetter } from 'src/lib/string/formatters';
const { raiseMaximumVolume } = options.menus.volume;

View File

@@ -1,8 +1,8 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { getIcon } from '../../utils';
import AstalWp from 'gi://AstalWp?version=0.1';
import { isPrimaryClick } from 'src/lib/events/mouse';
export const SliderIcon = ({ type, device }: SliderIconProps): JSX.Element => {
const iconBinding = Variable.derive([bind(device, 'volume'), bind(device, 'mute')], (volume, isMuted) => {

View File

@@ -1,7 +1,7 @@
import { Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
import { isPrimaryClick } from 'src/lib/events/mouse';
const DeviceIcon = ({ device, type, icon }: AudioDeviceProps): JSX.Element => {
return (

View File

@@ -1,10 +1,10 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { VolumeSliders } from './active/index.js';
import options from 'src/options.js';
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { AvailableDevices } from './available/index.js';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
import { RevealerTransitionMap } from 'src/components/settings/constants.js';
import options from 'src/configuration';
export default (): JSX.Element => {
return (

View File

@@ -1,7 +1,7 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { isPrimaryClick } from 'src/lib/events/mouse';
export const ConnectButton = ({ device }: ConnectButtonProps): JSX.Element => {
return (

View File

@@ -1,7 +1,7 @@
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { forgetBluetoothDevice } from '../helpers';
import { isPrimaryClick } from 'src/lib/events/mouse';
export const ForgetButton = ({ device }: ForgetButtonProps): JSX.Element => {
return (

View File

@@ -1,7 +1,7 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { isPrimaryClick } from 'src/lib/events/mouse';
export const PairButton = ({ device }: PairButtonProps): JSX.Element => {
return (

View File

@@ -1,7 +1,7 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { isPrimaryClick } from 'src/lib/events/mouse';
export const TrustButton = ({ device }: TrustButtonProps): JSX.Element => {
return (

View File

@@ -1,11 +1,11 @@
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import Spinner from 'src/components/shared/Spinner';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
import { DeviceIcon } from './DeviceIcon';
import { DeviceName } from './DeviceName';
import { DeviceStatus } from './DeviceStatus';
import { isPrimaryClick } from 'src/lib/events/mouse';
export const BluetoothDevice = ({ device, connectedDevices }: BluetoothDeviceProps): JSX.Element => {
const IsConnectingSpinner = (): JSX.Element => {

View File

@@ -1,8 +1,8 @@
import { Gtk } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { bind, timeout } from 'astal';
import { isDiscovering } from './helper';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { isPrimaryClick } from 'src/lib/events/mouse';
const bluetoothService = AstalBluetooth.get_default();

View File

@@ -1,10 +1,10 @@
import options from 'src/configuration';
import DropdownMenu from '../shared/dropdown/index.js';
import { BluetoothDevices } from './devices/index.js';
import { Header } from './header/index.js';
import options from 'src/options.js';
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
import { RevealerTransitionMap } from 'src/components/settings/constants.js';
export default (): JSX.Element => {
return (

View File

@@ -2,9 +2,9 @@ import DropdownMenu from '../shared/dropdown/index.js';
import { TimeWidget } from './time/index';
import { CalendarWidget } from './CalendarWidget.js';
import { WeatherWidget } from './weather/index';
import options from 'src/options';
import { bind } from 'astal';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
import { RevealerTransitionMap } from 'src/components/settings/constants.js';
import options from 'src/configuration';
const { transition } = options.menus;
const { enabled: weatherEnabled } = options.menus.clock.weather;

View File

@@ -1,7 +1,7 @@
import options from 'src/options';
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { systemTime } from 'src/shared/time';
import options from 'src/configuration';
import { systemTime } from 'src/lib/units/time';
const { military, hideSeconds } = options.menus.clock.time;

View File

@@ -1,7 +1,7 @@
import options from 'src/options';
import { bind, GLib, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { systemTime } from 'src/shared/time';
import options from 'src/configuration';
import { systemTime } from 'src/lib/units/time';
const { military, hideSeconds } = options.menus.clock.time;

View File

@@ -1,64 +1,51 @@
import { Weather, WeatherIconTitle } from 'src/lib/types/weather.types';
import { isValidWeatherIconTitle } from 'src/shared/weather';
import { Weather, WeatherIcon, WeatherStatus } from 'src/services/weather/types';
/**
* Retrieves the next epoch time for weather data.
* Calculates the target hour for weather data lookup
*
* This function calculates the next epoch time based on the current weather data and the specified number of hours from now.
* It ensures that the prediction remains within the current day by rewinding the time if necessary.
*
* @param wthr The current weather data.
* @param hoursFromNow The number of hours from now to calculate the next epoch time.
*
* @returns The next epoch time as a number.
* @param baseTime - The base time to calculate from
* @param hoursFromNow - Number of hours to add
* @returns A Date object set to the start of the target hour
*/
export const getNextEpoch = (wthr: Weather, hoursFromNow: number): number => {
const currentEpoch = wthr.location.localtime_epoch;
const epochAtHourStart = currentEpoch - (currentEpoch % 3600);
let nextEpoch = 3600 * hoursFromNow + epochAtHourStart;
export const getTargetHour = (baseTime: Date, hoursFromNow: number): Date => {
const targetTime = new Date(baseTime);
const newHour = targetTime.getHours() + hoursFromNow;
targetTime.setHours(newHour);
targetTime.setMinutes(0, 0, 0);
const curHour = new Date(currentEpoch * 1000).getHours();
/*
* NOTE: Since the API is only capable of showing the current day; if
* the hours left in the day are less than 4 (aka spilling into the next day),
* then rewind to contain the prediction within the current day.
*/
if (curHour > 19) {
const hoursToRewind = curHour - 19;
nextEpoch = 3600 * hoursFromNow + epochAtHourStart - hoursToRewind * 3600;
const currentHour = baseTime.getHours();
if (currentHour > 19) {
const hoursToRewind = currentHour - 19;
targetTime.setHours(targetTime.getHours() - hoursToRewind);
}
return nextEpoch;
return targetTime;
};
/**
* Retrieves the weather icon query for a specific time in the future.
* Retrieves the weather icon for a specific hour in the future
*
* This function calculates the next epoch time and retrieves the corresponding weather data.
* It then generates a weather icon query based on the weather condition and time of day.
*
* @param weather The current weather data.
* @param hoursFromNow The number of hours from now to calculate the weather icon query.
*
* @returns The weather icon query as a string.
* @param weather - The current weather data
* @param hoursFromNow - Number of hours from now to get the icon for
* @returns The appropriate weather icon
*/
export const getIconQuery = (weather: Weather, hoursFromNow: number): WeatherIconTitle => {
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const weatherAtEpoch = weather.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
if (weatherAtEpoch === undefined) {
return 'warning';
export const getHourlyWeatherIcon = (weather: Weather, hoursFromNow: number): WeatherIcon => {
if (!weather?.forecast?.[0]?.hourly) {
return WeatherIcon.WARNING;
}
let iconQuery = weatherAtEpoch.condition.text.trim().toLowerCase().replaceAll(' ', '_');
const targetHour = getTargetHour(weather.lastUpdated, hoursFromNow);
const targetTime = targetHour.getTime();
if (!weatherAtEpoch?.is_day && iconQuery === 'partly_cloudy') {
iconQuery = 'partly_cloudy_night';
const weatherAtHour = weather.forecast[0].hourly.find((hour) => {
const hourTime = hour.time.getTime();
return hourTime === targetTime;
});
if (!weatherAtHour) {
return WeatherIcon.WARNING;
}
if (isValidWeatherIconTitle(iconQuery)) {
return iconQuery;
} else {
return 'warning';
}
const iconQuery: WeatherStatus = weatherAtHour.condition?.text ?? 'WARNING';
return WeatherIcon[iconQuery];
};

View File

@@ -1,18 +1,18 @@
import { bind } from 'astal';
import { globalWeatherVar } from 'src/shared/weather';
import { Gtk } from 'astal/gtk3';
import { weatherIcons } from 'src/lib/icons/weather.js';
import { getIconQuery } from '../helpers';
import WeatherService from 'src/services/weather';
import { getHourlyWeatherIcon } from '../helpers';
const weatherService = WeatherService.getInstance();
export const HourlyIcon = ({ hoursFromNow }: HourlyIconProps): JSX.Element => {
return (
<box halign={Gtk.Align.CENTER}>
<label
className={'hourly-weather-icon txt-icon'}
label={bind(globalWeatherVar).as((weather) => {
const iconQuery = getIconQuery(weather, hoursFromNow);
const weatherIcn = weatherIcons[iconQuery] || weatherIcons['warning'];
return weatherIcn;
label={bind(weatherService.weatherData).as((weather) => {
const weatherIcon = getHourlyWeatherIcon(weather, hoursFromNow);
return weatherIcon;
})}
halign={Gtk.Align.CENTER}
/>

View File

@@ -1,24 +1,33 @@
import options from 'src/options';
import { globalWeatherVar } from 'src/shared/weather';
import { getNextEpoch } from '../helpers';
import { bind, Variable } from 'astal';
import options from 'src/configuration';
import WeatherService from 'src/services/weather';
import { getTargetHour } from '../helpers';
import { TemperatureConverter } from 'src/lib/units/temperature';
const weatherService = WeatherService.getInstance();
const { unit } = options.menus.clock.weather;
export const HourlyTemp = ({ hoursFromNow }: HourlyTempProps): JSX.Element => {
const weatherBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], (weather, unitType) => {
if (!Object.keys(weather).length) {
return '-';
}
const weatherBinding = Variable.derive(
[bind(weatherService.weatherData), bind(unit)],
(weather, unitType) => {
if (!Object.keys(weather).length || !weather?.forecast?.[0]?.hourly) {
return '-';
}
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const weatherAtEpoch = weather.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
const targetHour = getTargetHour(new Date(), hoursFromNow);
const weatherAtTargetHour = weather.forecast[0].hourly.find(
(h) => h.time.getTime() === targetHour.getTime(),
);
const temperatureAtTargetHour = weatherAtTargetHour?.temperature ?? 0;
if (unitType === 'imperial') {
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_f) : '-'}° F`;
}
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_c) : '-'}° C`;
});
const tempConverter = TemperatureConverter.fromCelsius(temperatureAtTargetHour);
const isImperial = unitType === 'imperial';
return isImperial ? tempConverter.formatFahrenheit() : tempConverter.formatCelsius();
},
);
return (
<label

Some files were not shown because too many files have changed in this diff Show More