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

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

@@ -0,0 +1,5 @@
export type BatteryIconKeys = 0 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100;
export type BatteryIcons = {
[key in BatteryIconKeys]: string;
};

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

@@ -0,0 +1,27 @@
export type KbLabelType = 'layout' | 'code';
export type HyprctlKeyboard = {
address: string;
name: string;
rules: string;
model: string;
layout: string;
variant: string;
options: string;
active_keymap: string;
main: boolean;
};
type HyprctlMouse = {
address: string;
name: string;
defaultSpeed: number;
};
export type HyprctlDeviceLayout = {
mice: HyprctlMouse[];
keyboards: HyprctlKeyboard[];
tablets: unknown[];
touch: unknown[];
switches: unknown[];
};

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

@@ -0,0 +1,8 @@
export type MediaTags = {
title: string;
artists: string;
artist: string;
album: string;
name: string;
identity: string;
};

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

@@ -0,0 +1,139 @@
export const defaultWindowTitleMap = [
// Misc
['kitty', '󰄛', 'Kitty Terminal'],
['firefox', '󰈹', 'Firefox'],
['microsoft-edge', '󰇩', 'Edge'],
['discord', '', 'Discord'],
['vesktop', '', 'Vesktop'],
['org.kde.dolphin', '', 'Dolphin'],
['plex', '󰚺', 'Plex'],
['steam', '', 'Steam'],
['spotify', '󰓇', 'Spotify'],
['ristretto', '󰋩', 'Ristretto'],
['obsidian', '󱓧', 'Obsidian'],
['rofi', '', 'Rofi'],
['qBittorrent$', '', 'QBittorrent'],
// Browsers
['google-chrome', '', 'Google Chrome'],
['brave-browser', '󰖟', 'Brave Browser'],
['chromium', '', 'Chromium'],
['opera', '', 'Opera'],
['vivaldi', '󰖟', 'Vivaldi'],
['waterfox', '󰖟', 'Waterfox'],
['thorium', '󰖟', 'Thorium'],
['tor-browser', '', 'Tor Browser'],
['floorp', '󰈹', 'Floorp'],
['zen', '', 'Zen Browser'],
// Terminals
['gnome-terminal', '', 'GNOME Terminal'],
['konsole', '', 'Konsole'],
['alacritty', '', 'Alacritty'],
['wezterm', '', 'Wezterm'],
['foot', '󰽒', 'Foot Terminal'],
['tilix', '', 'Tilix'],
['xterm', '', 'XTerm'],
['urxvt', '', 'URxvt'],
['com.mitchellh.ghostty', '󰊠', 'Ghostty'],
['^st$', '', 'st Terminal'],
// Development Tools
['code', '󰨞', 'Visual Studio Code'],
['vscode', '󰨞', 'VS Code'],
['sublime-text', '', 'Sublime Text'],
['atom', '', 'Atom'],
['android-studio', '󰀴', 'Android Studio'],
['jetbrains-idea', '', 'IntelliJ IDEA'],
['jetbrains-pycharm', '', 'PyCharm'],
['jetbrains-webstorm', '', 'WebStorm'],
['jetbrains-phpstorm', '', 'PhpStorm'],
['eclipse', '', 'Eclipse'],
['netbeans', '', 'NetBeans'],
['docker', '', 'Docker'],
['vim', '', 'Vim'],
['neovim', '', 'Neovim'],
['neovide', '', 'Neovide'],
['emacs', '', 'Emacs'],
// Communication Tools
['slack', '󰒱', 'Slack'],
['telegram-desktop', '', 'Telegram'],
['org.telegram.desktop', '', 'Telegram'],
['whatsapp', '󰖣', 'WhatsApp'],
['teams', '󰊻', 'Microsoft Teams'],
['skype', '󰒯', 'Skype'],
['thunderbird', '', 'Thunderbird'],
// File Managers
['nautilus', '󰝰', 'Files (Nautilus)'],
['thunar', '󰝰', 'Thunar'],
['pcmanfm', '󰝰', 'PCManFM'],
['nemo', '󰝰', 'Nemo'],
['ranger', '󰝰', 'Ranger'],
['doublecmd', '󰝰', 'Double Commander'],
['krusader', '󰝰', 'Krusader'],
// Media Players
['vlc', '󰕼', 'VLC Media Player'],
['mpv', '', 'MPV'],
['rhythmbox', '󰓃', 'Rhythmbox'],
// Graphics Tools
['gimp', '', 'GIMP'],
['inkscape', '', 'Inkscape'],
['krita', '', 'Krita'],
['blender', '󰂫', 'Blender'],
// Video Editing
['kdenlive', '', 'Kdenlive'],
// Games and Gaming Platforms
['lutris', '󰺵', 'Lutris'],
['heroic', '󰺵', 'Heroic Games Launcher'],
['minecraft', '󰍳', 'Minecraft'],
['csgo', '󰺵', 'CS:GO'],
['dota2', '󰺵', 'Dota 2'],
// Office and Productivity
['evernote', '', 'Evernote'],
['sioyek', '', 'Sioyek'],
// Cloud Services and Sync
['dropbox', '󰇣', 'Dropbox'],
];
const overrides = {
kitty: '',
};
/**
* Generates a mapping of application names to their corresponding icons.
* Uses the defaultWindowTitleMap to create the base mapping and applies any overrides.
*
* @returns An object where keys are application names and values are icon names.
* If an application name exists in the overrides, that value is used instead of the default.
*
* @example
* // Given:
* defaultWindowTitleMap = [['kitty', '󰄛', 'Kitty Terminal'], ['firefox', '󰈹', 'Firefox']]
* overrides = { 'kitty': '' }
*
* // Returns:
* { 'kitty': '', 'firefox': '󰈹' }
*/
export const defaultApplicationIconMap = defaultWindowTitleMap.reduce(
(iconMapAccumulator: Record<string, string>, windowTitles) => {
const currentIconMap = iconMapAccumulator;
const appName: string = windowTitles[0];
const appIcon: string = windowTitles[1];
if (!(appName in currentIconMap)) {
currentIconMap[appName] = appIcon;
}
return currentIconMap;
},
overrides,
);

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

