Upgrade to Agsv2 + Astal (#533)

* migrate to astal

* Reorganize project structure.

* progress

* Migrate Dashboard and Window Title modules.

* Migrate clock and notification bar modules.

* Remove unused code

* Media menu

* Rework network and volume modules

* Finish custom modules.

* Migrate battery bar module.

* Update battery module and organize helpers.

* Migrate workspace module.

* Wrap up bar modules.

* Checkpoint before I inevitbly blow something up.

* Updates

* Fix event propagation logic.

* Type fixes

* More type fixes

* Fix padding for event boxes.

* Migrate volume menu and refactor scroll event handlers.

* network module WIP

* Migrate network service.

* Migrate bluetooth menu

* Updates

* Migrate notifications

* Update scrolling behavior for custom modules.

* Improve popup notifications and add timer functionality.

* Migration notifications menu header/controls.

* Migrate notifications menu and consolidate notifications menu code.

* Migrate power menu.

* Dashboard progress

* Migrate dashboard

* Migrate media menu.

* Reduce media menu nesting.

* Finish updating media menu bindings to navigate active player.

* Migrate battery menu

* Consolidate code

* Migrate calendar menu

* Fix workspace logic to update on client add/change/remove and consolidate code.

* Migrate osd

* Consolidate hyprland service connections.

* Implement startup dropdown menu position allocation.

* Migrate settings menu (WIP)

* Settings dialo menu fixes

* Finish Dashboard menu

* Type updates

* update submoldule for types

* update github ci

* ci

* Submodule update

* Ci updates

* Remove type checking for now.

* ci fix

* Fix a bunch of stuff, losing track... need rest. Brb coffee

* Validate dropdown menu before render.

* Consolidate code and add auto-hide functionality.

* Improve auto-hide behavior.

* Consolidate audio menu code

* Organize bluetooth code

* Improve active player logic

* Properly dismiss a notification on action button resolution.

* Implement CLI command engine and migrate CLI commands.

* Handle variable disposal

* Bar component fixes and add hyprland startup rules.

* Handle potentially null bindings network and bluetooth bindings.

* Handle potentially null wired adapter.

* Fix GPU stats

* Handle poller for GPU

* Fix gpu bar logic.

* Clean up logic for stat bars.

* Handle wifi and wired bar icon bindings.

* Fix battery percentages

* Fix switch behavior

* Wifi staging fixes

* Reduce redundant hyprland service calls.

* Code cleanup

* Document the option code and reduce redundant calls to optimize performance.

* Remove outdated comment.

* Add JSDocs

* Add meson to build hyprpanel

* Consistency updates

* Organize commands

* Fix images not showing up on notifications.

* Remove todo

* Move hyprpanel configuration to the ~/.config/hyprpanel directory and add utility commands.

* Handle SRC directory for the bundled/built hyprpanel.

* Add namespaces to all windows

* Migrate systray

* systray updates

* Update meson to include ts, tsx and scss files.

* Remove log from meson

* Fix file choose path and make it float.

* Added a command to check the dependency status

* Update dep names.

* Get scale directly from env

* Add todo
This commit is contained in:
Jas Singh
2024-12-20 18:10:10 -08:00
committed by GitHub
parent 955eed6c60
commit 2ffd602910
605 changed files with 19543 additions and 15999 deletions

View File

@@ -0,0 +1,51 @@
import { BatteryIconKeys, BatteryIcons } from 'src/lib/types/battery';
const batteryIcons: BatteryIcons = {
0: '󰂎',
10: '󰁺',
20: '󰁻',
30: '󰁼',
40: '󰁽',
50: '󰁾',
60: '󰁿',
70: '󰂀',
80: '󰂁',
90: '󰂂',
100: '󰁹',
};
const batteryIconsCharging: BatteryIcons = {
0: '󰢟',
10: '󰢜',
20: '󰂆',
30: '󰂇',
40: '󰂈',
50: '󰢝',
60: '󰂉',
70: '󰢞',
80: '󰂊',
90: '󰂋',
100: '󰂅',
};
/**
* Retrieves the appropriate battery icon based on the battery percentage and charging status.
*
* This function returns the corresponding battery icon based on the provided battery percentage, charging status, and whether the battery is fully charged.
* It uses predefined mappings for battery icons and charging battery icons.
*
* @param percentage The current battery percentage.
* @param charging A boolean indicating whether the battery is currently charging.
* @param isCharged A boolean indicating whether the battery is fully charged.
*
* @returns The corresponding battery icon as a string.
*/
export const getBatteryIcon = (percentage: number, charging: boolean, isCharged: boolean): string => {
if (isCharged) {
return '󱟢';
}
const percentages: BatteryIconKeys[] = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
const foundPercentage = percentages.find((threshold) => threshold <= percentage) ?? 100;
return charging ? batteryIconsCharging[foundPercentage] : batteryIcons[foundPercentage];
};

View File

@@ -0,0 +1,133 @@
import { batteryService } from 'src/lib/constants/services.js';
import { Astal } from 'astal/gtk3';
import { openMenu } from '../../utils/menu';
import options from 'src/options';
import { BarBoxChild } from 'src/lib/types/bar.js';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import Variable from 'astal/variable';
import { bind } from 'astal/binding.js';
import AstalBattery from 'gi://AstalBattery?version=0.1';
import { useHook } from 'src/lib/shared/hookHandler';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { getBatteryIcon } from './helpers';
const { label: show_label, rightClick, middleClick, scrollUp, scrollDown, hideLabelWhenFull } = options.bar.battery;
const BatteryLabel = (): BarBoxChild => {
const batIcon = Variable.derive(
[bind(batteryService, 'percentage'), bind(batteryService, 'charging'), bind(batteryService, 'state')],
(batPercent: number, batCharging: boolean, state: AstalBattery.State) => {
const batCharged = state === AstalBattery.State.FULLY_CHARGED;
return getBatteryIcon(Math.floor(batPercent * 100), batCharging, batCharged);
},
);
const formatTime = (seconds: number): Record<string, number> => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return { hours, minutes };
};
const generateTooltip = (timeSeconds: number, isCharging: boolean, isCharged: boolean): string => {
if (isCharged === true) {
return 'Full';
}
const { hours, minutes } = formatTime(timeSeconds);
if (isCharging) {
return `Time to full: ${hours} h ${minutes} min`;
} else {
return `Time to empty: ${hours} h ${minutes} min`;
}
};
const componentClassName = Variable.derive(
[bind(options.theme.bar.buttons.style), bind(show_label)],
(style, showLabel) => {
const styleMap = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `battery-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
},
);
const componentTooltip = Variable.derive(
[bind(batteryService, 'charging'), bind(batteryService, 'timeToFull'), bind(batteryService, 'timeToEmpty')],
(isCharging, timeToFull, timeToEmpty) => {
const timeRemaining = isCharging ? timeToFull : timeToEmpty;
return generateTooltip(timeRemaining, isCharging, Math.floor(batteryService.percentage * 100) === 100);
},
);
const componentChildren = Variable.derive(
[bind(show_label), bind(batteryService, 'percentage'), bind(hideLabelWhenFull)],
(showLabel, percentage, hideLabelWhenFull) => {
const isCharged = Math.round(percentage) === 100;
const icon = <label className={'bar-button-icon battery txt-icon'} label={batIcon()} />;
const label = <label className={'bar-button-label battery'} label={`${Math.floor(percentage * 100)}%`} />;
const children = [icon];
if (showLabel && !(isCharged && hideLabelWhenFull)) {
children.push(label);
}
return children;
},
);
const component = (
<box
className={componentClassName()}
tooltipText={componentTooltip()}
onDestroy={() => {
batIcon.drop();
componentClassName.drop();
componentTooltip.drop();
componentChildren.drop();
}}
>
{componentChildren()}
</box>
);
return {
component,
isVisible: true,
boxClass: 'battery',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'energymenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
},
};
};
export { BatteryLabel };

View File

@@ -0,0 +1,93 @@
import { bluetoothService } from 'src/lib/constants/services.js';
import options from 'src/options.js';
import { openMenu } from '../../utils/menu.js';
import { BarBoxChild } from 'src/lib/types/bar.js';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { bind } from 'astal/binding.js';
import Variable from 'astal/variable.js';
import { useHook } from 'src/lib/shared/hookHandler.js';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { Astal } from 'astal/gtk3';
const { rightClick, middleClick, scrollDown, scrollUp } = options.bar.bluetooth;
const Bluetooth = (): BarBoxChild => {
const btIcon = (isPowered: boolean): JSX.Element => (
<label className={'bar-button-icon bluetooth txt-icon bar'} label={isPowered ? '󰂯' : '󰂲'} />
);
const btText = (isPowered: boolean, devices: AstalBluetooth.Device[]): JSX.Element => {
const connectDevices = devices.filter((device) => device.connected);
const label =
isPowered && connectDevices.length ? ` Connected (${connectDevices.length})` : isPowered ? 'On' : 'Off';
return <label label={label} className={'bar-button-label bluetooth'} />;
};
const componentClassName = Variable.derive(
[options.theme.bar.buttons.style, options.bar.volume.label],
(style, showLabel) => {
const styleMap = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `bluetooth-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
},
);
const componentBinding = Variable.derive(
[bind(options.bar.volume.label), bind(bluetoothService, 'isPowered'), bind(bluetoothService, 'devices')],
(showLabel: boolean, isPowered: boolean, devices: AstalBluetooth.Device[]): JSX.Element[] => {
if (showLabel) {
return [btIcon(isPowered), btText(isPowered, devices)];
}
return [btIcon(isPowered)];
},
);
const component = <box className={componentClassName()}>{componentBinding()}</box>;
return {
component,
isVisible: true,
boxClass: 'bluetooth',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'bluetoothmenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
onDestroy: (): void => {
componentClassName.drop();
componentBinding.drop();
},
},
};
};
export { Bluetooth };

View File

@@ -0,0 +1,89 @@
import { openMenu } from '../../utils/menu';
import options from 'src/options';
import { BarBoxChild } from 'src/lib/types/bar.js';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { bind, Variable } from 'astal';
import { useHook } from 'src/lib/shared/hookHandler';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { Astal } from 'astal/gtk3';
import { systemTime } from 'src/globals/time';
const { format, icon, showIcon, showTime, rightClick, middleClick, scrollUp, scrollDown } = options.bar.clock;
const { style } = options.theme.bar.buttons;
const time = Variable.derive([systemTime, format], (c, f) => c.format(f) || '');
const Clock = (): BarBoxChild => {
const clockTime = <label className={'bar-button-label clock bar'} label={bind(time)} />;
const clockIcon = <label className={'bar-button-icon clock txt-icon bar'} label={bind(icon)} />;
const componentClassName = Variable.derive(
[bind(style), bind(showIcon), bind(showTime)],
(btnStyle, shwIcn, shwLbl) => {
const styleMap = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `clock-container ${styleMap[btnStyle]} ${!shwLbl ? 'no-label' : ''} ${!shwIcn ? 'no-icon' : ''}`;
},
);
const componentChildren = Variable.derive([bind(showIcon), bind(showTime)], (shIcn, shTm) => {
if (shIcn && !shTm) {
return [clockIcon];
} else if (shTm && !shIcn) {
return [clockTime];
}
return [clockIcon, clockTime];
});
const component = (
<box
className={componentClassName()}
onDestroy={() => {
componentClassName.drop();
componentChildren.drop();
}}
>
{componentChildren()}
</box>
);
return {
component,
isVisible: true,
boxClass: 'clock',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'calendarmenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
},
};
};
export { Clock };

View File

@@ -0,0 +1,27 @@
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);
// Calculate the differences from the previous to current data
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

@@ -0,0 +1,61 @@
import { Module } from '../../shared/Module';
import options from 'src/options';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { computeCPU } from './helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
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');
export const Cpu = (): BarBoxChild => {
const renderLabel = (cpuUsg: number, rnd: boolean): string => {
return rnd ? `${Math.round(cpuUsg)}%` : `${cpuUsg.toFixed(2)}%`;
};
const labelBinding = Variable.derive([bind(cpuUsage), bind(round)], (cpuUsg, rnd) => {
return renderLabel(cpuUsg, rnd);
});
const cpuModule = Module({
textIcon: bind(icon),
label: labelBinding(),
tooltipText: 'CPU',
boxClass: 'cpu',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
onDestroy: () => {
labelBinding.drop();
},
},
});
return cpuModule;
};

View File

@@ -0,0 +1,44 @@
import { Variable } from 'astal';
import GLib from 'gi://GLib?version=2.0';
import { convertCelsiusToFahrenheit } from 'src/globals/weather';
import { UnitType } from 'src/lib/types/weather';
import options from 'src/options';
const { 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.
*/
export const getCPUTemperature = (round: Variable<boolean>, unit: Variable<UnitType>): number => {
try {
if (sensor.get().length === 0) {
return 0;
}
const [success, tempInfoBytes] = GLib.file_get_contents(sensor.get());
const tempInfo = new TextDecoder('utf-8').decode(tempInfoBytes);
if (!success || !tempInfoBytes) {
console.error(`Failed to read ${sensor.get()} or file content is null.`);
return 0;
}
let decimalTemp = parseInt(tempInfo, 10) / 1000;
if (unit.get() === 'imperial') {
decimalTemp = convertCelsiusToFahrenheit(decimalTemp);
}
return round.get() ? Math.round(decimalTemp) : parseFloat(decimalTemp.toFixed(2));
} catch (error) {
console.error('Error calculating CPU Temp:', error);
return 0;
}
};

View File

@@ -0,0 +1,81 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { getCPUTemperature } from './helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { UnitType } from 'src/lib/types/weather';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
const {
label,
sensor,
round,
showUnit,
unit,
leftClick,
rightClick,
middleClick,
scrollUp,
scrollDown,
pollingInterval,
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');
export const CpuTemp = (): BarBoxChild => {
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}`;
},
);
const cpuTempModule = Module({
textIcon: bind(icon),
label: labelBinding(),
tooltipText: 'CPU Temperature',
boxClass: 'cpu-temp',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
onDestroy: () => {
labelBinding.drop();
},
},
});
return cpuTempModule;
};

View File

@@ -0,0 +1,55 @@
import { execAsync, Variable } from 'astal';
/**
* Checks if the hypridle process is active.
*
* This command checks if the hypridle 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 'hypridle' &>/dev/null && echo 'yes' || echo 'no'"`;
/**
* A variable to track the active state of the hypridle process.
*/
export const isActive = Variable(false);
/**
* Updates the active state of the hypridle process.
*
* This function checks if the hypridle process is currently running and updates the `isActive` variable accordingly.
*
* @param isActive A Variable<boolean> that tracks the active state of the hypridle process.
*/
const updateIsActive = (isActive: Variable<boolean>): void => {
execAsync(isActiveCommand).then((res) => {
isActive.set(res === 'yes');
});
};
/**
* Toggles the hypridle process on or off based on its current state.
*
* This function checks if the hypridle process is currently running. If it is not running, it starts the process.
* If it is running, it stops the process. The active state is updated accordingly.
*
* @param isActive A Variable<boolean> that tracks the active state of the hypridle process.
*/
export const toggleIdle = (isActive: Variable<boolean>): void => {
execAsync(isActiveCommand).then((res) => {
const toggleIdleCommand =
res === 'no' ? `bash -c "nohup hypridle > /dev/null 2>&1 &"` : `bash -c "pkill hypridle"`;
execAsync(toggleIdleCommand).then(() => updateIsActive(isActive));
});
};
/**
* Checks the current status of the hypridle process and updates the active state.
*
* This function checks if the hypridle process is currently running and updates the `isActive` variable accordingly.
*/
export const checkIdleStatus = (): undefined => {
execAsync(isActiveCommand).then((res) => {
isActive.set(res === 'yes');
});
};

View File

@@ -0,0 +1,68 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler, throttleInput } from '../../utils/helpers';
import { checkIdleStatus, isActive, toggleIdle } from './helpers';
import { FunctionPoller } from '../../../../lib/poller/FunctionPoller';
import Variable from 'astal/variable';
import { bind } from 'astal';
import { BarBoxChild } from 'src/lib/types/bar';
import { Astal } from 'astal/gtk3';
const { label, pollingInterval, onIcon, offIcon, onLabel, offLabel, rightClick, middleClick, scrollUp, scrollDown } =
options.bar.customModules.hypridle;
const dummyVar = Variable(undefined);
checkIdleStatus();
const idleStatusPoller = new FunctionPoller<undefined, []>(dummyVar, [], bind(pollingInterval), checkIdleStatus);
idleStatusPoller.initialize('hypridle');
const throttledToggleIdle = throttleInput(() => toggleIdle(isActive), 1000);
export const Hypridle = (): BarBoxChild => {
const iconBinding = Variable.derive([bind(isActive), bind(onIcon), bind(offIcon)], (active, onIcn, offIcn) => {
return active ? onIcn : offIcn;
});
const labelBinding = Variable.derive([bind(isActive), bind(onLabel), bind(offLabel)], (active, onLbl, offLbl) => {
return active ? onLbl : offLbl;
});
const hypridleModule = Module({
textIcon: iconBinding(),
tooltipText: bind(isActive).as((active) => `Hypridle ${active ? 'enabled' : 'disabled'}`),
boxClass: 'hypridle',
label: labelBinding(),
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
fn: () => {
throttledToggleIdle();
},
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
onDestroy: () => {
iconBinding.drop();
labelBinding.drop();
},
},
});
return hypridleModule;
};

View File

@@ -0,0 +1,54 @@
import { execAsync, Variable } from 'astal';
import options from 'src/options';
const { temperature } = options.bar.customModules.hyprsunset;
/**
* Checks if the hyprsunset process is active.
*
* 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'"`;
/**
* A variable to track the active state of the hyprsunset process.
*/
export const isActive = Variable(false);
/**
* Toggles the hyprsunset process on or off based on its current state.
*
* This function checks if the hyprsunset process is currently running. If it is not running, it starts the process with the specified temperature.
* If it is running, it stops the process. The active state is updated accordingly.
*
* @param isActive A Variable<boolean> that tracks the active state of the hyprsunset process.
*/
export const toggleSunset = (isActive: Variable<boolean>): void => {
execAsync(isActiveCommand).then((res) => {
if (res === 'no') {
execAsync(`bash -c "nohup hyprsunset -t ${temperature.get()} > /dev/null 2>&1 &"`).then(() => {
execAsync(isActiveCommand).then((res) => {
isActive.set(res === 'yes');
});
});
} else {
execAsync(`bash -c "pkill hyprsunset "`).then(() => {
execAsync(isActiveCommand).then((res) => {
isActive.set(res === 'yes');
});
});
}
});
};
/**
* Checks the current status of the hyprsunset process and updates the active state.
*
* This function checks if the hyprsunset process is currently running and updates the `isActive` variable accordingly.
*/
export const checkSunsetStatus = (): undefined => {
execAsync(isActiveCommand).then((res) => {
isActive.set(res === 'yes');
});
};

View File

@@ -0,0 +1,84 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler, throttleInput } from 'src/components/bar/utils/helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import { checkSunsetStatus, isActive, toggleSunset } from './helpers';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
const {
label,
pollingInterval,
onIcon,
offIcon,
onLabel,
offLabel,
rightClick,
middleClick,
scrollUp,
scrollDown,
temperature,
} = options.bar.customModules.hyprsunset;
const dummyVar = Variable(undefined);
checkSunsetStatus();
const sunsetPoller = new FunctionPoller<undefined, []>(dummyVar, [], bind(pollingInterval), checkSunsetStatus);
sunsetPoller.initialize('hyprsunset');
const throttledToggleSunset = throttleInput(() => toggleSunset(isActive), 1000);
export const Hyprsunset = (): BarBoxChild => {
const iconBinding = Variable.derive([bind(isActive), bind(onIcon), bind(offIcon)], (active, onIcn, offIcn) => {
return active ? onIcn : offIcn;
});
const tooltipBinding = Variable.derive([isActive, temperature], (active, temp) => {
return `Hyprsunset ${active ? 'enabled' : 'disabled'}\nTemperature: ${temp}`;
});
const labelBinding = Variable.derive([bind(isActive), bind(onLabel), bind(offLabel)], (active, onLbl, offLbl) => {
return active ? onLbl : offLbl;
});
const hyprsunsetModule = Module({
textIcon: iconBinding(),
tooltipText: tooltipBinding(),
boxClass: 'hyprsunset',
label: labelBinding(),
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
fn: () => {
throttledToggleSunset();
},
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
onDestroy: () => {
iconBinding.drop();
tooltipBinding.drop();
labelBinding.drop();
},
},
});
return hyprsunsetModule;
};

View File

@@ -0,0 +1,39 @@
import {
HyprctlDeviceLayout,
HyprctlKeyboard,
KbLabelType,
LayoutKeys,
LayoutValues,
} from 'src/lib/types/customModules/kbLayout';
import { layoutMap } from './layouts';
/**
* Retrieves the keyboard layout from a given JSON string and format.
*
* This function parses the provided JSON string to extract the keyboard layout information.
* It returns the layout in the specified format, either as a code or a human-readable string.
*
* @param obj The JSON string containing the keyboard layout information.
* @param format The format in which to return the layout, either 'code' or 'label'.
*
* @returns The keyboard layout in the specified format. If no keyboards are found, returns 'Unknown' or 'Unknown Layout'.
*/
export const getKeyboardLayout = (obj: string, format: KbLabelType): LayoutKeys | LayoutValues => {
const hyprctlDevices: HyprctlDeviceLayout = JSON.parse(obj);
const keyboards = hyprctlDevices['keyboards'];
if (keyboards.length === 0) {
return format === 'code' ? 'Unknown' : 'Unknown Layout';
}
let mainKb = keyboards.find((kb: HyprctlKeyboard) => kb.main);
if (!mainKb) {
mainKb = keyboards[keyboards.length - 1];
}
const layout: LayoutKeys = mainKb['active_keymap'] as LayoutKeys;
const foundLayout: LayoutValues = layoutMap[layout];
return format === 'code' ? foundLayout || layout : layout;
};

View File