@@ -0,0 +1,57 @@
import { Astal, Gdk, Gtk, Widget } from 'astal/gtk3';
import { Binding } from 'astal';
import { Connectable } from 'astal/binding';
import { Label } from 'astal/gtk3/widget';
export type BarBoxChild = {
component: JSX.Element;
isVisible?: boolean;
isVis?: Binding<boolean>;
isBox?: boolean;
boxClass: string;
tooltip_text?: string | Binding<string>;
} & ({ isBox: true; props: Widget.EventBoxProps } | { isBox?: false; props: Widget.ButtonProps });
type BoxHook = (self: Gtk.Box) => void;
type LabelHook = (self: Label) => void;
export type BarModuleProps = {
icon?: string | Binding<string>;
textIcon?: string | Binding<string>;
useTextIcon?: Binding<boolean>;
label?: string | Binding<string>;
truncationSize?: Binding<number>;
labelHook?: LabelHook;
boundLabel?: string;
tooltipText?: string | Binding<string>;
boxClass: string;
isVis?: Binding<boolean>;
props?: Widget.ButtonProps;
showLabel?: boolean;
showLabelBinding?: Binding<boolean>;
showIconBinding?: Binding<boolean>;
hook?: BoxHook;
connection?: Binding<Connectable>;
};
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;
}
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,268 +0,0 @@
import { Gdk } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
const hyprlandService = AstalHyprland.get_default();
/**
* The MonitorMapper class encapsulates the conversion logic between GDK and Hyprland monitor IDs.
* It maintains internal state for monitors that have already been used so that duplicate assignments are avoided.
*/
export class GdkMonitorMapper {
private _usedGdkMonitors: Set<number>;
private _usedHyprlandMonitors: Set<number>;
constructor() {
this._usedGdkMonitors = new Set();
this._usedHyprlandMonitors = new Set();
}
/**
* Resets the internal state for both GDK and Hyprland monitor mappings.
*/
public reset(): void {
this._usedGdkMonitors.clear();
this._usedHyprlandMonitors.clear();
}
/**
* Converts a GDK monitor id to the corresponding Hyprland monitor id.
*
* @param monitor The GDK monitor id.
* @returns The corresponding Hyprland monitor id.
*/
public mapGdkToHyprland(monitor: number): number {
const gdkMonitors = this._getGdkMonitors();
if (Object.keys(gdkMonitors).length === 0) {
return monitor;
}
const gdkMonitor = gdkMonitors[monitor];
const hyprlandMonitors = hyprlandService.get_monitors();
return this._matchMonitor(
hyprlandMonitors,
gdkMonitor,
monitor,
this._usedHyprlandMonitors,
(mon) => mon.id,
(mon, gdkMon) => this._matchMonitorKey(mon, gdkMon),
);
}
/**
* Converts a Hyprland monitor id to the corresponding GDK monitor id.
*
* @param monitor The Hyprland monitor id.
* @returns The corresponding GDK monitor id.
*/
public mapHyprlandToGdk(monitor: number): number {
const gdkMonitors = this._getGdkMonitors();
const gdkCandidates = Object.entries(gdkMonitors).map(([monitorId, monitorMetadata]) => ({
id: Number(monitorId),
monitor: monitorMetadata,
}));
if (gdkCandidates.length === 0) {
return monitor;
}
const hyprlandMonitors = hyprlandService.get_monitors();
const foundHyprlandMonitor =
hyprlandMonitors.find((mon) => mon.id === monitor) || hyprlandMonitors[0];
return this._matchMonitor(
gdkCandidates,
foundHyprlandMonitor,
monitor,
this._usedGdkMonitors,
(candidate) => candidate.id,
(candidate, hyprlandMonitor) => this._matchMonitorKey(hyprlandMonitor, candidate.monitor),
);
}
/**
* Generic helper that finds the best matching candidate monitor based on:
* 1. A direct match (candidate matches the source and has the same id as the target).
* 2. A relaxed match (candidate matches the source, regardless of id).
* 3. A fallback match (first candidate that hasnt been used).
*
* @param candidates Array of candidate monitors.
* @param source The source monitor object to match against.
* @param target The desired monitor id.
* @param usedMonitors A Set of already used candidate ids.
* @param getId Function to extract the id from a candidate.
* @param compare Function that determines if a candidate matches the source.
* @returns The chosen monitor id.
*/
private _matchMonitor<T, U>(
candidates: T[],
source: U,
target: number,
usedMonitors: Set<number>,
getId: (candidate: T) => number,
compare: (candidate: T, source: U) => boolean,
): number {
// Direct match: candidate matches the source and has the same id as the target.
const directMatch = candidates.find(
(candidate) =>
compare(candidate, source) &&
!usedMonitors.has(getId(candidate)) &&
getId(candidate) === target,
);
if (directMatch !== undefined) {
usedMonitors.add(getId(directMatch));
return getId(directMatch);
}
// Relaxed match: candidate matches the source regardless of id.
const relaxedMatch = candidates.find(
(candidate) => compare(candidate, source) && !usedMonitors.has(getId(candidate)),
);
if (relaxedMatch !== undefined) {
usedMonitors.add(getId(relaxedMatch));
return getId(relaxedMatch);
}
// Fallback: use the first candidate that hasn't been used.
const fallback = candidates.find((candidate) => !usedMonitors.has(getId(candidate)));
if (fallback !== undefined) {
usedMonitors.add(getId(fallback));
return getId(fallback);
}
// As a last resort, iterate over candidates.
for (const candidate of candidates) {
const candidateId = getId(candidate);
if (!usedMonitors.has(candidateId)) {
usedMonitors.add(candidateId);
return candidateId;
}
}
console.warn(`Returning original monitor index as a last resort: ${target}`);
return target;
}
/**
* Determines if a Hyprland monitor matches a GDK monitor by comparing their keys
*
* @param hyprlandMonitor - Hyprland monitor object
* @param gdkMonitor - GDK monitor object
* @returns boolean indicating if the monitors match
*/
private _matchMonitorKey(hyprlandMonitor: AstalHyprland.Monitor, gdkMonitor: GdkMonitor): boolean {
const isRotated90 = hyprlandMonitor.transform % 2 !== 0;
const gdkScaleFactor = Math.ceil(hyprlandMonitor.scale);
const scaleFactorWidth = Math.trunc(hyprlandMonitor.width / gdkScaleFactor);
const scaleFactorHeight = Math.trunc(hyprlandMonitor.height / gdkScaleFactor);
const gdkScaleFactorKey = `${hyprlandMonitor.model}_${scaleFactorWidth}x${scaleFactorHeight}_${gdkScaleFactor}`;
const transWidth = isRotated90 ? hyprlandMonitor.height : hyprlandMonitor.width;
const transHeight = isRotated90 ? hyprlandMonitor.width : hyprlandMonitor.height;
const scaleWidth = Math.trunc(transWidth / hyprlandMonitor.scale);
const scaleHeight = Math.trunc(transHeight / hyprlandMonitor.scale);
const hyprlandScaleFactorKey = `${hyprlandMonitor.model}_${scaleWidth}x${scaleHeight}_${gdkScaleFactor}`;
const keyMatch = gdkMonitor.key === gdkScaleFactorKey || gdkMonitor.key === hyprlandScaleFactorKey;
this._logMonitorInfo(
gdkMonitor,
hyprlandMonitor,
isRotated90,
gdkScaleFactor,
gdkScaleFactorKey,
hyprlandScaleFactorKey,
keyMatch,
);
return keyMatch;
}
/**
* Retrieves all GDK monitors from the default display
*
* @returns Object containing GDK monitor information indexed by monitor ID
*/
private _getGdkMonitors(): GdkMonitors {
const display = Gdk.Display.get_default();
if (display === null) {
console.error('Failed to get Gdk display.');
return {};
}
const numGdkMonitors = display.get_n_monitors();
const gdkMonitors: GdkMonitors = {};
for (let i = 0; i < numGdkMonitors; i++) {
const curMonitor = display.get_monitor(i);
if (curMonitor === null) {
console.warn(`Monitor at index ${i} is null.`);
continue;
}
const model = curMonitor.get_model() ?? '';
const geometry = curMonitor.get_geometry();
const scaleFactor = curMonitor.get_scale_factor();
// GDK3 only supports integer scale factors
const key = `${model}_${geometry.width}x${geometry.height}_${scaleFactor}`;
gdkMonitors[i] = { key, model, used: false };
}
return gdkMonitors;
}
/**
* Logs detailed monitor information for debugging purposes
* @param gdkMonitor - GDK monitor object
* @param hyprlandMonitor - Hyprland monitor information
* @param isRotated90 - Whether the monitor is rotated 90 degrees
* @param gdkScaleFactor - The GDK monitor's scale factor
* @param gdkScaleFactorKey - Key used for scale factor matching
* @param hyprlandScaleFactorKey - Key used for general scale matching
* @param keyMatch - Whether the monitor keys match
*/
private _logMonitorInfo(
gdkMonitor: GdkMonitor,
hyprlandMonitor: AstalHyprland.Monitor,
isRotated90: boolean,
gdkScaleFactor: number,
gdkScaleFactorKey: string,
hyprlandScaleFactorKey: string,
keyMatch: boolean,
): void {
console.debug('=== Monitor Matching Debug Info ===');
console.debug('GDK Monitor');
console.debug(` Key: ${gdkMonitor.key}`);
console.debug('Hyprland Monitor');
console.debug(` ID: ${hyprlandMonitor.id}`);
console.debug(` Model: ${hyprlandMonitor.model}`);
console.debug(` Resolution: ${hyprlandMonitor.width}x${hyprlandMonitor.height}`);
console.debug(` Scale: ${hyprlandMonitor.scale}`);
console.debug(` Transform: ${hyprlandMonitor.transform}`);
console.debug('Calculated Values');
console.debug(` Rotation: ${isRotated90 ? '90°' : '0°'}`);
console.debug(` GDK Scale Factor: ${gdkScaleFactor}`);
console.debug('Calculated Keys');
console.debug(` GDK Scale Factor Key: ${gdkScaleFactorKey}`);
console.debug(` Hyprland Scale Factor Key: ${hyprlandScaleFactorKey}`);
console.debug('Match Result');
console.debug(` ${keyMatch ? '✅ Monitors Match' : '❌ No Match'}`);
console.debug('===============================\n');
}
}
type GdkMonitor = {
key: string;
model: string;
used: boolean;
};
type GdkMonitors = {
[key: string]: GdkMonitor;
};

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