@@ -0,0 +1,587 @@
import { LayoutKeys, LayoutValues } from 'src/lib/types/customModules/kbLayout';
export const layoutMap: Record<LayoutKeys, LayoutValues> = {
'Abkhazian (Russia)': 'RU (Ab)',
Akan: 'GH (Akan)',
Albanian: 'AL',
'Albanian (Plisi)': 'AL (Plisi)',
'Albanian (Veqilharxhi)': 'AL (Veqilharxhi)',
Amharic: 'ET',
Arabic: 'ARA',
'Arabic (Algeria)': 'DZ (Ar)',
'Arabic (AZERTY, Eastern Arabic numerals)': 'ARA (Azerty Digits)',
'Arabic (AZERTY)': 'ARA (Azerty)',
'Arabic (Buckwalter)': 'ARA (Buckwalter)',
'Arabic (Eastern Arabic numerals)': 'ARA (Digits)',
'Arabic (Macintosh)': 'ARA (Mac)',
'Arabic (Morocco)': 'MA',
'Arabic (OLPC)': 'ARA (Olpc)',
'Arabic (Pakistan)': 'PK (Ara)',
'Arabic (QWERTY, Eastern Arabic numerals)': 'ARA (Qwerty Digits)',
'Arabic (QWERTY)': 'ARA (Qwerty)',
'Arabic (Syria)': 'SY',
Armenian: 'AM',
'Armenian (alt. eastern)': 'AM (Eastern-Alt)',
'Armenian (alt. phonetic)': 'AM (Phonetic-Alt)',
'Armenian (eastern)': 'AM (Eastern)',
'Armenian (phonetic)': 'AM (Phonetic)',
'Armenian (western)': 'AM (Western)',
'Asturian (Spain, with bottom-dot H and L)': 'ES (Ast)',
Avatime: 'GH (Avn)',
Azerbaijani: 'AZ',
'Azerbaijani (Cyrillic)': 'AZ (Cyrillic)',
'Azerbaijani (Iran)': 'IR (Azb)',
Bambara: 'ML',
Bangla: 'BD',
'Bangla (India, Baishakhi InScript)': 'IN (Ben Inscript)',
'Bangla (India, Baishakhi)': 'IN (Ben Baishakhi)',
'Bangla (India, Bornona)': 'IN (Ben Bornona)',
'Bangla (India, Gitanjali)': 'IN (Ben Gitanjali)',
'Bangla (India, Probhat)': 'IN (Ben Probhat)',
'Bangla (India)': 'IN (Ben)',
'Bangla (Probhat)': 'BD (Probhat)',
Bashkirian: 'RU (Bak)',
Belarusian: 'BY',
'Belarusian (intl.)': 'BY (Intl)',
'Belarusian (Latin)': 'BY (Latin)',
'Belarusian (legacy)': 'BY (Legacy)',
'Belarusian (phonetic)': 'BY (Phonetic)',
Belgian: 'BE',
'Belgian (alt.)': 'BE (Oss)',
'Belgian (ISO, alt.)': 'BE (Iso-Alternate)',
'Belgian (Latin-9 only, alt.)': 'BE (Oss Latin9)',
'Belgian (no dead keys)': 'BE (Nodeadkeys)',
'Belgian (Wang 724 AZERTY)': 'BE (Wang)',
'Berber (Algeria, Latin)': 'DZ',
'Berber (Algeria, Tifinagh)': 'DZ (Ber)',
'Berber (Morocco, Tifinagh alt.)': 'MA (Tifinagh-Alt)',
'Berber (Morocco, Tifinagh extended phonetic)': 'MA (Tifinagh-Extended-Phonetic)',
'Berber (Morocco, Tifinagh extended)': 'MA (Tifinagh-Extended)',
'Berber (Morocco, Tifinagh phonetic, alt.)': 'MA (Tifinagh-Alt-Phonetic)',
'Berber (Morocco, Tifinagh phonetic)': 'MA (Tifinagh-Phonetic)',
'Berber (Morocco, Tifinagh)': 'MA (Tifinagh)',
Bosnian: 'BA',
'Bosnian (US, with Bosnian digraphs)': 'BA (Unicodeus)',
'Bosnian (US)': 'BA (Us)',
'Bosnian (with Bosnian digraphs)': 'BA (Unicode)',
'Bosnian (with guillemets)': 'BA (Alternatequotes)',
Braille: 'BRAI',
'Braille (left-handed inverted thumb)': 'BRAI (Left Hand Invert)',
'Braille (left-handed)': 'BRAI (Left Hand)',
'Braille (right-handed inverted thumb)': 'BRAI (Right Hand Invert)',
'Braille (right-handed)': 'BRAI (Right Hand)',
'Breton (France)': 'FR (Bre)',
Bulgarian: 'BG',
'Bulgarian (enhanced)': 'BG (Bekl)',
'Bulgarian (new phonetic)': 'BG (Bas Phonetic)',
'Bulgarian (traditional phonetic)': 'BG (Phonetic)',
Burmese: 'MM',
'Burmese Zawgyi': 'MM (Zawgyi)',
'Cameroon (AZERTY, intl.)': 'CM (Azerty)',
'Cameroon (Dvorak, intl.)': 'CM (Dvorak)',
'Cameroon Multilingual (QWERTY, intl.)': 'CM (Qwerty)',
'Canadian (CSA)': 'CA (Multix)',
'Catalan (Spain, with middle-dot L)': 'ES (Cat)',
Cherokee: 'US (Chr)',
Chinese: 'CN',
Chuvash: 'RU (Cv)',
'Chuvash (Latin)': 'RU (Cv Latin)',
CloGaelach: 'IE (CloGaelach)',
'Crimean Tatar (Turkish Alt-Q)': 'UA (Crh Alt)',
'Crimean Tatar (Turkish F)': 'UA (Crh F)',
'Crimean Tatar (Turkish Q)': 'UA (Crh)',
Croatian: 'HR',
'Croatian (US, with Croatian digraphs)': 'HR (Unicodeus)',
'Croatian (US)': 'HR (Us)',
'Croatian (with Croatian digraphs)': 'HR (Unicode)',
'Croatian (with guillemets)': 'HR (Alternatequotes)',
Czech: 'CZ',
'Czech (QWERTY, extended backslash)': 'CZ (Qwerty Bksl)',
'Czech (QWERTY, Macintosh)': 'CZ (Qwerty-Mac)',
'Czech (QWERTY)': 'CZ (Qwerty)',
'Czech (UCW, only accented letters)': 'CZ (Ucw)',
'Czech (US, Dvorak, UCW support)': 'CZ (Dvorak-Ucw)',
'Czech (with <\\|> key)': 'CZ (Bksl)',
Danish: 'DK',
'Danish (Dvorak)': 'DK (Dvorak)',
'Danish (Macintosh, no dead keys)': 'DK (Mac Nodeadkeys)',
'Danish (Macintosh)': 'DK (Mac)',
'Danish (no dead keys)': 'DK (Nodeadkeys)',
'Danish (Windows)': 'DK (Winkeys)',
Dari: 'AF',
'Dari (Afghanistan, OLPC)': 'AF (Fa-Olpc)',
Dhivehi: 'MV',
Dutch: 'NL',
'Dutch (Macintosh)': 'NL (Mac)',
'Dutch (standard)': 'NL (Std)',
'Dutch (US)': 'NL (Us)',
Dzongkha: 'BT',
'English (Australian)': 'AU',
'English (Cameroon)': 'CM',
'English (Canada)': 'CA (Eng)',
'English (classic Dvorak)': 'US (Dvorak-Classic)',
'English (Colemak-DH ISO)': 'US (Colemak Dh Iso)',
'English (Colemak-DH)': 'US (Colemak Dh)',
'English (Colemak)': 'US (Colemak)',
'English (Dvorak, alt. intl.)': 'US (Dvorak-Alt-Intl)',
'English (Dvorak, intl., with dead keys)': 'US (Dvorak-Intl)',
'English (Dvorak, left-handed)': 'US (Dvorak-L)',
'English (Dvorak, Macintosh)': 'US (Dvorak-Mac)',
'English (Dvorak, right-handed)': 'US (Dvorak-R)',
'English (Dvorak)': 'US (Dvorak)',
'English (Ghana, GILLBT)': 'GH (Gillbt)',
'English (Ghana, multilingual)': 'GH (Generic)',
'English (Ghana)': 'GH',
'English (India, with rupee)': 'IN (Eng)',
'English (intl., with AltGr dead keys)': 'US (Altgr-Intl)',
'English (Macintosh)': 'US (Mac)',
'English (Mali, US, intl.)': 'ML (Us-Intl)',
'English (Mali, US, Macintosh)': 'ML (Us-Mac)',
'English (Nigeria)': 'NG',
'English (Norman)': 'US (Norman)',
'English (programmer Dvorak)': 'US (Dvp)',
'English (South Africa)': 'ZA',
'English (the divide/multiply toggle the layout)': 'US (Olpc2)',
'English (UK, Colemak-DH)': 'GB (Colemak Dh)',
'English (UK, Colemak)': 'GB (Colemak)',
'English (UK, Dvorak, with UK punctuation)': 'GB (Dvorakukp)',
'English (UK, Dvorak)': 'GB (Dvorak)',
'English (UK, extended, Windows)': 'GB (Extd)',
'English (UK, intl., with dead keys)': 'GB (Intl)',
'English (UK, Macintosh, intl.)': 'GB (Mac Intl)',
'English (UK, Macintosh)': 'GB (Mac)',
'English (UK)': 'GB',
'English (US, alt. intl.)': 'US (Alt-Intl)',
'English (US, euro on 5)': 'US (Euro)',
'English (US, intl., with dead keys)': 'US (Intl)',
'English (US, Symbolic)': 'US (Symbolic)',
'English (US)': 'US',
'English (Workman, intl., with dead keys)': 'US (Workman-Intl)',
'English (Workman)': 'US (Workman)',
Esperanto: 'EPO',
'Esperanto (Brazil, Nativo)': 'BR (Nativo-Epo)',
'Esperanto (legacy)': 'EPO (Legacy)',
'Esperanto (Portugal, Nativo)': 'PT (Nativo-Epo)',
Estonian: 'EE',
'Estonian (Dvorak)': 'EE (Dvorak)',
'Estonian (no dead keys)': 'EE (Nodeadkeys)',
'Estonian (US)': 'EE (Us)',
Ewe: 'GH (Ewe)',
Faroese: 'FO',
'Faroese (no dead keys)': 'FO (Nodeadkeys)',
Filipino: 'PH',
'Filipino (Capewell-Dvorak, Baybayin)': 'PH (Capewell-Dvorak-Bay)',
'Filipino (Capewell-Dvorak, Latin)': 'PH (Capewell-Dvorak)',
'Filipino (Capewell-QWERF 2006, Baybayin)': 'PH (Capewell-Qwerf2k6-Bay)',
'Filipino (Capewell-QWERF 2006, Latin)': 'PH (Capewell-Qwerf2k6)',
'Filipino (Colemak, Baybayin)': 'PH (Colemak-Bay)',
'Filipino (Colemak, Latin)': 'PH (Colemak)',
'Filipino (Dvorak, Baybayin)': 'PH (Dvorak-Bay)',
'Filipino (Dvorak, Latin)': 'PH (Dvorak)',
'Filipino (QWERTY, Baybayin)': 'PH (Qwerty-Bay)',
Finnish: 'FI',
'Finnish (classic, no dead keys)': 'FI (Nodeadkeys)',
'Finnish (classic)': 'FI (Classic)',
'Finnish (Macintosh)': 'FI (Mac)',
'Finnish (Windows)': 'FI (Winkeys)',
French: 'FR',
'French (alt., Latin-9 only)': 'FR (Oss Latin9)',
'French (alt., no dead keys)': 'FR (Oss Nodeadkeys)',
'French (alt.)': 'FR (Oss)',
'French (AZERTY, AFNOR)': 'FR (Afnor)',
'French (AZERTY)': 'FR (Azerty)',
'French (BEPO, AFNOR)': 'FR (Bepo Afnor)',
'French (BEPO, Latin-9 only)': 'FR (Bepo Latin9)',
'French (BEPO)': 'FR (Bepo)',
'French (Cameroon)': 'CM (French)',
'French (Canada, Dvorak)': 'CA (Fr-Dvorak)',
'French (Canada, legacy)': 'CA (Fr-Legacy)',
'French (Canada)': 'CA',
'French (Democratic Republic of the Congo)': 'CD',
'French (Dvorak)': 'FR (Dvorak)',
'French (legacy, alt., no dead keys)': 'FR (Latin9 Nodeadkeys)',
'French (legacy, alt.)': 'FR (Latin9)',
'French (Macintosh)': 'FR (Mac)',
'French (Mali, alt.)': 'ML (Fr-Oss)',
'French (Morocco)': 'MA (French)',
'French (no dead keys)': 'FR (Nodeadkeys)',
'French (Switzerland, Macintosh)': 'CH (Fr Mac)',
'French (Switzerland, no dead keys)': 'CH (Fr Nodeadkeys)',
'French (Switzerland)': 'CH (Fr)',
'French (Togo)': 'TG',
'French (US)': 'FR (Us)',
'Friulian (Italy)': 'IT (Fur)',
Fula: 'GH (Fula)',
Ga: 'GH (Ga)',
Georgian: 'GE',
'Georgian (ergonomic)': 'GE (Ergonomic)',
'Georgian (France, AZERTY Tskapo)': 'FR (Geo)',
'Georgian (Italy)': 'IT (Geo)',
'Georgian (MESS)': 'GE (Mess)',
German: 'DE',
'German (Austria, Macintosh)': 'AT (Mac)',
'German (Austria, no dead keys)': 'AT (Nodeadkeys)',
'German (Austria)': 'AT',
'German (dead acute)': 'DE (Deadacute)',
'German (dead grave acute)': 'DE (Deadgraveacute)',
'German (dead tilde)': 'DE (Deadtilde)',
'German (Dvorak)': 'DE (Dvorak)',
'German (E1)': 'DE (E1)',
'German (E2)': 'DE (E2)',
'German (Macintosh, no dead keys)': 'DE (Mac Nodeadkeys)',
'German (Macintosh)': 'DE (Mac)',
'German (Neo 2)': 'DE (Neo)',
'German (no dead keys)': 'DE (Nodeadkeys)',
'German (QWERTY)': 'DE (Qwerty)',
'German (Switzerland, legacy)': 'CH (Legacy)',
'German (Switzerland, Macintosh)': 'CH (De Mac)',
'German (Switzerland, no dead keys)': 'CH (De Nodeadkeys)',
'German (Switzerland)': 'CH',
'German (T3)': 'DE (T3)',
'German (US)': 'DE (Us)',
Greek: 'GR',
'Greek (extended)': 'GR (Extended)',
'Greek (no dead keys)': 'GR (Nodeadkeys)',
'Greek (polytonic)': 'GR (Polytonic)',
'Greek (simple)': 'GR (Simple)',
Gujarati: 'IN (Guj)',
'Hanyu Pinyin Letters (with AltGr dead keys)': 'CN (Altgr-Pinyin)',
'Hausa (Ghana)': 'GH (Hausa)',
'Hausa (Nigeria)': 'NG (Hausa)',
Hawaiian: 'US (Haw)',
Hebrew: 'IL',
'Hebrew (Biblical, Tiro)': 'IL (Biblical)',
'Hebrew (lyx)': 'IL (Lyx)',
'Hebrew (phonetic)': 'IL (Phonetic)',
'Hindi (Bolnagri)': 'IN (Bolnagri)',
'Hindi (KaGaPa, phonetic)': 'IN (Hin-Kagapa)',
'Hindi (Wx)': 'IN (Hin-Wx)',
Hungarian: 'HU',
'Hungarian (no dead keys)': 'HU (Nodeadkeys)',
'Hungarian (QWERTY, 101-key, comma, dead keys)': 'HU (101 Qwerty Comma Dead)',
'Hungarian (QWERTY, 101-key, comma, no dead keys)': 'HU (101 Qwerty Comma Nodead)',
'Hungarian (QWERTY, 101-key, dot, dead keys)': 'HU (101 Qwerty Dot Dead)',
'Hungarian (QWERTY, 101-key, dot, no dead keys)': 'HU (101 Qwerty Dot Nodead)',
'Hungarian (QWERTY, 102-key, comma, dead keys)': 'HU (102 Qwerty Comma Dead)',
'Hungarian (QWERTY, 102-key, comma, no dead keys)': 'HU (102 Qwerty Comma Nodead)',
'Hungarian (QWERTY, 102-key, dot, dead keys)': 'HU (102 Qwerty Dot Dead)',
'Hungarian (QWERTY, 102-key, dot, no dead keys)': 'HU (102 Qwerty Dot Nodead)',
'Hungarian (QWERTY)': 'HU (Qwerty)',
'Hungarian (QWERTZ, 101-key, comma, dead keys)': 'HU (101 Qwertz Comma Dead)',
'Hungarian (QWERTZ, 101-key, comma, no dead keys)': 'HU (101 Qwertz Comma Nodead)',
'Hungarian (QWERTZ, 101-key, dot, dead keys)': 'HU (101 Qwertz Dot Dead)',
'Hungarian (QWERTZ, 101-key, dot, no dead keys)': 'HU (101 Qwertz Dot Nodead)',
'Hungarian (QWERTZ, 102-key, comma, dead keys)': 'HU (102 Qwertz Comma Dead)',
'Hungarian (QWERTZ, 102-key, comma, no dead keys)': 'HU (102 Qwertz Comma Nodead)',
'Hungarian (QWERTZ, 102-key, dot, dead keys)': 'HU (102 Qwertz Dot Dead)',
'Hungarian (QWERTZ, 102-key, dot, no dead keys)': 'HU (102 Qwertz Dot Nodead)',
'Hungarian (standard)': 'HU (Standard)',
Icelandic: 'IS',
'Icelandic (Dvorak)': 'IS (Dvorak)',
'Icelandic (Macintosh, legacy)': 'IS (Mac Legacy)',
'Icelandic (Macintosh)': 'IS (Mac)',
Igbo: 'NG (Igbo)',
Indian: 'IN',
'Indic IPA': 'IN (Iipa)',
'Indonesian (Arab Melayu, extended phonetic)': 'ID (Melayu-Phoneticx)',
'Indonesian (Arab Melayu, phonetic)': 'ID (Melayu-Phonetic)',
'Indonesian (Arab Pegon, phonetic)': 'ID (Pegon-Phonetic)',
'Indonesian (Latin)': 'ID',
Inuktitut: 'CA (Ike)',
Iraqi: 'IQ',
Irish: 'IE',
'Irish (UnicodeExpert)': 'IE (UnicodeExpert)',
Italian: 'IT',
'Italian (IBM 142)': 'IT (Ibm)',
'Italian (intl., with dead keys)': 'IT (Intl)',
'Italian (Macintosh)': 'IT (Mac)',
'Italian (no dead keys)': 'IT (Nodeadkeys)',
'Italian (US)': 'IT (Us)',
'Italian (Windows)': 'IT (Winkeys)',
Japanese: 'JP',
'Japanese (Dvorak)': 'JP (Dvorak)',
'Japanese (Kana 86)': 'JP (Kana86)',
'Japanese (Kana)': 'JP (Kana)',
'Japanese (Macintosh)': 'JP (Mac)',
'Japanese (OADG 109A)': 'JP (OADG109A)',
Javanese: 'ID (Javanese)',
'Kabyle (AZERTY, with dead keys)': 'DZ (Azerty-Deadkeys)',
'Kabyle (QWERTY, UK, with dead keys)': 'DZ (Qwerty-Gb-Deadkeys)',
'Kabyle (QWERTY, US, with dead keys)': 'DZ (Qwerty-Us-Deadkeys)',
Kalmyk: 'RU (Xal)',
Kannada: 'IN (Kan)',
'Kannada (KaGaPa, phonetic)': 'IN (Kan-Kagapa)',
Kashubian: 'PL (Csb)',
Kazakh: 'KZ',
'Kazakh (extended)': 'KZ (Ext)',
'Kazakh (Latin)': 'KZ (Latin)',
'Kazakh (with Russian)': 'KZ (Kazrus)',
'Khmer (Cambodia)': 'KH',
Kikuyu: 'KE (Kik)',
Komi: 'RU (Kom)',
Korean: 'KR',
'Korean (101/104-key compatible)': 'KR (Kr104)',
'Kurdish (Iran, Arabic-Latin)': 'IR (Ku Ara)',
'Kurdish (Iran, F)': 'IR (Ku F)',
'Kurdish (Iran, Latin Alt-Q)': 'IR (Ku Alt)',
'Kurdish (Iran, Latin Q)': 'IR (Ku)',
'Kurdish (Iraq, Arabic-Latin)': 'IQ (Ku Ara)',
'Kurdish (Iraq, F)': 'IQ (Ku F)',
'Kurdish (Iraq, Latin Alt-Q)': 'IQ (Ku Alt)',
'Kurdish (Iraq, Latin Q)': 'IQ (Ku)',
'Kurdish (Syria, F)': 'SY (Ku F)',
'Kurdish (Syria, Latin Alt-Q)': 'SY (Ku Alt)',
'Kurdish (Syria, Latin Q)': 'SY (Ku)',
'Kurdish (Turkey, F)': 'TR (Ku F)',
'Kurdish (Turkey, Latin Alt-Q)': 'TR (Ku Alt)',
'Kurdish (Turkey, Latin Q)': 'TR (Ku)',
Kyrgyz: 'KG',
'Kyrgyz (phonetic)': 'KG (Phonetic)',
Lao: 'LA',
'Lao (STEA)': 'LA (Stea)',
Latvian: 'LV',
'Latvian (adapted)': 'LV (Adapted)',
'Latvian (apostrophe)': 'LV (Apostrophe)',
'Latvian (ergonomic, ŪGJRMV)': 'LV (Ergonomic)',
'Latvian (F)': 'LV (Fkey)',
'Latvian (modern)': 'LV (Modern)',
'Latvian (tilde)': 'LV (Tilde)',
Lithuanian: 'LT',
'Lithuanian (IBM LST 1205-92)': 'LT (Ibm)',
'Lithuanian (LEKP)': 'LT (Lekp)',
'Lithuanian (LEKPa)': 'LT (Lekpa)',
'Lithuanian (Ratise)': 'LT (Ratise)',
'Lithuanian (standard)': 'LT (Std)',
'Lithuanian (US)': 'LT (Us)',
'Lower Sorbian': 'DE (Dsb)',
'Lower Sorbian (QWERTZ)': 'DE (Dsb Qwertz)',
Macedonian: 'MK',
'Macedonian (no dead keys)': 'MK (Nodeadkeys)',
'Malay (Jawi, Arabic Keyboard)': 'MY',
'Malay (Jawi, phonetic)': 'MY (Phonetic)',
Malayalam: 'IN (Mal)',
'Malayalam (enhanced InScript, with rupee)': 'IN (Mal Enhanced)',
'Malayalam (Lalitha)': 'IN (Mal Lalitha)',
Maltese: 'MT',
'Maltese (UK, with AltGr overrides)': 'MT (Alt-Gb)',
'Maltese (US, with AltGr overrides)': 'MT (Alt-Us)',
'Maltese (US)': 'MT (Us)',
'Manipuri (Eeyek)': 'IN (Eeyek)',
Maori: 'MAO',
'Marathi (enhanced InScript)': 'IN (Marathi)',
'Marathi (KaGaPa, phonetic)': 'IN (Mar-Kagapa)',
Mari: 'RU (Chm)',
Mmuock: 'CM (Mmuock)',
Moldavian: 'MD',
'Moldavian (Gagauz)': 'MD (Gag)',
Mon: 'MM (Mnw)',
'Mon (A1)': 'MM (Mnw-A1)',
Mongolian: 'MN',
'Mongolian (Bichig)': 'CN (Mon Trad)',
'Mongolian (Galik)': 'CN (Mon Trad Galik)',
'Mongolian (Manchu Galik)': 'CN (Mon Manchu Galik)',
'Mongolian (Manchu)': 'CN (Mon Trad Manchu)',
'Mongolian (Todo Galik)': 'CN (Mon Todo Galik)',
'Mongolian (Todo)': 'CN (Mon Trad Todo)',
'Mongolian (Xibe)': 'CN (Mon Trad Xibe)',
Montenegrin: 'ME',
'Montenegrin (Cyrillic, with guillemets)': 'ME (Cyrillicalternatequotes)',
'Montenegrin (Cyrillic, ZE and ZHE swapped)': 'ME (Cyrillicyz)',
'Montenegrin (Cyrillic)': 'ME (Cyrillic)',
'Montenegrin (Latin, QWERTY)': 'ME (Latinyz)',
'Montenegrin (Latin, Unicode, QWERTY)': 'ME (Latinunicodeyz)',
'Montenegrin (Latin, Unicode)': 'ME (Latinunicode)',
'Montenegrin (Latin, with guillemets)': 'ME (Latinalternatequotes)',
"N'Ko (AZERTY)": 'GN',
Nepali: 'NP',
'Northern Saami (Finland)': 'FI (Smi)',
'Northern Saami (Norway, no dead keys)': 'NO (Smi Nodeadkeys)',
'Northern Saami (Norway)': 'NO (Smi)',
'Northern Saami (Sweden)': 'SE (Smi)',
Norwegian: 'NO',
'Norwegian (Colemak)': 'NO (Colemak)',
'Norwegian (Dvorak)': 'NO (Dvorak)',
'Norwegian (Macintosh, no dead keys)': 'NO (Mac Nodeadkeys)',
'Norwegian (Macintosh)': 'NO (Mac)',
'Norwegian (no dead keys)': 'NO (Nodeadkeys)',
'Norwegian (Windows)': 'NO (Winkeys)',
Occitan: 'FR (Oci)',
Ogham: 'IE (Ogam)',
'Ogham (IS434)': 'IE (Ogam Is434)',
'Ol Chiki': 'IN (Olck)',
'Old Turkic': 'TR (Otk)',
'Old Turkic (F)': 'TR (Otkf)',
Oriya: 'IN (Ori)',
'Oriya (Bolnagri)': 'IN (Ori-Bolnagri)',
'Oriya (Wx)': 'IN (Ori-Wx)',
'Ossetian (Georgia)': 'GE (Os)',
'Ossetian (legacy)': 'RU (Os Legacy)',
'Ossetian (Windows)': 'RU (Os Winkeys)',
'Ottoman (F)': 'TR (Otf)',
'Ottoman (Q)': 'TR (Ot)',
'Pannonian Rusyn': 'RS (Rue)',
Pashto: 'AF (Ps)',
'Pashto (Afghanistan, OLPC)': 'AF (Ps-Olpc)',
Persian: 'IR',
'Persian (with Persian keypad)': 'IR (Pes Keypad)',
Polish: 'PL',
'Polish (British keyboard)': 'GB (Pl)',
'Polish (Dvorak, with Polish quotes on key 1)': 'PL (Dvorak Altquotes)',
'Polish (Dvorak, with Polish quotes on quotemark key)': 'PL (Dvorak Quotes)',
'Polish (Dvorak)': 'PL (Dvorak)',
'Polish (legacy)': 'PL (Legacy)',
'Polish (programmer Dvorak)': 'PL (Dvp)',
'Polish (QWERTZ)': 'PL (Qwertz)',
Portuguese: 'PT',
'Portuguese (Brazil, Dvorak)': 'BR (Dvorak)',
'Portuguese (Brazil, IBM/Lenovo ThinkPad)': 'BR (Thinkpad)',
'Portuguese (Brazil, Nativo for US keyboards)': 'BR (Nativo-Us)',
'Portuguese (Brazil, Nativo)': 'BR (Nativo)',
'Portuguese (Brazil, no dead keys)': 'BR (Nodeadkeys)',
'Portuguese (Brazil)': 'BR',
'Portuguese (Macintosh, no dead keys)': 'PT (Mac Nodeadkeys)',
'Portuguese (Macintosh)': 'PT (Mac)',
'Portuguese (Nativo for US keyboards)': 'PT (Nativo-Us)',
'Portuguese (Nativo)': 'PT (Nativo)',
'Portuguese (no dead keys)': 'PT (Nodeadkeys)',
'Punjabi (Gurmukhi Jhelum)': 'IN (Jhelum)',
'Punjabi (Gurmukhi)': 'IN (Guru)',
Romanian: 'RO',
'Romanian (Germany, no dead keys)': 'DE (Ro Nodeadkeys)',
'Romanian (Germany)': 'DE (Ro)',
'Romanian (standard)': 'RO (Std)',
'Romanian (Windows)': 'RO (Winkeys)',
Russian: 'RU',
'Russian (Belarus)': 'BY (Ru)',
'Russian (Czech, phonetic)': 'CZ (Rus)',
'Russian (DOS)': 'RU (Dos)',
'Russian (engineering, EN)': 'RU (Ruchey En)',
'Russian (engineering, RU)': 'RU (Ruchey Ru)',
'Russian (Georgia)': 'GE (Ru)',
'Russian (Germany, phonetic)': 'DE (Ru)',
'Russian (Kazakhstan, with Kazakh)': 'KZ (Ruskaz)',
'Russian (legacy)': 'RU (Legacy)',
'Russian (Macintosh)': 'RU (Mac)',
'Russian (phonetic, AZERTY)': 'RU (Phonetic Azerty)',
'Russian (phonetic, Dvorak)': 'RU (Phonetic Dvorak)',
'Russian (phonetic, French)': 'RU (Phonetic Fr)',
'Russian (phonetic, Windows)': 'RU (Phonetic Winkeys)',
'Russian (phonetic, YAZHERTY)': 'RU (Phonetic YAZHERTY)',
'Russian (phonetic)': 'RU (Phonetic)',
'Russian (Poland, phonetic Dvorak)': 'PL (Ru Phonetic Dvorak)',
'Russian (Sweden, phonetic, no dead keys)': 'SE (Rus Nodeadkeys)',
'Russian (Sweden, phonetic)': 'SE (Rus)',
'Russian (typewriter, legacy)': 'RU (Typewriter-Legacy)',
'Russian (typewriter)': 'RU (Typewriter)',
'Russian (Ukraine, standard RSTU)': 'UA (Rstu Ru)',
'Russian (US, phonetic)': 'US (Rus)',
'Saisiyat (Taiwan)': 'TW (Saisiyat)',
Samogitian: 'LT (Sgs)',
'Sanskrit (KaGaPa, phonetic)': 'IN (San-Kagapa)',
'Scottish Gaelic': 'GB (Gla)',
Serbian: 'RS',
'Serbian (Cyrillic, with guillemets)': 'RS (Alternatequotes)',
'Serbian (Cyrillic, ZE and ZHE swapped)': 'RS (Yz)',
'Serbian (Latin, QWERTY)': 'RS (Latinyz)',
'Serbian (Latin, Unicode, QWERTY)': 'RS (Latinunicodeyz)',
'Serbian (Latin, Unicode)': 'RS (Latinunicode)',
'Serbian (Latin, with guillemets)': 'RS (Latinalternatequotes)',
'Serbian (Latin)': 'RS (Latin)',
'Serbian (Russia)': 'RU (Srp)',
'Serbo-Croatian (US)': 'US (Hbs)',
Shan: 'MM (Shn)',
'Shan (Zawgyi Tai)': 'MM (Zgt)',
Sicilian: 'IT (Scn)',
Silesian: 'PL (Szl)',
Sindhi: 'PK (Snd)',
'Sinhala (phonetic)': 'LK',
'Sinhala (US)': 'LK (Us)',
Slovak: 'SK',
'Slovak (extended backslash)': 'SK (Bksl)',
'Slovak (QWERTY, extended backslash)': 'SK (Qwerty Bksl)',
'Slovak (QWERTY)': 'SK (Qwerty)',
Slovenian: 'SI',
'Slovenian (US)': 'SI (Us)',
'Slovenian (with guillemets)': 'SI (Alternatequotes)',
Spanish: 'ES',
'Spanish (dead tilde)': 'ES (Deadtilde)',
'Spanish (Dvorak)': 'ES (Dvorak)',
'Spanish (Latin American, Colemak)': 'LATAM (Colemak)',
'Spanish (Latin American, dead tilde)': 'LATAM (Deadtilde)',
'Spanish (Latin American, Dvorak)': 'LATAM (Dvorak)',
'Spanish (Latin American, no dead keys)': 'LATAM (Nodeadkeys)',
'Spanish (Latin American)': 'LATAM',
'Spanish (Macintosh)': 'ES (Mac)',
'Spanish (no dead keys)': 'ES (Nodeadkeys)',
'Spanish (Windows)': 'ES (Winkeys)',
'Swahili (Kenya)': 'KE',
'Swahili (Tanzania)': 'TZ',
Swedish: 'SE',
'Swedish (Dvorak, intl.)': 'SE (Us Dvorak)',
'Swedish (Dvorak)': 'SE (Dvorak)',
'Swedish (Macintosh)': 'SE (Mac)',
'Swedish (no dead keys)': 'SE (Nodeadkeys)',
'Swedish (Svdvorak)': 'SE (Svdvorak)',
'Swedish (US)': 'SE (Us)',
'Swedish Sign Language': 'SE (Swl)',
Syriac: 'SY (Syc)',
'Syriac (phonetic)': 'SY (Syc Phonetic)',
Taiwanese: 'TW',
'Taiwanese (indigenous)': 'TW (Indigenous)',
Tajik: 'TJ',
'Tajik (legacy)': 'TJ (Legacy)',
'Tamil (InScript, with Arabic numerals)': 'IN (Tam)',
'Tamil (InScript, with Tamil numerals)': 'IN (Tam Tamilnumbers)',
"Tamil (Sri Lanka, TamilNet '99, TAB encoding)": 'LK (Tam TAB)',
"Tamil (Sri Lanka, TamilNet '99)": 'LK (Tam Unicode)',
"Tamil (TamilNet '99 with Tamil numerals)": 'IN (Tamilnet Tamilnumbers)',
"Tamil (TamilNet '99, TAB encoding)": 'IN (Tamilnet TAB)',
"Tamil (TamilNet '99, TSCII encoding)": 'IN (Tamilnet TSCII)',
"Tamil (TamilNet '99)": 'IN (Tamilnet)',
Tarifit: 'MA (Rif)',
Tatar: 'RU (Tt)',
Telugu: 'IN (Tel)',
'Telugu (KaGaPa, phonetic)': 'IN (Tel-Kagapa)',
'Telugu (Sarala)': 'IN (Tel-Sarala)',
Thai: 'TH',
'Thai (Pattachote)': 'TH (Pat)',
'Thai (TIS-820.2538)': 'TH (Tis)',
Tibetan: 'CN (Tib)',
'Tibetan (with ASCII numerals)': 'CN (Tib Asciinum)',
Tswana: 'BW',
Turkish: 'TR',
'Turkish (Alt-Q)': 'TR (Alt)',
'Turkish (E)': 'TR (E)',
'Turkish (F)': 'TR (F)',
'Turkish (Germany)': 'DE (Tr)',
'Turkish (intl., with dead keys)': 'TR (Intl)',
Turkmen: 'TM',
'Turkmen (Alt-Q)': 'TM (Alt)',
Udmurt: 'RU (Udm)',
Ukrainian: 'UA',
'Ukrainian (homophonic)': 'UA (Homophonic)',
'Ukrainian (legacy)': 'UA (Legacy)',
'Ukrainian (macOS)': 'UA (MacOS)',
'Ukrainian (phonetic)': 'UA (Phonetic)',
'Ukrainian (standard RSTU)': 'UA (Rstu)',
'Ukrainian (typewriter)': 'UA (Typewriter)',
'Ukrainian (Windows)': 'UA (Winkeys)',
'Urdu (alt. phonetic)': 'IN (Urd-Phonetic3)',
'Urdu (Pakistan, CRULP)': 'PK (Urd-Crulp)',
'Urdu (Pakistan, NLA)': 'PK (Urd-Nla)',
'Urdu (Pakistan)': 'PK',
'Urdu (phonetic)': 'IN (Urd-Phonetic)',
'Urdu (Windows)': 'IN (Urd-Winkeys)',
Uyghur: 'CN (Ug)',
Uzbek: 'UZ',
'Uzbek (Afghanistan, OLPC)': 'AF (Uz-Olpc)',
'Uzbek (Afghanistan)': 'AF (Uz)',
'Uzbek (Latin)': 'UZ (Latin)',
Vietnamese: 'VN',
'Vietnamese (France)': 'VN (Fr)',
'Vietnamese (US)': 'VN (Us)',
Wolof: 'SN',
Yakut: 'RU (Sah)',
Yoruba: 'NG (Yoruba)',
'Unknown Layout': 'Unknown',
} as const;

View File

@@ -0,0 +1,70 @@
import { hyprlandService } from 'src/lib/constants/services';
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { getKeyboardLayout } from './helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import { bind, execAsync } from 'astal';
import { useHook } from 'src/lib/shared/hookHandler';
import { Astal } from 'astal/gtk3';
const { label, labelType, icon, leftClick, rightClick, middleClick, scrollUp, scrollDown } =
options.bar.customModules.kbLayout;
export const KbInput = (): BarBoxChild => {
const keyboardModule = Module({
textIcon: bind(icon),
tooltipText: '',
labelHook: (self: Astal.Label): void => {
useHook(
self,
hyprlandService,
() => {
execAsync('hyprctl devices -j')
.then((obj) => {
self.label = getKeyboardLayout(obj, labelType.get());
})
.catch((err) => {
console.error(err);
});
},
'keyboard-layout',
);
useHook(self, labelType, () => {
execAsync('hyprctl devices -j')
.then((obj) => {
self.label = getKeyboardLayout(obj, labelType.get());
})
.catch((err) => {
console.error(err);
});
});
},
boxClass: 'kblayout',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
},
});
return keyboardModule;
};

View File

@@ -0,0 +1,124 @@
import { MediaTags } from 'src/lib/types/audio.js';
import { Opt } from 'src/lib/option';
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { Variable } from 'astal';
/**
* Retrieves the icon for a given media player.
*
* This function returns the appropriate icon for the provided media player name based on a predefined mapping.
* If no match is found, it returns a default icon.
*
* @param playerName The name of the media player.
*
* @returns The icon for the media player as a string.
*/
const getIconForPlayer = (playerName: string): string => {
const windowTitleMap = [
['Firefox', '󰈹'],
['Microsoft Edge', '󰇩'],
['Discord', ''],
['Plex', '󰚺'],
['Spotify', '󰓇'],
['Vlc', '󰕼'],
['Mpv', ''],
['Rhythmbox', '󰓃'],
['Google Chrome', ''],
['Brave Browser', '󰖟'],
['Chromium', ''],
['Opera', ''],
['Vivaldi', '󰖟'],
['Waterfox', '󰈹'],
['Thorium', '󰈹'],
['Zen Browser', '󰈹'],
['Floorp', '󰈹'],
['(.*)', '󰝚'],
];
const foundMatch = windowTitleMap.find((wt) => RegExp(wt[0], 'i').test(playerName));
return foundMatch ? foundMatch[1] : '󰝚';
};
/**
* Checks if a given tag is a valid media tag.
*
* This function determines whether the provided tag is a valid media tag by checking it against a predefined list of media tag keys.
*
* @param tag The tag to check.
*
* @returns True if the tag is a valid media tag, false otherwise.
*/
const isValidMediaTag = (tag: unknown): tag is keyof MediaTags => {
if (typeof tag !== 'string') {
return false;
}
const mediaTagKeys = ['title', 'artists', 'artist', 'album', 'name', 'identity'] as const;
return (mediaTagKeys as readonly string[]).includes(tag);
};
/**
* Generates a media label based on the provided options.
*
* This function creates a media label string by formatting the media tags according to the specified format.
* It truncates the label if it exceeds the specified truncation size and returns a default label if no media is playing.
*
* @param truncation_size The maximum size of the label before truncation.
* @param show_label A boolean indicating whether to show the label.
* @param format The format string for the media label.
* @param songIcon A variable to store the icon for the current song.
* @param activePlayer A variable representing the active media player.
*
* @returns The generated media label as a string.
*/
export const generateMediaLabel = (
truncation_size: Opt<number>,
show_label: Opt<boolean>,
format: Opt<string>,
songIcon: Variable<string>,
activePlayer: Variable<AstalMpris.Player | undefined>,
): string => {
const currentPlayer = activePlayer.get();
if (!currentPlayer || !show_label.get()) {
songIcon.set(getIconForPlayer(activePlayer.get()?.identity || ''));
return `Media`;
}
const { title, identity, artist, album, busName } = currentPlayer;
songIcon.set(getIconForPlayer(identity));
const mediaTags: MediaTags = {
title: title,
artists: artist,
artist: artist,
album: album,
name: busName,
identity: identity,
};
const mediaFormat = format.get();
const truncatedLabel = mediaFormat.replace(
/{(title|artists|artist|album|name|identity)(:[^}]*)?}/g,
(_, p1: string | undefined, p2: string | undefined) => {
if (!isValidMediaTag(p1)) {
return '';
}
const value = p1 !== undefined ? mediaTags[p1] : '';
const suffix = p2?.length ? p2.slice(1) : '';
return value ? value + suffix : '';
},
);
const maxLabelSize = truncation_size.get();
let mediaLabel = truncatedLabel;
if (maxLabelSize > 0 && truncatedLabel.length > maxLabelSize) {
mediaLabel = `${truncatedLabel.substring(0, maxLabelSize)}...`;
}
return mediaLabel.length ? mediaLabel : 'Media';
};