@@ -0,0 +1,28 @@
import { Variable } from 'astal';
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>;
fn?: (output: string) => void;
};
export type InputHandlerEvents = {
onPrimaryClick?: InputHandlerEventArgs;
onSecondaryClick?: InputHandlerEventArgs;
onMiddleClick?: InputHandlerEventArgs;
onScrollUp?: InputHandlerEventArgs;
onScrollDown?: InputHandlerEventArgs;
};

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

View File

@@ -1,29 +1,32 @@
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';
const weatherService = WeatherService.getInstance();
const { military } = options.menus.clock.time;
export const HourlyTime = ({ hoursFromNow }: HourlyTimeProps): JSX.Element => {
const weatherBinding = Variable.derive([bind(globalWeatherVar), bind(military)], (weather, military) => {
if (!Object.keys(weather).length) {
return '-';
}
const weatherBinding = Variable.derive(
[bind(weatherService.weatherData), bind(military)],
(weather, military) => {
if (!Object.keys(weather).length) {
return '-';
}
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const dateAtEpoch = new Date(nextEpoch * 1000);
const targetHour = getTargetHour(new Date(), hoursFromNow);
let hours = dateAtEpoch.getHours();
let hours = targetHour.getHours();
if (military) {
return `${hours}:00`;
}
if (military) {
return `${hours}:00`;
}
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12 || 12;
return `${hours}${ampm}`;
});
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12 || 12;
return `${hours}${ampm}`;
},
);
return (
<label

View File

@@ -1,6 +1,8 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { getWeatherStatusTextIcon, globalWeatherVar } from 'src/shared/weather';
import WeatherService from 'src/services/weather';
const weatherService = WeatherService.getInstance();
export const TodayIcon = (): JSX.Element => {
return (
@@ -11,7 +13,7 @@ export const TodayIcon = (): JSX.Element => {
>
<label
className={'calendar-menu-weather today icon txt-icon'}
label={bind(globalWeatherVar).as((weather) => getWeatherStatusTextIcon(weather))}
label={bind(weatherService.statusIcon)}
/>
</box>
);

View File

@@ -1,31 +1,27 @@
import { getTemperature, globalWeatherVar } from 'src/shared/weather';
import options from 'src/options';
import { getRainChance } from 'src/shared/weather';
import { Gtk } from 'astal/gtk3';
import { bind, Variable } from 'astal';
import { bind } from 'astal';
import WeatherService from 'src/services/weather';
const { unit } = options.menus.clock.weather;
const weatherService = WeatherService.getInstance();
export const TodayStats = (): JSX.Element => {
const temperatureBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], getTemperature);
return (
<box
className={'calendar-menu-weather today stats container'}
halign={Gtk.Align.END}
valign={Gtk.Align.CENTER}
vertical
onDestroy={() => {
temperatureBinding.drop();
}}
>
<box className={'weather wind'}>
<label className={'weather wind icon txt-icon'} label={''} />
<label className={'weather wind label'} label={temperatureBinding()} />
<label className={'weather wind label'} label={bind(weatherService.windCondition)} />
</box>
<box className={'weather precip'}>
<label className={'weather precip icon txt-icon'} label={''} />
<label className={'weather precip label'} label={bind(globalWeatherVar).as(getRainChance)} />
<label
className={'weather precip label'}
label={bind(weatherService.rainChance).as((chanceOfRain) => `${chanceOfRain}%`)}
/>
</box>
</box>
);

View File

@@ -1,43 +1,48 @@
import options from 'src/options';
import { globalWeatherVar } from 'src/shared/weather';
import { getTemperature, getWeatherIcon } from 'src/shared/weather';
import { Gtk } from 'astal/gtk3';
import { bind, Variable } from 'astal';
import { bind } from 'astal';
import WeatherService from 'src/services/weather';
import options from 'src/configuration';
import { toTitleCase } from 'src/lib/string/formatters';
const { unit } = options.menus.clock.weather;
const weatherService = WeatherService.getInstance();
unit.subscribe((unitType) => (weatherService.unit = unitType));
const WeatherStatus = (): JSX.Element => {
return (
<box halign={Gtk.Align.CENTER}>
<label
className={bind(globalWeatherVar).as(
(weather) =>
`calendar-menu-weather today condition label ${getWeatherIcon(Math.ceil(weather.current.temp_f)).color}`,
className={bind(weatherService.gaugeIcon).as(
(gauge) => `calendar-menu-weather today condition label ${gauge.color}`,
)}
label={bind(weatherService.weatherData).as((weather) =>
toTitleCase(weather.current.condition.text),
)}
label={bind(globalWeatherVar).as((weather) => weather.current.condition.text)}
truncate
tooltipText={bind(globalWeatherVar).as((weather) => weather.current.condition.text)}
tooltipText={bind(weatherService.weatherData).as((weather) => weather.current.condition.text)}
/>
</box>
);
};
const Temperature = (): JSX.Element => {
const labelBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], getTemperature);
const TemperatureLabel = (): JSX.Element => {
return <label className={'calendar-menu-weather today temp label'} label={labelBinding()} />;
return (
<label
className={'calendar-menu-weather today temp label'}
label={bind(weatherService.temperature)}
/>
);
};
const ThermometerIcon = (): JSX.Element => {
return (
<label
className={bind(globalWeatherVar).as(
(weather) =>
`calendar-menu-weather today temp label icon txt-icon ${getWeatherIcon(Math.ceil(weather.current.temp_f)).color}`,
)}
label={bind(globalWeatherVar).as(
(weather) => getWeatherIcon(Math.ceil(weather.current.temp_f)).icon,
className={bind(weatherService.gaugeIcon).as(
(gauge) => `calendar-menu-weather today temp label icon txt-icon ${gauge.color}`,
)}
label={bind(weatherService.gaugeIcon).as((gauge) => gauge.icon)}
/>
);
};
@@ -47,9 +52,6 @@ const Temperature = (): JSX.Element => {
className={'calendar-menu-weather today temp container'}
valign={Gtk.Align.CENTER}
vertical={false}
onDestroy={() => {
labelBinding.drop();
}}
hexpand
>
<box halign={Gtk.Align.CENTER} hexpand>

View File

@@ -1,10 +1,10 @@
import { bind } from 'astal';
import { isPrimaryClick } from 'src/lib/utils';
import { isWifiEnabled } from './helpers';
import AstalNotifd from 'gi://AstalNotifd?version=0.1';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import AstalWp from 'gi://AstalWp?version=0.1';
import { isPrimaryClick } from 'src/lib/events/mouse';
const wireplumber = AstalWp.get_default() as AstalWp.Wp;
const audioService = wireplumber.audio;

View File

@@ -6,8 +6,9 @@ import {
PlaybackButton,
WifiButton,
} from './ControlButtons';
import { JSXElement } from 'src/core/types';
export const Controls = ({ isEnabled }: ControlsProps): JSX.Element => {
export const Controls = ({ isEnabled }: ControlsProps): JSXElement => {
if (!isEnabled) {
return null;
}

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