View File

@@ -0,0 +1,97 @@
import { openMenu } from '../../utils/menu.js';
import options from 'src/options.js';
import { runAsyncCommand } from 'src/components/bar/utils/helpers.js';
import { generateMediaLabel } from './helpers/index.js';
import { useHook } from 'src/lib/shared/hookHandler.js';
import { mprisService } from 'src/lib/constants/services.js';
import Variable from 'astal/variable.js';
import { onMiddleClick, onPrimaryClick, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import { bind } from 'astal/binding.js';
import { BarBoxChild } from 'src/lib/types/bar.js';
import { Astal } from 'astal/gtk3';
import { activePlayer, mediaAlbum, mediaArtist, mediaTitle } from 'src/globals/media.js';
const { truncation, truncation_size, show_label, show_active_only, rightClick, middleClick, format } =
options.bar.media;
const Media = (): BarBoxChild => {
const isVis = Variable(!show_active_only.get());
show_active_only.subscribe(() => {
isVis.set(!show_active_only.get() || mprisService.get_players().length > 0);
});
const songIcon = Variable('');
const mediaLabel = Variable.derive(
[
bind(activePlayer),
bind(truncation),
bind(truncation_size),
bind(show_label),
bind(format),
bind(mediaTitle),
bind(mediaAlbum),
bind(mediaArtist),
],
() => {
return generateMediaLabel(truncation_size, show_label, format, songIcon, activePlayer);
},
);
const componentClassName = Variable.derive([options.theme.bar.buttons.style, show_label], (style: string) => {
const styleMap: Record<string, string> = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `media-container ${styleMap[style]}`;
});
const component = (
<box
className={componentClassName()}
onDestroy={() => {
isVis.drop();
songIcon.drop();
mediaLabel.drop();
componentClassName.drop();
}}
>
<label className={'bar-button-icon media txt-icon bar'} label={bind(songIcon).as((icn) => icn || '󰝚')} />
<label className={'bar-button-label media'} label={mediaLabel()} />
</box>
);
return {
component,
isVis,
boxClass: 'media',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'mediamenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
};
});
},
},
};
};
export { Media };

View File

@@ -0,0 +1,75 @@
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 { bind } from 'astal/binding.js';
import Variable from 'astal/variable.js';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import { useHook } from 'src/lib/shared/hookHandler.js'; // Ensure correct import
import { BarBoxChild } from 'src/lib/types/bar.js';
import { Astal } from 'astal/gtk3';
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,
);
const componentClassName = bind(options.theme.bar.buttons.style).as((style: string) => {
const styleMap: Record<string, string> = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `dashboard ${styleMap[style]}`;
});
const component = (
<box
className={componentClassName}
onDestroy={() => {
iconBinding.drop();
}}
>
<label className={'bar-menu_label bar-button_icon txt-icon bar'} label={iconBinding()} />
</box>
);
return {
component,
isVisible: true,
boxClass: 'dashboard',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'dashboardmenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
},
};
};
export { Menu };

View File

@@ -0,0 +1,164 @@
import GLib from 'gi://GLib';
import { Variable } from 'astal';
import { NetworkResourceData } from 'src/lib/types/customModules/network';
import { GET_DEFAULT_NETSTAT_DATA } from 'src/lib/types/defaults/netstat';
import { RateUnit } from 'src/lib/types/bar';
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');
if (!success) {
console.error('Failed to read /proc/net/dev');
return { name: '', rx: 0, tx: 0 };
}
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!;
}
}
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 ? interfaceNameVar.get() : '';
const DEFAULT_NETSTAT_DATA = GET_DEFAULT_NETSTAT_DATA(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

@@ -0,0 +1,124 @@
import { networkService } from 'src/lib/constants/services';
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { computeNetwork } from './helpers';
import { BarBoxChild, NetstatLabelType, RateUnit } from 'src/lib/types/bar';
import { NetworkResourceData } from 'src/lib/types/customModules/network';
import { NETWORK_LABEL_TYPES } from 'src/lib/types/defaults/bar';
import { GET_DEFAULT_NETSTAT_DATA } from 'src/lib/types/defaults/netstat';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { bind, Variable } from 'astal';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { Astal } from 'astal/gtk3';
const {
label,
labelType,
networkInterface,
rateUnit,
dynamicIcon,
icon,
round,
leftClick,
rightClick,
middleClick,
pollingInterval,
} = options.bar.customModules.netstat;
export const networkUsage = Variable<NetworkResourceData>(GET_DEFAULT_NETSTAT_DATA(rateUnit.get()));
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');
export const Netstat = (): BarBoxChild => {
const renderNetworkLabel = (lblType: NetstatLabelType, networkService: NetworkResourceData): string => {
switch (lblType) {
case 'in':
return `${networkService.in}`;
case 'out':
return `${networkService.out}`;
default:
return `${networkService.in}${networkService.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;
}
return wfi?.icon_name;
},
);
const labelBinding = Variable.derive(
[bind(networkUsage), bind(labelType)],
(networkService: NetworkResourceData, lblTyp: NetstatLabelType) => renderNetworkLabel(lblTyp, networkService),
);
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';
}),
boxClass: 'netstat',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
fn: () => {
labelType.set(
NETWORK_LABEL_TYPES[
(NETWORK_LABEL_TYPES.indexOf(labelType.get()) + 1) % NETWORK_LABEL_TYPES.length
] as NetstatLabelType,
);
},
},
onScrollDown: {
fn: () => {
labelType.set(
NETWORK_LABEL_TYPES[
(NETWORK_LABEL_TYPES.indexOf(labelType.get()) - 1 + NETWORK_LABEL_TYPES.length) %
NETWORK_LABEL_TYPES.length
] as NetstatLabelType,
);
},
},
});
},
onDestroy: () => {
labelBinding.drop();
iconBinding.drop();
},
},
});
return netstatModule;
};

View File

@@ -0,0 +1,87 @@
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { bind, Variable } from 'astal';
import { networkService } from 'src/lib/constants/services';
export const wiredIcon: Variable<string> = Variable('');
export const wirelessIcon: Variable<string> = Variable('');
let wiredIconBinding: Variable<void>;
let wirelessIconBinding: Variable<void>;
/**
* Handles the wired network icon binding.
*
* This function sets up the binding for the wired network icon. It first drops any existing binding,
* then checks if the wired network service is available. If available, it binds the icon name to the `wiredIcon` variable.
*/
const handleWiredIcon = (): void => {
if (wiredIconBinding) {
wiredIconBinding();
wiredIconBinding.drop();
}
if (!networkService.wired) {
return;
}
wiredIconBinding = Variable.derive([bind(networkService.wired, 'iconName')], (icon) => {
wiredIcon.set(icon);
});
};
/**
* Handles the wireless network icon binding.
*
* This function sets up the binding for the wireless network icon. It first drops any existing binding,
* then checks if the wireless network service is available. If available, it binds the icon name to the `wirelessIcon` variable.
*/
const handleWirelessIcon = (): void => {
if (wirelessIconBinding) {
wirelessIconBinding();
wirelessIconBinding.drop();
}
if (!networkService.wifi) {
return;
}
wirelessIconBinding = Variable.derive([bind(networkService.wifi, 'iconName')], (icon) => {
wirelessIcon.set(icon);
});
};
/**
* Formats the frequency value to MHz.
*
* This function takes a frequency value in kHz and formats it to MHz with two decimal places.
*
* @param frequency The frequency value in kHz.
*
* @returns The formatted frequency value in MHz as a string.
*/
const formatFrequency = (frequency: number): string => {
return `${(frequency / 1000).toFixed(2)}MHz`;
};
/**
* Formats the WiFi information for display.
*
* This function takes a WiFi object and formats its SSID, signal strength, and frequency for display.
* If any of these values are not available, it provides default values.
*
* @param wifi The WiFi object containing SSID, signal strength, and frequency information.
*
* @returns A formatted string containing the WiFi information.
*/
export const formatWifiInfo = (wifi: AstalNetwork.Wifi | null): string => {
const netSsid = wifi?.ssid ? wifi.ssid : 'None';
const wifiStrength = wifi?.strength ? wifi.strength : '--';
const wifiFreq = wifi?.frequency ? formatFrequency(wifi.frequency) : '--';
return `Network: ${netSsid} \nSignal Strength: ${wifiStrength}% \nFrequency: ${wifiFreq}`;
};
Variable.derive([bind(networkService, 'wifi')], () => {
handleWiredIcon();
handleWirelessIcon();
});

View File

@@ -0,0 +1,121 @@
import { networkService } from 'src/lib/constants/services.js';
import options from 'src/options';
import { openMenu } from '../../utils/menu';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
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 { useHook } from 'src/lib/shared/hookHandler';
import { BarBoxChild } from 'src/lib/types/bar.js';
import { formatWifiInfo, wiredIcon, wirelessIcon } from './helpers';
const { label, truncation, truncation_size, rightClick, middleClick, scrollDown, scrollUp, showWifiInfo } =
options.bar.network;
const Network = (): BarBoxChild => {
const iconBinding = Variable.derive(
[bind(networkService, 'primary'), bind(wiredIcon), bind(wirelessIcon)],
(primaryNetwork, wiredIcon, wifiIcon) => {
const isWired = primaryNetwork === AstalNetwork.Primary.WIRED;
const iconName = isWired ? wiredIcon : wifiIcon;
return iconName;
},
);
const networkIcon = <icon className={'bar-button-icon network-icon'} icon={iconBinding()} />;
const networkLabel = Variable.derive(
[
bind(networkService, 'primary'),
bind(networkService, 'wifi'),
bind(label),
bind(truncation),
bind(truncation_size),
bind(showWifiInfo),
],
(primaryNetwork, networkWifi, showLabel, trunc, tSize, showWifiInfo) => {
if (!showLabel) {
return <box />;
}
if (primaryNetwork === AstalNetwork.Primary.WIRED) {
return <label className={'bar-button-label network-label'} label={'Wired'.substring(0, tSize)} />;
}
return (
<label
className={'bar-button-label network-label'}
label={
networkWifi?.ssid ? `${trunc ? networkWifi.ssid.substring(0, tSize) : networkWifi.ssid}` : '--'
}
tooltipText={showWifiInfo ? formatWifiInfo(networkWifi) : ''}
/>
);
},
);
const componentClassName = Variable.derive(
[bind(options.theme.bar.buttons.style), bind(options.bar.network.label)],
(style, showLabel) => {
const styleMap = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `network-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
},
);
const componentChildren = [networkIcon, networkLabel()];
const component = (
<box
vexpand
valign={Gtk.Align.FILL}
className={componentClassName()}
onDestroy={() => {
iconBinding.drop();
networkLabel.drop();
componentClassName.drop();
}}
>
{componentChildren}
</box>
);
return {
component,
isVisible: true,
boxClass: 'network',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'networkmenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
},
};
};
export { Network };

View File

@@ -0,0 +1,115 @@
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 { BarBoxChild } from 'src/lib/types/bar.js';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import { bind, Variable } from 'astal';
import { useHook } from 'src/lib/shared/hookHandler';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
const { show_total, rightClick, middleClick, scrollUp, scrollDown, hideCountWhenZero } = options.bar.notifications;
const { ignore } = options.notifications;
const notifs = AstalNotifd.get_default();
export const Notifications = (): BarBoxChild => {
const componentClassName = Variable.derive(
[bind(options.theme.bar.buttons.style), bind(show_total)],
(style: string, showTotal: boolean) => {
const styleMap: Record<string, string> = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `notifications-container ${styleMap[style]} ${!showTotal ? 'no-label' : ''}`;
},
);
const boxChildren = Variable.derive(
[
bind(notifs, 'notifications'),
bind(notifs, 'dontDisturb'),
bind(show_total),
bind(ignore),
bind(hideCountWhenZero),
],
(
notif: AstalNotifd.Notification[],
dnd: boolean,
showTotal: boolean,
ignoredNotifs: string[],
hideCountForZero: boolean,
) => {
const filteredNotifications = filterNotifications(notif, ignoredNotifs);
const notifIcon = (
<label
halign={Gtk.Align.CENTER}
className={'bar-button-icon notifications txt-icon bar'}
label={dnd ? '󰂛' : filteredNotifications.length > 0 ? '󱅫' : '󰂚'}
/>
);
const notifLabel = (
<label
halign={Gtk.Align.CENTER}
className={'bar-button-label notifications'}
label={filteredNotifications.length.toString()}
/>
);
if (showTotal) {
if (hideCountForZero && filteredNotifications.length === 0) {
return [notifIcon];
}
return [notifIcon, notifLabel];
}
return [notifIcon];
},
);
const component = (
<box halign={Gtk.Align.START} className={componentClassName()}>
<box halign={Gtk.Align.START} className={'bar-notifications'}>
{boxChildren()}
</box>
</box>
);
return {
component,
isVisible: true,
boxClass: 'notifications',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'notificationsmenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
},
};
};

View File

@@ -0,0 +1,39 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import { bind } from 'astal';
import { Astal } from 'astal/gtk3';
const { icon, leftClick, rightClick, middleClick, scrollUp, scrollDown } = options.bar.customModules.power;
export const Power = (): BarBoxChild => {
const powerModule = Module({
tooltipText: 'Power Menu',
textIcon: bind(icon),
boxClass: 'powermodule',
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
},
});
return powerModule;
};

View File

@@ -0,0 +1,48 @@
import { divide } from 'src/components/bar/utils/helpers';
import { GenericResourceData } from 'src/lib/types/customModules/generic';
import { GLib, Variable } from 'astal';
/**
* 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) {
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

@@ -0,0 +1,85 @@
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';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { GenericResourceData } from 'src/lib/types/customModules/generic';
import { bind, Variable } from 'astal';
import { BarBoxChild, ResourceLabelType } from 'src/lib/types/bar';
import { Astal } from 'astal/gtk3';
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');
export const Ram = (): BarBoxChild => {
const labelBinding = Variable.derive(
[bind(ramUsage), bind(labelType), bind(round)],
(rmUsg: GenericResourceData, lblTyp: ResourceLabelType, round: boolean) => {
const returnValue = renderResourceLabel(lblTyp, rmUsg, round);
return returnValue;
},
);
const ramModule = Module({
textIcon: bind(icon),
label: labelBinding(),
tooltipText: bind(labelType).as((lblTyp) => {
return formatTooltip('RAM', lblTyp);
}),
boxClass: 'ram',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
fn: () => {
labelType.set(
LABEL_TYPES[
(LABEL_TYPES.indexOf(labelType.get()) + 1) % LABEL_TYPES.length
] as ResourceLabelType,
);
},
},
onScrollDown: {
fn: () => {
labelType.set(
LABEL_TYPES[
(LABEL_TYPES.indexOf(labelType.get()) - 1 + LABEL_TYPES.length) % LABEL_TYPES.length
] as ResourceLabelType,
);
},
},
});
},
onDestroy: () => {
labelBinding.drop();
},
},
});
return ramModule;
};

View File

@@ -0,0 +1,39 @@
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';
/**
* 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,83 @@
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 { BarBoxChild, ResourceLabelType } from 'src/lib/types/bar';
import { GenericResourceData } from 'src/lib/types/customModules/generic';
import { LABEL_TYPES } from 'src/lib/types/defaults/bar';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
const { label, labelType, icon, round, leftClick, rightClick, middleClick, pollingInterval } =
options.bar.customModules.storage;
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,
round,
);
storagePoller.initialize('storage');
export const Storage = (): BarBoxChild => {
const labelBinding = Variable.derive(
[bind(storageUsage), bind(labelType), bind(round)],
(storage, lblTyp, round) => {
return renderResourceLabel(lblTyp, storage, round);
},
);
const storageModule = Module({
textIcon: bind(icon),
label: labelBinding(),
tooltipText: bind(labelType).as((lblTyp) => {
return formatTooltip('Storage', lblTyp);
}),
boxClass: 'storage',
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
fn: () => {
labelType.set(
LABEL_TYPES[
(LABEL_TYPES.indexOf(labelType.get()) + 1) % LABEL_TYPES.length
] as ResourceLabelType,
);
},
},
onScrollDown: {
fn: () => {
labelType.set(
LABEL_TYPES[
(LABEL_TYPES.indexOf(labelType.get()) - 1 + LABEL_TYPES.length) % LABEL_TYPES.length
] as ResourceLabelType,
);
},
},
});
},
onDestroy: () => {
labelBinding.drop();
},
},
});
return storageModule;
};

View File

@@ -0,0 +1,38 @@
import { Variable } from 'astal';
import { hyprlandService } from 'src/lib/constants/services';
/**
* Determines if a submap is enabled based on the provided submap name.
*
* This function checks if the given submap name is not 'default' and returns the appropriate enabled or disabled string.
*
* @param submap The name of the submap to check.
* @param enabled The string to return if the submap is enabled.
* @param disabled The string to return if the submap is disabled.
*
* @returns The enabled string if the submap is not 'default', otherwise the disabled string.
*/
export const isSubmapEnabled = (submap: string, enabled: string, disabled: string): string => {
return submap !== 'default' ? enabled : disabled;
};
/**
* Retrieves the initial submap status and updates the provided variable.
*
* This function gets the initial submap status from the `hyprlandService` and updates the `submapStatus` variable.
* It removes any newline characters from the submap status and sets it to 'default' if the status is 'unknown request'.
*
* @param submapStatus The variable to update with the initial submap status.
*/
export const getInitialSubmap = (submapStatus: Variable<string>): void => {
let submap = hyprlandService.message('submap');
const newLineCarriage = /\n/g;
submap = submap.replace(newLineCarriage, '');
if (submap === 'unknown request') {
submap = 'default';
}
submapStatus.set(submap);
};

View File

@@ -0,0 +1,88 @@
import { hyprlandService } from 'src/lib/constants/services';
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import { capitalizeFirstLetter } from 'src/lib/utils';
import { getInitialSubmap, isSubmapEnabled } from './helpers';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
const {
label,
showSubmapName,
enabledIcon,
disabledIcon,
enabledText,
disabledText,
leftClick,
rightClick,
middleClick,
scrollUp,
scrollDown,
} = options.bar.customModules.submap;
const submapStatus: Variable<string> = Variable('default');
hyprlandService.connect('submap', (_, currentSubmap) => {
if (currentSubmap.length === 0) {
submapStatus.set('default');
} else {
submapStatus.set(currentSubmap);
}
});
getInitialSubmap(submapStatus);
export const Submap = (): BarBoxChild => {
const submapLabel = Variable.derive(
[bind(submapStatus), bind(enabledText), bind(disabledText), bind(showSubmapName)],
(status, enabled, disabled, showSmName) => {
if (showSmName) {
return capitalizeFirstLetter(status);
}
return isSubmapEnabled(status, enabled, disabled);
},
);
const submapIcon = Variable.derive(
[bind(submapStatus), bind(enabledIcon), bind(disabledIcon)],
(status, enabled, disabled) => {
return isSubmapEnabled(status, enabled, disabled);
},
);
const submapModule = Module({
textIcon: submapIcon(),
tooltipText: submapLabel(),
label: submapLabel(),
showLabelBinding: bind(label),
boxClass: 'submap',
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
onDestroy: () => {
submapLabel.drop();
submapIcon.drop();
},
},
});
return submapModule;
};

View File

@@ -0,0 +1,126 @@
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 { BarBoxChild } from 'src/lib/types/bar';
import { Gdk, Gtk } from 'astal/gtk3';
import { BindableChild } from 'astal/gtk3/astalify';
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): Gtk.Menu => {
const menu = Gtk.Menu.new_from_model(menuModel);
menu.insert_action_group('dbusmenu', actionGroup);
return menu;
};
const MenuCustomIcon = ({ iconLabel, iconColor, item }: MenuCustomIconProps): JSX.Element => {
return (
<label
className={'systray-icon txt-icon'}
label={iconLabel}
css={iconColor ? `color: ${iconColor}` : ''}
tooltipMarkup={bind(item, 'tooltipMarkup')}
/>
);
};
const MenuDefaultIcon = ({ item }: MenuEntryProps): JSX.Element => {
return <icon className={'systray-icon'} gIcon={bind(item, 'gicon')} tooltipMarkup={bind(item, 'tooltipMarkup')} />;
};
const MenuEntry = ({ item, child }: MenuEntryProps): JSX.Element => {
const menu = createMenu(item.menuModel, item.actionGroup);
return (
<button
cursor={'pointer'}
onClick={(self, event) => {
if (isPrimaryClick(event)) {
item.activate(0, 0);
}
if (isSecondaryClick(event)) {
menu?.popup_at_widget(self, Gdk.Gravity.NORTH, Gdk.Gravity.SOUTH, null);
}
if (isMiddleClick(event)) {
Notify({ summary: 'App Name', body: item.id });
}
}}
onDestroy={() => menu?.destroy()}
>
{child}
</button>
);
};
const SysTray = (): BarBoxChild => {
const isVis = Variable(false);
const componentChildren = Variable.derive(
[bind(systemtray, 'items'), bind(ignore), bind(customIcons)],
(items, ignored, custIcons) => {
const filteredTray = items.filter(({ id }) => !ignored.includes(id) && id !== null);
isVis.set(filteredTray.length > 0);
return filteredTray.map((item) => {
const matchedCustomIcon = Object.keys(custIcons).find((iconRegex) => item.id.match(iconRegex));
if (matchedCustomIcon !== undefined) {
const iconLabel = custIcons[matchedCustomIcon].icon || '󰠫';
const iconColor = custIcons[matchedCustomIcon].color;
return (
<MenuEntry item={item}>
<MenuCustomIcon iconLabel={iconLabel} iconColor={iconColor} item={item} />
</MenuEntry>
);
}
return (
<MenuEntry item={item}>
<MenuDefaultIcon item={item} />
</MenuEntry>
);
});
},
);
const component = (
<box
className={'systray-container'}
onDestroy={() => {
isVis.drop();
componentChildren.drop();
}}
>
{componentChildren()}
</box>
);
return {
component,
isVisible: true,
boxClass: 'systray',
isVis,
isBox: true,
props: {},
};
};
interface MenuCustomIconProps {
iconLabel: string;
iconColor: string;
item: AstalTray.TrayItem;
}
interface MenuEntryProps {
item: AstalTray.TrayItem;
child?: BindableChild;
}
export { SysTray };

View File

@@ -0,0 +1,81 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import { BashPoller } from 'src/lib/poller/BashPoller';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
const {
updateCommand,
label,
padZero,
pollingInterval,
icon,
leftClick,
rightClick,
middleClick,
scrollUp,
scrollDown,
} = options.bar.customModules.updates;
const pendingUpdates: Variable<string> = Variable('0');
const postInputUpdater = Variable(true);
const processUpdateCount = (updateCount: string): string => {
if (!padZero.get()) return updateCount;
return `${updateCount.padStart(2, '0')}`;
};
const updatesPoller = new BashPoller<string, []>(
pendingUpdates,
[bind(padZero), bind(postInputUpdater)],
bind(pollingInterval),
updateCommand.get(),
processUpdateCount,
);
updatesPoller.initialize('updates');
const updatesIcon = Variable.derive(
[bind(icon.pending), bind(icon.updated), bind(pendingUpdates)],
(pendingIcon, updatedIcon, pUpdates) => {
return pUpdates === '0' ? updatedIcon : pendingIcon;
},
);
export const Updates = (): BarBoxChild => {
const updatesModule = Module({
textIcon: updatesIcon(),
tooltipText: bind(pendingUpdates).as((v) => `${v} updates available`),
boxClass: 'updates',
label: bind(pendingUpdates),
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(
self,
{
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
},
postInputUpdater,
);
},
},
});
return updatesModule;
};

View File

@@ -0,0 +1,32 @@
import { VolumeIcons } from 'src/lib/types/volume';
const icons: VolumeIcons = {
101: '󰕾',
66: '󰕾',
34: '󰖀',
1: '󰕿',
0: '󰝟',
};
/**
* Retrieves the appropriate volume icon based on the volume level and mute status.
*
* This function returns the corresponding volume icon based on the provided volume level and mute status.
* It uses predefined mappings for volume icons.
*
* @param isMuted A boolean indicating whether the volume is muted.
* @param vol The current volume level as a number between 0 and 1.
*
* @returns The corresponding volume icon as a string.
*/
export const getIcon = (isMuted: boolean, vol: number): string => {
if (isMuted) return icons[0];
const foundVol = [101, 66, 34, 1, 0].find((threshold) => threshold <= vol * 100);
if (foundVol !== undefined) {
return icons[foundVol];
}
return icons[101];
};

View File

@@ -0,0 +1,109 @@
import { audioService } from 'src/lib/constants/services.js';
import { openMenu } from '../../utils/menu.js';
import options from 'src/options';
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers.js';
import Variable from 'astal/variable.js';
import { bind } from 'astal/binding.js';
import { useHook } from 'src/lib/shared/hookHandler.js';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers.js';
import { getIcon } from './helpers/index.js';
import { BarBoxChild } from 'src/lib/types/bar.js';
import { Astal } from 'astal/gtk3';
const { rightClick, middleClick, scrollUp, scrollDown } = options.bar.volume;
const Volume = (): BarBoxChild => {
const volumeIcon = (isMuted: boolean, vol: number): JSX.Element => {
return <label className={'bar-button-icon volume txt-icon bar'} label={getIcon(isMuted, vol)} />;
};
const volumeLabel = (vol: number): JSX.Element => {
return <label className={'bar-button-label volume'} label={`${Math.round(vol * 100)}%`} />;
};
const componentTooltip = Variable.derive(
[
bind(audioService.defaultSpeaker, 'description'),
bind(audioService.defaultSpeaker, 'volume'),
bind(audioService.defaultSpeaker, 'mute'),
],
(desc, vol, isMuted) => {
return `${getIcon(isMuted, vol)} ${desc}`;
},
);
const componentClassName = Variable.derive(
[options.theme.bar.buttons.style, options.bar.volume.label],
(style, showLabel) => {
const styleMap = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `volume-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
},
);
const componentChildren = Variable.derive(
[
bind(options.bar.volume.label),
bind(audioService.defaultSpeaker, 'volume'),
bind(audioService.defaultSpeaker, 'mute'),
],
(showLabel, vol, isMuted) => {
if (showLabel) {
return [volumeIcon(isMuted, vol), volumeLabel(vol)];
}
return [volumeIcon(isMuted, vol)];
},
);
const component = (
<box
vexpand
tooltipText={componentTooltip()}
className={componentClassName()}
onDestroy={() => {
componentTooltip.drop();
componentClassName.drop();
componentChildren.drop();
}}
>
{componentChildren()}
</box>
);
return {
component,
isVisible: true,
boxClass: 'volume',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
openMenu(clicked, event, 'audiomenu');
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
},
};
};
export { Volume };

View File

@@ -0,0 +1,59 @@
import options from 'src/options';
import { Module } from '../../shared/Module';
import { inputHandler } from 'src/components/bar/utils/helpers';
import { getWeatherStatusTextIcon, globalWeatherVar } from 'src/globals/weather';
import { BarBoxChild } from 'src/lib/types/bar';
import { bind, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
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 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 weatherModule = Module({
textIcon: iconBinding(),
tooltipText: bind(globalWeatherVar).as((v) => `Weather Status: ${v.current.condition.text}`),
boxClass: 'weather-custom',
label: labelBinding(),
showLabelBinding: bind(label),
props: {
setup: (self: Astal.Button) => {
inputHandler(self, {
onPrimaryClick: {
cmd: leftClick,
},
onSecondaryClick: {
cmd: rightClick,
},
onMiddleClick: {
cmd: middleClick,
},
onScrollUp: {
cmd: scrollUp,
},
onScrollDown: {
cmd: scrollDown,
},
});
},
onDestroy: () => {
iconBinding.drop();
labelBinding.drop();
},
},
});
return weatherModule;
};

View File

@@ -0,0 +1,189 @@
import options from 'src/options';
import { capitalizeFirstLetter } from 'src/lib/utils';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
/**
* Retrieves the matching window title details for a given window.
*
* This function searches for a matching window title in the predefined `windowTitleMap` based on the class of the provided window.
* If a match is found, it returns an object containing the icon and label for the window. If no match is found, it returns a default icon and label.
*
* @param windowtitle The window object containing the class information.
*
* @returns An object containing the icon and label for the window.
*/
export const getWindowMatch = (windowtitle: AstalHyprland.Client): Record<string, string> => {
const windowTitleMap = [
// user provided values
...options.bar.windowtitle.title_map.get(),
// Original Entries
['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'],
// Browsers
['google-chrome', '', 'Google Chrome'],
['brave-browser', '󰖟', 'Brave Browser'],
['chromium', '', 'Chromium'],
['opera', '', 'Opera'],
['vivaldi', '󰖟', 'Vivaldi'],
['waterfox', '󰖟', 'Waterfox'],
['thorium', '󰖟', 'Waterfox'],
['tor-browser', '', 'Tor Browser'],
['floorp', '󰈹', 'Floorp'],
// Terminals
['gnome-terminal', '', 'GNOME Terminal'],
['konsole', '', 'Konsole'],
['alacritty', '', 'Alacritty'],
['wezterm', '', 'Wezterm'],
['foot', '󰽒', 'Foot Terminal'],
['tilix', '', 'Tilix'],
['xterm', '', 'XTerm'],
['urxvt', '', 'URxvt'],
['st', '', 'st Terminal'],
// Development Tools
['code', '󰨞', 'Visual Studio Code'],
['vscode', '󰨞', 'VS Code'],
['sublime-text', '', 'Sublime Text'],
['atom', '', 'Atom'],
['android-studio', '󰀴', 'Android Studio'],
['intellij-idea', '', 'IntelliJ IDEA'],
['pycharm', '󱃖', 'PyCharm'],
['webstorm', '󱃖', 'WebStorm'],
['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'],
// Desktop
['^$', '󰇄', 'Desktop'],
// Fallback icon
['(.+)', '󰣆', `${capitalizeFirstLetter(windowtitle?.class ?? 'Unknown')}`],
];
if (!windowtitle?.class) {
return {
icon: '󰇄',
label: 'Desktop',
};
}
const foundMatch = windowTitleMap.find((wt) => RegExp(wt[0]).test(windowtitle?.class.toLowerCase()));
if (!foundMatch || foundMatch.length !== 3) {
return {
icon: windowTitleMap[windowTitleMap.length - 1][1],
label: windowTitleMap[windowTitleMap.length - 1][2],
};
}
return {
icon: foundMatch[1],
label: foundMatch[2],
};
};
/**
* Retrieves the title for a given window client.
*
* This function returns the title of the window based on the provided client object and options.
* It can use a custom title, the class name, or the actual window title. If the title is empty, it falls back to the class name.
*
* @param client The window client object containing the title and class information.
* @param useCustomTitle A boolean indicating whether to use a custom title.
* @param useClassName A boolean indicating whether to use the class name as the title.
*
* @returns The title of the window as a string.
*/
export const getTitle = (client: AstalHyprland.Client, useCustomTitle: boolean, useClassName: boolean): string => {
if (client === null) return getWindowMatch(client).label;
if (useCustomTitle) return getWindowMatch(client).label;
if (useClassName) return client.class;
const title = client.title;
// If the title is empty or only filled with spaces, fallback to the class name
if (title.length === 0 || title.match(/^ *$/)) {
return client.class;
}
return title;
};
/**
* Truncates the given title to a specified maximum size.
*
* This function shortens the provided title string to the specified maximum size.
* If the title exceeds the maximum size, it appends an ellipsis ('...') to the truncated title.
*
* @param title The title string to truncate.
* @param max_size The maximum size of the truncated title.
*
* @returns The truncated title as a string. If the title is within the maximum size, returns the original title.
*/
export const truncateTitle = (title: string, max_size: number): string => {
if (max_size > 0 && title.length > max_size) {
return title.substring(0, max_size).trim() + '...';
}
return title;
};

View File

@@ -0,0 +1,113 @@
import { runAsyncCommand, throttledScrollHandler } from 'src/components/bar/utils/helpers';
import { BarBoxChild } from 'src/lib/types/bar';
import options from 'src/options';
import { hyprlandService } from 'src/lib/constants/services';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { useHook } from 'src/lib/shared/hookHandler';
import { onMiddleClick, onPrimaryClick, onScroll, onSecondaryClick } from 'src/lib/shared/eventHandlers';
import { bind, Variable } from 'astal';
import { getTitle, getWindowMatch, truncateTitle } from './helpers/title';
import { Astal } from 'astal/gtk3';
const { leftClick, rightClick, middleClick, scrollDown, scrollUp } = options.bar.windowtitle;
const ClientTitle = (): BarBoxChild => {
const { custom_title, class_name, label, icon, truncation, truncation_size } = options.bar.windowtitle;
const componentClassName = Variable.derive(
[bind(options.theme.bar.buttons.style), bind(label)],
(style: string, showLabel: boolean) => {
const styleMap: Record<string, string> = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `windowtitle-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
},
);
const componentChildren = Variable.derive(
[
bind(hyprlandService, 'focusedClient'),
bind(custom_title),
bind(class_name),
bind(label),
bind(icon),
bind(truncation),
bind(truncation_size),
],
(
client: AstalHyprland.Client,
useCustomTitle: boolean,
useClassName: boolean,
showLabel: boolean,
showIcon: boolean,
truncate: boolean,
truncationSize: number,
) => {
const children: JSX.Element[] = [];
if (showIcon) {
children.push(
<label
className={'bar-button-icon windowtitle txt-icon bar'}
label={getWindowMatch(client).icon}
/>,
);
}
if (showLabel) {
children.push(
<label
className={`bar-button-label windowtitle ${showIcon ? '' : 'no-icon'}`}
label={truncateTitle(
getTitle(client, useCustomTitle, useClassName),
truncate ? truncationSize : -1,
)}
/>,
);
}
return children;
},
);
const component = <box className={componentClassName()}>{componentChildren()}</box>;
return {
component,
isVisible: true,
boxClass: 'windowtitle',
props: {
setup: (self: Astal.Button): void => {
useHook(self, options.bar.scrollSpeed, () => {
const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.get());
const disconnectPrimary = onPrimaryClick(self, (clicked, event) => {
runAsyncCommand(leftClick.get(), { clicked, event });
});
const disconnectSecondary = onSecondaryClick(self, (clicked, event) => {
runAsyncCommand(rightClick.get(), { clicked, event });
});
const disconnectMiddle = onMiddleClick(self, (clicked, event) => {
runAsyncCommand(middleClick.get(), { clicked, event });
});
const disconnectScroll = onScroll(self, throttledHandler, scrollUp.get(), scrollDown.get());
return (): void => {
disconnectPrimary();
disconnectSecondary();
disconnectMiddle();
disconnectScroll();
};
});
},
},
};
};
export { ClientTitle };

View File

@@ -0,0 +1,361 @@
import { exec, Variable } from 'astal';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { hyprlandService } from 'src/lib/constants/services';
import { MonitorMap, WorkspaceMap, WorkspaceRule } from 'src/lib/types/workspace';
import { range } from 'src/lib/utils';
import options from 'src/options';
const { workspaces, reverse_scroll, ignored } = options.bar.workspaces;
/**
* Retrieves the workspaces for a specific monitor.
*
* This function checks if a given workspace is valid for a specified monitor based on the workspace rules.
*
* @param curWs - The current workspace number.
* @param wsRules - The workspace rules map.
* @param monitor - The monitor ID.
* @param workspaceList - The list of workspaces.
* @param monitorList - The list of monitors.
*
* @returns Whether the workspace is valid for the monitor.
*/
export const getWorkspacesForMonitor = (
curWs: number,
wsRules: WorkspaceMap,
monitor: number,
workspaceList: AstalHyprland.Workspace[],
monitorList: AstalHyprland.Monitor[],
): boolean => {
if (!wsRules || !Object.keys(wsRules).length) {
return true;
}
const monitorMap: MonitorMap = {};
const workspaceMonitorList = workspaceList.map((m) => {
return { id: m.monitor.id, name: m.monitor.name };
});
const monitors = [...new Map([...workspaceMonitorList, ...monitorList].map((item) => [item.id, item])).values()];
monitors.forEach((mon) => (monitorMap[mon.id] = mon.name));
const currentMonitorName = monitorMap[monitor];
const monitorWSRules = wsRules[currentMonitorName];
if (monitorWSRules === undefined) {
return true;
}
return monitorWSRules.includes(curWs);
};
/**
* Retrieves the workspace rules.
*
* This function fetches and parses the workspace rules from the Hyprland service.
*
* @returns The workspace rules map.
*/
export const getWorkspaceRules = (): WorkspaceMap => {
try {
const rules = exec('hyprctl workspacerules -j');
const workspaceRules: WorkspaceMap = {};
JSON.parse(rules).forEach((rule: WorkspaceRule) => {
const workspaceNum = parseInt(rule.workspaceString, 10);
if (isNaN(workspaceNum)) {
return;
}
if (Object.hasOwnProperty.call(workspaceRules, rule.monitor)) {
workspaceRules[rule.monitor].push(workspaceNum);
} else {
workspaceRules[rule.monitor] = [workspaceNum];
}
});
return workspaceRules;
} catch (err) {
console.error(err);
return {};
}
};
/**
* Retrieves the current monitor's workspaces.
*
* This function returns a list of workspace numbers for the specified monitor.
*
* @param monitor - The monitor ID.
*
* @returns The list of workspace numbers.
*/
export const getCurrentMonitorWorkspaces = (monitor: number): number[] => {
if (hyprlandService.get_monitors().length === 1) {
return Array.from({ length: workspaces.get() }, (_, i) => i + 1);
}
const monitorWorkspaces = getWorkspaceRules();
const monitorMap: MonitorMap = {};
hyprlandService.get_monitors().forEach((m) => (monitorMap[m.id] = m.name));
const currentMonitorName = monitorMap[monitor];
return monitorWorkspaces[currentMonitorName];
};
/**
* Checks if a workspace is ignored.
*
* This function determines if a given workspace number is in the ignored workspaces list.
*
* @param ignoredWorkspaces - The ignored workspaces variable.
* @param workspaceNumber - The workspace number.
*
* @returns Whether the workspace is ignored.
*/
export const isWorkspaceIgnored = (ignoredWorkspaces: Variable<string>, workspaceNumber: number): boolean => {
if (ignoredWorkspaces.get() === '') return false;
const ignoredWsRegex = new RegExp(ignoredWorkspaces.get());
return ignoredWsRegex.test(workspaceNumber.toString());
};
/**
* Navigates to the next or previous workspace.
*
* This function changes the current workspace to the next or previous one, considering active and ignored workspaces.
*
* @param direction - The direction to navigate ('next' or 'prev').
* @param currentMonitorWorkspaces - The current monitor's workspaces variable.
* @param activeWorkspaces - Whether to consider only active workspaces.
* @param ignoredWorkspaces - The ignored workspaces variable.
*/
const navigateWorkspace = (
direction: 'next' | 'prev',
currentMonitorWorkspaces: Variable<number[]>,
activeWorkspaces: boolean,
ignoredWorkspaces: Variable<string>,
): void => {
const hyprlandWorkspaces = hyprlandService.get_workspaces() || [];
const occupiedWorkspaces = hyprlandWorkspaces
.filter((ws) => hyprlandService.focusedMonitor.id === ws.monitor?.id)
.map((ws) => ws.id);
const workspacesList = activeWorkspaces
? occupiedWorkspaces
: currentMonitorWorkspaces.get() || Array.from({ length: workspaces.get() }, (_, i) => i + 1);
if (workspacesList.length === 0) return;
const currentIndex = workspacesList.indexOf(hyprlandService.focusedWorkspace.id);
const step = direction === 'next' ? 1 : -1;
let newIndex = (currentIndex + step + workspacesList.length) % workspacesList.length;
let attempts = 0;
while (attempts < workspacesList.length) {
const targetWS = workspacesList[newIndex];
if (!isWorkspaceIgnored(ignoredWorkspaces, targetWS)) {
hyprlandService.message_async(`dispatch workspace ${targetWS}`);
return;
}
newIndex = (newIndex + step + workspacesList.length) % workspacesList.length;
attempts++;
}
};
/**
* Navigates to the next workspace.
*
* This function changes the current workspace to the next one.
*
* @param currentMonitorWorkspaces - The current monitor's workspaces variable.
* @param activeWorkspaces - Whether to consider only active workspaces.
* @param ignoredWorkspaces - The ignored workspaces variable.
*/
export const goToNextWS = (
currentMonitorWorkspaces: Variable<number[]>,
activeWorkspaces: boolean,
ignoredWorkspaces: Variable<string>,
): void => {
navigateWorkspace('next', currentMonitorWorkspaces, activeWorkspaces, ignoredWorkspaces);
};
/**
* Navigates to the previous workspace.
*
* This function changes the current workspace to the previous one.
*
* @param currentMonitorWorkspaces - The current monitor's workspaces variable.
* @param activeWorkspaces - Whether to consider only active workspaces.
* @param ignoredWorkspaces - The ignored workspaces variable.
*/
export const goToPrevWS = (
currentMonitorWorkspaces: Variable<number[]>,
activeWorkspaces: boolean,
ignoredWorkspaces: Variable<string>,
): void => {
navigateWorkspace('prev', currentMonitorWorkspaces, activeWorkspaces, ignoredWorkspaces);
};
/**
* Throttles a function to limit its execution rate.
*
* This function ensures that the provided function is not called more often than the specified limit.
*
* @param func - The function to throttle.
* @param limit - The time limit in milliseconds.
*
* @returns The throttled function.
*/
export function throttle<T extends (...args: unknown[]) => void>(func: T, limit: number): T {
let inThrottle: boolean;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
} as T;
}
/**
* Creates throttled scroll handlers for navigating workspaces.
*
* This function returns handlers for scrolling up and down through workspaces, throttled by the specified scroll speed.
*
* @param scrollSpeed - The scroll speed.
* @param currentMonitorWorkspaces - The current monitor's workspaces variable.
* @param activeWorkspaces - Whether to consider only active workspaces.
*
* @returns The throttled scroll handlers.
*/
export const createThrottledScrollHandlers = (
scrollSpeed: number,
currentMonitorWorkspaces: Variable<number[]>,
activeWorkspaces: boolean = true,
): ThrottledScrollHandlers => {
const throttledScrollUp = throttle(() => {
if (reverse_scroll.get()) {
goToPrevWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
} else {
goToNextWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
}
}, 200 / scrollSpeed);
const throttledScrollDown = throttle(() => {
if (reverse_scroll.get()) {
goToNextWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
} else {
goToPrevWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
}
}, 200 / scrollSpeed);
return { throttledScrollUp, throttledScrollDown };
};
/**
* Retrieves the workspaces to render.
*
* This function returns a list of workspace numbers to render based on the total workspaces, workspace list, rules, and monitor.
*
* @param totalWorkspaces - The total number of workspaces.
* @param workspaceList - The list of workspaces.
* @param workspaceRules - The workspace rules map.
* @param monitor - The monitor ID.
* @param isMonitorSpecific - Whether the workspaces are monitor-specific.
* @param monitorList - The list of monitors.
*
* @returns The list of workspace numbers to render.
*/
export const getWorkspacesToRender = (
totalWorkspaces: number,
workspaceList: AstalHyprland.Workspace[],
workspaceRules: WorkspaceMap,
monitor: number,
isMonitorSpecific: boolean,
monitorList: AstalHyprland.Monitor[],
): number[] => {
let allWorkspaces = range(totalWorkspaces || 8);
const activeWorkspaces = workspaceList.map((ws) => ws.id);
const workspaceMonitorList = workspaceList.map((ws) => {
return {
id: ws.monitor?.id || -1,
name: ws.monitor?.name || '',
};
});
const curMonitor =
monitorList.find((mon) => mon.id === monitor) || workspaceMonitorList.find((mon) => mon.id === monitor);
const workspacesWithRules = Object.keys(workspaceRules).reduce((acc: number[], k: string) => {
return [...acc, ...workspaceRules[k]];
}, []);
const activesForMonitor = activeWorkspaces.filter((w) => {
if (
curMonitor &&
Object.hasOwnProperty.call(workspaceRules, curMonitor.name) &&
workspacesWithRules.includes(w)
) {
return workspaceRules[curMonitor.name].includes(w);
}
return true;
});
if (isMonitorSpecific) {
const workspacesInRange = range(totalWorkspaces).filter((ws) => {
return getWorkspacesForMonitor(ws, workspaceRules, monitor, workspaceList, monitorList);
});
allWorkspaces = [...new Set([...activesForMonitor, ...workspacesInRange])];
} else {
allWorkspaces = [...new Set([...allWorkspaces, ...activeWorkspaces])];
}
return allWorkspaces.sort((a, b) => a - b);
};
/**
* The workspace rules variable.
* This variable holds the current workspace rules.
*/
export const workspaceRules = Variable(getWorkspaceRules());
/**
* The force updater variable.
* This variable is used to force updates when workspace events occur.
*/
export const forceUpdater = Variable(true);
/**
* Sets up connections for workspace events.
* This function sets up event listeners for various workspace-related events to update the workspace rules and force updates.
*/
export const setupConnections = (): void => {
hyprlandService.connect('config-reloaded', () => {
workspaceRules.set(getWorkspaceRules());
});
hyprlandService.connect('client-moved', () => {
forceUpdater.set(!forceUpdater.get());
});
hyprlandService.connect('client-added', () => {
forceUpdater.set(!forceUpdater.get());
});
hyprlandService.connect('client-removed', () => {
forceUpdater.set(!forceUpdater.get());
});
};
type ThrottledScrollHandlers = {
throttledScrollUp: () => void;
throttledScrollDown: () => void;
};

View File

@@ -0,0 +1,274 @@
import { hyprlandService } from 'src/lib/constants/services';
import { defaultApplicationIcons } from 'src/lib/constants/workspaces';
import { AppIconOptions, WorkspaceIconMap } from 'src/lib/types/workspace';
import { isValidGjsColor } from 'src/lib/utils';
import options from 'src/options';
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;
/**
* 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.
*/
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];
if (!iconEntry) {
return `${i}`;
}
const hasIcon = typeof iconEntry === 'object' && 'icon' in iconEntry && iconEntry.icon !== '';
if (typeof iconEntry === 'string' && iconEntry !== '') {
return iconEntry;
}
if (hasIcon) {
return iconEntry.icon;
}
return `${i}`;
};
/**
* 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) {
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 iconMap = { ...userDefinedIconMap, ...defaultApplicationIcons };
const clients = hyprlandService
.get_clients()
.filter((client) => client.workspace.id === workspaceIndex)
.map((client) => [client.class, client.title]);
if (!clients.length) {
return emptyIcon;
}
let icons = clients
.map(([clientClass, clientTitle]) => {
const maybeIcon = Object.entries(iconMap).find(([matcher]) => {
try {
if (matcher.startsWith('class:')) {
const re = matcher.substring(6);
return new RegExp(re).test(clientClass);
}
if (matcher.startsWith('title:')) {
const re = matcher.substring(6);
return new RegExp(re).test(clientTitle);
}
return new RegExp(matcher, 'i').test(clientClass);
} catch {
return false;
}
});
if (!maybeIcon) {
return undefined;
}
return maybeIcon.at(1);
})
.filter((x) => x);
if (removeDuplicateIcons) {
icons = [...new Set(icons)];
}
if (icons.length) {
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 => {
if (showIcons) {
return 'workspace-icon txt-icon bar';
}
if (showNumbered || showWsIcons) {
const numActiveInd =
hyprlandService.focusedWorkspace.id === i || isWorkspaceActiveOnMonitor(monitor, i)
? numberedActiveIndicator
: '';
const wsIconClass = showWsIcons ? 'txt-icon' : '';
const smartHighlightClass = smartHighlight ? 'smart-highlight' : '';
const className = `workspace-number can_${numberedActiveIndicator} ${numActiveInd} ${wsIconClass} ${smartHighlightClass}`;
return className.trim();
}
return 'default';
};
/**
* 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)?.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,53 @@
import options from 'src/options';
import { createThrottledScrollHandlers, getCurrentMonitorWorkspaces } from './helpers';
import { BarBoxChild, SelfButton } from 'src/lib/types/bar';
import { WorkspaceModule } from './workspaces';
import { bind, Variable } from 'astal';
import { GtkWidget } from 'src/lib/types/widget';
import { Gdk } from 'astal/gtk3';
const { workspaces, scroll_speed } = options.bar.workspaces;
const Workspaces = (monitor = -1): BarBoxChild => {
const currentMonitorWorkspaces = Variable(getCurrentMonitorWorkspaces(monitor));
workspaces.subscribe(() => {
currentMonitorWorkspaces.set(getCurrentMonitorWorkspaces(monitor));
});
const component = (
<box className={'workspaces-box-container'}>
<WorkspaceModule monitor={monitor} />
</box>
);
return {
component,
isVisible: true,
boxClass: 'workspaces',
isBox: true,
props: {
setup: (self: SelfButton): void => {
Variable.derive([bind(scroll_speed)], (scroll_speed) => {
const { throttledScrollUp, throttledScrollDown } = createThrottledScrollHandlers(
scroll_speed,
currentMonitorWorkspaces,
);
const scrollHandlers = self.connect('scroll-event', (_: GtkWidget, event: Gdk.Event) => {
const eventDirection = event.get_scroll_direction()[1];
if (eventDirection === Gdk.ScrollDirection.UP) {
throttledScrollUp();
} else if (eventDirection === Gdk.ScrollDirection.DOWN) {
throttledScrollDown();
}
});
self.disconnect(scrollHandlers);
});
},
},
};
};
export { Workspaces };

View File

@@ -0,0 +1,178 @@
import { hyprlandService } from 'src/lib/constants/services';
import options from 'src/options';
import { forceUpdater, getWorkspacesToRender, isWorkspaceIgnored, setupConnections, workspaceRules } from './helpers';
import { getAppIcon, getWsColor, renderClassnames, renderLabel } from './helpers/utils';
import { ApplicationIcons, WorkspaceIconMap } from 'src/lib/types/workspace';
import { bind, Variable } from 'astal';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { Gtk } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
const {
workspaces,
monitorSpecific,
workspaceMask,
spacing,
ignored,
showAllActive,
show_icons,
show_numbered,
numbered_active_indicator,
workspaceIconMap,
showWsIcons,
showApplicationIcons,
applicationIconOncePerWorkspace,
applicationIconMap,
applicationIconEmptyWorkspace,
applicationIconFallback,
} = options.bar.workspaces;
const { available, active, occupied } = options.bar.workspaces.icons;
const { matugen } = options.theme;
const { smartHighlight } = options.theme.bar.buttons.workspaces;
setupConnections();
export const WorkspaceModule = ({ monitor }: WorkspaceModuleProps): JSX.Element => {
const boxChildren = Variable.derive(
[
bind(monitorSpecific),
bind(hyprlandService, 'workspaces'),
bind(workspaceMask),
bind(workspaces),
bind(show_icons),
bind(available),
bind(active),
bind(occupied),
bind(show_numbered),
bind(numbered_active_indicator),
bind(spacing),
bind(workspaceIconMap),
bind(showWsIcons),
bind(showApplicationIcons),
bind(applicationIconOncePerWorkspace),
bind(applicationIconMap),
bind(applicationIconEmptyWorkspace),
bind(applicationIconFallback),
bind(matugen),
bind(smartHighlight),
bind(hyprlandService, 'monitors'),
bind(ignored),
bind(showAllActive),
bind(hyprlandService, 'focusedWorkspace'),
bind(workspaceRules),
bind(forceUpdater),
],
(
isMonitorSpecific: boolean,
workspaceList: AstalHyprland.Workspace[],
workspaceMaskFlag: boolean,
totalWorkspaces: number,
displayIcons: boolean,
availableStatus: string,
activeStatus: string,
occupiedStatus: string,
displayNumbered: boolean,
numberedActiveIndicator: string,
spacingValue: number,
workspaceIconMapping: WorkspaceIconMap,
displayWorkspaceIcons: boolean,
displayApplicationIcons: boolean,
appIconOncePerWorkspace: boolean,
applicationIconMapping: ApplicationIcons,
applicationIconEmptyWorkspace: string,
applicationIconFallback: string,
matugenEnabled: boolean,
smartHighlightEnabled: boolean,
monitorList: AstalHyprland.Monitor[],
) => {
const activeWorkspace = hyprlandService.focusedWorkspace.id;
const workspacesToRender = getWorkspacesToRender(
totalWorkspaces,
workspaceList,
workspaceRules.get(),
monitor,
isMonitorSpecific,
monitorList,
);
return workspacesToRender.map((wsId, index) => {
if (isWorkspaceIgnored(ignored, wsId)) {
return <box />;
}
const appIcons = displayApplicationIcons
? getAppIcon(wsId, appIconOncePerWorkspace, {
iconMap: applicationIconMapping,
defaultIcon: applicationIconFallback,
emptyIcon: applicationIconEmptyWorkspace,
})
: '';
return (
<button
className={'workspace-button'}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
hyprlandService.dispatch('workspace', wsId.toString());
}
}}
>
<label
valign={Gtk.Align.CENTER}
css={
`margin: 0rem ${0.375 * spacingValue}rem;` +
`${displayWorkspaceIcons && !matugenEnabled ? getWsColor(workspaceIconMapping, wsId, smartHighlightEnabled, monitor) : ''}`
}
className={renderClassnames(
displayIcons,
displayNumbered,
numberedActiveIndicator,
displayWorkspaceIcons,
smartHighlightEnabled,
monitor,
wsId,
)}
label={renderLabel(
displayIcons,
availableStatus,
activeStatus,
occupiedStatus,
displayApplicationIcons,
appIcons,
workspaceMaskFlag,
displayWorkspaceIcons,
workspaceIconMapping,
wsId,
index,
monitor,
)}
setup={(self) => {
self.toggleClassName('active', activeWorkspace === wsId);
self.toggleClassName(
'occupied',
(hyprlandService.get_workspace(wsId)?.get_clients().length || 0) > 0,
);
}}
/>
</button>
);
});
},
);
return (
<box
onDestroy={() => {
boxChildren.drop();
}}
>
{boxChildren()}
</box>
);
};
interface WorkspaceModuleProps {
monitor: number;
}